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