bump package version to 1.5.0
[squeep-authentication-module] / lib / session-manager.js
index cb4c7126ec54cffc18b1856270cd1112f5c60182..d399e5bd1fb480044459cde001e7eda8a90f213b 100644 (file)
@@ -1,35 +1,52 @@
 '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)));
@@ -40,194 +57,364 @@ class SessionManager {
 
   /**
    * 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.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, appCb));
+    }
+
+    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 = [];
 
-    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, appCb)) {
+      // 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, appCb)) {
+      // Local auth path was taken.
       return;
     }
 
     // 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) {
+    } 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 (this.options.authenticator.authnEnabled.includes('indieAuth')
-    &&  me) {
-      let profile;
+    if (ctx.errors.length) {
+      res.end(Template.LoginHTML(ctx, this.options, appCb));
+      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)
-      &&  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) { // 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.`);
       }
-      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 {
-            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) {
-            this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
-            ctx.errors.push('Authorization server provided an unparsable issuer field.');
+      if (profile.metadata.issuer) {
+        // Validate issuer
+        try {
+          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.');
           }
-        } 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.');
+        } 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.');
         }
+      } 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();
-
-        const state = randomUUID();
-        session = {
-          authorizationEndpoint: authorizationEndpoint.href,
-          state,
-          codeVerifier: pkce.codeVerifier,
-          me,
-          redirect,
-          issuer: profile.metadata.issuer,
-        };
+    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,
+      };
 
-        // 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));
-      }
+      // 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) {
-      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 {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, appCb) {
+    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, 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');
+    }
+  }
+
+
+  /**
+   * 
+   * @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, appCb) {
+    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, appCb));
+      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, appCb));
+      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;
   }
 
 
   /**
    * 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 });
 
-    this._sessionCookieSet(res, '', 0);
+    await this._sessionCookieClear(res);
 
     const redirect = ctx.queryParams['r'] || './';
 
@@ -242,10 +429,11 @@ class SessionManager {
   /**
    * 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 });
 
@@ -254,8 +442,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 {
@@ -263,7 +451,7 @@ class SessionManager {
         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');
       }
     }
@@ -306,7 +494,7 @@ class SessionManager {
     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');
     }
@@ -335,8 +523,8 @@ class SessionManager {
     }
 
     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;
     }
 
@@ -347,15 +535,237 @@ 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 });
+  }
+
+
+  /**
+   * @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;