add account settings page, rest of otp support, stdio credential helper, other misc
[squeep-authentication-module] / test / lib / authenticator.js
index 5da64397118439ff57cff5e414a83c8bfb3d6da7..dea4a84427e2d9634b10e401a9084b1bea5370f9 100644 (file)
@@ -6,12 +6,9 @@ const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-requi
 const Authenticator = require('../../lib/authenticator');
 const stubLogger = require('../stub-logger');
 const stubDb = require('../stub-db');
-const Errors = require('../../lib/errors');
 const Enum = require('../../lib/enum');
 const Config = require('../stub-config');
 
-const noExpectedException = 'did not receive expected exception';
-
 describe('Authenticator', function () {
   let authenticator, credential, ctx, identifier, password, options;
   function _authMechanismRequired(a, m) {
@@ -28,6 +25,7 @@ describe('Authenticator', function () {
     ctx = {};
     password = 'badPassword';
     stubDb._reset();
+    stubLogger._reset();
   });
   afterEach(function () {
     sinon.restore();
@@ -35,12 +33,9 @@ describe('Authenticator', function () {
 
   it('covers no auth mechanisms', function () {
     options.authenticator.authnEnabled = [];
-    try {
-      authenticator = new Authenticator(stubLogger, stubDb, options);
-      assert.fail(noExpectedException);
-    } catch (e) {
-      assert.strictEqual(e.message, 'no authentication mechanisms available');
-    }
+    assert.throws(() => new Authenticator(stubLogger, stubDb, options), {
+      message: 'no authentication mechanisms available',
+    });
   });
 
   it('covers empty realm', function () {
@@ -48,6 +43,100 @@ describe('Authenticator', function () {
     authenticator = new Authenticator(stubLogger, stubDb, options);
   });
 
+  it('covers option defaults', function () {
+    delete options.authenticator.secureAuthOnly;
+    delete options.dingus?.proxyPrefix;
+    delete options.authenticator.forbiddenPAMIdentifiers;
+    options.authenticator.authnEnabled.push('flarpyauth');
+    authenticator = new Authenticator(stubLogger, stubDb, options);
+  });
+
+  describe('createIdentifier', function () {
+    let dbCtx;
+    beforeEach(function () {
+      dbCtx = {};
+      credential = 'badpassword';
+    });
+    it('covers success', async function () {
+      const otpKey = '1234567890123456789012';
+      await authenticator.createIdentifier(dbCtx, identifier, credential, otpKey);
+      assert(authenticator.db.authenticationInsertIdentifier.called);
+    });
+    it('covers failure', async function () {
+      const expected = new Error('blah');
+      await authenticator.db.authenticationInsertIdentifier.rejects(expected);
+      // assert.rejects was not happy to handle this for some reason
+      try {
+        await authenticator.createIdentifier(dbCtx, identifier, credential);
+        assert.fail('no expecte exception');
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+        assert(authenticator.db.authenticationInsertIdentifier.called);
+        assert(authenticator.logger.error.called);
+        }
+    });
+  }); // createIdentifier
+
+  describe('updateCredential', function () {
+    let dbCtx, newCredential;
+    beforeEach(function () {
+      dbCtx = {};
+      newCredential = 'newpassword';
+    });
+    it('covers success', async function () {
+      await authenticator.updateCredential(dbCtx, identifier, newCredential);
+      assert(authenticator.db.authenticationUpdateCredential.called);
+      assert(authenticator.logger.info.called);
+    });
+    it('covers failure', async function () {
+      const expected = new Error('foo');
+      authenticator.db.authenticationUpdateCredential.rejects(expected);
+      try {
+        await authenticator.updateCredential(dbCtx, identifier, newCredential);
+        assert.fail('no expected exception');
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+        assert(authenticator.logger.error.called);
+      }
+      // assert.rejects was not happy to handle this for some reason
+    });
+  }); // updateCredential
+
+  describe('_secureCredential', function () {
+    beforeEach(function () {
+      credential = 'badpassword';
+    });
+    it('covers plain', async function () {
+      const result = await authenticator._secureCredential(credential, 'plain');
+      assert.strictEqual(result, '$plain$' + credential);
+    });
+    it('covers default (argon2)', async function () {
+      const result = await authenticator._secureCredential(credential);
+      assert(result.startsWith('$argon2'));
+    });
+    it('covers invalid authn', async function () {
+      const authn = 'bogus';
+      assert.rejects(async () => await authenticator._secureCredential(credential, authn), RangeError);
+    });
+  }); // _secureCredential
+
+  describe('_validateAuthDataCredential', function () {
+    let authData;
+    beforeEach(function () {
+      credential = 'badpassword';
+      authData = {};
+    });
+    it('fails if not provided a credential', async function () {
+      const result = await authenticator._validateAuthDataCredential(authData, credential);
+      assert.strictEqual(result, false);
+    });
+    it('covers plain', async function () {
+      authData.credential = '$plain$badpassword';
+      const result = await authenticator._validateAuthDataCredential(authData, credential);
+      assert.strictEqual(result, true);
+    });
+  }); // _validateAuthDataCredential
+
   describe('isValidBasic', function () {
     it('succeeds', async function () {
       _authMechanismRequired(authenticator, 'argon2');
@@ -101,6 +190,19 @@ describe('Authenticator', function () {
       assert.strictEqual(result, true);
       assert.strictEqual(ctx.authenticationId, identifier);
     });
+    it('succeeds with OTP', async function () {
+      const otpKey = Buffer.from('1234567890');
+      _authMechanismRequired(authenticator, 'argon2');
+      authenticator.db.authenticationGet.resolves({
+        identifier,
+        credential,
+        otpKey,
+      });
+      const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
+      assert.strictEqual(result, true);
+      assert.strictEqual(ctx.authenticationId, identifier);
+      assert.deepStrictEqual(ctx.otpKey, otpKey);
+    });
     it('fails', async function () {
       _authMechanismRequired(authenticator, 'argon2');
       authenticator.db.authenticationGet.resolves({
@@ -143,73 +245,41 @@ describe('Authenticator', function () {
       assert.strictEqual(result, true);
       assert.strictEqual(ctx.authenticationId, identifier);
     });
-    it('covers debug', async function () {
-      authenticator.authnEnabled = ['DEBUG_ANY'];
-      const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
-      assert.strictEqual(result, true);
-      assert.strictEqual(ctx.authenticationId, identifier);
-    });
   }); // isValidIdentifierCredential
 
   describe('_isValidPAMIdentifier', function () {
+    let authData;
     beforeEach(function () {
       _authMechanismRequired(authenticator, 'pam');
       sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
+      authData = {
+        identifier,
+      };
     });
     it('covers success', async function () {
       authenticator.authn.pam.pamAuthenticatePromise.resolves(true);
-      const result = await authenticator._isValidPAMIdentifier(identifier, credential);
+      const result = await authenticator._isValidPAMIdentifier(authData, credential);
       assert.strictEqual(result, true);
     });
     it('covers failure', async function () {
       _authMechanismRequired(authenticator, 'pam');
       authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError());
-      const result = await authenticator._isValidPAMIdentifier(identifier, credential);
+      const result = await authenticator._isValidPAMIdentifier(authData, credential);
       assert.strictEqual(result, false);
     });
     it('covers error', async function () {
       _authMechanismRequired(authenticator, 'pam');
       const expected = new Error('blah');
       authenticator.authn.pam.pamAuthenticatePromise.rejects(expected);
-      try {
-        await authenticator._isValidPAMIdentifier(identifier, credential);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert.deepStrictEqual(e, expected);
-      }
+      assert.rejects(() => authenticator._isValidPAMIdentifier(authData, credential), expected);
     });
     it('covers forbidden', async function () {
-      identifier = 'root';
-      const result = await authenticator._isValidPAMIdentifier(identifier, credential);
+      authData.identifier = 'root';
+      const result = await authenticator._isValidPAMIdentifier(authData, credential);
       assert.strictEqual(result, false);
     });
   }); // _isValidPAMIdentifier
 
-  describe('_cookieParse', function () {
-    it('covers empty', function () {
-      const expected = {};
-      const result = Authenticator._cookieParse();
-      assert.deepStrictEqual(result, expected);
-    });
-    it('covers non variable', function () {
-      const cookie = 'foo';
-      const expected = {
-        foo: null,
-      };
-      const result = Authenticator._cookieParse(cookie);
-      assert.deepStrictEqual(result, expected);
-    });
-    it('parses cookie', function () {
-      const cookie = 'foo=bar; baz="quux"';
-      const expected = {
-        foo: 'bar',
-        baz: 'quux',
-      };
-      const result = Authenticator._cookieParse(cookie);
-      assert.deepStrictEqual(result, expected);
-    });
-  }); // _cookieParse
-
   describe('isValidAuthorization', function () {
     it('handles basic', async function () {
       const expected = true;
@@ -228,76 +298,120 @@ describe('Authenticator', function () {
 
   describe('requestBasic', function () {
     it('covers', function () {
-      try {
-        const res = {
-          setHeader: () => {},
-        };
-        authenticator.requestBasic(res);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert(e instanceof Errors.ResponseError);
-        assert.strictEqual(e.statusCode, Enum.ErrorResponse.Unauthorized.statusCode);
-      }
+      const res = {
+        setHeader: () => {},
+      };
+      assert.throws(() => authenticator.requestBasic(res), {
+        name: 'ResponseError',
+        statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
+      });
     });
   }); // requestBasic
 
   describe('isValidCookieAuth', function () {
-    let cookie;
     beforeEach(function () {
       sinon.stub(authenticator.mysteryBox, 'unpack');
-      cookie = 'squeepSession=dummy';
+      ctx.cookie = {
+        squeepSession: 'dummy',
+        otherCookie: 'foo',
+      };
     });
     it('covers identifier success', async function () {
       authenticator.mysteryBox.unpack.resolves({
         authenticatedIdentifier: 'identifier',
       });
-      const result = await authenticator.isValidCookieAuth(ctx, cookie);
+      const result = await authenticator.isValidCookieAuth(ctx);
       assert.strictEqual(result, true);
     });
     it('covers profile success', async function () {
       authenticator.mysteryBox.unpack.resolves({
         authenticatedProfile: 'profile',
       });
-      const result = await authenticator.isValidCookieAuth(ctx, cookie);
+      const result = await authenticator.isValidCookieAuth(ctx);
       assert.strictEqual(result, true);
     });
     it('covers missing cookie', async function () {
-      cookie = 'wrong=cookie';
-      const result = await authenticator.isValidCookieAuth(ctx, cookie);
+      delete ctx.cookie.squeepSession;
+      const result = await authenticator.isValidCookieAuth(ctx);
       assert.strictEqual(result, false);
     });
     it('covers bad cookie', async function () {
       authenticator.mysteryBox.unpack.rejects();
-      const result = await authenticator.isValidCookieAuth(ctx, cookie);
+      const result = await authenticator.isValidCookieAuth(ctx);
       assert.strictEqual(result, false);
     });
     it('covers broken session', async function () {
       authenticator.mysteryBox.unpack.resolves({
         randomData: 'foo',
       });
-      const result = await authenticator.isValidCookieAuth(ctx, cookie);
+      const result = await authenticator.isValidCookieAuth(ctx);
       assert.strictEqual(result, false);
     });
   }); // isValidCookieAuth
 
+  describe('checkOTP', function () {
+    let state, otp;
+    this.beforeEach(function () {
+      sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
+      state = {
+        key: '12345678901234567890123456789012',
+        attempt: 0,
+        epochMs: Date.now(),
+      };
+      otp = '000000';
+    });
+    it('covers valid OTP entry', function () {
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.Valid);
+    });
+    it('covers invalid OTP entry', function () {
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidSoftFail);
+    });
+    it('covers invalid OTP entry, too many failures', function () {
+      state.attempt = 10;
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
+    });
+    it('covers invalid OTP entry', function () {
+      state.epochMs = 0;
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
+    });
+  }); // checkOTP
+
   describe('sessionCheck', function () {
-    let cookie, req, res, loginPath, required, profilesAllowed;
+    let req, res, loginPath, required, profilesAllowed;
     beforeEach(function () {
-      cookie = 'squeepSession=sessionCookie';
       ctx.clientProtocol = 'https';
+      ctx.cookie = {
+        squeepSession: 'squeep_session_blob',
+      };
       req = {
         getHeader: sinon.stub(),
       };
       res = {
         end: sinon.stub(),
         setHeader: sinon.stub(),
+        appendHeader: sinon.stub(),
       };
       loginPath = '/admin/login';
       required = true;
       profilesAllowed = true;
     });
     it('covers valid cookie session', async function () {
-      req.getHeader.returns(cookie);
+      sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
+      ctx.session = {
+        authenticatedIdentifier: 'user',
+      };
+      const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
+      assert.strictEqual(result, true);
+    });
+    it('covers valid insecure cookie session', async function () {
+      authenticator.secureAuthOnly = false;
       sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
       ctx.session = {
         authenticatedIdentifier: 'user',
@@ -307,13 +421,10 @@ describe('Authenticator', function () {
     });
     it('rejects insecure connection', async function () {
       ctx.clientProtocol = 'http';
-      try {
-        await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert(e instanceof Errors.ResponseError);
-        assert.strictEqual(e.statusCode, Enum.ErrorResponse.Forbidden.statusCode);
-      }
+      assert.rejects(() => authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed), {
+        name: 'ResponseError',
+        sttausCode: Enum.ErrorResponse.Forbidden.statusCode,
+      });
     });
     it('ignores insecure connection if auth not required', async function () {
       ctx.clientProtocol = 'http';
@@ -329,7 +440,6 @@ describe('Authenticator', function () {
     describe('convenience wrappers', function () {
       describe('sessionRequiredLocal', function () {
         it('accepts identifier', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
           ctx.session = {
             authenticatedIdentifier: 'user',
@@ -338,7 +448,6 @@ describe('Authenticator', function () {
           assert.strictEqual(result, true);
         });
         it('redirects with profile', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
           ctx.session = {
             authenticatedProfile: 'user',
@@ -351,7 +460,6 @@ describe('Authenticator', function () {
       }); // sessionRequiredLocal
       describe('sessionRequired', function () {
         it('accepts identifier', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
           ctx.session = {
             authenticatedIdentifier: 'user',
@@ -360,7 +468,6 @@ describe('Authenticator', function () {
           assert.strictEqual(result, true);
         });
         it('accepts profile', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
           ctx.session = {
             authenticatedProfile: 'user',
@@ -369,7 +476,6 @@ describe('Authenticator', function () {
           assert.strictEqual(result, true);
         });
         it('rejects invalid', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
           const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
           assert.strictEqual(result, false);
@@ -378,7 +484,6 @@ describe('Authenticator', function () {
         });
         it('covers insecure allowed', async function () {
           authenticator.options.authenticator.secureAuthOnly = false;
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
           const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
           assert.strictEqual(result, false);
@@ -388,7 +493,6 @@ describe('Authenticator', function () {
       }); // sessionRequired
       describe('sessionOptionalLocal', function () {
         it('rejects profile', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
           ctx.session = {
             authenticatedProfile: 'user',
@@ -401,7 +505,6 @@ describe('Authenticator', function () {
       }); // sessionOptionalLocal
       describe('sessionOptional', function () {
         it('rejects invalid', async function () {
-          req.getHeader.returns(cookie);
           sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
           const result = await authenticator.sessionOptional(req, res, ctx, loginPath);
           assert.strictEqual(result, false);
@@ -437,14 +540,12 @@ describe('Authenticator', function () {
       req.getHeader.returns('Basic Zm9vOmJhcg==');
       sinon.stub(authenticator, 'sessionCheck').resolves(false);
       sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
-      try {
-        await authenticator.apiRequiredLocal(req, res, ctx);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert.strictEqual(e.statusCode, 401);
-        assert(!authenticator.sessionCheck.called);
-        assert(authenticator.isValidAuthorization.called);
-      }
+      assert.rejects(() => authenticator.apiRequiredLocal(req, res, ctx), {
+        name: 'ResponseError',
+        statusCode: 401,
+      });
+      assert(!authenticator.sessionCheck.called);
+      assert(authenticator.isValidAuthorization.called);
     });
     it('covers missing basic auth, valid session', async function () {
       req.getHeader.returns();
@@ -458,15 +559,13 @@ describe('Authenticator', function () {
     it('covers missing basic auth, ignores session', async function () {
       req.getHeader.returns();
       sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
-      try {
-        await authenticator.apiRequiredLocal(req, res, ctx, false);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert.strictEqual(e.statusCode, 401);
-        assert(!authenticator.sessionCheck.called);
-        assert(!authenticator.isValidAuthorization.called);
-        assert(res.setHeader.called);
-      }
+      assert.rejects(authenticator.apiRequiredLocal(req, res, ctx, false), {
+        name: 'ResponseError',
+        statusCode: 401,
+      });
+      assert(!authenticator.sessionCheck.called);
+      assert(!authenticator.isValidAuthorization.called);
+      assert(res.setHeader.called);
     });
   }); // apiRequiredLocal