| 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 |