auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

users.js (8349B)


      1 import crypto from 'node:crypto';
      2 import { HttpError } from './http/http-error.js';
      3 
      4 export function normalizeEmail(email) {
      5   return String(email || '').trim().toLowerCase();
      6 }
      7 
      8 export function normalizeUsername(username) {
      9   return String(username || '').trim().toLowerCase();
     10 }
     11 
     12 export function emailDomain(email) {
     13   const normalized = normalizeEmail(email);
     14   const at = normalized.lastIndexOf('@');
     15   return at > 0 && at < normalized.length - 1 ? normalized.slice(at + 1) : null;
     16 }
     17 
     18 export async function createUser(db, { username, displayName = null, homeRealmId = null, isSystemAdmin = false }) {
     19   const normalized = normalizeUsername(username);
     20   if (!normalized) throw new Error('username is required');
     21   const existing = await db.get(
     22     'SELECT id FROM users WHERE username_normalized = {username}',
     23     { username: normalized }
     24   );
     25   if (existing) throw new Error(`username is already taken: ${username}`);
     26 
     27   const id = crypto.randomUUID();
     28   const now = Date.now();
     29   await db.run(
     30     `INSERT INTO users
     31       (id, username, username_normalized, display_name, status, home_realm_id, is_system_admin, profile, created_at, updated_at)
     32      VALUES
     33       ({id}, {username}, {normalized}, {displayName}, 'active', {homeRealmId}, {isSystemAdmin}, '{}', {createdAt}, {updatedAt})`,
     34     {
     35       id,
     36       username,
     37       normalized,
     38       displayName: displayName || username,
     39       homeRealmId,
     40       isSystemAdmin: isSystemAdmin ? 1 : 0,
     41       createdAt: now,
     42       updatedAt: now,
     43     }
     44   );
     45   return id;
     46 }
     47 
     48 export async function findUserById(db, id) {
     49   return db.get(
     50     `SELECT id, username, username_normalized, display_name, status, home_realm_id,
     51             is_system_admin, profile, last_login_at, created_at, updated_at
     52        FROM users
     53       WHERE id = {id}`,
     54     { id }
     55   );
     56 }
     57 
     58 export async function findUserByEmail(db, email) {
     59   const normalized = normalizeEmail(email);
     60   return db.get(
     61     `SELECT u.id, u.username, u.username_normalized, u.display_name, u.status,
     62             u.home_realm_id, u.is_system_admin, u.profile, u.last_login_at,
     63             e.email, e.email_normalized
     64        FROM users u
     65        JOIN user_emails e ON e.user_id = u.id
     66       WHERE e.email_normalized = {email} AND e.verified = 1`,
     67     { email: normalized }
     68   );
     69 }
     70 
     71 export async function findUserByUsername(db, username) {
     72   const normalized = normalizeUsername(username);
     73   return db.get(
     74     `SELECT id, username, username_normalized, display_name, status,
     75             home_realm_id, is_system_admin, profile, last_login_at
     76        FROM users
     77       WHERE username_normalized = {username}`,
     78     { username: normalized }
     79   );
     80 }
     81 
     82 export async function findUserByIdentifier(db, identifier) {
     83   const value = String(identifier || '').trim();
     84   return value.includes('@')
     85     ? findUserByEmail(db, value)
     86     : findUserByUsername(db, value);
     87 }
     88 
     89 export async function getUserEmails(db, userId) {
     90   return db.all(
     91     `SELECT id, email, email_normalized, verified, is_primary, created_at, verified_at
     92        FROM user_emails
     93       WHERE user_id = {userId}
     94       ORDER BY is_primary DESC, created_at ASC`,
     95     { userId }
     96   );
     97 }
     98 
     99 export async function getPrimaryEmail(db, userId) {
    100   return db.get(
    101     `SELECT email, email_normalized, verified
    102        FROM user_emails
    103       WHERE user_id = {userId}
    104       ORDER BY is_primary DESC, created_at ASC
    105       LIMIT 1`,
    106     { userId }
    107   );
    108 }
    109 
    110 export async function addUserEmail(db, userId, email, { verified = false, primary = false } = {}) {
    111   const normalized = normalizeEmail(email);
    112   const existing = await db.get(
    113     'SELECT user_id FROM user_emails WHERE email_normalized = {email}',
    114     { email: normalized }
    115   );
    116   if (existing) throw new Error(`email is already in use: ${normalized}`);
    117 
    118   const id = crypto.randomUUID();
    119   const now = Date.now();
    120   await db.transaction(async (tx) => {
    121     if (primary) {
    122       await tx.run('UPDATE user_emails SET is_primary = 0 WHERE user_id = {userId}', { userId });
    123     }
    124     await tx.run(
    125       `INSERT INTO user_emails
    126         (id, user_id, email, email_normalized, verified, is_primary, created_at, verified_at)
    127        VALUES
    128         ({id}, {userId}, {email}, {normalized}, {verified}, {primary}, {createdAt}, {verifiedAt})`,
    129       {
    130         id,
    131         userId,
    132         email,
    133         normalized,
    134         verified: verified ? 1 : 0,
    135         primary: primary ? 1 : 0,
    136         createdAt: now,
    137         verifiedAt: verified ? now : null,
    138       }
    139     );
    140   });
    141   return id;
    142 }
    143 
    144 export async function setPasswordCredential(db, userId, secretHash) {
    145   const existing = await db.get(
    146     'SELECT id FROM credentials WHERE user_id = {userId} AND type = {type}',
    147     { userId, type: 'password' }
    148   );
    149   const now = Date.now();
    150   if (existing) {
    151     await db.run(
    152       `UPDATE credentials
    153           SET secret_hash = {secretHash}, updated_at = {now}
    154         WHERE id = {id}`,
    155       { id: existing.id, secretHash, now }
    156     );
    157     return existing.id;
    158   }
    159 
    160   const id = crypto.randomUUID();
    161   await db.run(
    162     `INSERT INTO credentials
    163       (id, user_id, type, secret_hash, created_at, updated_at)
    164      VALUES
    165       ({id}, {userId}, 'password', {secretHash}, {createdAt}, {updatedAt})`,
    166     { id, userId, secretHash, createdAt: now, updatedAt: now }
    167   );
    168   return id;
    169 }
    170 
    171 export async function getPasswordCredential(db, userId) {
    172   return db.get(
    173     `SELECT id, secret_hash
    174        FROM credentials
    175       WHERE user_id = {userId} AND type = 'password'`,
    176     { userId }
    177   );
    178 }
    179 
    180 export async function setSystemAdmin(db, userId, value = true) {
    181   await db.run(
    182     'UPDATE users SET is_system_admin = {value}, updated_at = {now} WHERE id = {userId}',
    183     { value: value ? 1 : 0, now: Date.now(), userId }
    184   );
    185 }
    186 
    187 export async function updateLastLogin(db, userId, now = Date.now()) {
    188   await db.run(
    189     'UPDATE users SET last_login_at = {now}, updated_at = {now} WHERE id = {userId}',
    190     { now, userId }
    191   );
    192 }
    193 
    194 export async function assignHomeRealmForEmail(db, userId, email) {
    195   const domain = emailDomain(email);
    196   if (!domain) return null;
    197 
    198   const owner = await db.get(
    199     `SELECT realm_id
    200        FROM realm_email_domains
    201       WHERE domain = {domain} AND verified = 1`,
    202     { domain }
    203   );
    204   if (!owner) return null;
    205 
    206   const user = await findUserById(db, userId);
    207   if (!user) throw new Error(`unknown user: ${userId}`);
    208   if (!user.home_realm_id) {
    209     await db.run(
    210       'UPDATE users SET home_realm_id = {realmId}, updated_at = {now} WHERE id = {userId}',
    211       { realmId: owner.realm_id, now: Date.now(), userId }
    212     );
    213     return owner.realm_id;
    214   }
    215   if (user.home_realm_id !== owner.realm_id) {
    216     throw new HttpError(409, 'home_realm_conflict', 'This email belongs to a different realm');
    217   }
    218   return user.home_realm_id;
    219 }
    220 
    221 export async function getMembership(db, userId) {
    222   return db.get(
    223     'SELECT user_id, status, created_at, updated_at FROM realm_memberships WHERE user_id = {userId}',
    224     { userId }
    225   );
    226 }
    227 
    228 export async function ensureMembership(db, userId, status = 'active') {
    229   const existing = await getMembership(db, userId);
    230   const now = Date.now();
    231   if (!existing) {
    232     await db.run(
    233       `INSERT INTO realm_memberships (user_id, status, created_at, updated_at)
    234        VALUES ({userId}, {status}, {createdAt}, {updatedAt})`,
    235       { userId, status, createdAt: now, updatedAt: now }
    236     );
    237     return;
    238   }
    239   if (existing.status !== status) {
    240     await db.run(
    241       'UPDATE realm_memberships SET status = {status}, updated_at = {now} WHERE user_id = {userId}',
    242       { status, now, userId }
    243     );
    244   }
    245 }
    246 
    247 export async function getUserRoles(db, userId) {
    248   const rows = await db.all(
    249     `SELECT r.slug
    250        FROM membership_roles mr
    251        JOIN roles r ON r.id = mr.role_id
    252       WHERE mr.user_id = {userId}
    253       ORDER BY r.slug ASC`,
    254     { userId }
    255   );
    256   return rows.map((row) => row.slug);
    257 }
    258 
    259 export async function assignRole(db, userId, roleSlug) {
    260   const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug: roleSlug });
    261   if (!role) throw new Error(`unknown role: ${roleSlug}`);
    262   const existing = await db.get(
    263     'SELECT user_id FROM membership_roles WHERE user_id = {userId} AND role_id = {roleId}',
    264     { userId, roleId: role.id }
    265   );
    266   if (existing) return;
    267   await db.run(
    268     'INSERT INTO membership_roles (user_id, role_id) VALUES ({userId}, {roleId})',
    269     { userId, roleId: role.id }
    270   );
    271 }