122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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 and marks the token used.
|
||
* Through `updateUser`, this ends the user's other sessions.
|
||
*
|
||
* @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<void> {
|
||
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;
|
||
}
|
||
}
|