import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { db, isPasswordResetTokenRow } from "../model/db.ts"; import { createPasswordResetToken, verifyPasswordResetToken, } from "../lib/jwt.ts"; import { getUserByEmail, updateUser } from "./user-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME, VALIDATION, } from "../config.ts"; import { marked } from "marked"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; const RESET_TOKEN_TTL_HOURS = 1; /** * Looks up the user by email, creates a signed reset token, persists it, and * sends the reset link. Always resolves without throwing so callers can return * 200 unconditionally (no user enumeration). */ // Per address: enough to retry a lost email, not enough to flood an inbox. const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; 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) }`; const body = `# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`; sendEmail({ from: FROM_EMAIL, to: user.email, subject: `Reset your ${OG_SITE_NAME} password`, text: body, html: await marked(body), }).catch((err) => console.error("[password-reset] failed to send email:", err) ); } /** * Verifies the token (JWT signature + expiry + DB record + not used), updates * the password, and marks the token consumed. */ export async function resetPassword( token: string, newPassword: string, ): Promise { if ( newPassword.length < VALIDATION.PASSWORD_MIN || newPassword.length > VALIDATION.PASSWORD_MAX ) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, ); } const payload = await verifyPasswordResetToken(token); if (!payload) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 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( APIErrorCode.NOT_FOUND, 404, "Reset token not found", ); } // Consume the link before the (async) password hash: checking used_at and // marking it later would let parallel submissions all pass the check. 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( APIErrorCode.VALIDATION_ERROR, 409, "Reset link has already been used", ); } try { await updateUser(payload.userId, { password: newPassword }); } catch (err) { db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`) .run(token); throw err; } }