import { randomBytes } from "node:crypto"; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, checkPasswordPolicy, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc.js"; import { getAppBaseUrl } from "../lib/app-base-url.js"; import { sendEmail } from "../lib/email.js"; import { authRateLimiter } from "../middleware/rate-limit.js"; const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour function resetEmailHtml(resetUrl: string): string { return `

You requested a password reset for your CapaKraken account.

Click the link below to set a new password:

${resetUrl}

This link expires in 1 hour and can only be used once.

If you did not request this, you can ignore this email.

`; } export const authRouter = createTRPCRouter({ /** * Request a password reset email. * Always returns { success: true } — even if the email is not registered — * to prevent user enumeration. */ requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; const keys = ipKey ? [`email:${input.email.toLowerCase()}`, ipKey] : [`email:${input.email.toLowerCase()}`]; const rl = await authRateLimiter(keys); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many password reset attempts. Please wait before trying again.", }); } const user = await ctx.db.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { // Timing-safe: don't reveal whether the email exists return { success: true }; } // Delete any existing (unused) reset tokens for this email await ctx.db.passwordResetToken.deleteMany({ where: { email: input.email, usedAt: null }, }); const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + RESET_TTL_MS); await ctx.db.passwordResetToken.create({ data: { email: input.email, token, expiresAt }, }); const resetUrl = `${getAppBaseUrl()}/auth/reset-password/${token}`; void sendEmail({ to: input.email, subject: "CapaKraken — reset your password", text: `You requested a password reset.\n\nReset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`, html: resetEmailHtml(resetUrl), }); return { success: true }; }), /** Validate a reset token and set a new password. */ resetPassword: publicProcedure .input( z.object({ token: z.string().min(1), password: z .string() .min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE) .max(PASSWORD_MAX_LENGTH), }), ) .mutation(async ({ ctx, input }) => { // Rate-limit keyed on IP (token is always new so token-keying is a no-op). // We cannot key on the resolved email before the token lookup; fall back // to IP-only here and apply an email-keyed limit AFTER the successful // lookup to bound per-email brute-force. const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; if (ipKey) { const rl = await authRateLimiter(ipKey); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many password reset attempts. Please wait before trying again.", }); } } const record = await ctx.db.passwordResetToken.findUnique({ where: { token: input.token }, }); if (!record) { throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." }); } if (record.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used.", }); } if (record.expiresAt < new Date()) { throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." }); } // Second-layer limit keyed on the resolved email, so a targeted // attacker cannot exhaust reset attempts for a known user even if // they cycle source IPs. const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`); if (!emailRl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many password reset attempts. Please wait before trying again.", }); } // Reject weak/common/identity-related passwords *after* the token is // validated so attackers can't probe the policy without a valid link. const userForPolicy = await ctx.db.user.findUnique({ where: { email: record.email }, select: { email: true, name: true }, }); const policy = checkPasswordPolicy(input.password, userForPolicy ?? undefined); if (!policy.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); // All three operations must succeed atomically: if session deletion // fails after the password is already changed, old sessions could // persist with the new password (CWE-613). await ctx.db.$transaction(async (tx) => { const updatedUser = await tx.user.update({ where: { email: record.email }, data: { passwordHash }, select: { id: true }, }); await tx.activeSession.deleteMany({ where: { userId: updatedUser.id } }); await tx.passwordResetToken.update({ where: { token: input.token }, data: { usedAt: new Date() }, }); }); return { success: true }; }), });