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