Files
soul/lib/admin-auth.ts

93 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 后台管理员登录鉴权:生成/校验签名 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
}