Merge branch 'v1.1-dev' as v1.1.2
[websub-hub] / src / authenticator.js
1 'use strict';
2
3 const argon2 = require('argon2');
4 const common = require('./common');
5 const Enum = require('./enum');
6 const Errors = require('./errors');
7
8 const _fileScope = common.fileScope(__filename);
9
10 class Authenticator {
11 constructor(logger, db, options) {
12 this.logger = logger;
13 this.db = db;
14 this.basicRealm = options.authenticator.basicRealm;
15 this.secureAuthOnly = options.authenticator.secureAuthOnly;
16 }
17
18
19 /**
20 * Check for valid Basic auth, updates ctx with identifier if valid.
21 * @param {String} credentials
22 * @param {Object} ctx
23 * @returns {Boolean}
24 */
25 async isValidBasic(credentials, ctx) {
26 const _scope = _fileScope('isValidBasic');
27 this.logger.debug(_scope, 'called', { ctx });
28
29 const [identifier, credential] = common.splitFirst(credentials, ':', '');
30
31 let valid = false;
32 await this.db.context(async (dbCtx) => {
33 const authData = await this.db.authenticationGet(dbCtx, identifier);
34 if (!authData) {
35 this.logger.debug(_scope, 'failed, invalid authentication id', { ctx });
36 return false;
37 }
38
39 if (authData.credential.startsWith('$argon2')) {
40 valid = await argon2.verify(authData.credential, credential);
41 } else {
42 this.logger.error(_scope, 'failed, unknown type of stored password hash', { ctx });
43 }
44 if (valid) {
45 ctx.authenticationId = identifier;
46 await this.db.authenticationSuccess(dbCtx, identifier);
47 }
48 });
49
50 return valid;
51 }
52
53
54 /**
55 * Determine which Authorization header is available, and if it is valid.
56 * @param {String} authorizationHeader
57 * @param {Object} ctx
58 */
59 async isValidAuthorization(authorizationHeader, ctx) {
60 const _scope = _fileScope('isValidAuthorization');
61 this.logger.debug(_scope, 'called', { authorizationHeader, ctx });
62
63 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
64 // eslint-disable-next-line sonarjs/no-small-switch
65 switch (authMethod.toLowerCase()) {
66 case 'basic': {
67 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
68 return await this.isValidBasic(credentials, ctx);
69 }
70
71 default:
72 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
73 return false;
74 }
75 }
76
77
78 /**
79 * Send a response requesting basic auth.
80 * @param {http.ServerResponse} res
81 */
82 requestBasic(res) {
83 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
84 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
85 }
86
87
88 /**
89 * Require that a request has valid auth over secure channel, requests if missing.
90 * @param {http.ClientRequest} req
91 * @param {http.ServerResponse} res
92 * @param {Object} ctx
93 */
94 async required(req, res, ctx) {
95 const _scope = _fileScope('required');
96 this.logger.debug(_scope, 'called', { ctx });
97
98 if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') {
99 this.logger.debug(_scope, 'rejecting insecure auth', ctx);
100 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
101 }
102
103 const authData = req.getHeader(Enum.Header.Authorization);
104 if (authData
105 && await this.isValidAuthorization(authData, ctx)) {
106 return true;
107 }
108 return this.requestBasic(res);
109 }
110
111 }
112
113 module.exports = Authenticator;