bump package version to 1.5.0
[squeep-authentication-module] / lib / authenticator.js
1 'use strict';
2
3 const common = require('./common');
4 const Enum = require('./enum');
5 const Errors = require('./errors');
6 const { MysteryBox } = require('@squeep/mystery-box');
7 const { TOTP } = require('@squeep/totp');
8 const { name: packageName } = require('../package');
9
10 const _fileScope = common.fileScope(__filename);
11
12 /**
13 * Wrangles the fiddly bits of authentication.
14 * Handles checking of credentials and otp codes, creating and validating
15 * cookie-based sessions, Basic auth.
16 * Interacts with the authentication database interface.
17 */
18
19 /**
20 * @typedef {import('node:http')} http
21 */
22
23 class Authenticator {
24 /**
25 * @typedef {object} AuthInfo
26 * @property {string} identifier identifier
27 * @property {string} credential hashed credential
28 * @property {string=} otpKey optional otp key
29 */
30 /**
31 * @callback DBContextExec
32 * @param {object} dbCtx
33 * @returns {Promise<any>}
34 */
35 /**
36 * @typedef {object} AuthDBInterface
37 * @property {(DBContextExec) => Promise<any>} context db context runner
38 * @property {(dbCtx: any, identifier: string) => Promise<AuthInfo>} authenticationGet get an authentication entry
39 * @property {(dbCtx: any, identifier: string) => Promise<void>} authenticationSuccess store a successful authentication event
40 * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise<void>} authenticationUpsert update an authentication entry
41 * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise<void>} authenticationUpdateOTPKey update otp key
42 * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential update credential
43 */
44 /**
45 * @typedef {object} ConsoleLike
46 * @property {Function} debug log debug
47 * @property {Function} error log error
48 * @property {Function} info log info
49 */
50 /**
51 * @param {ConsoleLike} logger logger instance
52 * @param {AuthDBInterface} db db instance
53 * @param {object} options options
54 * @param {string | string[]} options.encryptionSecret encryption secret
55 * @param {object} options.authenticator authenticator options
56 * @param {boolean} options.authenticator.secureAuthOnly disable auth over non-https
57 * @param {string[]} options.authenticator.forbiddenPAMIdentifiers reject these identifiers for PAM auth
58 * @param {string[]} options.authenticator.authnEnabled in order of preference for storing new credentials
59 * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
60 * @param {string[]=} options.authenticator.loginBlurb text for login page
61 * @param {string[]=} options.authenticator.indieAuthBlurb text for indieauth login section
62 * @param {string[]=} options.authenticator.userBlurb text for local user login section
63 * @param {string[]=} options.authenticator.otpBlurb text for otp entry
64 * @param {string=} options.dingus dingus options
65 * @param {string=} options.dingus.proxyPrefix base url prefix
66 */
67 constructor(logger, db, options) {
68 this.logger = logger;
69 this.db = db;
70 this.options = options;
71 this.basicRealm = options.authenticator.basicRealm || packageName;
72 this.secureAuthOnly = options.authenticator.secureAuthOnly ?? true;
73 this.proxyPrefix = options.dingus?.proxyPrefix ?? '';
74
75 // First construct map of all available code-supported auth mechanisms.
76 this.authn = {
77 indieAuth: {},
78 plain: {},
79 };
80 try {
81 this.authn.argon2 = require('argon2');
82 } catch (e) { /**/ } // eslint-disable-line no-unused-vars
83 try {
84 this.authn.pam = require('node-linux-pam');
85 this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers ?? ['root'];
86 } catch (e) { /**/ } // eslint-disable-line no-unused-vars
87
88 // Track which authn methods we can change credentials et cetera.
89 const authnUpdatable = ['plain', 'argon2'];
90
91 // Filter configured mechanisms from available, note the first as which to use for setting new credentials.
92 this.authnEnabled = new Set();
93 this.authnPreferred = undefined; // For updating credentials
94 options.authenticator.authnEnabled.forEach((authn) => {
95 if (authn in this.authn) {
96 this.authnEnabled.add(authn);
97 if (!this.authnPreferred && authnUpdatable.includes(authn)) {
98 this.authnPreferred = authn;
99 }
100 }
101 });
102 this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array.from(this.authnEnabled), preferred: this.authnPreferred });
103
104 if (this.authnEnabled.size === 0) {
105 throw new Error('no authentication mechanisms available');
106 }
107
108 this.mysteryBox = new MysteryBox(options);
109 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
110
111 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
112
113 this.TOTP = TOTP; // For ease of test stubbing.
114 }
115
116
117 /**
118 * Populate the authentication database with a new identifier, the
119 * secured credential, and optionally an OTP key.
120 * @param {*} dbCtx db context
121 * @param {string} identifier identifier
122 * @param {string} credential plaintext
123 * @param {string=} otpKey otp key
124 * @returns {Promise<void>}
125 */
126 async createIdentifier(dbCtx, identifier, credential, otpKey = null) {
127 const _scope = _fileScope('createIdentifier');
128 try {
129 const secureCredential = await this._secureCredential(credential);
130 await this.db.authenticationUpsert(dbCtx, identifier, secureCredential, otpKey);
131 } catch (e) {
132 this.logger.error(_scope, 'failed', { error: e, identifier });
133 throw e;
134 }
135 }
136
137
138 /**
139 * Update the authentication database with a new secured credential
140 * for an indentifier.
141 * @param {*} dbCtx dbCtx
142 * @param {string} identifier identifier
143 * @param {string} credential plaintext
144 * @returns {Promise<void>}
145 */
146 async updateCredential(dbCtx, identifier, credential) {
147 const _scope = _fileScope('updateCredential');
148 try {
149 const secureCredential = await this._secureCredential(credential);
150 await this.db.authenticationUpdateCredential(dbCtx, identifier, secureCredential);
151 this.logger.info(_scope, 'success', { identifier });
152 } catch (e) {
153 this.logger.error(_scope, 'failed', { error: e, identifier });
154 throw e;
155 }
156 }
157
158
159 /**
160 * Encode a plaintext credential in the preferred way to store in database.
161 * @param {string} credential plaintext
162 * @param {string=} authn authentication mechanism
163 * @returns {Promise<string>} encoded credential
164 */
165 async _secureCredential(credential, authn = this.authnPreferred) {
166 const _scope = _fileScope('_secureCredential');
167 try {
168 switch (authn) {
169 case 'plain':
170 return '$plain$' + credential;
171
172 case 'argon2':
173 return await this.authn.argon2.hash(credential, { type: this.authn.argon2.argon2id });
174
175 default:
176 throw new RangeError('Unknown authn');
177 }
178 } catch (e) {
179 this.logger.error(_scope, 'failed', { error: e, authn });
180 throw e;
181 }
182 }
183
184
185 /**
186 * Checks a supplied credential against supplied data.
187 * @param {AuthInfo} authData from database
188 * @param {string} credential plaintext
189 * @returns {Promise<boolean>} is valid
190 */
191 async _validateAuthDataCredential(authData, credential) {
192 const _scope = _fileScope('_validateAuthDataCredential');
193
194 if (!authData?.credential) {
195 this.logger.debug(_scope, 'failed, no authInfo');
196 return false;
197 }
198 if (authData.credential.startsWith('$argon2')
199 && this.authnEnabled.has('argon2')) {
200 return await this._isValidArgon2Identifier(authData, credential);
201 }
202 if (authData.credential.startsWith('$PAM$')
203 && this.authnEnabled.has('pam')) {
204 return await this._isValidPAMIdentifier(authData, credential);
205 }
206 if (authData.credential.startsWith('$plain$')
207 && this.authnEnabled.has('plain')) {
208 return this.constructor._isValidPlainIdentifier(authData, credential);
209 }
210 this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { authData });
211 return false;
212 }
213
214
215 /**
216 * Check argon2.
217 * @param {AuthInfo} authData auth entry
218 * @param {string} credential to check
219 * @returns {Promise<boolean>} is valid
220 */
221 async _isValidArgon2Identifier(authData, credential) {
222 return await this.authn.argon2.verify(authData.credential, credential);
223 }
224
225
226 /**
227 * Check plaintext.
228 * @param {AuthInfo} authData auth entry
229 * @param {string} credential to check
230 * @returns {Promise<boolean>} is valid
231 */
232 static _isValidPlainIdentifier(authData, credential) {
233 return authData.credential.substring('$plain$'.length) === credential;
234 }
235
236
237 /**
238 * Check system PAM.
239 * @param {AuthInfo} authData auth entry
240 * @param {string} credential to check
241 * @returns {Promise<boolean>} is valid
242 */
243 async _isValidPAMIdentifier(authData, credential) {
244 const _scope = _fileScope('_isValidPAMIdentifier');
245 const { identifier } = authData;
246 let isValid = false;
247 if (this.forbiddenPAMIdentifiers.includes(identifier)) {
248 return false;
249 }
250 try {
251 await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
252 isValid = true;
253 } catch (e) {
254 this.logger.debug(_scope, 'failed', { error: e });
255 if (!(e instanceof this.authn.pam.PamError)) {
256 throw e;
257 }
258 }
259 return isValid;
260 }
261
262
263 /**
264 * Check local auth entries.
265 * Sets ctx.authenticatedId if valid.
266 * Sets ctx.otpKey if account has otpKey.
267 * @param {string} identifier identifier
268 * @param {string} credential to check
269 * @param {object} ctx context
270 * @returns {Promise<boolean>} is valid
271 */
272 async isValidIdentifierCredential(identifier, credential, ctx) {
273 const _scope = _fileScope('isValidIdentifierCredential');
274 this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx });
275
276 let isValid = false;
277
278 if (typeof credential === 'undefined') {
279 return isValid;
280 }
281
282 await this.db.context(async (dbCtx) => {
283 const authData = await this.db.authenticationGet(dbCtx, identifier);
284 if (!authData) {
285 this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
286 return;
287 }
288
289 isValid = await this._validateAuthDataCredential(authData, credential);
290
291 if (isValid) {
292 ctx.authenticationId = identifier;
293 if (authData.otpKey) {
294 ctx.otpKey = authData.otpKey;
295 }
296 await this.db.authenticationSuccess(dbCtx, identifier);
297 }
298 }); // dbCtx
299
300 return isValid;
301 }
302
303
304 /**
305 * @typedef {object} OTPState
306 * @property {string} key otp key
307 * @property {number} attempt count of attempts
308 * @property {number} epochMs when entry was initiated
309 */
310 /**
311 * Validate if an entered otp token matches the key.
312 * @param {OTPState} state otp state
313 * @param {string} otp to check
314 * @returns {Enum.OTPResult} result
315 */
316 checkOTP(state, otp) {
317 const totp = new this.TOTP({
318 keyEncoding: 'base32',
319 ...this.options?.authenticator?.otpOptions,
320 key: state.key,
321 });
322 const isValid = totp.validate(otp);
323 if (isValid) {
324 return Enum.OTPResult.Valid;
325 }
326 const otpAttemptsRemain = state.attempt <= (this.options?.authenticator?.otpAttemptsAllowed ?? 3);
327 const otpWithinWindow = state.epochMs + (this.options?.authenticator?.otpWindowMs ?? 600000) > Date.now();
328 if (otpAttemptsRemain && otpWithinWindow) {
329 return Enum.OTPResult.InvalidSoftFail;
330 }
331 return Enum.OTPResult.InvalidHardFail;
332 }
333
334
335 /**
336 * Update the authentication database with a new otp key.
337 * @param {*} dbCtx db context
338 * @param {string} identifier identifier
339 * @param {string=} otpKey otp key
340 */
341 async updateOTPKey(dbCtx, identifier, otpKey) {
342 const _scope = _fileScope('updateOTPKey');
343 try {
344 await this.db.authenticationUpdateOTPKey(dbCtx, identifier, otpKey);
345 this.logger.info(_scope, 'otp key updated');
346 } catch (e) {
347 this.logger.error(_scope, 'failed', { error: e, identifier });
348 }
349 }
350
351
352 /**
353 * Check for valid Basic auth, updates ctx with identifier if valid.
354 * @param {string} authValue basic auth value (base64)
355 * @param {object} ctx context
356 * @returns {Promise<boolean>} is valid
357 */
358 async isValidBasic(authValue, ctx) {
359 const _scope = _fileScope('isValidBasic');
360 this.logger.debug(_scope, 'called', { ctx });
361
362 const credentials = Buffer.from(authValue, 'base64').toString('utf-8');
363 const [identifier, credential] = common.splitFirst(credentials, ':', '');
364
365 return this.isValidIdentifierCredential(identifier, credential, ctx);
366 }
367
368
369 /**
370 * Determine which Authorization header is available, and if it is valid.
371 * @param {string} authorizationHeader request header
372 * @param {object} ctx context
373 * @returns {Promise<boolean>} is valid
374 */
375 async isValidAuthorization(authorizationHeader, ctx) {
376 const _scope = _fileScope('isValidAuthorization');
377 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
378
379 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
380 // eslint-disable-next-line sonarjs/no-small-switch
381 switch (authMethod.toLowerCase()) {
382 case 'basic': {
383 return this.isValidBasic(authString, ctx);
384 }
385
386 default:
387 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
388 return false;
389 }
390 }
391
392
393 /**
394 * Send a response requesting basic auth.
395 * @param {http.ServerResponse} res response
396 */
397 requestBasic(res) {
398 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
399 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
400 }
401
402
403 /**
404 * Attempt to parse a session cookie, and determine if it contains an
405 * authenticated user.
406 * Restores ctx.session from cookie data, sets ctx.authenticationId to
407 * identifier or profile for session.
408 * @param {object} ctx context
409 * @param {object} ctx.cookie cookies object
410 * @returns {Promise<boolean>} is valid
411 */
412 async isValidCookieAuth(ctx) {
413 const _scope = _fileScope('isValidCookieAuth');
414 this.logger.debug(_scope, 'called', { ctx });
415
416 const cookieValue = ctx.cookie?.[Enum.SessionCookie];
417
418 if (!cookieValue) {
419 return false;
420 }
421 try {
422 ctx.session = await this.mysteryBox.unpack(cookieValue);
423 this.logger.debug(_scope, 'unpacked cookie', { ctx });
424
425 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
426 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.has('indieAuth');
427 const isValid = hasIdentifier || hasProfile;
428 if (isValid) {
429 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
430 }
431
432 return isValid;
433 } catch (e) {
434 this.logger.debug(_scope, 'could not unpack cookie', { error: e, ctx });
435 return false;
436 }
437 }
438
439
440 /**
441 * Check for a valid session.
442 * If a valid session cookie is present, refreshes the timeout for it.
443 * If not, and one is required, redirects to the login url.
444 * Convenience wrappers for option combinations:
445 * @see sessionRequired
446 * @see sessionRequiredLocal
447 * @see sessionOptional
448 * @see sessionOptionalLocal
449 * @param {http.ClientRequest} req request
450 * @param {http.ServerResponse} res response
451 * @param {object} ctx context
452 * @param {string} loginPath url path to redirect to when login is required
453 * @param {boolean} required redirect to login url if no valid session
454 * @param {boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential
455 * @returns {Promise<boolean>} is valid session
456 */
457 async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
458 const _scope = _fileScope('sessionCheck');
459 this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
460
461 if (this.secureAuthOnly
462 && ctx.clientProtocol.toLowerCase() !== 'https') {
463 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
464 if (required) {
465 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
466 } else {
467 return false;
468 }
469 }
470
471 if (await this.isValidCookieAuth(ctx)
472 && (ctx.session.authenticatedIdentifier
473 || (profilesAllowed && ctx.session.authenticatedProfile))) {
474 this.logger.debug(_scope, 'valid session cookie', { ctx });
475 // Refresh timeout on valid session.
476 common.addCookie(res, Enum.SessionCookie, ctx.cookie[Enum.SessionCookie], {
477 httpOnly: true,
478 maxAge: this.cookieLifespan,
479 sameSite: 'Lax',
480 path: `${this.proxyPrefix}/`,
481 secure: this.secureAuthOnly,
482 });
483 return true;
484 }
485
486 if (required) {
487 // Clear any existing invalid session
488 common.addCookie(res, Enum.SessionCookie, '""', {
489 httpOnly: true,
490 maxAge: 0,
491 sameSite: 'Lax',
492 path: `${this.proxyPrefix}/`,
493 secure: this.secureAuthOnly,
494 });
495
496 res.statusCode = 302;
497 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
498 res.end();
499 }
500
501 return false;
502 }
503
504
505 /**
506 * Requires a valid session with a local identifier. Redirects to loginPath if not.
507 * @param {http.ClientRequest} req request
508 * @param {http.ServerResponse} res response
509 * @param {object} ctx context
510 * @param {string} loginPath url path to redirect to when login is needed
511 * @returns {Promise<boolean>} is valid session
512 */
513 async sessionRequiredLocal(req, res, ctx, loginPath) {
514 return this.sessionCheck(req, res, ctx, loginPath, true, false);
515 }
516
517
518 /**
519 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
520 * @param {http.ClientRequest} req request
521 * @param {http.ServerResponse} res response
522 * @param {object} ctx context
523 * @param {string} loginPath url path to redirect to when login is needed
524 * @returns {Promise<boolean>} is valid session
525 */
526 async sessionRequired(req, res, ctx, loginPath) {
527 return this.sessionCheck(req, res, ctx, loginPath);
528 }
529
530
531 /**
532 * Check for a valid session with a local identifier, but do nothing if not.
533 * @param {http.ClientRequest} req request
534 * @param {http.ServerResponse} res response
535 * @param {object} ctx context
536 * @returns {Promise<boolean>} is valid session
537 */
538 async sessionOptionalLocal(req, res, ctx) {
539 return this.sessionCheck(req, res, ctx, undefined, false, false);
540 }
541
542
543 /**
544 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
545 * @param {http.ClientRequest} req request
546 * @param {http.ServerResponse} res response
547 * @param {object} ctx context
548 * @returns {Promise<boolean>} is valid session
549 */
550 async sessionOptional(req, res, ctx) {
551 return this.sessionCheck(req, res, ctx, undefined, false);
552 }
553
554
555 /**
556 * Require auth for an API endpoint.
557 * Check for valid local identifier in Authorization header;
558 * optionally fall back to session cookie if no header provided.
559 * Prompts for Basic auth if not valid.
560 * @param {http.ClientRequest} req request
561 * @param {http.ServerResponse} res response
562 * @param {object} ctx context
563 * @param {boolean} sessionAlsoValid fall back to session if no authz header
564 * @returns {Promise<boolean>} is valid
565 */
566 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
567 const _scope = _fileScope('apiRequiredLocal');
568 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
569
570 try {
571 // If a Authorization header was provided, never consider session as a fallback.
572 const authorizationHeader = req.getHeader(Enum.Header.Authorization);
573 if (authorizationHeader) {
574 if (await this.isValidAuthorization(authorizationHeader, ctx)) {
575 this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
576 return true;
577 }
578 } else if (sessionAlsoValid
579 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
580 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
581 return true;
582 }
583 } catch (e) {
584 this.logger.error(_scope, 'failed', { error: e });
585 throw e;
586 }
587
588 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
589 this.requestBasic(res);
590 }
591
592
593 }
594
595 module.exports = Authenticator;