Add database schema and API endpoints for system wording keys and values Replace hardcoded translations in frontend and backend with dynamic messages Add System Wordings management interface to Admin view
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
|
import { promisify } from 'node:util';
|
|
import type { PoolClient, QueryResultRow } from 'pg';
|
|
import { pool, queryOne } from './db.ts';
|
|
import { systemMessage } from './systemWordingQueries.ts';
|
|
|
|
const scrypt = promisify(scryptCallback);
|
|
const passwordKeyLength = 64;
|
|
const verificationTokenHours = 24;
|
|
const sessionDays = 30;
|
|
const defaultLocale = 'en';
|
|
|
|
type DbClient = PoolClient;
|
|
|
|
type StatusError = Error & { statusCode: number };
|
|
|
|
type UserRow = QueryResultRow & {
|
|
id: number;
|
|
email: string;
|
|
display_name: string;
|
|
email_verified_at: string | null;
|
|
};
|
|
|
|
type LoginUserRow = UserRow & {
|
|
password_hash: string;
|
|
};
|
|
|
|
type AuthMessageKey =
|
|
| 'emailRequired'
|
|
| 'invalidEmail'
|
|
| 'displayNameRequired'
|
|
| 'displayNameLength'
|
|
| 'passwordLength'
|
|
| 'invalidToken'
|
|
| 'emailAlreadyRegistered'
|
|
| 'checkVerificationEmail'
|
|
| 'emailVerified'
|
|
| 'invalidCredentials'
|
|
| 'verifyEmailFirst'
|
|
| 'emailSubject'
|
|
| 'emailHtml'
|
|
| 'emailText';
|
|
|
|
export type AuthUser = {
|
|
id: number;
|
|
email: string;
|
|
displayName: string;
|
|
emailVerified: boolean;
|
|
};
|
|
|
|
function statusError(message: string, statusCode: number): StatusError {
|
|
const error = new Error(message) as StatusError;
|
|
error.statusCode = statusCode;
|
|
return error;
|
|
}
|
|
|
|
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): Promise<string> {
|
|
const messageKeys: Record<AuthMessageKey, string> = {
|
|
emailRequired: 'server.auth.emailRequired',
|
|
invalidEmail: 'server.auth.invalidEmail',
|
|
displayNameRequired: 'server.auth.displayNameRequired',
|
|
displayNameLength: 'server.auth.displayNameLength',
|
|
passwordLength: 'server.auth.passwordLength',
|
|
invalidToken: 'server.auth.invalidToken',
|
|
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
|
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
|
emailVerified: 'server.auth.emailVerified',
|
|
invalidCredentials: 'server.auth.invalidCredentials',
|
|
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
|
emailSubject: 'email.auth.verificationSubject',
|
|
emailHtml: 'email.auth.verificationHtml',
|
|
emailText: 'email.auth.verificationText'
|
|
};
|
|
|
|
return systemMessage(locale || defaultLocale, messageKeys[key], params);
|
|
}
|
|
|
|
async function cleanEmail(value: unknown, locale: string): Promise<string> {
|
|
if (typeof value !== 'string') {
|
|
throw statusError(await authMessage(locale, 'emailRequired'), 400);
|
|
}
|
|
|
|
const email = value.trim().toLowerCase();
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
throw statusError(await authMessage(locale, 'invalidEmail'), 400);
|
|
}
|
|
|
|
return email;
|
|
}
|
|
|
|
async function cleanDisplayName(value: unknown, locale: string): Promise<string> {
|
|
if (typeof value !== 'string') {
|
|
throw statusError(await authMessage(locale, 'displayNameRequired'), 400);
|
|
}
|
|
|
|
const displayName = value.trim();
|
|
if (displayName.length < 1 || displayName.length > 40) {
|
|
throw statusError(await authMessage(locale, 'displayNameLength'), 400);
|
|
}
|
|
|
|
return displayName;
|
|
}
|
|
|
|
async function cleanPassword(value: unknown, locale: string): Promise<string> {
|
|
if (typeof value !== 'string' || value.length < 8) {
|
|
throw statusError(await authMessage(locale, 'passwordLength'), 400);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
async function cleanToken(value: unknown, locale: string): Promise<string> {
|
|
if (typeof value !== 'string' || value.trim().length < 32) {
|
|
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
|
}
|
|
|
|
return value.trim();
|
|
}
|
|
|
|
function toPublicUser(user: UserRow): AuthUser {
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
displayName: user.display_name,
|
|
emailVerified: user.email_verified_at !== null
|
|
};
|
|
}
|
|
|
|
async function clientQueryOne<T extends QueryResultRow>(
|
|
client: DbClient,
|
|
sql: string,
|
|
params: unknown[] = []
|
|
): Promise<T | null> {
|
|
const result = await client.query<T>(sql, params);
|
|
return result.rows[0] ?? null;
|
|
}
|
|
|
|
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
const result = await callback(client);
|
|
await client.query('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function hashPassword(password: string): Promise<string> {
|
|
const salt = randomBytes(16).toString('base64url');
|
|
const key = (await scrypt(password, salt, passwordKeyLength)) as Buffer;
|
|
return `scrypt$${salt}$${key.toString('base64url')}`;
|
|
}
|
|
|
|
async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
|
const [algorithm, salt, storedKey] = passwordHash.split('$');
|
|
if (algorithm !== 'scrypt' || !salt || !storedKey) {
|
|
return false;
|
|
}
|
|
|
|
const storedBuffer = Buffer.from(storedKey, 'base64url');
|
|
const key = (await scrypt(password, salt, storedBuffer.length)) as Buffer;
|
|
return key.length === storedBuffer.length && timingSafeEqual(key, storedBuffer);
|
|
}
|
|
|
|
function createPlainToken(): string {
|
|
return randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function getEmailConfig() {
|
|
const apiKey = process.env.RESEND_API_KEY;
|
|
const from = process.env.EMAIL_FROM;
|
|
|
|
if (!apiKey || !from) {
|
|
throw new Error('Email service is not configured');
|
|
}
|
|
|
|
return { apiKey, from };
|
|
}
|
|
|
|
function buildVerificationUrl(token: string): string {
|
|
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
|
const url = new URL('/verify-email', origin);
|
|
url.searchParams.set('token', token);
|
|
return url.toString();
|
|
}
|
|
|
|
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
|
const { apiKey, from } = getEmailConfig();
|
|
const verificationUrl = buildVerificationUrl(token);
|
|
const subject = await authMessage(locale, 'emailSubject');
|
|
const html = await authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours });
|
|
const text = await authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours });
|
|
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);
|
|
const password = await cleanPassword(payload.password, locale);
|
|
const passwordHash = await hashPassword(password);
|
|
const verificationToken = createPlainToken();
|
|
const verificationTokenHash = hashToken(verificationToken);
|
|
|
|
await withTransaction(async (client) => {
|
|
const existingUser = await clientQueryOne<UserRow>(
|
|
client,
|
|
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
|
[email]
|
|
);
|
|
|
|
if (existingUser?.email_verified_at) {
|
|
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
|
}
|
|
|
|
const user = existingUser
|
|
? await clientQueryOne<UserRow>(
|
|
client,
|
|
`
|
|
UPDATE users
|
|
SET display_name = $1, password_hash = $2, updated_at = now()
|
|
WHERE id = $3
|
|
RETURNING id, email, display_name, email_verified_at
|
|
`,
|
|
[displayName, passwordHash, existingUser.id]
|
|
)
|
|
: await clientQueryOne<UserRow>(
|
|
client,
|
|
`
|
|
INSERT INTO users (email, display_name, password_hash)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, email, display_name, email_verified_at
|
|
`,
|
|
[email, displayName, passwordHash]
|
|
);
|
|
|
|
if (!user) {
|
|
throw new Error('Failed to save user');
|
|
}
|
|
|
|
await client.query('DELETE FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
|
|
await client.query(
|
|
`
|
|
INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, now() + ($3 * interval '1 hour'))
|
|
`,
|
|
[user.id, verificationTokenHash, verificationTokenHours]
|
|
);
|
|
});
|
|
|
|
await sendVerificationEmail(email, verificationToken, locale);
|
|
return { message: await authMessage(locale, 'checkVerificationEmail') };
|
|
}
|
|
|
|
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
|
|
const token = await cleanToken(payload.token, locale);
|
|
const tokenHash = hashToken(token);
|
|
|
|
return withTransaction(async (client) => {
|
|
const tokenRow = await clientQueryOne<{ id: number; user_id: number }>(
|
|
client,
|
|
`
|
|
SELECT id, user_id
|
|
FROM email_verification_tokens
|
|
WHERE token_hash = $1
|
|
AND used_at IS NULL
|
|
AND expires_at > now()
|
|
FOR UPDATE
|
|
`,
|
|
[tokenHash]
|
|
);
|
|
|
|
if (!tokenRow) {
|
|
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
|
}
|
|
|
|
const user = await clientQueryOne<UserRow>(
|
|
client,
|
|
`
|
|
UPDATE users
|
|
SET email_verified_at = COALESCE(email_verified_at, now()), updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, email, display_name, email_verified_at
|
|
`,
|
|
[tokenRow.user_id]
|
|
);
|
|
|
|
if (!user) {
|
|
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
|
}
|
|
|
|
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
|
user.id
|
|
]);
|
|
|
|
return { message: await authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
|
});
|
|
}
|
|
|
|
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 user = await queryOne<LoginUserRow>(
|
|
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
|
[email]
|
|
);
|
|
|
|
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
|
throw statusError(await authMessage(locale, 'invalidCredentials'), 401);
|
|
}
|
|
|
|
if (!user.email_verified_at) {
|
|
throw statusError(await authMessage(locale, 'verifyEmailFirst'), 403);
|
|
}
|
|
|
|
const sessionToken = createPlainToken();
|
|
await pool.query(
|
|
`
|
|
INSERT INTO user_sessions (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, now() + ($3 * interval '1 day'))
|
|
`,
|
|
[user.id, hashToken(sessionToken), sessionDays]
|
|
);
|
|
|
|
return { token: sessionToken, user: toPublicUser(user) };
|
|
}
|
|
|
|
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
|
|
if (token.length < 32) {
|
|
return null;
|
|
}
|
|
|
|
const user = await queryOne<UserRow>(
|
|
`
|
|
SELECT u.id, u.email, u.display_name, u.email_verified_at
|
|
FROM user_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = $1
|
|
AND s.expires_at > now()
|
|
`,
|
|
[hashToken(token)]
|
|
);
|
|
|
|
return user ? toPublicUser(user) : null;
|
|
}
|
|
|
|
export async function logoutSession(token: string): Promise<void> {
|
|
if (token.length < 32) {
|
|
return;
|
|
}
|
|
|
|
await pool.query('DELETE FROM user_sessions WHERE token_hash = $1', [hashToken(token)]);
|
|
}
|