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