@bitfinex/bfx-facs-auth-google 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,896 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const fs = require('fs').promises
5
+ const assert = require('assert')
6
+ const _ = require('lodash')
7
+ const async = require('async')
8
+ const crypto = require('crypto')
9
+ const DbBase = require('bfx-facs-db-sqlite')
10
+ const uuidv4 = require('uuid/v4')
11
+ const { google } = require('googleapis')
12
+ const { UserError } = require('./errors')
13
+ const migrations = require('./migrations')
14
+ const { cloneDeep } = require('@bitfinexcom/lib-js-util-base')
15
+
16
+ const FORMS_FIELD = 'forms'
17
+
18
+ async function hash (password, salt = '') {
19
+ return new Promise((resolve, reject) => {
20
+ const computedSalt = salt || crypto.randomBytes(8).toString('hex')
21
+
22
+ crypto.scrypt(password, computedSalt, 64, (err, derivedKey) => {
23
+ if (err) reject(err)
24
+ resolve(computedSalt + ':' + derivedKey.toString('hex'))
25
+ })
26
+ })
27
+ }
28
+
29
+ async function verify (password, hash) {
30
+ return new Promise((resolve, reject) => {
31
+ const [salt, key] = hash.split(':')
32
+ crypto.scrypt(password, salt, 64, (err, derivedKey) => {
33
+ if (err) reject(err)
34
+ resolve(key === derivedKey.toString('hex'))
35
+ })
36
+ })
37
+ }
38
+
39
+ const tableName = 'admin_users'
40
+ /**
41
+ * @typedef {{
42
+ * email: string,
43
+ * level: number,
44
+ * readOnly?: boolean,
45
+ * blockPrivilege?: boolean,
46
+ * analyticsPrivilege?: boolean,
47
+ * manageAdminsPrivilege?: boolean,
48
+ * fetchMotivationsPrivilege?: boolean,
49
+ * passwordResetToken?: string,
50
+ * passwordResetSentAt?: Date,
51
+ * company?: string,
52
+ * forms?: string[]
53
+ * }} BaseAdminT
54
+ * @typedef { BaseAdminT & { password: string }} AddAdminT
55
+ * @typedef { BaseAdminT & {
56
+ * active: boolean,
57
+ * id: number
58
+ * }} AddedAdminT
59
+ * @typedef {{ username: string, password: string }} LoginUserT
60
+ * @typedef {{
61
+ * access_token: string | null;
62
+ * token_type: string | null;
63
+ * expiry_date: string | null;
64
+ * }} Credentials
65
+ * @typedef {{
66
+ * access_token: string | null;
67
+ * token_type: string | null;
68
+ * expiry_date: number | null;
69
+ * refresh_token?: string | null;
70
+ * id_token?: string | null;
71
+ * scope?: string;
72
+ * }} TokenCredentials
73
+ * @typedef { AddedAdminT & {
74
+ * username: string,
75
+ * token: string,
76
+ * expires_at: Date
77
+ * }} LoginResp
78
+ */
79
+ class GoogleAuth extends DbBase {
80
+ constructor (caller, opts = {}, ctx) {
81
+ opts.name = 'auth-google'
82
+ opts.runSqlAtStart = [
83
+ `CREATE TABLE IF NOT EXISTS ${tableName} (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ email TEXT UNIQUE NOT NULL,
86
+ password TEXT,
87
+ level INTEGER NOT NULL,
88
+ active TINYINTEGER DEFAULT 1,
89
+ readOnly TINYINTEGER,
90
+ blockPrivilege TINYINTEGER,
91
+ analyticsPrivilege TINYINTEGER,
92
+ manageAdminsPrivilege TINYINTEGER,
93
+ passwordResetToken TEXT,
94
+ passwordResetSentAt DATETIME,
95
+ company TEXT,
96
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
97
+ ${FORMS_FIELD} TEXT
98
+ )`,
99
+ `CREATE UNIQUE INDEX IF NOT EXISTS uidx_email ON ${tableName}(email ASC)`
100
+ ]
101
+ super(caller, opts, ctx)
102
+
103
+ this.name = 'auth-google'
104
+ this._hasConf = true
105
+ this.useRedis = opts.useRedis || false
106
+ this.mongoFac = opts.mongoFac || caller.dbMongo_m0
107
+ this.redisFac = opts.redisFac || caller.redis_gc0
108
+
109
+ this.init()
110
+
111
+ if (opts.conf) this.conf = opts.conf
112
+ this.checkAdmAccessLevel = this.checkAdmAccessLevel.bind(this)
113
+ }
114
+
115
+ /**
116
+ * Get all configured Google client entries (web client + mobile clients)
117
+ * @returns {{ entries: Object }}
118
+ */
119
+ _googleClientEntries () {
120
+ const { google = {} } = this.conf
121
+ const entries = {}
122
+
123
+ // Web client config (backward compatibility - single client setup)
124
+ if (google.clientId) {
125
+ entries.webClient = {
126
+ clientId: google.clientId,
127
+ clientSecret: google.clientSecret,
128
+ redirectUris: google.redirectUris
129
+ }
130
+ }
131
+
132
+ // Mobile clients config
133
+ if (google.mobile && typeof google.mobile === 'object') {
134
+ Object.entries(google.mobile).forEach(([key, value]) => {
135
+ if (value && value.clientId) {
136
+ entries[key] = value
137
+ }
138
+ })
139
+ }
140
+
141
+ return {
142
+ entries
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Get all configured client IDs for token audience validation
148
+ * @returns { string[] }
149
+ */
150
+ _googleClientIds () {
151
+ const { entries } = this._googleClientEntries()
152
+ return Object.values(entries)
153
+ .map(c => c.clientId)
154
+ .filter(Boolean)
155
+ }
156
+
157
+ /**
158
+ * Resolve which Google client config to use based on clientKey hint and/or token audience
159
+ * Security: Token audience (aud) is the source of truth - clientKey is just a hint
160
+ * @param {Object} opts
161
+ * @param {string|undefined} opts.clientKey - Optional hint from frontend (not trusted alone)
162
+ * @param {string|undefined} opts.tokenAud - Token audience from verified Google ID token (trusted)
163
+ * @returns {Object} Client config with clientId, clientSecret, redirectUris
164
+ * @throws {Error} AUTH_FAC_INVALID_GOOGLE_CLIENT if client not found or mismatch
165
+ */
166
+ _resolveGoogleClient ({ clientKey, tokenAud } = {}) {
167
+ const { entries } = this._googleClientEntries()
168
+
169
+ // Step 1: If we have token audience, use it as source of truth (most secure)
170
+ if (tokenAud) {
171
+ const matchedByAud = Object.keys(entries).find(key => entries[key]?.clientId === tokenAud)
172
+
173
+ if (!matchedByAud) {
174
+ throw new Error('AUTH_FAC_INVALID_GOOGLE_CLIENT')
175
+ }
176
+
177
+ // If clientKey was provided, verify it matches the token's audience
178
+ if (clientKey && clientKey !== matchedByAud) {
179
+ throw new Error('AUTH_FAC_INVALID_GOOGLE_CLIENT')
180
+ }
181
+
182
+ return entries[matchedByAud]
183
+ }
184
+
185
+ // Step 2: If no token audience, use clientKey hint (for OAuth code exchange flows)
186
+ if (clientKey) {
187
+ if (!entries[clientKey]) {
188
+ throw new Error('AUTH_FAC_INVALID_GOOGLE_CLIENT')
189
+ }
190
+ return entries[clientKey]
191
+ }
192
+
193
+ // Step 3: Fallback to main client (backward compatibility)
194
+ if (entries.webClient) {
195
+ return entries.webClient
196
+ }
197
+
198
+ throw new Error('AUTH_FAC_INVALID_GOOGLE_CLIENT')
199
+ }
200
+
201
+ _start (cb) {
202
+ if (!this.conf.useDB) {
203
+ return cb()
204
+ }
205
+
206
+ async.series([
207
+ async () => {
208
+ if (this.conf.useDB) {
209
+ const db = this.opts.db
210
+ const dbDir = path.dirname(db)
211
+ try {
212
+ await fs.access(dbDir)
213
+ } catch (err) {
214
+ if (err && err.code === 'ENOENT') {
215
+ await fs.mkdir(dbDir)
216
+ }
217
+ }
218
+ }
219
+ },
220
+ super._start.bind(this),
221
+ cb => {
222
+ this.runMigrations(migrations, cb)
223
+ },
224
+ async () => {
225
+ await this._saveAdminsFromConfig()
226
+ }
227
+ ], cb)
228
+ }
229
+
230
+ _stop (cb) {
231
+ if (!this.conf.useDB) {
232
+ return cb()
233
+ }
234
+
235
+ super._stop(cb)
236
+ }
237
+
238
+ /**
239
+ * @param {{ user: LoginUserT, google: Credentials, ip: number }} args
240
+ * @param { (err: null|Error, res: LoginResp) => void } cb
241
+ * @returns { void }
242
+ */
243
+ loginAdmin (args, cb) {
244
+ const { user, google, ip } = args
245
+
246
+ if (!user && !google) {
247
+ return cb(new Error('AUTH_FAC_LOGIN_KEYS_MISSING'))
248
+ }
249
+
250
+ const complete = (user)
251
+ ? ['username', 'password'].every(k => k in user)
252
+ : ['access_token', 'token_type', 'expiry_date'].every(k => k in google) ||
253
+ ['credential'].every(k => k in google)
254
+ if (!complete) return cb(new Error('AUTH_FAC_LOGIN_KEYS_MISSING'))
255
+
256
+ return (user)
257
+ ? this._loginAdminPass(user, ip, cb)
258
+ : this._loginAdminGoogle(google, ip, cb)
259
+ }
260
+
261
+ async _loginAdminPass (params, ip, cb) {
262
+ const {
263
+ valid, level, extra
264
+ } = await this.basicAuthAdmLogCheck(params.username, params.password)
265
+
266
+ return (valid)
267
+ ? this._createAdminToken(params.username, ip, level, extra, cb)
268
+ : cb(new Error('AUTH_FAC_LOGIN_INCORRECT_USERNAME_PASSWORD'))
269
+ }
270
+
271
+ /**
272
+ * this is used to validate code generated from google sso to fetch access token, id token
273
+ * we use id token to get email and validate
274
+ * @param {string} code
275
+ * @param {string} redirectUriKey - the redirect uri key to use for the google client
276
+ * @param {string|undefined} clientKey - optional client key to use for the google client
277
+ * @returns {Promise<TokenCredentials>}
278
+ */
279
+ async getTokensFromCode (code, redirectUriKey, clientKey) {
280
+ const oAuth2Client = this._getOAuth2Client({ clientKey, redirectUriKey })
281
+ const { tokens } = await oAuth2Client.getToken(code)
282
+ return tokens
283
+ }
284
+
285
+ async _loginAdminGoogle (params, ip, cb) {
286
+ const { clientKey } = params || {}
287
+ try {
288
+ const email = await this.googleEmailFromToken(params, clientKey)
289
+ const { valid, level, extra } = await this._validAdminUserGoogleEmail(email)
290
+ return (valid)
291
+ ? this._createAdminToken(email, ip, level, extra, cb)
292
+ : cb(new Error('AUTH_FAC_ACCOUNT_IS_NOT_VALID'))
293
+ } catch (e) {
294
+ console.log(e)
295
+ cb(new Error('AUTH_FAC_INCORRECT_GOOGLE_TOKEN'))
296
+ }
297
+ }
298
+
299
+ async _createAdminToken (user, ip, level, extra = {}, cb) {
300
+ const username = user
301
+ const token = 'ADM-' + uuidv4()
302
+ const exp = new Date()
303
+ exp.setHours(exp.getHours() + 8)
304
+ const query = { username, token, ip, level, expires_at: exp }
305
+ try {
306
+ await this._createUniqueAndExpireDbToken(query)
307
+ return cb(null, { username, token, level, ...extra, expires_at: exp })
308
+ } catch (e) {
309
+ return cb(new Error('AUTH_FAC_ADMIN_TOKEN_CREATE_ERROR'))
310
+ }
311
+ }
312
+
313
+ _tokenKey (query) {
314
+ return `adminTokens:${query.token}:${query.ip}`
315
+ }
316
+
317
+ _createUniqueAndExpireDbToken (query) {
318
+ if (!this.useRedis) { // mongodb
319
+ const mc = this.mongoFac.db
320
+ const collection = 'adminTokens'
321
+ return new Promise((resolve, reject) => {
322
+ mc.collection(collection)
323
+ .insertOne(query, (err, res) => {
324
+ if (err) return reject(err)
325
+ resolve()
326
+ })
327
+ })
328
+ } else { // redis
329
+ const key = this._tokenKey(query)
330
+ const expires_at = (query.expires_at - new Date()) / 1000 // eslint-disable-line camelcase
331
+ return new Promise((resolve, reject) => {
332
+ this.redisFac.cli_rw.multi([
333
+ ['set', key, JSON.stringify(query)],
334
+ ['expire', key, expires_at] // eslint-disable-line camelcase
335
+ ]).exec((err, result) => {
336
+ if (err) return reject(err)
337
+ resolve()
338
+ })
339
+ })
340
+ }
341
+ }
342
+
343
+ async _validAdminUserGoogleEmail (mail) {
344
+ return this._whiteListEmail(mail)
345
+ }
346
+
347
+ preAdminTokenCheck (authToken) {
348
+ return (authToken && authToken.length === 2 && authToken[0])
349
+ ? authToken[0].startsWith('ADM')
350
+ : false
351
+ }
352
+
353
+ async checkAdminRedis (authToken, level = 0) {
354
+ const preCheck = this.preAdminTokenCheck(authToken)
355
+ if (!preCheck) return false
356
+ const token = authToken[0]
357
+ const ip = authToken[1].ip
358
+ const key = this._tokenKey({ token, ip })
359
+ const json = await this.redisFac.cli_rw.get(key)
360
+ const data = JSON.parse(json)
361
+ return data && this.checkAdmAccessLevel(data.username, level)
362
+ }
363
+
364
+ /**
365
+ * Create OAuth2 client for Google authentication
366
+ * Note: redirectUris are optional for mobile clients (they use ID tokens, not OAuth code flow)
367
+ * @param {Object} opts
368
+ * @param {string|undefined} opts.clientKey - Client key hint
369
+ * @param {string|undefined} opts.redirectUriKey - Redirect URI key (e.g., 'ssoAuth') - only needed for web OAuth flows
370
+ * @param {string|undefined} opts.tokenAud - Token audience for validation
371
+ * @returns {google.auth.OAuth2}
372
+ */
373
+ _getOAuth2Client ({ clientKey, redirectUriKey, tokenAud } = {}) {
374
+ const { clientId, clientSecret, redirectUris } = this._resolveGoogleClient({
375
+ clientKey,
376
+ tokenAud
377
+ })
378
+
379
+ // redirectUri is only needed for OAuth code exchange flows (web apps)
380
+ // Mobile apps don't need it since they use ID tokens directly
381
+ const redirectUri = redirectUriKey ? redirectUris?.[redirectUriKey] : undefined
382
+
383
+ return new google.auth.OAuth2(
384
+ clientId,
385
+ clientSecret,
386
+ redirectUri
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Get user info from Google token (ID token or OAuth access token)
392
+ * For mobile apps: Uses ID token (credential) - validates audience automatically
393
+ * For web apps: Uses OAuth access token - requires clientKey for client selection
394
+ * @param {{ credential: string, access_token: string, token_type: string, expires_in: number, id_token: string, scope: string }} payload
395
+ * @param {string|undefined} clientKey - Optional hint for client selection (validated against token audience)
396
+ * @returns { Promise<Object> }
397
+ */
398
+ async googleUserInfoFromToken (payload, clientKey = undefined) {
399
+ // Mobile apps: ID token flow (credential field)
400
+ if (payload?.credential) {
401
+ const allowedAudiences = this._googleClientIds()
402
+ const verifier = new google.auth.OAuth2()
403
+
404
+ // Verify ID token signature and validate audience
405
+ const ticket = await verifier.verifyIdToken({
406
+ idToken: payload.credential,
407
+ ...(allowedAudiences.length > 0 && { audience: allowedAudiences })
408
+ })
409
+
410
+ const tokenPayload = ticket.getPayload()
411
+
412
+ if (!tokenPayload?.aud) {
413
+ throw new Error('AUTH_FAC_INVALID_GOOGLE_TOKEN')
414
+ }
415
+
416
+ // Validate that token audience matches a configured client
417
+ // Also verify clientKey hint matches if provided
418
+ this._resolveGoogleClient({
419
+ clientKey,
420
+ tokenAud: tokenPayload.aud
421
+ })
422
+
423
+ return tokenPayload
424
+ }
425
+
426
+ // Web apps: OAuth access token flow
427
+ const oAuth2Client = this._getOAuth2Client({ clientKey })
428
+ oAuth2Client.setCredentials(payload)
429
+ const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client })
430
+
431
+ try {
432
+ const userInfo = await oauth2.userinfo.get()
433
+ return userInfo?.data
434
+ } catch (error) {
435
+ throw new Error('AUTH_FAC_ERROR_ASK_EMAIL:' + error.toString())
436
+ }
437
+ }
438
+
439
+ /**
440
+ * returns the email from the google token based on the payload and scope at least email
441
+ * @param {{ credential: string, access_token: string, token_type: string, expires_in: number, id_token: string, scope: string }} payload
442
+ * @param {string|undefined} clientKey - optional client key to use for the google client
443
+ * @returns { Promise<string> }
444
+ */
445
+ async googleEmailFromToken (payload, clientKey = undefined) {
446
+ const userInfo = await this.googleUserInfoFromToken(payload, clientKey)
447
+ return userInfo?.email
448
+ }
449
+
450
+ async _whiteListEmail (sentEmail) {
451
+ const admin = await this._getAdmin(sentEmail)
452
+
453
+ if (!admin) return { valid: false }
454
+
455
+ const { email, password, level, ...extra } = admin
456
+
457
+ return {
458
+ valid: true,
459
+ level,
460
+ extra
461
+ }
462
+ }
463
+
464
+ async _saveAdminsFromConfig () {
465
+ const admins = this.conf.ADM_USERS
466
+ if (!(admins && Array.isArray(admins))) return true
467
+ const adminDbHasData = await this._checkIfAdminDbHasData()
468
+ if (adminDbHasData) return true
469
+
470
+ const tasks = admins.map(async (admin) => {
471
+ const { email } = admin
472
+ const adm = await this._getAdmin(email, false)
473
+ if (adm) return adm
474
+
475
+ return this.addAdmin(cloneDeep(admin))
476
+ })
477
+
478
+ await Promise.all(tasks)
479
+ }
480
+
481
+ /**
482
+ * @param { AddAdminT } user
483
+ * @returns { Promise<AddedAdminT> }
484
+ */
485
+ async addAdmin (user) {
486
+ assert.ok(this.conf.useDB, 'Cannot add admins if DB is not available')
487
+
488
+ const {
489
+ email,
490
+ password,
491
+ level,
492
+ readOnly,
493
+ blockPrivilege,
494
+ analyticsPrivilege,
495
+ manageAdminsPrivilege,
496
+ fetchMotivationsPrivilege,
497
+ company
498
+ } = user
499
+
500
+ assert.ok(typeof email === 'string', 'Email is required')
501
+ assert.ok(typeof level === 'number', 'Level must be a number')
502
+
503
+ if (password) {
504
+ assert.ok(typeof password === 'string', 'Password should be a string')
505
+ }
506
+
507
+ if (readOnly) {
508
+ assert.ok(typeof readOnly === 'boolean', 'readOnly should be a boolean')
509
+ }
510
+
511
+ if (blockPrivilege) {
512
+ assert.ok(typeof blockPrivilege === 'boolean', 'blockPrivilege should be a boolean')
513
+ }
514
+
515
+ if (analyticsPrivilege) {
516
+ assert.ok(typeof analyticsPrivilege === 'boolean', 'analyticsPrivilege should be a boolean')
517
+ }
518
+
519
+ if (manageAdminsPrivilege) {
520
+ assert.ok(typeof manageAdminsPrivilege === 'boolean', 'manageAdminsPrivilege should be a boolean')
521
+ }
522
+
523
+ if (fetchMotivationsPrivilege) {
524
+ assert.ok(typeof fetchMotivationsPrivilege === 'boolean', 'fetchMotivationsPrivilege should be a boolean')
525
+ }
526
+
527
+ if (company) {
528
+ assert.ok(typeof company === 'string', 'company should be a string')
529
+ }
530
+
531
+ const adm = await this._getAdmin(email, false)
532
+ if (adm) throw new UserError('ADMIN_ACCOUNT_EXISTS')
533
+
534
+ const hashedPassword = password
535
+ ? await hash(password, this.conf.hashSalt)
536
+ : null
537
+
538
+ user.password = hashedPassword
539
+
540
+ return new Promise((resolve, reject) => {
541
+ const keys = Object.keys(user)
542
+
543
+ this.db.run(
544
+ `INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${Array(keys.length).fill('?').join(', ')})`,
545
+ keys.map(key => key === FORMS_FIELD ? JSON.stringify(user[key]) : user[key]),
546
+ function (err) {
547
+ if (err) return reject(err)
548
+
549
+ resolve({
550
+ email,
551
+ level,
552
+ readOnly,
553
+ blockPrivilege,
554
+ analyticsPrivilege,
555
+ manageAdminsPrivilege,
556
+ fetchMotivationsPrivilege,
557
+ company,
558
+ active: true,
559
+ id: this.lastID
560
+ })
561
+ }
562
+ )
563
+ })
564
+ }
565
+
566
+ async updateAdmin (email, user) {
567
+ assert.ok(this.conf.useDB, 'Cannot add admins if DB is not available')
568
+
569
+ const {
570
+ password,
571
+ level,
572
+ readOnly,
573
+ blockPrivilege,
574
+ analyticsPrivilege,
575
+ manageAdminsPrivilege,
576
+ fetchMotivationsPrivilege,
577
+ company,
578
+ active
579
+ } = user
580
+
581
+ assert.ok(typeof email === 'string', 'Email is required')
582
+
583
+ if (user.email) {
584
+ throw new UserError('Email cannot be updated')
585
+ }
586
+
587
+ if (password) {
588
+ throw new UserError('Use Change Password endpoint to update user password')
589
+ }
590
+
591
+ if (level) {
592
+ assert.ok(typeof level === 'number', 'Level must be a number')
593
+ }
594
+
595
+ if (readOnly) {
596
+ assert.ok(typeof readOnly === 'boolean', 'readOnly should be a boolean')
597
+ }
598
+
599
+ if (blockPrivilege) {
600
+ assert.ok(typeof blockPrivilege === 'boolean', 'blockPrivilege should be a boolean')
601
+ }
602
+
603
+ if (analyticsPrivilege) {
604
+ assert.ok(typeof analyticsPrivilege === 'boolean', 'analyticsPrivilege should be a boolean')
605
+ }
606
+
607
+ if (manageAdminsPrivilege) {
608
+ assert.ok(typeof manageAdminsPrivilege === 'boolean', 'manageAdminsPrivilege should be a boolean')
609
+ }
610
+
611
+ if (fetchMotivationsPrivilege) {
612
+ assert.ok(typeof fetchMotivationsPrivilege === 'boolean', 'fetchMotivationsPrivilege should be a boolean')
613
+ }
614
+
615
+ if (company) {
616
+ assert.ok(typeof company === 'string', 'company should be a string')
617
+ }
618
+
619
+ if (active) {
620
+ assert.ok(typeof active === 'boolean', 'active should be a boolean')
621
+ }
622
+
623
+ const adm = await this._getAdmin(email, !active)
624
+ if (!adm) throw new UserError('ADMIN_ACCOUNT_DOES_NOT_EXIST_OR_IS_NOT_ACTIVE')
625
+
626
+ return new Promise((resolve, reject) => {
627
+ const keys = Object.keys(user)
628
+
629
+ this.db.run(
630
+ `UPDATE ${tableName} SET ${keys.join(' = ?, ')} = ? WHERE id = ?`,
631
+ keys.map(key => user[key]).concat(adm.id),
632
+ function (err) {
633
+ if (err) return reject(err)
634
+
635
+ resolve(user)
636
+ }
637
+ )
638
+ })
639
+ }
640
+
641
+ async updateAdminPassword (email, newPassword, oldPassword) {
642
+ assert.ok(this.conf.useDB, 'Cannot add admins if DB is not available')
643
+
644
+ assert.ok(typeof email === 'string', 'Email is required')
645
+ assert.ok(typeof newPassword === 'string', 'New Password is required')
646
+ assert.ok(typeof oldPassword === 'string', 'Old Password is required')
647
+
648
+ const adm = await this._getAdmin(email)
649
+ if (!adm) throw new UserError('ADMIN_ACCOUNT_DOES_NOT_EXIST_OR_IS_NOT_ACTIVE')
650
+
651
+ if (!(await verify(oldPassword, adm.password))) {
652
+ throw new UserError('INVALID_PASSWORD')
653
+ }
654
+
655
+ const password = await hash(newPassword, this.conf.hashSalt)
656
+
657
+ return new Promise((resolve, reject) => {
658
+ this.db.run(
659
+ `UPDATE ${tableName} SET password = ? WHERE id = ?`,
660
+ [password, adm.id],
661
+ function (err) {
662
+ if (err) return reject(err)
663
+
664
+ resolve(true)
665
+ }
666
+ )
667
+ })
668
+ }
669
+
670
+ async resetAdminPassword (email, newPassword, passwordResetToken) {
671
+ assert.ok(this.conf.useDB, 'Cannot add admins if DB is not available')
672
+
673
+ assert.ok(typeof email === 'string', 'Email is required')
674
+ assert.ok(typeof newPassword === 'string', 'New Password is required')
675
+
676
+ const admin = await this._getAdmin(email)
677
+ if (!admin) throw new UserError('ADMIN_ACCOUNT_DOES_NOT_EXIST_OR_IS_NOT_ACTIVE')
678
+ if (admin.passwordResetToken !== passwordResetToken) throw new UserError('INVALID_passwordResetToken')
679
+ const expiryDate = new Date(admin.passwordResetSentAt)
680
+ expiryDate.setDate(expiryDate.getDate() + 1)
681
+ if (Date.now() > expiryDate) throw new UserError('RESET_LINK_EXPIRED')
682
+
683
+ const password = await hash(newPassword, this.conf.hashSalt)
684
+
685
+ return new Promise((resolve, reject) => {
686
+ this.db.run(
687
+ `UPDATE ${tableName} SET password = ? WHERE id = ?`,
688
+ [password, admin.id],
689
+ function (err) {
690
+ if (err) return reject(err)
691
+
692
+ resolve(true)
693
+ }
694
+ )
695
+ })
696
+ }
697
+
698
+ async removeAdmin (idOrEmail) {
699
+ assert.ok(this.conf.useDB, 'Cannot remove admins if DB is not available')
700
+
701
+ return new Promise((resolve, reject) => {
702
+ this.db.serialize(() => {
703
+ const statement = this.db.prepare(`DELETE FROM ${tableName} WHERE id = ? OR LOWER(email) = ?`)
704
+ statement.run([idOrEmail, `${idOrEmail}`.toLowerCase()])
705
+ statement.finalize(err => {
706
+ if (err) return reject(err)
707
+
708
+ resolve(idOrEmail)
709
+ })
710
+ })
711
+ })
712
+ }
713
+
714
+ async basicAuthAdmLogCheck (sentEmail, sentPassword) {
715
+ const admin = await this._getAdmin(sentEmail)
716
+
717
+ const isValidPassword = this.conf.useDB
718
+ ? sentPassword && admin?.password && (await verify(sentPassword, admin.password))
719
+ : admin?.password === sentPassword
720
+
721
+ if (!(
722
+ admin &&
723
+ admin.password && // password cant be empty or false
724
+ isValidPassword
725
+ )) {
726
+ return { valid: false }
727
+ }
728
+
729
+ const { email, password, level, ...extra } = admin
730
+
731
+ return {
732
+ valid: true,
733
+ level,
734
+ extra
735
+ }
736
+ }
737
+
738
+ async checkAdmAccessLevel (adminEmail, level) {
739
+ const admin = await this._getAdmin(adminEmail)
740
+ const valid = !!admin && admin.level <= level
741
+ return valid
742
+ }
743
+
744
+ async checkAdmIsReadOnly (adminEmail) {
745
+ const admin = await this._getAdmin(adminEmail)
746
+ if (!admin) throw new Error('Searched admin was not found')
747
+
748
+ return !!admin.readOnly
749
+ }
750
+
751
+ async checkAdmHasBlockPrivilege (adminEmail) {
752
+ const admin = await this._getAdmin(adminEmail)
753
+ if (!admin) throw new Error('Searched admin was not found')
754
+
755
+ return !!(admin.level === 0 || admin.blockPrivilege)
756
+ }
757
+
758
+ async checkAdmHasAnalyticsPrivilege (adminEmail) {
759
+ const admin = await this._getAdmin(adminEmail)
760
+ if (!admin) throw new Error('Searched admin was not found')
761
+
762
+ return !!(admin.level === 0 || admin.analyticsPrivilege)
763
+ }
764
+
765
+ async checkAdmHasManageAdminsPrivilege (adminEmail) {
766
+ const admin = await this._getAdmin(adminEmail)
767
+ if (!admin) throw new Error('Searched admin was not found')
768
+
769
+ return !!(admin.level === 0 && admin.manageAdminsPrivilege)
770
+ }
771
+
772
+ async checkAdmHasFetchMotivationsPrivilege (adminEmail) {
773
+ const admin = await this._getAdmin(adminEmail)
774
+ if (!admin) throw new Error('Searched admin was not found')
775
+
776
+ return !!(admin.level === 0 || admin.fetchMotivationsPrivilege)
777
+ }
778
+
779
+ _checkIfAdminDbHasData () {
780
+ return new Promise((resolve, reject) => {
781
+ const query = `SELECT EXISTS(SELECT 1 FROM ${tableName}) as exist`
782
+ this.db.get(query, (err, row) => {
783
+ if (err) return reject(err)
784
+ resolve(row?.exist)
785
+ })
786
+ })
787
+ }
788
+
789
+ /**
790
+ * @param { string|number } emailOrId - Identifier used for searching the admin, it can be either the admin email address or its database id.
791
+ * @param { boolean } [active=true] - Flag for considering only the active users, it is `true` by default. If `false`, it will search through inactive users too.
792
+ * @param { boolean } [id=false] - Flag for searching as well by id criterion, it's `false` by default. If `true`, enables the mentioned behavior.
793
+ * @returns { Promise<BaseAdminT & { timestamp: Date, active: boolean }> }
794
+ */
795
+ async getAdmin (emailOrId, active = true, id = false) {
796
+ const admin = await this._getAdmin(emailOrId, active, id)
797
+ const displayKeys = ['email', 'level', 'blockPrivilege', 'company',
798
+ 'analyticsPrivilege', 'manageAdminsPrivilege', 'fetchMotivationsPrivilege', 'readOnly', 'active', 'timestamp', FORMS_FIELD]
799
+
800
+ if (this.conf.useDB && admin && admin[FORMS_FIELD]) {
801
+ admin[FORMS_FIELD] = JSON.parse(admin[FORMS_FIELD])
802
+ }
803
+
804
+ return admin
805
+ ? _.pick(admin, displayKeys)
806
+ : admin
807
+ }
808
+
809
+ async _getAdmin (emailOrId, active = true, id = false) {
810
+ if (!emailOrId) return false
811
+
812
+ return this.conf.useDB
813
+ ? this._getAdminFromDB(emailOrId, active, id)
814
+ : this._getAdminFromConfig(emailOrId)
815
+ }
816
+
817
+ async _getAdminFromDB (emailOrId, active, id) {
818
+ return new Promise((resolve, reject) => {
819
+ const identifierCondition = id
820
+ ? '(LOWER(email) = ? OR id = ?)'
821
+ : 'LOWER(email) = ?'
822
+
823
+ const query = active
824
+ ? `SELECT * FROM ${tableName} WHERE ${identifierCondition} AND active = 1`
825
+ : `SELECT * FROM ${tableName} WHERE ${identifierCondition}`
826
+
827
+ const params = [String(emailOrId).toLowerCase()]
828
+ if (id) {
829
+ params.push(emailOrId)
830
+ }
831
+
832
+ this.db.get(query, params, (err, row) => {
833
+ if (err) return reject(err)
834
+ resolve(row)
835
+ })
836
+ })
837
+ }
838
+
839
+ _getAdminFromConfig (email) {
840
+ const admins = this.conf.ADM_USERS || []
841
+
842
+ for (const adm of admins) {
843
+ if (adm.email.toLowerCase() === email.toLowerCase()) return adm
844
+ }
845
+
846
+ return false
847
+ }
848
+
849
+ async getAdminEmails (active = true, company) {
850
+ return this.conf.useDB
851
+ ? this._getAdminEmailsFromDB(active, company)
852
+ : this._getAdminEmailsFromConfig(company)
853
+ }
854
+
855
+ async _getAdminEmailsFromDB (active, company) {
856
+ return new Promise((resolve, reject) => {
857
+ const whereClause = [['active', active && 1], ['company', company && `'${company}'`]].reduce((query, prop) => {
858
+ if (prop[1]) {
859
+ if (query.length) {
860
+ query += ' AND '
861
+ }
862
+ query += `${prop[0]} = ${prop[1]}`
863
+ }
864
+ return query
865
+ }, '')
866
+
867
+ const query = active || company
868
+ ? `SELECT LOWER(email) AS email FROM ${tableName} WHERE ${whereClause} ORDER BY email ASC`
869
+ : `SELECT LOWER(email) AS email FROM ${tableName} ORDER BY email ASC`
870
+
871
+ this.db.all(query, [], (err, rows) => {
872
+ if (err) return reject(err)
873
+ resolve((rows || []).map(row => row.email))
874
+ })
875
+ })
876
+ }
877
+
878
+ async _getAdminEmailsFromConfig (company) {
879
+ const admins = this.conf.ADM_USERS || []
880
+ return admins
881
+ .filter(u => company
882
+ ? u.company.toLowerCase() === company.toLowerCase()
883
+ : true
884
+ )
885
+ .map(
886
+ u => u.email.toLowerCase()
887
+ )
888
+ }
889
+
890
+ async hasPassword (email) {
891
+ const admin = await this._getAdmin(email)
892
+ return !!admin?.password
893
+ }
894
+ }
895
+
896
+ module.exports = GoogleAuth