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