feat(auth): add password reset and remember me options
Add password reset request and reset endpoints with email verification Add "Remember me" option to login for persistent sessions Create frontend views for forgot and reset password flows
This commit is contained in:
@@ -130,6 +130,18 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
||||
ON email_verification_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
|
||||
ON password_reset_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -7,7 +7,9 @@ import { systemMessage } from './systemWordingQueries.ts';
|
||||
const scrypt = promisify(scryptCallback);
|
||||
const passwordKeyLength = 64;
|
||||
const verificationTokenHours = 24;
|
||||
const sessionDays = 30;
|
||||
const passwordResetTokenHours = 1;
|
||||
const rememberedSessionDays = 30;
|
||||
const sessionOnlySessionDays = 1;
|
||||
const defaultLocale = 'en';
|
||||
|
||||
type DbClient = PoolClient;
|
||||
@@ -35,11 +37,19 @@ type AuthMessageKey =
|
||||
| 'emailAlreadyRegistered'
|
||||
| 'checkVerificationEmail'
|
||||
| 'emailVerified'
|
||||
| 'checkPasswordResetEmail'
|
||||
| 'passwordResetComplete'
|
||||
| 'invalidCredentials'
|
||||
| 'verifyEmailFirst'
|
||||
| 'invalidResetToken'
|
||||
| 'emailSubject'
|
||||
| 'emailHtml'
|
||||
| 'emailText';
|
||||
| 'emailText'
|
||||
| 'passwordResetSubject'
|
||||
| 'passwordResetHtml'
|
||||
| 'passwordResetText';
|
||||
|
||||
type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken';
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
@@ -65,11 +75,17 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
||||
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
||||
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
||||
emailVerified: 'server.auth.emailVerified',
|
||||
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||
invalidCredentials: 'server.auth.invalidCredentials',
|
||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||
invalidResetToken: 'server.auth.invalidResetToken',
|
||||
emailSubject: 'email.auth.verificationSubject',
|
||||
emailHtml: 'email.auth.verificationHtml',
|
||||
emailText: 'email.auth.verificationText'
|
||||
emailText: 'email.auth.verificationText',
|
||||
passwordResetSubject: 'email.auth.passwordResetSubject',
|
||||
passwordResetHtml: 'email.auth.passwordResetHtml',
|
||||
passwordResetText: 'email.auth.passwordResetText'
|
||||
};
|
||||
|
||||
return systemMessage(locale || defaultLocale, messageKeys[key], params);
|
||||
@@ -109,9 +125,13 @@ async function cleanPassword(value: unknown, locale: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function cleanToken(value: unknown, locale: string): Promise<string> {
|
||||
async function cleanToken(
|
||||
value: unknown,
|
||||
locale: string,
|
||||
messageKey: AuthTokenMessageKey = 'invalidToken'
|
||||
): Promise<string> {
|
||||
if (typeof value !== 'string' || value.trim().length < 32) {
|
||||
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||
throw statusError(await authMessage(locale, messageKey), 400);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
@@ -187,13 +207,21 @@ function getEmailConfig() {
|
||||
return { apiKey, from };
|
||||
}
|
||||
|
||||
function buildVerificationUrl(token: string): string {
|
||||
function buildTokenUrl(pathname: string, token: string): string {
|
||||
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||
const url = new URL('/verify-email', origin);
|
||||
const url = new URL(pathname, origin);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildVerificationUrl(token: string): string {
|
||||
return buildTokenUrl('/verify-email', token);
|
||||
}
|
||||
|
||||
function buildPasswordResetUrl(token: string): string {
|
||||
return buildTokenUrl('/reset-password', token);
|
||||
}
|
||||
|
||||
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
||||
const { apiKey, from } = getEmailConfig();
|
||||
const verificationUrl = buildVerificationUrl(token);
|
||||
@@ -221,6 +249,33 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
|
||||
const { apiKey, from } = getEmailConfig();
|
||||
const resetUrl = buildPasswordResetUrl(token);
|
||||
const subject = await authMessage(locale, 'passwordResetSubject');
|
||||
const html = await authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours });
|
||||
const text = await authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours });
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [email],
|
||||
subject,
|
||||
html,
|
||||
text
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||
@@ -324,9 +379,90 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const user = await queryOne<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (user) {
|
||||
const resetToken = createPlainToken();
|
||||
const resetTokenHash = hashToken(resetToken);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query('DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + ($3 * interval '1 hour'))
|
||||
`,
|
||||
[user.id, resetTokenHash, passwordResetTokenHours]
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(email, resetToken, locale);
|
||||
} catch (error) {
|
||||
console.error('Password reset email failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { message: await authMessage(locale, 'checkPasswordResetEmail') };
|
||||
}
|
||||
|
||||
export async function resetPassword(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const token = await cleanToken(payload.token, locale, 'invalidResetToken');
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const tokenRow = await clientQueryOne<{ id: number; user_id: number }>(
|
||||
client,
|
||||
`
|
||||
SELECT id, user_id
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
FOR UPDATE
|
||||
`,
|
||||
[tokenHash]
|
||||
);
|
||||
|
||||
if (!tokenRow) {
|
||||
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
|
||||
}
|
||||
|
||||
const user = await clientQueryOne<UserRow>(
|
||||
client,
|
||||
`
|
||||
UPDATE users
|
||||
SET password_hash = $1, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, email, display_name, email_verified_at
|
||||
`,
|
||||
[passwordHash, tokenRow.user_id]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
|
||||
}
|
||||
|
||||
await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
||||
user.id
|
||||
]);
|
||||
await client.query('DELETE FROM user_sessions WHERE user_id = $1', [user.id]);
|
||||
|
||||
return { message: await authMessage(locale, 'passwordResetComplete') };
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays;
|
||||
const user = await queryOne<LoginUserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||
[email]
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
||||
import {
|
||||
getUserBySessionToken,
|
||||
loginUser,
|
||||
logoutSession,
|
||||
registerUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
type AuthUser
|
||||
} from './auth.ts';
|
||||
import { initializeDatabase, pool } from './db.ts';
|
||||
import {
|
||||
cleanLocale,
|
||||
@@ -170,6 +179,14 @@ app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body a
|
||||
|
||||
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
|
||||
|
||||
app.post('/api/auth/request-password-reset', async (request) =>
|
||||
requestPasswordReset(request.body as Record<string, unknown>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.post('/api/auth/reset-password', async (request) =>
|
||||
resetPassword(request.body as Record<string, unknown>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.get('/api/auth/me', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
Reference in New Issue
Block a user