rudimentary support for totp 2fa
[squeep-authentication-module] / lib / session-manager.js
index 43990cc376e4ee8b16a35092c2bf81620b3a1b9a..48ec11ab2a3428a19991fcce67553d5dd692129e 100644 (file)
@@ -58,7 +58,7 @@ class SessionManager {
     if (this.options.authenticator.secureAuthOnly) {
       cookieParts.push('Secure');
     }
-    if (typeof(maxAge) === 'number') {
+    if (typeof maxAge === 'number') {
       cookieParts.push(`Max-Age=${maxAge}`);
     }
     if (path) {
@@ -79,7 +79,21 @@ class SessionManager {
     const _scope = _fileScope('getAdminLogin');
     this.logger.debug(_scope, 'called', { ctx });
 
-    res.end(Template.LoginHTML(ctx, this.options));
+    // 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 })
   }
 
@@ -95,113 +109,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;
     }
 
     // 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) {
-      let profile;
+    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)
-      &&  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) {
-        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) {
+          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,
-        };
-
-        // 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 (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));
     }
 
     if (ctx.errors.length) {
@@ -218,6 +217,126 @@ 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
+   */
+  async _otpSubmission(res, ctx) {
+    const _scope = _fileScope('_otpSubmission');
+
+    // Are we processing an OTP entry attempt?
+    const { otp, state: stateBox } = ctx.parsedBody;
+    let state;
+    try {
+      if (stateBox) {
+        state = await this.mysteryBox.unpack(stateBox);
+      }
+    } 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 && 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');
+      }
+    }
+    // not in otp flow
+    return false;
+  }
+
+  /**
+   * 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  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 autenticationId and otpNeeded 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.otpNeeded) {
+      ctx.otpState = await this.mysteryBox.pack({
+        authenticatedIdentifier: ctx.authenticationId,
+        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, this.cookieLifespan);
+    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
@@ -254,7 +373,7 @@ class SessionManager {
 
     // Unpack cookie to restore session data
 
-    const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', '');
+    const [cookieName, cookieValue] = common.splitFirst((ctx.cookie || ''), '=', '');
     if (cookieName !== Enum.SessionCookie) {
       this.logger.debug(_scope, 'no cookie', { ctx });
       ctx.errors.push('missing required cookie');