X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=src%2Fauthenticator.js;h=3a196fc745c69401e04de11561c55eac28140fdd;hb=4807a77eca2858e8dc23d9ec2247a778814988d7;hp=7053b56f12cb4eecd4592db9b9c227572c91606a;hpb=28de4364128a4b03918a8cbe868009b5d427220a;p=websub-hub diff --git a/src/authenticator.js b/src/authenticator.js index 7053b56..3a196fc 100644 --- a/src/authenticator.js +++ b/src/authenticator.js @@ -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; } }