X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fsession-manager.js;h=d1045ff570674c608585752b5f7a9e900490c221;hb=53ef948ea83106e82d55e60d6695a15e94bf725e;hp=f7cd33a1dcf31ac13104255610b2a0b01244b3b2;hpb=dd173e6b450cbba8100883514610c9fde83d050a;p=squeep-authentication-module diff --git a/lib/session-manager.js b/lib/session-manager.js index f7cd33a..d1045ff 100644 --- a/lib/session-manager.js +++ b/lib/session-manager.js @@ -1,11 +1,14 @@ '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 { TOTP } = require('@squeep/totp'); +const { randomUUID } = require('crypto'); const common = require('./common'); const Enum = require('./enum'); const Template = require('./template'); @@ -19,53 +22,57 @@ class SessionManager { * @param {Object} options * @param {Object} options.authenticator * @param {String[]} options.authenticator.authnEnabled - * @param {Object} options.authenticator.secureAuthOnly - * @param {Object} options.dingus - * @param {Object} options.dingus.proxyPrefix - * @param {Object} options.dingus.selfBaseUrl + * @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 {Object} options.manager + * @param {String} options.manager.pageTitle */ 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(logger, options); + this.mysteryBox = new MysteryBox(options); + this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name))); - this.cookieLifespan = 60 * 60 * 24 * 32; + this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32; } /** * Set or update our session cookie. * @param {http.ServerResponse} res - * @param {Object} session - * @param {Number} maxAge - * @param {String} path + * @param {Object=} session + * @param {Number=} maxAge + * @param {String=} 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', - ]; - 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 + * @param {String} path + */ + async _sessionCookieClear(res, path = '/') { + await this._sessionCookieSet(res, undefined, 0, path); + } + /** * GET request for establishing admin session. * @param {http.ServerResponse} res @@ -75,8 +82,22 @@ class SessionManager { const _scope = _fileScope('getAdminLogin'); this.logger.debug(_scope, 'called', { ctx }); - res.end(Template.LoginHTML(ctx, this.options)); - this.logger.info(_scope, 'finished', { ctx }) + // If there is already a valid session established, and if a + // redirect is present, follow it. Why? Maybe someone had + // multiple tabs open, their session expired, and their browser + // restarted, reloading all of them, and they have relogged in + // on one and just want to hit reload on the others to go back + // to where they were. + const redirect = ctx.queryParams['r']; + if (ctx.authenticationId && redirect) { + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + } else { + res.end(Template.LoginHTML(ctx, this.options)); + } + + this.logger.info(_scope, 'finished', { ctx }); } @@ -91,79 +112,98 @@ class SessionManager { ctx.errors = []; - const redirect = ctx.queryParams['r'] || './'; - - // Only attempt user login if no IndieAuth profile is set - if (!this.options.authenticator.authnEnabled.includes('indieAuth') || !ctx.parsedBody['me']) { - - const identifier = ctx.parsedBody['identifier']; - const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from 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)); - return; - } + // Check if this was an OTP entry attempt. + if (await this._otpSubmission(res, ctx)) { + // OTP path was taken, either successful entry and session creation, or re-prompting for otp. + return; + } - // Valid auth, persist the authenticated session - ctx.session = { - authenticatedIdentifier: ctx.authenticationId, - }; - await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); - res.statusCode = 302; - res.setHeader(Enum.Header.Location, redirect); - res.end(); - this.logger.info(_scope, 'finished local', { ctx }); + if (await this._localUserAuth(res, ctx)) { + // Local auth path was taken. return; } - let me, session, authorizationEndpoint; + // Otherwise, carry on with IndieAuth handshake. + + // Is profile a reasonable url? + let me, meAutoScheme, session, authorizationEndpoint; try { me = new URL(ctx.parsedBody['me']); + meAutoScheme = !!ctx.parsedBody['me_auto_scheme']; } catch (e) { 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 (this.options.authenticator.authnEnabled.includes('indieAuth') - && me) { - const profile = await this.indieAuthCommunication.fetchProfile(me); - if (!profile || !profile.authorizationEndpoint) { - this.logger.debug(_scope, 'failed to find any profile information at url', { ctx }); - ctx.errors.push(`No profile information was found at '${me}'.`); - } else { - // fetch and parse me for 'authorization_endpoint' relation links + if (ctx.errors.length) { + res.end(Template.LoginHTML(ctx, this.options)); + return; + } + + let profile; + profile = await this.indieAuthCommunication.fetchProfile(me); + if ((!profile?.metadata) + && meAutoScheme) { + this.logger.debug(_scope, 'trying http fallback', { ctx }); + me.protocol = 'http'; + profile = await this.indieAuthCommunication.fetchProfile(me); + } + if (!profile?.metadata) { + this.logger.debug(_scope, 'failed to find any profile information at url', { ctx }); + ctx.errors.push(`No profile information was found at '${me}'.`); + } else { + // fetch and parse me for 'authorization_endpoint' relation links + try { + authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint); + } catch (e) { + ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); + } + + if (profile.metadata.issuer) { + // Validate issuer try { - authorizationEndpoint = new URL(profile.authorizationEndpoint); + const issuer = new URL(profile.metadata.issuer); + if (issuer.hash + || issuer.search + || issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing + this.logger.debug(_scope, 'supplied issuer url invalid', { ctx }); + ctx.errors.push('Authorization server provided an invalid issuer field.'); + } } catch (e) { - ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); + this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx }); + ctx.errors.push('Authorization server provided an unparsable issuer field.'); } + } else { + this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx }); + // Strict 20220212 compliance would error here. + // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.'); } + } - if (authorizationEndpoint) { - const pkce = await IndieAuthCommunication.generatePKCE(); - session = { - authorizationEndpoint: authorizationEndpoint.href, - state: ctx.requestId, - codeVerifier: pkce.codeVerifier, - me, - redirect, - }; + if (authorizationEndpoint) { + const pkce = await IndieAuthCommunication.generatePKCE(); + + const state = randomUUID(); + const redirect = ctx.queryParams['r'] || './'; + session = { + authorizationEndpoint: authorizationEndpoint.href, + state, + codeVerifier: pkce.codeVerifier, + me, + redirect, + issuer: profile.metadata.issuer, + }; - Object.entries({ - 'response_type': 'code', - 'client_id': this.options.dingus.selfBaseUrl, - 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`, - 'state': session.state, - 'code_challenge': pkce.codeChallenge, - 'code_challenge_method': pkce.codeChallengeMethod, - 'me': me, - }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value)); - } + // Update auth endpoint parameters + Object.entries({ + 'response_type': 'code', + 'client_id': this.options.dingus.selfBaseUrl, + 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`, + 'state': session.state, + 'code_challenge': pkce.codeChallenge, + 'code_challenge_method': pkce.codeChallengeMethod, + 'me': me, + }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value)); } if (ctx.errors.length) { @@ -171,12 +211,178 @@ class SessionManager { 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 + * @property {Buffer|String} key + * @property {Number} attempt + * @property {Number} epochMs + * @property {String} redirect + */ + /** + * @param {OTPState} otpState + */ + 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 {http.ServerResponse} res + * @param {Object} ctx + * @param {Object} ctx.parsedBody + * @param {String} ctx.parsedBody.state + * @param {String} ctx.parsedBody.otp + * @returns {Promise} true if otp was handled, otherwise false indicates further login processing needed + */ + async _otpSubmission(res, ctx) { + const _scope = _fileScope('_otpSubmission'); + + const { + otp, + state: stateBox, + } = ctx.parsedBody; + // Are we processing an OTP entry attempt? + if (!stateBox) { + // Ignore and continue back to main login. + return false; + } + /** @type OTPState */ + let state; + try { + 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. + return false; + } + + 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)); + 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)); + 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'); + } + } + + + /** + * + * @param {http.ServerResponse} res + * @param {Object} ctx + * @returns {Promise} true if handled, false if flow should continue + */ + async _localUserAuth(res, ctx) { + const _scope = _fileScope('_localUserAuth'); + + // If Indiauth enabled and profile was submitted, defer to that. + if (this.options.authenticator.authnEnabled.includes('indieAuth') + && ctx.parsedBody['me']) { + return false; + } + + const redirect = ctx.queryParams['r'] || './'; + 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 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)); + return true; + } + + // If OTP exists for valid identifier, follow that flow. + 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)); + this.logger.info(_scope, 'finished local, otp required', { ctx }); + return true; + } + + // Valid auth, persist the authenticated session + ctx.session = { + authenticatedIdentifier: ctx.authenticationId, + }; + await this._sessionCookieSet(res, ctx.session); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + this.logger.info(_scope, 'finished local, no otp', { ctx }); + return true; } @@ -189,7 +395,7 @@ class SessionManager { const _scope = _fileScope('getAdminLogout'); this.logger.debug(_scope, 'called', { ctx }); - this._sessionCookieSet(res, '', 0); + await this._sessionCookieClear(res); const redirect = ctx.queryParams['r'] || './'; @@ -203,6 +409,7 @@ class SessionManager { /** * GET request for returning IndieAuth redirect. + * This currently only redeems a scope-less profile. * @param {http.ServerResponse} res * @param {Object} ctx */ @@ -215,8 +422,8 @@ class SessionManager { // 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 { @@ -230,6 +437,7 @@ class SessionManager { } // Validate unpacked session values + // ... // Add any auth errors if (ctx.queryParams['error']) { @@ -251,6 +459,18 @@ class SessionManager { ctx.errors.push('invalid code'); } + // check issuer + if (ctx.session.issuer) { + if (ctx.queryParams['iss'] !== ctx.session.issuer) { + this.logger.debug(_scope, 'issuer mismatch', { ctx }); + ctx.errors.push('invalid issuer'); + } + } else { + this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx }); + // Strict 20220212 compliance would error here. (Also earlier.) + // ctx.errors.push('invalid issuer'); + } + let redeemProfileUrl; try { redeemProfileUrl = new URL(ctx.session.authorizationEndpoint); @@ -272,7 +492,7 @@ class SessionManager { const newProfileUrl = new URL(profile.me); // Rediscover auth endpoint for the new returned profile. const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl); - if (newProfile.authorizationEndpoint !== ctx.session.authorizationEndpoint) { + if (newProfile.metadata.authorizationEndpoint !== ctx.session.authorizationEndpoint) { this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile }); ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue'); } else { @@ -283,7 +503,7 @@ class SessionManager { } if (ctx.errors.length) { - await this._sessionCookieSet(res, '', 0); + await this._sessionCookieClear(res); res.end(Template.IAHTML(ctx, this.options)); return; } @@ -295,15 +515,230 @@ class SessionManager { 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 }); + } + + + /** + * Page for modifying credentials and OTP. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminSettings(res, ctx) { + 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!
¯\\_(ツ)_/¯
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)); + this.logger.info(_scope, 'finished', { ctx }); + } + + + /** + * Page for modifying credentials and OTP. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async postAdminSettings(res, ctx) { + 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!
¯\\_(ツ)_/¯
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)); + this.logger.info(_scope, 'finished', { ctx }); + } + + + /** + * Submission to disable OTP. + * @param {*} dbCtx + * @param {*} ctx + * @param {AuthInfo} authData + */ + 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 + */ + 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 + * @param {Object} ctx + */ + 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 + * @param {Object} ctx + * @param {AuthInfo} authData + */ + 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; \ No newline at end of file +module.exports = SessionManager;