返回 DeepSeek-Reasonix
auth.ts
根目录 / workers / crash-report / src / auth.ts
1 // Dashboard authorization. Identity comes from id.reasonix.io (the shared
2 // account service); this worker only maps a signed-in identity to a per-dashboard
3 // role via the `access` table.
4 import type { Env } from "./env";
5 import { esc } from "./shell";
6
7 export type Role = "pending" | "viewer" | "admin";
8
9 export interface User {
10 id: number;
11 email: string;
12 role: Role;
13 created_at: string;
14 approved_at: string | null;
15 }
16
17 const RANK: Record<Role, number> = { pending: 0, viewer: 1, admin: 2 };
18
19 export function atLeast(role: Role, min: Role): boolean {
20 return RANK[role] >= RANK[min];
21 }
22
23 // The id.reasonix.io session cookie is scoped to `.reasonix.io`, so the browser
24 // sends it here too; we hand it back as a Bearer token to resolve the identity.
25 const SHARED_COOKIE = "rxid";
26
27 function idOrigin(env: Env): string {
28 return (env.ID_ORIGIN ?? "https://id.reasonix.io").replace(/\/$/, "");
29 }
30
31 function appOrigin(env: Env): string {
32 return (env.APP_ORIGIN ?? "https://reasonix.io").replace(/\/$/, "");
33 }
34
35 export function getCookie(request: Request, name: string): string | null {
36 const header = request.headers.get("cookie") ?? "";
37 for (const part of header.split(";")) {
38 const [k, ...v] = part.trim().split("=");
39 if (k === name) return v.join("=");
40 }
41 return null;
42 }
43
44 // Where an unauthenticated visitor is sent to sign in: the shared login page,
45 // with a same-site `next` back to the page they wanted.
46 export function loginUrl(env: Env, request: Request): string {
47 const here = new URL(request.url);
48 const next = `${here.origin}${here.pathname}`;
49 return `${appOrigin(env)}/login/?next=${encodeURIComponent(next)}`;
50 }
51
52 interface Identity {
53 email: string;
54 emailVerified: boolean;
55 }
56
57 // Short-lived cache of a resolved session so repeat dashboard loads skip the
58 // cross-worker /me round-trip. Keyed by the token hash, never the raw token.
59 const IDENTITY_TTL_SECONDS = 60;
60
61 async function identityCacheKey(token: string): Promise<Request> {
62 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
63 const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
64 return new Request(`https://id-cache.reasonix.io/${hex}`);
65 }
66
67 async function resolveIdentity(request: Request, env: Env): Promise<Identity | null> {
68 const token = getCookie(request, SHARED_COOKIE);
69 if (!token) return null;
70 const cacheKey = await identityCacheKey(token);
71 const cached = await caches.default.match(cacheKey);
72 if (cached) return cached.json<Identity>().catch(() => null);
73
74 const res = await fetch(`${idOrigin(env)}/me`, { headers: { authorization: `Bearer ${token}` } });
75 if (!res.ok) return null;
76 const data = await res.json<{ user?: { email?: string; emailVerified?: boolean } }>().catch(() => null);
77 const email = data?.user?.email?.toLowerCase();
78 if (!email) return null;
79 const identity: Identity = { email, emailVerified: data?.user?.emailVerified === true };
80 await caches.default.put(
81 cacheKey,
82 new Response(JSON.stringify(identity), {
83 headers: { "content-type": "application/json", "cache-control": `max-age=${IDENTITY_TTL_SECONDS}` },
84 }),
85 );
86 return identity;
87 }
88
89 // Resolves the signed-in dashboard user, or null. A first-seen identity is
90 // recorded as `pending`; an ADMIN_EMAILS identity is force-promoted to admin so
91 // the owner can never be locked out of their own dashboard.
92 export async function currentUser(request: Request, env: Env): Promise<User | null> {
93 const identity = await resolveIdentity(request, env);
94 if (!identity) return null;
95
96 const now = new Date().toISOString();
97 const bootstrapAdmin = isAdminEmail(env, identity.email);
98 let row = await selectAccess(env, identity.email);
99 if (!row) {
100 await env.DB.prepare(
101 "INSERT INTO access (email, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(email) DO NOTHING",
102 )
103 .bind(identity.email, bootstrapAdmin ? "admin" : "pending", now, bootstrapAdmin ? now : null)
104 .run();
105 row = await selectAccess(env, identity.email);
106 if (!row) return null;
107 } else if (bootstrapAdmin && row.role !== "admin") {
108 await env.DB.prepare("UPDATE access SET role = 'admin', approved_at = COALESCE(approved_at, ?2) WHERE id = ?1")
109 .bind(row.id, now)
110 .run();
111 row.role = "admin";
112 }
113 return row;
114 }
115
116 function selectAccess(env: Env, email: string): Promise<User | null> {
117 return env.DB.prepare("SELECT id, email, role, created_at, approved_at FROM access WHERE email = ?1")
118 .bind(email)
119 .first<User>();
120 }
121
122 // Ends the shared id.reasonix.io session (best-effort) and returns a Set-Cookie
123 // that clears the shared cookie browser-side.
124 export async function sharedLogout(request: Request, env: Env): Promise<string> {
125 const token = getCookie(request, SHARED_COOKIE);
126 if (token) {
127 await fetch(`${idOrigin(env)}/auth/logout`, {
128 method: "POST",
129 headers: { authorization: `Bearer ${token}` },
130 }).catch(() => {});
131 }
132 return `${SHARED_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.reasonix.io; Max-Age=0`;
133 }
134
135 export function isAdminEmail(env: Env, email: string): boolean {
136 const list = (env.ADMIN_EMAILS ?? "")
137 .split(",")
138 .map((s) => s.trim().toLowerCase())
139 .filter(Boolean);
140 return list.includes(email.toLowerCase());
141 }
142
143 // CSRF guard for cookie-authed POSTs: a missing Origin is rejected, not waved
144 // through as same-site.
145 export function sameOrigin(request: Request): boolean {
146 const origin = request.headers.get("origin");
147 if (!origin) return false;
148 try {
149 return new URL(origin).host === new URL(request.url).host;
150 } catch {
151 return false;
152 }
153 }
154
155 export async function logAction(env: Env, actor: User, action: string, target = "", detail = ""): Promise<void> {
156 await env.DB.prepare(
157 "INSERT INTO audit_log (at, actor_id, actor_email, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
158 )
159 .bind(new Date().toISOString(), actor.id, actor.email, action, target, detail)
160 .run();
161 }
162
163 export function userNav(user: User): string {
164 const admin =
165 user.role === "admin"
166 ? `<a class="navlink" href="/community">Community</a><a class="navlink" href="/admin">Admin</a>`
167 : "";
168 return `<span class="chip"><span class="badge ${user.role}">${user.role}</span>${esc(user.email)}</span><a class="navlink" href="/account">Account</a>${admin}<form method="post" action="/logout" class="inline"><button class="btn ghost sm">Sign out</button></form>`;
169 }
170
170 lines TYPESCRIPT