v3: added password change/reset feature

This commit is contained in:
khannurien
2026-04-06 16:30:00 +00:00
parent 3b6980a8fc
commit 20b9bfe7b4
26 changed files with 1268 additions and 236 deletions

View File

@@ -0,0 +1,109 @@
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";
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).
*/
export async function requestPasswordReset(email: string): Promise<void> {
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<void> {
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",
);
}
if (row.used_at !== null) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Reset link has already been used",
);
}
await updateUser(payload.userId, { password: newPassword });
db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
).run(new Date().toISOString(), token);
}