| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import _ from 'lodash' |
| 4 | import { objRenameKey, setHidden, typedEntries, wantArray } from './misc' |
| 5 | import { defineConfig, saveConfigAsap } from './config' |
| 6 | import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a' |
| 7 | import events from './events' |
| 8 | import { getCurrentUsername } from './auth' |
| 9 | import Koa from 'koa' |
| 10 | |
| 11 | // for all the Account fields, falsy values must be equivalent to undefined. If this changes in the future, please adjust addAccount and setAccount |
| 12 | export interface Account { |
| 13 | username: string, // we keep username property for convenience, but hidden as we don't persist it inside the object, but as key of the accounts map |
| 14 | password?: string |
| 15 | srp?: string |
| 16 | belongs?: string[] |
| 17 | ignore_limits?: boolean |
| 18 | disable_password_change?: boolean |
| 19 | admin?: boolean |
| 20 | redirect?: string |
| 21 | disabled?: boolean |
| 22 | expire?: Date |
| 23 | days_to_live?: number // this is not inherited, but it will affect sub-accounts via 'expire' |
| 24 | allow_net?: string |
| 25 | auto_login_net?: string |
| 26 | require_password_change?: boolean // not inherited |
| 27 | notes?: string |
| 28 | plugin?: { id?: string, auth?: boolean, [rest: string]: unknown } |
| 29 | } |
| 30 | interface Accounts { [username:string]: Account } |
| 31 | |
| 32 | // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions |
| 33 | export function expandUsername(who: string) { |
| 34 | const ret = new Set<string>() |
| 35 | const q = [who] |
| 36 | for (const u of q) { |
| 37 | const a = getAccount(u) |
| 38 | if (!a || a.disabled) continue |
| 39 | ret.add(u) |
| 40 | if (a.belongs) |
| 41 | q.push(...a.belongs) |
| 42 | } |
| 43 | return ret |
| 44 | } |
| 45 | |
| 46 | // check if current username or any ancestor match the provided usernames |
| 47 | export function ctxBelongsTo(ctx: Koa.Context, usernames: string[]) { |
| 48 | const s = ctx.state.usernames ||= expandUsername(getCurrentUsername(ctx)) |
| 49 | return usernames.some(u => s.has(u)) // cache ancestors' usernames inside context state |
| 50 | } |
| 51 | |
| 52 | export function getUsernames() { |
| 53 | return Object.keys(accounts.get()) |
| 54 | } |
| 55 | |
| 56 | export function getAccount(username:string, normalize=true) : Account | undefined { |
| 57 | if (normalize) |
| 58 | username = normalizeUsername(username) |
| 59 | return username ? accounts.get()[username] : undefined |
| 60 | } |
| 61 | |
| 62 | export function saveSrpInfo(account:Account, salt:string | bigint, verifier: string | bigint) { |
| 63 | account.srp = String(salt) + '|' + String(verifier) |
| 64 | } |
| 65 | |
| 66 | const createAdminConfig = defineConfig('create-admin', '') |
| 67 | createAdminConfig.sub(v => { |
| 68 | if (!v) return |
| 69 | createAdminConfig.set('') |
| 70 | // we can't createAdmin right away, as its changes will be lost after return, when our caller (setConfig) applies undefined properties. setTimeout is good enough, as the process is sync. |
| 71 | setTimeout(() => createAdmin(v)) |
| 72 | }) |
| 73 | |
| 74 | export async function createAdmin(password: string, username='admin') { |
| 75 | const acc = await addAccount(username, { admin: true, password }, true) |
| 76 | console.log(acc ? "Account admin set" : "Something went wrong") |
| 77 | } |
| 78 | |
| 79 | const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters()) |
| 80 | |
| 81 | type Changer = (account:Account)=> void | Promise<void> |
| 82 | export async function updateAccount(account: Account, change: Partial<Account> | Changer) { |
| 83 | const jsonWas = JSON.stringify(account) |
| 84 | const { username: usernameWas } = account |
| 85 | if (typeof change === 'function') |
| 86 | await change?.(account) |
| 87 | else { |
| 88 | const u = normalizeUsername(change.username || '') |
| 89 | if (u && u !== usernameWas && getAccount(u)) |
| 90 | throw "username already exists" |
| 91 | Object.assign(account, _.mapValues(change, x => x || undefined)) |
| 92 | } |
| 93 | for (const [k,v] of typedEntries(account)) |
| 94 | if (!v) delete account[k] // we consider all account fields, when falsy, as equivalent to be missing (so, default value applies) |
| 95 | const { username, password } = account |
| 96 | if (password) { |
| 97 | console.debug('Hashing password for', username) |
| 98 | delete account.password |
| 99 | const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, password) |
| 100 | saveSrpInfo(account, res.s, res.v) |
| 101 | } |
| 102 | if (account.belongs) { |
| 103 | account.belongs = wantArray(account.belongs) |
| 104 | _.remove(account.belongs, b => { |
| 105 | if (accounts.get().hasOwnProperty(b)) return |
| 106 | console.error(`Account ${username} belongs to non-existing ${b}`) |
| 107 | return true |
| 108 | }) |
| 109 | if (!account.belongs.length) |
| 110 | delete account.belongs |
| 111 | } |
| 112 | account.expire &&= new Date(account.expire) |
| 113 | if (username !== usernameWas) |
| 114 | renameAccount(usernameWas, username) |
| 115 | if (jsonWas !== JSON.stringify(account)) // this test will miss the 'username' field, because hidden, but renameAccount is already calling saveAccountsASAP |
| 116 | saveAccountsAsap() |
| 117 | } |
| 118 | |
| 119 | const saveAccountsAsap = saveConfigAsap |
| 120 | |
| 121 | export const accounts = defineConfig('accounts', {} as Accounts) |
| 122 | accounts.sub(_.debounce(obj => { |
| 123 | // consider some validation here, in case of manual edit of the config |
| 124 | _.each(obj, (rec,k) => { |
| 125 | const norm = normalizeUsername(k) |
| 126 | if (rec?.username !== norm) { |
| 127 | if (!rec) // an empty object in yaml is parsed as null |
| 128 | rec = obj[norm] = { username: norm } |
| 129 | else if (objRenameKey(obj, k, norm)) |
| 130 | saveAccountsAsap() |
| 131 | setHidden(rec, { username: norm }) |
| 132 | } |
| 133 | void updateAccount(rec, {}) // work fields |
| 134 | removeLoops(norm) |
| 135 | }) |
| 136 | |
| 137 | function removeLoops(normalizedUsername: string, visiting = new Set<string>()) { |
| 138 | if (visiting.has(normalizedUsername)) |
| 139 | return |
| 140 | visiting.add(normalizedUsername) |
| 141 | const account = obj[normalizedUsername] |
| 142 | const removed = _.remove(account.belongs, parent => { |
| 143 | const k = normalizeUsername(parent) |
| 144 | return obj[k] && visiting.has(k) |
| 145 | }) |
| 146 | if (removed.length) |
| 147 | saveAccountsAsap() |
| 148 | if (account?.belongs?.length) { |
| 149 | for (const parent of account.belongs) { |
| 150 | const k = normalizeUsername(parent) |
| 151 | if (obj[k]) |
| 152 | removeLoops(k, visiting) |
| 153 | } |
| 154 | if (!account.belongs.length) |
| 155 | delete account.belongs |
| 156 | } |
| 157 | visiting.delete(normalizedUsername) |
| 158 | } |
| 159 | })) // don't trigger in the middle of a series of deletion, as we may have an inconsistent state |
| 160 | |
| 161 | export function normalizeUsername(username: string) { |
| 162 | return username.toLocaleLowerCase() |
| 163 | } |
| 164 | |
| 165 | export function renameAccount(from: string, to: string) { |
| 166 | from = normalizeUsername(from) |
| 167 | const as = accounts.get() |
| 168 | to = normalizeUsername(to) |
| 169 | if (!to || !as[from] || as[to]) |
| 170 | return false |
| 171 | if (to === from) |
| 172 | return true |
| 173 | objRenameKey(as, from, to) |
| 174 | setHidden(as[to], { username: to }) |
| 175 | // update references |
| 176 | for (const a of Object.values(as)) { |
| 177 | const idx = a.belongs?.indexOf(from) |
| 178 | if (idx !== undefined && idx >= 0) |
| 179 | a.belongs![idx] = to |
| 180 | } |
| 181 | accounts.set(as) |
| 182 | events.emit('accountRenamed', { from, to }) // everybody, take care of your stuff |
| 183 | saveAccountsAsap() |
| 184 | return true |
| 185 | } |
| 186 | |
| 187 | export function addAccount(username: string, props: Partial<Account>, updateExisting=false) { |
| 188 | username = normalizeUsername(username) |
| 189 | if (!username) return |
| 190 | let account = getAccount(username, false) |
| 191 | if (account && !updateExisting) return |
| 192 | account = setHidden(account || {}, { username }) // hidden so that stringification won't include it |
| 193 | Object.assign(account, _.pickBy(props, Boolean)) |
| 194 | accounts.set(was => |
| 195 | Object.assign(was, { [username]: account })) |
| 196 | return updateAccount(account, account).then(() => account!) |
| 197 | } |
| 198 | |
| 199 | export function delAccount(username: string) { |
| 200 | if (!getAccount(username)) |
| 201 | return false |
| 202 | accounts.set(was => _.omit(was, normalizeUsername(username)) ) |
| 203 | saveAccountsAsap() |
| 204 | return true |
| 205 | } |
| 206 | |
| 207 | // get some property from account, searching in its groups if necessary. Search is breadth-first, and this determines priority of inheritance. |
| 208 | export function getFromAccount<T=any>(account: Account | string, getter:(a:Account) => T) { |
| 209 | const search = [account] |
| 210 | for (const accountOrUsername of search) { |
| 211 | const a = typeof accountOrUsername === 'string' ? getAccount(accountOrUsername) : accountOrUsername |
| 212 | if (!a) continue |
| 213 | const res = getter(a) |
| 214 | if (res !== undefined) |
| 215 | return res |
| 216 | if (a.belongs) |
| 217 | search.push(...a.belongs) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | export function accountHasPassword(account: Account) { |
| 222 | return Boolean(account.password || account.srp) |
| 223 | } |
| 224 | |
| 225 | export function accountHasLoginMethod(account: Account) { |
| 226 | return Boolean(accountHasPassword(account) || account.plugin?.auth || account.auto_login_net) |
| 227 | } |
| 228 | |
| 229 | export function accountCanLogin(account: Account) { |
| 230 | return accountHasLoginMethod(account) && !accountIsDisabled(account) |
| 231 | } |
| 232 | |
| 233 | export function accountIsDisabled(account: Account): boolean { |
| 234 | return Boolean(account.disabled |
| 235 | || account.expire as any < Date.now() |
| 236 | || account.belongs?.length // don't every() on empty array, as it returns true |
| 237 | && account.belongs.map(u => getAccount(u, false)).every(a => a && accountIsDisabled(a)) ) |
| 238 | } |
| 239 | |
| 240 | export function accountCanLoginAdmin(account: Account) { |
| 241 | return accountCanLogin(account) && getFromAccount(account, a => a.admin) || false |
| 242 | } |
| 243 | |
| 244 | export function accountCanChangePassword(account: Account | undefined) { |
| 245 | return account && !getFromAccount(account, a => a.disable_password_change) |
| 246 | } |
| 247 | |
| 248 | declare module "koa" { |
| 249 | interface DefaultState { |
| 250 | usernames?: Set<string> |
| 251 | } |
| 252 | } |