conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

users.js (8059B)


      1 // src/lib/users.js - built-in user accounts
      2 //
      3 // Used when OIDC is not configured. Passwords are stored as scrypt hashes
      4 // and never recoverable; a forgotten password is reset, not read.
      5 
      6 import { newUserId } from './ids.js';
      7 import { hashPassword, verifyPassword, generatePassword, validatePassword } from './auth/password.js';
      8 
      9 export const ROLES = ['admin', 'user'];
     10 
     11 // A provider may hand over anything as a display name, so it is reduced to
     12 // the character set local accounts already use.
     13 function sanitizeUsername(value) {
     14   return String(value ?? '')
     15     .toLowerCase()
     16     .replace(/[^a-z0-9_.@-]+/g, '-')
     17     .replace(/^[^a-z0-9]+|-+$/g, '')
     18     .slice(0, 48);
     19 }
     20 
     21 const PUBLIC_COLUMNS = 'id, username, role, disabled, external_id, created_at, last_login_at';
     22 
     23 // Stored in password_hash for accounts the identity provider owns. It does
     24 // not parse as a scrypt hash, so verifyPassword rejects it and such an
     25 // account can never be signed into with a password.
     26 export const EXTERNAL_MARKER = 'external';
     27 
     28 export function createUsers({ db, logger = console }) {
     29   return {
     30     async get(id) {
     31       return db.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = {id}`, { id });
     32     },
     33 
     34     async byUsername(username) {
     35       return db.get(
     36         `SELECT id, username, password_hash, role, disabled FROM users WHERE username = {username}`,
     37         { username }
     38       );
     39     },
     40 
     41     async byExternalId(externalId) {
     42       return db.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE external_id = {externalId}`, { externalId });
     43     },
     44 
     45     // Creates or refreshes the local row for someone the identity provider
     46     // vouched for. Without this an OIDC user could authenticate but own
     47     // nothing, because projects and worker tokens reference users(id).
     48     //
     49     // The provider is authoritative for the role, so it is written on every
     50     // call: losing a role there takes effect here on the next request.
     51     // Being disabled locally is not overwritten, which leaves an
     52     // administrator a way to lock someone out immediately.
     53     async upsertExternal({ externalId, username, role }) {
     54       if (!externalId) throw new Error('an external account needs a stable identifier');
     55       const desired = ROLES.includes(role) ? role : 'user';
     56 
     57       return db.transaction(async (tx) => {
     58         const existing = await tx.get(
     59           `SELECT ${PUBLIC_COLUMNS} FROM users WHERE external_id = {externalId}`,
     60           { externalId }
     61         );
     62 
     63         if (existing) {
     64           if (existing.role !== desired) {
     65             await tx.run('UPDATE users SET role = {role} WHERE id = {id}', { id: existing.id, role: desired });
     66           }
     67           await tx.run('UPDATE users SET last_login_at = {now} WHERE id = {id}',
     68             { id: existing.id, now: Date.now() });
     69           return { ...existing, role: desired };
     70         }
     71 
     72         // Display names come from the provider and are not guaranteed
     73         // unique, so a clash is resolved rather than allowed to fail.
     74         const base = sanitizeUsername(username) || 'user';
     75         let candidate = base;
     76         for (let i = 1; await tx.get('SELECT id FROM users WHERE username = {u}', { u: candidate }); i += 1) {
     77           candidate = `${base}-${i}`;
     78           if (i > 50) throw new Error(`could not find a free username for ${base}`);
     79         }
     80 
     81         const id = newUserId();
     82         await tx.run(
     83           `INSERT INTO users (id, username, password_hash, role, disabled, external_id, created_at, last_login_at)
     84            VALUES ({id}, {username}, {hash}, {role}, 0, {externalId}, {now}, {now})`,
     85           { id, username: candidate, hash: EXTERNAL_MARKER, role: desired, externalId, now: Date.now() }
     86         );
     87         return tx.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = {id}`, { id });
     88       });
     89     },
     90 
     91     async list() {
     92       return db.all(`SELECT ${PUBLIC_COLUMNS} FROM users ORDER BY username`);
     93     },
     94 
     95     // With what each account owns, since removing one takes those with it.
     96     async listWithHoldings() {
     97       return db.all(
     98         `SELECT u.id, u.username, u.role, u.disabled, u.external_id, u.created_at, u.last_login_at,
     99                 (SELECT COUNT(*) FROM projects p WHERE p.owner_id = u.id) AS project_count,
    100                 (SELECT COUNT(*) FROM worker_tokens w WHERE w.owner_id = u.id) AS worker_count
    101            FROM users u ORDER BY u.username`
    102       );
    103     },
    104 
    105     async count() {
    106       return (await db.get('SELECT COUNT(*) AS c FROM users')).c;
    107     },
    108 
    109     async create({ username, password, role = 'user' }) {
    110       if (typeof username !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.@-]{1,63}$/.test(username)) {
    111         throw new Error('username must be 2 to 64 characters of letters, digits, underscore, dot, at or hyphen');
    112       }
    113       if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
    114 
    115       const problem = validatePassword(password);
    116       if (problem) throw new Error(problem);
    117 
    118       if (await this.byUsername(username)) throw new Error(`user ${username} already exists`);
    119 
    120       const id = newUserId();
    121       await db.run(
    122         `INSERT INTO users (id, username, password_hash, role, disabled, created_at)
    123          VALUES ({id}, {username}, {hash}, {role}, 0, {now})`,
    124         { id, username, hash: await hashPassword(password), role, now: Date.now() }
    125       );
    126       return this.get(id);
    127     },
    128 
    129     // Returns the user on success, or null. Deliberately gives the caller
    130     // no way to tell an unknown user from a wrong password.
    131     async authenticate(username, password) {
    132       const row = await this.byUsername(username);
    133       if (!row || row.disabled === 1) {
    134         // Spend the time anyway, so a missing user is not measurably faster.
    135         await verifyPassword(password, 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA');
    136         return null;
    137       }
    138       if (!(await verifyPassword(password, row.password_hash))) return null;
    139 
    140       await db.run('UPDATE users SET last_login_at = {now} WHERE id = {id}', { id: row.id, now: Date.now() });
    141       return { id: row.id, username: row.username, role: row.role, source: 'local' };
    142     },
    143 
    144     async setPassword(id, password) {
    145       const problem = validatePassword(password);
    146       if (problem) throw new Error(problem);
    147       const res = await db.run(
    148         'UPDATE users SET password_hash = {hash} WHERE id = {id}',
    149         { id, hash: await hashPassword(password) }
    150       );
    151       return res.changes > 0;
    152     },
    153 
    154     async setRole(id, role) {
    155       if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
    156       const res = await db.run('UPDATE users SET role = {role} WHERE id = {id}', { id, role });
    157       return res.changes > 0;
    158     },
    159 
    160     async setDisabled(id, disabled) {
    161       const res = await db.run(
    162         'UPDATE users SET disabled = {disabled} WHERE id = {id}',
    163         { id, disabled: disabled ? 1 : 0 }
    164       );
    165       return res.changes > 0;
    166     },
    167 
    168     async remove(id) {
    169       const res = await db.run('DELETE FROM users WHERE id = {id}', { id });
    170       return res.changes > 0;
    171     },
    172 
    173     // Creates the first administrator, once, while the table is empty. With
    174     // no password configured one is generated and printed, because an
    175     // install that silently has no way in is worse than a noisy log line.
    176     async bootstrap(cfg) {
    177       if (await this.count() > 0) return null;
    178 
    179       const username = cfg.auth.bootstrap_admin.username || 'admin';
    180       const configured = cfg.auth.bootstrap_admin.password;
    181       const password = configured || generatePassword();
    182 
    183       const user = await this.create({ username, password, role: 'admin' });
    184 
    185       if (configured) {
    186         logger.info?.(`created the initial administrator ${username} from configuration`);
    187       } else {
    188         logger.warn?.(
    189           `created the initial administrator ${username} with a generated password: ${password}\n` +
    190           'This is shown once. Change it, or set auth.bootstrap_admin.password.'
    191         );
    192       }
    193       return { ...user, password: configured ? null : password };
    194     },
    195   };
    196 }