X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fsession-manager.js;fp=lib%2Fsession-manager.js;h=f7cd33a1dcf31ac13104255610b2a0b01244b3b2;hb=dd173e6b450cbba8100883514610c9fde83d050a;hp=0000000000000000000000000000000000000000;hpb=7442dd9aef45fe74dc7abe4d111c734068ef2f7d;p=squeep-authentication-module diff --git a/lib/session-manager.js b/lib/session-manager.js new file mode 100644 index 0000000..f7cd33a --- /dev/null +++ b/lib/session-manager.js @@ -0,0 +1,309 @@ +'use strict'; + +/** + * Here we process activities which support login sessions. + */ + +const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper'); +const { MysteryBox } = require('@squeep/mystery-box'); +const common = require('./common'); +const Enum = require('./enum'); +const Template = require('./template'); + +const _fileScope = common.fileScope(__filename); + +class SessionManager { + /** + * @param {Console} logger + * @param {Authenticator} authenticator + * @param {Object} options + * @param {Object} options.authenticator + * @param {String[]} options.authenticator.authnEnabled + * @param {Object} options.authenticator.secureAuthOnly + * @param {Object} options.dingus + * @param {Object} options.dingus.proxyPrefix + * @param {Object} options.dingus.selfBaseUrl + */ + constructor(logger, authenticator, options) { + this.logger = logger; + this.authenticator = authenticator; + this.options = options; + this.indieAuthCommunication = new IndieAuthCommunication(logger, options); + this.mysteryBox = new MysteryBox(logger, options); + + this.cookieLifespan = 60 * 60 * 24 * 32; + } + + + /** + * Set or update our session cookie. + * @param {http.ServerResponse} res + * @param {Object} session + * @param {Number} maxAge + * @param {String} path + */ + async _sessionCookieSet(res, session, maxAge, path = '/') { + const _scope = _fileScope('_sessionCookieSet'); + + const cookieName = Enum.SessionCookie; + const secureSession = session && await this.mysteryBox.pack(session) || ''; + const cookieParts = [ + `${cookieName}=${secureSession}`, + 'HttpOnly', + ]; + if (this.options.authenticator.secureAuthOnly) { + cookieParts.push('Secure'); + } + if (typeof(maxAge) === 'number') { + cookieParts.push(`Max-Age=${maxAge}`); + } + if (path) { + cookieParts.push(`Path=${this.options.dingus.proxyPrefix}${path}`); + } + const cookie = cookieParts.join('; '); + this.logger.debug(_scope, 'session cookie', { cookie, session }) + res.setHeader(Enum.Header.SetCookie, cookie); + } + + + /** + * GET request for establishing admin session. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminLogin(res, ctx) { + const _scope = _fileScope('getAdminLogin'); + this.logger.debug(_scope, 'called', { ctx }); + + res.end(Template.LoginHTML(ctx, this.options)); + this.logger.info(_scope, 'finished', { ctx }) + } + + + /** + * POST request for taking form data to establish admin session. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async postAdminLogin(res, ctx) { + const _scope = _fileScope('postAdminLogin'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.errors = []; + + const redirect = ctx.queryParams['r'] || './'; + + // Only attempt user login if no IndieAuth profile is set + if (!this.options.authenticator.authnEnabled.includes('indieAuth') || !ctx.parsedBody['me']) { + + const identifier = ctx.parsedBody['identifier']; + const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx. + + const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx); + if (!isValidLocalIdentifier) { + ctx.errors.push('Invalid username or password'); + } + + if (ctx.errors.length) { + res.end(Template.LoginHTML(ctx, this.options)); + return; + } + + // Valid auth, persist the authenticated session + ctx.session = { + authenticatedIdentifier: ctx.authenticationId, + }; + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + this.logger.info(_scope, 'finished local', { ctx }); + return; + } + + let me, session, authorizationEndpoint; + try { + me = new URL(ctx.parsedBody['me']); + } catch (e) { + this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx }); + ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`); + } + + if (this.options.authenticator.authnEnabled.includes('indieAuth') + && me) { + const profile = await this.indieAuthCommunication.fetchProfile(me); + if (!profile || !profile.authorizationEndpoint) { + this.logger.debug(_scope, 'failed to find any profile information at url', { ctx }); + ctx.errors.push(`No profile information was found at '${me}'.`); + } else { + // fetch and parse me for 'authorization_endpoint' relation links + try { + authorizationEndpoint = new URL(profile.authorizationEndpoint); + } catch (e) { + ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); + } + } + + if (authorizationEndpoint) { + const pkce = await IndieAuthCommunication.generatePKCE(); + session = { + authorizationEndpoint: authorizationEndpoint.href, + state: ctx.requestId, + codeVerifier: pkce.codeVerifier, + me, + redirect, + }; + + Object.entries({ + 'response_type': 'code', + 'client_id': this.options.dingus.selfBaseUrl, + 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`, + 'state': session.state, + 'code_challenge': pkce.codeChallenge, + 'code_challenge_method': pkce.codeChallengeMethod, + 'me': me, + }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value)); + } + } + + if (ctx.errors.length) { + res.end(Template.LoginHTML(ctx, this.options)); + return; + } + + await this._sessionCookieSet(res, session, this.cookieLifespan); + res.setHeader(Enum.Header.Location, authorizationEndpoint.href); + res.statusCode = 302; // Found + res.end(); + + this.logger.info(_scope, 'finished indieauth', { ctx }) + } + + + /** + * GET request to remove current credentials. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminLogout(res, ctx) { + const _scope = _fileScope('getAdminLogout'); + this.logger.debug(_scope, 'called', { ctx }); + + this._sessionCookieSet(res, '', 0); + + const redirect = ctx.queryParams['r'] || './'; + + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + + this.logger.info(_scope, 'finished', { ctx }); + } + + + /** + * GET request for returning IndieAuth redirect. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminIA(res, ctx) { + const _scope = _fileScope('getAdminIA'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.errors = []; + ctx.session = {}; + + // Unpack cookie to restore session data + + const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', ''); + if (cookieName !== Enum.SessionCookie) { + this.logger.debug(_scope, 'no cookie', { ctx }); + ctx.errors.push('missing required cookie'); + } else { + try { + ctx.session = await this.mysteryBox.unpack(cookieValue); + this.logger.debug(_scope, 'restored session from cookie', { ctx }); + } catch (e) { + this.logger.debug(_scope, 'could not unpack cookie'); + ctx.errors.push('invalid cookie'); + } + } + + // Validate unpacked session values + + // Add any auth errors + if (ctx.queryParams['error']) { + ctx.errors.push(ctx.queryParams['error']); + if (ctx.queryParams['error_description']) { + ctx.errors.push(ctx.queryParams['error_description']); + } + } + + // check stuff + if (ctx.queryParams['state'] !== ctx.session.state) { + this.logger.debug(_scope, 'state mismatch', { ctx }); + ctx.errors.push('invalid state'); + } + + const code = ctx.queryParams['code']; + if (!code) { + this.logger.debug(_scope, 'missing code', { ctx }); + ctx.errors.push('invalid code'); + } + + let redeemProfileUrl; + try { + redeemProfileUrl = new URL(ctx.session.authorizationEndpoint); + } catch (e) { + this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx }); + ctx.errors.push('invalid cookie'); + } + let profile; + if (redeemProfileUrl) { + profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`); + if (!profile) { + this.logger.debug(_scope, 'no profile from code redemption', { ctx }); + ctx.errors.push('did not get a profile response from authorization endpoint code redemption'); + } else if (!profile.me) { + this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx }); + ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption'); + } else if (profile.me !== ctx.session.me) { + this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile }); + const newProfileUrl = new URL(profile.me); + // Rediscover auth endpoint for the new returned profile. + const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl); + if (newProfile.authorizationEndpoint !== ctx.session.authorizationEndpoint) { + this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile }); + ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue'); + } else { + // The endpoints match, all is okay, update our records. + ctx.session.me = profile.me; + } + } + } + + if (ctx.errors.length) { + await this._sessionCookieSet(res, '', 0); + res.end(Template.IAHTML(ctx, this.options)); + return; + } + + const redirect = ctx.session.redirect || './'; + + // Set cookie as auth valid, redirect to original location. + ctx.session = { + authenticatedProfile: ctx.session.me, + }; + + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + + this.logger.info(_scope, 'finished', { ctx }) + } + + +} + +module.exports = SessionManager; \ No newline at end of file