v3: added password change/reset feature
This commit is contained in:
109
api/services/password-reset-service.ts
Normal file
109
api/services/password-reset-service.ts
Normal 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);
|
||||
}
|
||||
@@ -85,6 +85,15 @@ export function getUserByUsername(username: string): User {
|
||||
return userRowToApi(userRow);
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string): User | null {
|
||||
const userRow = db.prepare(
|
||||
`${USER_SELECT} WHERE u.email = ?`,
|
||||
).get(email);
|
||||
|
||||
if (!userRow || !isUserRow(userRow)) return null;
|
||||
return userRowToApi(userRow);
|
||||
}
|
||||
|
||||
export function searchUsers(
|
||||
query: string,
|
||||
limit: number,
|
||||
|
||||
Reference in New Issue
Block a user