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