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