110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
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);
|
||
}
|