include Set-Login header on session state transition
[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.SetLogin, Enum.LoginState.LoggedIn);
236 res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
237 res.statusCode = 302; // Found
238 res.end();
239
240 this.logger.info(_scope, 'finished indieauth', { ctx });
241 }
242
243
244 /**
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
251 */
252 /**
253 * @param {OTPState} otpState otp state
254 */
255 static _validateOTPState(otpState) {
256 if (!otpState.authenticatedIdentifier) {
257 throw new Error('otp state missing authentication identifier');
258 }
259 if (!otpState.key) {
260 throw new Error('otp state missing otp key');
261 }
262 if (!('attempt' in otpState)) {
263 throw new Error('otp state missing attempt count');
264 }
265 if (!('epochMs' in otpState)) {
266 throw new Error('otp state missing timestamp');
267 }
268 if (!otpState.redirect) {
269 throw new Error('otp state missing redirect');
270 }
271 }
272
273
274 /**
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
285 */
286 async _otpSubmission(res, ctx, appCb) {
287 const _scope = _fileScope('_otpSubmission');
288
289 const {
290 otp,
291 state: stateBox,
292 } = ctx.parsedBody;
293 // Are we processing an OTP entry attempt?
294 if (!stateBox) {
295 // Ignore and continue back to main login.
296 return false;
297 }
298 /** @type {OTPState} */
299 let state;
300 try {
301 state = await this.mysteryBox.unpack(stateBox);
302 this.constructor._validateOTPState(state);
303 } catch (e) {
304 this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx });
305 // Ignore and continue back to main login.
306 return false;
307 }
308
309 if (!otp) {
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 });
314 return true;
315 }
316
317 const OTPResult = await this.authenticator.checkOTP(state, otp);
318 switch (OTPResult) {
319 case Enum.OTPResult.Valid:
320 // Valid auth, persist the authenticated session
321 ctx.session = {
322 authenticatedIdentifier: state.authenticatedIdentifier,
323 };
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);
328 res.end();
329 this.logger.info(_scope, 'finished otp', { ctx });
330 return true;
331
332 case Enum.OTPResult.InvalidSoftFail:
333 // Retry otp entry.
334 ctx.errors.push('Invalid OTP token.');
335 ctx.otpState = await this.mysteryBox.pack({
336 ...state,
337 attempt: state.attempt + 1,
338 });
339 res.end(Template.OTPHTML(ctx, this.options, appCb));
340 this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
341 return true;
342
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.');
347 return false;
348
349 default:
350 throw new RangeError('Unexpected OTPResult');
351 }
352 }
353
354
355 /**
356 *
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
361 */
362 async _localUserAuth(res, ctx, appCb) {
363 const _scope = _fileScope('_localUserAuth');
364
365 // If Indiauth enabled and profile was submitted, defer to that.
366 if (this.options.authenticator.authnEnabled.includes('indieAuth')
367 && ctx.parsedBody['me']) {
368 return false;
369 }
370
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.
374
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');
379 }
380
381 if (ctx.errors.length) {
382 res.end(Template.LoginHTML(ctx, this.options, appCb));
383 return true;
384 }
385
386 // If OTP exists for valid identifier, follow that flow.
387 if (ctx.otpKey) {
388 ctx.otpState = await this.mysteryBox.pack({
389 authenticatedIdentifier: ctx.authenticationId,
390 key: ctx.otpKey,
391 epochMs: Date.now(),
392 attempt: 0,
393 redirect,
394 });
395 res.end(Template.OTPHTML(ctx, this.options, appCb));
396 this.logger.info(_scope, 'finished local, otp required', { ctx });
397 return true;
398 }
399
400 // Valid auth, persist the authenticated session
401 ctx.session = {
402 authenticatedIdentifier: ctx.authenticationId,
403 };
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);
408 res.end();
409 this.logger.info(_scope, 'finished local, no otp', { ctx });
410 return true;
411 }
412
413
414 /**
415 * GET request to remove current credentials.
416 * @param {http.ServerResponse} res response
417 * @param {object} ctx context
418 */
419 async getAdminLogout(res, ctx) {
420 const _scope = _fileScope('getAdminLogout');
421 this.logger.debug(_scope, 'called', { ctx });
422
423 await this._sessionCookieClear(res);
424 res.setHeader(Enum.Header.SetLogin, Enum.LoginState.LoggedOut);
425
426 const redirect = ctx.queryParams['r'] || './';
427
428 res.statusCode = 302;
429 res.setHeader(Enum.Header.Location, redirect);
430 res.end();
431
432 this.logger.info(_scope, 'finished', { ctx });
433 }
434
435
436 /**
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
442 */
443 async getAdminIA(res, ctx, appCb) {
444 const _scope = _fileScope('getAdminIA');
445 this.logger.debug(_scope, 'called', { ctx });
446
447 ctx.errors = [];
448 ctx.session = {};
449
450 // Unpack cookie to restore session data
451
452 const cookieValue = ctx.cookie?.[Enum.SessionCookie];
453 if (!cookieValue) {
454 this.logger.debug(_scope, 'no cookie', { ctx });
455 ctx.errors.push('missing required cookie');
456 } else {
457 try {
458 ctx.session = await this.mysteryBox.unpack(cookieValue);
459 this.logger.debug(_scope, 'restored session from cookie', { ctx });
460 } catch (e) {
461 this.logger.debug(_scope, 'could not unpack cookie', { error: e });
462 ctx.errors.push('invalid cookie');
463 }
464 }
465
466 // Validate unpacked session values
467 // ...
468
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']);
474 }
475 }
476
477 // check stuff
478 if (ctx.queryParams['state'] !== ctx.session.state) {
479 this.logger.debug(_scope, 'state mismatch', { ctx });
480 ctx.errors.push('invalid state');
481 }
482
483 const code = ctx.queryParams['code'];
484 if (!code) {
485 this.logger.debug(_scope, 'missing code', { ctx });
486 ctx.errors.push('invalid code');
487 }
488
489 // check issuer
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');
494 }
495 } else {
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');
499 }
500
501 let redeemProfileUrl;
502 try {
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');
507 }
508 let profile;
509 if (redeemProfileUrl) {
510 profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
511 if (!profile) {
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');
525 } else {
526 // The endpoints match, all is okay, update our records.
527 ctx.session.me = profile.me;
528 }
529 }
530 }
531
532 if (ctx.errors.length) {
533 await this._sessionCookieClear(res);
534 res.end(Template.IAHTML(ctx, this.options, appCb));
535 return;
536 }
537
538 const redirect = ctx.session.redirect || './';
539
540 // Set cookie as auth valid, redirect to original location.
541 ctx.session = {
542 authenticatedProfile: ctx.session.me,
543 };
544
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);
549 res.end();
550
551 this.logger.info(_scope, 'finished', { ctx });
552 }
553
554
555 /**
556 * @typedef {object} AuthInfo
557 * @property {string} identifier identifier
558 * @property {string} credential hashed credential
559 * @property {string=} otpKey otp key
560 */
561 /**
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
566 */
567 async getAdminSettings(res, ctx, appCb) {
568 const _scope = _fileScope('getAdminSettings');
569 this.logger.debug(_scope, 'called', { ctx });
570
571 try {
572 await this.db.context(async (dbCtx) => {
573 const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
574 if (!authData) {
575 ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
576 return;
577 }
578 ctx.otpKey = authData.otpKey;
579 }); // dbCtx
580 } catch (e) {
581 this.logger.error(_scope, 'failed', { ctx, error: e });
582 ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
583 }
584
585 res.end(Template.SettingsHTML(ctx, this.options, appCb));
586 this.logger.info(_scope, 'finished', { ctx });
587 }
588
589
590 /**
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
595 */
596 async postAdminSettings(res, ctx, appCb) {
597 const _scope = _fileScope('postAdminSettings');
598 this.logger.debug(_scope, 'called', { ctx });
599
600 try {
601 await this.db.context(async (dbCtx) => {
602 const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
603 if (!authData) {
604 ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
605 return;
606 }
607 ctx.otpKey = authData.otpKey;
608
609 const otpSubmitButton = ctx.parsedBody?.otp;
610 switch (otpSubmitButton) {
611 case 'disable':
612 await this._otpDisable(dbCtx, ctx, authData);
613 return;
614
615 case 'confirm':
616 await this._otpConfirm(dbCtx, ctx);
617 return;
618
619 case 'enable':
620 await this._otpEnable(ctx);
621 return;
622 }
623
624 const credentialSubmitButton = ctx.parsedBody?.credential;
625 switch (credentialSubmitButton) { // eslint-disable-line sonarjs/no-small-switch
626 case 'update':
627 await this._credentialUpdate(dbCtx, ctx, authData);
628 return;
629 }
630 }); // dbCtx
631 } catch (e) {
632 this.logger.error(_scope, 'failed', { ctx, error: e });
633 ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
634 }
635
636 res.end(Template.SettingsHTML(ctx, this.options, appCb));
637 this.logger.info(_scope, 'finished', { ctx });
638 }
639
640
641 /**
642 * Submission to disable OTP.
643 * @param {*} dbCtx db context
644 * @param {*} ctx context
645 * @param {AuthInfo} authData auth info
646 */
647 async _otpDisable(dbCtx, ctx, authData) {
648 const _scope = _fileScope('_otpDisable');
649 try {
650 authData.otpKey = null;
651 await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, null);
652 ctx.notifications.push('OTP removed!');
653 delete ctx.otpKey;
654 this.logger.info(_scope, 'otp disabled', { identifier: ctx.authenticationId });
655 } catch (e) {
656 this.logger.error(_scope, 'failed', { error: e, ctx });
657 ctx.errors.push('Failed to disable OTP!');
658 }
659 }
660
661
662 /**
663 * Submission to enable OTP.
664 * @param {object} ctx context
665 */
666 async _otpEnable(ctx) {
667 const _scope = _fileScope('_otpEnable');
668 try {
669 ctx.otpConfirmKey = await TOTP.createKey('sha1', 'base32');
670 ctx.otpConfirmBox = await this.mysteryBox.pack({
671 otpKey: ctx.otpConfirmKey,
672 otpAttempt: 0,
673 otpInitiatedMs: Date.now(),
674 });
675 } catch (e) {
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!');
680 }
681 }
682
683
684 /**
685 * Submission to confirm enabling OTP.
686 * @param {*} dbCtx db context
687 * @param {object} ctx context
688 */
689 async _otpConfirm(dbCtx, ctx) {
690 const _scope = _fileScope('_otpConfirm');
691
692 const {
693 'otp-box': otpConfirmBox,
694 'otp-token': otpToken,
695 } = ctx.parsedBody;
696 let otpKey, otpAttempt, otpInitiatedMs;
697 try {
698 ({ otpKey, otpAttempt, otpInitiatedMs } = await this.mysteryBox.unpack(otpConfirmBox));
699 } catch (e) {
700 this.logger.debug(_scope, 'failed to unpack otp box', { error: e, ctx });
701 ctx.errors.push('Problem with form data.');
702 return;
703 }
704 if (!otpToken) {
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.');
709 return;
710 }
711 otpAttempt += 1;
712 const totp = new TOTP({
713 key: otpKey,
714 keyEncoding: 'base32',
715 });
716 if (!totp.validate(otpToken)) {
717 // Bad token, prompt again.
718 ctx.otpConfirmKey = otpKey;
719 ctx.otpConfirmBox = await this.mysteryBox.pack({
720 otpKey,
721 otpAttempt,
722 otpInitiatedMs,
723 });
724 ctx.errors.push('Invalid token!');
725 return;
726 }
727
728 try {
729 await this.db.context(async (dbCtx) => {
730 await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, otpKey);
731 ctx.otpKey = otpKey;
732 ctx.notifications.push('OTP enabled!');
733 this.logger.info(_scope, 'otp enabled', { identifier: ctx.authenticationId, otpAttempt, otpInitiatedMs });
734 }); // dbCtx
735 } catch (e) {
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.');
738 }
739 }
740
741
742 /**
743 * Submission to set new credential.
744 * @param {*} dbCtx db context
745 * @param {object} ctx context
746 * @param {AuthInfo} authData auth info
747 */
748 async _credentialUpdate(dbCtx, ctx, authData) {
749 const _scope = _fileScope('_credentialUpdate');
750 try {
751 const {
752 'credential-new': newCredential,
753 'credential-new-2': newCredential2,
754 'credential-current': oldCredential,
755 } = ctx.parsedBody;
756 if (newCredential !== newCredential2) {
757 ctx.errors.push('New password confirmation did not match!');
758 }
759 if (!newCredential) {
760 ctx.errors.push('Password cannot be empty!');
761 }
762 if (! await this.authenticator._validateAuthDataCredential(authData, oldCredential)) {
763 ctx.errors.push('Invalid current password!');
764 }
765 if (ctx.errors.length) {
766 return;
767 }
768 // update credential
769 await this.authenticator.updateCredential(dbCtx, ctx.authenticationId, newCredential);
770 ctx.notifications.push('Password updated!');
771 } catch (e) {
772 this.logger.error(_scope, 'failed', { error: e, ctx });
773 ctx.errors.push('Failed to update password!');
774 }
775 }
776
777 }
778
779 module.exports = SessionManager;