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 }