import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts' import { endSession, requireAuth, startSession } from '../auth/middleware.ts' import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts' import { RateLimiter } from '../auth/rate-limit.ts' import { deleteUserSessions } from '../auth/sessions.ts' import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts' import type { Db } from '../db/client.ts' import { organizations, users } from '../db/schema.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' import { publicUser } from './users.ts' const MAGIC_LINK_TTL_MS = 15 * 60 * 1000 const RESET_TTL_MS = 60 * 60 * 1000 async function activeUserByEmail(db: Db, email: string) { const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email)) return user?.active ? user : null } export function authRoutes() { // 10 failed-or-successful attempts per email per 15 minutes const loginLimiter = new RateLimiter(10, 15 * 60 * 1000) // Emailed links: 5 per email per 15 minutes const emailLimiter = new RateLimiter(5, 15 * 60 * 1000) return new Hono() .post('/login', validate('json', loginSchema), async (c) => { const { email, password } = c.req.valid('json') if (!loginLimiter.attempt(email)) throw fail(429, 'Too many sign-in attempts. Try again in 15 minutes.') const db = c.get('db') const [user] = await db.select().from(users).where(eq(users.email, email)) const valid = user?.active && user.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyAgainstDummy(password) if (!user || !valid) throw fail(401, 'Incorrect email or password') loginLimiter.reset(email) await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id)) await startSession(c, user.id) const [result] = await db.select(publicUser).from(users).where(eq(users.id, user.id)) return c.json({ user: result }) }) // Always 204, so the response never reveals whether an account exists. .post('/magic-link', validate('json', emailOnlySchema), async (c) => { const { email } = c.req.valid('json') if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.') const db = c.get('db') const user = await activeUserByEmail(db, email) if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS)) return c.body(null, 204) }) // POST rather than GET so email link scanners can't use up the token. .post('/magic-link/verify', validate('json', tokenSchema), async (c) => { const db = c.get('db') const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link') if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.') await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId)) await startSession(c, userId) const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId)) return c.json({ user }) }) .post('/password-reset', validate('json', emailOnlySchema), async (c) => { const { email } = c.req.valid('json') if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.') const db = c.get('db') const user = await activeUserByEmail(db, email) if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null) return c.body(null, 204) }) .post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => { const { token, newPassword } = c.req.valid('json') const db = c.get('db') const userId = await consumeAuthToken(db, token, 'password_reset') if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.') await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId)) await deleteUserSessions(db, userId) await startSession(c, userId) const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId)) return c.json({ user }) }) .post('/logout', async (c) => { await endSession(c) return c.body(null, 204) }) .get('/me', requireAuth, async (c) => { const me = currentUser(c) const db = c.get('db') const [user] = await db.select(publicUser).from(users).where(eq(users.id, me.id)) const [organization] = await db.select({ id: organizations.id, name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId)) return c.json({ user, organization }) }) .post('/password', requireAuth, validate('json', changePasswordSchema), async (c) => { const me = currentUser(c) const { currentPassword, newPassword } = c.req.valid('json') const db = c.get('db') const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, me.id)) if (!user.passwordHash || !(await verifyPassword(currentPassword, user.passwordHash))) throw fail(400, 'Current password is incorrect') await db.update(users).set({ passwordHash: await hashPassword(newPassword) }).where(eq(users.id, me.id)) // Sign out everywhere else await deleteUserSessions(db, me.id) await startSession(c, me.id) return c.body(null, 204) }) }