jkphl/antibot

View on GitHub
src/Antibot/Ports/Validators/HmacValidator.php

Summary

Maintainability
A
3 hrs
Test Coverage
A
90%
<?php

/**
 * antibot
 *
 * @category   Jkphl
 * @package    Jkphl\Antibot
 * @subpackage Jkphl\Antibot\Ports\Validators
 * @author     Joschi Kuphal <joschi@kuphal.net> / @jkphl
 * @copyright  Copyright © 2020 Joschi Kuphal <joschi@kuphal.net> / @jkphl
 * @license    http://opensource.org/licenses/MIT The MIT License (MIT)
 */

/***********************************************************************************
 *  The MIT License (MIT)
 *
 *  Copyright © 2020 Joschi Kuphal <joschi@kuphal.net>
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy of
 *  this software and associated documentation files (the "Software"), to deal in
 *  the Software without restriction, including without limitation the rights to
 *  use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 *  the Software, and to permit persons to whom the Software is furnished to do so,
 *  subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 *  FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 *  COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 *  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 *  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 ***********************************************************************************/

namespace Jkphl\Antibot\Ports\Validators;

use Jkphl\Antibot\Domain\Antibot;
use Jkphl\Antibot\Domain\Exceptions\InvalidRequestMethodOrderException;
use Jkphl\Antibot\Domain\Exceptions\SkippedValidationException;
use Jkphl\Antibot\Infrastructure\Exceptions\HmacValidationException;
use Jkphl\Antibot\Infrastructure\Factory\HmacFactory;
use Jkphl\Antibot\Infrastructure\Model\AbstractValidator;
use Jkphl\Antibot\Infrastructure\Model\InputElement;
use Jkphl\Antibot\Ports\Exceptions\InvalidArgumentException;
use Psr\Http\Message\ServerRequestInterface;

/**
 * HMAC Validator
 *
 * @package    Jkphl\Antibot
 * @subpackage Jkphl\Antibot\Ports\Validators
 */
class HmacValidator extends AbstractValidator
{
    /**
     * Request method vector
     *
     * @var null|array
     */
    protected $methodVector = null;
    /**
     * Request submission times
     *
     * @var null|array
     */
    protected $submissionTimes = null;
    /**
     * Previous request method extracted from valid HMAC
     *
     * @var string|null
     */
    protected $previousMethod = null;
    /**
     * Previous validation result
     *
     * @var null|bool
     */
    protected $validated = null;
    /**
     * Validation order position
     *
     * @var int
     */
    const POSITION = 100;
    /**
     * GET request
     *
     * @var string
     */
    const METHOD_GET = 'GET';
    /**
     * POST request
     *
     * @var string
     */
    const METHOD_POST = 'POST';
    /**
     * Minimum submission time
     *
     * @var float
     */
    const MINIMUM_SUBMISSION = 3;
    /**
     * Minimum submission time for follow-up submissions
     *
     * @var float
     */
    const MINIMUM_FOLLOWUP_SUBMISSION = 1;
    /**
     * Maximum submission time
     *
     * @var float
     */
    const MAXIMUM_SUBMISSION = 3600;
    /**
     * Block access
     *
     * @var string
     */
    const BLOCK = 'BLOCK';

    /**
     * Set the request method vector
     *
     * @param string $previous Previous request
     * @param string $current  Current request
     */
    public function setMethodVector(string $previous = null, string $current = null): void
    {
        // If the request method vector should be unset
        if ($previous === null) {
            $this->methodVector = null;

            return;
        }

        $this->methodVector = [$this->validateRequestMethod($previous), $this->validateRequestMethod($current)];
    }

    /**
     * Sanitize and validate a request method
     *
     * @param string $method Request method
     *
     * @return string Validated request method
     * @throws InvalidArgumentException If the request method is invalid
     */
    protected function validateRequestMethod(string $method): string
    {
        $method = strtoupper($method);
        if ($method !== static::METHOD_GET && $method !== static::METHOD_POST) {
            throw new InvalidArgumentException(
                sprintf(InvalidArgumentException::INVALID_REQUEST_METHOD_STR, $method),
                InvalidArgumentException::INVALID_REQUEST_METHOD
            );
        }

        return $method;
    }

    /**
     * Sanitize and set the submission times
     *
     * @param float $max              Maximum submission time
     * @param float $min              Minimum submission time
     * @param float|null $minFollowUp Minimum submission time for follow-up submissions
     */
    public function setSubmissionTimes(float $max = null, float $min = null, float $minFollowUp = null): void
    {
        // If the submission times should be unset
        if ($max === null) {
            $this->submissionTimes = null;

            return;
        }

        $max                   = min(floatval($max), static::MAXIMUM_SUBMISSION);
        $min                   = max(floatval($min), static::MINIMUM_SUBMISSION);
        $minFollowUp           = ($minFollowUp === null)
            ? $min : max(floatval($minFollowUp), static::MINIMUM_FOLLOWUP_SUBMISSION);
        $this->submissionTimes = [$min, $minFollowUp, $max];
    }

    /**
     * Validate a request
     *
     * @param ServerRequestInterface $request Request
     * @param Antibot $antibot                Antibot instance
     *
     * @return bool
     * @throws HmacValidationException
     * @throws SkippedValidationException If no Antibot data has been submitted
     */
    public function validate(ServerRequestInterface $request, Antibot $antibot): bool
    {
        $data = $antibot->getData();

        // If no Antibot data has been submitted
        if ($data === null) {
            throw new SkippedValidationException(static::class);
        }

        // If no HMAC has been submitted: Fail
        if (empty($data['hmac'])) {
            return false;
        }

        // Validate the submitted HMAC
        $success = $this->validateHmac($data['hmac'], $request, $antibot);

        // If the request method vector was valid: Store the initial request method for further use
        if (!empty($this->methodVector)) {
            $this->previousMethod = $this->methodVector[0];
        }

        return $success;
    }

    /**
     * Create protective form HTML
     *
     * @param ServerRequestInterface $request Request
     * @param Antibot $antibot                Antibot instance
     *
     * @return InputElement[] HMTL input elements
     */
    public function armor(ServerRequestInterface $request, Antibot $antibot): array
    {
        // Ensure validation has run before
        try {
            $this->validate($request, $antibot);
        } catch (\Exception $e) {
            // Continue
        }

        $now   = null;
        $hmac  = $this->calculateHmac($request, $antibot, $now);
        $armor = [
            new InputElement([
                'type'  => 'hidden',
                'name'  => $antibot->getParameterPrefix().'[hmac]',
                'value' => $hmac
            ])
        ];
        // Add the timestamp field
        if ($now !== null) {
            $armor[] = new InputElement([
                'type'  => 'hidden',
                'name'  => $antibot->getParameterPrefix().'[ts]',
                'value' => intval($now)
            ]);
        }

        return $armor;
    }

    /**
     * Decrypt and validate an HMAC
     *
     * @param string $hmac                    HMAC
     * @param ServerRequestInterface $request Request
     * @param Antibot $antibot                Antibot instance
     *
     * @return bool HMAC is valid
     * @throws HmacValidationException If the request timing is invalid
     */
    protected function validateHmac(string $hmac, ServerRequestInterface $request, Antibot $antibot): bool
    {
        $hmacParams = [$antibot->getUnique()];

        // Short-circuit blocked HMAC
        $hmacBlock   = $hmacParams;
        $hmacBlock[] = self::BLOCK;
        if (HmacFactory::createFromString(serialize($hmacBlock), $antibot->getUnique()) === $hmac) {
            return false;
        }

        // Validate the request method vector
        $this->validateRequestMethodVector($request, $hmacParams);

        // If the request timings validate
        if ($this->validateRequestTiming($hmac, $antibot, $hmacParams)) {
            return true;
        }

        // Else: Do a simple validation without request timings
        $currentHMAC = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());

        return $hmac === $currentHMAC;
    }

    /**
     * Validate the request method vector
     *
     * @param ServerRequestInterface $request Request
     * @param array $hmacParams               HMAC parameters
     *
     * @throws HmacValidationException If the request method order is invalid
     */
    protected function validateRequestMethodVector(ServerRequestInterface $request, array &$hmacParams): void
    {
        // If the request method vector should be used
        if (!empty($this->methodVector)) {
            $serverParams  = $request->getServerParams();
            $requestMethod = empty($serverParams['REQUEST_METHOD']) ? 'EMPTY' : $serverParams['REQUEST_METHOD'];
            if ($requestMethod !== $this->methodVector[1]) {
                throw new HmacValidationException(
                    HmacValidationException::INVALID_REQUEST_METHOD_ORDER_STR,
                    HmacValidationException::INVALID_REQUEST_METHOD_ORDER
                );
            }

            $hmacParams[] = $this->methodVector[0];
        }
    }

    /**
     * Validate the request timing
     *
     * @param string $hmac      HMAC
     * @param Antibot $antibot  Antibot instance
     * @param array $hmacParams HMAC parameters
     *
     * @return bool Request timings were enabled and validated successfully
     *
     * @throws HmacValidationException If the request timing is invalid
     */
    protected function validateRequestTiming(string $hmac, Antibot $antibot, array $hmacParams): bool
    {
        // If submission time checks are enabled
        if (!empty($this->submissionTimes)) {
            list($first, $min, $max) = $this->submissionTimes;
            $now       = time();
            $initial   = $now - $first;
            $data      = $antibot->getData();
            $timestamp = empty($data['ts']) ? null : $data['ts'];

            // If a timestamp has been submitted
            if ($timestamp
                && (($timestamp + $min) <= $now)
                && (($timestamp + $max) >= $now)
                && $this->probeTimedHmacAsInitialAndFollowup($hmac, $antibot, $hmacParams, $timestamp, $initial)
            ) {
                $antibot->getLogger()->debug("[HMAC] Validated using submitted timestamp $timestamp");

                return true;
            } else {
                // Run through the valid seconds range
                for ($time = $now - $min; $time >= $now - $max; --$time) {
                    // If the HMAC validates as initial or follow-up request
                    if ($this->probeTimedHmacAsInitialAndFollowup($hmac, $antibot, $hmacParams, $time, $initial)) {
                        return true;
                    }
                }
            }

            throw new HmacValidationException(
                HmacValidationException::INVALID_REQUEST_TIMING_STR,
                HmacValidationException::INVALID_REQUEST_TIMING
            );
        }

        return false;
    }

    /**
     * Probe a timed HMAC both as initial and follow-up request
     *
     * @param string $hmac      HMAC
     * @param Antibot $antibot  Antibot instance
     * @param array $hmacParams HMAC params
     * @param int $timestamp    Timestamp
     * @param int $initial      Initial request threshold
     *
     * @return bool HMAC is valid
     */
    protected function probeTimedHmacAsInitialAndFollowup(
        string $hmac,
        Antibot $antibot,
        array $hmacParams,
        int $timestamp,
        int $initial
    ): bool {
        // If the HMAC validates with auto-guessed mode: Succeed
        if ($this->probeTimedHmac($hmac, $antibot, $hmacParams, $timestamp, $timestamp > $initial)) {
            return true;
        }

        // Also test as late follow-up request
        if (($timestamp <= $initial) && $this->probeTimedHMAC($hmac, $antibot, $hmacParams, $timestamp, true)) {
            return true;
        }

        return false;
    }

    /**
     * Probe a timed HMAC
     *
     * @param string $hmac      HMAC
     * @param Antibot $antibot  Antibot instance
     * @param array $hmacParams HMAC params
     * @param int $timestamp    Timestamp
     * @param bool $followUp    Is a follow-up request
     *
     * @return bool HMAC is valid
     */
    protected function probeTimedHmac(
        string $hmac,
        Antibot $antibot,
        array $hmacParams,
        int $timestamp,
        bool $followUp = false
    ): bool {
        if ($followUp) {
            $hmacParams[] = true;
        }
        $hmacParams[] = $timestamp;
        $currentHMAC  = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());

        $antibot->getLogger()->debug("[HMAC] Probing $timestamp (".($followUp ? 'FLLW' : 'INIT')."): $currentHMAC");

        return $currentHMAC == $hmac;
    }

    /**
     * Calculate the HMAC
     *
     * @param ServerRequestInterface $request Request
     * @param Antibot $antibot                Antibot instance
     * @param int|null $now                   Current timestamp
     *
     * @return string HMAC
     */
    protected function calculateHmac(ServerRequestInterface $request, Antibot $antibot, int &$now = null): string
    {
        $hmacParams = [$antibot->getUnique()];
        $now        = null;

        // Invalidate the HMAC if there's a current, invalid one
        if (false) {
            $hmacParams[] = self::BLOCK;
        } else {
            $this->calculateRequestMethodVectorHmac($request, $hmacParams);
            $this->calculateRequestTimingHmac($antibot, $hmacParams, $now);
        }

        $hmac = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());

        $antibot->getLogger()->debug("[HMAC] Created HMAC $hmac", $hmacParams);

        return $hmac;
    }

    /**
     * Add request method vector data to the HMAC configuration
     *
     * @param ServerRequestInterface $request Request
     * @param array $hmacParams               HMAC parameters
     */
    protected function calculateRequestMethodVectorHmac(ServerRequestInterface $request, array &$hmacParams): void
    {
        // If the request method vector should be used
        if (!empty($this->methodVector)) {
            $serverParams  = $request->getServerParams();
            $requestMethod = $this->previousMethod ?: (empty($serverParams['REQUEST_METHOD']) ? '' : $serverParams['REQUEST_METHOD']);
//            $requestMethod = empty($serverParams['REQUEST_METHOD']) ? '' : $serverParams['REQUEST_METHOD'];
            $hmacParams[] = $this->validateRequestMethod($requestMethod);
        }
    }

    /**
     * Add request timing data to the HMAC configuration
     *
     * @param Antibot $antibot  Antibot instance
     * @param array $hmacParams HMAC parameters
     * @param int|null $now     Current timestamp
     */
    protected function calculateRequestTimingHmac(Antibot $antibot, array &$hmacParams, int &$now = null): void
    {
        // If submission time checks are enabled
        if (!empty($this->submissionTimes)) {
            if (!empty($antibot->getData())) {
                $hmacParams[] = true;
            }
            $hmacParams[] = $now = time();
        }
    }
}