rudimentary support for totp 2fa
[squeep-authentication-module] / lib / authenticator.js
index 4449a94b700889f5c2196b77c91872ed0b52d26d..da6b7180f4c4e8019d6f2bed82063d4ea057ba71 100644 (file)
@@ -4,11 +4,18 @@ const common = require('./common');
 const Enum = require('./enum');
 const Errors = require('./errors');
 const { MysteryBox } = require('@squeep/mystery-box');
+const { TOTP } = require('@squeep/totp');
 const { name: packageName } = require('../package');
 
 const _fileScope = common.fileScope(__filename);
 
 class Authenticator {
+  /**
+   * @typedef {Object} AuthInfo
+   * @property {String} identifier
+   * @property {String} credentials
+   * @property {String=} otpKey
+   */
   /**
    * @param {Console} logger
    * @param {*} db
@@ -51,18 +58,23 @@ class Authenticator {
       throw new Error('no authentication mechanisms available');
     }
 
-    this.mysteryBox = new MysteryBox(logger, options);
+    this.mysteryBox = new MysteryBox(options);
+    this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
 
     this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
+
+    this.TOTP = TOTP; // For ease of test stubbing.
   }
 
 
   /**
    * Check local auth entries.
    * Sets ctx.authenticatedId if valid.
+   * Sets ctx.otpNeeded if account has otpKey.
    * @param {String} identifier
    * @param {String} credential
    * @param {Object} ctx
+   * @returns {Promise<Boolean>}
    */
   async isValidIdentifierCredential(identifier, credential, ctx) {
     const _scope = _fileScope('isValidIdentifierCredential');
@@ -78,16 +90,14 @@ class Authenticator {
       const authData = await this.db.authenticationGet(dbCtx, identifier);
       if (!authData) {
         this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
+      } else if (authData.credential.startsWith('$argon2')
+      &&         this.authnEnabled.includes('argon2')) {
+        isValid = await this.authn.argon2.verify(authData.credential, credential);
+      } else if (authData.credential.startsWith('$PAM$')
+      &&         this.authnEnabled.includes('pam')) {
+        isValid = this._isValidPAMIdentifier(identifier, credential);
       } else {
-        if (authData.credential.startsWith('$argon2')
-        &&  this.authnEnabled.includes('argon2')) {
-          isValid = await this.authn.argon2.verify(authData.credential, credential);
-        } else if (authData.credential.startsWith('$PAM$')
-        &&         this.authnEnabled.includes('pam')) {
-          isValid = this._isValidPAMIdentifier(identifier, credential);
-        } else {
-          this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx });
-        }
+        this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
       }
 
       if (this.authnEnabled.includes('DEBUG_ANY')) {
@@ -96,6 +106,7 @@ class Authenticator {
 
       if (isValid) {
         ctx.authenticationId = identifier;
+        ctx.otpNeeded = !!authData?.otpKey;
         await this.db.authenticationSuccess(dbCtx, identifier);
       }
     }); // dbCtx
@@ -108,7 +119,7 @@ class Authenticator {
    * Check system PAM.
    * @param {String} identifier
    * @param {String} credential
-   * @returns {Boolean}
+   * @returns {Promise<Boolean>}
    */
   async _isValidPAMIdentifier(identifier, credential) {
     const _scope = _fileScope('_isValidPAMIdentifier');
@@ -129,11 +140,38 @@ class Authenticator {
   }
 
 
+  /**
+   * 
+   * @param {Object} state
+   * @param {String} state.key
+   * @param {Number} state.attempt
+   * @param {Number} state.epochMs
+   * @param {String} otp
+   * @returns {String} Enum.OTPResult
+   */
+  checkOTP(state, otp) {
+    const totp = new this.TOTP({
+      ...this.options?.authenticator?.otpOptions,
+      ...state,
+    });
+    const isValid = totp.validate(otp);
+    if (isValid) {
+      return Enum.OTPResult.Valid;
+    }
+    const otpAttemptsRemain = state.attempt <= (this.options?.authenticator?.otpAttemptsAllowed ?? 3);
+    const otpWithinWindow = state.epochMs + (this.options?.authenticator?.otpWindowMs ?? 600000) > Date.now();
+    if (otpAttemptsRemain && otpWithinWindow) {
+      return Enum.OTPResult.InvalidSoftFail;
+    }
+    return Enum.OTPResult.InvalidHardFail;
+  }
+
+
   /**
    * Check for valid Basic auth, updates ctx with identifier if valid.
    * @param {String} credentials
    * @param {Object} ctx
-   * @returns {Boolean}
+   * @returns {Promise<Boolean>}
    */
   async isValidBasic(credentials, ctx) {
     const _scope = _fileScope('isValidBasic');
@@ -149,6 +187,7 @@ class Authenticator {
    * Determine which Authorization header is available, and if it is valid.
    * @param {String} authorizationHeader
    * @param {Object} ctx
+   * @returns {Promise<Boolean>}
    */
   async isValidAuthorization(authorizationHeader, ctx) {
     const _scope = _fileScope('isValidAuthorization');
@@ -188,7 +227,7 @@ class Authenticator {
     (cookieHeader || '').split(/; */).forEach((field) => {
       const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim()));
       if (name && !(name in cookie)) {
-        if (value && value.startsWith('"') && value.endsWith('"')) {
+        if (value?.startsWith('"') && value.endsWith('"')) {
           cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
         } else {
           cookie[name] = value; // eslint-disable-line security/detect-object-injection
@@ -274,7 +313,7 @@ class Authenticator {
         'SameSite=Lax',
         `Path=${this.options.dingus.proxyPrefix}/`,
       ];
-      if (this.options.authenticator.secureAuthOnly) {
+      if (this.secureAuthOnly) {
         cookieParts.push('Secure');
       }
       res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
@@ -377,12 +416,10 @@ class Authenticator {
         this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
         return true;
       }
-    } else {
-      if (sessionAlsoValid
-      &&  await this.sessionCheck(req, res, ctx, undefined, false, false)) {
-        this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
-        return true;
-      }
+    } else if (sessionAlsoValid
+    &&         await this.sessionCheck(req, res, ctx, undefined, false, false)) {
+      this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
+      return true;
     }
 
     this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });