'use strict';
/**
- * Here we process activities which support login sessions.
+ * Here we wrangle activities which support login sessions, serving and
+ * processing the HTML forms a user interacts with.
*/
const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper');
const { MysteryBox } = require('@squeep/mystery-box');
-const { randomUUID } = require('crypto');
+const { TOTP } = require('@squeep/totp');
+const { randomUUID } = require('node:crypto');
const common = require('./common');
const Enum = require('./enum');
const Template = require('./template');
const _fileScope = common.fileScope(__filename);
+/**
+ * @typedef {import('node:http')} http
+ * @typedef {import('./authenticator')} Authenticator
+ */
+/**
+ * @typedef {object} ConsoleLike
+ * @property {Function} debug log debug
+ * @property {Function} error log error
+ * @property {Function} info log info
+ */
+
class SessionManager {
/**
- * @param {Console} logger
- * @param {Authenticator} authenticator
- * @param {Object} options
- * @param {Object} options.authenticator
- * @param {String[]} options.authenticator.authnEnabled
- * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
- * @param {Boolean} options.authenticator.secureAuthOnly
- * @param {Object} options.dingus
- * @param {String} options.dingus.proxyPrefix
- * @param {String} options.dingus.selfBaseUrl
+ * @param {ConsoleLike} logger logger
+ * @param {Authenticator} authenticator authenticator instance
+ * @param {object} options options
+ * @param {object} options.authenticator authenticator instance options
+ * @param {string[]} options.authenticator.authnEnabled authentication methods enabled
+ * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
+ * @param {boolean} options.authenticator.secureAuthOnly allow only https
+ * @param {object=} options.dingus dingus options
+ * @param {string=} options.dingus.proxyPrefix prefix on route paths
+ * @param {string} options.dingus.selfBaseUrl base url
+ * @param {object} options.manager manager options
+ * @param {string} options.manager.pageTitle page title
*/
constructor(logger, authenticator, options) {
this.logger = logger;
this.authenticator = authenticator;
+ this.db = authenticator.db; // TODO: take db arg in next major version bump
this.options = options;
+ this.proxyPrefix = options.dingus?.proxyPrefix ?? '';
this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
this.mysteryBox = new MysteryBox(options);
this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
/**
* Set or update our session cookie.
- * @param {http.ServerResponse} res
- * @param {Object} session
- * @param {Number} maxAge
- * @param {String} path
+ * @param {http.ServerResponse} res respoonse
+ * @param {object=} session session object
+ * @param {number=} maxAge session validity in seconds
+ * @param {string=} path session cookie path
*/
- async _sessionCookieSet(res, session, maxAge, path = '/') {
- const _scope = _fileScope('_sessionCookieSet');
-
+ async _sessionCookieSet(res, session, maxAge = this.cookieLifespan, path = '/') {
const cookieName = Enum.SessionCookie;
- const secureSession = session && await this.mysteryBox.pack(session) || '';
- const cookieParts = [
- `${cookieName}=${secureSession}`,
- 'HttpOnly',
- 'SameSite=Lax',
- ];
- if (this.options.authenticator.secureAuthOnly) {
- cookieParts.push('Secure');
- }
- if (typeof maxAge === 'number') {
- cookieParts.push(`Max-Age=${maxAge}`);
- }
- if (path) {
- cookieParts.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
- }
- const cookie = cookieParts.join('; ');
- this.logger.debug(_scope, 'session cookie', { cookie, session })
- res.setHeader(Enum.Header.SetCookie, cookie);
+ const secureSession = session && await this.mysteryBox.pack(session) || '""';
+ common.addCookie(res, cookieName, secureSession, {
+ httpOnly: true,
+ sameSite: 'Lax',
+ secure: this.options.authenticator.secureAuthOnly,
+ maxAge: session && maxAge || 0,
+ path,
+ });
}
+ /**
+ * Remove any current session cookie.
+ * @param {http.ServerResponse} res response
+ * @param {string} path session cookie path
+ */
+ async _sessionCookieClear(res, path = '/') {
+ await this._sessionCookieSet(res, undefined, 0, path);
+ }
+
+ /**
+ * @typedef {(pagePathLevel: number, ctx: object, htmlOptions: object) => void} AppTemplateCallback
+ */
+
/**
* GET request for establishing admin session.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async getAdminLogin(res, ctx) {
+ async getAdminLogin(res, ctx, appCb) {
const _scope = _fileScope('getAdminLogin');
this.logger.debug(_scope, 'called', { ctx });
res.setHeader(Enum.Header.Location, redirect);
res.end();
} else {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
}
- this.logger.info(_scope, 'finished', { ctx })
+ this.logger.info(_scope, 'finished', { ctx });
}
/**
* POST request for taking form data to establish admin session.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async postAdminLogin(res, ctx) {
+ async postAdminLogin(res, ctx, appCb) {
const _scope = _fileScope('postAdminLogin');
this.logger.debug(_scope, 'called', { ctx });
ctx.errors = [];
// Check if this was an OTP entry attempt.
- if (await this._otpSubmission(res, ctx)) {
+ if (await this._otpSubmission(res, ctx, appCb)) {
// OTP path was taken, either successful entry and session creation, or re-prompting for otp.
return;
}
- if (await this._localUserAuth(res, ctx)) {
+ if (await this._localUserAuth(res, ctx, appCb)) {
// Local auth path was taken.
return;
}
try {
me = new URL(ctx.parsedBody['me']);
meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
}
if (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return;
}
// fetch and parse me for 'authorization_endpoint' relation links
try {
authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
}
this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
ctx.errors.push('Authorization server provided an invalid issuer field.');
}
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
ctx.errors.push('Authorization server provided an unparsable issuer field.');
}
}
if (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return;
}
- await this._sessionCookieSet(res, session, this.cookieLifespan);
+ await this._sessionCookieSet(res, session);
res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
res.statusCode = 302; // Found
res.end();
- this.logger.info(_scope, 'finished indieauth', { ctx })
+ this.logger.info(_scope, 'finished indieauth', { ctx });
+ }
+
+
+ /**
+ * @typedef {object} OTPState
+ * @property {string} authenticatedIdentifier identifier of logging-in user
+ * @property {Buffer | string} key otp key
+ * @property {number} attempt counter
+ * @property {number} epochMs started
+ * @property {string} redirect where to go after successful otp entry
+ */
+ /**
+ * @param {OTPState} otpState otp state
+ */
+ static _validateOTPState(otpState) {
+ if (!otpState.authenticatedIdentifier) {
+ throw new Error('otp state missing authentication identifier');
+ }
+ if (!otpState.key) {
+ throw new Error('otp state missing otp key');
+ }
+ if (!('attempt' in otpState)) {
+ throw new Error('otp state missing attempt count');
+ }
+ if (!('epochMs' in otpState)) {
+ throw new Error('otp state missing timestamp');
+ }
+ if (!otpState.redirect) {
+ throw new Error('otp state missing redirect');
+ }
}
* Check if processing an OTP entry attempt. If not, resume login flow.
* If so, validate otp and establish session, else reprompt for OTP, or
* return to login entry after too many failures.
- * @param {*} res
- * @param {*} ctx
- * @returns {Boolean} true if otp was handled, otherwise false indicates further login processing needed
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {object} ctx.parsedBody submitted data
+ * @param {string} ctx.parsedBody.state packed state
+ * @param {string} ctx.parsedBody.otp entered code
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ * @returns {Promise<boolean>} true if otp was handled, otherwise false indicates further login processing needed
*/
- async _otpSubmission(res, ctx) {
+ async _otpSubmission(res, ctx, appCb) {
const _scope = _fileScope('_otpSubmission');
+ const {
+ otp,
+ state: stateBox,
+ } = ctx.parsedBody;
// Are we processing an OTP entry attempt?
- const { otp, state: stateBox } = ctx.parsedBody;
+ if (!stateBox) {
+ // Ignore and continue back to main login.
+ return false;
+ }
+ /** @type {OTPState} */
let state;
try {
- if (stateBox) {
- state = await this.mysteryBox.unpack(stateBox);
- }
+ state = await this.mysteryBox.unpack(stateBox);
+ this.constructor._validateOTPState(state);
} catch (e) {
this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx });
- // Ignore and continue back to main login
+ // Ignore and continue back to main login.
return false;
}
- if (otp && state) {
- const OTPResult = await this.authenticator.checkOTP(state, otp);
- switch (OTPResult) {
- case Enum.OTPResult.Valid:
- // successful otp entry
- // Valid auth, persist the authenticated session
- ctx.session = {
- authenticatedIdentifier: state.authenticationId,
- };
- await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
- res.statusCode = 302;
- res.setHeader(Enum.Header.Location, state.redirect);
- res.end();
- this.logger.info(_scope, 'finished otp', { ctx });
- return true;
-
- case Enum.OTPResult.InvalidSoftFail:
- // retry otp entry
- ctx.otpNeeded = true;
- ctx.errors.push('Invalid OTP token.');
- ctx.otpState = await this.mysteryBox.pack({
- ...state,
- attempt: state.attempt + 1,
- });
- res.end(Template.OTPHTML(ctx, this.options));
- this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
- return true;
-
- case Enum.OTPResult.InvalidHardFail:
- // return to initial login
- this.logger.debug(_scope, 'too many otp failures', { ctx, state });
- ctx.errors.push('Invalid OTP token, and too many failures. Try again.');
- return false;
-
- default:
- throw new RangeError('Unexpected OTPResult');
- }
+
+ if (!otp) {
+ // Nothing submitted, but valid state, just present otp form again, do not count as attempt.
+ ctx.otpState = stateBox;
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
+ this.logger.info(_scope, 'finished otp, nothing entered, request again', { ctx });
+ return true;
+ }
+
+ const OTPResult = await this.authenticator.checkOTP(state, otp);
+ switch (OTPResult) {
+ case Enum.OTPResult.Valid:
+ // Valid auth, persist the authenticated session
+ ctx.session = {
+ authenticatedIdentifier: state.authenticatedIdentifier,
+ };
+ await this._sessionCookieSet(res, ctx.session);
+ res.statusCode = 302;
+ res.setHeader(Enum.Header.Location, state.redirect);
+ res.end();
+ this.logger.info(_scope, 'finished otp', { ctx });
+ return true;
+
+ case Enum.OTPResult.InvalidSoftFail:
+ // Retry otp entry.
+ ctx.errors.push('Invalid OTP token.');
+ ctx.otpState = await this.mysteryBox.pack({
+ ...state,
+ attempt: state.attempt + 1,
+ });
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
+ this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
+ return true;
+
+ case Enum.OTPResult.InvalidHardFail:
+ // Return to initial login.
+ this.logger.debug(_scope, 'too many otp failures', { ctx });
+ ctx.errors.push('Unable to verify OTP token at this time. Try again.');
+ return false;
+
+ default:
+ throw new RangeError('Unexpected OTPResult');
}
- // not in otp flow
- return false;
}
+
/**
*
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ * @returns {Promise<boolean>} true if handled, false if flow should continue
*/
- async _localUserAuth(res, ctx) {
+ async _localUserAuth(res, ctx, appCb) {
const _scope = _fileScope('_localUserAuth');
// If Indiauth enabled and profile was submitted, defer to that.
const identifier = ctx.parsedBody['identifier'];
const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
- // N.B. validity check also sets autenticationId and otpNeeded on ctx
+ // N.B. validity check also sets authenticationId and maybe otpKey on ctx
const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
if (!isValidLocalIdentifier) {
ctx.errors.push('Invalid username or password');
}
if (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return true;
}
// If OTP exists for valid identifier, follow that flow.
- if (ctx.otpNeeded) {
+ if (ctx.otpKey) {
ctx.otpState = await this.mysteryBox.pack({
authenticatedIdentifier: ctx.authenticationId,
+ key: ctx.otpKey,
epochMs: Date.now(),
attempt: 0,
redirect,
});
- res.end(Template.OTPHTML(ctx, this.options));
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished local, otp required', { ctx });
return true;
}
ctx.session = {
authenticatedIdentifier: ctx.authenticationId,
};
- await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+ await this._sessionCookieSet(res, ctx.session);
res.statusCode = 302;
res.setHeader(Enum.Header.Location, redirect);
res.end();
/**
* GET request to remove current credentials.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
*/
async getAdminLogout(res, ctx) {
const _scope = _fileScope('getAdminLogout');
this.logger.debug(_scope, 'called', { ctx });
- await this._sessionCookieSet(res, '', 0);
+ await this._sessionCookieClear(res);
const redirect = ctx.queryParams['r'] || './';
/**
* GET request for returning IndieAuth redirect.
* This currently only redeems a scope-less profile.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async getAdminIA(res, ctx) {
+ async getAdminIA(res, ctx, appCb) {
const _scope = _fileScope('getAdminIA');
this.logger.debug(_scope, 'called', { ctx });
// Unpack cookie to restore session data
- const [cookieName, cookieValue] = common.splitFirst((ctx.cookie || ''), '=', '');
- if (cookieName !== Enum.SessionCookie) {
+ const cookieValue = ctx.cookie?.[Enum.SessionCookie];
+ if (!cookieValue) {
this.logger.debug(_scope, 'no cookie', { ctx });
ctx.errors.push('missing required cookie');
} else {
ctx.session = await this.mysteryBox.unpack(cookieValue);
this.logger.debug(_scope, 'restored session from cookie', { ctx });
} catch (e) {
- this.logger.debug(_scope, 'could not unpack cookie');
+ this.logger.debug(_scope, 'could not unpack cookie', { error: e });
ctx.errors.push('invalid cookie');
}
}
let redeemProfileUrl;
try {
redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
ctx.errors.push('invalid cookie');
}
}
if (ctx.errors.length) {
- await this._sessionCookieSet(res, '', 0);
- res.end(Template.IAHTML(ctx, this.options));
+ await this._sessionCookieClear(res);
+ res.end(Template.IAHTML(ctx, this.options, appCb));
return;
}
authenticatedProfile: ctx.session.me,
};
- await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+ await this._sessionCookieSet(res, ctx.session);
res.statusCode = 302;
res.setHeader(Enum.Header.Location, redirect);
res.end();
- this.logger.info(_scope, 'finished', { ctx })
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * @typedef {object} AuthInfo
+ * @property {string} identifier identifier
+ * @property {string} credential hashed credential
+ * @property {string=} otpKey otp key
+ */
+ /**
+ * Page for modifying credentials and OTP.
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ */
+ async getAdminSettings(res, ctx, appCb) {
+ const _scope = _fileScope('getAdminSettings');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ try {
+ await this.db.context(async (dbCtx) => {
+ const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
+ if (!authData) {
+ ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
+ return;
+ }
+ ctx.otpKey = authData.otpKey;
+ }); // dbCtx
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { ctx, error: e });
+ ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
+ }
+
+ res.end(Template.SettingsHTML(ctx, this.options, appCb));
+ this.logger.info(_scope, 'finished', { ctx });
}
+ /**
+ * Page for modifying credentials and OTP.
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ */
+ async postAdminSettings(res, ctx, appCb) {
+ const _scope = _fileScope('postAdminSettings');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ try {
+ await this.db.context(async (dbCtx) => {
+ const authData = await this.db.authenticationGet(dbCtx, ctx.authenticationId);
+ if (!authData) {
+ ctx.errors.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
+ return;
+ }
+ ctx.otpKey = authData.otpKey;
+
+ const otpSubmitButton = ctx.parsedBody?.otp;
+ switch (otpSubmitButton) {
+ case 'disable':
+ await this._otpDisable(dbCtx, ctx, authData);
+ return;
+
+ case 'confirm':
+ await this._otpConfirm(dbCtx, ctx);
+ return;
+
+ case 'enable':
+ await this._otpEnable(ctx);
+ return;
+ }
+
+ const credentialSubmitButton = ctx.parsedBody?.credential;
+ switch (credentialSubmitButton) { // eslint-disable-line sonarjs/no-small-switch
+ case 'update':
+ await this._credentialUpdate(dbCtx, ctx, authData);
+ return;
+ }
+ }); // dbCtx
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { ctx, error: e });
+ ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
+ }
+
+ res.end(Template.SettingsHTML(ctx, this.options, appCb));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Submission to disable OTP.
+ * @param {*} dbCtx db context
+ * @param {*} ctx context
+ * @param {AuthInfo} authData auth info
+ */
+ async _otpDisable(dbCtx, ctx, authData) {
+ const _scope = _fileScope('_otpDisable');
+ try {
+ authData.otpKey = null;
+ await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, null);
+ ctx.notifications.push('OTP removed!');
+ delete ctx.otpKey;
+ this.logger.info(_scope, 'otp disabled', { identifier: ctx.authenticationId });
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, ctx });
+ ctx.errors.push('Failed to disable OTP!');
+ }
+ }
+
+
+ /**
+ * Submission to enable OTP.
+ * @param {object} ctx context
+ */
+ async _otpEnable(ctx) {
+ const _scope = _fileScope('_otpEnable');
+ try {
+ ctx.otpConfirmKey = await TOTP.createKey('sha1', 'base32');
+ ctx.otpConfirmBox = await this.mysteryBox.pack({
+ otpKey: ctx.otpConfirmKey,
+ otpAttempt: 0,
+ otpInitiatedMs: Date.now(),
+ });
+ } catch (e) {
+ delete ctx.otpConfirmKey;
+ delete ctx.otpConfirmBox;
+ this.logger.error(_scope, 'failed', { error: e, ctx });
+ ctx.errors.push('Failed to enable OTP!');
+ }
+ }
+
+
+ /**
+ * Submission to confirm enabling OTP.
+ * @param {*} dbCtx db context
+ * @param {object} ctx context
+ */
+ async _otpConfirm(dbCtx, ctx) {
+ const _scope = _fileScope('_otpConfirm');
+
+ const {
+ 'otp-box': otpConfirmBox,
+ 'otp-token': otpToken,
+ } = ctx.parsedBody;
+ let otpKey, otpAttempt, otpInitiatedMs;
+ try {
+ ({ otpKey, otpAttempt, otpInitiatedMs } = await this.mysteryBox.unpack(otpConfirmBox));
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack otp box', { error: e, ctx });
+ ctx.errors.push('Problem with form data.');
+ return;
+ }
+ if (!otpToken) {
+ // No token entered, just prompt again.
+ ctx.otpConfirmKey = otpKey;
+ ctx.otpConfirmBox = otpConfirmBox;
+ ctx.notifications.push('Please enter the OTP token to enable 2FA.');
+ return;
+ }
+ otpAttempt += 1;
+ const totp = new TOTP({
+ key: otpKey,
+ keyEncoding: 'base32',
+ });
+ if (!totp.validate(otpToken)) {
+ // Bad token, prompt again.
+ ctx.otpConfirmKey = otpKey;
+ ctx.otpConfirmBox = await this.mysteryBox.pack({
+ otpKey,
+ otpAttempt,
+ otpInitiatedMs,
+ });
+ ctx.errors.push('Invalid token!');
+ return;
+ }
+
+ try {
+ await this.db.context(async (dbCtx) => {
+ await this.db.authenticationUpdateOTPKey(dbCtx, ctx.authenticationId, otpKey);
+ ctx.otpKey = otpKey;
+ ctx.notifications.push('OTP enabled!');
+ this.logger.info(_scope, 'otp enabled', { identifier: ctx.authenticationId, otpAttempt, otpInitiatedMs });
+ }); // dbCtx
+ } catch (e) {
+ this.logger.debug(_scope, 'failed', { error: e, ctx });
+ ctx.errors.push('An error occurred, OTP was not enabled. Sorry this is not very helpful.');
+ }
+ }
+
+
+ /**
+ * Submission to set new credential.
+ * @param {*} dbCtx db context
+ * @param {object} ctx context
+ * @param {AuthInfo} authData auth info
+ */
+ async _credentialUpdate(dbCtx, ctx, authData) {
+ const _scope = _fileScope('_credentialUpdate');
+ try {
+ const {
+ 'credential-new': newCredential,
+ 'credential-new-2': newCredential2,
+ 'credential-current': oldCredential,
+ } = ctx.parsedBody;
+ if (newCredential !== newCredential2) {
+ ctx.errors.push('New password confirmation did not match!');
+ }
+ if (!newCredential) {
+ ctx.errors.push('Password cannot be empty!');
+ }
+ if (! await this.authenticator._validateAuthDataCredential(authData, oldCredential)) {
+ ctx.errors.push('Invalid current password!');
+ }
+ if (ctx.errors.length) {
+ return;
+ }
+ // update credential
+ await this.authenticator.updateCredential(dbCtx, ctx.authenticationId, newCredential);
+ ctx.notifications.push('Password updated!');
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, ctx });
+ ctx.errors.push('Failed to update password!');
+ }
+ }
+
}
module.exports = SessionManager;