import type { Config } from "./config"; import { getUserByLogin, ensureUser, verifyPat, type UserRow } from "./store"; import { runAuthHook } from "./auth-hook"; import { readFileSync, existsSync } from "fs"; // Per-user token-cookie auth. Cookie value is HMAC-signed and carries login + // issued-at, so the server is stateless (no session table). Two cookie formats // coexist for one release to avoid breaking existing sessions on upgrade: // v2 (new): "v2..." — resolves to that user // legacy: "." — resolves to cfg.operatorLogin (shared-token era) export const AUTH_COOKIE_NAME = "ework_auth"; export const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const COOKIE_VERSION = "v2"; async function hmac(secret: string, msg: string): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(msg)); return Buffer.from(new Uint8Array(sig)).toString("base64url"); } function parseCookies(header: string | null): Record { const out: Record = {}; if (!header) return out; for (const part of header.split(";")) { const idx = part.indexOf("="); if (idx < 0) continue; const k = part.slice(0, idx).trim(); const v = part.slice(idx + 1).trim(); if (k) out[k] = v; } return out; } export interface AuthResult { ok: boolean; user: UserRow | null; } export function authCookieName(cfg: Config): string { // __Host- prefix (C2 hardening) requires Secure + Path=/ + no Domain and is only // honored by browsers over TLS; cfg.secureCookie is flipped on only after Caddy/TLS. const name = cfg.cookieName ?? AUTH_COOKIE_NAME; return cfg.secureCookie ? `__Host-${name}` : name; } export async function makeAuthCookieHeader(cfg: Config, login: string): Promise { const issued = Math.floor(Date.now() / 1000); const payload = `${COOKIE_VERSION}.${login}.${issued}`; const sig = await hmac(cfg.cookieSecret, payload); const value = `${payload}.${sig}`; const flags = ["Path=/", "HttpOnly", `Max-Age=${AUTH_COOKIE_MAX_AGE_SECONDS}`, "SameSite=Lax"]; if (cfg.secureCookie) flags.push("Secure"); return `${authCookieName(cfg)}=${value}; ${flags.join("; ")}`; } // Logout: set Max-Age=0 so the browser drops the cookie immediately. export function clearAuthCookieHeader(cfg: Config): string { const flags = ["Path=/", "HttpOnly", "Max-Age=0", "SameSite=Lax"]; if (cfg.secureCookie) flags.push("Secure"); return `${authCookieName(cfg)}=; ${flags.join("; ")}`; } function ctEqual(a: string, b: string): boolean { const ab = new TextEncoder().encode(a); const bb = new TextEncoder().encode(b); if (ab.length !== bb.length) return false; let diff = 0; for (let i = 0; i < ab.length; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); return diff === 0; } // v2 cookie parse: value is "v2..." where login is // guaranteed not to contain "." (enforced by LOGIN_RE in store.ts). function parseV2Cookie(value: string): { login: string; issued: string; sig: string } | null { const parts = value.split("."); if (parts.length !== 4 || parts[0] !== COOKIE_VERSION) return null; const [, login, issued, sig] = parts; if (!login || !issued || !sig) return null; return { login, issued, sig }; } export async function checkAuth(req: Request, cfg: Config, ip?: string | null): Promise { const cookies = parseCookies(req.headers.get("cookie")); const cookieVal = cookies[authCookieName(cfg)]; if (cookieVal) { if (cookieVal.startsWith(`${COOKIE_VERSION}.`)) { const parsed = parseV2Cookie(cookieVal); if (!parsed) return { ok: false, user: null }; const payload = `${COOKIE_VERSION}.${parsed.login}.${parsed.issued}`; const expected = await hmac(cfg.cookieSecret, payload); if (!ctEqual(parsed.sig, expected)) return { ok: false, user: null }; const user = await getUserByLogin(parsed.login); if (!user || !user.is_active) return { ok: false, user: null }; return { ok: true, user }; } // Legacy format: ".". Accept only if token == cfg.authToken, // then resolve to the configured operator user (auto-created on boot by // ensureBootstrapAdmin in index.ts). const dot = cookieVal.lastIndexOf("."); if (dot <= 0) return { ok: false, user: null }; const token = cookieVal.slice(0, dot); const sig = cookieVal.slice(dot + 1); const expected = await hmac(cfg.cookieSecret, token); if (!ctEqual(sig, expected) || !ctEqual(token, cfg.authToken)) { return { ok: false, user: null }; } const user = await getUserByLogin(cfg.operatorLogin); if (!user || !user.is_active) return { ok: false, user: null }; return { ok: true, user }; } // PAT bearer (API clients / agents). Same auth surface as cookies, so any // route that takes a logged-in cookie also takes a Bearer PAT. Two header // shapes are accepted: // "Bearer " — RFC 6750 / OAuth standard (also GitHub-compat) // "token " — Gitea legacy form (Gitea's own client uses this) const authHeader = req.headers.get("authorization"); if (authHeader) { const lower = authHeader.toLowerCase(); let token: string | null = null; if (lower.startsWith("bearer ")) { token = authHeader.slice(7).trim(); } else if (lower.startsWith("token ")) { token = authHeader.slice(6).trim(); } if (token) { const user = await verifyPat(token, ip); if (user) return { ok: true, user }; } } // Internal auth hook (daemon / machine-to-machine — permanent credentials). if (cfg.internalAuthHook) { const user = await runAuthHook(cfg.internalAuthHook, req); if (user) return { ok: true, user }; } // User auth hook (human users — may expire, e.g. SSO/OAuth sessions). if (cfg.userAuthHook) { const user = await runAuthHook(cfg.userAuthHook, req); if (user) return { ok: true, user }; } return { ok: false, user: null }; } export async function ensureBootstrapAdmin(login: string): Promise { const existing = await getUserByLogin(login); if (existing) return existing; return await ensureUser(login, "human"); } // Reserved system user for automated actions (cron, import jobs, future CI // integration). kind=system, no password (cannot login via UI). Created on // boot if missing. UI guards prevent disabling/deleting it. export async function ensureBootstrapSystem(login: string): Promise { const existing = await getUserByLogin(login); if (existing) return existing; return await ensureUser(login, "system"); } export function isReservedSystemLogin(login: string, cfg: Config): boolean { return login === cfg.systemLogin; } // Same-origin relative targets only. Rejects "//" and "/\" — browsers collapse a // leading "/\" to "//" (protocol-relative), an open-redirect bypass (M1). export function sanitizeNext(next: string): string { if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/"; try { const u = new URL(next, "http://x.invalid"); if (u.origin !== "http://x.invalid") return "/"; } catch { return "/"; } return next; } function esc(s: string): string { return (s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } export function loginHTML(next: string, error?: string, cfg?: Config): string { if (cfg?.loginPage && existsSync(cfg.loginPage)) { try { const tpl = readFileSync(cfg.loginPage, "utf-8"); return tpl .replace(/\{\{next\}\}/g, esc(sanitizeNext(next))) .replace(/\{\{error\}\}/g, error ? esc(error) : ""); } catch { } } const err = error ? `
${esc(error)}
` : ""; return ` 登录 · ework

🔒 ework 登录

${err}
新部署的管理员 token 在 .env 文件的 WORK_TOKEN 里。
或已注册用户
登录后 cookie 30 天有效;token 登录后可在「我的」页面给自己设密码,之后即可用户名密码登录。
`; }