conductor

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

users.js (9266B)


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