fertig-classic-games/server/auth/service.js

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;
}