IndieAuth login support, allows viewing of topics related to profile
[websub-hub] / src / 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
8 const _fileScope = common.fileScope(__filename);
9
10 class Authenticator {
11 /**
12 * @param {Console} logger
13 * @param {*} db
14 * @param {Object} options
15 * @param {Object} options.authenticator
16 * @param {String} options.authenticator.basicRealm
17 * @param {Boolean} options.authenticator.secureAuthOnly
18 * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
19 * @param {String[]} options.authenticator.authnEnabled
20 */
21 constructor(logger, db, options) {
22 this.logger = logger;
23 this.db = db;
24 this.basicRealm = options.authenticator.basicRealm;
25 this.secureAuthOnly = options.authenticator.secureAuthOnly;
26
27 this.authn = {
28 DEBUG_ANY: {},
29 };
30 try {
31 this.authn.argon2 = require('argon2');
32 } catch (e) { /**/ }
33 try {
34 this.authn.pam = require('node-linux-pam');
35 this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
36 } catch (e) { /**/ }
37
38 this.authnEnabled = Object.keys(this.authn).filter((auth) => options.authenticator.authnEnabled.includes(auth));
39 this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled });
40
41 if (this.authnEnabled.length === 0) {
42 throw new Error('no authentication mechanisms available');
43 }
44
45 this.mysteryBox = new MysteryBox(logger, options);
46 }
47
48
49 /**
50 * Check for valid Basic auth, updates ctx with identifier if valid.
51 * @param {String} credentials
52 * @param {Object} ctx
53 * @returns {Boolean}
54 */
55 async isValidBasic(credentials, ctx) {
56 const _scope = _fileScope('isValidBasic');
57 this.logger.debug(_scope, 'called', { ctx });
58
59 const [identifier, credential] = common.splitFirst(credentials, ':', '');
60
61 return this.isValidIdentifierCredential(identifier, credential, ctx);
62 }
63
64
65 /**
66 * Check local auth entries.
67 * @param {String} identifier
68 * @param {String} credential
69 * @param {Object} ctx
70 */
71 async isValidIdentifierCredential(identifier, credential, ctx) {
72 const _scope = _fileScope('isValidIdentifierCredential');
73 this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx });
74
75 let isValid = false;
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 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 * Determine which Authorization header is available, and if it is valid.
134 * @param {String} authorizationHeader
135 * @param {Object} ctx
136 */
137 async isValidAuthorization(authorizationHeader, ctx) {
138 const _scope = _fileScope('isValidAuthorization');
139 this.logger.debug(_scope, 'called', { authorizationHeader, ctx });
140
141 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
142 // eslint-disable-next-line sonarjs/no-small-switch
143 switch (authMethod.toLowerCase()) {
144 case 'basic': {
145 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
146 return this.isValidBasic(credentials, ctx);
147 }
148
149 default:
150 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
151 return false;
152 }
153 }
154
155
156 /**
157 * Send a response requesting basic auth.
158 * @param {http.ServerResponse} res
159 */
160 requestBasic(res) {
161 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
162 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
163 }
164
165
166 /**
167 * Attempt to parse a session cookie, and determine if it
168 * contains authenticated user.
169 * Restores ctx.session from cookie data.
170 * @param {Object} ctx
171 * @param {String} cookieHeader
172 * @returns {Boolean}
173 */
174 async isValidCookieAuth(ctx, cookieHeader) {
175 const _scope = _fileScope('isValidCookieAuth');
176 this.logger.debug(_scope, 'called', { ctx, cookieHeader });
177
178 const [ cookieName, cookieValue ] = common.splitFirst(cookieHeader, '=', '');
179 if (cookieName !== 'WSHas') {
180 return false;
181 }
182 try {
183 ctx.session = await this.mysteryBox.unpack(cookieValue);
184 this.logger.debug(_scope, 'unpacked cookie', { ctx });
185 return !!ctx.session.authenticatedProfile || !! ctx.session.authenticatedIdentifier;
186 } catch (e) {
187 this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
188 return false;
189 }
190 }
191
192
193 /**
194 * Require that a request has valid auth over secure channel, requests if missing.
195 * @param {http.ClientRequest} req
196 * @param {http.ServerResponse} res
197 * @param {Object} ctx
198 * @param {String} loginPath
199 */
200 async required(req, res, ctx, loginPath) {
201 const _scope = _fileScope('required');
202 this.logger.debug(_scope, 'called', { ctx });
203
204 if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') {
205 this.logger.debug(_scope, 'rejecting insecure auth', ctx);
206 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
207 }
208
209 const sessionCookie = req.getHeader(Enum.Header.Cookie);
210 if (sessionCookie && await this.isValidCookieAuth(ctx, sessionCookie)) {
211 return true;
212 }
213
214 const authData = req.getHeader(Enum.Header.Authorization);
215 if (authData) {
216 if (await this.isValidAuthorization(authData, ctx)) {
217 return true;
218 }
219 // If they came in trying header auth, let them try again.
220 return this.requestBasic(res);
221 }
222
223 // Otherwise redirect to login.
224 res.statusCode = 302;
225 res.setHeader(Enum.Header.Location, loginPath);
226 res.end();
227
228 return false;
229 }
230
231
232 /**
233 * Require that a request has valid local auth over secure channel, requests if missing.
234 * @param {http.ClientRequest} req
235 * @param {http.ServerResponse} res
236 * @param {Object} ctx
237 * @param {String} loginPath
238 */
239 async requiredLocal(req, res, ctx, loginPath) {
240 const _scope = _fileScope('requiredLocal');
241 this.logger.debug(_scope, 'called', { ctx });
242
243 if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') {
244 this.logger.debug(_scope, 'rejecting insecure auth', ctx);
245 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
246 }
247
248 // Only accept identifier sessions.
249 const sessionCookie = req.getHeader(Enum.Header.Cookie);
250 if (sessionCookie
251 && await this.isValidCookieAuth(ctx, sessionCookie)
252 && ctx.session.authenticatedIdentifier) {
253 return true;
254 }
255
256 // Allow header auth
257 const authData = req.getHeader(Enum.Header.Authorization);
258 if (authData) {
259 if (await this.isValidAuthorization(authData, ctx)) {
260 return true;
261 }
262 // If they came in trying header auth, let them try again.
263 return this.requestBasic(res);
264 }
265
266 // Otherwise redirect to login.
267 res.statusCode = 302;
268 res.setHeader(Enum.Header.Location, loginPath);
269 res.end();
270
271 return false;
272 }
273
274 }
275
276 module.exports = Authenticator;