4 * Here we wrangle activities which support login sessions, serving and
5 * processing the HTML forms a user interacts with.
8 const { Communication: IndieAuthCommunication
} = require('@squeep/indieauth-helper');
9 const { MysteryBox
} = require('@squeep/mystery-box');
10 const { TOTP
} = require('@squeep/totp');
11 const { randomUUID
} = require('node:crypto');
12 const common
= require('./common');
13 const Enum
= require('./enum');
14 const Template
= require('./template');
16 const _fileScope
= common
.fileScope(__filename
);
19 * @typedef {import('node:http')} http
20 * @typedef {import('./authenticator')} Authenticator
23 * @typedef {object} ConsoleLike
24 * @property {Function} debug log debug
25 * @property {Function} error log error
26 * @property {Function} info log info
29 class SessionManager
{
31 * @param {ConsoleLike} logger logger
32 * @param {Authenticator} authenticator authenticator instance
33 * @param {object} options options
34 * @param {object} options.authenticator authenticator instance options
35 * @param {string[]} options.authenticator.authnEnabled authentication methods enabled
36 * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
37 * @param {boolean} options.authenticator.secureAuthOnly allow only https
38 * @param {string=} options.authenticator.sessionCookieSameSite sameSite setting for session cookie, default Lax
39 * @param {object=} options.dingus dingus options
40 * @param {string=} options.dingus.proxyPrefix prefix on route paths
41 * @param {string} options.dingus.selfBaseUrl base url
42 * @param {object} options.manager manager options
43 * @param {string} options.manager.pageTitle page title
45 constructor(logger
, authenticator
, options
) {
47 this.authenticator
= authenticator
;
48 this.db
= authenticator
.db
; // TODO: take db arg in next major version bump
49 this.options
= options
;
50 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
51 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
?? true;
52 this.sameSite
= options
.authenticator
.sessionCookieSameSite
|| 'Lax';
53 this.indieAuthCommunication
= new IndieAuthCommunication(logger
, options
);
54 this.mysteryBox
= new MysteryBox(options
);
55 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
57 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
62 * Set or update our session cookie.
63 * @param {http.ServerResponse} res respoonse
64 * @param {object=} session session object
65 * @param {number=} maxAge session validity in seconds
66 * @param {string=} path session cookie path
68 async
_sessionCookieSet(res
, session
, maxAge
= this.cookieLifespan
, path
= '/') {
69 const cookieName
= Enum
.SessionCookie
;
70 const secureSession
= session
&& await
this.mysteryBox
.pack(session
) || '""';
71 common
.addCookie(res
, cookieName
, secureSession
, {
73 sameSite: this.sameSite
,
74 secure: this.secureAuthOnly
,
75 maxAge: session
&& maxAge
|| 0,
82 * Remove any current session cookie.
83 * @param {http.ServerResponse} res response
84 * @param {string} path session cookie path
86 async
_sessionCookieClear(res
, path
= '/') {
87 await
this._sessionCookieSet(res
, undefined, 0, path
);
91 * @typedef {(pagePathLevel: number, ctx: object, htmlOptions: object) => void} AppTemplateCallback
95 * GET request for establishing admin session.
96 * @param {http.ServerResponse} res response
97 * @param {object} ctx context
98 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
100 async
getAdminLogin(res
, ctx
, appCb
) {
101 const _scope
= _fileScope('getAdminLogin');
102 this.logger
.debug(_scope
, 'called', { ctx
});
104 // If there is already a valid session established, and if a
105 // redirect is present, follow it. Why? Maybe someone had
106 // multiple tabs open, their session expired, and their browser
107 // restarted, reloading all of them, and they have relogged in
108 // on one and just want to hit reload on the others to go back
109 // to where they were.
110 const redirect
= ctx
.queryParams
['r'];
111 if (ctx
.authenticationId
&& redirect
) {
112 res
.statusCode
= 302;
113 res
.setHeader(Enum
.Header
.Location
, redirect
);
116 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
119 this.logger
.info(_scope
, 'finished', { ctx
});
124 * POST request for taking form data to establish admin session.
125 * @param {http.ServerResponse} res response
126 * @param {object} ctx context
127 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
129 async
postAdminLogin(res
, ctx
, appCb
) {
130 const _scope
= _fileScope('postAdminLogin');
131 this.logger
.debug(_scope
, 'called', { ctx
});
135 // Check if this was an OTP entry attempt.
136 if (await
this._otpSubmission(res
, ctx
, appCb
)) {
137 // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
141 if (await
this._localUserAuth(res
, ctx
, appCb
)) {
142 // Local auth path was taken.
146 // Otherwise, carry on with IndieAuth handshake.
148 // Is profile a reasonable url?
149 let me
, meAutoScheme
, session
, authorizationEndpoint
;
151 me
= new URL(ctx
.parsedBody
['me']);
152 meAutoScheme
= !!ctx
.parsedBody
['me_auto_scheme'];
153 } catch (e
) { // eslint-disable-line no-unused-vars
154 this.logger
.debug(_scope
, 'failed to parse supplied profile url', { ctx
});
155 ctx
.errors
.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
158 if (ctx
.errors
.length
) {
159 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
164 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
165 if ((!profile
?.metadata
)
167 this.logger
.debug(_scope
, 'trying http fallback', { ctx
});
168 me
.protocol
= 'http';
169 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
171 if (!profile
?.metadata
) {
172 this.logger
.debug(_scope
, 'failed to find any profile information at url', { ctx
});
173 ctx
.errors
.push(`No profile information was found at '${me}'.`);
175 // fetch and parse me for 'authorization_endpoint' relation links
177 authorizationEndpoint
= new URL(profile
.metadata
.authorizationEndpoint
);
178 } catch (e
) { // eslint-disable-line no-unused-vars
179 ctx
.errors
.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
182 if (profile
.metadata
.issuer
) {
185 const issuer
= new URL(profile
.metadata
.issuer
);
188 || issuer
.protocol
.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
189 this.logger
.debug(_scope
, 'supplied issuer url invalid', { ctx
});
190 ctx
.errors
.push('Authorization server provided an invalid issuer field.');
192 } catch (e
) { // eslint-disable-line no-unused-vars
193 this.logger
.debug(_scope
, 'failed to parse supplied issuer url', { ctx
});
194 ctx
.errors
.push('Authorization server provided an unparsable issuer field.');
197 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
198 // Strict 20220212 compliance would error here.
199 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
203 if (authorizationEndpoint
) {
204 const pkce
= await IndieAuthCommunication
.generatePKCE();
206 const state
= randomUUID();
207 const redirect
= ctx
.queryParams
['r'] || './';
209 authorizationEndpoint: authorizationEndpoint
.href
,
211 codeVerifier: pkce
.codeVerifier
,
214 issuer: profile
.metadata
.issuer
,
217 // Update auth endpoint parameters
219 'response_type': 'code',
220 'client_id': this.options
.dingus
.selfBaseUrl
,
221 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
222 'state': session
.state
,
223 'code_challenge': pkce
.codeChallenge
,
224 'code_challenge_method': pkce
.codeChallengeMethod
,
226 }).forEach(([name
, value
]) => authorizationEndpoint
.searchParams
.set(name
, value
));
229 if (ctx
.errors
.length
) {
230 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
234 await
this._sessionCookieSet(res
, session
);
235 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
236 res
.setHeader(Enum
.Header
.Location
, authorizationEndpoint
.href
);
237 res
.statusCode
= 302; // Found
240 this.logger
.info(_scope
, 'finished indieauth', { ctx
});
245 * @typedef {object} OTPState
246 * @property {string} authenticatedIdentifier identifier of logging-in user
247 * @property {Buffer | string} key otp key
248 * @property {number} attempt counter
249 * @property {number} epochMs started
250 * @property {string} redirect where to go after successful otp entry
253 * @param {OTPState} otpState otp state
255 static _validateOTPState(otpState
) {
256 if (!otpState
.authenticatedIdentifier
) {
257 throw new Error('otp state missing authentication identifier');
260 throw new Error('otp state missing otp key');
262 if (!('attempt' in otpState
)) {
263 throw new Error('otp state missing attempt count');
265 if (!('epochMs' in otpState
)) {
266 throw new Error('otp state missing timestamp');
268 if (!otpState
.redirect
) {
269 throw new Error('otp state missing redirect');
275 * Check if processing an OTP entry attempt. If not, resume login flow.
276 * If so, validate otp and establish session, else reprompt for OTP, or
277 * return to login entry after too many failures.
278 * @param {http.ServerResponse} res response
279 * @param {object} ctx context
280 * @param {object} ctx.parsedBody submitted data
281 * @param {string} ctx.parsedBody.state packed state
282 * @param {string} ctx.parsedBody.otp entered code
283 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
284 * @returns {Promise<boolean>} true if otp was handled, otherwise false indicates further login processing needed
286 async
_otpSubmission(res
, ctx
, appCb
) {
287 const _scope
= _fileScope('_otpSubmission');
293 // Are we processing an OTP entry attempt?
295 // Ignore and continue back to main login.
298 /** @type {OTPState} */
301 state
= await
this.mysteryBox
.unpack(stateBox
);
302 this.constructor._validateOTPState(state
);
304 this.logger
.debug(_scope
, 'failed to unpack otp state', { error: e
, ctx
});
305 // Ignore and continue back to main login.
310 // Nothing submitted, but valid state, just present otp form again, do not count as attempt.
311 ctx
.otpState
= stateBox
;
312 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
313 this.logger
.info(_scope
, 'finished otp, nothing entered, request again', { ctx
});
317 const OTPResult
= await
this.authenticator
.checkOTP(state
, otp
);
319 case Enum
.OTPResult
.Valid:
320 // Valid auth, persist the authenticated session
322 authenticatedIdentifier: state
.authenticatedIdentifier
,
324 await
this._sessionCookieSet(res
, ctx
.session
);
325 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
326 res
.statusCode
= 302;
327 res
.setHeader(Enum
.Header
.Location
, state
.redirect
);
329 this.logger
.info(_scope
, 'finished otp', { ctx
});
332 case Enum
.OTPResult
.InvalidSoftFail:
334 ctx
.errors
.push('Invalid OTP token.');
335 ctx
.otpState
= await
this.mysteryBox
.pack({
337 attempt: state
.attempt
+ 1,
339 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
340 this.logger
.info(_scope
, 'finished otp, invalid, request again', { ctx
});
343 case Enum
.OTPResult
.InvalidHardFail:
344 // Return to initial login.
345 this.logger
.debug(_scope
, 'too many otp failures', { ctx
});
346 ctx
.errors
.push('Unable to verify OTP token at this time. Try again.');
350 throw new RangeError('Unexpected OTPResult');
357 * @param {http.ServerResponse} res response
358 * @param {object} ctx context
359 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
360 * @returns {Promise<boolean>} true if handled, false if flow should continue
362 async
_localUserAuth(res
, ctx
, appCb
) {
363 const _scope
= _fileScope('_localUserAuth');
365 // If Indiauth enabled and profile was submitted, defer to that.
366 if (this.options
.authenticator
.authnEnabled
.includes('indieAuth')
367 && ctx
.parsedBody
['me']) {
371 const redirect
= ctx
.queryParams
['r'] || './';
372 const identifier
= ctx
.parsedBody
['identifier'];
373 const credential
= ctx
.parsedBody
['credential']; // N.B. Logger must specifically mask this field from ctx.
375 // N.B. validity check also sets authenticationId and maybe otpKey on ctx
376 const isValidLocalIdentifier
= await
this.authenticator
.isValidIdentifierCredential(identifier
, credential
, ctx
);
377 if (!isValidLocalIdentifier
) {
378 ctx
.errors
.push('Invalid username or password');
381 if (ctx
.errors
.length
) {
382 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
386 // If OTP exists for valid identifier, follow that flow.
388 ctx
.otpState
= await
this.mysteryBox
.pack({
389 authenticatedIdentifier: ctx
.authenticationId
,
395 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
396 this.logger
.info(_scope
, 'finished local, otp required', { ctx
});
400 // Valid auth, persist the authenticated session
402 authenticatedIdentifier: ctx
.authenticationId
,
404 await
this._sessionCookieSet(res
, ctx
.session
);
405 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
406 res
.statusCode
= 302;
407 res
.setHeader(Enum
.Header
.Location
, redirect
);
409 this.logger
.info(_scope
, 'finished local, no otp', { ctx
});
415 * GET request to remove current credentials.
416 * @param {http.ServerResponse} res response
417 * @param {object} ctx context
419 async
getAdminLogout(res
, ctx
) {
420 const _scope
= _fileScope('getAdminLogout');
421 this.logger
.debug(_scope
, 'called', { ctx
});
423 await
this._sessionCookieClear(res
);
424 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedOut
);
426 const redirect
= ctx
.queryParams
['r'] || './';
428 res
.statusCode
= 302;
429 res
.setHeader(Enum
.Header
.Location
, redirect
);
432 this.logger
.info(_scope
, 'finished', { ctx
});
437 * GET request for returning IndieAuth redirect.
438 * This currently only redeems a scope-less profile.
439 * @param {http.ServerResponse} res response
440 * @param {object} ctx context
441 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
443 async
getAdminIA(res
, ctx
, appCb
) {
444 const _scope
= _fileScope('getAdminIA');
445 this.logger
.debug(_scope
, 'called', { ctx
});
450 // Unpack cookie to restore session data
452 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
454 this.logger
.debug(_scope
, 'no cookie', { ctx
});
455 ctx
.errors
.push('missing required cookie');
458 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
459 this.logger
.debug(_scope
, 'restored session from cookie', { ctx
});
461 this.logger
.debug(_scope
, 'could not unpack cookie', { error: e
});
462 ctx
.errors
.push('invalid cookie');
466 // Validate unpacked session values
469 // Add any auth errors
470 if (ctx
.queryParams
['error']) {
471 ctx
.errors
.push(ctx
.queryParams
['error']);
472 if (ctx
.queryParams
['error_description']) {
473 ctx
.errors
.push(ctx
.queryParams
['error_description']);
478 if (ctx
.queryParams
['state'] !== ctx
.session
.state
) {
479 this.logger
.debug(_scope
, 'state mismatch', { ctx
});
480 ctx
.errors
.push('invalid state');
483 const code
= ctx
.queryParams
['code'];
485 this.logger
.debug(_scope
, 'missing code', { ctx
});
486 ctx
.errors
.push('invalid code');
490 if (ctx
.session
.issuer
) {
491 if (ctx
.queryParams
['iss'] !== ctx
.session
.issuer
) {
492 this.logger
.debug(_scope
, 'issuer mismatch', { ctx
});
493 ctx
.errors
.push('invalid issuer');
496 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
497 // Strict 20220212 compliance would error here. (Also earlier.)
498 // ctx.errors.push('invalid issuer');
501 let redeemProfileUrl
;
503 redeemProfileUrl
= new URL(ctx
.session
.authorizationEndpoint
);
504 } catch (e
) { // eslint-disable-line no-unused-vars
505 this.logger
.debug(_scope
, 'failed to parse restored session authorization endpoint as url', { ctx
});
506 ctx
.errors
.push('invalid cookie');
509 if (redeemProfileUrl
) {
510 profile
= await
this.indieAuthCommunication
.redeemProfileCode(redeemProfileUrl
, code
, ctx
.session
.codeVerifier
, this.options
.dingus
.selfBaseUrl
, `${this.options.dingus.selfBaseUrl}admin/_ia`);
512 this.logger
.debug(_scope
, 'no profile from code redemption', { ctx
});
513 ctx
.errors
.push('did not get a profile response from authorization endpoint code redemption');
514 } else if (!profile
.me
) {
515 this.logger
.debug(_scope
, 'no profile me identifier from code redemption', { ctx
});
516 ctx
.errors
.push('did not get \'me\' value from authorization endpoint code redemption');
517 } else if (profile
.me
!== ctx
.session
.me
) {
518 this.logger
.debug(_scope
, 'mis-matched canonical me from redeemed profile', { ctx
, profile
});
519 const newProfileUrl
= new URL(profile
.me
);
520 // Rediscover auth endpoint for the new returned profile.
521 const newProfile
= await
this.indieAuthCommunication
.fetchProfile(newProfileUrl
);
522 if (newProfile
.metadata
.authorizationEndpoint
!== ctx
.session
.authorizationEndpoint
) {
523 this.logger
.debug(_scope
, 'mis-matched auth endpoints between provided me and canonical me', { ctx
, profile
, newProfile
});
524 ctx
.errors
.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
526 // The endpoints match, all is okay, update our records.
527 ctx
.session
.me
= profile
.me
;
532 if (ctx
.errors
.length
) {
533 await
this._sessionCookieClear(res
);
534 res
.end(Template
.IAHTML(ctx
, this.options
, appCb
));
538 const redirect
= ctx
.session
.redirect
|| './';
540 // Set cookie as auth valid, redirect to original location.
542 authenticatedProfile: ctx
.session
.me
,
545 await
this._sessionCookieSet(res
, ctx
.session
);
546 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
547 res
.statusCode
= 302;
548 res
.setHeader(Enum
.Header
.Location
, redirect
);
551 this.logger
.info(_scope
, 'finished', { ctx
});
556 * @typedef {object} AuthInfo
557 * @property {string} identifier identifier
558 * @property {string} credential hashed credential
559 * @property {string=} otpKey otp key
562 * Page for modifying credentials and OTP.
563 * @param {http.ServerResponse} res response
564 * @param {object} ctx context
565 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
567 async
getAdminSettings(res
, ctx
, appCb
) {
568 const _scope
= _fileScope('getAdminSettings');
569 this.logger
.debug(_scope
, 'called', { ctx
});
572 await
this.db
.context(async (dbCtx
) => {
573 const authData
= await
this.db
.authenticationGet(dbCtx
, ctx
.authenticationId
);
575 ctx
.errors
.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
578 ctx
.otpKey
= authData
.otpKey
;
581 this.logger
.error(_scope
, 'failed', { ctx
, error: e
});
582 ctx
.errors
.push('An error was encountered. Sorry that is not very helpful.');
585 res
.end(Template
.SettingsHTML(ctx
, this.options
, appCb
));
586 this.logger
.info(_scope
, 'finished', { ctx
});
591 * Page for modifying credentials and OTP.
592 * @param {http.ServerResponse} res response
593 * @param {object} ctx context
594 * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
596 async
postAdminSettings(res
, ctx
, appCb
) {
597 const _scope
= _fileScope('postAdminSettings');
598 this.logger
.debug(_scope
, 'called', { ctx
});
601 await
this.db
.context(async (dbCtx
) => {
602 const authData
= await
this.db
.authenticationGet(dbCtx
, ctx
.authenticationId
);
604 ctx
.errors
.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
607 ctx
.otpKey
= authData
.otpKey
;
609 const otpSubmitButton
= ctx
.parsedBody
?.otp
;
610 switch (otpSubmitButton
) {
612 await
this._otpDisable(dbCtx
, ctx
, authData
);
616 await
this._otpConfirm(dbCtx
, ctx
);
620 await
this._otpEnable(ctx
);
624 const credentialSubmitButton
= ctx
.parsedBody
?.credential
;
625 switch (credentialSubmitButton
) { // eslint-disable-line sonarjs/no-small-switch
627 await
this._credentialUpdate(dbCtx
, ctx
, authData
);
632 this.logger
.error(_scope
, 'failed', { ctx
, error: e
});
633 ctx
.errors
.push('An error was encountered. Sorry that is not very helpful.');
636 res
.end(Template
.SettingsHTML(ctx
, this.options
, appCb
));
637 this.logger
.info(_scope
, 'finished', { ctx
});
642 * Submission to disable OTP.
643 * @param {*} dbCtx db context
644 * @param {*} ctx context
645 * @param {AuthInfo} authData auth info
647 async
_otpDisable(dbCtx
, ctx
, authData
) {
648 const _scope
= _fileScope('_otpDisable');
650 authData
.otpKey
= null;
651 await
this.db
.authenticationUpdateOTPKey(dbCtx
, ctx
.authenticationId
, null);
652 ctx
.notifications
.push('OTP removed!');
654 this.logger
.info(_scope
, 'otp disabled', { identifier: ctx
.authenticationId
});
656 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
657 ctx
.errors
.push('Failed to disable OTP!');
663 * Submission to enable OTP.
664 * @param {object} ctx context
666 async
_otpEnable(ctx
) {
667 const _scope
= _fileScope('_otpEnable');
669 ctx
.otpConfirmKey
= await TOTP
.createKey('sha1', 'base32');
670 ctx
.otpConfirmBox
= await
this.mysteryBox
.pack({
671 otpKey: ctx
.otpConfirmKey
,
673 otpInitiatedMs: Date
.now(),
676 delete ctx
.otpConfirmKey
;
677 delete ctx
.otpConfirmBox
;
678 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
679 ctx
.errors
.push('Failed to enable OTP!');
685 * Submission to confirm enabling OTP.
686 * @param {*} dbCtx db context
687 * @param {object} ctx context
689 async
_otpConfirm(dbCtx
, ctx
) {
690 const _scope
= _fileScope('_otpConfirm');
693 'otp-box': otpConfirmBox
,
694 'otp-token': otpToken
,
696 let otpKey
, otpAttempt
, otpInitiatedMs
;
698 ({ otpKey
, otpAttempt
, otpInitiatedMs
} = await
this.mysteryBox
.unpack(otpConfirmBox
));
700 this.logger
.debug(_scope
, 'failed to unpack otp box', { error: e
, ctx
});
701 ctx
.errors
.push('Problem with form data.');
705 // No token entered, just prompt again.
706 ctx
.otpConfirmKey
= otpKey
;
707 ctx
.otpConfirmBox
= otpConfirmBox
;
708 ctx
.notifications
.push('Please enter the OTP token to enable 2FA.');
712 const totp
= new TOTP({
714 keyEncoding: 'base32',
716 if (!totp
.validate(otpToken
)) {
717 // Bad token, prompt again.
718 ctx
.otpConfirmKey
= otpKey
;
719 ctx
.otpConfirmBox
= await
this.mysteryBox
.pack({
724 ctx
.errors
.push('Invalid token!');
729 await
this.db
.context(async (dbCtx
) => {
730 await
this.db
.authenticationUpdateOTPKey(dbCtx
, ctx
.authenticationId
, otpKey
);
732 ctx
.notifications
.push('OTP enabled!');
733 this.logger
.info(_scope
, 'otp enabled', { identifier: ctx
.authenticationId
, otpAttempt
, otpInitiatedMs
});
736 this.logger
.debug(_scope
, 'failed', { error: e
, ctx
});
737 ctx
.errors
.push('An error occurred, OTP was not enabled. Sorry this is not very helpful.');
743 * Submission to set new credential.
744 * @param {*} dbCtx db context
745 * @param {object} ctx context
746 * @param {AuthInfo} authData auth info
748 async
_credentialUpdate(dbCtx
, ctx
, authData
) {
749 const _scope
= _fileScope('_credentialUpdate');
752 'credential-new': newCredential
,
753 'credential-new-2': newCredential2
,
754 'credential-current': oldCredential
,
756 if (newCredential
!== newCredential2
) {
757 ctx
.errors
.push('New password confirmation did not match!');
759 if (!newCredential
) {
760 ctx
.errors
.push('Password cannot be empty!');
762 if (! await
this.authenticator
._validateAuthDataCredential(authData
, oldCredential
)) {
763 ctx
.errors
.push('Invalid current password!');
765 if (ctx
.errors
.length
) {
769 await
this.authenticator
.updateCredential(dbCtx
, ctx
.authenticationId
, newCredential
);
770 ctx
.notifications
.push('Password updated!');
772 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
773 ctx
.errors
.push('Failed to update password!');
779 module
.exports
= SessionManager
;