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
);
14 * @typedef {Object} AuthInfo
15 * @property {String} identifier
16 * @property {String} credentials
17 * @property {String=} otpKey
20 * @param {Console} logger
22 * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
23 * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
24 * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
25 * @param {Object} options
26 * @param {Object} options.authenticator
27 * @param {Boolean} options.authenticator.secureAuthOnly
28 * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
29 * @param {String[]} options.authenticator.authnEnabled
30 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
31 * @param {String[]=} options.authenticator.loginBlurb
32 * @param {String[]=} options.authenticator.indieAuthBlurb
33 * @param {String[]=} options.authenticator.userBlurb
35 constructor(logger
, db
, options
) {
38 this.options
= options
;
39 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
40 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
;
47 this.authn
.argon2
= require('argon2');
50 this.authn
.pam
= require('node-linux-pam');
51 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
;
54 this.authnEnabled
= Object
.keys(this.authn
).filter((auth
) => options
.authenticator
.authnEnabled
.includes(auth
));
55 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled
});
57 if (this.authnEnabled
.length
=== 0) {
58 throw new Error('no authentication mechanisms available');
61 this.mysteryBox
= new MysteryBox(options
);
62 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
64 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
66 this.TOTP
= TOTP
; // For ease of test stubbing.
71 * Check local auth entries.
72 * Sets ctx.authenticatedId if valid.
73 * Sets ctx.otpNeeded if account has otpKey.
74 * @param {String} identifier
75 * @param {String} credential
77 * @returns {Promise<Boolean>}
79 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
80 const _scope
= _fileScope('isValidIdentifierCredential');
81 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
85 if (typeof credential
=== 'undefined') {
89 await
this.db
.context(async (dbCtx
) => {
90 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
92 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
93 } else if (authData
.credential
.startsWith('$argon2')
94 && this.authnEnabled
.includes('argon2')) {
95 isValid
= await
this.authn
.argon2
.verify(authData
.credential
, credential
);
96 } else if (authData
.credential
.startsWith('$PAM$')
97 && this.authnEnabled
.includes('pam')) {
98 isValid
= this._isValidPAMIdentifier(identifier
, credential
);
100 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { identifier
, ctx
});
103 if (this.authnEnabled
.includes('DEBUG_ANY')) {
108 ctx
.authenticationId
= identifier
;
109 ctx
.otpNeeded
= !!authData
?.otpKey
;
110 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
120 * @param {String} identifier
121 * @param {String} credential
122 * @returns {Promise<Boolean>}
124 async
_isValidPAMIdentifier(identifier
, credential
) {
125 const _scope
= _fileScope('_isValidPAMIdentifier');
127 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
131 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
134 this.logger
.debug(_scope
, 'failed', { error: e
});
135 if (!(e
instanceof this.authn
.pam
.PamError
)) {
145 * @param {Object} state
146 * @param {String} state.key
147 * @param {Number} state.attempt
148 * @param {Number} state.epochMs
149 * @param {String} otp
150 * @returns {String} Enum.OTPResult
152 checkOTP(state
, otp
) {
153 const totp
= new this.TOTP({
154 ...this.options
?.authenticator
?.otpOptions
,
157 const isValid
= totp
.validate(otp
);
159 return Enum
.OTPResult
.Valid
;
161 const otpAttemptsRemain
= state
.attempt
<= (this.options
?.authenticator
?.otpAttemptsAllowed
?? 3);
162 const otpWithinWindow
= state
.epochMs
+ (this.options
?.authenticator
?.otpWindowMs
?? 600000) > Date
.now();
163 if (otpAttemptsRemain
&& otpWithinWindow
) {
164 return Enum
.OTPResult
.InvalidSoftFail
;
166 return Enum
.OTPResult
.InvalidHardFail
;
171 * Check for valid Basic auth, updates ctx with identifier if valid.
172 * @param {String} credentials
173 * @param {Object} ctx
174 * @returns {Promise<Boolean>}
176 async
isValidBasic(credentials
, ctx
) {
177 const _scope
= _fileScope('isValidBasic');
178 this.logger
.debug(_scope
, 'called', { ctx
});
180 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
182 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
187 * Determine which Authorization header is available, and if it is valid.
188 * @param {String} authorizationHeader
189 * @param {Object} ctx
190 * @returns {Promise<Boolean>}
192 async
isValidAuthorization(authorizationHeader
, ctx
) {
193 const _scope
= _fileScope('isValidAuthorization');
194 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
196 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
197 // eslint-disable-next-line sonarjs/no-small-switch
198 switch (authMethod
.toLowerCase()) {
200 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
201 return this.isValidBasic(credentials
, ctx
);
205 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
212 * Send a response requesting basic auth.
213 * @param {http.ServerResponse} res
216 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
217 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
223 * @param {String} cookieHeader
225 static _cookieParse(cookieHeader
) {
227 (cookieHeader
|| '').split(/; */
).forEach((field
) => {
228 const [ name
, value
] = common
.splitFirst(field
, '=', null).map((x
) => x
&& decodeURIComponent(x
.trim()));
229 if (name
&& !(name
in cookie
)) {
230 if (value
?.startsWith('"') && value
.endsWith('"')) {
231 cookie
[name
] = value
.slice(1, -1); // eslint-disable-line security/detect-object-injection
233 cookie
[name
] = value
; // eslint-disable-line security/detect-object-injection
242 * Attempt to parse a session cookie, and determine if it contains an
243 * authenticated user.
244 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
245 * @param {Object} ctx
246 * @param {String} cookieHeader
249 async
isValidCookieAuth(ctx
, cookieHeader
) {
250 const _scope
= _fileScope('isValidCookieAuth');
251 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
253 const cookie
= Authenticator
._cookieParse(cookieHeader
);
254 const cookieValue
= cookie
[Enum
.SessionCookie
];
260 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
261 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
263 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
264 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.includes('indieAuth');
265 const isValid
= hasIdentifier
|| hasProfile
;
267 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
272 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
279 * Check for a valid session.
280 * @param {http.ClientRequest} req
281 * @param {http.ServerResponse} res
282 * @param {Object} ctx
283 * @param {String} loginPath
284 * @param {Boolean} required
285 * @param {Boolean} profilesAllowed
288 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
289 const _scope
= _fileScope('check');
290 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
292 if (this.secureAuthOnly
293 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
294 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
296 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
302 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
304 && await
this.isValidCookieAuth(ctx
, sessionCookie
)
305 && (ctx
.session
.authenticatedIdentifier
306 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
307 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
308 // Refresh timeout on valid session.
309 const cookieParts
= [
312 `Max-Age=${this.cookieLifespan}`,
314 `Path=${this.options.dingus.proxyPrefix}/`,
316 if (this.secureAuthOnly
) {
317 cookieParts
.push('Secure');
319 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
324 // Clear any existing invalid session
325 const cookieParts
= [
326 `${Enum.SessionCookie}=""`,
330 `Path=${this.options.dingus.proxyPrefix}/`,
332 if (this.options
.authenticator
.secureAuthOnly
) {
333 cookieParts
.push('Secure');
335 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
337 res
.statusCode
= 302;
338 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
347 * Requires a valid session with a local identifier. Redirects to loginPath if not.
348 * @param {http.ClientRequest} req
349 * @param {http.ServerResponse} res
350 * @param {Object} ctx
351 * @param {String} loginPath
354 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
355 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
360 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
361 * @param {http.ClientRequest} req
362 * @param {http.ServerResponse} res
363 * @param {Object} ctx
364 * @param {String} loginPath
367 async
sessionRequired(req
, res
, ctx
, loginPath
) {
368 return this.sessionCheck(req
, res
, ctx
, loginPath
);
373 * Check for a valid session with a local identifier, but do nothing if not.
374 * @param {http.ClientRequest} req
375 * @param {http.ServerResponse} res
376 * @param {Object} ctx
377 * @param {String} loginPath
380 async
sessionOptionalLocal(req
, res
, ctx
) {
381 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
386 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
387 * @param {http.ClientRequest} req
388 * @param {http.ServerResponse} res
389 * @param {Object} ctx
390 * @param {String} loginPath
393 async
sessionOptional(req
, res
, ctx
) {
394 return this.sessionCheck(req
, res
, ctx
, undefined, false);
399 * Require auth for an API endpoint.
400 * Check for valid local identifier in Authorization header; optionally
401 * fall back to session cookie if no header provided.
402 * Prompts for Basic auth if not valid.
403 * @param {http.ClientRequest} req
404 * @param {http.ServerResponse} res
405 * @param {Object} ctx
406 * @param {Boolean} sessionAlsoValid
408 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
409 const _scope
= _fileScope('apiRequiredLocal');
410 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
412 // If a Authorization header was provided, never consider session as a fallback.
413 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
414 if (authorizationHeader
) {
415 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
416 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
419 } else if (sessionAlsoValid
420 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
421 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
425 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
426 this.requestBasic(res
);
431 module
.exports
= Authenticator
;