127 lines
3.8 KiB
JavaScript
127 lines
3.8 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import bcrypt from 'bcrypt';
|
|
import db from '../db/index.js';
|
|
import config from '../config.js';
|
|
|
|
const EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const USERNAME_RX = /^[a-zA-Z0-9_]{3,24}$/;
|
|
|
|
export function validateRegistration({ email, username, password }) {
|
|
const errors = {};
|
|
if (!email || !EMAIL_RX.test(email)) errors.email = 'Invalid email address.';
|
|
if (!username || !USERNAME_RX.test(username)) {
|
|
errors.username = 'Username must be 3-24 chars (letters, digits, underscore).';
|
|
}
|
|
if (!password || password.length < 8) {
|
|
errors.password = 'Password must be at least 8 characters.';
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export async function createUser({ email, username, password }) {
|
|
const lowerEmail = email.toLowerCase();
|
|
const existing = db
|
|
.prepare('SELECT id FROM users WHERE email = ? OR username = ?')
|
|
.get(lowerEmail, username);
|
|
if (existing) {
|
|
const conflict = db
|
|
.prepare('SELECT email, username FROM users WHERE id = ?')
|
|
.get(existing.id);
|
|
if (conflict.email === lowerEmail) {
|
|
throw Object.assign(new Error('Email already in use.'), { code: 'EMAIL_TAKEN' });
|
|
}
|
|
throw Object.assign(new Error('Username already in use.'), { code: 'USERNAME_TAKEN' });
|
|
}
|
|
|
|
const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expires = new Date(
|
|
Date.now() + config.email.verificationTtlHours * 3600 * 1000,
|
|
).toISOString();
|
|
|
|
const info = db
|
|
.prepare(
|
|
`INSERT INTO users (email, username, password_hash, verification_token, verification_expires_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(lowerEmail, username, hash, token, expires);
|
|
|
|
db.prepare(
|
|
'INSERT INTO profiles (user_id, display_name) VALUES (?, ?)',
|
|
).run(info.lastInsertRowid, username);
|
|
|
|
return {
|
|
id: info.lastInsertRowid,
|
|
email: lowerEmail,
|
|
username,
|
|
verificationToken: token,
|
|
};
|
|
}
|
|
|
|
export async function verifyPassword(identifier, password) {
|
|
const ident = identifier.toLowerCase();
|
|
const user = db
|
|
.prepare(
|
|
'SELECT id, email, username, password_hash, email_verified FROM users WHERE email = ? OR LOWER(username) = ?',
|
|
)
|
|
.get(ident, ident);
|
|
if (!user) return null;
|
|
const ok = await bcrypt.compare(password, user.password_hash);
|
|
if (!ok) return null;
|
|
return user;
|
|
}
|
|
|
|
export function createSession(userId) {
|
|
const id = crypto.randomBytes(32).toString('hex');
|
|
const expires = new Date(
|
|
Date.now() + config.auth.sessionTtlDays * 86400 * 1000,
|
|
).toISOString();
|
|
db.prepare(
|
|
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)',
|
|
).run(id, userId, expires);
|
|
return { id, expiresAt: expires };
|
|
}
|
|
|
|
export function destroySession(sessionId) {
|
|
if (!sessionId) return;
|
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
|
}
|
|
|
|
export function findSessionUser(sessionId) {
|
|
if (!sessionId) return null;
|
|
const row = db
|
|
.prepare(
|
|
`SELECT u.id, u.email, u.username, u.email_verified, s.expires_at
|
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
WHERE s.id = ?`,
|
|
)
|
|
.get(sessionId);
|
|
if (!row) return null;
|
|
if (new Date(row.expires_at).getTime() < Date.now()) {
|
|
destroySession(sessionId);
|
|
return null;
|
|
}
|
|
return {
|
|
id: row.id,
|
|
email: row.email,
|
|
username: row.username,
|
|
emailVerified: !!row.email_verified,
|
|
};
|
|
}
|
|
|
|
export function consumeVerificationToken(token) {
|
|
const user = db
|
|
.prepare(
|
|
`SELECT id, verification_expires_at FROM users
|
|
WHERE verification_token = ? AND email_verified = 0`,
|
|
)
|
|
.get(token);
|
|
if (!user) return null;
|
|
if (new Date(user.verification_expires_at).getTime() < Date.now()) return null;
|
|
db.prepare(
|
|
`UPDATE users SET email_verified = 1, verification_token = NULL,
|
|
verification_expires_at = NULL WHERE id = ?`,
|
|
).run(user.id);
|
|
return user.id;
|
|
}
|