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');
9 const _fileScope
= common
.fileScope(__filename
);
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)
17 * For 'normal' level access, we allow a token passed as a parameter or a Bearer token.
20 const defaultOptions
= {
21 basicRealm: 'Authenticate',
22 customAuthName: 'SqueepSignature',
23 timestampGraceMs: 120 * 1000, // In ms
27 constructor(logger
, db
, options
= {}) {
28 common
.setOptions(this, defaultOptions
, options
);
35 * For Reasons, get rid of iffy b64 characters.
39 return s
&& s
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g
, '.');
44 * Put them back, if we need to.
47 static b64Unarmor(s
) {
48 return s
&& s
.replace(/-/g
, '+').replace(/_
/g
, '/').replace(/\./g
, '=');
54 * @param {string} secret
56 * @param {string} epoch
57 * @param {string} body
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
);
71 * @param {number} size
73 static async
generateToken(size
= 36) {
74 const randomBuffer
= await common
.randomBytesAsync(size
);
75 const token
= randomBuffer
.toString('base64');
76 return Authenticator
.b64Armor(token
);
82 * @param {http.ServerResponse} res
84 requestSignature(res
) {
85 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `${this.customAuthName}`);
86 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
91 * Check our bespoke scheme. If valid, update context with auth details.
92 * @param {String} reqSig
94 * @param {string} ctx.rawBody
96 async
isValidSignature(reqSig
, ctx
) {
97 const _scope
= _fileScope('isValidSignature');
99 this.logger
.debug(_scope
, 'checking authentication', { reqSig
});
101 const reqSigParts
= (reqSig
|| '').split(':');
103 if (reqSigParts
.length
!== 3) {
104 this.logger
.debug(_scope
, 'unparsable', { reqSigParts
});
108 // Get secret for id or fail.
109 const authenticationId
= reqSigParts
[0];
111 await
this.db
.context(async (dbCtx
) => {
112 authData
= await
this.db
.getAuthById(dbCtx
, authenticationId
);
114 const secret
= authData
&& authData
.secret
;
117 this.logger
.debug(_scope
, 'failed, invalid authenticationId', { ctx
})
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
})
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
});
138 ctx
.authenticationId
= authenticationId
;
139 ctx
.authenticationSecret
= secret
; // Retain this for computing response signature
141 return true; // Success
146 * Check for valid basic auth.
147 * @param {string} credentials
148 * @param {object} ctx
150 async
isValidBasic(credentials
, ctx
) {
151 const _scope
= _fileScope('isValidBasic');
152 this.logger
.debug(_scope
, 'called', { ctx
});
154 const [ authenticationId
, authenticationPass
] = common
.splitFirst(credentials
, ':', '');
157 await
this.db
.context(async (dbCtx
) => {
158 authData
= await
this.db
.getAuthById(dbCtx
, authenticationId
);
161 this.logger
.debug(_scope
, 'failed, invalid authenticationId', { ctx
});
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
});
172 this.logger
.debug(_scope
, 'passed argon2 verify', { ctx
});
175 if (authData
.password
!== authenticationPass
) {
176 this.logger
.debug(_scope
, 'failed, password mismatch', { ctx
});
181 ctx
.authenticationId
= authenticationId
;
183 return true; // Success
188 * Check if a presented Bearer token matches the presented link.
189 * @param {string} credentials
190 * @param {object} ctx
192 async
isValidBearer(credentials
, ctx
) {
193 const _scope
= _fileScope('isValidBearer');
194 this.logger
.debug(_scope
, 'called', { ctx
});
196 const linkId
= ctx
.params
.id
;
197 return await
this.isValidToken(linkId
, credentials
);
203 * @param {string} linkId
204 * @param {string} token
206 async
isValidToken(linkId
, token
) {
207 const _scope
= _fileScope('isValidToken');
208 this.logger
.debug(_scope
, 'called', { linkId
, token
});
211 this.logger
.debug(_scope
, 'no token, ignoring', { linkId
, token
});
216 this.logger
.debug(_scope
, 'no id, ignoring token', { linkId
, token
});
221 await
this.db
.context(async (dbCtx
) => {
222 link
= await
this.db
.getLinkById(dbCtx
, linkId
);
226 this.logger
.debug(_scope
, 'failed, invalid id', { linkId
, token
});
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
});
242 * Determine which sort of Authorization header is available, and either parse it or request one.
243 * @param {string} authorizationHeader
244 * @param {object} ctx
246 async
isValidAuthorization(authorizationHeader
, ctx
) {
247 const _scope
= _fileScope('isValidAuthorization');
248 this.logger
.debug(_scope
, 'called', { authorizationHeader
, ctx
});
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()) {
255 return await
this.isValidBasic(credentials
, ctx
);
258 return await
this.isValidBearer(credentials
, ctx
);
268 * @param {http.ServerResponse} res
271 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
272 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
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
283 async
required(req
, res
, ctx
) {
284 const _scope
= _fileScope('required');
285 this.logger
.debug(_scope
, 'called', { ctx
});
290 authData
= req
.getHeader(Enum
.Header
.Signature
);
292 const validSignature
= await
this.isValidSignature(authData
, ctx
);
293 if (validSignature
) {
294 return true; // Success
296 // Signature was present, but failed. Request signature auth.
297 return this.requestSignature(res
);
301 authData
= req
.getHeader(Enum
.Header
.Authorization
);
303 const validAuthorization
= await
this.isValidAuthorization(authData
, ctx
);
304 if (validAuthorization
) {
305 return true; // Success
307 return this.requestBasic(res
);
310 const linkId
= ctx
.params
&& ctx
.params
.id
;
311 // If there is an id parameter, check for a valid token query parameter
313 authData
= (ctx
.queryParams
&& ctx
.queryParams
.token
) || (ctx
.parsedBody
&& ctx
.parsedBody
.token
);
315 const validToken
= await
this.isValidToken(linkId
, authData
);
317 return true; // Success
322 return this.requestBasic(res
);
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
332 async
optional(req
, res
, ctx
) {
333 const _scope
= _fileScope('optional');
334 this.logger
.debug(_scope
, 'called', { ctx
});
338 // Allow and prefer a valid signature.
339 authData
= req
.getHeader(Enum
.Header
.Signature
);
341 const validSignature
= await
this.isValidSignature(authData
, ctx
);
342 if (validSignature
) {
343 return true; // Success
348 authData
= req
.getHeader(Enum
.Header
.Authorization
);
350 const validAuthorization
= await
this.isValidAuthorization(authData
, ctx
);
351 if (validAuthorization
) {
352 return true; // Success
356 // Allow a valid plain token.
357 const linkId
= ctx
.params
&& ctx
.params
.id
;
359 const token
= (ctx
.queryParams
&& ctx
.queryParams
.token
) || (ctx
.parsedBody
&& ctx
.parsedBody
.token
);
361 const validToken
= await
this.isValidToken(linkId
, token
);
363 return true; // Success
373 * If a request included signature auth, respond in kind.
374 * @param {http.ClientRequest} req
375 * @param {http.ServerResponse} res
376 * @param {object} ctx
378 signResponse(req
, res
, ctx
) {
379 const _scope
= _fileScope('signResponse');
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();
387 if (logCtx
.responseBody
.length
> 128) {
388 logCtx
.responseBody
= logCtx
.responseBody
.slice(0, 128) + `... (${logCtx.responseBody.length} bytes)`;
391 this.logger
.debug(_scope
, 'called', { logCtx
});
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
});
404 module
.exports
= Authenticator
;