main
ts 252 lines 9.5 KB
Raw
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 }