93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
/**
|
||
* 后台管理员登录鉴权:生成/校验签名 Cookie,不暴露账号密码
|
||
* 账号密码从环境变量读取,默认 admin / key123456(与 .cursorrules 一致)
|
||
*/
|
||
|
||
import { createHmac, timingSafeEqual } from 'crypto'
|
||
|
||
const COOKIE_NAME = 'admin_session'
|
||
const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
|
||
const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
|
||
|
||
export function getAdminCredentials() {
|
||
return {
|
||
username: process.env.ADMIN_USERNAME || 'admin',
|
||
password: process.env.ADMIN_PASSWORD || 'key123456',
|
||
}
|
||
}
|
||
|
||
export function verifyAdminCredentials(username: string, password: string): boolean {
|
||
const { username: u, password: p } = getAdminCredentials()
|
||
return username === u && password === p
|
||
}
|
||
|
||
function sign(payload: string): string {
|
||
return createHmac('sha256', SECRET).update(payload).digest('base64url')
|
||
}
|
||
|
||
/** 生成签名 token,写入 Cookie 用 */
|
||
export function createAdminToken(): string {
|
||
const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
|
||
const payload = `${exp}`
|
||
const sig = sign(payload)
|
||
return `${payload}.${sig}`
|
||
}
|
||
|
||
/** 校验 Cookie 中的 token */
|
||
export function verifyAdminToken(token: string | null | undefined): boolean {
|
||
if (!token || typeof token !== 'string') return false
|
||
const dot = token.indexOf('.')
|
||
if (dot === -1) return false
|
||
const payload = token.slice(0, dot)
|
||
const sig = token.slice(dot + 1)
|
||
const exp = parseInt(payload, 10)
|
||
if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
|
||
const expected = sign(payload)
|
||
if (typeof expected !== 'string' || typeof sig !== 'string') return false
|
||
if (sig.length !== expected.length) return false
|
||
try {
|
||
return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function getAdminCookieName() {
|
||
return COOKIE_NAME
|
||
}
|
||
|
||
export function getAdminCookieOptions() {
|
||
return {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax' as const,
|
||
maxAge: MAX_AGE_SEC,
|
||
path: '/',
|
||
}
|
||
}
|
||
|
||
/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
|
||
export function getAdminTokenFromRequest(request: Request): string | null {
|
||
const cookieHeader = request.headers.get('cookie')
|
||
if (!cookieHeader) return null
|
||
const name = COOKIE_NAME + '='
|
||
const start = cookieHeader.indexOf(name)
|
||
if (start === -1) return null
|
||
const valueStart = start + name.length
|
||
const end = cookieHeader.indexOf(';', valueStart)
|
||
const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
|
||
return value.trim() || null
|
||
}
|
||
|
||
/** 若未登录则返回 401 Response,供各 admin API 使用 */
|
||
export function requireAdminResponse(request: Request): Response | null {
|
||
const token = getAdminTokenFromRequest(request)
|
||
if (!verifyAdminToken(token)) {
|
||
return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
})
|
||
}
|
||
return null
|
||
}
|