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