X-Git-Url: http://git.squeep.com/?p=squeep-authentication-module;a=blobdiff_plain;f=lib%2Fsession-manager.js;fp=lib%2Fsession-manager.js;h=48ec11ab2a3428a19991fcce67553d5dd692129e;hp=43990cc376e4ee8b16a35092c2bf81620b3a1b9a;hb=2ca511865b0caf3108819cfd6ee775124ea70dff;hpb=8b998e55749e8613c0dece7a156b5edf83fb3608 diff --git a/lib/session-manager.js b/lib/session-manager.js index 43990cc..48ec11a 100644 --- a/lib/session-manager.js +++ b/lib/session-manager.js @@ -58,7 +58,7 @@ class SessionManager { if (this.options.authenticator.secureAuthOnly) { cookieParts.push('Secure'); } - if (typeof(maxAge) === 'number') { + if (typeof maxAge === 'number') { cookieParts.push(`Max-Age=${maxAge}`); } if (path) { @@ -79,7 +79,21 @@ class SessionManager { const _scope = _fileScope('getAdminLogin'); this.logger.debug(_scope, 'called', { ctx }); - res.end(Template.LoginHTML(ctx, this.options)); + // If there is already a valid session established, and if a + // redirect is present, follow it. Why? Maybe someone had + // multiple tabs open, their session expired, and their browser + // restarted, reloading all of them, and they have relogged in + // on one and just want to hit reload on the others to go back + // to where they were. + const redirect = ctx.queryParams['r']; + if (ctx.authenticationId && redirect) { + res.statusCode = 302; + res.setHeader(Enum.Header.Location, redirect); + res.end(); + } else { + res.end(Template.LoginHTML(ctx, this.options)); + } + this.logger.info(_scope, 'finished', { ctx }) } @@ -95,113 +109,98 @@ class SessionManager { 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; - } + // Check if this was an OTP entry attempt. + if (await this._otpSubmission(res, ctx)) { + // OTP path was taken, either successful entry and session creation, or re-prompting for otp. + 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 }); + if (await this._localUserAuth(res, ctx)) { + // Local auth path was taken. return; } // Otherwise, carry on with IndieAuth handshake. + + // Is profile a reasonable url? let me, meAutoScheme, session, authorizationEndpoint; try { me = new URL(ctx.parsedBody['me']); meAutoScheme = !!ctx.parsedBody['me_auto_scheme']; - } 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) { - let profile; + if (ctx.errors.length) { + res.end(Template.LoginHTML(ctx, this.options)); + return; + } + + let profile; + profile = await this.indieAuthCommunication.fetchProfile(me); + if ((!profile?.metadata) + && meAutoScheme) { + this.logger.debug(_scope, 'trying http fallback', { ctx }); + me.protocol = 'http'; profile = await this.indieAuthCommunication.fetchProfile(me); - if ((!profile?.metadata) - && meAutoScheme) { - this.logger.debug(_scope, 'trying http fallback', { ctx }); - me.protocol = 'http'; - profile = await this.indieAuthCommunication.fetchProfile(me); + } + if (!profile?.metadata) { + 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.metadata.authorizationEndpoint); + } catch (e) { + ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); } - if (!profile?.metadata) { - 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.metadata.authorizationEndpoint); - } catch (e) { - ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); - } - if (profile.metadata.issuer) { - // Validate issuer - try { - const issuer = new URL(profile.metadata.issuer); - if (issuer.hash - || issuer.search - || issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing - this.logger.debug(_scope, 'supplied issuer url invalid', { ctx }); - ctx.errors.push('Authorization server provided an invalid issuer field.'); - } - } catch (e) { - this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx }); - ctx.errors.push('Authorization server provided an unparsable issuer field.'); + if (profile.metadata.issuer) { + // Validate issuer + try { + const issuer = new URL(profile.metadata.issuer); + if (issuer.hash + || issuer.search + || issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing + this.logger.debug(_scope, 'supplied issuer url invalid', { ctx }); + ctx.errors.push('Authorization server provided an invalid issuer field.'); } - } else { - this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx }); - // Strict 20220212 compliance would error here. - // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.'); + } catch (e) { + this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx }); + ctx.errors.push('Authorization server provided an unparsable issuer field.'); } + } else { + this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx }); + // Strict 20220212 compliance would error here. + // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.'); } + } - if (authorizationEndpoint) { - const pkce = await IndieAuthCommunication.generatePKCE(); - - const state = randomUUID(); - session = { - authorizationEndpoint: authorizationEndpoint.href, - state, - codeVerifier: pkce.codeVerifier, - me, - redirect, - issuer: profile.metadata.issuer, - }; - - // Update auth endpoint parameters - 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 (authorizationEndpoint) { + const pkce = await IndieAuthCommunication.generatePKCE(); + + const state = randomUUID(); + const redirect = ctx.queryParams['r'] || './'; + session = { + authorizationEndpoint: authorizationEndpoint.href, + state, + codeVerifier: pkce.codeVerifier, + me, + redirect, + issuer: profile.metadata.issuer, + }; + + // Update auth endpoint parameters + 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) { @@ -218,6 +217,126 @@ class SessionManager { } + /** + * Check if processing an OTP entry attempt. If not, resume login flow. + * If so, validate otp and establish session, else reprompt for OTP, or + * return to login entry after too many failures. + * @param {*} res + * @param {*} ctx + * @returns {Boolean} true if otp was handled, otherwise false indicates further login processing needed + */ + async _otpSubmission(res, ctx) { + const _scope = _fileScope('_otpSubmission'); + + // Are we processing an OTP entry attempt? + const { otp, state: stateBox } = ctx.parsedBody; + let state; + try { + if (stateBox) { + state = await this.mysteryBox.unpack(stateBox); + } + } catch (e) { + this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx }); + // Ignore and continue back to main login + return false; + } + if (otp && state) { + const OTPResult = await this.authenticator.checkOTP(state, otp); + switch (OTPResult) { + case Enum.OTPResult.Valid: + // successful otp entry + // Valid auth, persist the authenticated session + ctx.session = { + authenticatedIdentifier: state.authenticationId, + }; + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, state.redirect); + res.end(); + this.logger.info(_scope, 'finished otp', { ctx }); + return true; + + case Enum.OTPResult.InvalidSoftFail: + // retry otp entry + ctx.otpNeeded = true; + ctx.errors.push('Invalid OTP token.'); + ctx.otpState = await this.mysteryBox.pack({ + ...state, + attempt: state.attempt + 1, + }); + res.end(Template.OTPHTML(ctx, this.options)); + this.logger.info(_scope, 'finished otp, invalid, request again', { ctx }); + return true; + + case Enum.OTPResult.InvalidHardFail: + // return to initial login + this.logger.debug(_scope, 'too many otp failures', { ctx, state }); + ctx.errors.push('Invalid OTP token, and too many failures. Try again.'); + return false; + + default: + throw new RangeError('Unexpected OTPResult'); + } + } + // not in otp flow + return false; + } + + /** + * + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async _localUserAuth(res, ctx) { + const _scope = _fileScope('_localUserAuth'); + + // If Indiauth enabled and profile was submitted, defer to that. + if (this.options.authenticator.authnEnabled.includes('indieAuth') + && ctx.parsedBody['me']) { + return false; + } + + const redirect = ctx.queryParams['r'] || './'; + const identifier = ctx.parsedBody['identifier']; + const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx. + + // N.B. validity check also sets autenticationId and otpNeeded on 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 true; + } + + // If OTP exists for valid identifier, follow that flow. + if (ctx.otpNeeded) { + ctx.otpState = await this.mysteryBox.pack({ + authenticatedIdentifier: ctx.authenticationId, + epochMs: Date.now(), + attempt: 0, + redirect, + }); + res.end(Template.OTPHTML(ctx, this.options)); + this.logger.info(_scope, 'finished local, otp required', { ctx }); + return true; + } + + // 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, no otp', { ctx }); + return true; + } + + /** * GET request to remove current credentials. * @param {http.ServerResponse} res @@ -254,7 +373,7 @@ class SessionManager { // Unpack cookie to restore session data - const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', ''); + const [cookieName, cookieValue] = common.splitFirst((ctx.cookie || ''), '=', ''); if (cookieName !== Enum.SessionCookie) { this.logger.debug(_scope, 'no cookie', { ctx }); ctx.errors.push('missing required cookie');