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:
parent
fc7f3d7255
commit
9986d99ab3
|
|
@ -14,3 +14,4 @@ db-backups/
|
|||
.playwright-mcp/
|
||||
*.tsbuildinfo
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null
|
||||
ipAddress?: string | null
|
||||
}
|
||||
|
||||
export function writeAuditLog(opts: AuditLogOptions): void {
|
||||
const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts
|
||||
|
||||
// 创建 assets-ai 的 store 适配器
|
||||
const store: AuditStore = {
|
||||
exec: (sql: string) => {
|
||||
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)
|
||||
}
|
||||
|
||||
// 写入审计日志(显式设置 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
|
||||
)
|
||||
db.exec(sql)
|
||||
} catch (e) {
|
||||
// 审计写入失败不阻断主操作
|
||||
console.error('审计日志写入失败:', e)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export function diffObjects(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>
|
||||
): 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 }
|
||||
}
|
||||
// 保持原有调用方式:writeAuditLog(opts) —— 内部调用共享库
|
||||
export function writeAuditLog(opts: AuditLogEntry): void {
|
||||
sharedWriteAuditLog(store, opts)
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
const payload = verifyJwt(token, config.jwtSecret)
|
||||
if (!payload) return null
|
||||
return {
|
||||
username: payload.username,
|
||||
displayName: payload.displayName,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
username: payload.username as string,
|
||||
displayName: (payload.displayName || payload.username) as string,
|
||||
iat: payload.iat as number,
|
||||
exp: payload.exp as number,
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// 保持原有签名: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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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: '/',
|
||||
export const middleware = createMiddleware({
|
||||
localCookieName: 'session_assets',
|
||||
adminPaths: ['/settings'],
|
||||
enableApiKey: true,
|
||||
})
|
||||
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).*)'] }
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue