initial commit
[urlittler] / src / authenticator.js
1 'use strict';
2
3 const argon2 = require('argon2');
4 const crypto = require('crypto');
5 const common = require('./common');
6 const Enum = require('./enum');
7 const { ResponseError } = require('./errors');
8
9 const _fileScope = common.fileScope(__filename);
10
11 /**
12 * Let us roll our own authentication dingus.
13 * For 'admin' level access, we allow this custom header scheme, or optionally Basic if a password is stored.
14 * We shall authenticate an id/secret from a header of this format:
15 * Signature: id:epoch:hmac(id + epoch + body)
16 *
17 * For 'normal' level access, we allow a token passed as a parameter or a Bearer token.
18 */
19
20 const defaultOptions = {
21 basicRealm: 'Authenticate',
22 customAuthName: 'SqueepSignature',
23 timestampGraceMs: 120 * 1000, // In ms
24 };
25
26 class Authenticator {
27 constructor(logger, db, options = {}) {
28 common.setOptions(this, defaultOptions, options);
29 this.logger = logger;
30 this.db = db;
31 }
32
33
34 /**
35 * For Reasons, get rid of iffy b64 characters.
36 * @param {string} s
37 */
38 static b64Armor(s) {
39 return s && s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '.');
40 }
41
42
43 /**
44 * Put them back, if we need to.
45 * @param {string} s
46 */
47 static b64Unarmor(s) {
48 return s && s.replace(/-/g, '+').replace(/_/g, '/').replace(/\./g, '=');
49 }
50
51
52 /**
53 *
54 * @param {string} secret
55 * @param {string} id
56 * @param {string} epoch
57 * @param {string} body
58 */
59 static signature(secret, id, epoch, body) {
60 const hmac = crypto.createHmac('sha384', secret);
61 hmac.update(new Buffer.from(id));
62 hmac.update(new Buffer.from(epoch.toString()));
63 hmac.update(new Buffer.from(body));
64 const digest = hmac.digest('base64');
65 return Authenticator.b64Armor(digest);
66 }
67
68
69 /**
70 *
71 * @param {number} size
72 */
73 static async generateToken(size = 36) {
74 const randomBuffer = await common.randomBytesAsync(size);
75 const token = randomBuffer.toString('base64');
76 return Authenticator.b64Armor(token);
77 }
78
79
80 /**
81 *
82 * @param {http.ServerResponse} res
83 */
84 requestSignature(res) {
85 res.setHeader(Enum.Header.WWWAuthenticate, `${this.customAuthName}`);
86 throw new ResponseError(Enum.ErrorResponse.Unauthorized);
87 }
88
89
90 /**
91 * Check our bespoke scheme. If valid, update context with auth details.
92 * @param {String} reqSig
93 * @param {Object} ctx
94 * @param {string} ctx.rawBody
95 */
96 async isValidSignature(reqSig, ctx) {
97 const _scope = _fileScope('isValidSignature');
98
99 this.logger.debug(_scope, 'checking authentication', { reqSig });
100
101 const reqSigParts = (reqSig || '').split(':');
102
103 if (reqSigParts.length !== 3) {
104 this.logger.debug(_scope, 'unparsable', { reqSigParts });
105 return false;
106 }
107
108 // Get secret for id or fail.
109 const authenticationId = reqSigParts[0];
110 let authData;
111 await this.db.context(async (dbCtx) => {
112 authData = await this.db.getAuthById(dbCtx, authenticationId);
113 });
114 const secret = authData && authData.secret;
115
116 if (!secret) {
117 this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx })
118 return false;
119 }
120
121 // Check timestamp
122 ctx.authenticationEpoch = parseInt(reqSigParts[1], 10);
123 const currentEpoch = Date.now() / 1000;
124 ctx.authenticationDrift = currentEpoch - ctx.authenticationEpoch;
125 if (Math.abs(ctx.authenticationDrift) > this.timestampGraceMs) {
126 this.logger.debug(_scope, 'failed, invalid timestamp', { ctx })
127 return false;
128 }
129
130 // Check signature
131 ctx.authenticationSignatureProvided = reqSigParts[2];
132 ctx.authenticationSignatureComputed = Authenticator.signature(secret, authenticationId, ctx.authenticationEpoch.toString(), ctx.rawBody);
133 if (ctx.authenticationSignatureProvided !== ctx.authenticationSignatureComputed) {
134 this.logger.debug(_scope, 'failed, signature mismatch', { ctx });
135 return false;
136 }
137
138 ctx.authenticationId = authenticationId;
139 ctx.authenticationSecret = secret; // Retain this for computing response signature
140
141 return true; // Success
142 }
143
144
145 /**
146 * Check for valid basic auth.
147 * @param {string} credentials
148 * @param {object} ctx
149 */
150 async isValidBasic(credentials, ctx) {
151 const _scope = _fileScope('isValidBasic');
152 this.logger.debug(_scope, 'called', { ctx });
153
154 const [ authenticationId, authenticationPass ] = common.splitFirst(credentials, ':', '');
155
156 let authData;
157 await this.db.context(async (dbCtx) => {
158 authData = await this.db.getAuthById(dbCtx, authenticationId);
159 });
160 if (!authData) {
161 this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx });
162 return false;
163 }
164
165 // Update pwhash
166 // authData.password = await argon2.hash(newPassword, { type: argon2.id });
167 if (authData.password.startsWith('$argon2')) {
168 if (await argon2.verify(authdata.password, authenticationPass)) {
169 this.logger.debug(_scope, 'failed argon2 verify', { ctx });
170 return false;
171 } else {
172 this.logger.debug(_scope, 'passed argon2 verify', { ctx });
173 }
174 } else {
175 if (authData.password !== authenticationPass) {
176 this.logger.debug(_scope, 'failed, password mismatch', { ctx });
177 return false;
178 }
179 }
180
181 ctx.authenticationId = authenticationId;
182
183 return true; // Success
184 }
185
186
187 /**
188 * Check if a presented Bearer token matches the presented link.
189 * @param {string} credentials
190 * @param {object} ctx
191 */
192 async isValidBearer(credentials, ctx) {
193 const _scope = _fileScope('isValidBearer');
194 this.logger.debug(_scope, 'called', { ctx });
195
196 const linkId = ctx.params.id;
197 return await this.isValidToken(linkId, credentials);
198 }
199
200
201 /**
202 *
203 * @param {string} linkId
204 * @param {string} token
205 */
206 async isValidToken(linkId, token) {
207 const _scope = _fileScope('isValidToken');
208 this.logger.debug(_scope, 'called', { linkId, token });
209
210 if (!token) {
211 this.logger.debug(_scope, 'no token, ignoring', { linkId, token });
212 return false;
213 }
214
215 if (!linkId) {
216 this.logger.debug(_scope, 'no id, ignoring token', { linkId, token });
217 return false;
218 }
219
220 let link;
221 await this.db.context(async (dbCtx) => {
222 link = await this.db.getLinkById(dbCtx, linkId);
223 });
224
225 if (!link) {
226 this.logger.debug(_scope, 'failed, invalid id', { linkId, token });
227 return false;
228 }
229
230 // eslint-disable-next-line security/detect-possible-timing-attacks
231 if (link.authToken !== token) {
232 this.logger.debug(_scope, 'failed, token mismatch', { linkId, token });
233 return false;
234 }
235
236 // Success
237 return true;
238 }
239
240
241 /**
242 * Determine which sort of Authorization header is available, and either parse it or request one.
243 * @param {string} authorizationHeader
244 * @param {object} ctx
245 */
246 async isValidAuthorization(authorizationHeader, ctx) {
247 const _scope = _fileScope('isValidAuthorization');
248 this.logger.debug(_scope, 'called', { authorizationHeader, ctx });
249
250 const [ authMethod, b64Credentials ] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
251 const credentials = Buffer.from(b64Credentials, 'base64').toString('utf-8');
252 // eslint-disable-next-line sonarjs/no-small-switch
253 switch (authMethod.toLowerCase()) {
254 case 'basic':
255 return await this.isValidBasic(credentials, ctx);
256
257 case 'bearer':
258 return await this.isValidBearer(credentials, ctx);
259
260 default:
261 return false;
262 }
263 }
264
265
266 /**
267 *
268 * @param {http.ServerResponse} res
269 */
270 requestBasic(res) {
271 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
272 throw new ResponseError(Enum.ErrorResponse.Unauthorized);
273 }
274
275
276 /**
277 * Require a request passes auth.
278 * Sets ctx.authenticationId on valid non-token authentication.
279 * @param {http.ClientRequest} req
280 * @param {http.ServerResponse} res
281 * @param {object} ctx
282 */
283 async required(req, res, ctx) {
284 const _scope = _fileScope('required');
285 this.logger.debug(_scope, 'called', { ctx });
286
287 let authData;
288
289 // Prefer custom
290 authData = req.getHeader(Enum.Header.Signature);
291 if (authData) {
292 const validSignature = await this.isValidSignature(authData, ctx);
293 if (validSignature) {
294 return true; // Success
295 }
296 // Signature was present, but failed. Request signature auth.
297 return this.requestSignature(res);
298 }
299
300 // Accept standards
301 authData = req.getHeader(Enum.Header.Authorization);
302 if (authData) {
303 const validAuthorization = await this.isValidAuthorization(authData, ctx);
304 if (validAuthorization) {
305 return true; // Success
306 }
307 return this.requestBasic(res);
308 }
309
310 const linkId = ctx.params && ctx.params.id;
311 // If there is an id parameter, check for a valid token query parameter
312 if (linkId) {
313 authData = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token);
314 if (authData) {
315 const validToken = await this.isValidToken(linkId, authData);
316 if (validToken) {
317 return true; // Success
318 }
319 }
320 }
321
322 return this.requestBasic(res);
323 }
324
325
326 /**
327 * Check and set authentication if provided and valid, but do not require it.
328 * @param {http.ClientRequest} req
329 * @param {http.ServerResponse} res
330 * @param {object} ctx
331 */
332 async optional(req, res, ctx) {
333 const _scope = _fileScope('optional');
334 this.logger.debug(_scope, 'called', { ctx });
335
336 let authData;
337
338 // Allow and prefer a valid signature.
339 authData = req.getHeader(Enum.Header.Signature);
340 if (authData) {
341 const validSignature = await this.isValidSignature(authData, ctx);
342 if (validSignature) {
343 return true; // Success
344 }
345 }
346
347 // Allow valid auth.
348 authData = req.getHeader(Enum.Header.Authorization);
349 if (authData) {
350 const validAuthorization = await this.isValidAuthorization(authData, ctx);
351 if (validAuthorization) {
352 return true; // Success
353 }
354 }
355
356 // Allow a valid plain token.
357 const linkId = ctx.params && ctx.params.id;
358 if (linkId) {
359 const token = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token);
360 if (token) {
361 const validToken = await this.isValidToken(linkId, token);
362 if (validToken) {
363 return true; // Success
364 }
365 }
366 }
367
368 return false;
369 }
370
371
372 /**
373 * If a request included signature auth, respond in kind.
374 * @param {http.ClientRequest} req
375 * @param {http.ServerResponse} res
376 * @param {object} ctx
377 */
378 signResponse(req, res, ctx) {
379 const _scope = _fileScope('signResponse');
380
381 // Keep huge payloads out of logs...
382 const logCtx = Object.assign({}, ctx);
383 if (logCtx.responseBody) {
384 if (typeof logCtx.responseBody !== 'string') {
385 logCtx.responseBody = logCtx.responseBody.toString();
386 }
387 if (logCtx.responseBody.length > 128) {
388 logCtx.responseBody = logCtx.responseBody.slice(0, 128) + `... (${logCtx.responseBody.length} bytes)`;
389 }
390 }
391 this.logger.debug(_scope, 'called', { logCtx });
392
393 if (ctx.authenticationSecret && ctx.authenticationId) {
394 const currentEpoch = Date.now() / 1000;
395 const signature = Authenticator.signature(ctx.authenticationSecret, ctx.authenticationId, currentEpoch, ctx.responseBody || '');
396 const signatureHeader = [ctx.authenticationId, currentEpoch, signature].join(':');
397 res.setHeader(Enum.Header.Signature, signatureHeader);
398 this.logger.debug(_scope, 'added signature', { logCtx, signatureHeader });
399 }
400 }
401
402 }
403
404 module.exports = Authenticator;