108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
// GET /api/auth/callback — OIDC callback 处理
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc'
|
||
import { signJwt } from '@shared/lib/auth/jwt'
|
||
import { syncOidcUser } from '@shared/lib/auth/user-sync'
|
||
import { authConfig } from '@/lib/auth-config'
|
||
import { dbQueryParams, dbExec, escapeSql } from '@/lib/db'
|
||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const { searchParams } = new URL(request.url)
|
||
const code = searchParams.get('code')
|
||
const state = searchParams.get('state')
|
||
const error = searchParams.get('error')
|
||
|
||
// 使用 OIDC_REDIRECT_URI 构造公共 URL(LESSONS-LEARNED #16)
|
||
const baseUrl = authConfig.oidcRedirectUri?.replace(/\/api\/auth\/callback.*/, '') || 'http://localhost:6181'
|
||
|
||
if (error) {
|
||
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, baseUrl))
|
||
}
|
||
|
||
if (!code || !state) {
|
||
return NextResponse.redirect(new URL('/login?error=missing_params', baseUrl))
|
||
}
|
||
|
||
// 验证 state
|
||
const savedState = request.cookies.get('oidc_state')?.value
|
||
if (!savedState || savedState !== state) {
|
||
return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl))
|
||
}
|
||
|
||
// 取出 code_verifier
|
||
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
|
||
if (!codeVerifier) {
|
||
return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl))
|
||
}
|
||
|
||
// 换取 token
|
||
const tokenResult = await exchangeCodeForToken(
|
||
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||
code, codeVerifier,
|
||
)
|
||
|
||
if (!tokenResult.success) {
|
||
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(tokenResult.error || 'token_exchange_failed')}`, baseUrl))
|
||
}
|
||
|
||
// 验证 nonce
|
||
const savedNonce = request.cookies.get('oidc_nonce')?.value
|
||
if (savedNonce && tokenResult.nonce && savedNonce !== tokenResult.nonce) {
|
||
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
|
||
}
|
||
|
||
// 获取 userinfo(带错误处理)
|
||
let userinfo
|
||
try {
|
||
userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : 'userinfo_fetch_failed'
|
||
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(msg)}`, baseUrl))
|
||
}
|
||
|
||
// 用户同步(使用参数化查询)
|
||
const user = syncOidcUser({
|
||
getUser: (username) => {
|
||
const rows = dbQueryParams<{ id: number; role: string }>('SELECT id, role FROM users WHERE username = ?', [username])
|
||
return rows[0] ?? null
|
||
},
|
||
createUser: (username, displayName, email) => {
|
||
dbExec('INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)', [username, displayName, email, 'viewer'])
|
||
// 使用同一连接查询 last_insert_rowid(通过合并为单条 SQL)
|
||
const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', [])
|
||
return { id: row[0]?.id ?? 0, role: 'viewer' }
|
||
},
|
||
updateUser: (username, displayName, email) => {
|
||
dbExec('UPDATE users SET display_name = ?, email = ?, updated_at = datetime(\'now\', \'+8 hours\') WHERE username = ?', [displayName, email, username])
|
||
},
|
||
}, userinfo)
|
||
|
||
// 签发 JWT
|
||
const token = signJwt({
|
||
secret: authConfig.jwtSecret,
|
||
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role },
|
||
})
|
||
|
||
const response = NextResponse.redirect(new URL('/dashboard', baseUrl))
|
||
|
||
// 签发 tlyq_session cookie
|
||
response.cookies.set('tlyq_session', token, {
|
||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.NODE_ENV === 'production' ? authConfig.cookieDomain : undefined, path: '/', maxAge: 604800,
|
||
})
|
||
|
||
// 清理 OIDC 临时 cookie
|
||
response.cookies.delete('oidc_code_verifier')
|
||
response.cookies.delete('oidc_state')
|
||
response.cookies.delete('oidc_nonce')
|
||
|
||
// 审计日志
|
||
writeAuditLog({ exec: dbExec }, {
|
||
userId: user.id, username: userinfo.preferred_username, action: 'login',
|
||
entityType: 'auth', details: { method: 'oidc', isNew: user.isNew },
|
||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||
})
|
||
|
||
return response
|
||
}
|