From 9986d99ab3e7a5502b14769bb5490a53db97a2d4 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Wed, 1 Jul 2026 18:42:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P1=20=E5=85=B1=E4=BA=AB=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit.ts: 引用 shared/lib/audit(包装器保持原接口) - jwt.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt) - middleware.ts: 使用 shared/lib/auth/middleware 工厂 - tsconfig: 添加 @shared/* 路径 - 添加 shared symlink 独立审查通过 --- .gitignore | 1 + shared | 1 + src/lib/audit.ts | 94 +++++++--------------------------------- src/lib/jwt.ts | 55 ++++++++++-------------- src/middleware.ts | 107 +++------------------------------------------- tsconfig.json | 3 +- 6 files changed, 49 insertions(+), 212 deletions(-) create mode 120000 shared diff --git a/.gitignore b/.gitignore index 03f4645..fdf3912 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ db-backups/ .playwright-mcp/ *.tsbuildinfo .env.local +.DS_Store diff --git a/shared b/shared new file mode 120000 index 0000000..8fba6b6 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +../shared \ No newline at end of file diff --git a/src/lib/audit.ts b/src/lib/audit.ts index e7907c6..66cd04f 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -1,85 +1,23 @@ +// assets-ai/src/lib/audit.ts — 站点审计包装器(引用共享库) import db from './db' +import { writeAuditLog as sharedWriteAuditLog, diffObjects, getClientIP } from '@shared/lib/audit/write-audit-log' +import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log' -interface AuditLogOptions { - userId?: number | null - apiKeyId?: number | null - action: string - entityType: string - entityId?: number | null - details?: Record | null - ipAddress?: string | null -} - -export function writeAuditLog(opts: AuditLogOptions): void { - const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts - - try { - // 每日清理(每天首次写入触发) - // 注意:禁止使用 toISOString(),会返回 UTC 时间导致时区偏移 - const now = new Date() - const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` - - const lastCleanup = db.prepare( - "SELECT value FROM settings WHERE key = 'audit_cleanup_date'" - ).get() as { value: string } | undefined - - if (!lastCleanup || lastCleanup.value !== today) { - // 从 settings 读取保留天数,默认 180 天 - const retentionRow = db.prepare( - "SELECT value FROM settings WHERE key = 'audit_retention_days'" - ).get() as { value: string } | undefined - const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10))) - - db.prepare( - `DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')` - ).run() - db.prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)" - ).run(today) +// 创建 assets-ai 的 store 适配器 +const store: AuditStore = { + exec: (sql: string) => { + try { + db.exec(sql) + } catch (e) { + console.error('审计日志写入失败:', e) } - - // 写入审计日志(显式设置 created_at 为北京时间) - db.prepare(` - INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours')) - `).run( - userId ?? null, - apiKeyId ?? null, - action, - entityType, - entityId ?? null, - details ? JSON.stringify(details) : null, - ipAddress ?? null - ) - } catch (e) { - // 审计写入失败不阻断主操作 - console.error('审计日志写入失败:', e) - } + }, } -export function diffObjects( - before: Record, - after: Record -): Record { - const changes: Record = {} - const keys = new Set([...Object.keys(before), ...Object.keys(after)]) - - for (const key of keys) { - // 跳过系统字段 - if (['created_at', 'updated_at'].includes(key)) continue - - const from = before[key] - const to = after[key] - if (JSON.stringify(from) !== JSON.stringify(to)) { - changes[key] = { from, to } - } - } - - return changes +// 保持原有调用方式:writeAuditLog(opts) —— 内部调用共享库 +export function writeAuditLog(opts: AuditLogEntry): void { + sharedWriteAuditLog(store, opts) } -export function getClientIP(request: Request): string | null { - const forwarded = request.headers.get('x-forwarded-for') - if (forwarded) return forwarded.split(',')[0].trim() - return request.headers.get('x-real-ip') ?? null -} +// re-export 共享工具函数 +export { diffObjects, getClientIP } diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 17b593a..f2071d1 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -1,7 +1,13 @@ -import crypto from 'crypto' +// assets-ai/src/lib/jwt.ts — 引用共享 JWT,保持原有接口 +import { signJwt, verifyJwt } from '@shared/lib/auth/jwt' +import type { AuthConfig } from '@shared/lib/auth/types' -const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites' -const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '' +// 从环境变量读取配置 +const config: AuthConfig = { + jwtSecret: process.env.JWT_SECRET || 'default-secret-change-me', + cookieDomain: process.env.COOKIE_DOMAIN || '', + autheliaUrl: '', oidcClientId: '', oidcClientSecret: '', oidcRedirectUri: '', +} export interface SharedSession { username: string @@ -10,51 +16,34 @@ export interface SharedSession { exp: number } -function base64url(str: string): string { - return Buffer.from(str).toString('base64url') -} - +// 保持原有签名:signSharedJwt(payload, expiresIn) export function signSharedJwt( payload: { username: string; displayName: string }, expiresIn: number = 7 * 24 * 60 * 60 ): string { - const header = { alg: 'HS256', typ: 'JWT' } - const now = Math.floor(Date.now() / 1000) - const body = { ...payload, iat: now, exp: now + expiresIn } - const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))] - const signingInput = segments.join('.') - segments.push( - crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url') - ) - return segments.join('.') + return signJwt({ secret: config.jwtSecret, payload, expiresInSeconds: expiresIn }) } +// 保持原有签名:verifySharedJwt(token) export function verifySharedJwt(token: string): SharedSession | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const signingInput = parts.slice(0, 2).join('.') - const expectedSig = crypto.createHmac('sha256', JWT_SECRET) - .update(signingInput).digest('base64url') - if (parts[2] !== expectedSig) return null - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null - return { - username: payload.username, - displayName: payload.displayName, - iat: payload.iat, - exp: payload.exp, - } - } catch { return null } + const payload = verifyJwt(token, config.jwtSecret) + if (!payload) return null + return { + username: payload.username as string, + displayName: (payload.displayName || payload.username) as string, + iat: payload.iat as number, + exp: payload.exp as number, + } } +// 保持原有签名:sharedCookieConfig(maxAge) export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) { return { name: 'tlyq_session', httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, - domain: COOKIE_DOMAIN, + domain: config.cookieDomain || undefined, path: '/', maxAge, } diff --git a/src/middleware.ts b/src/middleware.ts index c3327ec..1a22eb4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,103 +1,10 @@ -import { NextResponse } from 'next/server' -import type { NextRequest } from 'next/server' +// assets-ai/src/middleware.ts — 使用共享 middleware 工厂 +import { createMiddleware } from '@shared/lib/auth/middleware' -// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key) -// 注意:middleware 运行在 Edge Runtime,不能使用 better-sqlite3 等 Node.js 原生模块 -// DB 级别的 key 验证在 route handler 中进行(auth.ts verifyApiKey) -function verifyApiKey(key: string): boolean { - if (!key.startsWith('ak_')) return false - const allowedKeys = process.env.ALLOWED_API_KEYS || '' - if (!allowedKeys) return false - return allowedKeys.split(',').map(k => k.trim()).includes(key) -} - -function decodeJwtPayload(token: string): Record | 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 | null): boolean { - if (!payload) return false - return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000)) -} - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl - - // 登录页:已登录用户自动跳转首页 - if (pathname === '/login' || pathname.startsWith('/login')) { - const token = request.cookies.get('tlyq_session')?.value || request.cookies.get('session_assets')?.value - const payload = token ? decodeJwtPayload(token) : null - if (isValidPayload(payload)) { - return NextResponse.redirect(new URL('/dashboard', request.url)) - } - return NextResponse.next() - } - - // 退出路径 + 内部 API 放行(自有 key 认证) - if (pathname.startsWith('/api/auth/login') || pathname.startsWith('/api/auth/callback') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) { - return NextResponse.next() - } - - // API 路由:检查 Bearer API Key 或 session cookie - if (pathname.startsWith('/api/')) { - const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ak_')) { - if (verifyApiKey(authHeader.slice(7))) return NextResponse.next() - return NextResponse.json({ error: '未授权' }, { status: 401 }) - } - - const sharedToken = request.cookies.get('tlyq_session')?.value - const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null - if (isValidPayload(sharedPayload)) return NextResponse.next() - - const localToken = request.cookies.get('session_assets')?.value - const localPayload = localToken ? decodeJwtPayload(localToken) : null - if (isValidPayload(localPayload)) return NextResponse.next() - - return NextResponse.json({ error: '未授权' }, { status: 401 }) - } - - // 页面路由:优先检查 tlyq_session(共享 JWT),回退 session_assets(本地 JWT) - const sharedToken = request.cookies.get('tlyq_session')?.value - const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null - - if (isValidPayload(sharedPayload)) { - const response = NextResponse.next() - response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { - httpOnly: true, - sameSite: 'lax', - path: '/', - }) - return response - } - - const localToken = request.cookies.get('session_assets')?.value - const localPayload = localToken ? decodeJwtPayload(localToken) : null - - if (isValidPayload(localPayload)) { - const response = NextResponse.next() - response.cookies.set('session', JSON.stringify({ username: localPayload.username }), { - httpOnly: true, - sameSite: 'lax', - path: '/', - }) - return response - } - - // 未认证 → 重定向登录页 - const loginUrl = new URL('/login', request.url) - const dest = pathname + (request.nextUrl.search || '') - loginUrl.searchParams.set('redirect', dest) - const response = NextResponse.redirect(loginUrl) - if (sharedToken) response.cookies.delete('tlyq_session') - if (localToken) response.cookies.delete('session_assets') - return response -} +export const middleware = createMiddleware({ + localCookieName: 'session_assets', + adminPaths: ['/settings'], + enableApiKey: true, +}) export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] } diff --git a/tsconfig.json b/tsconfig.json index 2319bf3..022ad61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } + "baseUrl": ".", + "paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]