X-Git-Url: http://git.squeep.com/?p=squeep-authentication-module;a=blobdiff_plain;f=lib%2Fsession-manager.js;fp=lib%2Fsession-manager.js;h=d1045ff570674c608585752b5f7a9e900490c221;hp=48ec11ab2a3428a19991fcce67553d5dd692129e;hb=53ef948ea83106e82d55e60d6695a15e94bf725e;hpb=842a3da269de1ab82e9a2a12aae8ed5677f064d8 diff --git a/lib/session-manager.js b/lib/session-manager.js index 48ec11a..d1045ff 100644 --- a/lib/session-manager.js +++ b/lib/session-manager.js @@ -1,11 +1,13 @@ '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'); @@ -22,14 +24,18 @@ class SessionManager { * @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 {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(options); this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name))); @@ -41,35 +47,32 @@ class SessionManager { /** * 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', - '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 + * @param {String} path + */ + async _sessionCookieClear(res, path = '/') { + await this._sessionCookieSet(res, undefined, 0, path); + } + /** * GET request for establishing admin session. * @param {http.ServerResponse} res @@ -94,7 +97,7 @@ class SessionManager { res.end(Template.LoginHTML(ctx, this.options)); } - this.logger.info(_scope, 'finished', { ctx }) + this.logger.info(_scope, 'finished', { ctx }); } @@ -208,12 +211,42 @@ 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'); + } } @@ -221,71 +254,86 @@ class SessionManager { * 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 + * @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? - 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)); + 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'); } - // not in otp flow - return false; } + /** * * @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'); @@ -300,7 +348,7 @@ class SessionManager { 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'); @@ -312,9 +360,10 @@ class SessionManager { } // 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, @@ -328,7 +377,7 @@ class SessionManager { 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(); @@ -346,7 +395,7 @@ class SessionManager { const _scope = _fileScope('getAdminLogout'); this.logger.debug(_scope, 'called', { ctx }); - await this._sessionCookieSet(res, '', 0); + await this._sessionCookieClear(res); const redirect = ctx.queryParams['r'] || './'; @@ -373,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 { @@ -454,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; } @@ -466,12 +515,227 @@ 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!'); + } }