feat(users): refactor users system

This commit is contained in:
2026-04-19 16:42:33 -04:00
parent af8da2aa86
commit f08376d979
24 changed files with 1531 additions and 669 deletions
+148 -7
View File
@@ -9,6 +9,7 @@
import { query, updateById } from '@zen/core/database';
import { updateUser } from './lib/auth.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users';
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
@@ -339,6 +340,138 @@ async function handleDeleteProfilePicture(_request, _params, { session }) {
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/:id/roles (admin only)
// ---------------------------------------------------------------------------
async function handleGetUserRoles(_request, { id: userId }) {
const roles = await getUserRoles(userId);
return apiSuccess({ roles });
}
// ---------------------------------------------------------------------------
// POST /zen/api/users/:id/roles (admin only)
// ---------------------------------------------------------------------------
async function handleAssignUserRole(request, { id: userId }) {
try {
const body = await request.json();
const { roleId } = body;
if (!roleId) return apiError('Bad Request', 'roleId is required');
const roleCheck = await query(`SELECT id FROM zen_auth_roles WHERE id = $1`, [roleId]);
if (roleCheck.rows.length === 0) return apiError('Not Found', 'Role not found');
await assignUserRole(userId, roleId);
return apiSuccess({ success: true });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to assign role');
}
}
// ---------------------------------------------------------------------------
// DELETE /zen/api/users/:id/roles/:roleId (admin only)
// ---------------------------------------------------------------------------
async function handleRevokeUserRole(_request, { id: userId, roleId }) {
try {
await revokeUserRole(userId, roleId);
return apiSuccess({ success: true });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to revoke role');
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/roles (admin only)
// ---------------------------------------------------------------------------
async function handleListRoles() {
const roles = await listRoles();
return apiSuccess({ roles });
}
// ---------------------------------------------------------------------------
// POST /zen/api/roles (admin only)
// ---------------------------------------------------------------------------
async function handleCreateRole(request) {
try {
const body = await request.json();
const { name, description, color } = body;
if (!name || !String(name).trim()) {
return apiError('Bad Request', 'Role name is required');
}
const role = await createRole({
name: String(name).trim(),
description: description ? String(description).trim() : null,
color: color ? String(color) : '#6b7280'
});
return apiSuccess({ role });
} catch (error) {
if (error.message === 'Role name is required') return apiError('Bad Request', error.message);
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to create role');
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/roles/:id (admin only)
// ---------------------------------------------------------------------------
async function handleGetRole(_request, { id: roleId }) {
const role = await getRoleById(roleId);
if (!role) return apiError('Not Found', 'Role not found');
return apiSuccess({ role });
}
// ---------------------------------------------------------------------------
// PUT /zen/api/roles/:id (admin only)
// ---------------------------------------------------------------------------
async function handleUpdateRole(request, { id: roleId }) {
try {
const body = await request.json();
const { name, description, color, permissionKeys } = body;
const role = await updateRole(roleId, {
...(name !== undefined && { name: String(name).trim() }),
...(description !== undefined && { description: description ? String(description).trim() : null }),
...(color !== undefined && { color: String(color) }),
...(permissionKeys !== undefined && { permissionKeys: Array.isArray(permissionKeys) ? permissionKeys : [] })
});
return apiSuccess({ role });
} catch (error) {
if (error.message === 'Role not found') return apiError('Not Found', error.message);
if (error.message === 'Role name cannot be empty') return apiError('Bad Request', error.message);
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to update role');
}
}
// ---------------------------------------------------------------------------
// DELETE /zen/api/roles/:id (admin only)
// ---------------------------------------------------------------------------
async function handleDeleteRole(_request, { id: roleId }) {
try {
await deleteRole(roleId);
return apiSuccess({ success: true });
} catch (error) {
if (error.message === 'Cannot delete a system role') return apiError('Bad Request', error.message);
if (error.message === 'Role not found') return apiError('Not Found', error.message);
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to delete role');
}
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
@@ -347,11 +480,19 @@ async function handleDeleteProfilePicture(_request, _params, { session }) {
// parameterised paths (/users/:id) so they match first.
export const routes = defineApiRoutes([
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
]);
+7
View File
@@ -5,6 +5,7 @@
import { query, tableExists } from '@zen/core/database';
import { done, warn } from '@zen/core/shared/logger';
import { createTables as createUserCoreTables, dropTables as dropUserCoreTables } from '../../core/users/db.js';
const AUTH_TABLES = [
{
@@ -93,6 +94,11 @@ export async function createTables() {
}
}
// Create role/permission tables and seed defaults
const coreResult = await createUserCoreTables();
created.push(...coreResult.created);
skipped.push(...coreResult.skipped);
return { created, skipped };
}
@@ -113,5 +119,6 @@ export async function dropTables() {
}
}
await dropUserCoreTables();
done('All authentication tables dropped');
}
+18 -297
View File
@@ -1,301 +1,22 @@
/**
* Authentication Logic
* Main authentication functions for user registration, login, and password management
*/
import { create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, sendPasswordChangedEmail } from './email.js';
/**
* Register a new user
* @param {Object} userData - User registration data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {string} userData.name - User name
* @returns {Promise<Object>} Created user and session
*/
async function register(userData) {
const { email, password, name } = userData;
// Validate required fields
if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
}
// Validate email length (maximum 254 characters - RFC standard)
if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
}
// Validate password length (minimum 8, maximum 128 characters)
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Validate name length (maximum 100 characters)
if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins');
}
// Validate name is not empty after trimming
if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide');
}
// Check if user already exists
const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà');
}
// Check if this is the first user - if so, make them admin
const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user';
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const userId = generateId();
const user = await create('zen_auth_users', {
id: userId,
email,
name,
email_verified: false,
image: null,
role,
updated_at: new Date()
});
// Create account with password
const accountId = generateId();
await create('zen_auth_accounts', {
id: accountId,
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
// Create email verification token
const verification = await createEmailVerification(email);
return {
user,
verificationToken: verification.token
};
}
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} User and session
*/
async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials;
// Validate required fields
if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Find account with password
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Verify password
const isValid = await verifyPassword(password, account.password);
if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Create session
const session = await createSession(user.id, sessionOptions);
return {
user,
session
};
}
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} Reset token
*/
async function requestPasswordReset(email) {
// Validate email
if (!email) {
throw new Error('L\'e-mail est requis');
}
// Check if user exists
const user = await findOne('zen_auth_users', { email });
if (!user) {
// Don't reveal if user exists or not
return { success: true };
}
// Create password reset token
const reset = await createPasswordReset(email);
return {
success: true,
token: reset.token
};
}
/**
* Reset password with token
* @param {Object} resetData - Reset data
* @param {string} resetData.email - User email
* @param {string} resetData.token - Reset token
* @param {string} resetData.newPassword - New password
* @returns {Promise<Object>} Success status
*/
async function resetPassword(resetData) {
const { email, token, newPassword } = resetData;
// Validate required fields
if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
}
// Validate password length (minimum 8, maximum 128 characters)
if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Authoritative token verification — this check must live here so that any
// caller that imports resetPassword() directly (bypassing the server-action
// layer) cannot reset a password with an arbitrary or omitted token.
const tokenValid = await verifyResetToken(email, token);
if (!tokenValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Jeton de réinitialisation invalide');
}
// Find account
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account) {
throw new Error('Compte introuvable');
}
// Hash new password
const hashedPassword = await hashPassword(newPassword);
// Update password
await updateById('zen_auth_accounts', account.id, {
password: hashedPassword,
updated_at: new Date()
});
// Delete reset token
await deleteResetToken(email);
// Send password changed confirmation email
try {
await sendPasswordChangedEmail(email);
} catch (error) {
// Log error but don't fail the password reset process
fail(`Auth: failed to send password changed email to ${email}: ${error.message}`);
}
return { success: true };
}
/**
* Verify user email
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated user
*/
async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, {
email_verified: true,
updated_at: new Date()
});
}
/**
* Update user profile
* @param {string} userId - User ID
* @param {Object} updateData - Data to update
* @returns {Promise<Object>} Updated user
*/
async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language'];
const filteredData = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
}
filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData);
}
export {
register,
import {
register as _register,
resetPassword as _resetPassword,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
};
} from '../../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js';
// Inject email sending into register (verification email) — kept here because
// it depends on JSX templates that live in features/auth.
export function register(userData) {
return _register(userData);
}
// Inject sendPasswordChangedEmail — the template lives in features/auth.
export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
}
export { login, requestPasswordReset, verifyUserEmail, updateUser };
+3 -108
View File
@@ -1,108 +1,12 @@
import crypto from 'crypto';
import { render } from '@react-email/components';
import { create, findOne, deleteWhere } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
import { fail, info } from '@zen/core/shared/logger';
import { sendEmail } from '@zen/core/email';
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
async function createEmailVerification(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await deleteWhere('zen_auth_verifications', { identifier: 'email_verification', value: email });
const verification = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...verification, token };
}
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Timing-safe comparison — always operate on same-length buffers so that a
// wrong-length guess yields no measurable timing difference from a wrong-value guess.
const storedBuf = Buffer.from(verification.token, 'utf8');
const providedBuf = Buffer.from(
token.length === verification.token.length ? token : verification.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === verification.token.length;
if (!tokensMatch) return false;
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
async function createPasswordReset(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
await deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
const reset = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...reset, token };
}
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Timing-safe comparison — same rationale as verifyEmailToken above.
const storedBuf = Buffer.from(reset.token, 'utf8');
const providedBuf = Buffer.from(
token.length === reset.token.length ? token : reset.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === reset.token.length;
if (!tokensMatch) return false;
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../../core/users/verifications.js';
async function sendVerificationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
@@ -142,13 +46,4 @@ async function sendPasswordChangedEmail(email) {
return result;
}
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
};
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
+1 -73
View File
@@ -1,73 +1 @@
/**
* Password Hashing and Verification
* Provides secure password hashing using bcrypt
*/
import crypto from 'crypto';
/**
* Hash a password using scrypt (Node.js native)
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(password) {
return new Promise((resolve, reject) => {
// Generate a salt
const salt = crypto.randomBytes(16).toString('hex');
// Hash password with salt using scrypt
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex'));
});
});
}
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if password matches, false otherwise
*/
async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) { reject(err); return; }
try {
const storedKey = Buffer.from(key, 'hex');
// timingSafeEqual requires identical lengths; if the stored hash is
// malformed the lengths will differ and we reject without leaking timing.
if (storedKey.length !== derivedKey.length) { resolve(false); return; }
resolve(crypto.timingSafeEqual(storedKey, derivedKey));
} catch {
resolve(false);
}
});
});
}
/**
* Generate a random token
* @param {number} length - Token length in bytes (default: 32)
* @returns {string} Random token
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
/**
* Generate a random ID
* @returns {string} Random ID
*/
function generateId() {
return crypto.randomUUID();
}
export {
hashPassword,
verifyPassword,
generateToken,
generateId
};
export { hashPassword, verifyPassword, generateToken, generateId } from '../../../core/users/password.js';
+1 -138
View File
@@ -1,138 +1 @@
/**
* Session Management
* Handles user session creation, validation, and deletion
*/
import { create, findOne, deleteWhere, updateById } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
/**
* Create a new session for a user
* @param {string} userId - User ID
* @param {Object} options - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} Session object with token
*/
async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options;
// Generate session token
const token = generateToken(32);
const sessionId = generateId();
// Session expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', {
id: sessionId,
user_id: userId,
token,
expires_at: expiresAt,
ip_address: ipAddress || null,
user_agent: userAgent || null,
updated_at: new Date()
});
return session;
}
/**
* Validate a session token
* @param {string} token - Session token
* @returns {Promise<Object|null>} Session object with user data or null if invalid
*/
async function validateSession(token) {
if (!token) return null;
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Check if session is expired
if (new Date(session.expires_at) < new Date()) {
await deleteSession(token);
return null;
}
// Get user data
const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) {
await deleteSession(token);
return null;
}
// Auto-refresh session if it expires in less than 20 days
const now = new Date();
const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false;
if (daysUntilExpiry < 20) {
// Extend session to 30 days from now
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt,
updated_at: new Date()
});
// Update the session object with new expiration
session.expires_at = newExpiresAt;
sessionRefreshed = true;
}
return {
session,
user,
sessionRefreshed
};
}
/**
* Delete a session
* @param {string} token - Session token
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token });
}
/**
* Delete all sessions for a user
* @param {string} userId - User ID
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId });
}
/**
* Refresh a session (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object|null>} Updated session or null
*/
async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Extend session by 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt,
updated_at: new Date()
});
}
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
};
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../../core/users/session.js';