use existing auth db method for identifier creation
[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 * Check for valid Basic auth, updates ctx with identifier if valid.
324 * @param {String} credentials
325 * @param {Object} ctx
326 * @returns {Promise<Boolean>}
327 */
328 async isValidBasic(credentials, ctx) {
329 const _scope = _fileScope('isValidBasic');
330 this.logger.debug(_scope, 'called', { ctx });
331
332 const [identifier, credential] = common.splitFirst(credentials, ':', '');
333
334 return this.isValidIdentifierCredential(identifier, credential, ctx);
335 }
336
337
338 /**
339 * Determine which Authorization header is available, and if it is valid.
340 * @param {String} authorizationHeader
341 * @param {Object} ctx
342 * @returns {Promise<Boolean>}
343 */
344 async isValidAuthorization(authorizationHeader, ctx) {
345 const _scope = _fileScope('isValidAuthorization');
346 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
347
348 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
349 // eslint-disable-next-line sonarjs/no-small-switch
350 switch (authMethod.toLowerCase()) {
351 case 'basic': {
352 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
353 return this.isValidBasic(credentials, ctx);
354 }
355
356 default:
357 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
358 return false;
359 }
360 }
361
362
363 /**
364 * Send a response requesting basic auth.
365 * @param {http.ServerResponse} res
366 */
367 requestBasic(res) {
368 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
369 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
370 }
371
372
373 /**
374 * Attempt to parse a session cookie, and determine if it contains an
375 * authenticated user.
376 * Restores ctx.session from cookie data, sets ctx.authenticationId to
377 * identifier or profile for session.
378 * @param {Object} ctx
379 * @param {Object} ctx.cookie
380 * @returns {Promise<Boolean>}
381 */
382 async isValidCookieAuth(ctx) {
383 const _scope = _fileScope('isValidCookieAuth');
384 this.logger.debug(_scope, 'called', { ctx });
385
386 const cookieValue = ctx.cookie?.[Enum.SessionCookie];
387
388 if (!cookieValue) {
389 return false;
390 }
391 try {
392 ctx.session = await this.mysteryBox.unpack(cookieValue);
393 this.logger.debug(_scope, 'unpacked cookie', { ctx });
394
395 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
396 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.has('indieAuth');
397 const isValid = hasIdentifier || hasProfile;
398 if (isValid) {
399 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
400 }
401
402 return isValid;
403 } catch (e) {
404 this.logger.debug(_scope, 'could not unpack cookie', { error: e, ctx });
405 return false;
406 }
407 }
408
409
410 /**
411 * Check for a valid session.
412 * If a valid session cookie is present, refreshes the timeout for it.
413 * If not, and one is required, redirects to the login url.
414 * Convenience wrappers for option combinations:
415 * @see sessionRequired
416 * @see sessionRequiredLocal
417 * @see sessionOptional
418 * @see sessionOptionalLocal
419 * @param {http.ClientRequest} req
420 * @param {http.ServerResponse} res
421 * @param {Object} ctx
422 * @param {String} loginPath
423 * @param {Boolean} required redirect to login url if no valid session
424 * @param {Boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential
425 * @returns {Promise<Boolean>}
426 */
427 async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
428 const _scope = _fileScope('sessionCheck');
429 this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
430
431 if (this.secureAuthOnly
432 && ctx.clientProtocol.toLowerCase() !== 'https') {
433 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
434 if (required) {
435 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
436 } else {
437 return false;
438 }
439 }
440
441 if (await this.isValidCookieAuth(ctx)
442 && (ctx.session.authenticatedIdentifier
443 || (profilesAllowed && ctx.session.authenticatedProfile))) {
444 this.logger.debug(_scope, 'valid session cookie', { ctx });
445 // Refresh timeout on valid session.
446 common.addCookie(res, Enum.SessionCookie, ctx.cookie[Enum.SessionCookie], {
447 httpOnly: true,
448 maxAge: this.cookieLifespan,
449 sameSite: 'Lax',
450 path: `${this.proxyPrefix}/`,
451 secure: this.secureAuthOnly,
452 });
453 return true;
454 }
455
456 if (required) {
457 // Clear any existing invalid session
458 common.addCookie(res, Enum.SessionCookie, '""', {
459 httpOnly: true,
460 maxAge: 0,
461 sameSite: 'Lax',
462 path: `${this.proxyPrefix}/`,
463 secure: this.secureAuthOnly,
464 });
465
466 res.statusCode = 302;
467 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
468 res.end();
469 }
470
471 return false;
472 }
473
474
475 /**
476 * Requires a valid session with a local identifier. Redirects to loginPath if not.
477 * @param {http.ClientRequest} req
478 * @param {http.ServerResponse} res
479 * @param {Object} ctx
480 * @param {String} loginPath
481 * @returns {Promise<Boolean>}
482 */
483 async sessionRequiredLocal(req, res, ctx, loginPath) {
484 return this.sessionCheck(req, res, ctx, loginPath, true, false);
485 }
486
487
488 /**
489 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
490 * @param {http.ClientRequest} req
491 * @param {http.ServerResponse} res
492 * @param {Object} ctx
493 * @param {String} loginPath
494 * @returns {Promise<Boolean>}
495 */
496 async sessionRequired(req, res, ctx, loginPath) {
497 return this.sessionCheck(req, res, ctx, loginPath);
498 }
499
500
501 /**
502 * Check for a valid session with a local identifier, but do nothing if not.
503 * @param {http.ClientRequest} req
504 * @param {http.ServerResponse} res
505 * @param {Object} ctx
506 * @param {String} loginPath
507 * @returns {Promise<Boolean>}
508 */
509 async sessionOptionalLocal(req, res, ctx) {
510 return this.sessionCheck(req, res, ctx, undefined, false, false);
511 }
512
513
514 /**
515 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
516 * @param {http.ClientRequest} req
517 * @param {http.ServerResponse} res
518 * @param {Object} ctx
519 * @param {String} loginPath
520 * @returns {Promise<Boolean>}
521 */
522 async sessionOptional(req, res, ctx) {
523 return this.sessionCheck(req, res, ctx, undefined, false);
524 }
525
526
527 /**
528 * Require auth for an API endpoint.
529 * Check for valid local identifier in Authorization header;
530 * optionally fall back to session cookie if no header provided.
531 * Prompts for Basic auth if not valid.
532 * @param {http.ClientRequest} req
533 * @param {http.ServerResponse} res
534 * @param {Object} ctx
535 * @param {Boolean} sessionAlsoValid
536 * @returns {Promise<Boolean}
537 */
538 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
539 const _scope = _fileScope('apiRequiredLocal');
540 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
541
542 // If a Authorization header was provided, never consider session as a fallback.
543 const authorizationHeader = req.getHeader(Enum.Header.Authorization);
544 if (authorizationHeader) {
545 if (await this.isValidAuthorization(authorizationHeader, ctx)) {
546 this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
547 return true;
548 }
549 } else if (sessionAlsoValid
550 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
551 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
552 return true;
553 }
554
555 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
556 this.requestBasic(res);
557 }
558
559
560 }
561
562 module.exports = Authenticator;