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