use existing auth db method for identifier creation
[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 stubLogger._reset();
29 });
30 afterEach(function () {
31 sinon.restore();
32 });
33
34 it('covers no auth mechanisms', function () {
35 options.authenticator.authnEnabled = [];
36 assert.throws(() => new Authenticator(stubLogger, stubDb, options), {
37 message: 'no authentication mechanisms available',
38 });
39 });
40
41 it('covers empty realm', function () {
42 options.authenticator.basicRealm = undefined;
43 authenticator = new Authenticator(stubLogger, stubDb, options);
44 });
45
46 it('covers option defaults', function () {
47 delete options.authenticator.secureAuthOnly;
48 delete options.dingus?.proxyPrefix;
49 delete options.authenticator.forbiddenPAMIdentifiers;
50 options.authenticator.authnEnabled.push('flarpyauth');
51 authenticator = new Authenticator(stubLogger, stubDb, options);
52 });
53
54 describe('createIdentifier', function () {
55 let dbCtx;
56 beforeEach(function () {
57 dbCtx = {};
58 credential = 'badpassword';
59 });
60 it('covers success', async function () {
61 const otpKey = '1234567890123456789012';
62 await authenticator.createIdentifier(dbCtx, identifier, credential, otpKey);
63 assert(authenticator.db.authenticationUpsert.called);
64 });
65 it('covers failure', async function () {
66 const expected = new Error('blah');
67 await authenticator.db.authenticationUpsert.rejects(expected);
68 // assert.rejects was not happy to handle this for some reason
69 try {
70 await authenticator.createIdentifier(dbCtx, identifier, credential);
71 assert.fail('no expecte exception');
72 } catch (e) {
73 assert.deepStrictEqual(e, expected);
74 assert(authenticator.db.authenticationUpsert.called);
75 assert(authenticator.logger.error.called);
76 }
77 });
78 }); // createIdentifier
79
80 describe('updateCredential', function () {
81 let dbCtx, newCredential;
82 beforeEach(function () {
83 dbCtx = {};
84 newCredential = 'newpassword';
85 });
86 it('covers success', async function () {
87 await authenticator.updateCredential(dbCtx, identifier, newCredential);
88 assert(authenticator.db.authenticationUpdateCredential.called);
89 assert(authenticator.logger.info.called);
90 });
91 it('covers failure', async function () {
92 const expected = new Error('foo');
93 authenticator.db.authenticationUpdateCredential.rejects(expected);
94 try {
95 await authenticator.updateCredential(dbCtx, identifier, newCredential);
96 assert.fail('no expected exception');
97 } catch (e) {
98 assert.deepStrictEqual(e, expected);
99 assert(authenticator.logger.error.called);
100 }
101 // assert.rejects was not happy to handle this for some reason
102 });
103 }); // updateCredential
104
105 describe('_secureCredential', function () {
106 beforeEach(function () {
107 credential = 'badpassword';
108 });
109 it('covers plain', async function () {
110 const result = await authenticator._secureCredential(credential, 'plain');
111 assert.strictEqual(result, '$plain$' + credential);
112 });
113 it('covers default (argon2)', async function () {
114 const result = await authenticator._secureCredential(credential);
115 assert(result.startsWith('$argon2'));
116 });
117 it('covers invalid authn', async function () {
118 const authn = 'bogus';
119 assert.rejects(async () => await authenticator._secureCredential(credential, authn), RangeError);
120 });
121 }); // _secureCredential
122
123 describe('_validateAuthDataCredential', function () {
124 let authData;
125 beforeEach(function () {
126 credential = 'badpassword';
127 authData = {};
128 });
129 it('fails if not provided a credential', async function () {
130 const result = await authenticator._validateAuthDataCredential(authData, credential);
131 assert.strictEqual(result, false);
132 });
133 it('covers plain', async function () {
134 authData.credential = '$plain$badpassword';
135 const result = await authenticator._validateAuthDataCredential(authData, credential);
136 assert.strictEqual(result, true);
137 });
138 }); // _validateAuthDataCredential
139
140 describe('isValidBasic', function () {
141 it('succeeds', async function () {
142 _authMechanismRequired(authenticator, 'argon2');
143 authenticator.db.authenticationGet.resolves({
144 identifier,
145 credential,
146 });
147 const authString = `${identifier}:${password}`;
148 const result = await authenticator.isValidBasic(authString, ctx);
149 assert.strictEqual(result, true);
150 assert.strictEqual(ctx.authenticationId, identifier);
151 });
152 it('fails', async function () {
153 _authMechanismRequired(authenticator, 'argon2');
154 authenticator.db.authenticationGet.resolves({
155 identifier,
156 credential,
157 });
158 const authString = `${identifier}:wrongPassword}`;
159 const result = await authenticator.isValidBasic(authString, ctx);
160 assert.strictEqual(result, false);
161 assert.strictEqual(ctx.authenticationId, undefined);
162 });
163 it('covers no entry', async function() {
164 authenticator.db.authenticationGet.resolves();
165 const authString = `${identifier}:wrongPassword}`;
166 const result = await authenticator.isValidBasic(authString, ctx);
167 assert.strictEqual(result, false);
168 assert.strictEqual(ctx.authenticationId, undefined);
169 });
170 it('covers unknown password hash', async function () {
171 authenticator.db.authenticationGet.resolves({
172 identifier,
173 credential: '$other$kind_of_credential',
174 });
175 const authString = `${identifier}:wrongPassword}`;
176 const result = await authenticator.isValidBasic(authString, ctx);
177 assert.strictEqual(result, false);
178 assert.strictEqual(ctx.authenticationId, undefined);
179 });
180 }); // isValidBasic
181
182 describe('isValidIdentifierCredential', function () {
183 it('succeeds', async function () {
184 _authMechanismRequired(authenticator, 'argon2');
185 authenticator.db.authenticationGet.resolves({
186 identifier,
187 credential,
188 });
189 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
190 assert.strictEqual(result, true);
191 assert.strictEqual(ctx.authenticationId, identifier);
192 });
193 it('succeeds with OTP', async function () {
194 const otpKey = Buffer.from('1234567890');
195 _authMechanismRequired(authenticator, 'argon2');
196 authenticator.db.authenticationGet.resolves({
197 identifier,
198 credential,
199 otpKey,
200 });
201 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
202 assert.strictEqual(result, true);
203 assert.strictEqual(ctx.authenticationId, identifier);
204 assert.deepStrictEqual(ctx.otpKey, otpKey);
205 });
206 it('fails', async function () {
207 _authMechanismRequired(authenticator, 'argon2');
208 authenticator.db.authenticationGet.resolves({
209 identifier,
210 credential,
211 });
212 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
213 assert.strictEqual(result, false);
214 assert.strictEqual(ctx.authenticationId, undefined);
215 });
216 it('covers no entry', async function() {
217 authenticator.db.authenticationGet.resolves();
218 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
219 assert.strictEqual(result, false);
220 assert.strictEqual(ctx.authenticationId, undefined);
221 });
222 it('covers non-string credential', async function () {
223 credential = '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
224 const result = await authenticator.isValidIdentifierCredential(identifier, undefined, ctx);
225 assert.strictEqual(result, false);
226 assert.strictEqual(ctx.authenticationId, undefined);
227 });
228 it('covers unknown password hash', async function () {
229 authenticator.db.authenticationGet.resolves({
230 identifier,
231 credential: '$other$kind_of_credential',
232 });
233 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
234 assert.strictEqual(result, false);
235 assert.strictEqual(ctx.authenticationId, undefined);
236 });
237 it('covers PAM', async function () {
238 _authMechanismRequired(authenticator, 'pam');
239 sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true);
240 authenticator.db.authenticationGet.resolves({
241 identifier,
242 credential: '$PAM$',
243 });
244 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
245 assert.strictEqual(result, true);
246 assert.strictEqual(ctx.authenticationId, identifier);
247 });
248 }); // isValidIdentifierCredential
249
250 describe('_isValidPAMIdentifier', function () {
251 let authData;
252 beforeEach(function () {
253 _authMechanismRequired(authenticator, 'pam');
254 sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
255 authData = {
256 identifier,
257 };
258 });
259 it('covers success', async function () {
260 authenticator.authn.pam.pamAuthenticatePromise.resolves(true);
261 const result = await authenticator._isValidPAMIdentifier(authData, credential);
262 assert.strictEqual(result, true);
263 });
264 it('covers failure', async function () {
265 _authMechanismRequired(authenticator, 'pam');
266 authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError());
267 const result = await authenticator._isValidPAMIdentifier(authData, credential);
268 assert.strictEqual(result, false);
269 });
270 it('covers error', async function () {
271 _authMechanismRequired(authenticator, 'pam');
272 const expected = new Error('blah');
273 authenticator.authn.pam.pamAuthenticatePromise.rejects(expected);
274 assert.rejects(() => authenticator._isValidPAMIdentifier(authData, credential), expected);
275 });
276 it('covers forbidden', async function () {
277 authData.identifier = 'root';
278 const result = await authenticator._isValidPAMIdentifier(authData, credential);
279 assert.strictEqual(result, false);
280 });
281 }); // _isValidPAMIdentifier
282
283 describe('isValidAuthorization', function () {
284 it('handles basic', async function () {
285 const expected = true;
286 const authorizationHeader = 'basic Zm9vOmJhcg==';
287 sinon.stub(authenticator, 'isValidBasic').resolves(expected);
288 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
289 assert.strictEqual(result, expected);
290 });
291 it('handles other', async function () {
292 const expected = false;
293 const authorizationHeader = 'bearer Zm9vOmJhcg==';
294 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
295 assert.strictEqual(result, expected);
296 });
297 }); // isValidAuthorization
298
299 describe('requestBasic', function () {
300 it('covers', function () {
301 const res = {
302 setHeader: () => {},
303 };
304 assert.throws(() => authenticator.requestBasic(res), {
305 name: 'ResponseError',
306 statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
307 });
308 });
309 }); // requestBasic
310
311 describe('isValidCookieAuth', function () {
312 beforeEach(function () {
313 sinon.stub(authenticator.mysteryBox, 'unpack');
314 ctx.cookie = {
315 squeepSession: 'dummy',
316 otherCookie: 'foo',
317 };
318 });
319 it('covers identifier success', async function () {
320 authenticator.mysteryBox.unpack.resolves({
321 authenticatedIdentifier: 'identifier',
322 });
323 const result = await authenticator.isValidCookieAuth(ctx);
324 assert.strictEqual(result, true);
325 });
326 it('covers profile success', async function () {
327 authenticator.mysteryBox.unpack.resolves({
328 authenticatedProfile: 'profile',
329 });
330 const result = await authenticator.isValidCookieAuth(ctx);
331 assert.strictEqual(result, true);
332 });
333 it('covers missing cookie', async function () {
334 delete ctx.cookie.squeepSession;
335 const result = await authenticator.isValidCookieAuth(ctx);
336 assert.strictEqual(result, false);
337 });
338 it('covers bad cookie', async function () {
339 authenticator.mysteryBox.unpack.rejects();
340 const result = await authenticator.isValidCookieAuth(ctx);
341 assert.strictEqual(result, false);
342 });
343 it('covers broken session', async function () {
344 authenticator.mysteryBox.unpack.resolves({
345 randomData: 'foo',
346 });
347 const result = await authenticator.isValidCookieAuth(ctx);
348 assert.strictEqual(result, false);
349 });
350 }); // isValidCookieAuth
351
352 describe('checkOTP', function () {
353 let state, otp;
354 this.beforeEach(function () {
355 sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
356 state = {
357 key: '12345678901234567890123456789012',
358 attempt: 0,
359 epochMs: Date.now(),
360 };
361 otp = '000000';
362 });
363 it('covers valid OTP entry', function () {
364 const result = authenticator.checkOTP(state, otp);
365 assert.strictEqual(result, Enum.OTPResult.Valid);
366 });
367 it('covers invalid OTP entry', function () {
368 authenticator.TOTP.prototype.validate.returns(false);
369 const result = authenticator.checkOTP(state, otp);
370 assert.strictEqual(result, Enum.OTPResult.InvalidSoftFail);
371 });
372 it('covers invalid OTP entry, too many failures', function () {
373 state.attempt = 10;
374 authenticator.TOTP.prototype.validate.returns(false);
375 const result = authenticator.checkOTP(state, otp);
376 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
377 });
378 it('covers invalid OTP entry', function () {
379 state.epochMs = 0;
380 authenticator.TOTP.prototype.validate.returns(false);
381 const result = authenticator.checkOTP(state, otp);
382 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
383 });
384 }); // checkOTP
385
386 describe('sessionCheck', function () {
387 let req, res, loginPath, required, profilesAllowed;
388 beforeEach(function () {
389 ctx.clientProtocol = 'https';
390 ctx.cookie = {
391 squeepSession: 'squeep_session_blob',
392 };
393 req = {
394 getHeader: sinon.stub(),
395 };
396 res = {
397 end: sinon.stub(),
398 setHeader: sinon.stub(),
399 appendHeader: sinon.stub(),
400 };
401 loginPath = '/admin/login';
402 required = true;
403 profilesAllowed = true;
404 });
405 it('covers valid cookie session', async function () {
406 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
407 ctx.session = {
408 authenticatedIdentifier: 'user',
409 };
410 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
411 assert.strictEqual(result, true);
412 });
413 it('covers valid insecure cookie session', async function () {
414 authenticator.secureAuthOnly = false;
415 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
416 ctx.session = {
417 authenticatedIdentifier: 'user',
418 };
419 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
420 assert.strictEqual(result, true);
421 });
422 it('rejects insecure connection', async function () {
423 ctx.clientProtocol = 'http';
424 assert.rejects(() => authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed), {
425 name: 'ResponseError',
426 sttausCode: Enum.ErrorResponse.Forbidden.statusCode,
427 });
428 });
429 it('ignores insecure connection if auth not required', async function () {
430 ctx.clientProtocol = 'http';
431 required = false;
432 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
433 assert.strictEqual(result, false);
434 });
435 it('redirects without any auth', async function () {
436 await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
437 assert(res.end.called);
438 assert(res.setHeader.called);
439 });
440 describe('convenience wrappers', function () {
441 describe('sessionRequiredLocal', function () {
442 it('accepts identifier', async function () {
443 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
444 ctx.session = {
445 authenticatedIdentifier: 'user',
446 };
447 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
448 assert.strictEqual(result, true);
449 });
450 it('redirects with profile', async function () {
451 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
452 ctx.session = {
453 authenticatedProfile: 'user',
454 };
455 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
456 assert.strictEqual(result, false);
457 assert(res.end.called);
458 assert(res.setHeader.called);
459 });
460 }); // sessionRequiredLocal
461 describe('sessionRequired', function () {
462 it('accepts identifier', async function () {
463 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
464 ctx.session = {
465 authenticatedIdentifier: 'user',
466 };
467 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
468 assert.strictEqual(result, true);
469 });
470 it('accepts profile', async function () {
471 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
472 ctx.session = {
473 authenticatedProfile: 'user',
474 };
475 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
476 assert.strictEqual(result, true);
477 });
478 it('rejects invalid', async function () {
479 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
480 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
481 assert.strictEqual(result, false);
482 assert(res.end.called);
483 assert(res.setHeader.called);
484 });
485 it('covers insecure allowed', async function () {
486 authenticator.options.authenticator.secureAuthOnly = false;
487 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
488 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
489 assert.strictEqual(result, false);
490 assert(res.end.called);
491 assert(res.setHeader.called);
492 });
493 }); // sessionRequired
494 describe('sessionOptionalLocal', function () {
495 it('rejects profile', async function () {
496 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
497 ctx.session = {
498 authenticatedProfile: 'user',
499 };
500 const result = await authenticator.sessionOptionalLocal(req, res, ctx, loginPath);
501 assert.strictEqual(result, false);
502 assert(!res.end.called);
503 assert(!res.setHeader.called);
504 });
505 }); // sessionOptionalLocal
506 describe('sessionOptional', function () {
507 it('rejects invalid', async function () {
508 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
509 const result = await authenticator.sessionOptional(req, res, ctx, loginPath);
510 assert.strictEqual(result, false);
511 assert(!res.end.called);
512 assert(!res.setHeader.called);
513 });
514 }); // sessionOptional
515 }); // convenience wrappers
516 }); // sessionCheck
517
518 describe('apiRequiredLocal', function () {
519 let req, res;
520 beforeEach(function () {
521 ctx = {};
522 req = {
523 getHeader: sinon.stub(),
524 };
525 res = {
526 end: sinon.stub(),
527 setHeader: sinon.stub(),
528 };
529 });
530 it('covers valid basic auth', async function () {
531 req.getHeader.returns('Basic Zm9vOmJhcg==');
532 sinon.stub(authenticator, 'sessionCheck').resolves(false);
533 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
534 const result = await authenticator.apiRequiredLocal(req, res, ctx);
535 assert.strictEqual(result, true);
536 assert(authenticator.isValidAuthorization.called);
537 assert(!authenticator.sessionCheck.called);
538 });
539 it('covers invalid basic auth', async function () {
540 req.getHeader.returns('Basic Zm9vOmJhcg==');
541 sinon.stub(authenticator, 'sessionCheck').resolves(false);
542 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
543 assert.rejects(() => authenticator.apiRequiredLocal(req, res, ctx), {
544 name: 'ResponseError',
545 statusCode: 401,
546 });
547 assert(!authenticator.sessionCheck.called);
548 assert(authenticator.isValidAuthorization.called);
549 });
550 it('covers missing basic auth, valid session', async function () {
551 req.getHeader.returns();
552 sinon.stub(authenticator, 'sessionCheck').resolves(true);
553 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
554 const result = await authenticator.apiRequiredLocal(req, res, ctx);
555 assert.strictEqual(result, true);
556 assert(!authenticator.isValidAuthorization.called);
557 assert(authenticator.sessionCheck.called);
558 });
559 it('covers missing basic auth, ignores session', async function () {
560 req.getHeader.returns();
561 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
562 assert.rejects(authenticator.apiRequiredLocal(req, res, ctx, false), {
563 name: 'ResponseError',
564 statusCode: 401,
565 });
566 assert(!authenticator.sessionCheck.called);
567 assert(!authenticator.isValidAuthorization.called);
568 assert(res.setHeader.called);
569 });
570 }); // apiRequiredLocal
571
572 }); // Authenticator