rudimentary support for totp 2fa
[squeep-authentication-module] / test / lib / authenticator.js
1 /* eslint-env mocha */
2 'use strict';
3
4 const assert = require('assert');
5 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
6 const Authenticator = require('../../lib/authenticator');
7 const stubLogger = require('../stub-logger');
8 const stubDb = require('../stub-db');
9 const Enum = require('../../lib/enum');
10 const Config = require('../stub-config');
11
12 describe('Authenticator', function () {
13 let authenticator, credential, ctx, identifier, password, options;
14 function _authMechanismRequired(a, m) {
15 if (!a.authn[m]) { // eslint-disable-line security/detect-object-injection
16 this.skip();
17 }
18 }
19
20 beforeEach(function () {
21 options = Config('test');
22 authenticator = new Authenticator(stubLogger, stubDb, options);
23 identifier = 'username';
24 credential = '$argon2id$v=19$m=4096,t=3,p=1$1a6zRlX4BI4$sZGcQ72BTpDOlxUI/j3DmE1PMcu+Cs5liZ/D6kk79Ew';
25 ctx = {};
26 password = 'badPassword';
27 stubDb._reset();
28 });
29 afterEach(function () {
30 sinon.restore();
31 });
32
33 it('covers no auth mechanisms', function () {
34 options.authenticator.authnEnabled = [];
35 assert.throws(() => new Authenticator(stubLogger, stubDb, options), {
36 message: 'no authentication mechanisms available',
37 });
38 });
39
40 it('covers empty realm', function () {
41 options.authenticator.basicRealm = undefined;
42 authenticator = new Authenticator(stubLogger, stubDb, options);
43 });
44
45 describe('isValidBasic', function () {
46 it('succeeds', async function () {
47 _authMechanismRequired(authenticator, 'argon2');
48 authenticator.db.authenticationGet.resolves({
49 identifier,
50 credential,
51 });
52 const authString = `${identifier}:${password}`;
53 const result = await authenticator.isValidBasic(authString, ctx);
54 assert.strictEqual(result, true);
55 assert.strictEqual(ctx.authenticationId, identifier);
56 });
57 it('fails', async function () {
58 _authMechanismRequired(authenticator, 'argon2');
59 authenticator.db.authenticationGet.resolves({
60 identifier,
61 credential,
62 });
63 const authString = `${identifier}:wrongPassword}`;
64 const result = await authenticator.isValidBasic(authString, ctx);
65 assert.strictEqual(result, false);
66 assert.strictEqual(ctx.authenticationId, undefined);
67 });
68 it('covers no entry', async function() {
69 authenticator.db.authenticationGet.resolves();
70 const authString = `${identifier}:wrongPassword}`;
71 const result = await authenticator.isValidBasic(authString, ctx);
72 assert.strictEqual(result, false);
73 assert.strictEqual(ctx.authenticationId, undefined);
74 });
75 it('covers unknown password hash', async function () {
76 authenticator.db.authenticationGet.resolves({
77 identifier,
78 credential: '$other$kind_of_credential',
79 });
80 const authString = `${identifier}:wrongPassword}`;
81 const result = await authenticator.isValidBasic(authString, ctx);
82 assert.strictEqual(result, false);
83 assert.strictEqual(ctx.authenticationId, undefined);
84 });
85 }); // isValidBasic
86
87 describe('isValidIdentifierCredential', function () {
88 it('succeeds', async function () {
89 _authMechanismRequired(authenticator, 'argon2');
90 authenticator.db.authenticationGet.resolves({
91 identifier,
92 credential,
93 });
94 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
95 assert.strictEqual(result, true);
96 assert.strictEqual(ctx.authenticationId, identifier);
97 });
98 it('fails', async function () {
99 _authMechanismRequired(authenticator, 'argon2');
100 authenticator.db.authenticationGet.resolves({
101 identifier,
102 credential,
103 });
104 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
105 assert.strictEqual(result, false);
106 assert.strictEqual(ctx.authenticationId, undefined);
107 });
108 it('covers no entry', async function() {
109 authenticator.db.authenticationGet.resolves();
110 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
111 assert.strictEqual(result, false);
112 assert.strictEqual(ctx.authenticationId, undefined);
113 });
114 it('covers non-string credential', async function () {
115 credential = '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
116 const result = await authenticator.isValidIdentifierCredential(identifier, undefined, ctx);
117 assert.strictEqual(result, false);
118 assert.strictEqual(ctx.authenticationId, undefined);
119 });
120 it('covers unknown password hash', async function () {
121 authenticator.db.authenticationGet.resolves({
122 identifier,
123 credential: '$other$kind_of_credential',
124 });
125 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
126 assert.strictEqual(result, false);
127 assert.strictEqual(ctx.authenticationId, undefined);
128 });
129 it('covers PAM', async function () {
130 _authMechanismRequired(authenticator, 'pam');
131 sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true);
132 authenticator.db.authenticationGet.resolves({
133 identifier,
134 credential: '$PAM$',
135 });
136 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
137 assert.strictEqual(result, true);
138 assert.strictEqual(ctx.authenticationId, identifier);
139 });
140 it('covers debug', async function () {
141 authenticator.authnEnabled = ['DEBUG_ANY'];
142 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
143 assert.strictEqual(result, true);
144 assert.strictEqual(ctx.authenticationId, identifier);
145 });
146 }); // isValidIdentifierCredential
147
148 describe('_isValidPAMIdentifier', function () {
149 beforeEach(function () {
150 _authMechanismRequired(authenticator, 'pam');
151 sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
152 });
153 it('covers success', async function () {
154 authenticator.authn.pam.pamAuthenticatePromise.resolves(true);
155 const result = await authenticator._isValidPAMIdentifier(identifier, credential);
156 assert.strictEqual(result, true);
157 });
158 it('covers failure', async function () {
159 _authMechanismRequired(authenticator, 'pam');
160 authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError());
161 const result = await authenticator._isValidPAMIdentifier(identifier, credential);
162 assert.strictEqual(result, false);
163 });
164 it('covers error', async function () {
165 _authMechanismRequired(authenticator, 'pam');
166 const expected = new Error('blah');
167 authenticator.authn.pam.pamAuthenticatePromise.rejects(expected);
168 assert.rejects(() => authenticator._isValidPAMIdentifier(identifier, credential), expected);
169 });
170 it('covers forbidden', async function () {
171 identifier = 'root';
172 const result = await authenticator._isValidPAMIdentifier(identifier, credential);
173 assert.strictEqual(result, false);
174 });
175 }); // _isValidPAMIdentifier
176
177 describe('_cookieParse', function () {
178 it('covers empty', function () {
179 const expected = {};
180 const result = Authenticator._cookieParse();
181 assert.deepStrictEqual(result, expected);
182 });
183 it('covers non variable', function () {
184 const cookie = 'foo';
185 const expected = {
186 foo: null,
187 };
188 const result = Authenticator._cookieParse(cookie);
189 assert.deepStrictEqual(result, expected);
190 });
191 it('parses cookie', function () {
192 const cookie = 'foo=bar; baz="quux"';
193 const expected = {
194 foo: 'bar',
195 baz: 'quux',
196 };
197 const result = Authenticator._cookieParse(cookie);
198 assert.deepStrictEqual(result, expected);
199 });
200 }); // _cookieParse
201
202 describe('isValidAuthorization', function () {
203 it('handles basic', async function () {
204 const expected = true;
205 const authorizationHeader = 'basic Zm9vOmJhcg==';
206 sinon.stub(authenticator, 'isValidBasic').resolves(expected);
207 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
208 assert.strictEqual(result, expected);
209 });
210 it('handles other', async function () {
211 const expected = false;
212 const authorizationHeader = 'bearer Zm9vOmJhcg==';
213 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
214 assert.strictEqual(result, expected);
215 });
216 }); // isValidAuthorization
217
218 describe('requestBasic', function () {
219 it('covers', function () {
220 const res = {
221 setHeader: () => {},
222 };
223 assert.throws(() => authenticator.requestBasic(res), {
224 name: 'ResponseError',
225 statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
226 });
227 });
228 }); // requestBasic
229
230 describe('isValidCookieAuth', function () {
231 let cookie;
232 beforeEach(function () {
233 sinon.stub(authenticator.mysteryBox, 'unpack');
234 cookie = 'squeepSession=dummy';
235 });
236 it('covers identifier success', async function () {
237 authenticator.mysteryBox.unpack.resolves({
238 authenticatedIdentifier: 'identifier',
239 });
240 const result = await authenticator.isValidCookieAuth(ctx, cookie);
241 assert.strictEqual(result, true);
242 });
243 it('covers profile success', async function () {
244 authenticator.mysteryBox.unpack.resolves({
245 authenticatedProfile: 'profile',
246 });
247 const result = await authenticator.isValidCookieAuth(ctx, cookie);
248 assert.strictEqual(result, true);
249 });
250 it('covers missing cookie', async function () {
251 cookie = 'wrong=cookie';
252 const result = await authenticator.isValidCookieAuth(ctx, cookie);
253 assert.strictEqual(result, false);
254 });
255 it('covers bad cookie', async function () {
256 authenticator.mysteryBox.unpack.rejects();
257 const result = await authenticator.isValidCookieAuth(ctx, cookie);
258 assert.strictEqual(result, false);
259 });
260 it('covers broken session', async function () {
261 authenticator.mysteryBox.unpack.resolves({
262 randomData: 'foo',
263 });
264 const result = await authenticator.isValidCookieAuth(ctx, cookie);
265 assert.strictEqual(result, false);
266 });
267 }); // isValidCookieAuth
268
269 describe('checkOTP', function () {
270 let state, otp;
271 this.beforeEach(function () {
272 sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
273 state = {
274 key: Buffer.from('12345678901234567890'),
275 attempt: 0,
276 epochMs: Date.now(),
277 };
278 otp = '000000';
279 });
280 it('covers valid OTP entry', function () {
281 const result = authenticator.checkOTP(state, otp);
282 assert.strictEqual(result, Enum.OTPResult.Valid);
283 });
284 it('covers invalid OTP entry', function () {
285 authenticator.TOTP.prototype.validate.returns(false);
286 const result = authenticator.checkOTP(state, otp);
287 assert.strictEqual(result, Enum.OTPResult.InvalidSoftFail);
288 });
289 it('covers invalid OTP entry, too many failures', function () {
290 state.attempt = 10;
291 authenticator.TOTP.prototype.validate.returns(false);
292 const result = authenticator.checkOTP(state, otp);
293 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
294 });
295 it('covers invalid OTP entry', function () {
296 state.epochMs = 0;
297 authenticator.TOTP.prototype.validate.returns(false);
298 const result = authenticator.checkOTP(state, otp);
299 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
300 });
301 }); // checkOTP
302
303 describe('sessionCheck', function () {
304 let cookie, req, res, loginPath, required, profilesAllowed;
305 beforeEach(function () {
306 cookie = 'squeepSession=sessionCookie';
307 ctx.clientProtocol = 'https';
308 req = {
309 getHeader: sinon.stub(),
310 };
311 res = {
312 end: sinon.stub(),
313 setHeader: sinon.stub(),
314 };
315 loginPath = '/admin/login';
316 required = true;
317 profilesAllowed = true;
318 });
319 it('covers valid cookie session', async function () {
320 req.getHeader.returns(cookie);
321 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
322 ctx.session = {
323 authenticatedIdentifier: 'user',
324 };
325 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
326 assert.strictEqual(result, true);
327 });
328 it('covers valid insecure cookie session', async function () {
329 authenticator.secureAuthOnly = false;
330 req.getHeader.returns(cookie);
331 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
332 ctx.session = {
333 authenticatedIdentifier: 'user',
334 };
335 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
336 assert.strictEqual(result, true);
337 });
338 it('rejects insecure connection', async function () {
339 ctx.clientProtocol = 'http';
340 assert.rejects(() => authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed), {
341 name: 'ResponseError',
342 sttausCode: Enum.ErrorResponse.Forbidden.statusCode,
343 });
344 });
345 it('ignores insecure connection if auth not required', async function () {
346 ctx.clientProtocol = 'http';
347 required = false;
348 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
349 assert.strictEqual(result, false);
350 });
351 it('redirects without any auth', async function () {
352 await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
353 assert(res.end.called);
354 assert(res.setHeader.called);
355 });
356 describe('convenience wrappers', function () {
357 describe('sessionRequiredLocal', function () {
358 it('accepts identifier', async function () {
359 req.getHeader.returns(cookie);
360 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
361 ctx.session = {
362 authenticatedIdentifier: 'user',
363 };
364 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
365 assert.strictEqual(result, true);
366 });
367 it('redirects with profile', async function () {
368 req.getHeader.returns(cookie);
369 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
370 ctx.session = {
371 authenticatedProfile: 'user',
372 };
373 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
374 assert.strictEqual(result, false);
375 assert(res.end.called);
376 assert(res.setHeader.called);
377 });
378 }); // sessionRequiredLocal
379 describe('sessionRequired', function () {
380 it('accepts identifier', async function () {
381 req.getHeader.returns(cookie);
382 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
383 ctx.session = {
384 authenticatedIdentifier: 'user',
385 };
386 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
387 assert.strictEqual(result, true);
388 });
389 it('accepts profile', async function () {
390 req.getHeader.returns(cookie);
391 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
392 ctx.session = {
393 authenticatedProfile: 'user',
394 };
395 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
396 assert.strictEqual(result, true);
397 });
398 it('rejects invalid', async function () {
399 req.getHeader.returns(cookie);
400 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
401 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
402 assert.strictEqual(result, false);
403 assert(res.end.called);
404 assert(res.setHeader.called);
405 });
406 it('covers insecure allowed', async function () {
407 authenticator.options.authenticator.secureAuthOnly = false;
408 req.getHeader.returns(cookie);
409 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
410 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
411 assert.strictEqual(result, false);
412 assert(res.end.called);
413 assert(res.setHeader.called);
414 });
415 }); // sessionRequired
416 describe('sessionOptionalLocal', function () {
417 it('rejects profile', async function () {
418 req.getHeader.returns(cookie);
419 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
420 ctx.session = {
421 authenticatedProfile: 'user',
422 };
423 const result = await authenticator.sessionOptionalLocal(req, res, ctx, loginPath);
424 assert.strictEqual(result, false);
425 assert(!res.end.called);
426 assert(!res.setHeader.called);
427 });
428 }); // sessionOptionalLocal
429 describe('sessionOptional', function () {
430 it('rejects invalid', async function () {
431 req.getHeader.returns(cookie);
432 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
433 const result = await authenticator.sessionOptional(req, res, ctx, loginPath);
434 assert.strictEqual(result, false);
435 assert(!res.end.called);
436 assert(!res.setHeader.called);
437 });
438 }); // sessionOptional
439 }); // convenience wrappers
440 }); // sessionCheck
441
442 describe('apiRequiredLocal', function () {
443 let req, res;
444 beforeEach(function () {
445 ctx = {};
446 req = {
447 getHeader: sinon.stub(),
448 };
449 res = {
450 end: sinon.stub(),
451 setHeader: sinon.stub(),
452 };
453 });
454 it('covers valid basic auth', async function () {
455 req.getHeader.returns('Basic Zm9vOmJhcg==');
456 sinon.stub(authenticator, 'sessionCheck').resolves(false);
457 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
458 const result = await authenticator.apiRequiredLocal(req, res, ctx);
459 assert.strictEqual(result, true);
460 assert(authenticator.isValidAuthorization.called);
461 assert(!authenticator.sessionCheck.called);
462 });
463 it('covers invalid basic auth', async function () {
464 req.getHeader.returns('Basic Zm9vOmJhcg==');
465 sinon.stub(authenticator, 'sessionCheck').resolves(false);
466 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
467 assert.rejects(() => authenticator.apiRequiredLocal(req, res, ctx), {
468 name: 'ResponseError',
469 statusCode: 401,
470 });
471 assert(!authenticator.sessionCheck.called);
472 assert(authenticator.isValidAuthorization.called);
473 });
474 it('covers missing basic auth, valid session', async function () {
475 req.getHeader.returns();
476 sinon.stub(authenticator, 'sessionCheck').resolves(true);
477 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
478 const result = await authenticator.apiRequiredLocal(req, res, ctx);
479 assert.strictEqual(result, true);
480 assert(!authenticator.isValidAuthorization.called);
481 assert(authenticator.sessionCheck.called);
482 });
483 it('covers missing basic auth, ignores session', async function () {
484 req.getHeader.returns();
485 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
486 assert.rejects(authenticator.apiRequiredLocal(req, res, ctx, false), {
487 name: 'ResponseError',
488 statusCode: 401,
489 });
490 assert(!authenticator.sessionCheck.called);
491 assert(!authenticator.isValidAuthorization.called);
492 assert(res.setHeader.called);
493 });
494 }); // apiRequiredLocal
495
496 }); // Authenticator