/** * Emails password reset links and sets the new password when one is used. * `routes/password-resets.ts` calls it for `POST /api/password-resets` and * `PUT /api/password-resets/:token`; the link opens the client's * `/reset-password` page. * * @module */ import { APIException } from "../lib/api-exception.ts"; import { db } from "../db/connection.ts"; import { isPasswordResetTokenRow } from "../model/password-reset.ts"; import { createPasswordResetToken, verifyPasswordResetToken, } from "../lib/jwt.ts"; import { getUserByEmail, updateUser } from "./user-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { FRONTEND_URL } from "../config.ts"; import { VALIDATION } from "../../shared/validation.ts"; import { renderPasswordResetEmail } from "../mail/password-reset.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; /** Must match the `1h` that `createPasswordResetToken` signs into the token. */ const RESET_TOKEN_TTL_HOURS = 1; /** * Applies per address. It allows a retry after a lost email, but not enough * requests to flood an inbox. */ const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; /** * Emails a reset link to the account with address `email`, if there is one and * email is configured. It says nothing of what it did, so that the route * answers the same whether the address has an account or not. It sends at * most 3 links in a row per address, then one every 10 minutes. */ export async function requestPasswordReset(email: string): Promise { if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return; const user = getUserByEmail(email); if (!user) return; if (!isEmailEnabled()) return; const token = await createPasswordResetToken(user.id); const expiresAt = new Date( Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000, ); db.prepare( `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, ).run(token, user.id, expiresAt.toISOString()); const resetUrl = `${FRONTEND_URL}/reset-password?token=${ encodeURIComponent(token) }`; sendEmail({ to: user.email, ...renderPasswordResetEmail(user.username, resetUrl, RESET_TOKEN_TTL_HOURS), }).catch((err) => console.error("[password-reset] failed to send email:", err) ); } /** * Sets `newPassword` for the user of a reset token, marks the token used, and * returns the user's id. Through `updateUser`, this ends the user's sessions * here; the route tells the instances they signed in to. * * @throws {APIException} 400 when the password's length is out of bounds or * the token is invalid or expired, 404 when the token isn't on record or its * user is gone, 409 when it has been used. */ export async function resetPassword( token: string, newPassword: string, ): Promise { if ( newPassword.length < VALIDATION.PASSWORD_MIN || newPassword.length > VALIDATION.PASSWORD_MAX ) { throw new APIException( 400, `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, ); } const payload = await verifyPasswordResetToken(token); if (!payload) { throw new APIException(400, "Invalid or expired reset link"); } const row = db.prepare( `SELECT token, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?;`, ).get(token); if (!row || !isPasswordResetTokenRow(row)) { throw new APIException(404, "Reset token not found"); } // Claim the link before the async hash: checking `used_at` first and // marking it after would let parallel submissions all pass. const claimed = db.prepare( `UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`, ).run(new Date().toISOString(), token); if (claimed.changes === 0) { throw new APIException(409, "Reset link has already been used"); } try { await updateUser(payload.userId, { password: newPassword }); } catch (err) { // The password didn't change, so the link must keep working. db.prepare( `UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`, ) .run(token); throw err; } return payload.userId; }