148 lines
5.0 KiB
TypeScript
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;
|
|
}
|