3 const common
= require('./common');
4 const Enum
= require('./enum');
5 const Errors
= require('./errors');
6 const { MysteryBox
} = require('@squeep/mystery-box');
7 const { name: packageName
} = require('../package');
9 const _fileScope
= common
.fileScope(__filename
);
13 * @param {Console} logger
15 * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
16 * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
17 * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
18 * @param {Object} options
19 * @param {Object} options.authenticator
20 * @param {Boolean} options.authenticator.secureAuthOnly
21 * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
22 * @param {String[]} options.authenticator.authnEnabled
23 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
24 * @param {String[]=} options.authenticator.loginBlurb
25 * @param {String[]=} options.authenticator.indieAuthBlurb
26 * @param {String[]=} options.authenticator.userBlurb
28 constructor(logger
, db
, options
) {
31 this.options
= options
;
32 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
33 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
;
40 this.authn
.argon2
= require('argon2');
43 this.authn
.pam
= require('node-linux-pam');
44 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
;
47 this.authnEnabled
= Object
.keys(this.authn
).filter((auth
) => options
.authenticator
.authnEnabled
.includes(auth
));
48 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled
});
50 if (this.authnEnabled
.length
=== 0) {
51 throw new Error('no authentication mechanisms available');
54 this.mysteryBox
= new MysteryBox(logger
, options
);
56 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
61 * Check local auth entries.
62 * Sets ctx.authenticatedId if valid.
63 * @param {String} identifier
64 * @param {String} credential
67 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
68 const _scope
= _fileScope('isValidIdentifierCredential');
69 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
73 if (typeof credential
=== 'undefined') {
77 await
this.db
.context(async (dbCtx
) => {
78 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
80 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
82 if (authData
.credential
.startsWith('$argon2')
83 && this.authnEnabled
.includes('argon2')) {
84 isValid
= await
this.authn
.argon2
.verify(authData
.credential
, credential
);
85 } else if (authData
.credential
.startsWith('$PAM$')
86 && this.authnEnabled
.includes('pam')) {
87 isValid
= this._isValidPAMIdentifier(identifier
, credential
);
89 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { identifier
, ctx
});
93 if (this.authnEnabled
.includes('DEBUG_ANY')) {
98 ctx
.authenticationId
= identifier
;
99 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
109 * @param {String} identifier
110 * @param {String} credential
113 async
_isValidPAMIdentifier(identifier
, credential
) {
114 const _scope
= _fileScope('_isValidPAMIdentifier');
116 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
120 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
123 this.logger
.debug(_scope
, 'failed', { error: e
});
124 if (!(e
instanceof this.authn
.pam
.PamError
)) {
133 * Check for valid Basic auth, updates ctx with identifier if valid.
134 * @param {String} credentials
135 * @param {Object} ctx
138 async
isValidBasic(credentials
, ctx
) {
139 const _scope
= _fileScope('isValidBasic');
140 this.logger
.debug(_scope
, 'called', { ctx
});
142 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
144 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
149 * Determine which Authorization header is available, and if it is valid.
150 * @param {String} authorizationHeader
151 * @param {Object} ctx
153 async
isValidAuthorization(authorizationHeader
, ctx
) {
154 const _scope
= _fileScope('isValidAuthorization');
155 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
157 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
158 // eslint-disable-next-line sonarjs/no-small-switch
159 switch (authMethod
.toLowerCase()) {
161 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
162 return this.isValidBasic(credentials
, ctx
);
166 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
173 * Send a response requesting basic auth.
174 * @param {http.ServerResponse} res
177 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
178 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
184 * @param {String} cookieHeader
186 static _cookieParse(cookieHeader
) {
188 (cookieHeader
|| '').split(/; */
).forEach((field
) => {
189 const [ name
, value
] = common
.splitFirst(field
, '=', null).map((x
) => x
&& decodeURIComponent(x
.trim()));
190 if (name
&& !(name
in cookie
)) {
191 if (value
&& value
.startsWith('"') && value
.endsWith('"')) {
192 cookie
[name
] = value
.slice(1, -1); // eslint-disable-line security/detect-object-injection
194 cookie
[name
] = value
; // eslint-disable-line security/detect-object-injection
203 * Attempt to parse a session cookie, and determine if it contains an
204 * authenticated user.
205 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
206 * @param {Object} ctx
207 * @param {String} cookieHeader
210 async
isValidCookieAuth(ctx
, cookieHeader
) {
211 const _scope
= _fileScope('isValidCookieAuth');
212 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
214 const cookie
= Authenticator
._cookieParse(cookieHeader
);
215 const cookieValue
= cookie
[Enum
.SessionCookie
];
221 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
222 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
224 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
225 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.includes('indieAuth');
226 const isValid
= hasIdentifier
|| hasProfile
;
228 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
233 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
240 * Check for a valid session.
241 * @param {http.ClientRequest} req
242 * @param {http.ServerResponse} res
243 * @param {Object} ctx
244 * @param {String} loginPath
245 * @param {Boolean} required
246 * @param {Boolean} profilesAllowed
249 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
250 const _scope
= _fileScope('check');
251 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
253 if (this.secureAuthOnly
254 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
255 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
257 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
263 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
265 && await
this.isValidCookieAuth(ctx
, sessionCookie
)
266 && (ctx
.session
.authenticatedIdentifier
267 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
268 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
269 // Refresh timeout on valid session.
270 const cookieParts
= [
273 `Max-Age=${this.cookieLifespan}`,
275 `Path=${this.options.dingus.proxyPrefix}/`,
277 if (this.secureAuthOnly
) {
278 cookieParts
.push('Secure');
280 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
285 // Clear any existing invalid session
286 const cookieParts
= [
287 `${Enum.SessionCookie}=""`,
291 `Path=${this.options.dingus.proxyPrefix}/`,
293 if (this.options
.authenticator
.secureAuthOnly
) {
294 cookieParts
.push('Secure');
296 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
298 res
.statusCode
= 302;
299 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
308 * Requires a valid session with a local identifier. Redirects to loginPath if not.
309 * @param {http.ClientRequest} req
310 * @param {http.ServerResponse} res
311 * @param {Object} ctx
312 * @param {String} loginPath
315 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
316 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
321 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
322 * @param {http.ClientRequest} req
323 * @param {http.ServerResponse} res
324 * @param {Object} ctx
325 * @param {String} loginPath
328 async
sessionRequired(req
, res
, ctx
, loginPath
) {
329 return this.sessionCheck(req
, res
, ctx
, loginPath
);
334 * Check for a valid session with a local identifier, but do nothing if not.
335 * @param {http.ClientRequest} req
336 * @param {http.ServerResponse} res
337 * @param {Object} ctx
338 * @param {String} loginPath
341 async
sessionOptionalLocal(req
, res
, ctx
) {
342 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
347 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
348 * @param {http.ClientRequest} req
349 * @param {http.ServerResponse} res
350 * @param {Object} ctx
351 * @param {String} loginPath
354 async
sessionOptional(req
, res
, ctx
) {
355 return this.sessionCheck(req
, res
, ctx
, undefined, false);
360 * Require auth for an API endpoint.
361 * Check for valid local identifier in Authorization header; optionally
362 * fall back to session cookie if no header provided.
363 * Prompts for Basic auth if not valid.
364 * @param {http.ClientRequest} req
365 * @param {http.ServerResponse} res
366 * @param {Object} ctx
367 * @param {Boolean} sessionAlsoValid
369 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
370 const _scope
= _fileScope('apiRequiredLocal');
371 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
373 // If a Authorization header was provided, never consider session as a fallback.
374 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
375 if (authorizationHeader
) {
376 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
377 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
382 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
383 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
388 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
389 this.requestBasic(res
);
394 module
.exports
= Authenticator
;