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.
21 * @typedef {Object} AuthInfo
22 * @property {String} identifier
23 * @property {String} credential
24 * @property {String=} otpKey
27 * @callback DBContextExec
28 * @param {Object} dbCtx
29 * @returns {Promise<any>}
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>} authenticationInsertIdentifier
37 * @property {(dbCtx: any, identifier: String, otpKey: String) => Promise<void>} authenticationUpdateOTPKey
38 * @property {(dbCtx: any, identifier: String, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential
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
57 constructor(logger
, db
, options
) {
60 this.options
= options
;
61 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
62 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
?? true;
63 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
65 // First construct map of all available code-supported auth mechanisms.
71 this.authn
.argon2
= require('argon2');
74 this.authn
.pam
= require('node-linux-pam');
75 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
?? ['root'];
78 // Track which authn methods we can change credentials et cetera.
79 const authnUpdatable
= ['plain', 'argon2'];
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
;
92 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array
.from(this.authnEnabled
), preferred: this.authnPreferred
});
94 if (this.authnEnabled
.size
=== 0) {
95 throw new Error('no authentication mechanisms available');
98 this.mysteryBox
= new MysteryBox(options
);
99 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
101 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
103 this.TOTP
= TOTP
; // For ease of test stubbing.
108 * Populate the authentication database with a new identifier, the
109 * secured credential, and optionally an OTP key.
111 * @param {String} identifier
112 * @param {String} credential plaintext
113 * @param {String=} otpKey
114 * @returns {Promise<void>}
116 async
createIdentifier(dbCtx
, identifier
, credential
, otpKey
= null) {
117 const _scope
= _fileScope('createIdentifier');
119 const secureCredential
= await
this._secureCredential(credential
);
120 await
this.db
.authenticationInsertIdentifier(dbCtx
, identifier
, secureCredential
, otpKey
);
122 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
129 * Update the authentication database with a new secured credential
130 * for an indentifier.
132 * @param {*} identifier
133 * @param {*} credential plaintext
134 * @returns {Promise<void>}
136 async
updateCredential(dbCtx
, identifier
, credential
) {
137 const _scope
= _fileScope('updateCredential');
139 const secureCredential
= await
this._secureCredential(credential
);
140 await
this.db
.authenticationUpdateCredential(dbCtx
, identifier
, secureCredential
);
141 this.logger
.info(_scope
, 'success', { identifier
});
143 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
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>}
155 async
_secureCredential(credential
, authn
= this.authnPreferred
) {
156 const _scope
= _fileScope('_secureCredential');
160 return '$plain$' + credential
;
163 return await
this.authn
.argon2
.hash(credential
, { type: this.authn
.argon2
.argon2id
});
166 throw new RangeError('Unknown authn');
169 this.logger
.error(_scope
, 'failed', { error: e
, authn
});
176 * Checks a supplied credential against supplied data.
177 * @param {AuthInfo} authData from database
178 * @param {String} credential plaintext
179 * @returns {Promise<Boolean>}
181 async
_validateAuthDataCredential(authData
, credential
) {
182 const _scope
= _fileScope('_validateAuthDataCredential');
184 if (!authData
?.credential
) {
185 this.logger
.debug(_scope
, 'failed, no authInfo');
188 if (authData
.credential
.startsWith('$argon2')
189 && this.authnEnabled
.has('argon2')) {
190 return await
this._isValidArgon2Identifier(authData
, credential
);
192 if (authData
.credential
.startsWith('$PAM$')
193 && this.authnEnabled
.has('pam')) {
194 return await
this._isValidPAMIdentifier(authData
, credential
);
196 if (authData
.credential
.startsWith('$plain$')
197 && this.authnEnabled
.has('plain')) {
198 return this.constructor._isValidPlainIdentifier(authData
, credential
);
200 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { authData
});
207 * @param {AuthInfo} authData
208 * @param {String} credential
209 * @returns {Promise<Boolean>}
211 async
_isValidArgon2Identifier(authData
, credential
) {
212 return await
this.authn
.argon2
.verify(authData
.credential
, credential
);
218 * @param {AuthInfo} authData
219 * @param {String} credential
220 * @returns {Promise<Boolean>}
222 static _isValidPlainIdentifier(authData
, credential
) {
223 return authData
.credential
.substring('$plain$'.length
) === credential
;
229 * @param {AuthInfo} authData
230 * @param {String} credential
231 * @returns {Promise<Boolean>}
233 async
_isValidPAMIdentifier(authData
, credential
) {
234 const _scope
= _fileScope('_isValidPAMIdentifier');
235 const { identifier
} = authData
;
237 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
241 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
244 this.logger
.debug(_scope
, 'failed', { error: e
});
245 if (!(e
instanceof this.authn
.pam
.PamError
)) {
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>}
262 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
263 const _scope
= _fileScope('isValidIdentifierCredential');
264 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
268 if (typeof credential
=== 'undefined') {
272 await
this.db
.context(async (dbCtx
) => {
273 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
275 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
279 isValid
= await
this._validateAuthDataCredential(authData
, credential
);
282 ctx
.authenticationId
= identifier
;
283 if (authData
.otpKey
) {
284 ctx
.otpKey
= authData
.otpKey
;
286 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
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
303 checkOTP(state
, otp
) {
304 const totp
= new this.TOTP({
305 keyEncoding: 'base32',
306 ...this.options
?.authenticator
?.otpOptions
,
309 const isValid
= totp
.validate(otp
);
311 return Enum
.OTPResult
.Valid
;
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
;
318 return Enum
.OTPResult
.InvalidHardFail
;
323 * Check for valid Basic auth, updates ctx with identifier if valid.
324 * @param {String} credentials
325 * @param {Object} ctx
326 * @returns {Promise<Boolean>}
328 async
isValidBasic(credentials
, ctx
) {
329 const _scope
= _fileScope('isValidBasic');
330 this.logger
.debug(_scope
, 'called', { ctx
});
332 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
334 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
339 * Determine which Authorization header is available, and if it is valid.
340 * @param {String} authorizationHeader
341 * @param {Object} ctx
342 * @returns {Promise<Boolean>}
344 async
isValidAuthorization(authorizationHeader
, ctx
) {
345 const _scope
= _fileScope('isValidAuthorization');
346 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
348 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
349 // eslint-disable-next-line sonarjs/no-small-switch
350 switch (authMethod
.toLowerCase()) {
352 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
353 return this.isValidBasic(credentials
, ctx
);
357 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
364 * Send a response requesting basic auth.
365 * @param {http.ServerResponse} res
368 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
369 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
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>}
382 async
isValidCookieAuth(ctx
) {
383 const _scope
= _fileScope('isValidCookieAuth');
384 this.logger
.debug(_scope
, 'called', { ctx
});
386 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
392 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
393 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
395 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
396 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.has('indieAuth');
397 const isValid
= hasIdentifier
|| hasProfile
;
399 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
404 this.logger
.debug(_scope
, 'could not unpack cookie', { error: e
, ctx
});
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>}
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
});
431 if (this.secureAuthOnly
432 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
433 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
435 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
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
], {
448 maxAge: this.cookieLifespan
,
450 path: `${this.proxyPrefix}/`,
451 secure: this.secureAuthOnly
,
457 // Clear any existing invalid session
458 common
.addCookie(res
, Enum
.SessionCookie
, '""', {
462 path: `${this.proxyPrefix}/`,
463 secure: this.secureAuthOnly
,
466 res
.statusCode
= 302;
467 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
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>}
483 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
484 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
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>}
496 async
sessionRequired(req
, res
, ctx
, loginPath
) {
497 return this.sessionCheck(req
, res
, ctx
, loginPath
);
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>}
509 async
sessionOptionalLocal(req
, res
, ctx
) {
510 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
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>}
522 async
sessionOptional(req
, res
, ctx
) {
523 return this.sessionCheck(req
, res
, ctx
, undefined, false);
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}
538 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
539 const _scope
= _fileScope('apiRequiredLocal');
540 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
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
});
549 } else if (sessionAlsoValid
550 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
551 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
555 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
556 this.requestBasic(res
);
562 module
.exports
= Authenticator
;