/** * Console 用户认证:用户名密码登录 + HMAC 签名会话 cookie。 * cookie 值: base64url(payload) "." hex(HMAC-SHA256(sessionSecret, payload)) */ import { hmacSha256Hex, timingSafeEqualHex, verifyPassword } from "../../shared/src/index.ts"; import type { Env } from "./env.ts"; import { getSessionSecret } from "./db.ts"; import { nowIso, readJson } from "./util.ts"; import { Hono } from "hono"; /** # 成员变量: 会话 cookie 名称 **/ export const SESSION_COOKIE = "wmh_session"; /** # 成员变量: 会话有效期(秒),默认 7 天 **/ const SESSION_TTL_SECONDS = 7 * 24 * 3600; /** # 成员变量: 会话 payload **/ interface SessionPayload { uid: string; username: string; exp: number; } /** # 函数 * 签发会话 token */ export async function issueSessionToken(env: Env, uid: string, username: string): Promise { const payload: SessionPayload = { uid, username, exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, }; const body = base64UrlEncode(JSON.stringify(payload)); const secret = await getSessionSecret(env); const sig = await hmacSha256Hex(secret, body); return `${body}.${sig}`; } /** # 函数 * 校验会话 token,返回 payload;无效/过期返回 null */ export async function verifySessionToken( env: Env, token: string | undefined, ): Promise { if (!token) return null; const dot = token.lastIndexOf("."); if (dot <= 0) return null; const body = token.slice(0, dot); const sig = token.slice(dot + 1); const secret = await getSessionSecret(env); const expected = await hmacSha256Hex(secret, body); if (!timingSafeEqualHex(expected, sig)) return null; let payload: SessionPayload; try { payload = JSON.parse(base64UrlDecode(body)) as SessionPayload; } catch { return null; } if (!payload?.uid || typeof payload.exp !== "number") return null; if (payload.exp < Math.floor(Date.now() / 1000)) return null; return payload; } /** # 函数 * 从 Request 中取会话 cookie */ export function readSessionCookie(request: Request): string | undefined { const cookie = request.headers.get("Cookie") ?? ""; for (const part of cookie.split(";")) { const [k, ...rest] = part.trim().split("="); if (k === SESSION_COOKIE) return rest.join("="); } return undefined; } /** # 函数 * 构造 Set-Cookie 会话头 */ export function sessionSetCookie(token: string): string { return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_SECONDS}`; } /** # 函数 * 构造清除会话 cookie 头 */ export function clearSessionCookie(): string { return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; } function base64UrlEncode(s: string): string { const bytes = new TextEncoder().encode(s); let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function base64UrlDecode(s: string): string { const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new TextDecoder().decode(bytes); } /** # 函数 * 装配认证路由:POST /login、POST /logout、GET /me */ export function authRoutes() { const app = new Hono<{ Bindings: Env }>(); /** 登录:用户名密码 → 签名会话 cookie。默认账号 admin/admin(首次启动自动播种) */ app.post("/login", async (c) => { const { username, password } = await readJson<{ username?: string; password?: string }>(c.req.raw); if (!username || !password) { return c.json({ error: "username and password required" }, 400); } const user = await c.env.DB.prepare("SELECT id, username, password_hash FROM users WHERE username = ?") .bind(username) .first<{ id: string; username: string; password_hash: string }>(); if (!user || !(await verifyPassword(password, user.password_hash))) { return c.json({ error: "invalid username or password" }, 401); } const token = await issueSessionToken(c.env, user.id, user.username); c.header("Set-Cookie", sessionSetCookie(token)); return c.json({ ok: true, user: { id: user.id, username: user.username } }); }); /** 登出:清除会话 cookie */ app.post("/logout", (c) => { c.header("Set-Cookie", clearSessionCookie()); return c.json({ ok: true }); }); /** 当前登录用户信息(用户名以库内为准:v2.9.0 起支持改用户名,旧会话 cookie 的 payload 可能是旧名) */ app.get("/me", async (c) => { const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw)); if (!payload) return c.json({ error: "unauthorized" }, 401); const row = await c.env.DB.prepare("SELECT username FROM users WHERE id = ?") .bind(payload.uid) .first<{ username: string }>(); return c.json({ user: { id: payload.uid, username: row?.username ?? payload.username }, now: nowIso() }); }); return app; }