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 }