1.4.0
[squeep-authentication-module] / lib / session-manager.js
1 'use strict';
2
3 /**
4 * Here we wrangle activities which support login sessions, serving and
5 * processing the HTML forms a user interacts with.
6 */
7
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('crypto');
12 const common = require('./common');
13 const Enum = require('./enum');
14 const Template = require('./template');
15
16 const _fileScope = common.fileScope(__filename);
17
18 class SessionManager {
19 /**
20 * @param {Console} logger
21 * @param {Authenticator} authenticator
22 * @param {Object} options
23 * @param {Object} options.authenticator
24 * @param {String[]} options.authenticator.authnEnabled
25 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
26 * @param {Boolean} options.authenticator.secureAuthOnly
27 * @param {Object=} options.dingus
28 * @param {String=} options.dingus.proxyPrefix
29 * @param {String} options.dingus.selfBaseUrl
30 * @param {Object} options.manager
31 * @param {String} options.manager.pageTitle
32 */
33 constructor(logger, authenticator, options) {
34 this.logger = logger;
35 this.authenticator = authenticator;
36 this.db = authenticator.db; // TODO: take db arg in next major version bump
37 this.options = options;
38 this.proxyPrefix = options.dingus?.proxyPrefix ?? '';
39 this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
40 this.mysteryBox = new MysteryBox(options);
41 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
42
43 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
44 }
45
46
47 /**
48 * Set or update our session cookie.
49 * @param {http.ServerResponse} res
50 * @param {Object=} session
51 * @param {Number=} maxAge
52 * @param {String=} path
53 */
54 async _sessionCookieSet(res, session, maxAge = this.cookieLifespan, path = '/') {
55 const cookieName = Enum.SessionCookie;
56 const secureSession = session && await this.mysteryBox.pack(session) || '""';
57 common.addCookie(res, cookieName, secureSession, {
58 httpOnly: true,
59 sameSite: 'Lax',
60 secure: this.options.authenticator.secureAuthOnly,
61 maxAge: session && maxAge || 0,
62 path,
63 });
64 }
65
66
67 /**
68 * Remove any current session cookie.
69 * @param {http.ServerResponse} res
70 * @param {String} path
71 */
72 async _sessionCookieClear(res, path = '/') {
73 await this._sessionCookieSet(res, undefined, 0, path);
74 }
75
76 /**
77 * @typedef {(pagePathLevel: Number, ctx: Object, htmlOptions: Object) => void} AppTemplateCallback
78 */
79
80 /**
81 * GET request for establishing admin session.
82 * @param {http.ServerResponse} res
83 * @param {Object} ctx
84 * @param {AppTemplateCallback} appCb
85 */
86 async getAdminLogin(res, ctx, appCb) {
87 const _scope = _fileScope('getAdminLogin');
88 this.logger.debug(_scope, 'called', { ctx });
89
90 // If there is already a valid session established, and if a
91 // redirect is present, follow it. Why? Maybe someone had
92 // multiple tabs open, their session expired, and their browser
93 // restarted, reloading all of them, and they have relogged in
94 // on one and just want to hit reload on the others to go back
95 // to where they were.
96 const redirect = ctx.queryParams['r'];
97 if (ctx.authenticationId && redirect) {
98 res.statusCode = 302;
99 res.setHeader(Enum.Header.Location, redirect);
100 res.end();
101 } else {
102 res.end(Template.LoginHTML(ctx, this.options, appCb));
103 }
104
105 this.logger.info(_scope, 'finished', { ctx });
106 }
107
108
109 /**
110 * POST request for taking form data to establish admin session.
111 * @param {http.ServerResponse} res
112 * @param {Object} ctx
113 * @param {AppTemplateCallback} appCb
114 */
115 async postAdminLogin(res, ctx, appCb) {
116 const _scope = _fileScope('postAdminLogin');
117 this.logger.debug(_scope, 'called', { ctx });
118
119 ctx.errors = [];
120
121 // Check if this was an OTP entry attempt.
122 if (await this._otpSubmission(res, ctx, appCb)) {
123 // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
124 return;
125 }
126
127 if (await this._localUserAuth(res, ctx, appCb)) {
128 // Local auth path was taken.
129 return;
130 }
131
132 // Otherwise, carry on with IndieAuth handshake.
133
134 // Is profile a reasonable url?
135 let me, meAutoScheme, session, authorizationEndpoint;
136 try {
137 me = new URL(ctx.parsedBody['me']);
138 meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
139 } catch (e) {
140 this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
141 ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
142 }
143
144 if (ctx.errors.length) {
145 res.end(Template.LoginHTML(ctx, this.options, appCb));
146 return;
147 }
148
149 let profile;
150 profile = await this.indieAuthCommunication.fetchProfile(me);
151 if ((!profile?.metadata)
152 && meAutoScheme) {
153 this.logger.debug(_scope, 'trying http fallback', { ctx });
154 me.protocol = 'http';
155 profile = await this.indieAuthCommunication.fetchProfile(me);
156 }
157 if (!profile?.metadata) {
158 this.logger.debug(_scope, 'failed to find any profile information at url', { ctx });
159 ctx.errors.push(`No profile information was found at '${me}'.`);
160 } else {
161 // fetch and parse me for 'authorization_endpoint' relation links
162 try {
163 authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
164 } catch (e) {
165 ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
166 }
167
168 if (profile.metadata.issuer) {
169 // Validate issuer
170 try {
171 const issuer = new URL(profile.metadata.issuer);
172 if (issuer.hash
173 || issuer.search
174 || issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
175 this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
176 ctx.errors.push('Authorization server provided an invalid issuer field.');
177 }
178 } catch (e) {
179 this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
180 ctx.errors.push('Authorization server provided an unparsable issuer field.');
181 }
182 } else {
183 this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
184 // Strict 20220212 compliance would error here.
185 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
186 }
187 }
188
189 if (authorizationEndpoint) {
190 const pkce = await IndieAuthCommunication.generatePKCE();
191
192 const state = randomUUID();
193 const redirect = ctx.queryParams['r'] || './';
194 session = {
195 authorizationEndpoint: authorizationEndpoint.href,
196 state,
197 codeVerifier: pkce.codeVerifier,
198 me,
199 redirect,
200 issuer: profile.metadata.issuer,
201 };
202
203 // Update auth endpoint parameters
204 Object.entries({
205 'response_type': 'code',
206 'client_id': this.options.dingus.selfBaseUrl,
207 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
208 'state': session.state,
209 'code_challenge': pkce.codeChallenge,
210 'code_challenge_method': pkce.codeChallengeMethod,
211 'me': me,
212 }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
213 }
214
215 if (ctx.errors.length) {
216 res.end(Template.LoginHTML(ctx, this.options, appCb));
217 return;
218 }
219
220 await this._sessionCookieSet(res, session);
221 res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
222 res.statusCode = 302; // Found
223 res.end();
224
225 this.logger.info(_scope, 'finished indieauth', { ctx });
226 }
227
228
229 /**
230 * @typedef {Object} OTPState
231 * @property {String} authenticatedIdentifier
232 * @property {Buffer|String} key
233 * @property {Number} attempt
234 * @property {Number} epochMs
235 * @property {String} redirect
236 */
237 /**
238 * @param {OTPState} otpState
239 */
240 static _validateOTPState(otpState) {
241 if (!otpState.authenticatedIdentifier) {
242 throw new Error('otp state missing authentication identifier');
243 }
244 if (!otpState.key) {
245 throw new Error('otp state missing otp key');
246 }
247 if (!('attempt' in otpState)) {
248 throw new Error('otp state missing attempt count');
249 }
250 if (!('epochMs' in otpState)) {
251 throw new Error('otp state missing timestamp');
252 }
253 if (!otpState.redirect) {
254 throw new Error('otp state missing redirect');
255 }
256 }
257
258
259 /**
260 * Check if processing an OTP entry attempt. If not, resume login flow.
261 * If so, validate otp and establish session, else reprompt for OTP, or
262 * return to login entry after too many failures.
263 * @param {http.ServerResponse} res
264 * @param {Object} ctx
265 * @param {Object} ctx.parsedBody
266 * @param {String} ctx.parsedBody.state
267 * @param {String} ctx.parsedBody.otp
268 * @returns {Promise<Boolean>} true if otp was handled, otherwise false indicates further login processing needed
269 */
270 async _otpSubmission(res, ctx, appCb) {
271 const _scope = _fileScope('_otpSubmission');
272
273 const {
274 otp,
275 state: stateBox,
276 } = ctx.parsedBody;
277 // Are we processing an OTP entry attempt?
278 if (!stateBox) {
279 // Ignore and continue back to main login.
280 return false;
281 }
282 /** @type OTPState */
283 let state;
284 try {
285 state = await this.mysteryBox.unpack(stateBox);
286 this.constructor._validateOTPState(state);
287 } catch (e) {
288 this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx });
289 // Ignore and continue back to main login.
290 return false;
291 }
292
293 if (!otp) {
294 // Nothing submitted, but valid state, just present otp form again, do not count as attempt.
295 ctx.otpState = stateBox;
296 res.end(Template.OTPHTML(ctx, this.options, appCb));
297 this.logger.info(_scope, 'finished otp, nothing entered, request again', { ctx });
298 return true;
299 }
300
301 const OTPResult = await this.authenticator.checkOTP(state, otp);
302 switch (OTPResult) {
303 case Enum.OTPResult.Valid:
304 // Valid auth, persist the authenticated session
305 ctx.session = {
306 authenticatedIdentifier: state.authenticatedIdentifier,
307 };
308 await this._sessionCookieSet(res, ctx.session);
309 res.statusCode = 302;
310 res.setHeader(Enum.Header.Location, state.redirect);
311 res.end();
312 this.logger.info(_scope, 'finished otp', { ctx });
313 return true;
314
315 case Enum.OTPResult.InvalidSoftFail:
316 // Retry otp entry.
317 ctx.errors.push('Invalid OTP token.');
318 ctx.otpState = await this.mysteryBox.pack({
319 ...state,
320 attempt: state.attempt + 1,
321 });
322 res.end(Template.OTPHTML(ctx, this.options, appCb));
323 this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
324 return true;
325
326 case Enum.OTPResult.InvalidHardFail:
327 // Return to initial login.
328 this.logger.debug(_scope, 'too many otp failures', { ctx });
329 ctx.errors.push('Unable to verify OTP token at this time. Try again.');
330 return false;
331
332 default:
333 throw new RangeError('Unexpected OTPResult');
334 }
335 }
336
337
338 /**
339 *
340 * @param {http.ServerResponse} res
341 * @param {Object} ctx
342 * @returns {Promise<Boolean>} true if handled, false if flow should continue
343 */
344 async _localUserAuth(res, ctx, appCb) {
345 const _scope = _fileScope('_localUserAuth');
346
347 // If Indiauth enabled and profile was submitted, defer to that.
348 if (this.options.authenticator.authnEnabled.includes('indieAuth')
349 && ctx.parsedBody['me']) {
350 return false;
351 }
352
353 const redirect = ctx.queryParams['r'] || './';
354 const identifier = ctx.parsedBody['identifier'];
355 const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
356
357 // N.B. validity check also sets authenticationId and maybe otpKey on ctx
358 const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
359 if (!isValidLocalIdentifier) {
360 ctx.errors.push('Invalid username or password');
361 }
362
363 if (ctx.errors.length) {
364 res.end(Template.LoginHTML(ctx, this.options, appCb));
365 return true;
366 }
367
368 // If OTP exists for valid identifier, follow that flow.
369 if (ctx.otpKey) {
370 ctx.otpState = await this.mysteryBox.pack({
371 authenticatedIdentifier: ctx.authenticationId,
372 key: ctx.otpKey,
373 epochMs: Date.now(),
374 attempt: 0,
375 redirect,
376 });
377 res.end(Template.OTPHTML(ctx, this.options, appCb));
378 this.logger.info(_scope, 'finished local, otp required', { ctx });
379 return true;
380 }
381
382 // Valid auth, persist the authenticated session
383 ctx.session = {
384 authenticatedIdentifier: ctx.authenticationId,
385 };
386 await this._sessionCookieSet(res, ctx.session);
387 res.statusCode = 302;
388 res.setHeader(Enum.Header.Location, redirect);
389 res.end();
390 this.logger.info(_scope, 'finished local, no otp', { ctx });
391 return true;
392 }
393
394
395 /**
396 * GET request to remove current credentials.
397 * @param {http.ServerResponse} res
398 * @param {Object} ctx
399 */
400 async getAdminLogout(res, ctx) {
401 const _scope = _fileScope('getAdminLogout');
402 this.logger.debug(_scope, 'called', { ctx });
403
404 await this._sessionCookieClear(res);
405
406 const redirect = ctx.queryParams['r'] || './';
407
408 res.statusCode = 302;
409 res.setHeader(Enum.Header.Location, redirect);
410 res.end();
411
412 this.logger.info(_scope, 'finished', { ctx });
413 }
414
415
416 /**
417 * GET request for returning IndieAuth redirect.
418 * This currently only redeems a scope-less profile.
419 * @param {http.ServerResponse} res
420 * @param {Object} ctx
421 * @param {AppTemplateCallback} appCb
422 */
423 async getAdminIA(res, ctx, appCb) {
424 const _scope = _fileScope('getAdminIA');
425 this.logger.debug(_scope, 'called', { ctx });
426
427 ctx.errors = [];
428 ctx.session = {};
429
430 // Unpack cookie to restore session data
431
432 const cookieValue = ctx.cookie?.[Enum.SessionCookie];
433 if (!cookieValue) {
434 this.logger.debug(_scope, 'no cookie', { ctx });
435 ctx.errors.push('missing required cookie');
436 } else {
437 try {
438 ctx.session = await this.mysteryBox.unpack(cookieValue);
439 this.logger.debug(_scope, 'restored session from cookie', { ctx });
440 } catch (e) {
441 this.logger.debug(_scope, 'could not unpack cookie');
442 ctx.errors.push('invalid cookie');
443 }
444 }
445
446 // Validate unpacked session values
447 // ...
448
449 // Add any auth errors
450 if (ctx.queryParams['error']) {
451 ctx.errors.push(ctx.queryParams['error']);
452 if (ctx.queryParams['error_description']) {
453 ctx.errors.push(ctx.queryParams['error_description']);
454 }
455 }
456
457 // check stuff
458 if (ctx.queryParams['state'] !== ctx.session.state) {
459 this.logger.debug(_scope, 'state mismatch', { ctx });
460 ctx.errors.push('invalid state');
461 }
462
463 const code = ctx.queryParams['code'];
464 if (!code) {
465 this.logger.debug(_scope, 'missing code', { ctx });
466 ctx.errors.push('invalid code');
467 }
468
469 // check issuer
470 if (ctx.session.issuer) {
471 if (ctx.queryParams['iss'] !== ctx.session.issuer) {
472 this.logger.debug(_scope, 'issuer mismatch', { ctx });
473 ctx.errors.push('invalid issuer');
474 }
475 } else {
476 this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
477 // Strict 20220212 compliance would error here. (Also earlier.)
478 // ctx.errors.push('invalid issuer');
479 }
480
481 let redeemProfileUrl;
482 try {
483 redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
484 } catch (e) {
485 this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
486 ctx.errors.push('invalid cookie');
487 }
488 let profile;
489 if (redeemProfileUrl) {
490 profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
491 if (!profile) {
492 this.logger.debug(_scope, 'no profile from code redemption', { ctx });
493 ctx.errors.push('did not get a profile response from authorization endpoint code redemption');
494 } else if (!profile.me) {
495 this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx });
496 ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption');
497 } else if (profile.me !== ctx.session.me) {
498 this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile });
499 const newProfileUrl = new URL(profile.me);
500 // Rediscover auth endpoint for the new returned profile.
501 const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl);
502 if (newProfile.metadata.authorizationEndpoint !== ctx.session.authorizationEndpoint) {
503 this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile });
504 ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
505 } else {
506 // The endpoints match, all is okay, update our records.
507 ctx.session.me = profile.me;
508 }
509 }
510 }
511
512 if (ctx.errors.length) {
513 await this._sessionCookieClear(res);
514 res.end(Template.IAHTML(ctx, this.options, appCb));
515 return;
516 }
517
518 const redirect = ctx.session.redirect || './';
519
520 // Set cookie as auth valid, redirect to original location.
521 ctx.session = {
522 authenticatedProfile: ctx.session.me,
523 };
524
525 await this._sessionCookieSet(res, ctx.session);
526 res.statusCode = 302;
527 res.setHeader(Enum.Header.Location, redirect);
528 res.end();
529
530 this.logger.info(_scope, 'finished', { ctx });
531 }
532
533
534 /**
535 * Page for modifying credentials and OTP.
536 * @param {http.ServerResponse} res
537 * @param {Object} ctx
538 * @param {AppTemplateCallback} appCb
539 */
540 async getAdminSettings(res, ctx, appCb) {
541 const _scope = _fileScope('getAdminSettings');
542 this.logger.debug(_scope, 'called', { ctx });
543
544 try {
545 await this.db.context(async (dbCtx) => {
546 const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
547 if (!authData) {
548 ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
549 return;
550 }
551 ctx.otpKey = authData.otpKey;
552 }); // dbCtx
553 } catch (e) {
554 this.logger.error(_scope, 'failed', { ctx, error: e });
555 ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
556 }
557
558 res.end(Template.SettingsHTML(ctx, this.options, appCb));
559 this.logger.info(_scope, 'finished', { ctx });
560 }
561
562
563 /**
564 * Page for modifying credentials and OTP.
565 * @param {http.ServerResponse} res
566 * @param {Object} ctx
567 * @param {Object[]=} appNavLinks
568 * @param {AppTemplateCallback} appCb
569 */
570 async postAdminSettings(res, ctx, appCb) {
571 const _scope = _fileScope('postAdminSettings');
572 this.logger.debug(_scope, 'called', { ctx });
573
574 try {
575 await this.db.context(async (dbCtx) => {
576 const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
577 if (!authData) {
578 ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
579 return;
580 }
581 ctx.otpKey = authData.otpKey;
582
583 const otpSubmitButton = ctx.parsedBody?.otp;
584 switch (otpSubmitButton) {
585 case 'disable':
586 await this._otpDisable(dbCtx, ctx, authData);
587 return;
588
589 case 'confirm':
590 await this._otpConfirm(dbCtx, ctx);
591 return;
592
593 case 'enable':
594 await this._otpEnable(ctx);
595 return;
596 }
597
598 const credentialSubmitButton = ctx.parsedBody?.credential;
599 switch (credentialSubmitButton) { // eslint-disable-line sonarjs/no-small-switch
600 case 'update':
601 await this._credentialUpdate(dbCtx, ctx, authData);
602 return;
603 }
604 }); // dbCtx
605 } catch (e) {
606 this.logger.error(_scope, 'failed', { ctx, error: e });
607 ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
608 }
609
610 res.end(Template.SettingsHTML(ctx, this.options, appCb));
611 this.logger.info(_scope, 'finished', { ctx });
612 }
613
614
615 /**
616 * Submission to disable OTP.
617 * @param {*} dbCtx
618 * @param {*} ctx
619 * @param {AuthInfo} authData
620 */
621 async _otpDisable(dbCtx, ctx, authData) {
622 const _scope = _fileScope('_otpDisable');
623 try {
624 authData.otpKey = null;
625 await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, null);
626 ctx.notifications.push('OTP removed!');
627 delete ctx.otpKey;
628 this.logger.info(_scope, 'otp disabled', { identifier: ctx.authenticationId });
629 } catch (e) {
630 this.logger.error(_scope, 'failed', { error: e, ctx });
631 ctx.errors.push('Failed to disable OTP!');
632 }
633 }
634
635
636 /**
637 * Submission to enable OTP.
638 * @param {Object} ctx
639 */
640 async _otpEnable(ctx) {
641 const _scope = _fileScope('_otpEnable');
642 try {
643 ctx.otpConfirmKey = await TOTP.createKey('sha1', 'base32');
644 ctx.otpConfirmBox = await this.mysteryBox.pack({
645 otpKey: ctx.otpConfirmKey,
646 otpAttempt: 0,
647 otpInitiatedMs: Date.now(),
648 });
649 } catch (e) {
650 delete ctx.otpConfirmKey;
651 delete ctx.otpConfirmBox;
652 this.logger.error(_scope, 'failed', { error: e, ctx });
653 ctx.errors.push('Failed to enable OTP!');
654 }
655 }
656
657
658 /**
659 * Submission to confirm enabling OTP.
660 * @param {*} dbCtx
661 * @param {Object} ctx
662 */
663 async _otpConfirm(dbCtx, ctx) {
664 const _scope = _fileScope('_otpConfirm');
665
666 const {
667 'otp-box': otpConfirmBox,
668 'otp-token': otpToken,
669 } = ctx.parsedBody;
670 let otpKey, otpAttempt, otpInitiatedMs;
671 try {
672 ({ otpKey, otpAttempt, otpInitiatedMs } = await this.mysteryBox.unpack(otpConfirmBox));
673 } catch (e) {
674 this.logger.debug(_scope, 'failed to unpack otp box', { error: e, ctx });
675 ctx.errors.push('Problem with form data.');
676 return;
677 }
678 if (!otpToken) {
679 // No token entered, just prompt again.
680 ctx.otpConfirmKey = otpKey;
681 ctx.otpConfirmBox = otpConfirmBox;
682 ctx.notifications.push('Please enter the OTP token to enable 2FA.');
683 return;
684 }
685 otpAttempt += 1;
686 const totp = new TOTP({
687 key: otpKey,
688 keyEncoding: 'base32',
689 });
690 if (!totp.validate(otpToken)) {
691 // Bad token, prompt again.
692 ctx.otpConfirmKey = otpKey;
693 ctx.otpConfirmBox = await this.mysteryBox.pack({
694 otpKey,
695 otpAttempt,
696 otpInitiatedMs,
697 });
698 ctx.errors.push('Invalid token!');
699 return;
700 }
701
702 try {
703 await this.db.context(async (dbCtx) => {
704 await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, otpKey);
705 ctx.otpKey = otpKey;
706 ctx.notifications.push('OTP enabled!');
707 this.logger.info(_scope, 'otp enabled', { identifier: ctx.authenticationId, otpAttempt, otpInitiatedMs });
708 }); // dbCtx
709 } catch (e) {
710 this.logger.debug(_scope, 'failed', { error: e, ctx });
711 ctx.errors.push('An error occurred, OTP was not enabled. Sorry this is not very helpful.');
712 }
713 }
714
715
716 /**
717 * Submission to set new credential.
718 * @param {*} dbCtx
719 * @param {Object} ctx
720 * @param {AuthInfo} authData
721 */
722 async _credentialUpdate(dbCtx, ctx, authData) {
723 const _scope = _fileScope('_credentialUpdate');
724 try {
725 const {
726 'credential-new': newCredential,
727 'credential-new-2': newCredential2,
728 'credential-current': oldCredential,
729 } = ctx.parsedBody;
730 if (newCredential !== newCredential2) {
731 ctx.errors.push('New password confirmation did not match!');
732 }
733 if (!newCredential) {
734 ctx.errors.push('Password cannot be empty!');
735 }
736 if (! await this.authenticator._validateAuthDataCredential(authData, oldCredential)) {
737 ctx.errors.push('Invalid current password!');
738 }
739 if (ctx.errors.length) {
740 return;
741 }
742 // update credential
743 await this.authenticator.updateCredential(dbCtx, ctx.authenticationId, newCredential);
744 ctx.notifications.push('Password updated!');
745 } catch (e) {
746 this.logger.error(_scope, 'failed', { error: e, ctx });
747 ctx.errors.push('Failed to update password!');
748 }
749 }
750
751 }
752
753 module.exports = SessionManager;