Files
wmailhub/console/src/auth.ts
T

148 lines
5.0 KiB
TypeScript

/**
* 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<string> {
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<SessionPayload | null> {
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;
}