initial commit
[squeep-authentication-module] / lib / authenticator.js
diff --git a/lib/authenticator.js b/lib/authenticator.js
new file mode 100644 (file)
index 0000000..15089a0
--- /dev/null
@@ -0,0 +1,340 @@
+'use strict';
+
+const common = require('./common');
+const Enum = require('./enum');
+const Errors = require('./errors');
+const { MysteryBox } = require('@squeep/mystery-box');
+const { name: packageName } = require('../package');
+
+const _fileScope = common.fileScope(__filename);
+
+class Authenticator {
+  /**
+   * @param {Console} logger
+   * @param {*} db
+   * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
+   * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
+   * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
+   * @param {Object} options
+   * @param {Object} options.authenticator
+   * @param {Boolean} options.authenticator.secureAuthOnly
+   * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
+   * @param {String[]} options.authenticator.authnEnabled
+   * @param {String[]=} options.authenticator.loginBlurb
+   * @param {String[]=} options.authenticator.indieAuthBlurb
+   * @param {String[]=} options.authenticator.userBlurb
+   */
+  constructor(logger, db, options) {
+    this.logger = logger;
+    this.db = db;
+    this.options = options;
+    this.basicRealm = options.authenticator.basicRealm || packageName;
+    this.secureAuthOnly = options.authenticator.secureAuthOnly;
+
+    this.authn = {
+      DEBUG_ANY: {},
+      indieAuth: {},
+    };
+    try {
+      this.authn.argon2 = require('argon2');
+    } catch (e) { /**/ }
+    try {
+      this.authn.pam = require('node-linux-pam');
+      this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
+    } catch (e) { /**/ }
+
+    this.authnEnabled = Object.keys(this.authn).filter((auth) => options.authenticator.authnEnabled.includes(auth));
+    this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled });
+
+    if (this.authnEnabled.length === 0) {
+      throw new Error('no authentication mechanisms available');
+    }
+
+    this.mysteryBox = new MysteryBox(logger, options);
+  }
+
+
+  /**
+   * Check local auth entries.
+   * Sets ctx.authenticatedId if valid.
+   * @param {String} identifier
+   * @param {String} credential
+   * @param {Object} ctx
+   */
+  async isValidIdentifierCredential(identifier, credential, ctx) {
+    const _scope = _fileScope('isValidIdentifierCredential');
+    this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx });
+
+    let isValid = false;
+
+    await this.db.context(async (dbCtx) => {
+      const authData = await this.db.authenticationGet(dbCtx, identifier);
+      if (!authData) {
+        this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
+      } else {
+        if (authData.credential.startsWith('$argon2')
+        &&  this.authnEnabled.includes('argon2')) {
+          isValid = await this.authn.argon2.verify(authData.credential, credential);
+        } else if (authData.credential.startsWith('$PAM$')
+        &&         this.authnEnabled.includes('pam')) {
+          isValid = this._isValidPAMIdentifier(identifier, credential);
+        } else {
+          this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx });
+        }
+      }
+
+      if (this.authnEnabled.includes('DEBUG_ANY')) {
+        isValid = true;
+      }
+
+      if (isValid) {
+        ctx.authenticationId = identifier;
+        await this.db.authenticationSuccess(dbCtx, identifier);
+      }
+    }); // dbCtx
+
+    return isValid;
+  }
+
+
+  /**
+   * Check system PAM.
+   * @param {String} identifier
+   * @param {String} credential
+   * @returns {Boolean}
+   */
+  async _isValidPAMIdentifier(identifier, credential) {
+    const _scope = _fileScope('_isValidPAMIdentifier');
+    let isValid = false;
+    if (this.forbiddenPAMIdentifiers.includes(identifier)) {
+      return false;
+    }
+    try {
+      await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
+      isValid = true;
+    } catch (e) {
+      this.logger.debug(_scope, 'failed', { error: e });
+      if (!(e instanceof this.authn.pam.PamError)) {
+        throw e;
+      }
+    }
+    return isValid;
+  }
+
+
+  /**
+   * Check for valid Basic auth, updates ctx with identifier if valid.
+   * @param {String} credentials
+   * @param {Object} ctx
+   * @returns {Boolean}
+   */
+  async isValidBasic(credentials, ctx) {
+    const _scope = _fileScope('isValidBasic');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const [identifier, credential] = common.splitFirst(credentials, ':', '');
+
+    return this.isValidIdentifierCredential(identifier, credential, ctx);
+  }
+
+
+  /**
+   * Determine which Authorization header is available, and if it is valid.
+   * @param {String} authorizationHeader
+   * @param {Object} ctx
+   */
+  async isValidAuthorization(authorizationHeader, ctx) {
+    const _scope = _fileScope('isValidAuthorization');
+    this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
+
+    const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
+    // eslint-disable-next-line sonarjs/no-small-switch
+    switch (authMethod.toLowerCase()) {
+      case 'basic': {
+        const credentials = Buffer.from(authString, 'base64').toString('utf-8');
+        return this.isValidBasic(credentials, ctx);
+      }
+
+      default:
+        this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
+        return false;
+    }
+  }
+
+
+  /**
+   * Send a response requesting basic auth.
+   * @param {http.ServerResponse} res
+   */
+  requestBasic(res) {
+    res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
+    throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
+  }
+
+
+  /**
+   * 
+   * @param {String} cookieHeader
+   */
+  static _cookieParse(cookieHeader) {
+    const cookie = {};
+    (cookieHeader || '').split(/; */).forEach((field) => {
+      const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim()));
+      if (name && !(name in cookie)) {
+        if (value && value.startsWith('"') && value.endsWith('"')) {
+          cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
+        } else {
+          cookie[name] = value; // eslint-disable-line security/detect-object-injection
+        }
+      }
+    });
+    return cookie;
+  }
+
+
+  /**
+   * Attempt to parse a session cookie, and determine if it contains an
+   * authenticated user.
+   * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
+   * @param {Object} ctx
+   * @param {String} cookieHeader
+   * @returns {Boolean}
+   */
+  async isValidCookieAuth(ctx, cookieHeader) {
+    const _scope = _fileScope('isValidCookieAuth');
+    this.logger.debug(_scope, 'called', { ctx, cookieHeader });
+
+    const cookie = Authenticator._cookieParse(cookieHeader);
+    const cookieValue = cookie[Enum.SessionCookie];
+
+    if (!cookieValue) {
+      return false;
+    }
+    try {
+      ctx.session = await this.mysteryBox.unpack(cookieValue);
+      this.logger.debug(_scope, 'unpacked cookie', { ctx });
+
+      const hasIdentifier = !!ctx.session.authenticatedIdentifier;
+      const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
+      const isValid = hasIdentifier || hasProfile;
+      if (isValid) {
+        ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
+      }
+
+      return isValid;
+    } catch (e) {
+      this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
+      return false;
+    }
+  }
+
+
+  /**
+   * Check for a valid session.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   * @param {Boolean} required
+   * @param {Boolean} profilesAllowed
+   * @returns {Boolean}
+   */
+  async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
+    const _scope = _fileScope('check');
+    this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
+
+    if (this.secureAuthOnly
+    &&  ctx.clientProtocol.toLowerCase() !== 'https') {
+      this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
+      if (required) {
+        throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
+      } else {
+        return false;
+      }
+    }
+
+    const sessionCookie = req.getHeader(Enum.Header.Cookie);
+    if (sessionCookie
+    &&  await this.isValidCookieAuth(ctx, sessionCookie)
+    &&  (ctx.session.authenticatedIdentifier
+         || (profilesAllowed && ctx.session.authenticatedProfile))) {
+      this.logger.debug(_scope, 'valid session cookie', { ctx });
+      return true;
+    }
+
+    if (required) {
+      // Clear any existing invalid session
+      const cookieParts = [
+        `${Enum.SessionCookie}=""`,
+        'HttpOnly',
+        'Max-Age=0',
+        `Path=${this.options.dingus.proxyPrefix}/`,
+      ];
+      if (this.options.authenticator.secureAuthOnly) {
+        cookieParts.push('Secure');
+      }
+      res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
+
+      res.statusCode = 302;
+      res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
+      res.end();
+    }
+
+    return false;
+  }
+
+
+  /**
+   * Requires a valid session with a local identifier. Redirects to loginPath if not.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   * @returns {Boolean}
+   */
+  async sessionRequiredLocal(req, res, ctx, loginPath) {
+    return this.sessionCheck(req, res, ctx, loginPath, true, false);
+  }
+
+
+  /**
+   * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   * @returns {Boolean}
+   */
+  async sessionRequired(req, res, ctx, loginPath) {
+    return this.sessionCheck(req, res, ctx, loginPath);
+  }
+
+
+  /**
+   * Check for a valid session with a local identifier, but do nothing if not.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   * @returns {Boolean}
+   */
+  async sessionOptionalLocal(req, res, ctx) {
+    return this.sessionCheck(req, res, ctx, undefined, false, false);
+  }
+
+
+  /**
+   * Check for a valid session with either a local identifier or a profile, but do nothing if not.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   * @returns {Boolean}
+   */
+  async sessionOptional(req, res, ctx) {
+    return this.sessionCheck(req, res, ctx, undefined, false);
+  }
+
+}
+
+module.exports = Authenticator;
\ No newline at end of file