if (this.options.authenticator.secureAuthOnly) {
cookieParts.push('Secure');
}
- if (typeof(maxAge) === 'number') {
+ if (typeof maxAge === 'number') {
cookieParts.push(`Max-Age=${maxAge}`);
}
if (path) {
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 })
}
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) {
}
+ /**
+ * 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
// 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');