update dependencies and devDependencies, fix breaking changes from mystery-box update
[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 { name: packageName } = require('../package');
8
9 const _fileScope = common.fileScope(__filename);
10
11 class Authenticator {
12 /**
13 * @param {Console} logger
14 * @param {*} db
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
27 */
28 constructor(logger, db, options) {
29 this.logger = logger;
30 this.db = db;
31 this.options = options;
32 this.basicRealm = options.authenticator.basicRealm || packageName;
33 this.secureAuthOnly = options.authenticator.secureAuthOnly;
34
35 this.authn = {
36 DEBUG_ANY: {},
37 indieAuth: {},
38 };
39 try {
40 this.authn.argon2 = require('argon2');
41 } catch (e) { /**/ }
42 try {
43 this.authn.pam = require('node-linux-pam');
44 this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
45 } catch (e) { /**/ }
46
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 });
49
50 if (this.authnEnabled.length === 0) {
51 throw new Error('no authentication mechanisms available');
52 }
53
54 this.mysteryBox = new MysteryBox(options);
55 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
56
57 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
58 }
59
60
61 /**
62 * Check local auth entries.
63 * Sets ctx.authenticatedId if valid.
64 * @param {String} identifier
65 * @param {String} credential
66 * @param {Object} ctx
67 */
68 async isValidIdentifierCredential(identifier, credential, ctx) {
69 const _scope = _fileScope('isValidIdentifierCredential');
70 this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx });
71
72 let isValid = false;
73
74 if (typeof credential === 'undefined') {
75 return isValid;
76 }
77
78 await this.db.context(async (dbCtx) => {
79 const authData = await this.db.authenticationGet(dbCtx, identifier);
80 if (!authData) {
81 this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
82 } else {
83 if (authData.credential.startsWith('$argon2')
84 && this.authnEnabled.includes('argon2')) {
85 isValid = await this.authn.argon2.verify(authData.credential, credential);
86 } else if (authData.credential.startsWith('$PAM$')
87 && this.authnEnabled.includes('pam')) {
88 isValid = this._isValidPAMIdentifier(identifier, credential);
89 } else {
90 this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
91 }
92 }
93
94 if (this.authnEnabled.includes('DEBUG_ANY')) {
95 isValid = true;
96 }
97
98 if (isValid) {
99 ctx.authenticationId = identifier;
100 await this.db.authenticationSuccess(dbCtx, identifier);
101 }
102 }); // dbCtx
103
104 return isValid;
105 }
106
107
108 /**
109 * Check system PAM.
110 * @param {String} identifier
111 * @param {String} credential
112 * @returns {Boolean}
113 */
114 async _isValidPAMIdentifier(identifier, credential) {
115 const _scope = _fileScope('_isValidPAMIdentifier');
116 let isValid = false;
117 if (this.forbiddenPAMIdentifiers.includes(identifier)) {
118 return false;
119 }
120 try {
121 await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
122 isValid = true;
123 } catch (e) {
124 this.logger.debug(_scope, 'failed', { error: e });
125 if (!(e instanceof this.authn.pam.PamError)) {
126 throw e;
127 }
128 }
129 return isValid;
130 }
131
132
133 /**
134 * Check for valid Basic auth, updates ctx with identifier if valid.
135 * @param {String} credentials
136 * @param {Object} ctx
137 * @returns {Boolean}
138 */
139 async isValidBasic(credentials, ctx) {
140 const _scope = _fileScope('isValidBasic');
141 this.logger.debug(_scope, 'called', { ctx });
142
143 const [identifier, credential] = common.splitFirst(credentials, ':', '');
144
145 return this.isValidIdentifierCredential(identifier, credential, ctx);
146 }
147
148
149 /**
150 * Determine which Authorization header is available, and if it is valid.
151 * @param {String} authorizationHeader
152 * @param {Object} ctx
153 */
154 async isValidAuthorization(authorizationHeader, ctx) {
155 const _scope = _fileScope('isValidAuthorization');
156 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
157
158 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
159 // eslint-disable-next-line sonarjs/no-small-switch
160 switch (authMethod.toLowerCase()) {
161 case 'basic': {
162 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
163 return this.isValidBasic(credentials, ctx);
164 }
165
166 default:
167 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
168 return false;
169 }
170 }
171
172
173 /**
174 * Send a response requesting basic auth.
175 * @param {http.ServerResponse} res
176 */
177 requestBasic(res) {
178 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
179 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
180 }
181
182
183 /**
184 *
185 * @param {String} cookieHeader
186 */
187 static _cookieParse(cookieHeader) {
188 const cookie = {};
189 (cookieHeader || '').split(/; */).forEach((field) => {
190 const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim()));
191 if (name && !(name in cookie)) {
192 if (value && value.startsWith('"') && value.endsWith('"')) {
193 cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
194 } else {
195 cookie[name] = value; // eslint-disable-line security/detect-object-injection
196 }
197 }
198 });
199 return cookie;
200 }
201
202
203 /**
204 * Attempt to parse a session cookie, and determine if it contains an
205 * authenticated user.
206 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
207 * @param {Object} ctx
208 * @param {String} cookieHeader
209 * @returns {Boolean}
210 */
211 async isValidCookieAuth(ctx, cookieHeader) {
212 const _scope = _fileScope('isValidCookieAuth');
213 this.logger.debug(_scope, 'called', { ctx, cookieHeader });
214
215 const cookie = Authenticator._cookieParse(cookieHeader);
216 const cookieValue = cookie[Enum.SessionCookie];
217
218 if (!cookieValue) {
219 return false;
220 }
221 try {
222 ctx.session = await this.mysteryBox.unpack(cookieValue);
223 this.logger.debug(_scope, 'unpacked cookie', { ctx });
224
225 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
226 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
227 const isValid = hasIdentifier || hasProfile;
228 if (isValid) {
229 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
230 }
231
232 return isValid;
233 } catch (e) {
234 this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
235 return false;
236 }
237 }
238
239
240 /**
241 * Check for a valid session.
242 * @param {http.ClientRequest} req
243 * @param {http.ServerResponse} res
244 * @param {Object} ctx
245 * @param {String} loginPath
246 * @param {Boolean} required
247 * @param {Boolean} profilesAllowed
248 * @returns {Boolean}
249 */
250 async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
251 const _scope = _fileScope('check');
252 this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
253
254 if (this.secureAuthOnly
255 && ctx.clientProtocol.toLowerCase() !== 'https') {
256 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
257 if (required) {
258 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
259 } else {
260 return false;
261 }
262 }
263
264 const sessionCookie = req.getHeader(Enum.Header.Cookie);
265 if (sessionCookie
266 && await this.isValidCookieAuth(ctx, sessionCookie)
267 && (ctx.session.authenticatedIdentifier
268 || (profilesAllowed && ctx.session.authenticatedProfile))) {
269 this.logger.debug(_scope, 'valid session cookie', { ctx });
270 // Refresh timeout on valid session.
271 const cookieParts = [
272 sessionCookie,
273 'HttpOnly',
274 `Max-Age=${this.cookieLifespan}`,
275 'SameSite=Lax',
276 `Path=${this.options.dingus.proxyPrefix}/`,
277 ];
278 if (this.secureAuthOnly) {
279 cookieParts.push('Secure');
280 }
281 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
282 return true;
283 }
284
285 if (required) {
286 // Clear any existing invalid session
287 const cookieParts = [
288 `${Enum.SessionCookie}=""`,
289 'HttpOnly',
290 'Max-Age=0',
291 'SameSite=Lax',
292 `Path=${this.options.dingus.proxyPrefix}/`,
293 ];
294 if (this.options.authenticator.secureAuthOnly) {
295 cookieParts.push('Secure');
296 }
297 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
298
299 res.statusCode = 302;
300 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
301 res.end();
302 }
303
304 return false;
305 }
306
307
308 /**
309 * Requires a valid session with a local identifier. Redirects to loginPath if not.
310 * @param {http.ClientRequest} req
311 * @param {http.ServerResponse} res
312 * @param {Object} ctx
313 * @param {String} loginPath
314 * @returns {Boolean}
315 */
316 async sessionRequiredLocal(req, res, ctx, loginPath) {
317 return this.sessionCheck(req, res, ctx, loginPath, true, false);
318 }
319
320
321 /**
322 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
323 * @param {http.ClientRequest} req
324 * @param {http.ServerResponse} res
325 * @param {Object} ctx
326 * @param {String} loginPath
327 * @returns {Boolean}
328 */
329 async sessionRequired(req, res, ctx, loginPath) {
330 return this.sessionCheck(req, res, ctx, loginPath);
331 }
332
333
334 /**
335 * Check for a valid session with a local identifier, but do nothing if not.
336 * @param {http.ClientRequest} req
337 * @param {http.ServerResponse} res
338 * @param {Object} ctx
339 * @param {String} loginPath
340 * @returns {Boolean}
341 */
342 async sessionOptionalLocal(req, res, ctx) {
343 return this.sessionCheck(req, res, ctx, undefined, false, false);
344 }
345
346
347 /**
348 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
349 * @param {http.ClientRequest} req
350 * @param {http.ServerResponse} res
351 * @param {Object} ctx
352 * @param {String} loginPath
353 * @returns {Boolean}
354 */
355 async sessionOptional(req, res, ctx) {
356 return this.sessionCheck(req, res, ctx, undefined, false);
357 }
358
359
360 /**
361 * Require auth for an API endpoint.
362 * Check for valid local identifier in Authorization header; optionally
363 * fall back to session cookie if no header provided.
364 * Prompts for Basic auth if not valid.
365 * @param {http.ClientRequest} req
366 * @param {http.ServerResponse} res
367 * @param {Object} ctx
368 * @param {Boolean} sessionAlsoValid
369 */
370 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
371 const _scope = _fileScope('apiRequiredLocal');
372 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
373
374 // If a Authorization header was provided, never consider session as a fallback.
375 const authorizationHeader = req.getHeader(Enum.Header.Authorization);
376 if (authorizationHeader) {
377 if (await this.isValidAuthorization(authorizationHeader, ctx)) {
378 this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
379 return true;
380 }
381 } else {
382 if (sessionAlsoValid
383 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
384 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
385 return true;
386 }
387 }
388
389 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
390 this.requestBasic(res);
391 }
392
393 }
394
395 module.exports = Authenticator;