Completed
Push — master ( df4068...6a3bd7 )
by Joschi
03:02
created

HmacValidator::calculateHmac()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2.004

Importance

Changes 0
Metric Value
dl 0
loc 19
ccs 9
cts 10
cp 0.9
rs 9.6333
c 0
b 0
f 0
cc 2
nc 2
nop 3
crap 2.004
1
<?php
2
3
/**
4
 * antibot
5
 *
6
 * @category   Jkphl
7
 * @package    Jkphl\Antibot
8
 * @subpackage Jkphl\Antibot\Ports\Validators
9
 * @author     Joschi Kuphal <[email protected]> / @jkphl
10
 * @copyright  Copyright © 2018 Joschi Kuphal <[email protected]> / @jkphl
11
 * @license    http://opensource.org/licenses/MIT The MIT License (MIT)
12
 */
13
14
/***********************************************************************************
15
 *  The MIT License (MIT)
16
 *
17
 *  Copyright © 2018 Joschi Kuphal <[email protected]>
18
 *
19
 *  Permission is hereby granted, free of charge, to any person obtaining a copy of
20
 *  this software and associated documentation files (the "Software"), to deal in
21
 *  the Software without restriction, including without limitation the rights to
22
 *  use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
23
 *  the Software, and to permit persons to whom the Software is furnished to do so,
24
 *  subject to the following conditions:
25
 *
26
 *  The above copyright notice and this permission notice shall be included in all
27
 *  copies or substantial portions of the Software.
28
 *
29
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
31
 *  FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
32
 *  COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
33
 *  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
34
 *  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
35
 ***********************************************************************************/
36
37
namespace Jkphl\Antibot\Ports\Validators;
38
39
use Jkphl\Antibot\Domain\Antibot;
40
use Jkphl\Antibot\Domain\Exceptions\InvalidRequestMethodOrderException;
41
use Jkphl\Antibot\Domain\Exceptions\SkippedValidationException;
42
use Jkphl\Antibot\Infrastructure\Exceptions\HmacValidationException;
43
use Jkphl\Antibot\Infrastructure\Factory\HmacFactory;
44
use Jkphl\Antibot\Infrastructure\Model\AbstractValidator;
45
use Jkphl\Antibot\Infrastructure\Model\InputElement;
46
use Jkphl\Antibot\Ports\Exceptions\InvalidArgumentException;
47
use Psr\Http\Message\ServerRequestInterface;
48
49
/**
50
 * HMAC Validator
51
 *
52
 * @package    Jkphl\Antibot
53
 * @subpackage Jkphl\Antibot\Ports\Validators
54
 */
55
class HmacValidator extends AbstractValidator
56
{
57
    /**
58
     * Request method vector
59
     *
60
     * @var null|array
61
     */
62
    protected $methodVector = null;
63
    /**
64
     * Request submission times
65
     *
66
     * @var null|array
67
     */
68
    protected $submissionTimes = null;
69
    /**
70
     * Validation order position
71
     *
72
     * @var int
73
     */
74
    const POSITION = 100;
75
    /**
76
     * GET request
77
     *
78
     * @var string
79
     */
80
    const METHOD_GET = 'GET';
81
    /**
82
     * POST request
83
     *
84
     * @var string
85
     */
86
    const METHOD_POST = 'POST';
87
    /**
88
     * Minimum submission time
89
     *
90
     * @var float
91
     */
92
    const MINIMUM_SUBMISSION = 3;
93
    /**
94
     * Minimum submission time for follow-up submissions
95
     *
96
     * @var float
97
     */
98
    const MINIMUM_FOLLOWUP_SUBMISSION = 1;
99
    /**
100
     * Maximum submission time
101
     *
102
     * @var float
103
     */
104
    const MAXIMUM_SUBMISSION = 3600;
105
    /**
106
     * Block access
107
     *
108
     * @var string
109
     */
110
    const BLOCK = 'BLOCK';
111
112
    /**
113
     * Set the request method vector
114
     *
115
     * @param string $previous Previous request
116
     * @param string $current  Current request
117
     */
118 2
    public function setMethodVector(string $previous = null, string $current = null): void
119
    {
120
        // If the request method vector should be unset
121 2
        if ($previous === null) {
122
            $this->methodVector = null;
123
124
            return;
125
        }
126
127 2
        $this->methodVector = [$this->validateRequestMethod($previous), $this->validateRequestMethod($current)];
128 2
    }
129
130
    /**
131
     * Sanitize and validate a request method
132
     *
133
     * @param string $method Request method
134
     *
135
     * @return string Validated request method
136
     * @throws InvalidArgumentException If the request method is invalid
137
     */
138 2
    protected function validateRequestMethod(string $method): string
139
    {
140 2
        $method = strtoupper($method);
141 2
        if ($method !== static::METHOD_GET && $method !== static::METHOD_POST) {
142
            throw new InvalidArgumentException(
143
                sprintf(InvalidArgumentException::INVALID_REQUEST_METHOD_STR, $method),
144
                InvalidArgumentException::INVALID_REQUEST_METHOD
145
            );
146
        }
147
148 2
        return $method;
149
    }
150
151
    /**
152
     * Sanitize and set the submission times
153
     *
154
     * @param float $max              Maximum submission time
155
     * @param float $min              Minimum submission time
156
     * @param float|null $minFollowUp Minimum submission time for follow-up submissions
157
     */
158 2
    public function setSubmissionTimes(float $max = null, float $min = null, float $minFollowUp = null): void
159
    {
160
        // If the submission times should be unset
161 2
        if ($max === null) {
162
            $this->submissionTimes = null;
163
164
            return;
165
        }
166
167 2
        $max                   = min(floatval($max), static::MAXIMUM_SUBMISSION);
168 2
        $min                   = max(floatval($min), static::MINIMUM_SUBMISSION);
169 2
        $minFollowUp           = ($minFollowUp === null)
170 2
            ? $min : max(floatval($minFollowUp), static::MINIMUM_FOLLOWUP_SUBMISSION);
171 2
        $this->submissionTimes = [$min, $minFollowUp, $max];
172 2
    }
173
174
    /**
175
     * Validate a request
176
     *
177
     * @param ServerRequestInterface $request Request
178
     * @param Antibot $antibot                Antibot instance
179
     *
180
     * @return bool
181
     * @throws HmacValidationException
182
     * @throws SkippedValidationException If no Antibot data has been submitted
183
     */
184 3
    public function validate(ServerRequestInterface $request, Antibot $antibot): bool
185
    {
186 3
        $data = $antibot->getData();
187
188
        // If no Antibot data has been submitted
189 3
        if ($data === null) {
190 2
            throw new SkippedValidationException(static::class);
191
        }
192
193 3
        return empty($data['hmac']) ? false : $this->validateHmac($data['hmac'], $request, $antibot);
194
    }
195
196
    /**
197
     * Create protective form HTML
198
     *
199
     * @param ServerRequestInterface $request Request
200
     * @param Antibot $antibot                Antibot instance
201
     *
202
     * @return InputElement[] HMTL input elements
203
     */
204 4
    public function armor(ServerRequestInterface $request, Antibot $antibot): array
205
    {
206 4
        $now   = null;
207 4
        $hmac  = $this->calculateHmac($request, $antibot, $now);
208
        $armor = [
209 4
            new InputElement([
210 4
                'type'  => 'hidden',
211 4
                'name'  => $antibot->getParameterPrefix().'[hmac]',
212 4
                'value' => $hmac
213
            ])
214
        ];
215
        // Add the timestamp field
216 4
        if ($now !== null) {
217 2
            $armor[] = new InputElement([
218 2
                'type'  => 'hidden',
219 2
                'name'  => $antibot->getParameterPrefix().'[ts]',
220 2
                'value' => intval($now)
221
            ]);
222
        }
223
224 4
        return $armor;
225
    }
226
227
    /**
228
     * Decrypt and validate an HMAC
229
     *
230
     * @param string $hmac                    HMAC
231
     * @param ServerRequestInterface $request Request
232
     * @param Antibot $antibot                Antibot instance
233
     *
234
     * @return bool HMAC is valid
235
     * @throws HmacValidationException If the request timing is invalid
236
     */
237 3
    protected function validateHmac(string $hmac, ServerRequestInterface $request, Antibot $antibot): bool
238
    {
239
//        $previousMethod = null;
240 3
        $hmacParams = [$antibot->getUnique()];
241
242
        // Short-circuit blocked HMAC
243 3
        $hmacBlock   = $hmacParams;
244 3
        $hmacBlock[] = self::BLOCK;
245 3
        if (HmacFactory::createFromString(serialize($hmacBlock), $antibot->getUnique()) === $hmac) {
246
            return false;
247
        }
248
249
        // Validate the request method vector
250 3
        $this->validateRequestMethodVector($request, $hmacParams);
251
252
        // If the request timings validate
253 3
        if ($this->validateRequestTiming($hmac, $antibot, $hmacParams)) {
254 1
            return true;
255
        }
256
257
        // Else: Do a simple validation without request timings
258 2
        $currentHMAC = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());
259
260 2
        return $hmac === $currentHMAC;
261
    }
262
263
    /**
264
     * Validate the request method vector
265
     *
266
     * @param ServerRequestInterface $request Request
267
     * @param array $hmacParams               HMAC parameters
268
     *
269
     * @throws HmacValidationException If the request method order is invalid
270
     */
271 3
    protected function validateRequestMethodVector(ServerRequestInterface $request, array &$hmacParams): void
272
    {
273
        // If the request method vector should be used
274 3
        if (!empty($this->methodVector)) {
275 1
            $serverParams  = $request->getServerParams();
276 1
            $requestMethod = empty($serverParams['REQUEST_METHOD']) ? 'EMPTY' : $serverParams['REQUEST_METHOD'];
277 1
            if ($requestMethod !== $this->methodVector[1]) {
278 1
                throw new HmacValidationException(
279 1
                    HmacValidationException::INVALID_REQUEST_METHOD_ORDER_STR,
280 1
                    HmacValidationException::INVALID_REQUEST_METHOD_ORDER
281
                );
282
            }
283
284 1
            $hmacParams[] = $this->methodVector[0];
285
        }
286 3
    }
287
288
    /**
289
     * Validate the request timing
290
     *
291
     * @param string $hmac      HMAC
292
     * @param Antibot $antibot  Antibot instance
293
     * @param array $hmacParams HMAC parameters
294
     *
295
     * @return bool Request timings were enabled and validated successfully
296
     *
297
     * @throws HmacValidationException If the request timing is invalid
298
     */
299 3
    protected function validateRequestTiming(string $hmac, Antibot $antibot, array $hmacParams): bool
300
    {
301
        // If submission time checks are enabled
302 3
        if (!empty($this->submissionTimes)) {
303 1
            list($first, $min, $max) = $this->submissionTimes;
304 1
            $now       = time();
305 1
            $initial   = $now - $first;
306 1
            $data      = $antibot->getData();
307 1
            $timestamp = empty($data['ts']) ? null : $data['ts'];
308
309
            // If a timestamp has been submitted
310 1
            if ($timestamp
311 1
                && (($timestamp + $min) <= $now)
312 1
                && (($timestamp + $max) >= $now)
313 1
                && $this->probeTimedHmacAsInitialAndFollowup($hmac, $antibot, $hmacParams, $timestamp, $initial)
314
            ) {
315 1
                $antibot->getLogger()->debug("[HMAC] Validated using submitted timestamp $timestamp");
316
317 1
                return true;
318
            } else {
319
                // Run through the valid seconds range
320 1
                for ($time = $now - $min; $time >= $now - $max; --$time) {
321
                    // If the HMAC validates as initial or follow-up request
322 1
                    if ($this->probeTimedHmacAsInitialAndFollowup($hmac, $antibot, $hmacParams, $time, $initial)) {
323
                        return true;
324
                    }
325
                }
326
            }
327
328 1
            throw new HmacValidationException(
329 1
                HmacValidationException::INVALID_REQUEST_TIMING_STR,
330 1
                HmacValidationException::INVALID_REQUEST_TIMING
331
            );
332
        }
333
334 2
        return false;
335
    }
336
337
    /**
338
     * Probe a timed HMAC both as initial and follow-up request
339
     *
340
     * @param string $hmac      HMAC
341
     * @param Antibot $antibot  Antibot instance
342
     * @param array $hmacParams HMAC params
343
     * @param int $timestamp    Timestamp
344
     * @param int $initial      Initial request threshold
345
     *
346
     * @return bool HMAC is valid
347
     */
348 1
    protected function probeTimedHmacAsInitialAndFollowup(
349
        string $hmac,
350
        Antibot $antibot,
351
        array $hmacParams,
352
        int $timestamp,
353
        int $initial
354
    ): bool {
355
        // If the HMAC validates with auto-guessed mode: Succeed
356 1
        if ($this->probeTimedHmac($hmac, $antibot, $hmacParams, $timestamp, $timestamp > $initial)) {
357 1
            return true;
358
        }
359
360
        // Also test as late follow-up request
361 1
        if (($timestamp <= $initial) && $this->probeTimedHMAC($hmac, $antibot, $hmacParams, $timestamp, true)) {
362
            return true;
363
        }
364
365 1
        return false;
366
    }
367
368
    /**
369
     * Probe a timed HMAC
370
     *
371
     * @param string $hmac      HMAC
372
     * @param Antibot $antibot  Antibot instance
373
     * @param array $hmacParams HMAC params
374
     * @param int $timestamp    Timestamp
375
     * @param bool $followUp    Is a follow-up request
376
     *
377
     * @return bool HMAC is valid
378
     */
379 1
    protected function probeTimedHmac(
380
        string $hmac,
381
        Antibot $antibot,
382
        array $hmacParams,
383
        int $timestamp,
384
        bool $followUp = false
385
    ): bool {
386 1
        if ($followUp) {
387 1
            $hmacParams[] = true;
388
        }
389 1
        $hmacParams[] = $timestamp;
390 1
        $currentHMAC  = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());
391
392 1
        $antibot->getLogger()->debug("[HMAC] Probing $timestamp (".($followUp ? 'FLLW' : 'INIT')."): $currentHMAC");
393
394 1
        return $currentHMAC == $hmac;
395
    }
396
397
    /**
398
     * Calculate the HMAC
399
     *
400
     * @param ServerRequestInterface $request Request
401
     * @param Antibot $antibot                Antibot instance
402
     * @param int|null $now                   Current timestamp
403
     *
404
     * @return string HMAC
405
     */
406 4
    protected function calculateHmac(ServerRequestInterface $request, Antibot $antibot, int &$now = null): string
407
    {
408 4
        $hmacParams = [$antibot->getUnique()];
409 4
        $now        = null;
410
411
        // Invalidate the HMAC if there's a current, invalid one
412 4
        if (false) {
413
            $hmacParams[] = self::BLOCK;
414
        } else {
415 4
            $this->calculateRequestMethodVectorHmac($request, $hmacParams);
416 4
            $this->calculateRequestTimingHmac($antibot, $hmacParams, $now);
417
        }
418
419 4
        $hmac = HmacFactory::createFromString(serialize($hmacParams), $antibot->getUnique());
420
421 4
        $antibot->getLogger()->debug("[HMAC] Created HMAC $hmac", $hmacParams);
422
423 4
        return $hmac;
424
    }
425
426
    /**
427
     * Add request method vector data to the HMAC configuration
428
     *
429
     * @param ServerRequestInterface $request Request
430
     * @param array $hmacParams               HMAC parameters
431
     */
432 4
    protected function calculateRequestMethodVectorHmac(ServerRequestInterface $request, array &$hmacParams): void
433
    {
434
        // If the request method vector should be used
435 4
        if (!empty($this->methodVector)) {
436 2
            $serverParams  = $request->getServerParams();
437 2
            $requestMethod = empty($serverParams['REQUEST_METHOD']) ? '' : $serverParams['REQUEST_METHOD'];
438 2
            $hmacParams[]  = $this->validateRequestMethod($requestMethod);
439
        }
440 4
    }
441
442
    /**
443
     * Add request timing data to the HMAC configuration
444
     *
445
     * @param Antibot $antibot  Antibot instance
446
     * @param array $hmacParams HMAC parameters
447
     * @param int|null $now     Current timestamp
448
     */
449 4
    protected function calculateRequestTimingHmac(Antibot $antibot, array &$hmacParams, int &$now = null): void
450
    {
451
        // If submission time checks are enabled
452 4
        if (!empty($this->submissionTimes)) {
453 2
            if (!empty($antibot->getData())) {
454 1
                $hmacParams[] = true;
455
            }
456 2
            $hmacParams[] = $now = time();
457
        }
458 4
    }
459
}
460