返回 DeepSeek-Reasonix
auth.ts
根目录 / workers / accounts / src / routes / auth.ts
1 import { Hono } from "hono";
2 import type { AppEnv, Bindings } from "../env";
3 import type { Role } from "../types";
4 import { toAccountUser } from "../types";
5 import { repos, UserRepo } from "../db";
6 import { buildMailer, verifyEmail, resetEmail, accountExistsEmail } from "../email";
7 import { hashPassword, verifyPassword, randomSuffix } from "../auth/crypto";
8 import { setSessionCookie, clearSessionCookie, readSessionToken } from "../auth/cookies";
9 import { authRateLimit } from "../http/ratelimit";
10 import { readBearerToken } from "../http/auth";
11 import { ApiError } from "../http/errors";
12 import { deriveHandleBase, isValidHandle } from "../lib/handle";
13 import { parseBody, RegisterSchema, LoginSchema, ForgotSchema, ResetSchema, ResendSchema } from "../lib/validation";
14 import { VERIFY_TTL_MS, RESET_TTL_MS } from "../config";
15
16 const auth = new Hono<AppEnv>();
17
18 // Throttle every auth endpoint per source IP.
19 auth.use("*", authRateLimit);
20
21 function isAdminEmail(env: Bindings, email: string): boolean {
22 const list = (env.ADMIN_EMAILS ?? "")
23 .split(",")
24 .map((s) => s.trim().toLowerCase())
25 .filter(Boolean);
26 return list.includes(email);
27 }
28
29 // Find a free, valid handle starting from a derived base, appending digits on
30 // collision and falling back to a random handle as a last resort.
31 async function uniqueHandle(users: UserRepo, base: string): Promise<string> {
32 if (isValidHandle(base) && !(await users.handleTaken(base))) return base;
33 for (let i = 0; i < 5; i++) {
34 const candidate = `${base}${randomSuffix(i < 2 ? 2 : 4)}`.slice(0, 30).replace(/_+$/g, "");
35 if (isValidHandle(candidate) && !(await users.handleTaken(candidate))) return candidate;
36 }
37 return `user${randomSuffix(8)}`;
38 }
39
40 // Built from the configured ACCOUNT_ORIGIN, never the request origin: the Host
41 // header is caller-controlled, so a request-derived origin would let emailed
42 // verification links point at an attacker-chosen host (#5550).
43 export function verifyLink(accountOrigin: string, token: string): string {
44 const base = accountOrigin.replace(/\/+$/, "");
45 return `${base}/auth/verify?token=${encodeURIComponent(token)}`;
46 }
47
48 // Register is deliberately enumeration-safe: the response is identical whether or
49 // not the email already exists. New emails get a verification link; existing ones
50 // get a resend or an "account already exists" nudge — never a different status.
51 auth.post("/register", async (c) => {
52 const { email, password, displayName } = await parseBody(c, RegisterSchema);
53 const { users, emailTokens } = repos(c.env);
54 const mailer = buildMailer(c.env);
55 const existing = await users.byEmail(email);
56
57 if (!existing) {
58 const handle = await uniqueHandle(users, deriveHandleBase(email));
59 const role: Role = isAdminEmail(c.env, email) ? "admin" : "member";
60 const user = await users.create({ handle, email, passwordHash: await hashPassword(password), displayName: displayName ?? "", role });
61 const token = await emailTokens.issue(user.id, "verify", VERIFY_TTL_MS);
62 await mailer.send({ to: email, ...verifyEmail(verifyLink(c.env.ACCOUNT_ORIGIN, token)) });
63 } else if (existing.email_verified === 0) {
64 await emailTokens.invalidateForUser(existing.id, "verify");
65 const token = await emailTokens.issue(existing.id, "verify", VERIFY_TTL_MS);
66 await mailer.send({ to: email, ...verifyEmail(verifyLink(c.env.ACCOUNT_ORIGIN, token)) });
67 } else {
68 await mailer.send({ to: email, ...accountExistsEmail(`${c.env.APP_ORIGIN}/login`, `${c.env.APP_ORIGIN}/forgot`) });
69 }
70
71 return c.json({ ok: true, message: "If that address is valid, check your inbox to confirm your account." });
72 });
73
74 // Email link target. Always lands the browser back on the site; never leaks
75 // whether the token was good beyond the ?verified flag.
76 auth.get("/verify", async (c) => {
77 const token = c.req.query("token") ?? "";
78 const { emailTokens, users } = repos(c.env);
79 let ok = false;
80 if (token.length >= 10) {
81 const userId = await emailTokens.consume(token, "verify");
82 if (userId !== null) {
83 await users.markEmailVerified(userId);
84 ok = true;
85 }
86 }
87 return c.redirect(`${c.env.APP_ORIGIN}/login?verified=${ok ? "1" : "0"}`, 302);
88 });
89
90 auth.post("/login", async (c) => {
91 const { email, password } = await parseBody(c, LoginSchema);
92 const { users, sessions } = repos(c.env);
93 const user = await users.byEmail(email);
94 const ok = user ? await verifyPassword(password, user.password_hash) : false;
95 if (!user || !ok) throw new ApiError(401, "invalid_credentials", "Incorrect email or password.");
96 if (user.status !== "active") throw new ApiError(403, "account_unavailable", "This account is not available.");
97
98 const token = await sessions.create(user.id, { userAgent: c.req.header("user-agent") ?? "" });
99 setSessionCookie(c, token);
100 return c.json({ user: toAccountUser(user) });
101 });
102
103 auth.post("/logout", async (c) => {
104 const token = readSessionToken(c) ?? readBearerToken(c);
105 if (token) await repos(c.env).sessions.deleteByToken(token);
106 clearSessionCookie(c);
107 return c.json({ ok: true });
108 });
109
110 auth.post("/forgot", async (c) => {
111 const { email } = await parseBody(c, ForgotSchema);
112 const { users, emailTokens } = repos(c.env);
113 const user = await users.byEmail(email);
114 if (user && user.status === "active") {
115 await emailTokens.invalidateForUser(user.id, "reset");
116 const token = await emailTokens.issue(user.id, "reset", RESET_TTL_MS);
117 await buildMailer(c.env).send({ to: email, ...resetEmail(`${c.env.APP_ORIGIN}/reset?token=${token}`) });
118 }
119 return c.json({ ok: true, message: "If that account exists, a reset link is on its way." });
120 });
121
122 auth.post("/reset", async (c) => {
123 const { token, password } = await parseBody(c, ResetSchema);
124 const { users, emailTokens, sessions } = repos(c.env);
125 const userId = await emailTokens.consume(token, "reset");
126 if (userId === null) throw new ApiError(400, "invalid_token", "This reset link is invalid or has expired.");
127 await users.updatePassword(userId, await hashPassword(password));
128 await sessions.deleteAllForUser(userId); // force a fresh sign-in everywhere
129 return c.json({ ok: true, message: "Password updated. You can now sign in." });
130 });
131
132 auth.post("/resend-verification", async (c) => {
133 const { email } = await parseBody(c, ResendSchema);
134 const { users, emailTokens } = repos(c.env);
135 const user = await users.byEmail(email);
136 if (user && user.email_verified === 0) {
137 await emailTokens.invalidateForUser(user.id, "verify");
138 const token = await emailTokens.issue(user.id, "verify", VERIFY_TTL_MS);
139 await buildMailer(c.env).send({ to: email, ...verifyEmail(verifyLink(c.env.ACCOUNT_ORIGIN, token)) });
140 }
141 return c.json({ ok: true, message: "If that address needs confirming, a new link is on its way." });
142 });
143
144 export default auth;
145
145 lines TYPESCRIPT