rudimentary support for totp 2fa
[squeep-authentication-module] / lib / session-manager.js
1 'use strict';
2
3 /**
4 * Here we process activities which support login sessions.
5 */
6
7 const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper');
8 const { MysteryBox } = require('@squeep/mystery-box');
9 const { randomUUID } = require('crypto');
10 const common = require('./common');
11 const Enum = require('./enum');
12 const Template = require('./template');
13
14 const _fileScope = common.fileScope(__filename);
15
16 class SessionManager {
17 /**
18 * @param {Console} logger
19 * @param {Authenticator} authenticator
20 * @param {Object} options
21 * @param {Object} options.authenticator
22 * @param {String[]} options.authenticator.authnEnabled
23 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
24 * @param {Boolean} options.authenticator.secureAuthOnly
25 * @param {Object} options.dingus
26 * @param {String} options.dingus.proxyPrefix
27 * @param {String} options.dingus.selfBaseUrl
28 */
29 constructor(logger, authenticator, options) {
30 this.logger = logger;
31 this.authenticator = authenticator;
32 this.options = options;
33 this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
34 this.mysteryBox = new MysteryBox(options);
35 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
36
37 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
38 }
39
40
41 /**
42 * Set or update our session cookie.
43 * @param {http.ServerResponse} res
44 * @param {Object} session
45 * @param {Number} maxAge
46 * @param {String} path
47 */
48 async _sessionCookieSet(res, session, maxAge, path = '/') {
49 const _scope = _fileScope('_sessionCookieSet');
50
51 const cookieName = Enum.SessionCookie;
52 const secureSession = session && await this.mysteryBox.pack(session) || '';
53 const cookieParts = [
54 `${cookieName}=${secureSession}`,
55 'HttpOnly',
56 'SameSite=Lax',
57 ];
58 if (this.options.authenticator.secureAuthOnly) {
59 cookieParts.push('Secure');
60 }
61 if (typeof maxAge === 'number') {
62 cookieParts.push(`Max-Age=${maxAge}`);
63 }
64 if (path) {
65 cookieParts.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
66 }
67 const cookie = cookieParts.join('; ');
68 this.logger.debug(_scope, 'session cookie', { cookie, session })
69 res.setHeader(Enum.Header.SetCookie, cookie);
70 }
71
72
73 /**
74 * GET request for establishing admin session.
75 * @param {http.ServerResponse} res
76 * @param {Object} ctx
77 */
78 async getAdminLogin(res, ctx) {
79 const _scope = _fileScope('getAdminLogin');
80 this.logger.debug(_scope, 'called', { ctx });
81
82 // If there is already a valid session established, and if a
83 // redirect is present, follow it. Why? Maybe someone had
84 // multiple tabs open, their session expired, and their browser
85 // restarted, reloading all of them, and they have relogged in
86 // on one and just want to hit reload on the others to go back
87 // to where they were.
88 const redirect = ctx.queryParams['r'];
89 if (ctx.authenticationId && redirect) {
90 res.statusCode = 302;
91 res.setHeader(Enum.Header.Location, redirect);
92 res.end();
93 } else {
94 res.end(Template.LoginHTML(ctx, this.options));
95 }
96
97 this.logger.info(_scope, 'finished', { ctx })
98 }
99
100
101 /**
102 * POST request for taking form data to establish admin session.
103 * @param {http.ServerResponse} res
104 * @param {Object} ctx
105 */
106 async postAdminLogin(res, ctx) {
107 const _scope = _fileScope('postAdminLogin');
108 this.logger.debug(_scope, 'called', { ctx });
109
110 ctx.errors = [];
111
112 // Check if this was an OTP entry attempt.
113 if (await this._otpSubmission(res, ctx)) {
114 // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
115 return;
116 }
117
118 if (await this._localUserAuth(res, ctx)) {
119 // Local auth path was taken.
120 return;
121 }
122
123 // Otherwise, carry on with IndieAuth handshake.
124
125 // Is profile a reasonable url?
126 let me, meAutoScheme, session, authorizationEndpoint;
127 try {
128 me = new URL(ctx.parsedBody['me']);
129 meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
130 } catch (e) {
131 this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
132 ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
133 }
134
135 if (ctx.errors.length) {
136 res.end(Template.LoginHTML(ctx, this.options));
137 return;
138 }
139
140 let profile;
141 profile = await this.indieAuthCommunication.fetchProfile(me);
142 if ((!profile?.metadata)
143 && meAutoScheme) {
144 this.logger.debug(_scope, 'trying http fallback', { ctx });
145 me.protocol = 'http';
146 profile = await this.indieAuthCommunication.fetchProfile(me);
147 }
148 if (!profile?.metadata) {
149 this.logger.debug(_scope, 'failed to find any profile information at url', { ctx });
150 ctx.errors.push(`No profile information was found at '${me}'.`);
151 } else {
152 // fetch and parse me for 'authorization_endpoint' relation links
153 try {
154 authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
155 } catch (e) {
156 ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
157 }
158
159 if (profile.metadata.issuer) {
160 // Validate issuer
161 try {
162 const issuer = new URL(profile.metadata.issuer);
163 if (issuer.hash
164 || issuer.search
165 || issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
166 this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
167 ctx.errors.push('Authorization server provided an invalid issuer field.');
168 }
169 } catch (e) {
170 this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
171 ctx.errors.push('Authorization server provided an unparsable issuer field.');
172 }
173 } else {
174 this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
175 // Strict 20220212 compliance would error here.
176 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
177 }
178 }
179
180 if (authorizationEndpoint) {
181 const pkce = await IndieAuthCommunication.generatePKCE();
182
183 const state = randomUUID();
184 const redirect = ctx.queryParams['r'] || './';
185 session = {
186 authorizationEndpoint: authorizationEndpoint.href,
187 state,
188 codeVerifier: pkce.codeVerifier,
189 me,
190 redirect,
191 issuer: profile.metadata.issuer,
192 };
193
194 // Update auth endpoint parameters
195 Object.entries({
196 'response_type': 'code',
197 'client_id': this.options.dingus.selfBaseUrl,
198 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
199 'state': session.state,
200 'code_challenge': pkce.codeChallenge,
201 'code_challenge_method': pkce.codeChallengeMethod,
202 'me': me,
203 }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
204 }
205
206 if (ctx.errors.length) {
207 res.end(Template.LoginHTML(ctx, this.options));
208 return;
209 }
210
211 await this._sessionCookieSet(res, session, this.cookieLifespan);
212 res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
213 res.statusCode = 302; // Found
214 res.end();
215
216 this.logger.info(_scope, 'finished indieauth', { ctx })
217 }
218
219
220 /**
221 * Check if processing an OTP entry attempt. If not, resume login flow.
222 * If so, validate otp and establish session, else reprompt for OTP, or
223 * return to login entry after too many failures.
224 * @param {*} res
225 * @param {*} ctx
226 * @returns {Boolean} true if otp was handled, otherwise false indicates further login processing needed
227 */
228 async _otpSubmission(res, ctx) {
229 const _scope = _fileScope('_otpSubmission');
230
231 // Are we processing an OTP entry attempt?
232 const { otp, state: stateBox } = ctx.parsedBody;
233 let state;
234 try {
235 if (stateBox) {
236 state = await this.mysteryBox.unpack(stateBox);
237 }
238 } catch (e) {
239 this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx });
240 // Ignore and continue back to main login
241 return false;
242 }
243 if (otp && state) {
244 const OTPResult = await this.authenticator.checkOTP(state, otp);
245 switch (OTPResult) {
246 case Enum.OTPResult.Valid:
247 // successful otp entry
248 // Valid auth, persist the authenticated session
249 ctx.session = {
250 authenticatedIdentifier: state.authenticationId,
251 };
252 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
253 res.statusCode = 302;
254 res.setHeader(Enum.Header.Location, state.redirect);
255 res.end();
256 this.logger.info(_scope, 'finished otp', { ctx });
257 return true;
258
259 case Enum.OTPResult.InvalidSoftFail:
260 // retry otp entry
261 ctx.otpNeeded = true;
262 ctx.errors.push('Invalid OTP token.');
263 ctx.otpState = await this.mysteryBox.pack({
264 ...state,
265 attempt: state.attempt + 1,
266 });
267 res.end(Template.OTPHTML(ctx, this.options));
268 this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
269 return true;
270
271 case Enum.OTPResult.InvalidHardFail:
272 // return to initial login
273 this.logger.debug(_scope, 'too many otp failures', { ctx, state });
274 ctx.errors.push('Invalid OTP token, and too many failures. Try again.');
275 return false;
276
277 default:
278 throw new RangeError('Unexpected OTPResult');
279 }
280 }
281 // not in otp flow
282 return false;
283 }
284
285 /**
286 *
287 * @param {http.ServerResponse} res
288 * @param {Object} ctx
289 */
290 async _localUserAuth(res, ctx) {
291 const _scope = _fileScope('_localUserAuth');
292
293 // If Indiauth enabled and profile was submitted, defer to that.
294 if (this.options.authenticator.authnEnabled.includes('indieAuth')
295 && ctx.parsedBody['me']) {
296 return false;
297 }
298
299 const redirect = ctx.queryParams['r'] || './';
300 const identifier = ctx.parsedBody['identifier'];
301 const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
302
303 // N.B. validity check also sets autenticationId and otpNeeded on ctx
304 const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
305 if (!isValidLocalIdentifier) {
306 ctx.errors.push('Invalid username or password');
307 }
308
309 if (ctx.errors.length) {
310 res.end(Template.LoginHTML(ctx, this.options));
311 return true;
312 }
313
314 // If OTP exists for valid identifier, follow that flow.
315 if (ctx.otpNeeded) {
316 ctx.otpState = await this.mysteryBox.pack({
317 authenticatedIdentifier: ctx.authenticationId,
318 epochMs: Date.now(),
319 attempt: 0,
320 redirect,
321 });
322 res.end(Template.OTPHTML(ctx, this.options));
323 this.logger.info(_scope, 'finished local, otp required', { ctx });
324 return true;
325 }
326
327 // Valid auth, persist the authenticated session
328 ctx.session = {
329 authenticatedIdentifier: ctx.authenticationId,
330 };
331 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
332 res.statusCode = 302;
333 res.setHeader(Enum.Header.Location, redirect);
334 res.end();
335 this.logger.info(_scope, 'finished local, no otp', { ctx });
336 return true;
337 }
338
339
340 /**
341 * GET request to remove current credentials.
342 * @param {http.ServerResponse} res
343 * @param {Object} ctx
344 */
345 async getAdminLogout(res, ctx) {
346 const _scope = _fileScope('getAdminLogout');
347 this.logger.debug(_scope, 'called', { ctx });
348
349 await this._sessionCookieSet(res, '', 0);
350
351 const redirect = ctx.queryParams['r'] || './';
352
353 res.statusCode = 302;
354 res.setHeader(Enum.Header.Location, redirect);
355 res.end();
356
357 this.logger.info(_scope, 'finished', { ctx });
358 }
359
360
361 /**
362 * GET request for returning IndieAuth redirect.
363 * This currently only redeems a scope-less profile.
364 * @param {http.ServerResponse} res
365 * @param {Object} ctx
366 */
367 async getAdminIA(res, ctx) {
368 const _scope = _fileScope('getAdminIA');
369 this.logger.debug(_scope, 'called', { ctx });
370
371 ctx.errors = [];
372 ctx.session = {};
373
374 // Unpack cookie to restore session data
375
376 const [cookieName, cookieValue] = common.splitFirst((ctx.cookie || ''), '=', '');
377 if (cookieName !== Enum.SessionCookie) {
378 this.logger.debug(_scope, 'no cookie', { ctx });
379 ctx.errors.push('missing required cookie');
380 } else {
381 try {
382 ctx.session = await this.mysteryBox.unpack(cookieValue);
383 this.logger.debug(_scope, 'restored session from cookie', { ctx });
384 } catch (e) {
385 this.logger.debug(_scope, 'could not unpack cookie');
386 ctx.errors.push('invalid cookie');
387 }
388 }
389
390 // Validate unpacked session values
391 // ...
392
393 // Add any auth errors
394 if (ctx.queryParams['error']) {
395 ctx.errors.push(ctx.queryParams['error']);
396 if (ctx.queryParams['error_description']) {
397 ctx.errors.push(ctx.queryParams['error_description']);
398 }
399 }
400
401 // check stuff
402 if (ctx.queryParams['state'] !== ctx.session.state) {
403 this.logger.debug(_scope, 'state mismatch', { ctx });
404 ctx.errors.push('invalid state');
405 }
406
407 const code = ctx.queryParams['code'];
408 if (!code) {
409 this.logger.debug(_scope, 'missing code', { ctx });
410 ctx.errors.push('invalid code');
411 }
412
413 // check issuer
414 if (ctx.session.issuer) {
415 if (ctx.queryParams['iss'] !== ctx.session.issuer) {
416 this.logger.debug(_scope, 'issuer mismatch', { ctx });
417 ctx.errors.push('invalid issuer');
418 }
419 } else {
420 this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
421 // Strict 20220212 compliance would error here. (Also earlier.)
422 // ctx.errors.push('invalid issuer');
423 }
424
425 let redeemProfileUrl;
426 try {
427 redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
428 } catch (e) {
429 this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
430 ctx.errors.push('invalid cookie');
431 }
432 let profile;
433 if (redeemProfileUrl) {
434 profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
435 if (!profile) {
436 this.logger.debug(_scope, 'no profile from code redemption', { ctx });
437 ctx.errors.push('did not get a profile response from authorization endpoint code redemption');
438 } else if (!profile.me) {
439 this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx });
440 ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption');
441 } else if (profile.me !== ctx.session.me) {
442 this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile });
443 const newProfileUrl = new URL(profile.me);
444 // Rediscover auth endpoint for the new returned profile.
445 const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl);
446 if (newProfile.metadata.authorizationEndpoint !== ctx.session.authorizationEndpoint) {
447 this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile });
448 ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
449 } else {
450 // The endpoints match, all is okay, update our records.
451 ctx.session.me = profile.me;
452 }
453 }
454 }
455
456 if (ctx.errors.length) {
457 await this._sessionCookieSet(res, '', 0);
458 res.end(Template.IAHTML(ctx, this.options));
459 return;
460 }
461
462 const redirect = ctx.session.redirect || './';
463
464 // Set cookie as auth valid, redirect to original location.
465 ctx.session = {
466 authenticatedProfile: ctx.session.me,
467 };
468
469 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
470 res.statusCode = 302;
471 res.setHeader(Enum.Header.Location, redirect);
472 res.end();
473
474 this.logger.info(_scope, 'finished', { ctx })
475 }
476
477
478 }
479
480 module.exports = SessionManager;