Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted. 303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
4.1 KiB
TypeScript
124 lines
4.1 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, 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<string> {
|
||
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;
|
||
}
|