@prsm/auth 1.0.0
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/README.md +226 -0
- package/index.d.ts +19 -0
- package/package.json +76 -0
- package/src/__tests__/auth.test.js +1171 -0
- package/src/__tests__/impersonation-test-setup.js +208 -0
- package/src/__tests__/impersonation.test.js +473 -0
- package/src/__tests__/oauth-test-setup.js +136 -0
- package/src/__tests__/oauth.test.js +400 -0
- package/src/__tests__/prsm.test.js +215 -0
- package/src/__tests__/test-setup.js +385 -0
- package/src/__tests__/totp.test.js +158 -0
- package/src/__tests__/two-factor-test-setup.js +331 -0
- package/src/__tests__/two-factor.test.js +396 -0
- package/src/activity-logger.js +228 -0
- package/src/auth-context.js +120 -0
- package/src/auth-functions.js +520 -0
- package/src/auth-manager.js +1371 -0
- package/src/errors.js +173 -0
- package/src/hooks.js +41 -0
- package/src/index.js +23 -0
- package/src/invalidation.js +166 -0
- package/src/middleware.js +33 -0
- package/src/providers/azure-provider.js +114 -0
- package/src/providers/base-provider.js +152 -0
- package/src/providers/github-provider.js +86 -0
- package/src/providers/google-provider.js +76 -0
- package/src/providers/index.js +4 -0
- package/src/queries.js +543 -0
- package/src/schema.js +261 -0
- package/src/totp.js +221 -0
- package/src/two-factor/index.js +3 -0
- package/src/two-factor/otp-provider.js +128 -0
- package/src/two-factor/totp-provider.js +98 -0
- package/src/two-factor/two-factor-manager.js +676 -0
- package/src/types.js +399 -0
- package/src/user-roles.js +128 -0
- package/src/util.js +32 -0
- package/types/activity-logger.d.ts +73 -0
- package/types/auth-context.d.ts +88 -0
- package/types/auth-functions.d.ts +151 -0
- package/types/auth-manager.d.ts +365 -0
- package/types/errors.d.ts +108 -0
- package/types/hooks.d.ts +30 -0
- package/types/index.d.ts +13 -0
- package/types/invalidation.d.ts +40 -0
- package/types/middleware.d.ts +11 -0
- package/types/providers/azure-provider.d.ts +35 -0
- package/types/providers/base-provider.d.ts +52 -0
- package/types/providers/github-provider.d.ts +29 -0
- package/types/providers/google-provider.d.ts +29 -0
- package/types/providers/index.d.ts +4 -0
- package/types/queries.d.ts +287 -0
- package/types/schema.d.ts +37 -0
- package/types/totp.d.ts +72 -0
- package/types/two-factor/index.d.ts +3 -0
- package/types/two-factor/otp-provider.d.ts +57 -0
- package/types/two-factor/totp-provider.d.ts +58 -0
- package/types/two-factor/two-factor-manager.d.ts +191 -0
- package/types/types.d.ts +688 -0
- package/types/user-roles.d.ts +47 -0
- package/types/util.d.ts +3 -0
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
import hash from "@prsm/hash"
|
|
2
|
+
import ms from "@prsm/ms"
|
|
3
|
+
import { AuthStatus, AuthRole, AuthActivityAction } from "./types.js"
|
|
4
|
+
import { AuthQueries } from "./queries.js"
|
|
5
|
+
import { ActivityLogger } from "./activity-logger.js"
|
|
6
|
+
import { wasInvalidatedSince } from "./invalidation.js"
|
|
7
|
+
import { validateEmail, createMapFromEnum } from "./util.js"
|
|
8
|
+
import {
|
|
9
|
+
ConfirmationExpiredError,
|
|
10
|
+
ConfirmationNotFoundError,
|
|
11
|
+
EmailNotVerifiedError,
|
|
12
|
+
EmailTakenError,
|
|
13
|
+
InvalidPasswordError,
|
|
14
|
+
InvalidTokenError,
|
|
15
|
+
ResetDisabledError,
|
|
16
|
+
ResetExpiredError,
|
|
17
|
+
ResetNotFoundError,
|
|
18
|
+
TooManyResetsError,
|
|
19
|
+
UserInactiveError,
|
|
20
|
+
UserNotFoundError,
|
|
21
|
+
UserNotLoggedInError,
|
|
22
|
+
SecondFactorRequiredError,
|
|
23
|
+
TwoFactorExpiredError,
|
|
24
|
+
ImpersonationDisabledError,
|
|
25
|
+
ImpersonationNotAllowedError,
|
|
26
|
+
AlreadyImpersonatingError,
|
|
27
|
+
NotImpersonatingError,
|
|
28
|
+
RateLimitedError,
|
|
29
|
+
} from "./errors.js"
|
|
30
|
+
import { GitHubProvider, GoogleProvider, AzureProvider } from "./providers/index.js"
|
|
31
|
+
import { TwoFactorManager } from "./two-factor/index.js"
|
|
32
|
+
import * as authFunctions from "./auth-functions.js"
|
|
33
|
+
import { withSpan, consumeLimit } from "./hooks.js"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {import("./types.js").AuthConfig} AuthConfig
|
|
37
|
+
* @typedef {import("./types.js").AuthAccount} AuthAccount
|
|
38
|
+
* @typedef {import("./types.js").AuthSession} AuthSession
|
|
39
|
+
* @typedef {import("./types.js").TokenCallback} TokenCallback
|
|
40
|
+
* @typedef {import("./types.js").OAuthProvider} OAuthProvider
|
|
41
|
+
* @typedef {import("./types.js").StartImpersonationOptions} StartImpersonationOptions
|
|
42
|
+
* @typedef {import("./types.js").ImpersonationInfo} ImpersonationInfo
|
|
43
|
+
* @typedef {import("./types.js").ImpersonationActor} ImpersonationActor
|
|
44
|
+
* @typedef {import("./types.js").UserIdentifier} UserIdentifier
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export class AuthManager {
|
|
48
|
+
/**
|
|
49
|
+
* @param {import("express").Request} req
|
|
50
|
+
* @param {import("express").Response} res
|
|
51
|
+
* @param {AuthConfig} config
|
|
52
|
+
*/
|
|
53
|
+
constructor(req, res, config) {
|
|
54
|
+
this.req = req
|
|
55
|
+
this.res = res
|
|
56
|
+
this.config = config
|
|
57
|
+
this.queries = new AuthQueries(config)
|
|
58
|
+
this.activityLogger = new ActivityLogger(config)
|
|
59
|
+
this.providers = this.initializeProviders()
|
|
60
|
+
this.twoFactor = new TwoFactorManager(req, res, config)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
initializeProviders() {
|
|
64
|
+
const providers = {}
|
|
65
|
+
|
|
66
|
+
if (this.config.providers?.github) {
|
|
67
|
+
providers.github = new GitHubProvider(this.config.providers.github, this.config, this)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.config.providers?.google) {
|
|
71
|
+
providers.google = new GoogleProvider(this.config.providers.google, this.config, this)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.config.providers?.azure) {
|
|
75
|
+
providers.azure = new AzureProvider(this.config.providers.azure, this.config, this)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return providers
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
generateAutoUserId() {
|
|
82
|
+
return crypto.randomUUID()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async shouldRequire2FA(account) {
|
|
86
|
+
// skip 2FA for OAuth users unless explicitly configured
|
|
87
|
+
const providers = await this.queries.findProvidersByAccountId(account.id)
|
|
88
|
+
const hasOAuthProviders = providers.length > 0
|
|
89
|
+
|
|
90
|
+
if (hasOAuthProviders && !this.config.twoFactor?.requireForOAuth) {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
validatePassword(password) {
|
|
97
|
+
const minLength = this.config.minPasswordLength || 8
|
|
98
|
+
const maxLength = this.config.maxPasswordLength || 64
|
|
99
|
+
|
|
100
|
+
if (typeof password !== "string") {
|
|
101
|
+
throw new InvalidPasswordError()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (password.length < minLength) {
|
|
105
|
+
throw new InvalidPasswordError()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (password.length > maxLength) {
|
|
109
|
+
throw new InvalidPasswordError()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getRoleMap() {
|
|
114
|
+
return createMapFromEnum(this.config.roles || AuthRole)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getStatusMap() {
|
|
118
|
+
return createMapFromEnum(AuthStatus)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getAuthAccount() {
|
|
122
|
+
if (!this.req.session?.auth?.accountId) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return await this.queries.findAccountById(this.req.session.auth.accountId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setRememberCookie(token, expires) {
|
|
130
|
+
const cookieName = this.config.rememberCookieName || "remember_token"
|
|
131
|
+
const cookieConfig = this.config.cookie || {}
|
|
132
|
+
|
|
133
|
+
if (token === null) {
|
|
134
|
+
this.res.clearCookie(cookieName, {
|
|
135
|
+
domain: cookieConfig.domain,
|
|
136
|
+
secure: cookieConfig.secure ?? this.req.secure,
|
|
137
|
+
sameSite: cookieConfig.sameSite,
|
|
138
|
+
})
|
|
139
|
+
} else {
|
|
140
|
+
this.res.cookie(cookieName, token, {
|
|
141
|
+
expires,
|
|
142
|
+
httpOnly: true,
|
|
143
|
+
secure: cookieConfig.secure ?? this.req.secure,
|
|
144
|
+
domain: cookieConfig.domain,
|
|
145
|
+
sameSite: cookieConfig.sameSite,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getRememberToken() {
|
|
151
|
+
const { cookies } = this.req
|
|
152
|
+
if (!cookies) {
|
|
153
|
+
return { token: null }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cookieName = this.config.rememberCookieName || "remember_token"
|
|
157
|
+
const token = cookies[cookieName]
|
|
158
|
+
|
|
159
|
+
return { token: token || null }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async regenerateSession() {
|
|
163
|
+
const { auth } = this.req.session
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
this.req.session.regenerate((err) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
reject(err)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
this.req.session.auth = auth
|
|
172
|
+
resolve()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resync the current session against the persisted account state.
|
|
179
|
+
* @param {boolean} [force=false]
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async resyncSession(force = false) {
|
|
183
|
+
if (!this.isLoggedIn()) {
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.req.session.auth.shouldForceLogout) {
|
|
188
|
+
await this.logout()
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const interval = ms(this.config.resyncInterval || "30s")
|
|
193
|
+
const lastResync = new Date(this.req.session.auth.lastResync)
|
|
194
|
+
|
|
195
|
+
if (!force && lastResync && lastResync.getTime() > Date.now() - interval) {
|
|
196
|
+
// honor the interval unless another instance signaled this account invalid
|
|
197
|
+
// more recently than our last resync (cross-instance LISTEN/NOTIFY)
|
|
198
|
+
if (!wasInvalidatedSince(this.config, this.req.session.auth.accountId, lastResync.getTime())) {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (this.isImpersonating()) {
|
|
204
|
+
const actorSnapshot = this.req.session.auth.actor
|
|
205
|
+
|
|
206
|
+
// expiry first - revert to actor before any other action
|
|
207
|
+
if (actorSnapshot.expiresAt && new Date() >= new Date(actorSnapshot.expiresAt)) {
|
|
208
|
+
await this.stopImpersonationInternal("expired")
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const actorAccount = await this.queries.findAccountById(actorSnapshot.accountId)
|
|
213
|
+
if (!actorAccount || actorAccount.status !== AuthStatus.Normal || actorAccount.force_logout > actorSnapshot.forceLogout) {
|
|
214
|
+
// actor is no longer valid - kill the whole session.
|
|
215
|
+
// clear actor first so the Logout activity row doesn't reference a deleted/invalid actor via FK
|
|
216
|
+
this.req.session.auth.actor = undefined
|
|
217
|
+
await this.logout()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// target may have been modified by the admin while impersonating; refresh effective fields
|
|
222
|
+
const target = await this.queries.findAccountById(this.req.session.auth.accountId)
|
|
223
|
+
if (!target) {
|
|
224
|
+
// target deleted mid-impersonation - revert to actor
|
|
225
|
+
await this.stopImpersonationInternal("target_gone")
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.req.session.auth.email = target.email
|
|
230
|
+
this.req.session.auth.status = target.status
|
|
231
|
+
this.req.session.auth.rolemask = target.rolemask
|
|
232
|
+
this.req.session.auth.verified = target.verified
|
|
233
|
+
this.req.session.auth.hasPassword = target.password !== null
|
|
234
|
+
this.req.session.auth.lastResync = new Date()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const account = await this.getAuthAccount()
|
|
239
|
+
|
|
240
|
+
if (!account) {
|
|
241
|
+
await this.logout()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (account.force_logout > this.req.session.auth.forceLogout) {
|
|
246
|
+
await this.logout()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.req.session.auth.shouldForceLogout = false
|
|
251
|
+
this.req.session.auth.email = account.email
|
|
252
|
+
this.req.session.auth.status = account.status
|
|
253
|
+
this.req.session.auth.rolemask = account.rolemask
|
|
254
|
+
this.req.session.auth.verified = account.verified
|
|
255
|
+
this.req.session.auth.hasPassword = account.password !== null
|
|
256
|
+
this.req.session.auth.lastResync = new Date()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Restore a session from a valid remember token when not already logged in.
|
|
261
|
+
* @returns {Promise<void>}
|
|
262
|
+
*/
|
|
263
|
+
async processRememberDirective() {
|
|
264
|
+
if (this.isLoggedIn()) {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { token } = this.getRememberToken()
|
|
269
|
+
if (!token) {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const remember = await this.queries.findRememberToken(token)
|
|
274
|
+
if (!remember) {
|
|
275
|
+
this.setRememberCookie(null, new Date(0))
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// expired?
|
|
280
|
+
if (new Date() > remember.expires) {
|
|
281
|
+
await this.queries.deleteRememberToken(token)
|
|
282
|
+
this.setRememberCookie(null, new Date(0))
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// clean up expired tokens for this account
|
|
287
|
+
await this.queries.deleteExpiredRememberTokensForAccount(remember.account_id)
|
|
288
|
+
|
|
289
|
+
// get the account and log in
|
|
290
|
+
const account = await this.queries.findAccountById(remember.account_id)
|
|
291
|
+
if (!account) {
|
|
292
|
+
await this.queries.deleteRememberToken(token)
|
|
293
|
+
this.setRememberCookie(null, new Date(0))
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// pass false to avoid creating a new remember token - we're restoring from an existing one
|
|
298
|
+
await this.onLoginSuccessful(account, false)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async onLoginSuccessful(account, remember = false) {
|
|
302
|
+
await this.queries.updateAccountLastLogin(account.id)
|
|
303
|
+
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
if (!this.req.session?.regenerate) {
|
|
306
|
+
resolve()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.req.session.regenerate(async (err) => {
|
|
311
|
+
if (err) {
|
|
312
|
+
reject(err)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const session = {
|
|
317
|
+
loggedIn: true,
|
|
318
|
+
accountId: account.id,
|
|
319
|
+
userId: account.user_id,
|
|
320
|
+
email: account.email,
|
|
321
|
+
status: account.status,
|
|
322
|
+
rolemask: account.rolemask,
|
|
323
|
+
remembered: remember,
|
|
324
|
+
lastResync: new Date(),
|
|
325
|
+
lastRememberCheck: new Date(),
|
|
326
|
+
forceLogout: account.force_logout,
|
|
327
|
+
verified: account.verified,
|
|
328
|
+
hasPassword: account.password !== null,
|
|
329
|
+
shouldForceLogout: false,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
this.req.session.auth = session
|
|
333
|
+
|
|
334
|
+
if (remember) {
|
|
335
|
+
await this.createRememberDirective(account)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.req.session.save((err) => {
|
|
339
|
+
if (err) {
|
|
340
|
+
reject(err)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
resolve()
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async createRememberDirective(account) {
|
|
350
|
+
const token = await hash.encode(account.email)
|
|
351
|
+
const duration = this.config.rememberDuration || "30d"
|
|
352
|
+
const expires = new Date(Date.now() + ms(duration))
|
|
353
|
+
|
|
354
|
+
await this.queries.createRememberToken({
|
|
355
|
+
accountId: account.id,
|
|
356
|
+
token,
|
|
357
|
+
expires,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
this.setRememberCookie(token, expires)
|
|
361
|
+
|
|
362
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.RememberTokenCreated, this.req, true, { email: account.email, duration })
|
|
363
|
+
|
|
364
|
+
return token
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Check if the current user is logged in.
|
|
369
|
+
* @returns {boolean} true if user has an active authenticated session
|
|
370
|
+
*/
|
|
371
|
+
isLoggedIn() {
|
|
372
|
+
return this.req.session?.auth?.loggedIn ?? false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Authenticate user with email and password, creating a session.
|
|
377
|
+
* @param {string} email
|
|
378
|
+
* @param {string} password
|
|
379
|
+
* @param {boolean} [remember=false]
|
|
380
|
+
* @returns {Promise<void>}
|
|
381
|
+
* @throws {UserNotFoundError} Account with this email doesn't exist
|
|
382
|
+
* @throws {InvalidPasswordError} Password is incorrect
|
|
383
|
+
* @throws {EmailNotVerifiedError} Account exists but email is not verified
|
|
384
|
+
* @throws {UserInactiveError} Account is banned, locked, or otherwise inactive
|
|
385
|
+
* @throws {SecondFactorRequiredError} Two-factor authentication is required
|
|
386
|
+
*/
|
|
387
|
+
async login(email, password, remember = false) {
|
|
388
|
+
return withSpan(this.config.tracer, "auth.login", { email }, async () => {
|
|
389
|
+
// optional brute-force throttle, keyed by email; only active when a limiter
|
|
390
|
+
// is configured. behavior is unchanged when absent
|
|
391
|
+
if (this.config.limiter) {
|
|
392
|
+
const result = await consumeLimit(this.config.limiter, `login:${email}`)
|
|
393
|
+
if (result && result.allowed === false) {
|
|
394
|
+
await this.activityLogger.logActivity(null, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "rate_limited" })
|
|
395
|
+
throw new RateLimitedError(result.retryAfter)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const account = await this.queries.findAccountByEmail(email)
|
|
400
|
+
|
|
401
|
+
if (!account) {
|
|
402
|
+
await this.activityLogger.logActivity(null, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "account_not_found" })
|
|
403
|
+
throw new UserNotFoundError()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!account.password || !(await hash.verify(account.password, password))) {
|
|
407
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "invalid_password" })
|
|
408
|
+
throw new InvalidPasswordError()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!account.verified) {
|
|
412
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "email_not_verified" })
|
|
413
|
+
throw new EmailNotVerifiedError()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (account.status !== AuthStatus.Normal) {
|
|
417
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "account_inactive", status: account.status })
|
|
418
|
+
throw new UserInactiveError()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// check if 2FA is enabled and required for this user
|
|
422
|
+
if (this.config.twoFactor?.enabled && (await this.shouldRequire2FA(account))) {
|
|
423
|
+
const twoFactorMethods = await this.queries.findTwoFactorMethodsByAccountId(account.id)
|
|
424
|
+
const enabledMethods = twoFactorMethods.filter((method) => method.verified)
|
|
425
|
+
|
|
426
|
+
if (enabledMethods.length > 0) {
|
|
427
|
+
// create 2FA challenge
|
|
428
|
+
const challenge = await this.twoFactor.createChallenge(account.id)
|
|
429
|
+
|
|
430
|
+
// set 2FA session state (user NOT logged in yet)
|
|
431
|
+
const expiryDuration = this.config.twoFactor?.tokenExpiry || "5m"
|
|
432
|
+
const expiresAt = new Date(Date.now() + ms(expiryDuration))
|
|
433
|
+
|
|
434
|
+
this.req.session.auth = {
|
|
435
|
+
loggedIn: false,
|
|
436
|
+
accountId: 0,
|
|
437
|
+
userId: "",
|
|
438
|
+
email: "",
|
|
439
|
+
status: 0,
|
|
440
|
+
rolemask: 0,
|
|
441
|
+
remembered: false,
|
|
442
|
+
lastResync: new Date(),
|
|
443
|
+
lastRememberCheck: new Date(),
|
|
444
|
+
forceLogout: 0,
|
|
445
|
+
verified: false,
|
|
446
|
+
hasPassword: false,
|
|
447
|
+
awaitingTwoFactor: {
|
|
448
|
+
accountId: account.id,
|
|
449
|
+
expiresAt,
|
|
450
|
+
remember,
|
|
451
|
+
availableMechanisms: enabledMethods.map((m) => m.mechanism),
|
|
452
|
+
attemptedMechanisms: [],
|
|
453
|
+
originalEmail: account.email,
|
|
454
|
+
selectors: challenge.selectors,
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.TwoFactorFailed, this.req, true, { prompt: true, mechanisms: enabledMethods.map((m) => m.mechanism) })
|
|
459
|
+
|
|
460
|
+
throw new SecondFactorRequiredError(challenge)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await this.onLoginSuccessful(account, remember)
|
|
465
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.Login, this.req, true, { email, remember })
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Complete two-factor authentication and log in the user.
|
|
471
|
+
* @returns {Promise<void>}
|
|
472
|
+
* @throws {TwoFactorExpiredError} No pending 2FA state or it has expired
|
|
473
|
+
* @throws {UserNotFoundError} Associated account no longer exists
|
|
474
|
+
*/
|
|
475
|
+
async completeTwoFactorLogin() {
|
|
476
|
+
const twoFactorState = this.req.session?.auth?.awaitingTwoFactor
|
|
477
|
+
|
|
478
|
+
if (!twoFactorState) {
|
|
479
|
+
throw new TwoFactorExpiredError()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// check if the 2FA session has expired
|
|
483
|
+
if (twoFactorState.expiresAt <= new Date()) {
|
|
484
|
+
// clear expired 2FA state
|
|
485
|
+
delete this.req.session.auth.awaitingTwoFactor
|
|
486
|
+
throw new TwoFactorExpiredError()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// get the account that was awaiting 2FA
|
|
490
|
+
const account = await this.queries.findAccountById(twoFactorState.accountId)
|
|
491
|
+
if (!account) {
|
|
492
|
+
delete this.req.session.auth.awaitingTwoFactor
|
|
493
|
+
throw new UserNotFoundError()
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// complete the login process
|
|
497
|
+
await this.onLoginSuccessful(account, twoFactorState.remember)
|
|
498
|
+
|
|
499
|
+
// clear the 2FA state
|
|
500
|
+
delete this.req.session.auth.awaitingTwoFactor
|
|
501
|
+
|
|
502
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.Login, this.req, true, { email: account.email, remember: twoFactorState.remember, twoFactorCompleted: true })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Log out the current user, clearing the session and remember tokens.
|
|
507
|
+
* @returns {Promise<void>}
|
|
508
|
+
*/
|
|
509
|
+
async logout() {
|
|
510
|
+
if (!this.isLoggedIn()) {
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const accountId = this.getId()
|
|
515
|
+
const email = this.getEmail()
|
|
516
|
+
const { token } = this.getRememberToken()
|
|
517
|
+
|
|
518
|
+
if (token) {
|
|
519
|
+
await this.queries.deleteRememberToken(token)
|
|
520
|
+
this.setRememberCookie(null, new Date(0))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// log BEFORE clearing the session so actor_account_id auto-pickup catches
|
|
524
|
+
// impersonation context (e.g. forced logout while impersonating)
|
|
525
|
+
if (accountId && email) {
|
|
526
|
+
await this.activityLogger.logActivity(accountId, AuthActivityAction.Logout, this.req, true, { email })
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this.req.session.auth = undefined
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Register a new account.
|
|
534
|
+
* @param {string} email
|
|
535
|
+
* @param {string} password
|
|
536
|
+
* @param {string|number} [userId] Optional user ID to link; a UUID is generated if omitted
|
|
537
|
+
* @param {TokenCallback} [callback] If provided, account is created unverified and callback receives the confirmation token
|
|
538
|
+
* @returns {Promise<AuthAccount>} The created account record
|
|
539
|
+
* @throws {EmailTakenError} Email is already registered
|
|
540
|
+
* @throws {InvalidPasswordError} Password doesn't meet length requirements
|
|
541
|
+
*/
|
|
542
|
+
async register(email, password, userId, callback) {
|
|
543
|
+
validateEmail(email)
|
|
544
|
+
this.validatePassword(password)
|
|
545
|
+
|
|
546
|
+
const existing = await this.queries.findAccountByEmail(email)
|
|
547
|
+
if (existing) {
|
|
548
|
+
throw new EmailTakenError()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const finalUserId = userId || this.generateAutoUserId()
|
|
552
|
+
|
|
553
|
+
const hashedPassword = await hash.encode(password)
|
|
554
|
+
const verified = typeof callback !== "function"
|
|
555
|
+
|
|
556
|
+
const account = await this.queries.createAccount({
|
|
557
|
+
userId: finalUserId,
|
|
558
|
+
email,
|
|
559
|
+
password: hashedPassword,
|
|
560
|
+
verified,
|
|
561
|
+
status: AuthStatus.Normal,
|
|
562
|
+
rolemask: 0,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
if (!verified && callback) {
|
|
566
|
+
await this.createConfirmationToken(account, email, callback)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.Register, this.req, true, { email, verified, userId: finalUserId })
|
|
570
|
+
|
|
571
|
+
return account
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async createConfirmationToken(account, email, callback) {
|
|
575
|
+
const token = await hash.encode(email)
|
|
576
|
+
const expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 1 week
|
|
577
|
+
|
|
578
|
+
await this.queries.createConfirmation({
|
|
579
|
+
accountId: account.id,
|
|
580
|
+
token,
|
|
581
|
+
email,
|
|
582
|
+
expires,
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
if (callback) {
|
|
586
|
+
callback(token)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get the current user's account ID.
|
|
592
|
+
* @returns {number|null} Account ID if logged in, null otherwise
|
|
593
|
+
*/
|
|
594
|
+
getId() {
|
|
595
|
+
return this.req.session?.auth?.accountId || null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get the current user's email address.
|
|
600
|
+
* @returns {string|null} Email if logged in, null otherwise
|
|
601
|
+
*/
|
|
602
|
+
getEmail() {
|
|
603
|
+
return this.req.session?.auth?.email || null
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Get the current user's account status.
|
|
608
|
+
* @returns {number|null} Status number if logged in, null otherwise
|
|
609
|
+
*/
|
|
610
|
+
getStatus() {
|
|
611
|
+
return this.req.session?.auth?.status ?? null
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Check if the current user's email is verified.
|
|
616
|
+
* @returns {boolean|null} true if verified, false if unverified, null if not logged in
|
|
617
|
+
*/
|
|
618
|
+
getVerified() {
|
|
619
|
+
return this.req.session?.auth?.verified ?? null
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Check if the current user has a password set.
|
|
624
|
+
* @returns {boolean|null} true if user has a password, false if OAuth-only, null if not logged in
|
|
625
|
+
*/
|
|
626
|
+
hasPassword() {
|
|
627
|
+
return this.req.session?.auth?.hasPassword ?? null
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get human-readable role names for the current user or a specific rolemask.
|
|
632
|
+
* @param {number} [rolemask] Optional rolemask; defaults to the current user's roles
|
|
633
|
+
* @returns {string[]} Array of role names
|
|
634
|
+
*/
|
|
635
|
+
getRoleNames(rolemask) {
|
|
636
|
+
const mask = rolemask !== undefined ? rolemask : (this.req.session?.auth?.rolemask ?? 0)
|
|
637
|
+
|
|
638
|
+
if (!mask && mask !== 0) {
|
|
639
|
+
return []
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return Object.entries(this.getRoleMap())
|
|
643
|
+
.filter(([key]) => mask & parseInt(key))
|
|
644
|
+
.map(([, value]) => value)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Get the human-readable status name for the current user.
|
|
649
|
+
* @returns {string|null} Status name if logged in, null otherwise
|
|
650
|
+
*/
|
|
651
|
+
getStatusName() {
|
|
652
|
+
const status = this.getStatus()
|
|
653
|
+
if (status === null) return null
|
|
654
|
+
return this.getStatusMap()[status] || null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Check if the current user has a specific role.
|
|
659
|
+
* @param {number} role Role bitmask to check
|
|
660
|
+
* @returns {Promise<boolean>} true if user has the role, false otherwise
|
|
661
|
+
*/
|
|
662
|
+
async hasRole(role) {
|
|
663
|
+
if (this.req.session?.auth) {
|
|
664
|
+
return (this.req.session.auth.rolemask & role) === role
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const account = await this.getAuthAccount()
|
|
668
|
+
return account ? (account.rolemask & role) === role : false
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Check if the current user has admin privileges.
|
|
673
|
+
* @returns {Promise<boolean>} true if user has Admin role, false otherwise
|
|
674
|
+
*/
|
|
675
|
+
async isAdmin() {
|
|
676
|
+
return this.hasRole(AuthRole.Admin)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Check if the current user was automatically logged in via remember token.
|
|
681
|
+
* @returns {boolean} true if auto-logged in from a persistent cookie, false otherwise
|
|
682
|
+
*/
|
|
683
|
+
isRemembered() {
|
|
684
|
+
return this.req.session?.auth?.remembered ?? false
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Request an email change for the current user, sending a confirmation token.
|
|
689
|
+
* @param {string} newEmail
|
|
690
|
+
* @param {TokenCallback} callback Called with the confirmation token
|
|
691
|
+
* @returns {Promise<void>}
|
|
692
|
+
* @throws {UserNotLoggedInError} User is not logged in
|
|
693
|
+
* @throws {EmailTakenError} New email is already registered
|
|
694
|
+
* @throws {UserNotFoundError} Current user account not found
|
|
695
|
+
* @throws {EmailNotVerifiedError} Current account's email is not verified
|
|
696
|
+
*/
|
|
697
|
+
async changeEmail(newEmail, callback) {
|
|
698
|
+
if (!this.isLoggedIn()) {
|
|
699
|
+
throw new UserNotLoggedInError()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
validateEmail(newEmail)
|
|
703
|
+
|
|
704
|
+
const existing = await this.queries.findAccountByEmail(newEmail)
|
|
705
|
+
if (existing) {
|
|
706
|
+
throw new EmailTakenError()
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const account = await this.getAuthAccount()
|
|
710
|
+
if (!account) {
|
|
711
|
+
throw new UserNotFoundError()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!account.verified) {
|
|
715
|
+
throw new EmailNotVerifiedError()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
await this.createConfirmationToken(account, newEmail, callback)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Confirm an email address using a token from registration or email change.
|
|
723
|
+
* @param {string} token
|
|
724
|
+
* @returns {Promise<string>} The confirmed email address
|
|
725
|
+
* @throws {ConfirmationNotFoundError} Token is invalid or doesn't exist
|
|
726
|
+
* @throws {ConfirmationExpiredError} Token has expired
|
|
727
|
+
* @throws {InvalidTokenError} Token format is invalid
|
|
728
|
+
*/
|
|
729
|
+
async confirmEmail(token) {
|
|
730
|
+
const confirmation = await this.queries.findConfirmation(token)
|
|
731
|
+
|
|
732
|
+
if (!confirmation) {
|
|
733
|
+
throw new ConfirmationNotFoundError()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (new Date(confirmation.expires) < new Date()) {
|
|
737
|
+
throw new ConfirmationExpiredError()
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!(await hash.verify(token, confirmation.email))) {
|
|
741
|
+
throw new InvalidTokenError()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
await this.queries.updateAccount(confirmation.account_id, {
|
|
745
|
+
verified: true,
|
|
746
|
+
email: confirmation.email,
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
if (this.isLoggedIn() && this.req.session?.auth?.accountId === confirmation.account_id) {
|
|
750
|
+
this.req.session.auth.verified = true
|
|
751
|
+
this.req.session.auth.email = confirmation.email
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
await this.queries.deleteConfirmation(token)
|
|
755
|
+
|
|
756
|
+
await this.activityLogger.logActivity(confirmation.account_id, AuthActivityAction.EmailConfirmed, this.req, true, { email: confirmation.email })
|
|
757
|
+
|
|
758
|
+
return confirmation.email
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Confirm an email address and automatically log in the user.
|
|
763
|
+
* @param {string} token
|
|
764
|
+
* @param {boolean} [remember=false]
|
|
765
|
+
* @returns {Promise<void>}
|
|
766
|
+
* @throws {ConfirmationNotFoundError} Token is invalid or doesn't exist
|
|
767
|
+
* @throws {ConfirmationExpiredError} Token has expired
|
|
768
|
+
* @throws {InvalidTokenError} Token format is invalid
|
|
769
|
+
* @throws {UserNotFoundError} Associated account no longer exists
|
|
770
|
+
* @throws {SecondFactorRequiredError} Two-factor authentication is required
|
|
771
|
+
*/
|
|
772
|
+
async confirmEmailAndLogin(token, remember = false) {
|
|
773
|
+
const email = await this.confirmEmail(token)
|
|
774
|
+
|
|
775
|
+
if (this.isLoggedIn()) {
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const account = await this.queries.findAccountByEmail(email)
|
|
780
|
+
if (!account) {
|
|
781
|
+
throw new UserNotFoundError()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// check if 2FA is enabled and required for this user
|
|
785
|
+
if (this.config.twoFactor?.enabled && (await this.shouldRequire2FA(account))) {
|
|
786
|
+
const twoFactorMethods = await this.queries.findTwoFactorMethodsByAccountId(account.id)
|
|
787
|
+
const enabledMethods = twoFactorMethods.filter((method) => method.verified)
|
|
788
|
+
|
|
789
|
+
if (enabledMethods.length > 0) {
|
|
790
|
+
// create 2FA challenge
|
|
791
|
+
const challenge = await this.twoFactor.createChallenge(account.id)
|
|
792
|
+
|
|
793
|
+
// set 2FA session state (user NOT logged in yet)
|
|
794
|
+
const expiryDuration = this.config.twoFactor?.tokenExpiry || "5m"
|
|
795
|
+
const expiresAt = new Date(Date.now() + ms(expiryDuration))
|
|
796
|
+
|
|
797
|
+
this.req.session.auth = {
|
|
798
|
+
loggedIn: false,
|
|
799
|
+
accountId: 0,
|
|
800
|
+
userId: "",
|
|
801
|
+
email: "",
|
|
802
|
+
status: 0,
|
|
803
|
+
rolemask: 0,
|
|
804
|
+
remembered: false,
|
|
805
|
+
lastResync: new Date(),
|
|
806
|
+
lastRememberCheck: new Date(),
|
|
807
|
+
forceLogout: 0,
|
|
808
|
+
verified: false,
|
|
809
|
+
hasPassword: false,
|
|
810
|
+
awaitingTwoFactor: {
|
|
811
|
+
accountId: account.id,
|
|
812
|
+
expiresAt,
|
|
813
|
+
remember,
|
|
814
|
+
availableMechanisms: enabledMethods.map((m) => m.mechanism),
|
|
815
|
+
attemptedMechanisms: [],
|
|
816
|
+
originalEmail: account.email,
|
|
817
|
+
selectors: challenge.selectors,
|
|
818
|
+
},
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.TwoFactorFailed, this.req, true, { prompt: true, mechanisms: enabledMethods.map((m) => m.mechanism) })
|
|
822
|
+
|
|
823
|
+
throw new SecondFactorRequiredError(challenge)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
await this.onLoginSuccessful(account, remember)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Initiate a password reset for a user, creating a reset token.
|
|
832
|
+
* @param {string} email
|
|
833
|
+
* @param {string|number|null} [expiresAfter=null] Token expiration (default 6h)
|
|
834
|
+
* @param {number|null} [maxOpenRequests=null] Maximum concurrent reset tokens (default 2)
|
|
835
|
+
* @param {TokenCallback} [callback] Called with the reset token
|
|
836
|
+
* @returns {Promise<void>}
|
|
837
|
+
* @throws {EmailNotVerifiedError} Account doesn't exist or email not verified
|
|
838
|
+
* @throws {ResetDisabledError} Account has password reset disabled
|
|
839
|
+
* @throws {TooManyResetsError} Too many active reset requests
|
|
840
|
+
*/
|
|
841
|
+
async resetPassword(email, expiresAfter = null, maxOpenRequests = null, callback) {
|
|
842
|
+
validateEmail(email)
|
|
843
|
+
|
|
844
|
+
const expiry = !expiresAfter ? ms("6h") : ms(expiresAfter)
|
|
845
|
+
const maxRequests = maxOpenRequests === null ? 2 : Math.max(1, maxOpenRequests)
|
|
846
|
+
|
|
847
|
+
const account = await this.queries.findAccountByEmail(email)
|
|
848
|
+
|
|
849
|
+
if (!account || !account.verified) {
|
|
850
|
+
throw new EmailNotVerifiedError()
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!account.resettable) {
|
|
854
|
+
throw new ResetDisabledError()
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const openRequests = await this.queries.countActiveResetTokensForAccount(account.id)
|
|
858
|
+
|
|
859
|
+
if (openRequests >= maxRequests) {
|
|
860
|
+
throw new TooManyResetsError()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const token = await hash.encode(email)
|
|
864
|
+
const expires = new Date(Date.now() + expiry)
|
|
865
|
+
|
|
866
|
+
await this.queries.createResetToken({
|
|
867
|
+
accountId: account.id,
|
|
868
|
+
token,
|
|
869
|
+
expires,
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.PasswordResetRequested, this.req, true, { email })
|
|
873
|
+
|
|
874
|
+
if (callback) {
|
|
875
|
+
callback(token)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Complete a password reset using a reset token.
|
|
881
|
+
* @param {string} token
|
|
882
|
+
* @param {string} password New password (will be hashed)
|
|
883
|
+
* @param {boolean} [logout=true] Whether to force logout all sessions
|
|
884
|
+
* @returns {Promise<void>}
|
|
885
|
+
* @throws {ResetNotFoundError} Token is invalid or doesn't exist
|
|
886
|
+
* @throws {ResetExpiredError} Token has expired
|
|
887
|
+
* @throws {UserNotFoundError} Associated account no longer exists
|
|
888
|
+
* @throws {ResetDisabledError} Account has password reset disabled
|
|
889
|
+
* @throws {InvalidPasswordError} New password doesn't meet requirements
|
|
890
|
+
* @throws {InvalidTokenError} Token format is invalid
|
|
891
|
+
*/
|
|
892
|
+
async confirmResetPassword(token, password, logout = true) {
|
|
893
|
+
const reset = await this.queries.findResetToken(token)
|
|
894
|
+
|
|
895
|
+
if (!reset) {
|
|
896
|
+
throw new ResetNotFoundError()
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (new Date(reset.expires) < new Date()) {
|
|
900
|
+
throw new ResetExpiredError()
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const account = await this.queries.findAccountById(reset.account_id)
|
|
904
|
+
if (!account) {
|
|
905
|
+
throw new UserNotFoundError()
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (!account.resettable) {
|
|
909
|
+
throw new ResetDisabledError()
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
this.validatePassword(password)
|
|
913
|
+
|
|
914
|
+
if (!(await hash.verify(token, account.email))) {
|
|
915
|
+
throw new InvalidTokenError()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
await this.queries.updateAccount(account.id, {
|
|
919
|
+
password: await hash.encode(password),
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
if (logout) {
|
|
923
|
+
await this.forceLogoutForAccountById(account.id)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
await this.queries.deleteResetToken(token)
|
|
927
|
+
|
|
928
|
+
await this.activityLogger.logActivity(account.id, AuthActivityAction.PasswordResetCompleted, this.req, true, { email: account.email })
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Verify if a password matches the current user's password.
|
|
933
|
+
* @param {string} password
|
|
934
|
+
* @returns {Promise<boolean>} true if password matches, false otherwise
|
|
935
|
+
* @throws {UserNotLoggedInError} User is not logged in
|
|
936
|
+
* @throws {UserNotFoundError} Current user account not found
|
|
937
|
+
*/
|
|
938
|
+
async verifyPassword(password) {
|
|
939
|
+
if (!this.isLoggedIn()) {
|
|
940
|
+
throw new UserNotLoggedInError()
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const account = await this.getAuthAccount()
|
|
944
|
+
|
|
945
|
+
if (!account) {
|
|
946
|
+
throw new UserNotFoundError()
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (!account.password) {
|
|
950
|
+
return false // OAuth users don't have passwords
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return await hash.verify(account.password, password)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async forceLogoutForAccountById(accountId) {
|
|
957
|
+
await this.queries.deleteRememberTokensForAccount(accountId)
|
|
958
|
+
await this.queries.incrementForceLogout(accountId)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Force logout all other sessions while keeping the current one active.
|
|
963
|
+
* @returns {Promise<void>}
|
|
964
|
+
*/
|
|
965
|
+
async logoutEverywhereElse() {
|
|
966
|
+
if (!this.isLoggedIn()) {
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const accountId = this.getId()
|
|
971
|
+
if (!accountId) {
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const account = await this.queries.findAccountById(accountId)
|
|
976
|
+
if (!account) {
|
|
977
|
+
await this.logout()
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
await this.forceLogoutForAccountById(accountId)
|
|
982
|
+
|
|
983
|
+
this.req.session.auth.forceLogout += 1
|
|
984
|
+
|
|
985
|
+
await this.regenerateSession()
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Force logout all sessions including the current one.
|
|
990
|
+
* @returns {Promise<void>}
|
|
991
|
+
*/
|
|
992
|
+
async logoutEverywhere() {
|
|
993
|
+
if (!this.isLoggedIn()) {
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
await this.logoutEverywhereElse()
|
|
998
|
+
await this.logout()
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async findAccountByIdentifier(identifier) {
|
|
1002
|
+
if (identifier.accountId !== undefined) {
|
|
1003
|
+
return await this.queries.findAccountById(identifier.accountId)
|
|
1004
|
+
} else if (identifier.email !== undefined) {
|
|
1005
|
+
return await this.queries.findAccountByEmail(identifier.email)
|
|
1006
|
+
} else if (identifier.userId !== undefined) {
|
|
1007
|
+
return await this.queries.findAccountByUserId(identifier.userId)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return null
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// admin/standalone functions (delegated to auth-functions.js due to lack of need for request context)
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Create a user account (admin function).
|
|
1017
|
+
* @param {{ email: string, password: string }} credentials
|
|
1018
|
+
* @param {string|number} [userId]
|
|
1019
|
+
* @param {TokenCallback} [callback]
|
|
1020
|
+
* @returns {Promise<AuthAccount>}
|
|
1021
|
+
*/
|
|
1022
|
+
async createUser(credentials, userId, callback) {
|
|
1023
|
+
return authFunctions.createUser(this.config, credentials, userId, callback)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Delete a user account by identifier (admin function).
|
|
1028
|
+
* @param {UserIdentifier} identifier
|
|
1029
|
+
* @returns {Promise<void>}
|
|
1030
|
+
*/
|
|
1031
|
+
async deleteUserBy(identifier) {
|
|
1032
|
+
return authFunctions.deleteUserBy(this.config, identifier)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Add a role for a user by identifier (admin function).
|
|
1037
|
+
* @param {UserIdentifier} identifier
|
|
1038
|
+
* @param {number} role
|
|
1039
|
+
* @returns {Promise<void>}
|
|
1040
|
+
*/
|
|
1041
|
+
async addRoleForUserBy(identifier, role) {
|
|
1042
|
+
return authFunctions.addRoleForUserBy(this.config, identifier, role)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Remove a role for a user by identifier (admin function).
|
|
1047
|
+
* @param {UserIdentifier} identifier
|
|
1048
|
+
* @param {number} role
|
|
1049
|
+
* @returns {Promise<void>}
|
|
1050
|
+
*/
|
|
1051
|
+
async removeRoleForUserBy(identifier, role) {
|
|
1052
|
+
return authFunctions.removeRoleForUserBy(this.config, identifier, role)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Check whether a user has a role by identifier (admin function).
|
|
1057
|
+
* @param {UserIdentifier} identifier
|
|
1058
|
+
* @param {number} role
|
|
1059
|
+
* @returns {Promise<boolean>}
|
|
1060
|
+
*/
|
|
1061
|
+
async hasRoleForUserBy(identifier, role) {
|
|
1062
|
+
return authFunctions.hasRoleForUserBy(this.config, identifier, role)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Change a user's password by identifier (admin function).
|
|
1067
|
+
* @param {UserIdentifier} identifier
|
|
1068
|
+
* @param {string} password
|
|
1069
|
+
* @returns {Promise<void>}
|
|
1070
|
+
*/
|
|
1071
|
+
async changePasswordForUserBy(identifier, password) {
|
|
1072
|
+
return authFunctions.changePasswordForUserBy(this.config, identifier, password)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Set a user's status by identifier (admin function).
|
|
1077
|
+
* @param {UserIdentifier} identifier
|
|
1078
|
+
* @param {number} status
|
|
1079
|
+
* @returns {Promise<void>}
|
|
1080
|
+
*/
|
|
1081
|
+
async setStatusForUserBy(identifier, status) {
|
|
1082
|
+
return authFunctions.setStatusForUserBy(this.config, identifier, status)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Initiate a password reset for a user by identifier (admin function).
|
|
1087
|
+
* @param {UserIdentifier} identifier
|
|
1088
|
+
* @param {string|number|null} [expiresAfter]
|
|
1089
|
+
* @param {TokenCallback} [callback]
|
|
1090
|
+
* @returns {Promise<void>}
|
|
1091
|
+
*/
|
|
1092
|
+
async initiatePasswordResetForUserBy(identifier, expiresAfter, callback) {
|
|
1093
|
+
return authFunctions.initiatePasswordResetForUserBy(this.config, identifier, expiresAfter, callback)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Check whether a user exists by email (admin function).
|
|
1098
|
+
* @param {string} email
|
|
1099
|
+
* @returns {Promise<boolean>}
|
|
1100
|
+
*/
|
|
1101
|
+
async userExistsByEmail(email) {
|
|
1102
|
+
return authFunctions.userExistsByEmail(this.config, email)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Force logout a user by identifier; flags the current session if it is owned by that user.
|
|
1107
|
+
* @param {UserIdentifier} identifier
|
|
1108
|
+
* @returns {Promise<void>}
|
|
1109
|
+
*/
|
|
1110
|
+
async forceLogoutForUserBy(identifier) {
|
|
1111
|
+
const result = await authFunctions.forceLogoutForUserBy(this.config, identifier)
|
|
1112
|
+
|
|
1113
|
+
// the question is "did the owner of this session get force-logged-out?"
|
|
1114
|
+
// when impersonating, the session is owned by the actor, not the effective (target) identity.
|
|
1115
|
+
// force-logging-out the target should NOT kick the admin who is impersonating them.
|
|
1116
|
+
const sessionOwnerId = this.isImpersonating() ? this.getActorId() : this.getId()
|
|
1117
|
+
if (sessionOwnerId === result.accountId) {
|
|
1118
|
+
this.req.session.auth.shouldForceLogout = true
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Log in as another user (admin function), replacing the current session.
|
|
1124
|
+
* @param {UserIdentifier} identifier
|
|
1125
|
+
* @returns {Promise<void>}
|
|
1126
|
+
* @throws {UserNotFoundError} No account matches the identifier
|
|
1127
|
+
*/
|
|
1128
|
+
async loginAsUserBy(identifier) {
|
|
1129
|
+
const account = await this.findAccountByIdentifier(identifier)
|
|
1130
|
+
|
|
1131
|
+
if (!account) {
|
|
1132
|
+
throw new UserNotFoundError()
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
await this.onLoginSuccessful(account, false)
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Check whether the current session is impersonating another user.
|
|
1140
|
+
* @returns {boolean}
|
|
1141
|
+
*/
|
|
1142
|
+
isImpersonating() {
|
|
1143
|
+
return !!this.req.session?.auth?.actor
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get the account id of the original (actor) user when impersonating.
|
|
1148
|
+
* @returns {number|null} Actor account id when impersonating, null otherwise
|
|
1149
|
+
*/
|
|
1150
|
+
getActorId() {
|
|
1151
|
+
return this.req.session?.auth?.actor?.accountId ?? null
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Get the email of the original (actor) user when impersonating.
|
|
1156
|
+
* @returns {string|null} Actor email when impersonating, null otherwise
|
|
1157
|
+
*/
|
|
1158
|
+
getActorEmail() {
|
|
1159
|
+
return this.req.session?.auth?.actor?.email ?? null
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Get a structured summary of the current impersonation session.
|
|
1164
|
+
* @returns {ImpersonationInfo|null} ImpersonationInfo when impersonating, null otherwise
|
|
1165
|
+
*/
|
|
1166
|
+
getImpersonationInfo() {
|
|
1167
|
+
const auth = this.req.session?.auth
|
|
1168
|
+
if (!auth?.actor) return null
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
actor: {
|
|
1172
|
+
accountId: auth.actor.accountId,
|
|
1173
|
+
userId: auth.actor.userId,
|
|
1174
|
+
email: auth.actor.email,
|
|
1175
|
+
rolemask: auth.actor.rolemask,
|
|
1176
|
+
},
|
|
1177
|
+
target: {
|
|
1178
|
+
accountId: auth.accountId,
|
|
1179
|
+
userId: auth.userId,
|
|
1180
|
+
email: auth.email,
|
|
1181
|
+
rolemask: auth.rolemask,
|
|
1182
|
+
},
|
|
1183
|
+
startedAt: new Date(auth.actor.startedAt),
|
|
1184
|
+
expiresAt: auth.actor.expiresAt ? new Date(auth.actor.expiresAt) : undefined,
|
|
1185
|
+
reason: auth.actor.reason,
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Begin impersonating another user while preserving the actor identity.
|
|
1191
|
+
* @param {UserIdentifier} identifier
|
|
1192
|
+
* @param {StartImpersonationOptions} [options={}]
|
|
1193
|
+
* @returns {Promise<void>}
|
|
1194
|
+
* @throws {UserNotLoggedInError} No active session
|
|
1195
|
+
* @throws {ImpersonationDisabledError} config.impersonation.enabled is false
|
|
1196
|
+
* @throws {AlreadyImpersonatingError} Another impersonation session is already active
|
|
1197
|
+
* @throws {UserNotFoundError} No account matches the identifier
|
|
1198
|
+
* @throws {ImpersonationNotAllowedError} canImpersonate returned false, or target is the actor
|
|
1199
|
+
*/
|
|
1200
|
+
async startImpersonation(identifier, options = {}) {
|
|
1201
|
+
if (!this.isLoggedIn()) {
|
|
1202
|
+
throw new UserNotLoggedInError()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (!this.config.impersonation?.enabled) {
|
|
1206
|
+
throw new ImpersonationDisabledError()
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (this.isImpersonating()) {
|
|
1210
|
+
throw new AlreadyImpersonatingError()
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const actor = await this.getAuthAccount()
|
|
1214
|
+
if (!actor) {
|
|
1215
|
+
throw new UserNotFoundError()
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const target = await this.findAccountByIdentifier(identifier)
|
|
1219
|
+
if (!target) {
|
|
1220
|
+
throw new UserNotFoundError()
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (target.id === actor.id) {
|
|
1224
|
+
await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, { reason: "self_impersonation", targetAccountId: target.id })
|
|
1225
|
+
throw new ImpersonationNotAllowedError()
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// fail closed if the policy hook throws - never silently allow impersonation when the
|
|
1229
|
+
// policy can't be evaluated. preserves the underlying error message in audit metadata.
|
|
1230
|
+
let allowed = false
|
|
1231
|
+
try {
|
|
1232
|
+
allowed = (await this.config.impersonation.canImpersonate?.(actor, target)) ?? false
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, {
|
|
1235
|
+
reason: "policy_error",
|
|
1236
|
+
targetAccountId: target.id,
|
|
1237
|
+
policyError: err?.message ?? String(err),
|
|
1238
|
+
})
|
|
1239
|
+
throw new ImpersonationNotAllowedError()
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (!allowed) {
|
|
1243
|
+
await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, { reason: "denied", targetAccountId: target.id })
|
|
1244
|
+
throw new ImpersonationNotAllowedError()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// resolve ttl: explicit option wins, then config default, capped by config.maxTtl
|
|
1248
|
+
const requestedMs = options.ttl !== undefined ? ms(options.ttl) : this.config.impersonation.defaultTtl ? ms(this.config.impersonation.defaultTtl) : null
|
|
1249
|
+
const maxMs = this.config.impersonation.maxTtl ? ms(this.config.impersonation.maxTtl) : null
|
|
1250
|
+
const effectiveMs = requestedMs !== null && maxMs !== null ? Math.min(requestedMs, maxMs) : (requestedMs ?? maxMs)
|
|
1251
|
+
const expiresAt = effectiveMs !== null ? new Date(Date.now() + effectiveMs) : undefined
|
|
1252
|
+
|
|
1253
|
+
const actorSnapshot = {
|
|
1254
|
+
accountId: actor.id,
|
|
1255
|
+
userId: actor.user_id,
|
|
1256
|
+
email: actor.email,
|
|
1257
|
+
rolemask: actor.rolemask,
|
|
1258
|
+
forceLogout: actor.force_logout,
|
|
1259
|
+
startedAt: new Date(),
|
|
1260
|
+
expiresAt,
|
|
1261
|
+
reason: options.reason,
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const newSession = {
|
|
1265
|
+
loggedIn: true,
|
|
1266
|
+
accountId: target.id,
|
|
1267
|
+
userId: target.user_id,
|
|
1268
|
+
email: target.email,
|
|
1269
|
+
status: target.status,
|
|
1270
|
+
rolemask: target.rolemask,
|
|
1271
|
+
remembered: false,
|
|
1272
|
+
lastResync: new Date(),
|
|
1273
|
+
lastRememberCheck: new Date(),
|
|
1274
|
+
forceLogout: target.force_logout,
|
|
1275
|
+
verified: target.verified,
|
|
1276
|
+
hasPassword: target.password !== null,
|
|
1277
|
+
shouldForceLogout: false,
|
|
1278
|
+
actor: actorSnapshot,
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
await this.regenerateSessionWith(newSession)
|
|
1282
|
+
|
|
1283
|
+
// logged after session mutation so actor_account_id auto-pickup resolves to the admin
|
|
1284
|
+
await this.activityLogger.logActivity(target.id, AuthActivityAction.ImpersonationStarted, this.req, true, {
|
|
1285
|
+
targetAccountId: target.id,
|
|
1286
|
+
targetEmail: target.email,
|
|
1287
|
+
reason: options.reason,
|
|
1288
|
+
expiresAt: expiresAt?.toISOString(),
|
|
1289
|
+
})
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Stop the current impersonation session and revert to the actor's identity.
|
|
1294
|
+
* @returns {Promise<void>}
|
|
1295
|
+
* @throws {NotImpersonatingError} No active impersonation session
|
|
1296
|
+
*/
|
|
1297
|
+
async stopImpersonation() {
|
|
1298
|
+
if (!this.isImpersonating()) {
|
|
1299
|
+
throw new NotImpersonatingError()
|
|
1300
|
+
}
|
|
1301
|
+
await this.stopImpersonationInternal("manual")
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// internal stop that also handles auto-revert paths (expiry, target_gone) from resync
|
|
1305
|
+
async stopImpersonationInternal(cause) {
|
|
1306
|
+
const actor = this.req.session.auth.actor
|
|
1307
|
+
const targetAccountId = this.req.session.auth.accountId
|
|
1308
|
+
const targetEmail = this.req.session.auth.email
|
|
1309
|
+
|
|
1310
|
+
// log BEFORE clearing actor so the audit row carries actor_account_id.
|
|
1311
|
+
// when the target has been deleted the account_id FK would fail, so log against null in that case.
|
|
1312
|
+
const action = cause === "expired" ? AuthActivityAction.ImpersonationExpired : AuthActivityAction.ImpersonationStopped
|
|
1313
|
+
const logAccountId = cause === "target_gone" ? null : targetAccountId
|
|
1314
|
+
await this.activityLogger.logActivity(logAccountId, action, this.req, true, {
|
|
1315
|
+
targetAccountId,
|
|
1316
|
+
targetEmail,
|
|
1317
|
+
cause,
|
|
1318
|
+
startedAt: new Date(actor.startedAt).toISOString(),
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
const actorAccount = await this.queries.findAccountById(actor.accountId)
|
|
1322
|
+
if (!actorAccount) {
|
|
1323
|
+
// actor account is gone - nothing to revert to
|
|
1324
|
+
this.req.session.auth = undefined
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const newSession = {
|
|
1329
|
+
loggedIn: true,
|
|
1330
|
+
accountId: actorAccount.id,
|
|
1331
|
+
userId: actorAccount.user_id,
|
|
1332
|
+
email: actorAccount.email,
|
|
1333
|
+
status: actorAccount.status,
|
|
1334
|
+
rolemask: actorAccount.rolemask,
|
|
1335
|
+
remembered: false,
|
|
1336
|
+
lastResync: new Date(),
|
|
1337
|
+
lastRememberCheck: new Date(),
|
|
1338
|
+
forceLogout: actorAccount.force_logout,
|
|
1339
|
+
verified: actorAccount.verified,
|
|
1340
|
+
hasPassword: actorAccount.password !== null,
|
|
1341
|
+
shouldForceLogout: false,
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
await this.regenerateSessionWith(newSession)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async regenerateSessionWith(newAuth) {
|
|
1348
|
+
return new Promise((resolve, reject) => {
|
|
1349
|
+
if (!this.req.session?.regenerate) {
|
|
1350
|
+
this.req.session.auth = newAuth
|
|
1351
|
+
resolve()
|
|
1352
|
+
return
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
this.req.session.regenerate((err) => {
|
|
1356
|
+
if (err) {
|
|
1357
|
+
reject(err)
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
this.req.session.auth = newAuth
|
|
1361
|
+
this.req.session.save((saveErr) => {
|
|
1362
|
+
if (saveErr) {
|
|
1363
|
+
reject(saveErr)
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
resolve()
|
|
1367
|
+
})
|
|
1368
|
+
})
|
|
1369
|
+
})
|
|
1370
|
+
}
|
|
1371
|
+
}
|