Compare commits
No commits in common. "main" and "v2026.07.01" have entirely different histories.
main
...
v2026.07.0
|
|
@ -42,9 +42,6 @@ shared/
|
|||
├── Table.tsx # 表格(styled thead,tbody 由调用方提供)
|
||||
├── Toast.tsx # 通知条(右下角固定定位)
|
||||
├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页")
|
||||
├── hooks/ # React Hooks
|
||||
│ ├── useDirtyTracker.ts # 脏状态追踪("修改后才显示保存按钮"模式)
|
||||
│ └── index.ts # Barrel 导出
|
||||
└── login-page.tsx # 统一登录页(SSO + LDAP 切换,不在 barrel 中导出)
|
||||
```
|
||||
|
||||
|
|
@ -353,7 +350,6 @@ import LoginPage from '@shared/ui/login-page' // 单独导入,不在 barrel
|
|||
| Table | `headers: string[]`, `children: ReactNode` | 样式化 thead,tbody 由调用方提供 |
|
||||
| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 |
|
||||
| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签,totalPages <= 1 时隐藏 |
|
||||
| useDirtyTracker | `isDirty(key, item): boolean`, `markClean(key, item): void`, `removeItem(key): void` | 脏状态追踪 Hook,"修改后才显示保存按钮"模式 |
|
||||
| LoginPage | `siteName: string`, `title?: string` | 完整登录页,支持 SSO 和 LDAP 切换 |
|
||||
|
||||
---
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -1,70 +0,0 @@
|
|||
# shared — TLYQ 共享标准库
|
||||
|
||||
tlyq.ai 各站点共享的标准库,消除代码重复,统一基础设施实现。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
shared/
|
||||
├── lib/
|
||||
│ ├── auth/ # 认证标准库
|
||||
│ │ ├── config.ts # config 工厂(createAuthConfig)
|
||||
│ │ ├── jwt.ts # JWT 签发/验证
|
||||
│ │ ├── ldap.ts # LDAP 认证
|
||||
│ │ ├── middleware.ts # middleware 工厂(createMiddleware)
|
||||
│ │ ├── oidc.ts # OIDC 客户端
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── user-sync.ts # OIDC 用户同步
|
||||
│ ├── audit/ # 审计日志标准库
|
||||
│ │ ├── audit-schema.ts # 表结构常量
|
||||
│ │ └── write-audit-log.ts # writeAuditLog + diffObjects + getClientIP
|
||||
│ ├── wechat/ # 企业微信推送
|
||||
│ │ ├── wechat-pusher.ts # WeChatPusher 类
|
||||
│ │ └── message-formatter.ts # 消息格式化工具
|
||||
│ ├── alert/ # 告警管理
|
||||
│ │ ├── alert-manager.ts # AlertManager(去重/冷却/免打扰)
|
||||
│ │ ├── health-checker.ts # HealthChecker(HTTP/Docker)
|
||||
│ │ └── types.ts # 类型定义
|
||||
│ └── db/ # 数据库 schema
|
||||
│ └── alert-schema.ts # CREATE TABLE 常量
|
||||
└── ui/ # 共享 UI 组件
|
||||
└── login-page.tsx # 统一登录页
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 创建 symlink
|
||||
|
||||
```bash
|
||||
ln -sf ../shared {站点目录}/shared
|
||||
```
|
||||
|
||||
### 2. 配置 tsconfig
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 引用
|
||||
|
||||
```typescript
|
||||
import { createAuthConfig } from '@shared/lib/auth/config'
|
||||
import { createMiddleware } from '@shared/lib/auth/middleware'
|
||||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 零业务依赖:纯基础设施,不含站点特有逻辑
|
||||
- 配置驱动:所有环境变量通过参数传入,不硬编码
|
||||
- 接口稳定:包装器保持各站点原有调用方式不变
|
||||
- Edge Runtime 兼容:middleware 仅用 atob,不用 Node.js crypto
|
||||
|
|
@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
username TEXT,
|
||||
api_key_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
// shared/lib/auth/handle-callback.ts — OIDC callback 工厂(步骤 1a 新增)
|
||||
// 统一 OIDC 回调流程:token 交换 → userinfo 获取 → 用户同步 → JWT 签发 → cookie 设置 → 审计
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { signJwtV2 } from './jwt-v2'
|
||||
import type { OidcCallbackUserInfo } from './types-v2'
|
||||
|
||||
interface OidcConfig {
|
||||
autheliaUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export interface HandleOidcCallbackConfig {
|
||||
oidc: OidcConfig
|
||||
jwtSecret: string
|
||||
cookieDomain: string
|
||||
/** 从本地 DB 查找已存在的用户 */
|
||||
getUser: (username: string) => OidcCallbackUserInfo | null | Promise<OidcCallbackUserInfo | null>
|
||||
/** 创建新用户(首次登录),返回含 role 的用户信息 */
|
||||
createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise<OidcCallbackUserInfo>
|
||||
/** 更新用户信息(displayName、email 变更时) */
|
||||
updateUser?: (username: string, displayName: string, email: string) => void | Promise<void>
|
||||
/** 审计日志回调 */
|
||||
onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** 从 URL 解析 baseUrl(redirectUri 去除路径部分) */
|
||||
function baseUrlFromRedirectUri(redirectUri: string): string {
|
||||
try {
|
||||
const url = new URL(redirectUri)
|
||||
return `${url.protocol}//${url.host}`
|
||||
} catch {
|
||||
return redirectUri.split('/api/')[0]
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建错误响应:redirect 到 /login?error= 并清除 OIDC 临时 cookie(try/finally 确保) */
|
||||
function loginError(baseUrl: string, request: NextRequest): NextResponse {
|
||||
const response = NextResponse.redirect(new URL(`/login?error=login_failed`, baseUrl))
|
||||
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
|
||||
if (request.cookies.get(name)) response.cookies.delete(name)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** OIDC 回调处理。成功返回 200 JSON + Set-Cookie,失败返回 redirect 到 /login?error= */
|
||||
export async function handleOidcCallback(
|
||||
request: NextRequest,
|
||||
config: HandleOidcCallbackConfig
|
||||
): Promise<NextResponse> {
|
||||
const baseUrl = baseUrlFromRedirectUri(config.oidc.redirectUri)
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
console.error(`OIDC error: ${error}`)
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
// 1. 验证 state
|
||||
const savedState = request.cookies.get('oidc_state')?.value
|
||||
if (state !== savedState) {
|
||||
console.error('OIDC state mismatch')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
// 2. 获取 code_verifier
|
||||
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
|
||||
if (!codeVerifier) {
|
||||
console.error('OIDC code_verifier missing')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
// 3. 交换 token
|
||||
// client_secret_post:secret 放入 POST body(Authelia 不支持 client_secret_basic)
|
||||
const tokenRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.oidc.redirectUri,
|
||||
client_id: config.oidc.clientId,
|
||||
client_secret: config.oidc.clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
console.error(`Token exchange failed: ${tokenRes.status}`)
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
const accessToken = tokenData.access_token as string
|
||||
const idToken = tokenData.id_token as string | undefined
|
||||
|
||||
// 4. 验证 id_token nonce(严格三阶段:生成→id_token 解码→比对,任一缺失/不匹配→拒绝)
|
||||
if (idToken) {
|
||||
const idParts = idToken.split('.')
|
||||
if (idParts.length !== 3) {
|
||||
console.error('OIDC id_token malformed')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
try {
|
||||
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
|
||||
const savedNonce = request.cookies.get('oidc_nonce')?.value
|
||||
if (!savedNonce) {
|
||||
console.error('OIDC nonce missing in cookie')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
if (!idPayload.nonce) {
|
||||
console.error('OIDC nonce missing in id_token')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
if (idPayload.nonce !== savedNonce) {
|
||||
console.error('OIDC nonce mismatch')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
// 验证 aud(设计方案 §3.3,裸 fetch 替代 openid-client 需手动校验)
|
||||
if (!idPayload.aud) {
|
||||
console.error('OIDC aud missing in id_token')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
const audList = Array.isArray(idPayload.aud) ? idPayload.aud : [idPayload.aud]
|
||||
if (!audList.includes(config.oidc.clientId)) {
|
||||
console.error('OIDC aud mismatch')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
} catch {
|
||||
console.error('OIDC id_token parse failed')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取 userinfo
|
||||
const userinfoRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!userinfoRes.ok) {
|
||||
console.error(`Userinfo fetch failed: ${userinfoRes.status}`)
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
const userinfo = await userinfoRes.json()
|
||||
const username = userinfo.preferred_username as string
|
||||
const displayName = (userinfo.name as string) || username
|
||||
const email = (userinfo.email as string) || ''
|
||||
|
||||
// 6. sub 一致性检查(id_token sub 必须与 userinfo sub 一致)
|
||||
if (idToken && userinfo.sub) {
|
||||
try {
|
||||
const idParts = idToken.split('.')
|
||||
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
|
||||
if (idPayload.sub && idPayload.sub !== userinfo.sub) {
|
||||
console.error('OIDC sub mismatch')
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
} catch { /* sub check failure is non-fatal if id_token is unavailable */ }
|
||||
}
|
||||
|
||||
// 7. 用户同步(查找或创建)
|
||||
let user: OidcCallbackUserInfo | null = await Promise.resolve(config.getUser(username))
|
||||
const isNew = !user
|
||||
|
||||
if (!user && config.createUser) {
|
||||
user = await Promise.resolve(config.createUser(username, displayName, email))
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error(`User ${username} not found and createUser not provided`)
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
|
||||
// 8. 更新用户信息(如果 displayName 或 email 变更)
|
||||
if (!isNew && config.updateUser) {
|
||||
try {
|
||||
await Promise.resolve(config.updateUser(username, displayName, email))
|
||||
} catch (e) {
|
||||
console.error('updateUser failed:', e)
|
||||
// 不阻塞登录
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 签发 JWT(不含 role)
|
||||
const token = signJwtV2({
|
||||
secret: config.jwtSecret,
|
||||
payload: { username, displayName },
|
||||
iss: config.oidc.autheliaUrl,
|
||||
})
|
||||
|
||||
// 10. 签发 cookie + 302 跳转首页(OIDC 回调是浏览器导航,必须 redirect 而非 JSON)
|
||||
const response = NextResponse.redirect(new URL('/', baseUrl))
|
||||
|
||||
response.cookies.set('tlyq_session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: config.cookieDomain,
|
||||
path: '/',
|
||||
maxAge: 604800,
|
||||
})
|
||||
|
||||
// 11. 清理 OIDC 临时 cookie
|
||||
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
|
||||
response.cookies.delete(name)
|
||||
}
|
||||
|
||||
// 12. 审计日志
|
||||
if (config.onAuditLog) {
|
||||
try {
|
||||
await Promise.resolve(config.onAuditLog(user.id, username, request))
|
||||
} catch (e) {
|
||||
console.error('onAuditLog failed:', e)
|
||||
// 不阻塞登录
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (e) {
|
||||
console.error('handleOidcCallback error:', e)
|
||||
return loginError(baseUrl, request)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
// shared/lib/auth/handle-login.ts — OIDC 登录工厂(步骤 1a 新增)
|
||||
// 生成 PKCE + state + nonce → 构建 Authelia 授权 URL → 设置临时 cookie → 302 跳转
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generatePkce, generateState, buildAuthorizeUrl, type OidcClientConfig } from './oidc'
|
||||
|
||||
interface HandleOidcLoginConfig {
|
||||
autheliaUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
/** 强制重新认证("使用其他账号登录",对应 OIDC prompt=login) */
|
||||
switchUser?: boolean
|
||||
}
|
||||
|
||||
const cookieOpts = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 300, // 5 分钟
|
||||
} as const
|
||||
|
||||
/** OIDC 登录入口。生成 PKCE + nonce → 设置临时 cookie → 重定向到 Authelia */
|
||||
export async function handleOidcLogin(config: HandleOidcLoginConfig): Promise<NextResponse> {
|
||||
// 切换账号:通过 Authelia /logout 页面清除 authelia_session
|
||||
// Authelia 4.38 不支持 /api/oidc/end_session(404),且 prompt=login&max_age=0
|
||||
// 均被忽略(仍然跳转同意页面)。唯一可靠方式:先到 Authelia /logout 清除 session,
|
||||
// 用户确认后再回跳本页重新发起 OIDC 授权(此时无 session → Authelia 显示登录页)
|
||||
if (config.switchUser) {
|
||||
// 注意:rd 参数必须用不带 ?switch=1 的 login/oidc URL,否则无限循环
|
||||
const returnUrl = `${config.redirectUri.replace('/api/auth/callback', '')}/api/auth/login/oidc`
|
||||
const logoutUrl = `${config.autheliaUrl}/logout?rd=${encodeURIComponent(returnUrl)}`
|
||||
const response = NextResponse.redirect(logoutUrl)
|
||||
response.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
response.cookies.set('session', '', { maxAge: 0, path: '/' })
|
||||
return response
|
||||
}
|
||||
|
||||
const { codeVerifier, codeChallenge } = generatePkce()
|
||||
const state = generateState()
|
||||
const nonce = generateState()
|
||||
|
||||
const oidcConfig: OidcClientConfig = {
|
||||
autheliaUrl: config.autheliaUrl,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
redirectUri: config.redirectUri,
|
||||
}
|
||||
|
||||
const authorizeUrl = buildAuthorizeUrl(oidcConfig, {
|
||||
codeChallenge,
|
||||
state,
|
||||
nonce,
|
||||
})
|
||||
|
||||
const response = NextResponse.redirect(authorizeUrl)
|
||||
|
||||
// OIDC 临时 cookie(5 分钟生命周期)
|
||||
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
|
||||
response.cookies.set('oidc_state', state, cookieOpts)
|
||||
response.cookies.set('oidc_nonce', nonce, cookieOpts)
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// shared/lib/auth/handle-logout.ts — 登出工厂(步骤 1a 新增)
|
||||
// 清除 tlyq_session cookie → 返回 200 JSON
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
interface HandleOidcLogoutConfig {
|
||||
cookieDomain: string
|
||||
}
|
||||
|
||||
/** 登出:清除 tlyq_session 和 OIDC 临时 cookie → 返回 200 JSON */
|
||||
export function handleOidcLogout(_req: NextRequest, config: HandleOidcLogoutConfig): NextResponse {
|
||||
const response = NextResponse.json({ success: true })
|
||||
|
||||
// 清除 tlyq_session
|
||||
response.cookies.set('tlyq_session', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: config.cookieDomain,
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
})
|
||||
|
||||
// 清除 OIDC 临时 cookie
|
||||
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
|
||||
response.cookies.set(name, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
// shared/lib/auth/jwt-edge.ts — V2 Edge Runtime JWT 验证(步骤 1a 新增)
|
||||
// 使用 Web Crypto API(Edge Runtime 兼容),3 参数强制校验 iss 匹配
|
||||
import type { SessionPayloadV2 } from './types-v2'
|
||||
|
||||
/** Base64url 解码为 Uint8Array */
|
||||
function base64urlToBytes(str: string): Uint8Array {
|
||||
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** 将 ArrayBuffer 转为 hex 字符串 */
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** 验证 JWT(Edge Runtime),3 参数。expectedIss = '*' 时跳过 iss 校验(迁移模式) */
|
||||
export async function verifyJwtEdge(
|
||||
token: string,
|
||||
secret: string,
|
||||
expectedIss: string
|
||||
): Promise<SessionPayloadV2 | null> {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
// 解码 payload
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as SessionPayloadV2
|
||||
|
||||
// 过期检查
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) return null
|
||||
|
||||
// iss 校验(* 为迁移模式,跳过校验,兼容旧 token 不含 iss)
|
||||
if (expectedIss !== '*' && payload.iss !== expectedIss) return null
|
||||
|
||||
// 验签(HMAC-SHA256)
|
||||
const encoder = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
)
|
||||
|
||||
const data = encoder.encode(`${parts[0]}.${parts[1]}`)
|
||||
const signature = base64urlToBytes(parts[2])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const valid = await crypto.subtle.verify('HMAC', key, signature as any, data)
|
||||
return valid ? payload : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// shared/lib/auth/jwt-v2.ts — V2 JWT 签发/验证(步骤 1a 新增,旧 jwt.ts 不动)
|
||||
// 与 V1 的关键差异:signJwtV2 强制注入 iss,verifyJwtV2 强制校验 iss 匹配
|
||||
import crypto from 'crypto'
|
||||
import type { SessionPayloadV2 } from './types-v2'
|
||||
|
||||
interface SignJwtV2Options {
|
||||
secret: string
|
||||
payload: { username: string; displayName?: string }
|
||||
/** 签发者标识:OA LDAP 用 "oa.tlyq.ai",OIDC 用 autheliaUrl */
|
||||
iss: string
|
||||
expiresInSeconds?: number
|
||||
}
|
||||
|
||||
/** 签发 JWT(V2),强制注入 iss */
|
||||
export function signJwtV2({ secret, payload, iss, expiresInSeconds = 604800 }: SignJwtV2Options): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body = { ...payload, iss, iat: now, exp: now + expiresInSeconds }
|
||||
|
||||
const encoded = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url')
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${encoded(header)}.${encoded(body)}`)
|
||||
.digest('base64url')
|
||||
|
||||
return `${encoded(header)}.${encoded(body)}.${signature}`
|
||||
}
|
||||
|
||||
/** 验证 JWT(V2),3 参数强制校验 iss 匹配 */
|
||||
export function verifyJwtV2(token: string, secret: string, expectedIss: string): SessionPayloadV2 | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) return null
|
||||
if (payload.iss !== expectedIss) return null
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${parts[0]}.${parts[1]}`)
|
||||
.digest('base64url')
|
||||
|
||||
return signature === parts[2] ? payload : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 token 解码提取 iss(不验签,用于 Layer 2/3 自引用校验:先提取 iss → 再 verifyJwtV2) */
|
||||
export function extractIss(token: string): string | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
|
||||
return payload.iss || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
// shared/lib/auth/middleware-v2.ts — V2 middleware 工厂(步骤 1a 新增,旧 middleware.ts 不动)
|
||||
// 与 V1 的关键差异:单 cookie 模型、Edge 验签+iss 校验、无 admin 路径检查(鉴权下沉到 layout/API)
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { verifyJwtEdge } from './jwt-edge'
|
||||
import type { MiddlewareV2Config } from './types-v2'
|
||||
|
||||
// Edge Runtime 兼容的 JWT payload 解码(不验签)
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (payload.length % 4) payload += '='
|
||||
return JSON.parse(atob(payload))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||
if (!payload) return false
|
||||
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||
}
|
||||
|
||||
function noCache(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
return response
|
||||
}
|
||||
|
||||
/** 创建 V2 middleware(单 cookie 模型,Edge 验签) */
|
||||
export function createMiddlewareV2(cfg: MiddlewareV2Config) {
|
||||
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
|
||||
const allowedIssuers = cfg.allowedIssuers
|
||||
const enableApiKey = cfg.enableApiKey || false
|
||||
|
||||
return async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 1. 公开路径放行
|
||||
if (publicPaths.some(p => pathname.startsWith(p))) {
|
||||
if (pathname === '/login') {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
const payload = token ? decodeJwtPayload(token) : null
|
||||
if (isValidPayload(payload)) {
|
||||
return NextResponse.redirect(new URL('/', request.nextUrl.origin))
|
||||
}
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 2. API Key 认证(可选,迁移期间保留)
|
||||
if (enableApiKey) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader?.startsWith('Bearer ak_')) {
|
||||
const key = authHeader.slice(7)
|
||||
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||||
if (allowedKeys && allowedKeys.split(',').map(k => k.trim()).includes(key)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: '无效的 API Key' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 验证 tlyq_session
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
}
|
||||
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
|
||||
}
|
||||
|
||||
// 4. 提取 iss → 验签(Edge Runtime)
|
||||
const payload = decodeJwtPayload(token)
|
||||
if (!payload || !isValidPayload(payload)) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
|
||||
}
|
||||
const loginUrl = new URL('/login', request.nextUrl.origin)
|
||||
const response = NextResponse.redirect(loginUrl)
|
||||
response.cookies.delete('tlyq_session')
|
||||
return response
|
||||
}
|
||||
|
||||
const iss = payload.iss as string | undefined
|
||||
|
||||
// allowedIssuers 白名单校验("*" 跳过,全量迁移后改为具体值)
|
||||
if (allowedIssuers.length === 1 && allowedIssuers[0] === '*') {
|
||||
// 迁移模式:跳过白名单校验,仅验签名+过期
|
||||
} else if (!iss || !allowedIssuers.includes(iss)) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: '无效的签发来源' }, { status: 401 })
|
||||
}
|
||||
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
|
||||
}
|
||||
|
||||
// 5. 验签(使用提取出的 iss 进行自引用校验)
|
||||
// 迁移模式(allowedIssuers = ["*"]):跳过 iss 校验,仅验签名+过期
|
||||
// 全量迁移后(allowedIssuers 为具体白名单):强制 iss 校验
|
||||
const jwtSecret = cfg.jwtSecret
|
||||
const isMigrationMode = allowedIssuers.length === 1 && allowedIssuers[0] === '*'
|
||||
const expectedIss = isMigrationMode ? (iss || '*') : (iss || '')
|
||||
const verified = await verifyJwtEdge(token, jwtSecret, expectedIss)
|
||||
|
||||
if (!verified) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
|
||||
}
|
||||
const loginUrl = new URL('/login', request.nextUrl.origin)
|
||||
const response = NextResponse.redirect(loginUrl)
|
||||
response.cookies.delete('tlyq_session')
|
||||
return response
|
||||
}
|
||||
|
||||
// 6. 放行(设置 session cookie 供客户端展示用,httpOnly:false,仅含 username)
|
||||
const response = noCache(NextResponse.next())
|
||||
response.cookies.set('session', JSON.stringify({ username: verified.username }), {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
@ -96,46 +96,63 @@ export function createMiddleware(cfg: MiddlewareConfig = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// 优先验证本地 cookie(含 role,由本站 OIDC callback 签发)
|
||||
// 回退到 tlyq_session(共享 JWT,不含 role,仅证明身份)
|
||||
// 验证 tlyq_session(共享 JWT)
|
||||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||||
const localToken = localCookieName ? request.cookies.get(localCookieName)?.value : undefined
|
||||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||||
|
||||
// 1. 本地 cookie 优先(含 role,可做 admin 检查)
|
||||
if (isValidPayload(localPayload)) {
|
||||
if (isValidPayload(sharedPayload)) {
|
||||
// 管理路径检查 admin 权限
|
||||
if (adminPaths.some(p => pathname.startsWith(p))) {
|
||||
const role = localPayload!.role as string
|
||||
const role = sharedPayload?.role as string
|
||||
if (role !== adminRole && role !== 'admin') {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
}
|
||||
if (pathname.startsWith('/api/')) return NextResponse.next()
|
||||
const response = noCache(NextResponse.next())
|
||||
response.cookies.set('session', JSON.stringify({ username: localPayload!.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
|
||||
// 设置 session cookie(兼容旧代码)
|
||||
if (sharedPayload) {
|
||||
const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next())
|
||||
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// 清除无效的本地 cookie
|
||||
if (localToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
const response = NextResponse.redirect(loginUrl)
|
||||
response.cookies.delete(localCookieName!)
|
||||
if (sharedToken) response.cookies.delete('tlyq_session')
|
||||
return response
|
||||
}
|
||||
// 回退本地 cookie(可选)
|
||||
if (localCookieName) {
|
||||
const localToken = request.cookies.get(localCookieName)?.value
|
||||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||||
|
||||
// 2. 回退到 tlyq_session(共享 JWT,不含 role,只验证身份)
|
||||
if (isValidPayload(sharedPayload)) {
|
||||
// 注意:tlyq_session 不含 role,admin 检查在各站点 callback 中通过查本地 DB 完成
|
||||
const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next())
|
||||
response.cookies.set('session', JSON.stringify({ username: sharedPayload!.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
if (isValidPayload(localPayload)) {
|
||||
// 管理路径检查 admin 权限
|
||||
if (adminPaths.some(p => pathname.startsWith(p))) {
|
||||
const role = localPayload?.role as string
|
||||
if (role !== adminRole && role !== 'admin') {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
if (localPayload) {
|
||||
const response = noCache(NextResponse.next())
|
||||
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// 清除无效的本地 cookie
|
||||
if (localToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
const response = NextResponse.redirect(loginUrl)
|
||||
response.cookies.delete(localCookieName)
|
||||
if (sharedToken) response.cookies.delete('tlyq_session')
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// 未认证:API 返回 401,页面重定向登录
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export function buildAuthorizeUrl(config: OidcClientConfig, params: {
|
|||
state: string
|
||||
nonce: string
|
||||
prompt?: string
|
||||
maxAge?: number
|
||||
}): string {
|
||||
const { autheliaUrl, clientId, redirectUri } = config
|
||||
const url = new URL(`${autheliaUrl}/api/oidc/authorization`)
|
||||
|
|
@ -49,7 +48,6 @@ export function buildAuthorizeUrl(config: OidcClientConfig, params: {
|
|||
url.searchParams.set('code_challenge', params.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', 'S256')
|
||||
if (params.prompt) url.searchParams.set('prompt', params.prompt)
|
||||
if (params.maxAge !== undefined) url.searchParams.set('max_age', String(params.maxAge))
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
// shared/lib/auth/types-v2.ts — V2 类型定义(步骤 1a 新增,旧 types.ts 不动)
|
||||
// 与 V1 的关键差异:SessionPayloadV2 含 iss(不含 role),认证与鉴权分离
|
||||
|
||||
export interface SessionPayloadV2 {
|
||||
username: string
|
||||
displayName?: string
|
||||
/** JWT 签发者标识:OA LDAP 用 "oa.tlyq.ai",OIDC 用 autheliaUrl */
|
||||
iss: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
/** handleOidcCallback 中 getUser/createUser 返回的用户信息(role 不写入 JWT) */
|
||||
export interface OidcCallbackUserInfo {
|
||||
id: number
|
||||
role: string
|
||||
}
|
||||
|
||||
/** createMiddlewareV2 的配置 */
|
||||
export interface MiddlewareV2Config {
|
||||
jwtSecret: string
|
||||
cookieDomain: string
|
||||
/** 允许的 iss 值白名单。迁移期间可设为 ["*"](跳过白名单校验,仅验签名+过期),全量迁移后改为具体值 */
|
||||
allowedIssuers: string[]
|
||||
publicPaths?: string[]
|
||||
enableApiKey?: boolean
|
||||
/** 管理员角色名,默认 'admin' */
|
||||
adminRole?: string
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export class WeChatPusher {
|
|||
const body = await res.text().catch(() => null)
|
||||
return { success: res.ok, responseCode: res.status, responseBody: body }
|
||||
} catch (err) {
|
||||
return { success: false, responseCode: null, responseBody: null, error: 'Network request failed' }
|
||||
return { success: false, responseCode: null, responseBody: null, error: String(err) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { useDirtyTracker } from './useDirtyTracker'
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
'use client'
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 脏状态追踪 Hook — 用于"修改后才显示保存按钮"模式。
|
||||
*
|
||||
* @example 数组场景(每个子项独立保存)
|
||||
* const { isDirty, markClean, removeItem } = useDirtyTracker()
|
||||
* {items.map(item => (
|
||||
* <div key={item.id}>
|
||||
* <input onChange={...} />
|
||||
* {isDirty(item.id, item) && <button onClick={save}>保存</button>}
|
||||
* </div>
|
||||
* ))}
|
||||
* // 保存成功后: markClean(id, savedItem)
|
||||
* // 删除时: removeItem(id)
|
||||
*/
|
||||
export function useDirtyTracker() {
|
||||
const savedMap = useRef<Map<string, string>>(new Map())
|
||||
|
||||
/**
|
||||
* 判断指定 key 的当前数据是否相对于上次 markClean 有修改。
|
||||
* 从未 markClean 过的 key 视为 dirty(适用于新增项)。
|
||||
*/
|
||||
const isDirty = (key: string, current: unknown): boolean => {
|
||||
const saved = savedMap.current.get(key)
|
||||
if (saved === undefined) return true // 从未保存过 → dirty
|
||||
return saved !== JSON.stringify(current)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定 key 的当前数据标记为"已保存"(重置 dirty 状态)。
|
||||
*/
|
||||
const markClean = (key: string, item: unknown): void => {
|
||||
savedMap.current.set(key, JSON.stringify(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定 key 的追踪记录(用于列表项被删除时清理)。
|
||||
*/
|
||||
const removeItem = (key: string): void => {
|
||||
savedMap.current.delete(key)
|
||||
}
|
||||
|
||||
return { isDirty, markClean, removeItem }
|
||||
}
|
||||
|
|
@ -1,26 +1,18 @@
|
|||
'use client'
|
||||
// shared/ui/login-page.tsx — 统一登录页组件
|
||||
// 支持 SSO、LDAP 和 localadmin(通过 ?method=local URL 参数激活)三种登录方式
|
||||
import { useState, useEffect } from 'react'
|
||||
// shared/ui/login-page.tsx — 统一登录页组件(与 issue-ai 登录页风格一致)
|
||||
import { useState } from 'react'
|
||||
|
||||
interface LoginPageProps {
|
||||
siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心"
|
||||
title?: string // 副标题(可选),如 "monitor-ai"
|
||||
redirectPath?: string // 登录后跳转路径,默认 "/"
|
||||
}
|
||||
|
||||
export default function LoginPage({ siteName, title, redirectPath = '/' }: LoginPageProps) {
|
||||
export default function LoginPage({ siteName, title }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
||||
const [showLocalAdmin, setShowLocalAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('method') === 'local') setShowLocalAdmin(true)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setError(''); setLoading(true)
|
||||
|
|
@ -34,7 +26,7 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
|||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get('redirect')
|
||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : redirectPath
|
||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/'
|
||||
window.location.href = dest
|
||||
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||
}
|
||||
|
|
@ -54,7 +46,7 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
|||
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{!showLdapForm && !showLocalAdmin ? (
|
||||
{!showLdapForm ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSsoLogin}
|
||||
|
|
@ -63,14 +55,6 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
|||
统一认证登录
|
||||
</button>
|
||||
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
||||
<p className="text-center mb-2">
|
||||
<button
|
||||
onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
|
||||
>
|
||||
使用其他账号登录
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-center">
|
||||
<button
|
||||
onClick={() => setShowLdapForm(true)}
|
||||
|
|
@ -80,43 +64,6 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
|||
</button>
|
||||
</p>
|
||||
</>
|
||||
) : showLocalAdmin ? (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||
<input
|
||||
type="text" value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||
<input
|
||||
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200"
|
||||
>
|
||||
{loading ? '登录中...' : '本地管理员登录'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center mt-3">
|
||||
<button
|
||||
onClick={() => { window.location.href = '/login' }}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
|
||||
>
|
||||
返回普通登录
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue