refactor: P1 共享库迁移

- audit.ts: 引用 shared/lib/audit(包装器保持原接口)
- jwt.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt)
- middleware.ts: 使用 shared/lib/auth/middleware 工厂
- tsconfig: 添加 @shared/* 路径
- 添加 shared symlink

独立审查通过
This commit is contained in:
gitadmin 2026-07-01 18:42:35 +08:00
parent fc7f3d7255
commit 9986d99ab3
6 changed files with 49 additions and 212 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ db-backups/
.playwright-mcp/ .playwright-mcp/
*.tsbuildinfo *.tsbuildinfo
.env.local .env.local
.DS_Store

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -1,85 +1,23 @@
// assets-ai/src/lib/audit.ts — 站点审计包装器(引用共享库)
import db from './db' 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 { // 创建 assets-ai 的 store 适配器
userId?: number | null const store: AuditStore = {
apiKeyId?: number | null exec: (sql: string) => {
action: string
entityType: string
entityId?: number | null
details?: Record<string, unknown> | null
ipAddress?: string | null
}
export function writeAuditLog(opts: AuditLogOptions): void {
const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts
try { try {
// 每日清理(每天首次写入触发) db.exec(sql)
// 注意:禁止使用 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)
}
// 写入审计日志(显式设置 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) { } catch (e) {
// 审计写入失败不阻断主操作
console.error('审计日志写入失败:', e) console.error('审计日志写入失败:', e)
} }
},
} }
export function diffObjects( // 保持原有调用方式writeAuditLog(opts) —— 内部调用共享库
before: Record<string, unknown>, export function writeAuditLog(opts: AuditLogEntry): void {
after: Record<string, unknown> sharedWriteAuditLog(store, opts)
): Record<string, { from: unknown; to: unknown }> {
const changes: Record<string, { from: unknown; to: unknown }> = {}
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
} }
export function getClientIP(request: Request): string | null { // re-export 共享工具函数
const forwarded = request.headers.get('x-forwarded-for') export { diffObjects, getClientIP }
if (forwarded) return forwarded.split(',')[0].trim()
return request.headers.get('x-real-ip') ?? null
}

View File

@ -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 { export interface SharedSession {
username: string username: string
@ -10,51 +16,34 @@ export interface SharedSession {
exp: number exp: number
} }
function base64url(str: string): string { // 保持原有签名signSharedJwt(payload, expiresIn)
return Buffer.from(str).toString('base64url')
}
export function signSharedJwt( export function signSharedJwt(
payload: { username: string; displayName: string }, payload: { username: string; displayName: string },
expiresIn: number = 7 * 24 * 60 * 60 expiresIn: number = 7 * 24 * 60 * 60
): string { ): string {
const header = { alg: 'HS256', typ: 'JWT' } return signJwt({ secret: config.jwtSecret, payload, expiresInSeconds: expiresIn })
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('.')
} }
// 保持原有签名verifySharedJwt(token)
export function verifySharedJwt(token: string): SharedSession | null { export function verifySharedJwt(token: string): SharedSession | null {
try { const payload = verifyJwt(token, config.jwtSecret)
const parts = token.split('.') if (!payload) return null
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 { return {
username: payload.username, username: payload.username as string,
displayName: payload.displayName, displayName: (payload.displayName || payload.username) as string,
iat: payload.iat, iat: payload.iat as number,
exp: payload.exp, exp: payload.exp as number,
} }
} catch { return null }
} }
// 保持原有签名sharedCookieConfig(maxAge)
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) { export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
return { return {
name: 'tlyq_session', name: 'tlyq_session',
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const, sameSite: 'lax' as const,
domain: COOKIE_DOMAIN, domain: config.cookieDomain || undefined,
path: '/', path: '/',
maxAge, maxAge,
} }

View File

@ -1,103 +1,10 @@
import { NextResponse } from 'next/server' // assets-ai/src/middleware.ts — 使用共享 middleware 工厂
import type { NextRequest } from 'next/server' import { createMiddleware } from '@shared/lib/auth/middleware'
// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key export const middleware = createMiddleware({
// 注意middleware 运行在 Edge Runtime不能使用 better-sqlite3 等 Node.js 原生模块 localCookieName: 'session_assets',
// DB 级别的 key 验证在 route handler 中进行auth.ts verifyApiKey adminPaths: ['/settings'],
function verifyApiKey(key: string): boolean { enableApiKey: true,
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<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))
}
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 config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }

View File

@ -7,7 +7,8 @@
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] } "baseUrl": ".",
"paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]