X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=src%2Fsession-manager.js;fp=src%2Fsession-manager.js;h=5caff347386fbd1ce950b7cb0dcf324a94bc048d;hb=d0444b4b0ee8166d911e6b227218d110eb7eddf1;hp=0000000000000000000000000000000000000000;hpb=728dc0686aa5282e7fc9473c92fbbc617a2b93d7;p=websub-hub diff --git a/src/session-manager.js b/src/session-manager.js new file mode 100644 index 0000000..5caff34 --- /dev/null +++ b/src/session-manager.js @@ -0,0 +1,279 @@ +'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 { + 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.secureCookie = options.authenticator.secureAuthOnly ? ' Secure;' : ''; + this.cookieLifespan = 60 * 60 * 24 * 32; + } + + + /** + * Set or update our session cookie. + * @param {http.ServerResponse} res + * @param {Object} session + * @param {Number} maxAge + */ + async _sessionCookieSet(res, session, maxAge) { + const cookieName = 'WSHas'; + const secureSession = session && await this.mysteryBox.pack(session) || ''; + const cookie = [ + `${cookieName}=${secureSession}`, + 'HttpOnly', + this.secureCookie, + `Max-Age: ${maxAge}`, + ].join('; '); + 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.adminLoginHTML(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 = []; + + // Only attempt user login if no IndieAuth profile is set + if (!ctx.parsedBody['me']) { + this.logger.debug(_scope, 'no indieauth profile, trying identifier', { ctx }); + + const identifier = ctx.parsedBody['identifier']; + const credential = ctx.parsedBody['credential']; + + 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.adminLoginHTML(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, './'); + 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 (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, + }; + + 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.adminLoginHTML(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); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, './'); + 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 !== 'WSHas') { + 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.adminIAHTML(ctx, this.options)); + return; + } + + // set cookie as auth valid, redirect to admin + ctx.session = { + authenticatedProfile: ctx.session.me, + }; + + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, './'); + res.end(); + + this.logger.info(_scope, 'finished', { ctx }) + } + + +} + +module.exports = SessionManager; \ No newline at end of file