da6b7180f4c4e8019d6f2bed82063d4ea057ba71
[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 { TOTP } = require('@squeep/totp');
8 const { name: packageName } = require('../package');
9
10 const _fileScope = common.fileScope(__filename);
11
12 class Authenticator {
13 /**
14 * @typedef {Object} AuthInfo
15 * @property {String} identifier
16 * @property {String} credentials
17 * @property {String=} otpKey
18 */
19 /**
20 * @param {Console} logger
21 * @param {*} db
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
34 */
35 constructor(logger, db, options) {
36 this.logger = logger;
37 this.db = db;
38 this.options = options;
39 this.basicRealm = options.authenticator.basicRealm || packageName;
40 this.secureAuthOnly = options.authenticator.secureAuthOnly;
41
42 this.authn = {
43 DEBUG_ANY: {},
44 indieAuth: {},
45 };
46 try {
47 this.authn.argon2 = require('argon2');
48 } catch (e) { /**/ }
49 try {
50 this.authn.pam = require('node-linux-pam');
51 this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
52 } catch (e) { /**/ }
53
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 });
56
57 if (this.authnEnabled.length === 0) {
58 throw new Error('no authentication mechanisms available');
59 }
60
61 this.mysteryBox = new MysteryBox(options);
62 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
63
64 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
65
66 this.TOTP = TOTP; // For ease of test stubbing.
67 }
68
69
70 /**
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
76 * @param {Object} ctx
77 * @returns {Promise<Boolean>}
78 */
79 async isValidIdentifierCredential(identifier, credential, ctx) {
80 const _scope = _fileScope('isValidIdentifierCredential');
81 this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx });
82
83 let isValid = false;
84
85 if (typeof credential === 'undefined') {
86 return isValid;
87 }
88
89 await this.db.context(async (dbCtx) => {
90 const authData = await this.db.authenticationGet(dbCtx, identifier);
91 if (!authData) {
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);
99 } else {
100 this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
101 }
102
103 if (this.authnEnabled.includes('DEBUG_ANY')) {
104 isValid = true;
105 }
106
107 if (isValid) {
108 ctx.authenticationId = identifier;
109 ctx.otpNeeded = !!authData?.otpKey;
110 await this.db.authenticationSuccess(dbCtx, identifier);
111 }
112 }); // dbCtx
113
114 return isValid;
115 }
116
117
118 /**
119 * Check system PAM.
120 * @param {String} identifier
121 * @param {String} credential
122 * @returns {Promise<Boolean>}
123 */
124 async _isValidPAMIdentifier(identifier, credential) {
125 const _scope = _fileScope('_isValidPAMIdentifier');
126 let isValid = false;
127 if (this.forbiddenPAMIdentifiers.includes(identifier)) {
128 return false;
129 }
130 try {
131 await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
132 isValid = true;
133 } catch (e) {
134 this.logger.debug(_scope, 'failed', { error: e });
135 if (!(e instanceof this.authn.pam.PamError)) {
136 throw e;
137 }
138 }
139 return isValid;
140 }
141
142
143 /**
144 *
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
151 */
152 checkOTP(state, otp) {
153 const totp = new this.TOTP({
154 ...this.options?.authenticator?.otpOptions,
155 ...state,
156 });
157 const isValid = totp.validate(otp);
158 if (isValid) {
159 return Enum.OTPResult.Valid;
160 }
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;
165 }
166 return Enum.OTPResult.InvalidHardFail;
167 }
168
169
170 /**
171 * Check for valid Basic auth, updates ctx with identifier if valid.
172 * @param {String} credentials
173 * @param {Object} ctx
174 * @returns {Promise<Boolean>}
175 */
176 async isValidBasic(credentials, ctx) {
177 const _scope = _fileScope('isValidBasic');
178 this.logger.debug(_scope, 'called', { ctx });
179
180 const [identifier, credential] = common.splitFirst(credentials, ':', '');
181
182 return this.isValidIdentifierCredential(identifier, credential, ctx);
183 }
184
185
186 /**
187 * Determine which Authorization header is available, and if it is valid.
188 * @param {String} authorizationHeader
189 * @param {Object} ctx
190 * @returns {Promise<Boolean>}
191 */
192 async isValidAuthorization(authorizationHeader, ctx) {
193 const _scope = _fileScope('isValidAuthorization');
194 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
195
196 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
197 // eslint-disable-next-line sonarjs/no-small-switch
198 switch (authMethod.toLowerCase()) {
199 case 'basic': {
200 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
201 return this.isValidBasic(credentials, ctx);
202 }
203
204 default:
205 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
206 return false;
207 }
208 }
209
210
211 /**
212 * Send a response requesting basic auth.
213 * @param {http.ServerResponse} res
214 */
215 requestBasic(res) {
216 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
217 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
218 }
219
220
221 /**
222 *
223 * @param {String} cookieHeader
224 */
225 static _cookieParse(cookieHeader) {
226 const cookie = {};
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
232 } else {
233 cookie[name] = value; // eslint-disable-line security/detect-object-injection
234 }
235 }
236 });
237 return cookie;
238 }
239
240
241 /**
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
247 * @returns {Boolean}
248 */
249 async isValidCookieAuth(ctx, cookieHeader) {
250 const _scope = _fileScope('isValidCookieAuth');
251 this.logger.debug(_scope, 'called', { ctx, cookieHeader });
252
253 const cookie = Authenticator._cookieParse(cookieHeader);
254 const cookieValue = cookie[Enum.SessionCookie];
255
256 if (!cookieValue) {
257 return false;
258 }
259 try {
260 ctx.session = await this.mysteryBox.unpack(cookieValue);
261 this.logger.debug(_scope, 'unpacked cookie', { ctx });
262
263 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
264 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
265 const isValid = hasIdentifier || hasProfile;
266 if (isValid) {
267 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
268 }
269
270 return isValid;
271 } catch (e) {
272 this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
273 return false;
274 }
275 }
276
277
278 /**
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
286 * @returns {Boolean}
287 */
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 });
291
292 if (this.secureAuthOnly
293 && ctx.clientProtocol.toLowerCase() !== 'https') {
294 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
295 if (required) {
296 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
297 } else {
298 return false;
299 }
300 }
301
302 const sessionCookie = req.getHeader(Enum.Header.Cookie);
303 if (sessionCookie
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 = [
310 sessionCookie,
311 'HttpOnly',
312 `Max-Age=${this.cookieLifespan}`,
313 'SameSite=Lax',
314 `Path=${this.options.dingus.proxyPrefix}/`,
315 ];
316 if (this.secureAuthOnly) {
317 cookieParts.push('Secure');
318 }
319 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
320 return true;
321 }
322
323 if (required) {
324 // Clear any existing invalid session
325 const cookieParts = [
326 `${Enum.SessionCookie}=""`,
327 'HttpOnly',
328 'Max-Age=0',
329 'SameSite=Lax',
330 `Path=${this.options.dingus.proxyPrefix}/`,
331 ];
332 if (this.options.authenticator.secureAuthOnly) {
333 cookieParts.push('Secure');
334 }
335 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
336
337 res.statusCode = 302;
338 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
339 res.end();
340 }
341
342 return false;
343 }
344
345
346 /**
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
352 * @returns {Boolean}
353 */
354 async sessionRequiredLocal(req, res, ctx, loginPath) {
355 return this.sessionCheck(req, res, ctx, loginPath, true, false);
356 }
357
358
359 /**
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
365 * @returns {Boolean}
366 */
367 async sessionRequired(req, res, ctx, loginPath) {
368 return this.sessionCheck(req, res, ctx, loginPath);
369 }
370
371
372 /**
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
378 * @returns {Boolean}
379 */
380 async sessionOptionalLocal(req, res, ctx) {
381 return this.sessionCheck(req, res, ctx, undefined, false, false);
382 }
383
384
385 /**
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
391 * @returns {Boolean}
392 */
393 async sessionOptional(req, res, ctx) {
394 return this.sessionCheck(req, res, ctx, undefined, false);
395 }
396
397
398 /**
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
407 */
408 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
409 const _scope = _fileScope('apiRequiredLocal');
410 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
411
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 });
417 return true;
418 }
419 } else if (sessionAlsoValid
420 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
421 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
422 return true;
423 }
424
425 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
426 this.requestBasic(res);
427 }
428
429 }
430
431 module.exports = Authenticator;