├── src ├── Psr15 │ ├── SecretProviderInterface.php │ ├── KeyProviderInterface.php │ └── HmacMiddleware.php ├── Internal │ ├── HashCalculator.php │ ├── HeaderNameNormalizer.php │ ├── HeaderValidator.php │ └── RequestSerializer.php ├── Verifier.php ├── Signer.php └── Specification.php ├── LICENSE └── composer.json /src/Psr15/SecretProviderInterface.php: -------------------------------------------------------------------------------- 1 | 'content-length', 11 | 'CONTENT_TYPE' => 'content-type', 12 | ]; 13 | 14 | public static function normalize(string $name): string 15 | { 16 | if (\array_key_exists($name, self::$specialSnowflakes)) { 17 | return self::$specialSnowflakes[$name]; 18 | } 19 | 20 | $normalized = \strtolower($name); 21 | 22 | if (0 === \strpos($normalized, 'http_')) { 23 | $normalized = \str_replace('_', '-', \substr($normalized, 5)); 24 | } 25 | 26 | return $normalized; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Internal/HeaderValidator.php: -------------------------------------------------------------------------------- 1 | rules[$header] = $rule; 16 | 17 | return $this; 18 | } 19 | 20 | /** 21 | * @return array|bool 22 | */ 23 | public function conforms(MessageInterface $message) 24 | { 25 | $matches = []; 26 | 27 | foreach ($this->rules as $header => $rule) { 28 | if (0 === \preg_match($rule, $message->getHeaderLine($header), $matches[$header])) { 29 | return false; 30 | } 31 | } 32 | 33 | return $matches; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2021 Marcel Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uma/psr7-hmac", 3 | "description": "An HMAC authentication library built on top of the PSR-7 specification", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": ["http", "psr7", "hmac"], 7 | "homepage": "https://github.com/1ma/Psr7Hmac", 8 | "support": { 9 | "issues": "https://github.com/1ma/Psr7Hmac/issues", 10 | "source": "https://github.com/1ma/Psr7Hmac" 11 | }, 12 | "require": { 13 | "php": "^7.3.0 || ^7.4.0 || ^8.0.0", 14 | "psr/http-message": "^1.0", 15 | "psr/http-server-middleware": "^1.0" 16 | }, 17 | "require-dev": { 18 | "guzzlehttp/psr7": "^1.3", 19 | "kambo/httpmessage": "^0.9.0", 20 | "laminas/laminas-diactoros": "^2.5", 21 | "nyholm/psr7": "^1.0", 22 | "phpmetrics/phpmetrics": "^2.7", 23 | "phpunit/phpunit": "^9.5", 24 | "ringcentral/psr7": "^1.2", 25 | "slim/slim": "^3.4", 26 | "symfony/psr-http-message-bridge": "^2.0", 27 | "wandu/http": "^3.0", 28 | "windwalker/http": "^3.1" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "UMA\\Psr7Hmac\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "UMA\\Tests\\Psr7Hmac\\": "tests/" 38 | } 39 | }, 40 | "scripts": { 41 | "test": "php -dzend.assertions=1 -dassert.exception=1 vendor/bin/phpunit", 42 | "metrics": [ 43 | "@test", 44 | "vendor/bin/phpmetrics --junit=./build/junit.xml --report-html=./build/metrics ." 45 | ] 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Verifier.php: -------------------------------------------------------------------------------- 1 | validator = (new HeaderValidator()) 23 | ->addRule(Specification::AUTH_HEADER, Specification::AUTH_REGEXP) 24 | ->addRule(Specification::SIGN_HEADER, Specification::SIGN_REGEXP); 25 | } 26 | 27 | public function verify(RequestInterface $request, string $secret): bool 28 | { 29 | if (false === $matches = $this->validator->conforms($request)) { 30 | return false; 31 | } 32 | 33 | $clientSideSignature = $matches[Specification::AUTH_HEADER][1]; 34 | 35 | $serverSideSignature = HashCalculator::hmac( 36 | RequestSerializer::serialize($this->withoutUnsignedHeaders($request)), 37 | $secret 38 | ); 39 | 40 | return \hash_equals($serverSideSignature, $clientSideSignature); 41 | } 42 | 43 | private function withoutUnsignedHeaders(RequestInterface $request): RequestInterface 44 | { 45 | $signedHeaders = \array_filter(\explode(',', $request->getHeaderLine(Specification::SIGN_HEADER))); 46 | 47 | foreach (\array_keys($request->getHeaders()) as $headerName) { 48 | if (!\in_array(HeaderNameNormalizer::normalize($headerName), $signedHeaders, true)) { 49 | $request = $request->withoutHeader($headerName); 50 | } 51 | } 52 | 53 | return $request; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Signer.php: -------------------------------------------------------------------------------- 1 | secret = $secret; 22 | } 23 | 24 | public function sign(RequestInterface $request): RequestInterface 25 | { 26 | $serialization = RequestSerializer::serialize( 27 | $preSignedMessage = $this->withSignedHeadersHeader($request) 28 | ); 29 | 30 | return $preSignedMessage->withHeader( 31 | Specification::AUTH_HEADER, 32 | Specification::AUTH_PREFIX.HashCalculator::hmac($serialization, $this->secret) 33 | ); 34 | } 35 | 36 | private function withSignedHeadersHeader(RequestInterface $request): RequestInterface 37 | { 38 | $headers = [HeaderNameNormalizer::normalize(Specification::SIGN_HEADER)]; 39 | foreach ($request->getHeaders() as $name => $_) { 40 | $headers[] = HeaderNameNormalizer::normalize($name); 41 | } 42 | 43 | // Some of the tested RequestInterface implementations do not include 44 | // the Host header in $message->getHeaders(), so it is explicitly set when needed 45 | if (!\in_array('host', $headers, true)) { 46 | $headers[] = 'host'; 47 | } 48 | 49 | // There is no guarantee about the order of the headers returned by 50 | // $message->getHeaders(), so they are explicitly sorted in order 51 | // to produce the exact same string regardless of the underlying implementation 52 | \sort($headers); 53 | 54 | return $request->withHeader(Specification::SIGN_HEADER, \implode(',', $headers)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Internal/RequestSerializer.php: -------------------------------------------------------------------------------- 1 | getBody(); 23 | } 24 | 25 | private static function requestLine(RequestInterface $request): string 26 | { 27 | $method = $request->getMethod(); 28 | $target = self::fixTarget(\trim($request->getRequestTarget())); 29 | $protocol = 'HTTP/'.$request->getProtocolVersion(); 30 | 31 | return "$method $target $protocol".self::CRLF; 32 | } 33 | 34 | private static function headers(RequestInterface $request): string 35 | { 36 | $headers = $request->getHeaders(); 37 | 38 | unset($headers['host'], $headers['Host'], $headers['HTTP_HOST']); 39 | 40 | $headerLines = []; 41 | foreach ($headers as $name => $value) { 42 | $value = \is_array($value) ? $value : [$value]; 43 | $normalizedName = HeaderNameNormalizer::normalize($name); 44 | 45 | $headerLines[$normalizedName] = $normalizedName.': '.\implode(',', $value).self::CRLF; 46 | } 47 | 48 | \ksort($headerLines, SORT_STRING); 49 | 50 | $host = $request->getUri()->getHost(); 51 | 52 | return "host: $host".self::CRLF.\implode($headerLines).self::CRLF; 53 | } 54 | 55 | private static function fixTarget($target): string 56 | { 57 | if (!\array_key_exists('query', $parsedTarget = \parse_url($target))) { 58 | return $target; 59 | } 60 | 61 | \parse_str($parsedTarget['query'], $query); 62 | 63 | \ksort($query, SORT_STRING); 64 | 65 | return $parsedTarget['path'].'?'.\http_build_query($query); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Psr15/HmacMiddleware.php: -------------------------------------------------------------------------------- 1 | keyProvider = $keyProvider; 54 | $this->secretProvider = $secretProvider; 55 | $this->noKeyHandler = $noKeyHandler; 56 | $this->noSecretHandler = $noSecretHandler; 57 | $this->badSigHandler = $badSigHandler; 58 | $this->hmacVerifier = new Verifier(); 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 65 | { 66 | if (null === $key = $this->keyProvider->getKeyFrom($request)) { 67 | return $this->noKeyHandler->handle($request); 68 | } 69 | 70 | if (null === $secret = $this->secretProvider->getSecretFor($key)) { 71 | return $this->noSecretHandler->handle($request); 72 | } 73 | 74 | if (false === $this->hmacVerifier->verify($request, $secret)) { 75 | return $this->badSigHandler->handle($request); 76 | } 77 | 78 | return $handler->handle($request); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Specification.php: -------------------------------------------------------------------------------- 1 |