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');
10 const _fileScope
= common
.fileScope(__filename
);
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.
20 * @typedef {import('node:http')} http
25 * @typedef {object} AuthInfo
26 * @property {string} identifier identifier
27 * @property {string} credential hashed credential
28 * @property {string=} otpKey optional otp key
31 * @callback DBContextExec
32 * @param {object} dbCtx
33 * @returns {Promise<any>}
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
45 * @typedef {object} ConsoleLike
46 * @property {Function} debug log debug
47 * @property {Function} error log error
48 * @property {Function} info log info
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
67 constructor(logger
, db
, options
) {
70 this.options
= options
;
71 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
72 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
?? true;
73 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
75 // First construct map of all available code-supported auth mechanisms.
81 this.authn
.argon2
= require('argon2');
82 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
84 this.authn
.pam
= require('node-linux-pam');
85 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
?? ['root'];
86 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
88 // Track which authn methods we can change credentials et cetera.
89 const authnUpdatable
= ['plain', 'argon2'];
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
;
102 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array
.from(this.authnEnabled
), preferred: this.authnPreferred
});
104 if (this.authnEnabled
.size
=== 0) {
105 throw new Error('no authentication mechanisms available');
108 this.mysteryBox
= new MysteryBox(options
);
109 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
111 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
113 this.TOTP
= TOTP
; // For ease of test stubbing.
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>}
126 async
createIdentifier(dbCtx
, identifier
, credential
, otpKey
= null) {
127 const _scope
= _fileScope('createIdentifier');
129 const secureCredential
= await
this._secureCredential(credential
);
130 await
this.db
.authenticationUpsert(dbCtx
, identifier
, secureCredential
, otpKey
);
132 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
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>}
146 async
updateCredential(dbCtx
, identifier
, credential
) {
147 const _scope
= _fileScope('updateCredential');
149 const secureCredential
= await
this._secureCredential(credential
);
150 await
this.db
.authenticationUpdateCredential(dbCtx
, identifier
, secureCredential
);
151 this.logger
.info(_scope
, 'success', { identifier
});
153 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
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
165 async
_secureCredential(credential
, authn
= this.authnPreferred
) {
166 const _scope
= _fileScope('_secureCredential');
170 return '$plain$' + credential
;
173 return await
this.authn
.argon2
.hash(credential
, { type: this.authn
.argon2
.argon2id
});
176 throw new RangeError('Unknown authn');
179 this.logger
.error(_scope
, 'failed', { error: e
, authn
});
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
191 async
_validateAuthDataCredential(authData
, credential
) {
192 const _scope
= _fileScope('_validateAuthDataCredential');
194 if (!authData
?.credential
) {
195 this.logger
.debug(_scope
, 'failed, no authInfo');
198 if (authData
.credential
.startsWith('$argon2')
199 && this.authnEnabled
.has('argon2')) {
200 return await
this._isValidArgon2Identifier(authData
, credential
);
202 if (authData
.credential
.startsWith('$PAM$')
203 && this.authnEnabled
.has('pam')) {
204 return await
this._isValidPAMIdentifier(authData
, credential
);
206 if (authData
.credential
.startsWith('$plain$')
207 && this.authnEnabled
.has('plain')) {
208 return this.constructor._isValidPlainIdentifier(authData
, credential
);
210 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { authData
});
217 * @param {AuthInfo} authData auth entry
218 * @param {string} credential to check
219 * @returns {Promise<boolean>} is valid
221 async
_isValidArgon2Identifier(authData
, credential
) {
222 return await
this.authn
.argon2
.verify(authData
.credential
, credential
);
228 * @param {AuthInfo} authData auth entry
229 * @param {string} credential to check
230 * @returns {Promise<boolean>} is valid
232 static _isValidPlainIdentifier(authData
, credential
) {
233 return authData
.credential
.substring('$plain$'.length
) === credential
;
239 * @param {AuthInfo} authData auth entry
240 * @param {string} credential to check
241 * @returns {Promise<boolean>} is valid
243 async
_isValidPAMIdentifier(authData
, credential
) {
244 const _scope
= _fileScope('_isValidPAMIdentifier');
245 const { identifier
} = authData
;
247 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
251 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
254 this.logger
.debug(_scope
, 'failed', { error: e
});
255 if (!(e
instanceof this.authn
.pam
.PamError
)) {
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
272 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
273 const _scope
= _fileScope('isValidIdentifierCredential');
274 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
278 if (typeof credential
=== 'undefined') {
282 await
this.db
.context(async (dbCtx
) => {
283 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
285 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
289 isValid
= await
this._validateAuthDataCredential(authData
, credential
);
292 ctx
.authenticationId
= identifier
;
293 if (authData
.otpKey
) {
294 ctx
.otpKey
= authData
.otpKey
;
296 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
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
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
316 checkOTP(state
, otp
) {
317 const totp
= new this.TOTP({
318 keyEncoding: 'base32',
319 ...this.options
?.authenticator
?.otpOptions
,
322 const isValid
= totp
.validate(otp
);
324 return Enum
.OTPResult
.Valid
;
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
;
331 return Enum
.OTPResult
.InvalidHardFail
;
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
341 async
updateOTPKey(dbCtx
, identifier
, otpKey
) {
342 const _scope
= _fileScope('updateOTPKey');
344 await
this.db
.authenticationUpdateOTPKey(dbCtx
, identifier
, otpKey
);
345 this.logger
.info(_scope
, 'otp key updated');
347 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
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
358 async
isValidBasic(authValue
, ctx
) {
359 const _scope
= _fileScope('isValidBasic');
360 this.logger
.debug(_scope
, 'called', { ctx
});
362 const credentials
= Buffer
.from(authValue
, 'base64').toString('utf-8');
363 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
365 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
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
375 async
isValidAuthorization(authorizationHeader
, ctx
) {
376 const _scope
= _fileScope('isValidAuthorization');
377 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
379 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
380 // eslint-disable-next-line sonarjs/no-small-switch
381 switch (authMethod
.toLowerCase()) {
383 return this.isValidBasic(authString
, ctx
);
387 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
394 * Send a response requesting basic auth.
395 * @param {http.ServerResponse} res response
398 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
399 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
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
412 async
isValidCookieAuth(ctx
) {
413 const _scope
= _fileScope('isValidCookieAuth');
414 this.logger
.debug(_scope
, 'called', { ctx
});
416 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
422 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
423 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
425 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
426 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.has('indieAuth');
427 const isValid
= hasIdentifier
|| hasProfile
;
429 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
434 this.logger
.debug(_scope
, 'could not unpack cookie', { error: e
, ctx
});
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
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
});
461 if (this.secureAuthOnly
462 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
463 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
465 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
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
], {
478 maxAge: this.cookieLifespan
,
480 path: `${this.proxyPrefix}/`,
481 secure: this.secureAuthOnly
,
487 // Clear any existing invalid session
488 common
.addCookie(res
, Enum
.SessionCookie
, '""', {
492 path: `${this.proxyPrefix}/`,
493 secure: this.secureAuthOnly
,
496 res
.statusCode
= 302;
497 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
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
513 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
514 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
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
526 async
sessionRequired(req
, res
, ctx
, loginPath
) {
527 return this.sessionCheck(req
, res
, ctx
, loginPath
);
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
538 async
sessionOptionalLocal(req
, res
, ctx
) {
539 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
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
550 async
sessionOptional(req
, res
, ctx
) {
551 return this.sessionCheck(req
, res
, ctx
, undefined, false);
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
566 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
567 const _scope
= _fileScope('apiRequiredLocal');
568 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
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
});
578 } else if (sessionAlsoValid
579 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
580 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
584 this.logger
.error(_scope
, 'failed', { error: e
});
588 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
589 this.requestBasic(res
);
595 module
.exports
= Authenticator
;