IndieAuth login support, allows viewing of topics related to profile
[websub-hub] / src / authenticator.js
index 7053b56f12cb4eecd4592db9b9c227572c91606a..3a196fc745c69401e04de11561c55eac28140fdd 100644 (file)
@@ -1,18 +1,48 @@
 'use strict';
 
-const argon2 = require('argon2');
 const common = require('./common');
 const Enum = require('./enum');
 const Errors = require('./errors');
+const { MysteryBox } = require('@squeep/mystery-box');
 
 const _fileScope = common.fileScope(__filename);
 
 class Authenticator {
+  /**
+   * @param {Console} logger
+   * @param {*} db
+   * @param {Object} options
+   * @param {Object} options.authenticator
+   * @param {String} options.authenticator.basicRealm
+   * @param {Boolean} options.authenticator.secureAuthOnly
+   * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
+   * @param {String[]} options.authenticator.authnEnabled
+   */
   constructor(logger, db, options) {
     this.logger = logger;
     this.db = db;
     this.basicRealm = options.authenticator.basicRealm;
     this.secureAuthOnly = options.authenticator.secureAuthOnly;
+
+    this.authn = {
+      DEBUG_ANY: {},
+    };
+    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);
   }
 
 
@@ -28,26 +58,74 @@ class Authenticator {
 
     const [identifier, credential] = common.splitFirst(credentials, ':', '');
 
-    let valid = false;
+    return this.isValidIdentifierCredential(identifier, credential, ctx);
+  }
+
+
+  /**
+   * Check local auth entries.
+   * @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 authentication id', { ctx });
-        return false;
+        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 (authData.credential.startsWith('$argon2')) {
-        valid = await argon2.verify(authData.credential, credential);
-      } else {
-        this.logger.error(_scope, 'failed, unknown type of stored password hash', { ctx });
+      if (this.authnEnabled.includes('DEBUG_ANY')) {
+        isValid = true;
       }
-      if (valid) {
+
+      if (isValid) {
         ctx.authenticationId = identifier;
         await this.db.authenticationSuccess(dbCtx, identifier);
       }
-    });
+    }); // dbCtx
+
+    return isValid;
+  }
 
-    return valid;
+
+  /**
+   * 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;
   }
 
 
@@ -65,7 +143,7 @@ class Authenticator {
     switch (authMethod.toLowerCase()) {
       case 'basic': {
         const credentials = Buffer.from(authString, 'base64').toString('utf-8');
-        return await this.isValidBasic(credentials, ctx);
+        return this.isValidBasic(credentials, ctx);
       }
 
       default:
@@ -85,13 +163,41 @@ class Authenticator {
   }
 
 
+  /**
+   * Attempt to parse a session cookie, and determine if it
+   * contains authenticated user.
+   * Restores ctx.session from cookie data.
+   * @param {Object} ctx
+   * @param {String} cookieHeader
+   * @returns {Boolean}
+   */
+  async isValidCookieAuth(ctx, cookieHeader) {
+    const _scope = _fileScope('isValidCookieAuth');
+    this.logger.debug(_scope, 'called', { ctx, cookieHeader });
+
+    const [ cookieName, cookieValue ] = common.splitFirst(cookieHeader, '=', '');
+    if (cookieName !== 'WSHas') {
+      return false;
+    }
+    try {
+      ctx.session = await this.mysteryBox.unpack(cookieValue);
+      this.logger.debug(_scope, 'unpacked cookie', { ctx });
+      return !!ctx.session.authenticatedProfile || !! ctx.session.authenticatedIdentifier;
+    } catch (e) {
+      this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
+      return false;
+    }
+  }
+
+
   /**
    * Require that a request has valid auth over secure channel, requests if missing.
    * @param {http.ClientRequest} req
    * @param {http.ServerResponse} res
    * @param {Object} ctx
+   * @param {String} loginPath
    */
-  async required(req, res, ctx) {
+  async required(req, res, ctx, loginPath) {
     const _scope = _fileScope('required');
     this.logger.debug(_scope, 'called', { ctx });
 
@@ -100,12 +206,69 @@ class Authenticator {
       throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
     }
 
+    const sessionCookie = req.getHeader(Enum.Header.Cookie);
+    if (sessionCookie && await this.isValidCookieAuth(ctx, sessionCookie)) {
+      return true;
+    }
+
     const authData = req.getHeader(Enum.Header.Authorization);
-    if (authData
-    &&  await this.isValidAuthorization(authData, ctx)) {
+    if (authData) {
+      if (await this.isValidAuthorization(authData, ctx)) {
+        return true;
+      }
+      // If they came in trying header auth, let them try again.
+      return this.requestBasic(res);
+    }
+
+    // Otherwise redirect to login.
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, loginPath);
+    res.end();
+
+    return false;
+  }
+
+
+  /**
+   * Require that a request has valid local auth over secure channel, requests if missing.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   * @param {String} loginPath
+   */
+  async requiredLocal(req, res, ctx, loginPath) {
+    const _scope = _fileScope('requiredLocal');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') {
+      this.logger.debug(_scope, 'rejecting insecure auth', ctx);
+      throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
+    }
+
+    // Only accept identifier sessions.
+    const sessionCookie = req.getHeader(Enum.Header.Cookie);
+    if (sessionCookie
+    &&  await this.isValidCookieAuth(ctx, sessionCookie)
+    &&  ctx.session.authenticatedIdentifier) {
       return true;
     }
-    return this.requestBasic(res);
+
+    // Allow header auth
+    const authData = req.getHeader(Enum.Header.Authorization);
+    if (authData) {
+      if (await this.isValidAuthorization(authData, ctx)) {
+        return true;
+      }
+      // If they came in trying header auth, let them try again.
+      return this.requestBasic(res);
+    }
+
+    // Otherwise redirect to login.
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, loginPath);
+    res.end();
+
+    return false;
   }
 
 }