default indieauth profile entry to https if scheme not specified
[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 if (typeof credential === 'undefined') {
74 return isValid;
75 }
76
77 await this.db.context(async (dbCtx) => {
78 const authData = await this.db.authenticationGet(dbCtx, identifier);
79 if (!authData) {
80 this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
81 } else {
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);
88 } else {
89 this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
90 }
91 }
92
93 if (this.authnEnabled.includes('DEBUG_ANY')) {
94 isValid = true;
95 }
96
97 if (isValid) {
98 ctx.authenticationId = identifier;
99 await this.db.authenticationSuccess(dbCtx, identifier);
100 }
101 }); // dbCtx
102
103 return isValid;
104 }
105
106
107 /**
108 * Check system PAM.
109 * @param {String} identifier
110 * @param {String} credential
111 * @returns {Boolean}
112 */
113 async _isValidPAMIdentifier(identifier, credential) {
114 const _scope = _fileScope('_isValidPAMIdentifier');
115 let isValid = false;
116 if (this.forbiddenPAMIdentifiers.includes(identifier)) {
117 return false;
118 }
119 try {
120 await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
121 isValid = true;
122 } catch (e) {
123 this.logger.debug(_scope, 'failed', { error: e });
124 if (!(e instanceof this.authn.pam.PamError)) {
125 throw e;
126 }
127 }
128 return isValid;
129 }
130
131
132 /**
133 * Check for valid Basic auth, updates ctx with identifier if valid.
134 * @param {String} credentials
135 * @param {Object} ctx
136 * @returns {Boolean}
137 */
138 async isValidBasic(credentials, ctx) {
139 const _scope = _fileScope('isValidBasic');
140 this.logger.debug(_scope, 'called', { ctx });
141
142 const [identifier, credential] = common.splitFirst(credentials, ':', '');
143
144 return this.isValidIdentifierCredential(identifier, credential, ctx);
145 }
146
147
148 /**
149 * Determine which Authorization header is available, and if it is valid.
150 * @param {String} authorizationHeader
151 * @param {Object} ctx
152 */
153 async isValidAuthorization(authorizationHeader, ctx) {
154 const _scope = _fileScope('isValidAuthorization');
155 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
156
157 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
158 // eslint-disable-next-line sonarjs/no-small-switch
159 switch (authMethod.toLowerCase()) {
160 case 'basic': {
161 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
162 return this.isValidBasic(credentials, ctx);
163 }
164
165 default:
166 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
167 return false;
168 }
169 }
170
171
172 /**
173 * Send a response requesting basic auth.
174 * @param {http.ServerResponse} res
175 */
176 requestBasic(res) {
177 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
178 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
179 }
180
181
182 /**
183 *
184 * @param {String} cookieHeader
185 */
186 static _cookieParse(cookieHeader) {
187 const cookie = {};
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
193 } else {
194 cookie[name] = value; // eslint-disable-line security/detect-object-injection
195 }
196 }
197 });
198 return cookie;
199 }
200
201
202 /**
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
208 * @returns {Boolean}
209 */
210 async isValidCookieAuth(ctx, cookieHeader) {
211 const _scope = _fileScope('isValidCookieAuth');
212 this.logger.debug(_scope, 'called', { ctx, cookieHeader });
213
214 const cookie = Authenticator._cookieParse(cookieHeader);
215 const cookieValue = cookie[Enum.SessionCookie];
216
217 if (!cookieValue) {
218 return false;
219 }
220 try {
221 ctx.session = await this.mysteryBox.unpack(cookieValue);
222 this.logger.debug(_scope, 'unpacked cookie', { ctx });
223
224 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
225 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
226 const isValid = hasIdentifier || hasProfile;
227 if (isValid) {
228 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
229 }
230
231 return isValid;
232 } catch (e) {
233 this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
234 return false;
235 }
236 }
237
238
239 /**
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
247 * @returns {Boolean}
248 */
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 });
252
253 if (this.secureAuthOnly
254 && ctx.clientProtocol.toLowerCase() !== 'https') {
255 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
256 if (required) {
257 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
258 } else {
259 return false;
260 }
261 }
262
263 const sessionCookie = req.getHeader(Enum.Header.Cookie);
264 if (sessionCookie
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 = [
271 sessionCookie,
272 'HttpOnly',
273 `Max-Age=${this.cookieLifespan}`,
274 'SameSite=Lax',
275 `Path=${this.options.dingus.proxyPrefix}/`,
276 ];
277 if (this.secureAuthOnly) {
278 cookieParts.push('Secure');
279 }
280 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
281 return true;
282 }
283
284 if (required) {
285 // Clear any existing invalid session
286 const cookieParts = [
287 `${Enum.SessionCookie}=""`,
288 'HttpOnly',
289 'Max-Age=0',
290 'SameSite=Lax',
291 `Path=${this.options.dingus.proxyPrefix}/`,
292 ];
293 if (this.options.authenticator.secureAuthOnly) {
294 cookieParts.push('Secure');
295 }
296 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
297
298 res.statusCode = 302;
299 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
300 res.end();
301 }
302
303 return false;
304 }
305
306
307 /**
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
313 * @returns {Boolean}
314 */
315 async sessionRequiredLocal(req, res, ctx, loginPath) {
316 return this.sessionCheck(req, res, ctx, loginPath, true, false);
317 }
318
319
320 /**
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
326 * @returns {Boolean}
327 */
328 async sessionRequired(req, res, ctx, loginPath) {
329 return this.sessionCheck(req, res, ctx, loginPath);
330 }
331
332
333 /**
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
339 * @returns {Boolean}
340 */
341 async sessionOptionalLocal(req, res, ctx) {
342 return this.sessionCheck(req, res, ctx, undefined, false, false);
343 }
344
345
346 /**
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
352 * @returns {Boolean}
353 */
354 async sessionOptional(req, res, ctx) {
355 return this.sessionCheck(req, res, ctx, undefined, false);
356 }
357
358
359 /**
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
368 */
369 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
370 const _scope = _fileScope('apiRequiredLocal');
371 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
372
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 });
378 return true;
379 }
380 } else {
381 if (sessionAlsoValid
382 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
383 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
384 return true;
385 }
386 }
387
388 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
389 this.requestBasic(res);
390 }
391
392 }
393
394 module.exports = Authenticator;