├── src ├── Exception │ ├── InvalidRequestException.php │ ├── KeyNotFoundException.php │ ├── InvalidSignatureException.php │ ├── TimestampOutOfRangeException.php │ ├── MalformedRequestException.php │ └── MalformedResponseException.php ├── KeyLoaderInterface.php ├── KeyInterface.php ├── Base64KeyLoader.php ├── ResponseSignerInterface.php ├── ResponseAuthenticatorInterface.php ├── Digest │ ├── DigestInterface.php │ └── Digest.php ├── KeyLoader.php ├── Symfony │ ├── HmacAuthenticationEntryPoint.php │ ├── HmacFactory.php │ ├── HmacToken.php │ ├── HmacResponseListener.php │ ├── HmacAuthenticationProvider.php │ └── HmacAuthenticationListener.php ├── RequestAuthenticatorInterface.php ├── Key.php ├── SignatureInterface.php ├── AuthorizationHeaderInterface.php ├── ResponseAuthenticator.php ├── ResponseSigner.php ├── RequestSignerInterface.php ├── Guzzle │ └── HmacAuthMiddleware.php ├── AuthorizationHeader.php ├── RequestSigner.php ├── RequestAuthenticator.php └── AuthorizationHeaderBuilder.php ├── .editorconfig ├── .gitignore ├── test ├── Mocks │ ├── MockKeyLoader.php │ ├── MockHmacAuthMiddleware.php │ ├── MockRequestSigner.php │ ├── MockRequestAuthenticator.php │ └── Symfony │ │ └── HmacClient.php ├── Symfony │ ├── HmacTokenTest.php │ ├── HmacAuthenticationEntryPointTest.php │ ├── HmacAuthenticationProviderTest.php │ ├── HmacResponseListenerTest.php │ └── HmacAuthenticationListenerTest.php ├── Base64KeyLoaderTest.php ├── KeyTest.php ├── DigestTest.php ├── ResponseSignerTest.php ├── ResponseAuthenticatorTest.php ├── AcquiaSpecTest.php ├── RequestSignerTest.php ├── GuzzleAuthMiddlewareTest.php ├── RequestAuthenticatorTest.php ├── acquia_spec_features.json └── AuthorizationHeaderTest.php ├── phpunit.xml.dist ├── Makefile ├── LICENSE ├── phpmd.xml ├── .travis.yml ├── composer.json ├── .github └── workflows │ └── orca.yml ├── .php-cs-fixer.dist.php └── README.md /src/Exception/InvalidRequestException.php: -------------------------------------------------------------------------------- 1 | keys[$id])) { 18 | return false; 19 | } 20 | 21 | return new Key($id, base64_encode($this->keys[$id])); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ResponseSignerInterface.php: -------------------------------------------------------------------------------- 1 | keys = $keys; 15 | } 16 | 17 | /** 18 | * {@inheritDoc} 19 | */ 20 | public function load($id) 21 | { 22 | if (!isset($this->keys[$id])) { 23 | return false; 24 | } 25 | 26 | return new Key($id, $this->keys[$id]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ResponseAuthenticatorInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | ./test 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | src/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install clean test coverage update format 2 | 3 | install: 4 | composer install --no-interaction 5 | 6 | clean: 7 | rm -rf vendor/ dist/ composer.lock .php_cs.cache .phpunit.result.cache 8 | 9 | test: install 10 | ./vendor/bin/phpunit 11 | ./vendor/bin/php-cs-fixer fix --dry-run -v 12 | ./vendor/bin/phpmd src/,test/ text ./phpmd.xml 13 | 14 | coverage: install 15 | phpdbg -qrr ./vendor/bin/phpunit --coverage-clover dist/tests.clover 16 | ./vendor/bin/php-coveralls -v --coverage_clover='./dist/tests.clover' --json_path='./dist/coveralls-upload.json' 17 | 18 | update: 19 | composer update --no-interaction 20 | 21 | format: install 22 | ./vendor/bin/php-cs-fixer fix -v 23 | -------------------------------------------------------------------------------- /src/Digest/DigestInterface.php: -------------------------------------------------------------------------------- 1 | createMock(Request::class); 21 | $key = $this->createMock(KeyInterface::class); 22 | 23 | $token = new HmacToken($request, $key); 24 | 25 | $this->assertEquals($request, $token->getRequest()); 26 | $this->assertEquals($key, $token->getCredentials()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/KeyLoader.php: -------------------------------------------------------------------------------- 1 | keys = $keys; 25 | } 26 | 27 | /** 28 | * {@inheritDoc} 29 | */ 30 | public function load($id) 31 | { 32 | if (!isset($this->keys[$id])) { 33 | return false; 34 | } 35 | 36 | return new Key($id, $this->keys[$id]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Symfony/HmacAuthenticationEntryPoint.php: -------------------------------------------------------------------------------- 1 | setStatusCode(401, $authException ? $authException->getMessage() : null); 22 | 23 | return $response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/Base64KeyLoaderTest.php: -------------------------------------------------------------------------------- 1 | $secret, 23 | ]); 24 | 25 | $this->assertEquals(base64_encode($secret), $loader->load($id)->getSecret()); 26 | } 27 | 28 | public function testLoadOnKeyIsNotFound() 29 | { 30 | $loader = new Base64KeyLoader([]); 31 | 32 | $this->assertFalse($loader->load('invalid_id')); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/RequestAuthenticatorInterface.php: -------------------------------------------------------------------------------- 1 | algorithm = $algorithm; 18 | } 19 | 20 | /** 21 | * {@inheritDoc} 22 | */ 23 | public function sign($message, $secretKey) 24 | { 25 | // The Acquia HMAC spec requires that we use MIME Base64 encoded 26 | // secrets, but PHP requires them to be decoded before signing. 27 | $digest = hash_hmac($this->algorithm, $message, base64_decode($secretKey, true), true); 28 | 29 | return base64_encode($digest); 30 | } 31 | 32 | 33 | /** 34 | * {@inheritDoc} 35 | */ 36 | public function hash($message) 37 | { 38 | return base64_encode(hash($this->algorithm, $message, true)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Key.php: -------------------------------------------------------------------------------- 1 | id = $id; 33 | $this->secret = $secret; 34 | } 35 | 36 | /** 37 | * {@inheritDoc} 38 | */ 39 | public function getId() 40 | { 41 | return $this->id; 42 | } 43 | 44 | /** 45 | * {@inheritDoc} 46 | */ 47 | public function getSecret() 48 | { 49 | return $this->secret; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/Symfony/HmacAuthenticationEntryPointTest.php: -------------------------------------------------------------------------------- 1 | createMock(Request::class); 23 | $authException = new AuthenticationException($responseMessage); 24 | 25 | $entryPoint = new HmacAuthenticationEntryPoint(); 26 | 27 | $response = $entryPoint->start($request, $authException); 28 | 29 | $this->assertEquals(401, $response->getStatusCode()); 30 | $this->assertStringContainsString($responseMessage, (string) $response); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Acquia, Inc. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/Mocks/MockHmacAuthMiddleware.php: -------------------------------------------------------------------------------- 1 | requestSigner = new MockRequestSigner($key, $realm, new Digest(), $authHeader); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | PHP Project Starter Rulset: Adopted From Jenkins for Symfony 2 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/SignatureInterface.php: -------------------------------------------------------------------------------- 1 | assertEquals($id, $key->getId()); 25 | $this->assertEquals($secret, $key->getSecret()); 26 | } 27 | 28 | public function testKeyLoaderOnLoadKeyId() 29 | { 30 | $id = 'efdde334-fe7b-11e4-a322-1697f925ec7b'; 31 | $secret = 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI='; 32 | 33 | $loader = new KeyLoader([ 34 | $id => $secret, 35 | ]); 36 | 37 | $this->assertEquals($id, $loader->load($id)->getId()); 38 | $this->assertEquals($secret, $loader->load($id)->getSecret()); 39 | } 40 | 41 | public function testKeyLoaderOnLoadInvalidKeyId() 42 | { 43 | $loader = new KeyLoader([]); 44 | 45 | $this->assertFalse($loader->load('invalid_id')); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # acquia/http-hmac-php project Travis configuration 3 | # 4 | # @see https://docs.travis-ci.com/user/customizing-the-build/ 5 | # 6 | 7 | language: php 8 | 9 | matrix: 10 | fast_finish: true 11 | include: 12 | - { php: 8.0, env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=5.3 SYMFONY_DEPRECATIONS_HELPER=weak, name: "PHP 8.0, Symfony 5.3" } 13 | - { php: 7.4, env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=5.3 SYMFONY_DEPRECATIONS_HELPER=weak, name: "PHP 7.4, Symfony 5.3" } 14 | - { php: 7.3, env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=5.3 SYMFONY_DEPRECATIONS_HELPER=weak, name: "PHP 7.3, Symfony 5.3" } 15 | - { php: 7.4, env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=4.4, name: "PHP 7.4, Symfony 4.4" } 16 | - { php: 7.3, env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=4.4, name: "PHP 7.3, Symfony 4.4" } 17 | 18 | before_install: 19 | - travis_retry composer self-update 20 | - phpenv config-rm xdebug.ini 21 | - echo "memory_limit=2G" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 22 | 23 | install: 24 | - composer require "symfony/psr-http-message-bridge:${PSR_MESSAGE_BRIDGE_VERSION}" --no-update 25 | - composer require "symfony/security-bundle:${SYMFONY_VERSION}" --no-update 26 | - travis_retry make install 27 | 28 | script: 29 | - make test 30 | 31 | jobs: 32 | include: 33 | - stage: coverage 34 | script: make coverage 35 | env: PSR_MESSAGE_BRIDGE_VERSION=2.1 SYMFONY_VERSION=5.3 36 | -------------------------------------------------------------------------------- /src/Exception/MalformedRequestException.php: -------------------------------------------------------------------------------- 1 | request = $request; 34 | } 35 | 36 | /** 37 | * Returns the request. 38 | * 39 | * @return RequestInterface 40 | */ 41 | public function getRequest() 42 | { 43 | return $this->request; 44 | } 45 | 46 | /** 47 | * Sets the request. 48 | * 49 | * @param RequestInterface $request 50 | */ 51 | public function setRequest($request) 52 | { 53 | $this->request = $request; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Exception/MalformedResponseException.php: -------------------------------------------------------------------------------- 1 | response = $response; 38 | } 39 | 40 | /** 41 | * Returns the response. 42 | * 43 | * @return ResponseInterface 44 | */ 45 | public function getResponse() 46 | { 47 | return $this->response; 48 | } 49 | 50 | /** 51 | * Sets the response. 52 | * 53 | * @param ResponseInterface $response 54 | */ 55 | public function setResponse($response) 56 | { 57 | $this->response = $response; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/Mocks/MockRequestSigner.php: -------------------------------------------------------------------------------- 1 | authHeader = $authHeader; 39 | } 40 | 41 | /** 42 | * {@inheritDoc} 43 | */ 44 | protected function buildAuthorizationHeader(RequestInterface $request, array $customHeaders = []) 45 | { 46 | return $this->authHeader; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Symfony/HmacFactory.php: -------------------------------------------------------------------------------- 1 | setDefinition($providerId, new ChildDefinition('hmac.security.authentication.provider')); 24 | 25 | $listenerId = 'security.authentication.listener.hmac.' . $id; 26 | $container->setDefinition($listenerId, new ChildDefinition('hmac.security.authentication.listener')); 27 | 28 | return [$providerId, $listenerId, $defaultEntryPoint]; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function getPosition() 35 | { 36 | return 'pre_auth'; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function getKey() 43 | { 44 | return 'hmac_auth'; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function addConfiguration(NodeDefinition $node) 51 | { 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acquia/http-hmac-php", 3 | "type": "library", 4 | "description": "An implementation of the HTTP HMAC Spec in PHP that integrates with popular libraries such as Symfony and Guzzle.", 5 | "homepage": "https://github.com/acquia/http-hmac-php", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "See contributors", 10 | "homepage": "https://github.com/acquia/http-hmac-php/graphs/contributors" 11 | } 12 | ], 13 | "support": { 14 | "issues": "https://github.com/acquia/http-hmac-php/issues" 15 | }, 16 | "require": { 17 | "php": ">=7.3.0", 18 | "psr/http-message": "^1.0 || ^2.0" 19 | }, 20 | "suggest": { 21 | "guzzlehttp/guzzle": "^6.0", 22 | "laminas/laminas-diactoros": "^1.8 || ^2.2", 23 | "symfony/security": "^4.0 || ^5.0" 24 | }, 25 | "require-dev": { 26 | "friendsofphp/php-cs-fixer": "^3.2.1", 27 | "guzzlehttp/guzzle": "^6.0", 28 | "nyholm/psr7": "^1.0", 29 | "php-coveralls/php-coveralls": "^2.2", 30 | "phpmd/phpmd": "^2.0", 31 | "phpunit/phpunit": "^8.0", 32 | "symfony/psr-http-message-bridge": "^2.0", 33 | "symfony/phpunit-bridge": "^4.0 || ^5.0", 34 | "symfony/security-bundle": "^4.0 || ^5.0" 35 | }, 36 | "replace": { 37 | "acquia/hmac-request": "self.version" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Acquia\\Hmac\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Acquia\\Hmac\\Test\\": "test/" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/Mocks/MockRequestAuthenticator.php: -------------------------------------------------------------------------------- 1 | authHeader = $authHeader; 41 | $this->timestamp = $timestamp ?: time(); 42 | } 43 | 44 | /** 45 | * {@inheritDoc} 46 | */ 47 | protected function getCurrentTimestamp() 48 | { 49 | return $this->timestamp; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Symfony/HmacToken.php: -------------------------------------------------------------------------------- 1 | request = $request; 41 | $this->key = $key; 42 | } 43 | 44 | /** 45 | * Returns the authenticated request associated with the token. 46 | * 47 | * @return \Symfony\Component\HttpFoundation\Request 48 | * The authenticated request. 49 | */ 50 | public function getRequest() 51 | { 52 | return $this->request; 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | */ 58 | public function getCredentials() 59 | { 60 | return $this->key; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/AuthorizationHeaderInterface.php: -------------------------------------------------------------------------------- 1 | authSecret = 'TXkgU2VjcmV0IEtleSBUaGF0IGlzIFZlcnkgU2VjdXJl'; 31 | $this->message = 'The quick brown fox jumps over the lazy dog.'; 32 | } 33 | 34 | /** 35 | * Ensures a message is signed correctly with a secret key. 36 | */ 37 | public function testSign() 38 | { 39 | $digest = new Digest(); 40 | 41 | $hash = 'vcOqnVc4i0YB5ILPTt92mE4zsBHC0cMHq6YpM5Gw8rI='; 42 | 43 | $this->assertEquals($hash, $digest->sign($this->message, $this->authSecret)); 44 | } 45 | 46 | /** 47 | * Ensures a message is hashed correctly. 48 | */ 49 | public function testHash() 50 | { 51 | $digest = new Digest(); 52 | 53 | $hash = '71N/JciVv6eCUmUpqbY9l6pjFWTV14nCt2VEjIY1+2w='; 54 | 55 | $this->assertEquals($hash, $digest->hash($this->message)); 56 | } 57 | 58 | /** 59 | * Ensures the message does not sign correctly if the secret contains invalid characters. 60 | */ 61 | public function testSignFailsWithMalformedSecret() 62 | { 63 | $digest = new Digest(); 64 | 65 | $invalid_secret = $this->authSecret . '%%%'; 66 | $hash = 'vcOqnVc4i0YB5ILPTt92mE4zsBHC0cMHq6YpM5Gw8rI='; 67 | 68 | $this->assertNotEquals($hash, $digest->sign($this->message, $invalid_secret)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Symfony/HmacResponseListener.php: -------------------------------------------------------------------------------- 1 | isMainRequest() : $event->isMasterRequest(); 24 | if (!$mainRequest) { 25 | return; 26 | } 27 | 28 | $request = $event->getRequest(); 29 | $response = $event->getResponse(); 30 | 31 | if ($request->attributes->has('hmac.key')) { 32 | $psr17Factory = new Psr17Factory(); 33 | $httpMessageFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); 34 | $foundationFactory = new HttpFoundationFactory(); 35 | 36 | $psr7Request = $httpMessageFactory->createRequest($request); 37 | $psr7Response = $httpMessageFactory->createResponse($response); 38 | 39 | $signer = new ResponseSigner($request->attributes->get('hmac.key'), $psr7Request); 40 | $signedResponse = $signer->signResponse($psr7Response); 41 | 42 | $event->setResponse($foundationFactory->createResponse($signedResponse)); 43 | } 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public static function getSubscribedEvents() 50 | { 51 | return [KernelEvents::RESPONSE => 'onKernelResponse']; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Symfony/HmacAuthenticationProvider.php: -------------------------------------------------------------------------------- 1 | authenticator = $authenticator; 32 | } 33 | 34 | /** 35 | * {@inheritDoc} 36 | */ 37 | public function authenticate(TokenInterface $token) 38 | { 39 | $psr17Factory = new Psr17Factory(); 40 | $httpMessageFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); 41 | $psr7Request = $httpMessageFactory->createRequest($token->getRequest()); 42 | 43 | try { 44 | $key = $this->authenticator->authenticate($psr7Request); 45 | 46 | return new HmacToken($token->getRequest(), $key); 47 | } catch (\Exception $e) { 48 | throw new AuthenticationException($e->getMessage()); 49 | } 50 | } 51 | 52 | /** 53 | * {@inheritDoc} 54 | */ 55 | public function supports(TokenInterface $token) 56 | { 57 | return $token instanceof HmacToken; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ResponseAuthenticator.php: -------------------------------------------------------------------------------- 1 | request = $request; 37 | $this->key = $key; 38 | } 39 | 40 | /** 41 | * {@inheritDoc} 42 | */ 43 | public function isAuthentic(ResponseInterface $response) 44 | { 45 | if (!$response->hasHeader('X-Server-Authorization-HMAC-SHA256')) { 46 | throw new MalformedResponseException( 47 | 'Response is missing required X-Server-Authorization-HMAC-SHA256 header.', 48 | null, 49 | 0, 50 | $response 51 | ); 52 | } 53 | 54 | $responseSigner = new ResponseSigner($this->key, $this->request); 55 | $compareResponse = $responseSigner->signResponse( 56 | $response->withoutHeader('X-Server-Authorization-HMAC-SHA256') 57 | ); 58 | 59 | $responseSignature = $response->getHeaderLine('X-Server-Authorization-HMAC-SHA256'); 60 | $compareSignature = $compareResponse->getHeaderLine('X-Server-Authorization-HMAC-SHA256'); 61 | 62 | return hash_equals($compareSignature, $responseSignature); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/orca.yml: -------------------------------------------------------------------------------- 1 | name: ORCA CI 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | env: 11 | PSR_MESSAGE_BRIDGE_VERSION: ${{ matrix.PSR_MESSAGE_BRIDGE_VERSION }} 12 | SYMFONY_VERSION: ${{ matrix.SYMFONY_VERSION }} 13 | SYMFONY_DEPRECATIONS_HELPER: ${{ matrix.SYMFONY_DEPRECATIONS_HELPER }} 14 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | GITHUB_RUN_ID: ${{ github.run_id }} 16 | GITHUB_EVENT_NAME: ${{ github.event_name }} 17 | strategy: 18 | matrix: 19 | include: 20 | - PSR_MESSAGE_BRIDGE_VERSION: "2.1" 21 | SYMFONY_VERSION: "5.3" 22 | SYMFONY_DEPRECATIONS_HELPER: "weak" 23 | php-version : "8.0" 24 | 25 | - PSR_MESSAGE_BRIDGE_VERSION: "2.1" 26 | SYMFONY_VERSION: "5.3" 27 | SYMFONY_DEPRECATIONS_HELPER: "weak" 28 | php-version: "7.4" 29 | 30 | - PSR_MESSAGE_BRIDGE_VERSION: "2.1" 31 | SYMFONY_VERSION: "5.3" 32 | SYMFONY_DEPRECATIONS_HELPER: "weak" 33 | php-version: "7.3" 34 | 35 | - PSR_MESSAGE_BRIDGE_VERSION: "2.1" 36 | SYMFONY_VERSION: "4.4" 37 | php-version: "7.4" 38 | 39 | - PSR_MESSAGE_BRIDGE_VERSION: "2.1" 40 | SYMFONY_VERSION: "4.4" 41 | php-version: "7.4" 42 | steps: 43 | - uses: actions/checkout@v2 44 | 45 | - uses: shivammathur/setup-php@v2 46 | with: 47 | php-version: ${{ matrix.php-version }} 48 | coverage: none 49 | 50 | - name: Before install 51 | run: composer self-update 52 | 53 | - name: Install 54 | run: | 55 | composer require "symfony/psr-http-message-bridge:${{matrix.PSR_MESSAGE_BRIDGE_VERSION}}" --no-update 56 | composer require "symfony/security-bundle:${{matrix.SYMFONY_VERSION}}" --no-update 57 | make install 58 | 59 | - name: Script 60 | run: make test 61 | 62 | - name: coverage 63 | if: ${{ matrix.PSR_MESSAGE_BRIDGE_VERSION == '2.1' && matrix.SYMFONY_VERSION == '5.3' && matrix.php-version != '8.0'}} 64 | run: make coverage 65 | -------------------------------------------------------------------------------- /test/ResponseSignerTest.php: -------------------------------------------------------------------------------- 1 | $timestamp, 36 | ]; 37 | 38 | $request = new Request('GET', 'http://example.com', $headers); 39 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $authKey); 40 | $authHeaderBuilder->setRealm($realm); 41 | $authHeaderBuilder->setId($authKey->getId()); 42 | $authHeaderBuilder->setNonce($nonce); 43 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 44 | 45 | $requestSigner = new MockRequestSigner($authKey, $realm, new Digest(), $authHeader); 46 | $signedRequest = $requestSigner->signRequest($request); 47 | 48 | $response = new Response(200, [], $body); 49 | 50 | $responseSigner = new ResponseSigner($authKey, $signedRequest); 51 | $signedResponse = $responseSigner->signResponse($response); 52 | 53 | $this->assertTrue($signedResponse->hasHeader('X-Server-Authorization-HMAC-SHA256')); 54 | $this->assertEquals($signature, $signedResponse->getHeaderLine('X-Server-Authorization-HMAC-SHA256')); 55 | $this->assertEquals($body, $response->getBody()->getContents()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ResponseSigner.php: -------------------------------------------------------------------------------- 1 | key = $key; 46 | $this->request = $request; 47 | $this->digest = $digest ?: new Digest(); 48 | } 49 | 50 | /** 51 | * {@inheritDoc} 52 | */ 53 | public function signResponse(ResponseInterface $response) 54 | { 55 | $authHeader = AuthorizationHeader::createFromRequest($this->request); 56 | 57 | $parts = [ 58 | $authHeader->getNonce(), 59 | $this->request->getHeaderLine('X-Authorization-Timestamp'), 60 | (string) $response->getBody(), 61 | ]; 62 | 63 | $response->getBody()->rewind(); 64 | 65 | $message = implode("\n", $parts); 66 | 67 | $signature = $this->digest->sign($message, $this->key->getSecret()); 68 | 69 | /** @var \Psr\Http\Message\ResponseInterface $response */ 70 | $response = $response->withHeader('X-Server-Authorization-HMAC-SHA256', $signature); 71 | 72 | return $response; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/RequestSignerInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * Dariusz Rumiński 10 | * 11 | * This source file is subject to the MIT license that is bundled 12 | * with this source code in the file LICENSE. 13 | */ 14 | 15 | $header = <<<'EOF' 16 | This file is part of PHP CS Fixer. 17 | 18 | (c) Fabien Potencier 19 | Dariusz Rumiński 20 | 21 | This source file is subject to the MIT license that is bundled 22 | with this source code in the file LICENSE. 23 | EOF; 24 | 25 | $finder = PhpCsFixer\Finder::create() 26 | ->ignoreVCSIgnored(true) 27 | ->exclude('tests/Fixtures') 28 | ->in(__DIR__) 29 | ->append([ 30 | __DIR__ . '/dev-tools/doc.php', 31 | // __DIR__.'/php-cs-fixer', disabled, as we want to be able to run bootstrap file even on lower PHP version, to show nice message 32 | __FILE__, 33 | ]) 34 | ; 35 | 36 | $config = new PhpCsFixer\Config(); 37 | $config 38 | ->setRiskyAllowed(true) 39 | ->setRules([ 40 | '@PSR2' => true, 41 | 'array_indentation' => true, 42 | 'array_syntax' => [ 43 | 'syntax' => 'short', 44 | ], 45 | 'concat_space' => [ 46 | 'spacing' => 'one', 47 | ], 48 | 'method_chaining_indentation' => true, 49 | 'phpdoc_indent' => true, 50 | 'no_unused_imports' => true, 51 | 'no_blank_lines_after_class_opening' => true, 52 | 'no_blank_lines_after_phpdoc' => true, 53 | 'no_whitespace_before_comma_in_array' => true, 54 | 'no_whitespace_in_blank_line' => true, 55 | 'php_unit_namespaced' => true, 56 | 'psr_autoloading' => true, 57 | 'short_scalar_cast' => true, 58 | 'trailing_comma_in_multiline' => true, 59 | ]) 60 | ->setFinder($finder) 61 | ; 62 | 63 | // special handling of fabbot.io service if it's using too old PHP CS Fixer version 64 | if (false !== getenv('FABBOT_IO')) { 65 | try { 66 | PhpCsFixer\FixerFactory::create() 67 | ->registerBuiltInFixers() 68 | ->registerCustomFixers($config->getCustomFixers()) 69 | ->useRuleSet(new PhpCsFixer\RuleSet($config->getRules())) 70 | ; 71 | } catch (PhpCsFixer\ConfigurationException\InvalidConfigurationException $e) { 72 | $config->setRules([]); 73 | } catch (UnexpectedValueException $e) { 74 | $config->setRules([]); 75 | } catch (InvalidArgumentException $e) { 76 | $config->setRules([]); 77 | } 78 | } 79 | 80 | return $config; 81 | -------------------------------------------------------------------------------- /test/Mocks/Symfony/HmacClient.php: -------------------------------------------------------------------------------- 1 | key = $key; 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * Sign the request with HTTP HMAC and authenticate the response signature. 42 | * 43 | * @param \Symfony\Component\HttpFoundation\Request $request 44 | * The Symfony request. 45 | * 46 | * @return \Symfony\Component\HttpFoundation\Response 47 | * An Symfony response indicating the result of making the signed request. 48 | */ 49 | protected function doRequest($request) 50 | { 51 | if (!$this->key instanceof KeyInterface) { 52 | return new Response('The HTTP HMAC key has not been provided.', 400); 53 | } 54 | 55 | $psr17Factory = new Psr17Factory(); 56 | $psr7Factory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); 57 | $httpFoundationFactory = new HttpFoundationFactory(); 58 | 59 | $psrRequest = $psr7Factory->createRequest($request); 60 | 61 | $hmacSigner = new RequestSigner($this->key); 62 | $signedRequest = $hmacSigner->signRequest($psrRequest); 63 | $symfonyRequest = $httpFoundationFactory->createRequest($signedRequest); 64 | 65 | $response = parent::doRequest($symfonyRequest); 66 | $psrResponse = $psr7Factory->createResponse($response); 67 | 68 | $authenticator = new ResponseAuthenticator($signedRequest, $this->key); 69 | 70 | if (!$authenticator->isAuthentic($psrResponse)) { 71 | return new Response('The response cannot be authenticated.', 400); 72 | } 73 | 74 | return $response; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Guzzle/HmacAuthMiddleware.php: -------------------------------------------------------------------------------- 1 | key = $key; 38 | $this->customHeaders = $customHeaders; 39 | $this->requestSigner = new RequestSigner($key, $realm); 40 | } 41 | 42 | /** 43 | * Called when the middleware is handled. 44 | * 45 | * @param callable $handler 46 | * 47 | * @return \Closure 48 | */ 49 | public function __invoke(callable $handler) 50 | { 51 | return function ($request, array $options) use ($handler) { 52 | $request = $this->signRequest($request); 53 | 54 | $promise = function (ResponseInterface $response) use ($request) { 55 | if ($response->getStatusCode() != 401) { 56 | $authenticator = new ResponseAuthenticator($request, $this->key); 57 | 58 | if (!$authenticator->isAuthentic($response)) { 59 | throw new MalformedResponseException( 60 | 'Could not verify the authenticity of the response.', 61 | null, 62 | 0, 63 | $response 64 | ); 65 | } 66 | } 67 | 68 | return $response; 69 | }; 70 | 71 | return $handler($request, $options)->then($promise); 72 | }; 73 | } 74 | 75 | /** 76 | * Signs the request with the appropriate headers. 77 | * 78 | * @param \Psr\Http\Message\RequestInterface $request 79 | * 80 | * @return \Psr\Http\Message\RequestInterface 81 | */ 82 | public function signRequest(RequestInterface $request) 83 | { 84 | return $this->requestSigner->signRequest($request, $this->customHeaders); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Symfony/HmacAuthenticationListener.php: -------------------------------------------------------------------------------- 1 | tokenStorage = $tokenStorage; 47 | $this->authManager = $authManager; 48 | $this->entryPoint = $entryPoint; 49 | } 50 | 51 | /** 52 | * Handles the incoming request. 53 | * 54 | * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event 55 | * The event corresponding to the request. 56 | */ 57 | public function __invoke(RequestEvent $event) 58 | { 59 | $request = $event->getRequest(); 60 | 61 | // Requests require an Authorization header. 62 | if (!$request->headers->has('Authorization')) { 63 | $event->setResponse($this->entryPoint->start($request)); 64 | return; 65 | } 66 | 67 | $token = new HmacToken($request); 68 | 69 | try { 70 | $authToken = $this->authManager->authenticate($token); 71 | $this->tokenStorage->setToken($authToken); 72 | $request->attributes->set('hmac.key', $authToken->getCredentials()); 73 | } catch (AuthenticationException $e) { 74 | $event->setResponse($this->entryPoint->start($request, $e)); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/ResponseAuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | authKey = new Key($authId, $authSecret); 35 | } 36 | 37 | /** 38 | * Ensures a response can be authenticated. 39 | */ 40 | public function testIsAuthentic() 41 | { 42 | $realm = 'Pipet service'; 43 | $nonce = 'd1954337-5319-4821-8427-115542e08d10'; 44 | $timestamp = 1432075982; 45 | $signature = 'LusIUHmqt9NOALrQ4N4MtXZEFE03MjcDjziK+vVqhvQ='; 46 | 47 | $requestHeaders = [ 48 | 'X-Authorization-Timestamp' => $timestamp, 49 | ]; 50 | 51 | $request = new Request('GET', 'http://example.com', $requestHeaders); 52 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $this->authKey); 53 | $authHeaderBuilder->setRealm($realm); 54 | $authHeaderBuilder->setId($this->authKey->getId()); 55 | $authHeaderBuilder->setNonce($nonce); 56 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 57 | 58 | $requestSigner = new MockRequestSigner($this->authKey, $realm, new Digest(), $authHeader); 59 | $signedRequest = $requestSigner->signRequest($request); 60 | 61 | $responseHeaders = [ 62 | 'X-Server-Authorization-HMAC-SHA256' => $signature, 63 | ]; 64 | 65 | $response = new Response(200, $responseHeaders); 66 | 67 | $authenticator = new ResponseAuthenticator($signedRequest, $this->authKey); 68 | 69 | $this->assertTrue($authenticator->isAuthentic($response)); 70 | } 71 | 72 | /** 73 | * Ensures an exception is thrown if response is missing a X-Server-Authorization-HMAC-SHA256 header. 74 | */ 75 | public function testMissingServerAuthorizationHeader() 76 | { 77 | $request = new Request('GET', 'http://example.com'); 78 | $response = new Response(); 79 | 80 | $authenticator = new ResponseAuthenticator($request, $this->authKey); 81 | 82 | $this->expectException(MalformedResponseException::class); 83 | $this->expectExceptionMessage('Response is missing required X-Server-Authorization-HMAC-SHA256 header.'); 84 | 85 | try { 86 | $authenticator->isAuthentic($response); 87 | } catch (MalformedResponseException $e) { 88 | $this->assertSame($response, $e->getResponse()); 89 | throw $e; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/Symfony/HmacAuthenticationProviderTest.php: -------------------------------------------------------------------------------- 1 | createMock(RequestAuthenticatorInterface::class); 29 | 30 | $request = Request::create('http://example.com'); 31 | $key = new Key($authId, $authSecret); 32 | 33 | $authenticator->expects($this->any()) 34 | ->method('authenticate') 35 | ->will($this->returnValue($key)); 36 | 37 | $token = new HmacToken($request); 38 | $provider = new HmacAuthenticationProvider($authenticator); 39 | 40 | $response = $provider->authenticate($token); 41 | 42 | $this->assertInstanceOf(HmacToken::class, $response); 43 | $this->assertInstanceOf(KeyInterface::class, $response->getCredentials()); 44 | $this->assertEquals($authId, $response->getCredentials()->getId()); 45 | $this->assertEquals($authSecret, $response->getCredentials()->getSecret()); 46 | $this->assertInstanceOf(Request::class, $response->getRequest()); 47 | } 48 | 49 | /** 50 | * Ensures the authentication provider throws an exception if auth fails. 51 | */ 52 | public function testAuthenticationFailed() 53 | { 54 | $authenticator = $this->createMock(RequestAuthenticatorInterface::class); 55 | 56 | $authenticator->expects($this->any()) 57 | ->method('authenticate') 58 | ->will($this->throwException(new \Exception('Authentication failed.'))); 59 | 60 | $this->expectException(AuthenticationException::class); 61 | $this->expectExceptionMessage('Authentication failed.'); 62 | 63 | $request = Request::create('http://example.com'); 64 | $token = new HmacToken($request); 65 | $provider = new HmacAuthenticationProvider($authenticator); 66 | 67 | $provider->authenticate($token); 68 | } 69 | 70 | /** 71 | * Ensures the authentication provider only supports HMAC tokens. 72 | */ 73 | public function testSupportsHmacTokens() 74 | { 75 | $request = $this->createMock(Request::class); 76 | $authenticator = $this->createMock(RequestAuthenticatorInterface::class); 77 | 78 | $provider = new HmacAuthenticationProvider($authenticator); 79 | $hmacToken = new HmacToken($request); 80 | $anonToken = new AnonymousToken('foo', 'foo'); 81 | 82 | $this->assertTrue($provider->supports($hmacToken)); 83 | $this->assertFalse($provider->supports($anonToken)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/Symfony/HmacResponseListenerTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey(KernelEvents::RESPONSE, HmacResponseListener::getSubscribedEvents()); 25 | } 26 | 27 | /** 28 | * Ensures the response listener only responds to the main request. 29 | */ 30 | public function testSubRequestsAreIgnored() 31 | { 32 | $kernel = $this->createMock(HttpKernelInterface::class); 33 | $request = $this->createMock(Request::class); 34 | $response = $this->createMock(Response::class); 35 | 36 | $event = new ResponseEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, $response); 37 | $listener = new HmacResponseListener(); 38 | 39 | $listener->onKernelResponse($event); 40 | 41 | $this->assertSame($response, $event->getResponse()); 42 | } 43 | 44 | /** 45 | * Ensures the response listener only responds to HMAC-tagged requests. 46 | */ 47 | public function testNonHmacRequestsAreIgnored() 48 | { 49 | $kernel = $this->createMock(HttpKernelInterface::class); 50 | $response = $this->createMock(Response::class); 51 | 52 | $request = new Request(); 53 | $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, $response); 54 | $listener = new HmacResponseListener(); 55 | 56 | $listener->onKernelResponse($event); 57 | 58 | $this->assertSame($response, $event->getResponse()); 59 | } 60 | 61 | /** 62 | * Ensures the response listener signs responses correctly. 63 | */ 64 | public function testHmacResponsesAreSigned() 65 | { 66 | $kernel = $this->createMock(HttpKernelInterface::class); 67 | 68 | $authId = 'efdde334-fe7b-11e4-a322-1697f925ec7b'; 69 | $authSecret = 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI='; 70 | $timestamp = 1432075982; 71 | $authHeader = 'acquia-http-hmac realm="Pipet%20service",id="efdde334-fe7b-11e4-a322-1697f925ec7b",nonce="d1954337-5319-4821-8427-115542e08d10",version="2.0",headers="",signature="Ficfxef2w69S/HoCM8THKWiN/gu2TMMz1skYBc5KPjA="'; 72 | $signature = 'LusIUHmqt9NOALrQ4N4MtXZEFE03MjcDjziK+vVqhvQ='; 73 | 74 | $request = Request::create('http://example.com'); 75 | $request->headers->set('X-Authorization-Timestamp', $timestamp); 76 | $request->headers->set('Authorization', $authHeader); 77 | $request->attributes->set('hmac.key', new Key($authId, $authSecret)); 78 | 79 | $response = new Response(); 80 | $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, $response); 81 | $listener = new HmacResponseListener(); 82 | 83 | $listener->onKernelResponse($event); 84 | 85 | $signedResponse = $event->getResponse(); 86 | 87 | $this->assertTrue($signedResponse->headers->has('X-Server-Authorization-HMAC-SHA256')); 88 | $this->assertEquals($signature, $signedResponse->headers->get('X-Server-Authorization-HMAC-SHA256')); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/AuthorizationHeader.php: -------------------------------------------------------------------------------- 1 | realm = $realm; 36 | $this->id = $id; 37 | $this->nonce = $nonce; 38 | $this->version = $version; 39 | $this->headers = $headers; 40 | $this->signature = $signature; 41 | } 42 | 43 | /** 44 | * {@inheritDoc} 45 | */ 46 | public static function createFromRequest(RequestInterface $request) 47 | { 48 | if (!$request->hasHeader('Authorization')) { 49 | throw new MalformedRequestException('Authorization header is required.', null, 0, $request); 50 | } 51 | 52 | $header = $request->getHeaderLine('Authorization'); 53 | 54 | $id_match = preg_match('/.*id="(.*?)"/', $header, $id_matches); 55 | $realm_match = preg_match('/.*realm="(.*?)"/', $header, $realm_matches); 56 | $nonce_match = preg_match('/.*nonce="(.*?)"/', $header, $nonce_matches); 57 | $version_match = preg_match('/.*version="(.*?)"/', $header, $version_matches); 58 | $signature_match = preg_match('/.*signature="(.*?)"/', $header, $signature_matches); 59 | $headers_match = preg_match('/.*headers="(.*?)"/', $header, $headers_matches); 60 | 61 | if (!$id_match || !$realm_match || !$nonce_match || !$version_match || !$signature_match) { 62 | throw new MalformedRequestException( 63 | 'Authorization header requires a realm, id, version, nonce and a signature.', 64 | null, 65 | 0, 66 | $request 67 | ); 68 | } 69 | 70 | $customHeaders = !empty($headers_matches[1]) ? explode('%3B', $headers_matches[1]) : []; 71 | 72 | return new static( 73 | rawurldecode($realm_matches[1]), 74 | $id_matches[1], 75 | $nonce_matches[1], 76 | $version_matches[1], 77 | $customHeaders, 78 | $signature_matches[1] 79 | ); 80 | } 81 | 82 | /** 83 | * {@inheritDoc} 84 | */ 85 | public function getRealm() 86 | { 87 | return $this->realm; 88 | } 89 | 90 | /** 91 | * {@inheritDoc} 92 | */ 93 | public function getId() 94 | { 95 | return $this->id; 96 | } 97 | 98 | /** 99 | * {@inheritDoc} 100 | */ 101 | public function getNonce() 102 | { 103 | return $this->nonce; 104 | } 105 | 106 | /** 107 | * {@inheritDoc} 108 | */ 109 | public function getVersion() 110 | { 111 | return $this->version; 112 | } 113 | 114 | /** 115 | * {@inheritDoc} 116 | */ 117 | public function getCustomHeaders() 118 | { 119 | return $this->headers; 120 | } 121 | 122 | /** 123 | * {@inheritDoc} 124 | */ 125 | public function getSignature() 126 | { 127 | return $this->signature; 128 | } 129 | 130 | /** 131 | * {@inheritDoc} 132 | */ 133 | public function __toString() 134 | { 135 | return 'acquia-http-hmac realm="' . rawurlencode($this->realm) . '",' 136 | . 'id="' . $this->id . '",' 137 | . 'nonce="' . $this->nonce . '",' 138 | . 'version="' . $this->version . '",' 139 | . 'headers="' . implode('%3B', $this->headers) . '",' 140 | . 'signature="' . $this->signature . '"'; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/RequestSigner.php: -------------------------------------------------------------------------------- 1 | key = $key; 46 | $this->realm = $realm; 47 | $this->digest = $digest ?: new Digest(); 48 | } 49 | 50 | /** 51 | * {@inheritDoc} 52 | */ 53 | public function signRequest(RequestInterface $request, array $customHeaders = []) 54 | { 55 | $request = $this->getTimestampedRequest($request); 56 | $request = $this->getContentHashedRequest($request); 57 | $request = $this->getAuthorizedRequest($request, $customHeaders); 58 | 59 | return $request; 60 | } 61 | 62 | /** 63 | * {@inheritDoc} 64 | */ 65 | public function getTimestampedRequest(RequestInterface $request, \DateTime $date = null) 66 | { 67 | if ($request->hasHeader('X-Authorization-Timestamp')) { 68 | return clone $request; 69 | } 70 | 71 | $date = $date ?: new \DateTime('now', new \DateTimeZone('UTC')); 72 | 73 | /** @var RequestInterface $request */ 74 | $request = $request->withHeader('X-Authorization-Timestamp', (string) $date->getTimestamp()); 75 | 76 | return $request; 77 | } 78 | 79 | /** 80 | * {@inheritDoc} 81 | */ 82 | public function getContentHashedRequest(RequestInterface $request) 83 | { 84 | $body = (string) $request->getBody(); 85 | 86 | if (!strlen($body)) { 87 | return clone $request; 88 | } 89 | 90 | $hashedBody = $this->digest->hash((string) $body); 91 | 92 | /** @var RequestInterface $request */ 93 | $request = $request->withHeader('X-Authorization-Content-SHA256', $hashedBody); 94 | 95 | return $request; 96 | } 97 | 98 | /** 99 | * {@inheritDoc} 100 | */ 101 | public function getAuthorizedRequest(RequestInterface $request, array $customHeaders = []) 102 | { 103 | if ($request->hasHeader('Authorization')) { 104 | $authHeader = AuthorizationHeader::createFromRequest($request); 105 | } else { 106 | $authHeader = $this->buildAuthorizationHeader($request, $customHeaders); 107 | } 108 | 109 | /** @var RequestInterface $request */ 110 | $request = $request->withHeader('Authorization', (string) $authHeader); 111 | 112 | return $request; 113 | } 114 | 115 | /** 116 | * Builds an AuthorizationHeader object. 117 | * 118 | * @param \Psr\Http\Message\RequestInterface $request 119 | * The request being signed. 120 | * @param string[] $customHeaders 121 | * A list of custom header names. The values of the headers will be 122 | * extracted from the request. 123 | * 124 | * @return \Acquia\Hmac\AuthorizationHeader 125 | * The compiled authorizatio header object. 126 | */ 127 | protected function buildAuthorizationHeader(RequestInterface $request, array $customHeaders = []) 128 | { 129 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $this->key, $this->digest); 130 | $authHeaderBuilder->setRealm($this->realm); 131 | $authHeaderBuilder->setId($this->key->getId()); 132 | $authHeaderBuilder->setCustomHeaders($customHeaders); 133 | 134 | return $authHeaderBuilder->getAuthorizationHeader(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /test/AcquiaSpecTest.php: -------------------------------------------------------------------------------- 1 | keys = [ 30 | 'efdde334-fe7b-11e4-a322-1697f925ec7b' => 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI=', 31 | '615d6517-1cea-4aa3-b48e-96d83c16c4dd' => 'TXkgU2VjcmV0IEtleSBUaGF0IGlzIFZlcnkgU2VjdXJl', 32 | ]; 33 | } 34 | 35 | /** 36 | * Get the shared test fixtures. 37 | */ 38 | public function specFixtureProvider() 39 | { 40 | $fixtures_json = file_get_contents(realpath(__DIR__ . "/acquia_spec_features.json")); 41 | $fixtures = json_decode($fixtures_json, true); 42 | return $fixtures['fixtures']['2.0']; 43 | } 44 | 45 | /** 46 | * @dataProvider specFixtureProvider 47 | */ 48 | public function testSpec($input, $expectations) 49 | { 50 | $key = new Key($input['id'], $input['secret']); 51 | $digest = new Digest(); 52 | 53 | $headers = [ 54 | 'X-Authorization-Timestamp' => $input['timestamp'], 55 | 'Content-Type' => $input['content_type'], 56 | ]; 57 | foreach ($input['headers'] as $header => $value) { 58 | $headers[$header] = $value; 59 | } 60 | 61 | $body = !empty($input['content_body']) ? $input['content_body'] : null; 62 | 63 | $request = new Request($input['method'], $input['url'], $headers, $body); 64 | 65 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $key); 66 | $authHeaderBuilder->setRealm($input['realm']); 67 | $authHeaderBuilder->setId($input['id']); 68 | $authHeaderBuilder->setNonce($input['nonce']); 69 | $authHeaderBuilder->setVersion('2.0'); 70 | $authHeaderBuilder->setCustomHeaders($input['signed_headers']); 71 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 72 | 73 | $requestSigner = new MockRequestSigner($key, $input['realm'], $digest, $authHeader); 74 | 75 | $signedRequest = $requestSigner->signRequest($request, $input['signed_headers']); 76 | 77 | $signedAuthHeader = $signedRequest->getHeaderLine('Authorization'); 78 | 79 | $this->assertStringContainsString('id="' . $input['id'] . '"', $signedAuthHeader); 80 | $this->assertStringContainsString('nonce="' . $input['nonce'] . '"', $signedAuthHeader); 81 | $this->assertStringContainsString('realm="' . rawurlencode($input['realm']) . '"', $signedAuthHeader); 82 | $this->assertStringContainsString('signature="' . $expectations['message_signature'] . '"', $signedAuthHeader); 83 | $this->assertStringContainsString('version="2.0"', $signedAuthHeader); 84 | 85 | // Prove that the digest generates the correct signature. 86 | $signedMessage = $digest->sign($expectations['signable_message'], $input['secret']); 87 | $this->assertEquals($expectations['message_signature'], $signedMessage); 88 | 89 | // Prove that the authenticator can authenticate the request. 90 | $keyLoader = new MockKeyLoader([ 91 | $input['id'] => $input['secret'], 92 | ] + $this->keys); 93 | $authenticator = new MockRequestAuthenticator($keyLoader, null, $input['timestamp']); 94 | $compareKey = $authenticator->authenticate($signedRequest); 95 | 96 | $this->assertEquals($compareKey->getId(), $input['id']); 97 | 98 | // Prove that the response signer generates the correct signature. 99 | $response = new Response(200, [], $expectations['response_body']); 100 | $responseSigner = new ResponseSigner($key, $signedRequest); 101 | 102 | $response = $responseSigner->signResponse($response); 103 | 104 | $this->assertTrue($response->hasHeader('X-Server-Authorization-HMAC-SHA256')); 105 | $this->assertEquals($expectations['response_signature'], $response->getHeaderLine('X-Server-Authorization-HMAC-SHA256')); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/RequestAuthenticator.php: -------------------------------------------------------------------------------- 1 | keyLoader = $keyLoader; 32 | $this->expiry = '+15 min'; 33 | } 34 | 35 | /** 36 | * {@inheritDoc} 37 | */ 38 | public function authenticate(RequestInterface $request) 39 | { 40 | $authHeader = AuthorizationHeader::createFromRequest($request); 41 | $signature = $authHeader->getSignature(); 42 | 43 | // Check whether the timestamp is valid. 44 | $comparison = $this->compareTimestamp($request, $this->expiry); 45 | 46 | if (-1 == $comparison) { 47 | throw new TimestampOutOfRangeException('Request is too old'); 48 | } elseif (1 == $comparison) { 49 | throw new TimestampOutOfRangeException('Request is too far in the future'); 50 | } 51 | 52 | // Load the API Key and sign the request. 53 | if (!$key = $this->keyLoader->load($authHeader->getId())) { 54 | throw new KeyNotFoundException('API key not found'); 55 | } 56 | 57 | // Generate the signature from the passed authorization header. 58 | // If it matches the request signature, the request is authenticated. 59 | $compareRequest = $request->withoutHeader('Authorization'); 60 | 61 | 62 | $authHeaderBuilder = new AuthorizationHeaderBuilder($compareRequest, $key); 63 | $authHeaderBuilder->setRealm($authHeader->getRealm()); 64 | $authHeaderBuilder->setId($authHeader->getId()); 65 | $authHeaderBuilder->setNonce($authHeader->getNonce()); 66 | $authHeaderBuilder->setVersion($authHeader->getVersion()); 67 | $authHeaderBuilder->setCustomHeaders($authHeader->getCustomHeaders()); 68 | 69 | $compareAuthHeader = $authHeaderBuilder->getAuthorizationHeader(); 70 | $compareSignature = $compareAuthHeader->getSignature(); 71 | 72 | 73 | if (!hash_equals($compareSignature, $signature)) { 74 | throw new InvalidSignatureException('Signature not valid'); 75 | } 76 | 77 | return $key; 78 | } 79 | 80 | /** 81 | * Retrieves the current timestamp. 82 | * 83 | * This is provided as a method to allow mocking during unit tests. 84 | * 85 | * @return int 86 | * The current timestamp. 87 | */ 88 | protected function getCurrentTimestamp() 89 | { 90 | return time(); 91 | } 92 | 93 | 94 | /** 95 | * {@inheritDoc} 96 | * 97 | * @throws \InvalidArgumentException 98 | */ 99 | protected function compareTimestamp(RequestInterface $request, $expiry) 100 | { 101 | if (!$request->hasHeader('X-Authorization-Timestamp')) { 102 | throw new MalformedRequestException('Request is missing X-Authorization-Timestamp.', null, 0, $request); 103 | } 104 | 105 | $timestamp = (int) $request->getHeaderLine('X-Authorization-Timestamp'); 106 | $current = $this->getCurrentTimestamp(); 107 | 108 | // Is the request too old? 109 | $lowerLimit = $this->getExpiry($expiry, $timestamp); 110 | if ($current > $lowerLimit) { 111 | return -1; 112 | } 113 | 114 | // Is the request too far in the future? 115 | $upperLimit = $this->getExpiry($expiry, $current); 116 | if ($timestamp > $upperLimit) { 117 | return 1; 118 | } 119 | 120 | // Timestamp is within the expected range. 121 | return 0; 122 | } 123 | 124 | /** 125 | * Retrieves the request expiry as a timestamp. 126 | * 127 | * @param int|string $expiry 128 | * The passed expiry. 129 | * @param int $relativeTimestamp 130 | * The timestamp from which to base the expiry. 131 | * 132 | * @return int 133 | * The expiry as a timestamp. 134 | * 135 | */ 136 | protected function getExpiry($expiry, $relativeTimestamp) 137 | { 138 | if (!is_int($expiry)) { 139 | $expiry = strtotime($expiry, $relativeTimestamp); 140 | } 141 | 142 | return $expiry; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/Symfony/HmacAuthenticationListenerTest.php: -------------------------------------------------------------------------------- 1 | createMock(HttpKernelInterface::class); 30 | $storage = $this->createMock(TokenStorageInterface::class); 31 | $manager = $this->createMock(AuthenticationManagerInterface::class); 32 | $entry = $this->createMock(AuthenticationEntryPointInterface::class, ['start']); 33 | 34 | $entryResponse = new Response('Authentication failed', 401); 35 | $entry->expects($this->any()) 36 | ->method('start') 37 | ->will($this->returnValue($entryResponse)); 38 | 39 | $request = new Request(); 40 | $event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST); 41 | $listener = new HmacAuthenticationListener($storage, $manager, $entry); 42 | 43 | $listener($event); 44 | 45 | $this->assertTrue($event->hasResponse()); 46 | $this->assertEquals($entryResponse->getStatusCode(), $event->getResponse()->getStatusCode()); 47 | $this->assertEquals($entryResponse->getContent(), $event->getResponse()->getContent()); 48 | } 49 | 50 | /** 51 | * Ensures a request receives the auth key if authenticated properly. 52 | */ 53 | public function testAuthentication() 54 | { 55 | $authId = 'efdde334-fe7b-11e4-a322-1697f925ec7b'; 56 | $authSecret = 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI='; 57 | 58 | $kernel = $this->createMock(HttpKernelInterface::class); 59 | $storage = $this->createMock(TokenStorageInterface::class); 60 | $manager = $this->createMock(AuthenticationManagerInterface::class); 61 | $entry = $this->createMock(AuthenticationEntryPointInterface::class); 62 | 63 | $request = new Request(); 64 | $response = new Response(); 65 | $authKey = new Key($authId, $authSecret); 66 | $authToken = new HmacToken($request, $authKey); 67 | 68 | $request->headers->set('Authorization', 'foo'); 69 | 70 | $manager->expects($this->any()) 71 | ->method('authenticate') 72 | ->will($this->returnValue($authToken)); 73 | 74 | $event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST); 75 | $listener = new HmacAuthenticationListener($storage, $manager, $entry); 76 | 77 | $event->setResponse($response); 78 | $listener($event); 79 | 80 | $handledRequest = $event->getRequest(); 81 | 82 | $this->assertTrue($handledRequest->attributes->has('hmac.key')); 83 | 84 | $key = $handledRequest->attributes->get('hmac.key'); 85 | 86 | $this->assertInstanceOf(KeyInterface::class, $key); 87 | $this->assertEquals($authId, $key->getId()); 88 | $this->assertEquals($authSecret, $key->getSecret()); 89 | } 90 | 91 | /** 92 | * Ensures the response is correct if the request fails to authenticate. 93 | */ 94 | public function testFailedAuthentication() 95 | { 96 | $kernel = $this->createMock(HttpKernelInterface::class); 97 | $storage = $this->createMock(TokenStorageInterface::class); 98 | $manager = $this->createMock(AuthenticationManagerInterface::class); 99 | $entry = $this->createMock(AuthenticationEntryPointInterface::class); 100 | 101 | $request = new Request(); 102 | $entryResponse = new Response('Authentication failed', 401); 103 | 104 | $request->headers->set('Authorization', 'foo'); 105 | 106 | $manager->expects($this->any()) 107 | ->method('authenticate') 108 | ->will($this->throwException(new AuthenticationException('Authentication failed'))); 109 | 110 | $entry->expects($this->any()) 111 | ->method('start') 112 | ->will($this->returnValue($entryResponse)); 113 | 114 | $event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST); 115 | $listener = new HmacAuthenticationListener($storage, $manager, $entry); 116 | 117 | $listener($event); 118 | 119 | $this->assertTrue($event->hasResponse()); 120 | $this->assertEquals($entryResponse->getStatusCode(), $event->getResponse()->getStatusCode()); 121 | $this->assertEquals($entryResponse->getContent(), $event->getResponse()->getContent()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test/RequestSignerTest.php: -------------------------------------------------------------------------------- 1 | authKey = new Key($authId, $authSecret); 45 | $this->realm = 'Pipet service'; 46 | $this->timestamp = 1432075982; 47 | } 48 | 49 | /** 50 | * Ensures the correct headers are generated when signing a request. 51 | */ 52 | public function testSignRequest() 53 | { 54 | $headers = [ 55 | 'Content-Type' => 'text/plain', 56 | 'X-Authorization-Timestamp' => $this->timestamp, 57 | ]; 58 | 59 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10', $headers); 60 | 61 | $digest = new Digest(); 62 | 63 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $this->authKey, $digest); 64 | $authHeaderBuilder->setRealm($this->realm); 65 | $authHeaderBuilder->setId($this->authKey->getId()); 66 | $authHeaderBuilder->setNonce('d1954337-5319-4821-8427-115542e08d10'); 67 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 68 | 69 | $signer = new MockRequestSigner($this->authKey, $this->realm, $digest, $authHeader); 70 | 71 | $signedRequest = $signer->signRequest($request); 72 | 73 | $this->assertFalse($signedRequest->hasHeader('X-Authorization-Content-SHA256')); 74 | $this->assertTrue($signedRequest->hasHeader('X-Authorization-Timestamp')); 75 | $this->assertEquals($this->timestamp, $signedRequest->getHeaderLine('X-Authorization-Timestamp')); 76 | $this->assertTrue($signedRequest->hasHeader('Authorization')); 77 | $this->assertStringContainsString('signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', $signedRequest->getHeaderLine('Authorization')); 78 | 79 | // Ensure that we can get the AuthorizationHeader back from the request. 80 | $signedAuthRequest = $signer->getAuthorizedRequest($signedRequest); 81 | $this->assertStringContainsString('signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', $signedAuthRequest->getHeaderLine('Authorization')); 82 | } 83 | 84 | /** 85 | * Ensures the X-Authorization-Timestamp header is unmodified if already set. 86 | */ 87 | public function testAuthorizationTimestampExists() 88 | { 89 | $signer = new RequestSigner($this->authKey, $this->realm); 90 | 91 | $headers = [ 92 | 'X-Authorization-Timestamp' => $this->timestamp, 93 | ]; 94 | 95 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10', $headers); 96 | 97 | $timestampedRequest = $signer->getTimestampedRequest($request); 98 | 99 | $this->assertTrue($timestampedRequest->hasHeader('X-Authorization-Timestamp')); 100 | $this->assertEquals($this->timestamp, $timestampedRequest->getHeaderLine('X-Authorization-Timestamp')); 101 | } 102 | 103 | /** 104 | * Ensures the X-Authorization-Timestamp header is set when a \DateTime is provided. 105 | */ 106 | public function testAuthorizationTimestampCustomDateTime() 107 | { 108 | $signer = new RequestSigner($this->authKey, $this->realm); 109 | 110 | $date = new \DateTime(); 111 | $date->setTimestamp($this->timestamp); 112 | 113 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10'); 114 | 115 | $timestampedRequest = $signer->getTimestampedRequest($request, $date); 116 | 117 | $this->assertTrue($timestampedRequest->hasHeader('X-Authorization-Timestamp')); 118 | $this->assertEquals($this->timestamp, $timestampedRequest->getHeaderLine('X-Authorization-Timestamp')); 119 | } 120 | 121 | /** 122 | * Ensures the X-Authorization-Content-SHA256 header is set correctly if there is a request body. 123 | */ 124 | public function testAuthprizationContentSha256() 125 | { 126 | $signer = new RequestSigner($this->authKey, $this->realm); 127 | 128 | $body = '{"method":"hi.bob","params":["5","4","8"]}'; 129 | $hashedBody = '6paRNxUA7WawFxJpRp4cEixDjHq3jfIKX072k9slalo='; 130 | 131 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10', [], $body); 132 | 133 | $contentHashedRequest = $signer->getContentHashedRequest($request); 134 | 135 | $this->assertTrue($contentHashedRequest->hasHeader('X-Authorization-Content-SHA256')); 136 | $this->assertEquals($hashedBody, $contentHashedRequest->getHeaderLine('X-Authorization-Content-SHA256')); 137 | } 138 | 139 | /** 140 | * Ensures the X-Authorization-Content-SHA256 header is not set if there is no request body. 141 | */ 142 | public function testAuthorizationContentSha256NoBody() 143 | { 144 | $signer = new RequestSigner($this->authKey, $this->realm); 145 | 146 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10'); 147 | 148 | $contentHashedRequest = $signer->getContentHashedRequest($request); 149 | 150 | $this->assertFalse($contentHashedRequest->hasHeader('X-Authorization-Content-SHA256')); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /test/GuzzleAuthMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | authKey = new Key($authId, $authSecret); 35 | } 36 | 37 | /** 38 | * Ensures the HTTP HMAC middleware timestamps requests correctly. 39 | */ 40 | public function testSetDefaultDateHeader() 41 | { 42 | $middleware = new HmacAuthMiddleware($this->authKey); 43 | 44 | $uri = 'http://example.com/resource/1?key=value'; 45 | $request = $middleware->signRequest(new Request('GET', $uri, [])); 46 | 47 | $timestamp = (int) $request->getHeaderLine('X-Authorization-Timestamp'); 48 | 49 | // It shouldn't take this test 10 seconds to run, but pad it since we 50 | // can not assume the time will be exactly the same. 51 | $difference = time() - $timestamp; 52 | $this->assertTrue($difference > -10); 53 | $this->assertTrue($difference < 10); 54 | } 55 | 56 | /** 57 | * Ensures the HTTP HMAC middleware signs requests correctly. 58 | */ 59 | public function testAuthorizationHeader() 60 | { 61 | $realm = 'CIStore'; 62 | 63 | $headers = [ 64 | 'X-Authorization-Timestamp' => '1432075982', 65 | 'X-Custom-Signer1' => 'custom-1', 66 | 'X-Custom-Signer2' => 'custom-2', 67 | ]; 68 | 69 | $authKey = new Key('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059', 'bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA=='); 70 | 71 | $request = new Request('GET', 'https://example.pipeline.io/api/v1/ci/pipelines', $headers); 72 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $authKey); 73 | $authHeaderBuilder->setRealm($realm); 74 | $authHeaderBuilder->setId('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059'); 75 | $authHeaderBuilder->setNonce('a9938d07-d9f0-480c-b007-f1e956bcd027'); 76 | $authHeaderBuilder->setCustomHeaders(['X-Custom-Signer2', 'X-Custom-Signer1']); 77 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 78 | 79 | $middleware = new MockHmacAuthMiddleware( 80 | $authKey, 81 | $realm, 82 | ['X-Custom-Signer1', 'X-Custom-Signer2'], 83 | $authHeader 84 | ); 85 | 86 | $request = $middleware->signRequest($request); 87 | 88 | $expected = 'acquia-http-hmac realm="CIStore",' 89 | . 'id="e7fe97fa-a0c8-4a42-ab8e-2c26d52df059",' 90 | . 'nonce="a9938d07-d9f0-480c-b007-f1e956bcd027",' 91 | . 'version="2.0",' 92 | . 'headers="X-Custom-Signer1%3BX-Custom-Signer2",' 93 | . 'signature="yoHiYvx79ssSDIu3+OldpbFs8RsjrMXgRoM89d5t+zA="'; 94 | 95 | $this->assertEquals($expected, $request->getHeaderLine('Authorization')); 96 | } 97 | 98 | /** 99 | * Ensures the middleware throws an exception if the response is missing the right header. 100 | */ 101 | public function testMissingRequiredResponseHeader() 102 | { 103 | $stack = new HandlerStack(); 104 | $stack->setHandler(new MockHandler([new Response(200)])); 105 | $stack->push(new HmacAuthMiddleware($this->authKey)); 106 | 107 | $client = new Client([ 108 | 'handler' => $stack, 109 | ]); 110 | 111 | $this->expectException(MalformedResponseException::class); 112 | $this->expectExceptionMessage('Response is missing required X-Server-Authorization-HMAC-SHA256 header.'); 113 | 114 | try { 115 | $client->get('http://example.com'); 116 | } catch (MalformedResponseException $e) { 117 | $this->assertInstanceOf('\Psr\Http\Message\ResponseInterface', $e->getResponse()); 118 | throw $e; 119 | } 120 | } 121 | 122 | /** 123 | * Ensures the middleware throws an exception if the response can't be authenticated. 124 | */ 125 | public function testInauthenticResponse() 126 | { 127 | $headers = [ 128 | 'X-Server-Authorization-HMAC-SHA256' => 'bad-signature', 129 | ]; 130 | 131 | $response = new Response(200, $headers); 132 | 133 | $stack = new HandlerStack(); 134 | 135 | $stack->setHandler(new MockHandler([$response])); 136 | $stack->push(new HmacAuthMiddleware($this->authKey)); 137 | 138 | $client = new Client([ 139 | 'handler' => $stack, 140 | ]); 141 | 142 | $this->expectException(MalformedResponseException::class); 143 | $this->expectExceptionMessage('Could not verify the authenticity of the response.'); 144 | 145 | try { 146 | $client->get('http://example.com'); 147 | } catch (MalformedResponseException $e) { 148 | $this->assertInstanceOf('\Psr\Http\Message\ResponseInterface', $e->getResponse()); 149 | throw $e; 150 | } 151 | } 152 | 153 | /** 154 | * Ensures the HTTP HMAC middleware registers correctly. 155 | */ 156 | public function testRegisterPlugin() 157 | { 158 | $realm = 'Pipet service'; 159 | 160 | $requestHeaders = [ 161 | 'X-Authorization-Timestamp' => '1432075982', 162 | ]; 163 | 164 | $request = new Request('GET', 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10', $requestHeaders); 165 | $authHeaderBuilder = new AuthorizationHeaderBuilder($request, $this->authKey); 166 | $authHeaderBuilder->setRealm($realm); 167 | $authHeaderBuilder->setId('efdde334-fe7b-11e4-a322-1697f925ec7b'); 168 | $authHeaderBuilder->setNonce('d1954337-5319-4821-8427-115542e08d10'); 169 | $authHeader = $authHeaderBuilder->getAuthorizationHeader(); 170 | 171 | $middleware = new MockHmacAuthMiddleware($this->authKey, $realm, [], $authHeader); 172 | 173 | $container = []; 174 | $history = Middleware::history($container); 175 | 176 | $responseHeaders = [ 177 | 'X-Server-Authorization-HMAC-SHA256' => 'LusIUHmqt9NOALrQ4N4MtXZEFE03MjcDjziK+vVqhvQ=', 178 | ]; 179 | 180 | $response = new Response(200, $responseHeaders); 181 | 182 | $stack = new HandlerStack(); 183 | $stack->setHandler(new MockHandler([$response])); 184 | $stack->push($middleware); 185 | $stack->push($history); 186 | 187 | $client = new Client([ 188 | 'base_uri' => 'https://example.acquiapipet.net/', 189 | 'handler' => $stack, 190 | ]); 191 | 192 | $client->send($request); 193 | 194 | $transaction = reset($container); 195 | $request = $transaction['request']; 196 | 197 | $expected = 'acquia-http-hmac realm="Pipet%20service",' 198 | . 'id="efdde334-fe7b-11e4-a322-1697f925ec7b",' 199 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 200 | . 'version="2.0",' 201 | . 'headers="",' 202 | . 'signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="'; 203 | 204 | $this->assertEquals($expected, $request->getHeaderLine('Authorization')); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP HMAC Signer for PHP 2 | 3 | [![Build Status](https://travis-ci.org/acquia/http-hmac-php.svg)](https://travis-ci.org/acquia/http-hmac-php) 4 | [![Total Downloads](https://poser.pugx.org/acquia/http-hmac-php/downloads)](https://packagist.org/packages/acquia/http-hmac-php) 5 | [![Latest Stable Version](https://poser.pugx.org/acquia/http-hmac-php/v/stable.svg)](https://packagist.org/packages/acquia/http-hmac-php) 6 | [![License](https://poser.pugx.org/acquia/http-hmac-php/license.svg)](https://packagist.org/packages/acquia/http-hmac-php) 7 | 8 | This library implements version 2.0 of the [HTTP HMAC Spec](https://github.com/acquia/http-hmac-spec/tree/2.0) to sign and verify RESTful Web API requests. It integrates with popular frameworks and libraries, like Symfony and Guzzle, and can be used on both the server and client. 9 | 10 | ## Installation 11 | 12 | Use [Composer](http://getcomposer.org) and add it as a dependency to your project's composer.json file: 13 | 14 | ```json 15 | { 16 | "require": { 17 | "acquia/http-hmac-php": "^5.0" 18 | } 19 | } 20 | ``` 21 | 22 | Please refer to [Composer's documentation](https://github.com/composer/composer/blob/master/doc/00-intro.md#introduction) for more detailed installation and usage instructions. 23 | 24 | ## Usage 25 | 26 | ### Sign an API request sent via Guzzle 27 | 28 | ```php 29 | 30 | require_once 'vendor/autoload.php'; 31 | 32 | use Acquia\Hmac\Guzzle\HmacAuthMiddleware; 33 | use Acquia\Hmac\Key; 34 | use GuzzleHttp\Client; 35 | use GuzzleHttp\HandlerStack; 36 | 37 | // Create the HTTP HMAC key. 38 | // A key consists of and ID and a Base64-encoded shared secret. 39 | // Note: the API provider may have already encoded the secret. In this case, it should not be re-encoded. 40 | $key_id = 'e7fe97fa-a0c8-4a42-ab8e-2c26d52df059'; 41 | $key_secret = base64_encode('secret'); 42 | $key = new Key($key_id, $key_secret); 43 | 44 | // Optionally, you can provide additional headers when generating the signature. 45 | // The header keys need to be provided to the middleware below. 46 | $headers = [ 47 | 'X-Custom-1' => 'value1', 48 | 'X-Custom-2' => 'value2', 49 | ]; 50 | 51 | // Specify the API's realm. 52 | // Consult the API documentation for this value. 53 | $realm = 'Acquia'; 54 | 55 | // Create a Guzzle middleware to handle authentication during all requests. 56 | // Provide your key, realm and the names of any additional custom headers. 57 | $middleware = new HmacAuthMiddleware($key, $realm, array_keys($headers)); 58 | 59 | // Register the middleware. 60 | $stack = HandlerStack::create(); 61 | $stack->push($middleware); 62 | 63 | // Create a client. 64 | $client = new Client([ 65 | 'handler' => $stack, 66 | ]); 67 | 68 | // Request. 69 | try { 70 | $result = $client->get('https://service.acquia.io/api/v1/widget', [ 71 | 'headers' => $headers, 72 | ]); 73 | } catch (ClientException $e) { 74 | print $e->getMessage(); 75 | $response = $e->getResponse(); 76 | } 77 | 78 | print $response->getBody(); 79 | ``` 80 | 81 | ### Authenticate the request using PSR-7-compatible requests 82 | 83 | ```php 84 | use Acquia\Hmac\RequestAuthenticator; 85 | use Acquia\Hmac\ResponseSigner; 86 | 87 | // $keyLoader implements \Acquia\Hmac\KeyLoaderInterface 88 | $authenticator = new RequestAuthenticator($keyLoader); 89 | 90 | // $request implements PSR-7's \Psr\Http\Message\RequestInterface 91 | // An exception will be thrown if it cannot authenticate. 92 | $key = $authenticator->authenticate($request); 93 | 94 | $signer = new ResponseSigner($key, $request); 95 | $signedResponse = $signer->signResponse($response); 96 | ``` 97 | 98 | ### Authenticate using Symfony's Security component 99 | 100 | In order to use the provided Symfony integration, you will need to include the following optional libraries in your project's `composer.json` 101 | 102 | ```json 103 | { 104 | "require": { 105 | "symfony/psr-http-message-bridge": "~0.1", 106 | "symfony/security": "~3.0", 107 | "zendframework/zend-diactoros": "~1.3.5" 108 | } 109 | } 110 | ``` 111 | 112 | Sample implementation: 113 | 114 | ```yaml 115 | # app/config/parameters.yml 116 | parameters: 117 | hmac_keys: {"key": "secret"} 118 | 119 | # app/config/services.yml 120 | services: 121 | hmac.keyloader: 122 | class: Acquia\Hmac\KeyLoader 123 | arguments: 124 | $keys: '%hmac_keys%' 125 | 126 | hmac.request.authenticator: 127 | class: Acquia\Hmac\RequestAuthenticator 128 | arguments: 129 | - '@hmac.keyloader' 130 | public: false 131 | 132 | hmac.response.signer: 133 | class: Acquia\Hmac\Symfony\HmacResponseListener 134 | tags: 135 | - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse } 136 | 137 | hmac.entry-point: 138 | class: Acquia\Hmac\Symfony\HmacAuthenticationEntryPoint 139 | 140 | hmac.security.authentication.provider: 141 | class: Acquia\Hmac\Symfony\HmacAuthenticationProvider 142 | arguments: 143 | - '@hmac.request.authenticator' 144 | public: false 145 | 146 | hmac.security.authentication.listener: 147 | class: Acquia\Hmac\Symfony\HmacAuthenticationListener 148 | arguments: ['@security.token_storage', '@security.authentication.manager', '@hmac.entry-point'] 149 | public: false 150 | 151 | # app/config/security.yml 152 | security: 153 | # ... 154 | 155 | firewalls: 156 | hmac_auth: 157 | pattern: ^/api/ 158 | stateless: true 159 | hmac_auth: true 160 | ``` 161 | 162 | ```php 163 | // src/AppBundle/AppBundle.php 164 | namespace AppBundle; 165 | 166 | use Acquia\Hmac\Symfony\HmacFactory; 167 | use Symfony\Component\HttpKernel\Bundle\Bundle; 168 | use Symfony\Component\DependencyInjection\ContainerBuilder; 169 | 170 | class AppBundle extends Bundle 171 | { 172 | public function build(ContainerBuilder $container) 173 | { 174 | parent::build($container); 175 | $extension = $container->getExtension('security'); 176 | $extension->addSecurityListenerFactory(new HmacFactory()); 177 | } 178 | } 179 | ``` 180 | 181 | PHPUnit testing a controller using HMAC HTTP authentication in Symfony: 182 | 183 | 1. Add the service declaration: 184 | 185 | ```yaml 186 | # app/config/parameters_test.yml 187 | 188 | services: 189 | test.client.hmac: 190 | class: Acquia\Hmac\Test\Mocks\Symfony\HmacClient 191 | arguments: ['@kernel', '%test.client.parameters%', '@test.client.history', '@test.client.cookiejar'] 192 | 193 | ``` 194 | 195 | ```php 196 | // src/AppBundle/Tests/HmacTestCase.php 197 | 198 | namespace MyApp\Bundle\AppBundle\Tests; 199 | 200 | use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 201 | use Symfony\Component\HttpFoundation\Response; 202 | use Symfony\Bundle\FrameworkBundle\Client; 203 | use Acquia\Hmac\Key; 204 | 205 | class HmacTestCase extends WebTestCase 206 | { 207 | /** 208 | * @var Client 209 | */ 210 | private $client; 211 | 212 | protected static function createClient(array $options = array(), array $server = array()) 213 | { 214 | $kernel = static::bootKernel($options); 215 | 216 | $client = $kernel->getContainer()->get('test.client.hmac'); 217 | $client->setServerParameters($server); 218 | 219 | return $client; 220 | } 221 | 222 | protected function setUp() 223 | { 224 | $this->client = static::createClient(); 225 | 226 | $this->client->setKey(new Key('my-key', 'my-not-really-secret')); 227 | } 228 | ``` 229 | 230 | ## Contributing and Development 231 | 232 | [GNU Make](https://www.gnu.org/software/make/) and [Composer](https://getcomposer.org) are used to manage development dependencies and testing: 233 | 234 | ```sh 235 | # Install depdendencies 236 | make install 237 | 238 | # Run test suite 239 | make test 240 | ``` 241 | 242 | All code should adhere to the following standards: 243 | 244 | * [PSR-1](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-1-basic-coding-standard.md) 245 | * [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) 246 | * [PSR-4](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader.md) 247 | * [PSR-7](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md) 248 | 249 | Submit changes using GitHub's standard [pull request](https://help.github.com/articles/using-pull-requests) workflow. 250 | -------------------------------------------------------------------------------- /test/RequestAuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | keys = [ 33 | 'efdde334-fe7b-11e4-a322-1697f925ec7b' => 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI=', 34 | '615d6517-1cea-4aa3-b48e-96d83c16c4dd' => 'TXkgU2VjcmV0IEtleSBUaGF0IGlzIFZlcnkgU2VjdXJl', 35 | ]; 36 | } 37 | 38 | /** 39 | * Ensures a valid request with a valid signature authenticates correctly. 40 | */ 41 | public function testValidSignature() 42 | { 43 | $authId = key($this->keys); 44 | $authSecret = reset($this->keys); 45 | $timestamp = 1432075982; 46 | 47 | $headers = [ 48 | 'Content-Type' => 'text/plain', 49 | 'X-Authorization-Timestamp' => $timestamp, 50 | 'Authorization' => 'acquia-http-hmac realm="Pipet service",' 51 | . 'id="' . $authId . '",' 52 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 53 | . 'version="2.0",' 54 | . 'headers="",' 55 | . 'signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 56 | ]; 57 | $request = new Request( 58 | 'GET', 59 | 'https://example.acquiapipet.net/v1.0/task-status/133?limit=10', 60 | $headers 61 | ); 62 | 63 | $authenticator = new MockRequestAuthenticator( 64 | new MockKeyLoader($this->keys), 65 | null, 66 | $timestamp 67 | ); 68 | 69 | $key = $authenticator->authenticate($request); 70 | 71 | $this->assertInstanceOf(KeyInterface::class, $key); 72 | $this->assertEquals($authId, $key->getId()); 73 | $this->assertEquals($authSecret, $key->getSecret()); 74 | } 75 | 76 | /** 77 | * Ensures an exception is thrown if the signature is invalid. 78 | */ 79 | public function testInvalidSignature() 80 | { 81 | $realm = 'Pipet service'; 82 | $id = key($this->keys); 83 | $nonce = 'd1954337-5319-4821-8427-115542e08d10'; 84 | $version = '2.0'; 85 | $headers = []; 86 | 87 | $headers = [ 88 | 'Content-Type' => 'text/plain', 89 | 'X-Authorization-Timestamp' => time(), 90 | 'Authorization' => 'acquia-http-hmac realm="' . $realm . '",' 91 | . 'id="' . $id . '",' 92 | . 'nonce="' . $nonce . '",' 93 | . 'version="' . $version . '",' 94 | . 'headers="' . implode(';', $headers) . '",' 95 | . 'signature="bRlPr/Z1WQz2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 96 | ]; 97 | $request = new Request('GET', 'https://example.com/test', $headers); 98 | 99 | $authHeader = new AuthorizationHeader( 100 | $realm, 101 | $id, 102 | $nonce, 103 | $version, 104 | $headers, 105 | 'bad-sig' 106 | ); 107 | 108 | $this->expectException(InvalidSignatureException::class); 109 | 110 | $authenticator = new MockRequestAuthenticator( 111 | new MockKeyLoader($this->keys), 112 | $authHeader 113 | ); 114 | $authenticator->authenticate($request); 115 | } 116 | 117 | /** 118 | * Ensures an exception is thrown if the request has expired. 119 | */ 120 | public function testExpiredRequest() 121 | { 122 | $authId = key($this->keys); 123 | 124 | $headers = [ 125 | 'Content-Type' => 'text/plain', 126 | 'X-Authorization-Timestamp' => 1, 127 | 'Authorization' => 'acquia-http-hmac realm="Pipet service",' 128 | . 'id="' . $authId . '",' 129 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 130 | . 'version="2.0",' 131 | . 'headers="",' 132 | . 'signature="bRlPr/Z1WQz2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 133 | ]; 134 | $request = new Request('GET', 'https://example.com/test', $headers); 135 | $authHeader = AuthorizationHeader::createFromRequest($request); 136 | 137 | $this->expectException(TimestampOutOfRangeException::class); 138 | 139 | $authenticator = new MockRequestAuthenticator( 140 | new MockKeyLoader($this->keys), 141 | $authHeader 142 | ); 143 | $authenticator->authenticate($request); 144 | } 145 | 146 | /** 147 | * Ensures an exception is thrown if the request is from the far future. 148 | */ 149 | public function testFutureRequest() 150 | { 151 | $auth_id = key($this->keys); 152 | 153 | $time = new \DateTime('+16 minutes'); 154 | $timestamp = (string) $time->getTimestamp(); 155 | 156 | $headers = [ 157 | 'Content-Type' => 'text/plain', 158 | 'X-Authorization-Timestamp' => $timestamp, 159 | 'Authorization' => 'acquia-http-hmac realm="Pipet service",' 160 | . 'id="' . $auth_id . '",' 161 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 162 | . 'version="2.0",' 163 | . 'headers="",' 164 | . 'signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 165 | ]; 166 | 167 | $request = new Request('GET', 'https://example.com/test', $headers); 168 | 169 | $this->expectException(TimestampOutOfRangeException::class); 170 | 171 | $authenticator = new RequestAuthenticator(new MockKeyLoader($this->keys)); 172 | $authenticator->authenticate($request); 173 | } 174 | 175 | /** 176 | * Ensures an exception is thrown if the key cannot be found in the loader. 177 | */ 178 | public function testKeyNotFound() 179 | { 180 | $headers = [ 181 | 'Content-Type' => 'text/plain', 182 | 'X-Authorization-Timestamp' => time(), 183 | 'Authorization' => 'acquia-http-hmac realm="Pipet service",' 184 | . 'id="bad-id",' 185 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 186 | . 'version="2.0",' 187 | . 'headers="",' 188 | . 'signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 189 | ]; 190 | $request = new Request('GET', 'https://example.com/test', $headers); 191 | 192 | $this->expectException(KeyNotFoundException::class); 193 | 194 | $authenticator = new RequestAuthenticator(new MockKeyLoader($this->keys)); 195 | $authenticator->authenticate($request); 196 | } 197 | 198 | /** 199 | * Ensures an exception is thrown if the request is missing the X-Authorization-Timestamp header. 200 | */ 201 | public function testMissingAuthenticationTimestampHeader() 202 | { 203 | $headers = [ 204 | 'Content-Type' => 'text/plain', 205 | 'Authorization' => 'acquia-http-hmac realm="Pipet service",' 206 | . 'id="bad-id",' 207 | . 'nonce="d1954337-5319-4821-8427-115542e08d10",' 208 | . 'version="2.0",' 209 | . 'headers="",' 210 | . 'signature="MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc="', 211 | ]; 212 | $request = new Request('GET', 'https://example.com/test', $headers); 213 | 214 | $this->expectException(MalformedRequestException::class); 215 | $this->expectExceptionMessage('Request is missing X-Authorization-Timestamp.'); 216 | 217 | $authenticator = new RequestAuthenticator(new MockKeyLoader($this->keys)); 218 | 219 | try { 220 | $authenticator->authenticate($request); 221 | } catch (MalformedRequestException $e) { 222 | $this->assertSame($request, $e->getRequest()); 223 | throw $e; 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /test/acquia_spec_features.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "spec_versions": ["1.0", "2.0"], 4 | "fixtures" : { 5 | "2.0": [ 6 | { 7 | "input": { 8 | "name": "GET 1", 9 | "description": "Valid GET request", 10 | "host": "example.acquiapipet.net", 11 | "url": "https://example.acquiapipet.net/v1.0/task-status/133?limit=10", 12 | "method": "GET", 13 | "content_body": "", 14 | "content_type": "application/json", 15 | "content_sha": "", 16 | "timestamp": 1432075982, 17 | "realm": "Pipet service", 18 | "id": "efdde334-fe7b-11e4-a322-1697f925ec7b", 19 | "secret": "W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI=", 20 | "nonce": "d1954337-5319-4821-8427-115542e08d10", 21 | "signed_headers": [], 22 | "headers" : {} 23 | }, 24 | "expectations": { 25 | "authorization_header": "acquia-http-hmac id=\"efdde334-fe7b-11e4-a322-1697f925ec7b\",nonce=\"d1954337-5319-4821-8427-115542e08d10\",realm=\"Pipet%20service\",signature=\"MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc=\",version=\"2.0\"", 26 | "signable_message": "GET\nexample.acquiapipet.net\n/v1.0/task-status/133\nlimit=10\nid=efdde334-fe7b-11e4-a322-1697f925ec7b&nonce=d1954337-5319-4821-8427-115542e08d10&realm=Pipet%20service&version=2.0\n1432075982", 27 | "message_signature": "MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc=", 28 | "response_signature": "M4wYp1MKvDpQtVOnN7LVt9L8or4pKyVLhfUFVJxHemU=", 29 | "response_body": "{\"id\": 133, \"status\": \"done\"}" 30 | } 31 | }, 32 | { 33 | "input": { 34 | "name": "GET 2", 35 | "description": "Valid GET request", 36 | "host": "example.acquiapipet.net", 37 | "url": "https://example.acquiapipet.net/v1.0/task-status/145?limit=1", 38 | "method": "GET", 39 | "content_body": "", 40 | "content_type": "application/json", 41 | "content_sha": "", 42 | "timestamp": 1432075982, 43 | "realm": "Pipet service", 44 | "id": "615d6517-1cea-4aa3-b48e-96d83c16c4dd", 45 | "secret": "TXkgU2VjcmV0IEtleSBUaGF0IGlzIFZlcnkgU2VjdXJl", 46 | "nonce": "24c0c836-4f6c-4ed6-a6b0-e091d75ea19d", 47 | "signed_headers": [], 48 | "headers" : {} 49 | }, 50 | "expectations": { 51 | "authorization_header": "acquia-http-hmac id=\"615d6517-1cea-4aa3-b48e-96d83c16c4dd\",nonce=\"24c0c836-4f6c-4ed6-a6b0-e091d75ea19d\",realm=\"Pipet%20service\",signature=\"1Ku5UroiW1knVP6GH4l7Z4IuQSRxZO2gp/e5yhapv1s=\",version=\"2.0\"", 52 | "signable_message": "GET\nexample.acquiapipet.net\n/v1.0/task-status/145\nlimit=1\nid=615d6517-1cea-4aa3-b48e-96d83c16c4dd&nonce=24c0c836-4f6c-4ed6-a6b0-e091d75ea19d&realm=Pipet%20service&version=2.0\n1432075982", 53 | "message_signature": "1Ku5UroiW1knVP6GH4l7Z4IuQSRxZO2gp/e5yhapv1s=", 54 | "response_signature": "C98MEJHnQSNiYCxmI4CxJegO62sGZdzEEiSXgSIoxlo=", 55 | "response_body": "{\"id\": 145, \"status\": \"in-progress\"}" 56 | } 57 | }, 58 | { 59 | "input": { 60 | "name": "GET 3", 61 | "description": "Valid GET request with signed headers", 62 | "host": "example.pipeline.io", 63 | "url": "https://example.pipeline.io/api/v1/ci/pipelines", 64 | "method": "GET", 65 | "content_body": "", 66 | "content_type": "application/json", 67 | "content_sha": "", 68 | "timestamp": 1432075982, 69 | "realm": "CIStore", 70 | "id": "e7fe97fa-a0c8-4a42-ab8e-2c26d52df059", 71 | "secret": "bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA==", 72 | "nonce": "a9938d07-d9f0-480c-b007-f1e956bcd027", 73 | "signed_headers": [ "X-Custom-Signer1", "X-Custom-Signer2"], 74 | "headers" : { 75 | "X-Custom-Signer1": "custom-1", 76 | "X-Custom-Signer2": "custom-2" 77 | } 78 | }, 79 | "expectations": { 80 | "authorization_header": "acquia-http-hmac headers=\"X-Custom-Signer1%3BX-Custom-Signer2\",id=\"e7fe97fa-a0c8-4a42-ab8e-2c26d52df059\",nonce=\"a9938d07-d9f0-480c-b007-f1e956bcd027\",realm=\"CIStore\",signature=\"yoHiYvx79ssSDIu3+OldpbFs8RsjrMXgRoM89d5t+zA=\",version=\"2.0\"", 81 | "signable_message": "GET\nexample.pipeline.io\n/api/v1/ci/pipelines\n\nid=e7fe97fa-a0c8-4a42-ab8e-2c26d52df059&nonce=a9938d07-d9f0-480c-b007-f1e956bcd027&realm=CIStore&version=2.0\nx-custom-signer1:custom-1\nx-custom-signer2:custom-2\n1432075982", 82 | "message_signature": "yoHiYvx79ssSDIu3+OldpbFs8RsjrMXgRoM89d5t+zA=", 83 | "response_signature": "cUDFSS5tN5vBBS7orIfUag8jhkaGouBb/o8fstUvTF8=", 84 | "response_body": "[{\"pipeline_id\":\"39b5d58d-0a8f-437d-8dd6-4da50dcc87b7\",\"sitename\":\"enterprise-g1:sfwiptravis\",\"name\":\"pipeline.yml\",\"last_job_id\":\"810e4344-1bed-4fd0-a642-1ba17eb996d5\",\"last_branch\":\"validate-yaml\",\"last_requested\":\"2016-03-25T20:26:39.000Z\",\"last_finished\":null,\"last_status\":\"succeeded\",\"last_duration\":null}]" 85 | } 86 | }, 87 | { 88 | "input": { 89 | "name": "POST 1", 90 | "description": "Valid POST request", 91 | "host": "example.acquiapipet.net", 92 | "url": "https://example.acquiapipet.net/v1.0/task", 93 | "method": "POST", 94 | "content_body": "{\"method\":\"hi.bob\",\"params\":[\"5\",\"4\",\"8\"]}", 95 | "content_type": "application/json", 96 | "content_sha": "6paRNxUA7WawFxJpRp4cEixDjHq3jfIKX072k9slalo=", 97 | "timestamp": 1432075982, 98 | "realm": "Pipet service", 99 | "id": "efdde334-fe7b-11e4-a322-1697f925ec7b", 100 | "secret": "W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI=", 101 | "nonce": "d1954337-5319-4821-8427-115542e08d10", 102 | "signed_headers": [], 103 | "headers" : {} 104 | }, 105 | "expectations": { 106 | "authorization_header": "acquia-http-hmac id=\"efdde334-fe7b-11e4-a322-1697f925ec7b\",nonce=\"d1954337-5319-4821-8427-115542e08d10\",realm=\"Pipet%20service\",signature=\"XDBaXgWFCY3aAgQvXyGXMbw9Vds2WPKJe2yP+1eXQgM=\",version=\"2.0\"", 107 | "signable_message": "POST\nexample.acquiapipet.net\n/v1.0/task\n\nid=efdde334-fe7b-11e4-a322-1697f925ec7b&nonce=d1954337-5319-4821-8427-115542e08d10&realm=Pipet%20service&version=2.0\n1432075982\napplication/json\n6paRNxUA7WawFxJpRp4cEixDjHq3jfIKX072k9slalo=", 108 | "message_signature": "XDBaXgWFCY3aAgQvXyGXMbw9Vds2WPKJe2yP+1eXQgM=", 109 | "response_signature": "LusIUHmqt9NOALrQ4N4MtXZEFE03MjcDjziK+vVqhvQ=", 110 | "response_body": "" 111 | } 112 | }, 113 | { 114 | "input": { 115 | "name": "POST 2", 116 | "description": "Valid POST request with signed headers.", 117 | "host": "example.pipeline.io", 118 | "url": "https://example.pipeline.io/api/v1/ci/pipelines/39b5d58d-0a8f-437d-8dd6-4da50dcc87b7/start", 119 | "method": "POST", 120 | "content_body": "{\"cloud_endpoint\":\"https://cloudapi.acquia.com/v1\",\"cloud_user\":\"example@acquia.com\",\"cloud_pass\":\"password\",\"branch\":\"validate\"}", 121 | "content_type": "application/json", 122 | "content_sha": "2YGTI4rcSnOEfd7hRwJzQ2OuJYqAf7jzyIdcBXCGreQ=", 123 | "timestamp": 1449578521, 124 | "realm": "CIStore", 125 | "id": "e7fe97fa-a0c8-4a42-ab8e-2c26d52df059", 126 | "secret": "bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA==", 127 | "nonce": "a9938d07-d9f0-480c-b007-f1e956bcd027", 128 | "signed_headers": [ "X-Custom-Signer1", "X-Custom-Signer2"], 129 | "headers" : { 130 | "X-Custom-Signer1": "custom-1", 131 | "X-Custom-Signer2": "custom-2" 132 | } 133 | }, 134 | "expectations": { 135 | "authorization_header": "acquia-http-hmac headers=\"X-Custom-Signer1%3BX-Custom-Signer2\",id=\"e7fe97fa-a0c8-4a42-ab8e-2c26d52df059\",nonce=\"a9938d07-d9f0-480c-b007-f1e956bcd027\",realm=\"CIStore\",signature=\"0duvqeMauat7pTULg3EgcSmBjrorrcRkGKxRDtZEa1c=\",version=\"2.0\"", 136 | "signable_message": "POST\nexample.pipeline.io\n/api/v1/ci/pipelines/39b5d58d-0a8f-437d-8dd6-4da50dcc87b7/start\n\nid=e7fe97fa-a0c8-4a42-ab8e-2c26d52df059&nonce=a9938d07-d9f0-480c-b007-f1e956bcd027&realm=CIStore&version=2.0\nx-custom-signer1:custom-1\nx-custom-signer2:custom-2\n1449578521\napplication/json\n2YGTI4rcSnOEfd7hRwJzQ2OuJYqAf7jzyIdcBXCGreQ=", 137 | "message_signature": "0duvqeMauat7pTULg3EgcSmBjrorrcRkGKxRDtZEa1c=", 138 | "response_signature": "SlOYi3pUZADkzU9wEv7kw3hmxjlEyMqBONFEVd7iDbM=", 139 | "response_body": "\"57674bb1-f2ce-4d0f-bfdc-736a78aa027a\"" 140 | } 141 | } 142 | ] 143 | }, 144 | "skeletons": { 145 | "2.0": { 146 | "input": { 147 | "name": "", 148 | "description": "", 149 | "host": "", 150 | "url": "", 151 | "method": "", 152 | "content_body": "", 153 | "content_type": "", 154 | "content_sha": "", 155 | "timestamp": 0, 156 | "realm": "", 157 | "id": "", 158 | "secret": "", 159 | "nonce": "", 160 | "signed_headers": [], 161 | "headers" : {} 162 | }, 163 | "expectations": { 164 | "authorization_header": "", 165 | "signable_message": "", 166 | "message_signature": "", 167 | "response_signature": "", 168 | "response_body": "" 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/AuthorizationHeaderBuilder.php: -------------------------------------------------------------------------------- 1 | request = $request; 83 | $this->key = $key; 84 | $this->digest = $digest ?: new Digest(); 85 | $this->nonce = $this->generateNonce(); 86 | } 87 | 88 | /** 89 | * Set the realm/provider. 90 | * 91 | * This method is optional: if not called, the realm will be "Acquia". 92 | * 93 | * @param string $realm 94 | * The realm/provider. 95 | */ 96 | public function setRealm($realm) 97 | { 98 | $this->realm = $realm; 99 | } 100 | 101 | /** 102 | * Set the API key's unique identifier. 103 | * 104 | * This method is required for an authorization header to be built. 105 | * 106 | * @param string $id 107 | * The API key's unique identifier. 108 | */ 109 | public function setId($id) 110 | { 111 | $this->id = $id; 112 | } 113 | 114 | /** 115 | * Set the nonce. 116 | * 117 | * This is optional: if not called, a nonce will be generated automatically. 118 | * 119 | * @param string $nonce 120 | * The nonce. The nonce should be hex-based v4 UUID. 121 | */ 122 | public function setNonce($nonce) 123 | { 124 | $this->nonce = $nonce; 125 | } 126 | 127 | /** 128 | * Set the spec version. 129 | * 130 | * This is optional: if not called, the version will be "2.0". 131 | * 132 | * @param string $version 133 | * The spec version. 134 | */ 135 | public function setVersion($version) 136 | { 137 | $this->version = $version; 138 | } 139 | 140 | /** 141 | * Set the list of custom headers found in a request. 142 | * 143 | * This is optional: if not called, the list of custom headers will be 144 | * empty. 145 | * 146 | * @param string[] $headers 147 | * A list of custom header names. The values of the headers will be 148 | * extracted from the request. 149 | */ 150 | public function setCustomHeaders(array $headers = []) 151 | { 152 | $this->headers = $headers; 153 | } 154 | 155 | /** 156 | * Set the authorization signature. 157 | * 158 | * This is optional: if not called, the signature will be generated from the 159 | * other fields and the request. Calling this method manually is not 160 | * recommended outside of testing. 161 | * 162 | * @param string $signature 163 | * The Base64-encoded authorization signature. 164 | */ 165 | public function setSignature($signature) 166 | { 167 | $this->signature = $signature; 168 | } 169 | 170 | /** 171 | * Builds the authorization header. 172 | * 173 | * @throws \Acquia\Hmac\Exception\MalformedRequestException 174 | * When a required field (ID, nonce, realm, version) is empty or missing. 175 | * 176 | * @return \Acquia\Hmac\AuthorizationHeader 177 | * The compiled authorization header. 178 | */ 179 | public function getAuthorizationHeader() 180 | { 181 | if (empty($this->realm) || empty($this->id) || empty($this->nonce) || empty($this->version)) { 182 | throw new MalformedRequestException( 183 | 'One or more required authorization header fields (ID, nonce, realm, version) are missing.', 184 | null, 185 | 0, 186 | $this->request 187 | ); 188 | } 189 | 190 | $signature = !empty($this->signature) ? $this->signature : $this->generateSignature(); 191 | 192 | return new AuthorizationHeader( 193 | $this->realm, 194 | $this->id, 195 | $this->nonce, 196 | $this->version, 197 | $this->headers, 198 | $signature 199 | ); 200 | } 201 | 202 | /** 203 | * Generate a new nonce. 204 | * 205 | * The nonce is a v4 UUID. 206 | * 207 | * @see https://stackoverflow.com/a/15875555 208 | * 209 | * @return string 210 | * The generated nonce. 211 | */ 212 | public function generateNonce() 213 | { 214 | $data = function_exists('random_bytes') ? random_bytes(16) : openssl_random_pseudo_bytes(16); 215 | $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100 216 | $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10 217 | 218 | return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); 219 | } 220 | 221 | /** 222 | * Generate a signature from the request. 223 | * 224 | * @throws \Acquia\Hmac\Exception\MalformedRequestException 225 | * When a required header is missing. 226 | * 227 | * @return string 228 | * The generated signature. 229 | */ 230 | protected function generateSignature() 231 | { 232 | if (!$this->request->hasHeader('X-Authorization-Timestamp')) { 233 | throw new MalformedRequestException( 234 | 'X-Authorization-Timestamp header missing from request.', 235 | null, 236 | 0, 237 | $this->request 238 | ); 239 | } 240 | 241 | $host = $this->request->getUri()->getHost(); 242 | $port = $this->request->getUri()->getPort(); 243 | 244 | if ($port) { 245 | $host .= ':' . $port; 246 | } 247 | 248 | $parts = [ 249 | strtoupper($this->request->getMethod()), 250 | $host, 251 | $this->request->getUri()->getPath(), 252 | $this->request->getUri()->getQuery(), 253 | $this->serializeAuthorizationParameters(), 254 | ]; 255 | 256 | $parts = array_merge($parts, $this->normalizeCustomHeaders()); 257 | 258 | $parts[] = $this->request->getHeaderLine('X-Authorization-Timestamp'); 259 | 260 | $body = (string) $this->request->getBody(); 261 | 262 | if (strlen($body)) { 263 | if ($this->request->hasHeader('Content-Type')) { 264 | $parts[] = $this->request->getHeaderLine('Content-Type'); 265 | } 266 | 267 | $parts[] = $this->digest->hash((string) $body); 268 | } 269 | 270 | return $this->digest->sign(implode("\n", $parts), $this->key->getSecret()); 271 | } 272 | 273 | /** 274 | * Serializes the requireed authorization parameters. 275 | * 276 | * @return string 277 | * The serialized authorization parameter string. 278 | */ 279 | protected function serializeAuthorizationParameters() 280 | { 281 | return sprintf( 282 | 'id=%s&nonce=%s&realm=%s&version=%s', 283 | $this->id, 284 | $this->nonce, 285 | rawurlencode($this->realm), 286 | $this->version 287 | ); 288 | } 289 | 290 | /** 291 | * Normalizes the custom headers for signing. 292 | * 293 | * @return string[] 294 | * An array of normalized headers. 295 | */ 296 | protected function normalizeCustomHeaders() 297 | { 298 | $headers = []; 299 | 300 | // The spec requires that headers are sorted by header name. 301 | sort($this->headers); 302 | foreach ($this->headers as $header) { 303 | if ($this->request->hasHeader($header)) { 304 | $headers[] = strtolower($header) . ':' . $this->request->getHeaderLine($header); 305 | } 306 | } 307 | 308 | return $headers; 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /test/AuthorizationHeaderTest.php: -------------------------------------------------------------------------------- 1 | header = 'acquia-http-hmac headers="X-Custom-Signer1;X-Custom-Signer2",id="e7fe97fa-a0c8-4a42-ab8e-2c26d52df059",nonce="a9938d07-d9f0-480c-b007-f1e956bcd027",realm="CIStore",signature="0duvqeMauat7pTULg3EgcSmBjrorrcRkGKxRDtZEa1c=",version="2.0"'; 30 | // @codingStandardsIgnoreEnd 31 | } 32 | 33 | /** 34 | * Ensures the getters work as expected. 35 | */ 36 | public function testGetters() 37 | { 38 | $realm = 'Pipet service'; 39 | $id = 'efdde334-fe7b-11e4-a322-1697f925ec7b'; 40 | $nonce = 'd1954337-5319-4821-8427-115542e08d10'; 41 | $version = '2.0'; 42 | $headers = ['X-Custom-Signer1', 'X-Custom-Signer2']; 43 | $signature = 'MRlPr/Z1WQY2sMthcaEqETRMw4gPYXlPcTpaLWS2gcc='; 44 | 45 | $authHeader = new AuthorizationHeader($realm, $id, $nonce, $version, $headers, $signature); 46 | 47 | $this->assertEquals($realm, $authHeader->getRealm()); 48 | $this->assertEquals($id, $authHeader->getId()); 49 | $this->assertEquals($nonce, $authHeader->getNonce()); 50 | $this->assertEquals($version, $authHeader->getVersion()); 51 | $this->assertEquals($headers, $authHeader->getCustomHeaders()); 52 | $this->assertEquals($signature, $authHeader->getSignature()); 53 | } 54 | 55 | /** 56 | * Ensures an authorization header can be created from a request. 57 | */ 58 | public function testCreateFromRequest() 59 | { 60 | $headers = [ 61 | 'Authorization' => $this->header, 62 | ]; 63 | $request = new Request('GET', 'http://example.com', $headers); 64 | 65 | $authHeader = AuthorizationHeader::createFromRequest($request); 66 | 67 | $this->assertEquals( 68 | (string) $authHeader, 69 | // @codingStandardsIgnoreStart 70 | 'acquia-http-hmac realm="CIStore",id="e7fe97fa-a0c8-4a42-ab8e-2c26d52df059",nonce="a9938d07-d9f0-480c-b007-f1e956bcd027",version="2.0",headers="X-Custom-Signer1;X-Custom-Signer2",signature="0duvqeMauat7pTULg3EgcSmBjrorrcRkGKxRDtZEa1c="' 71 | // @codingStandardsIgnoreEnd 72 | ); 73 | } 74 | 75 | /** 76 | * Ensures an authorization header is created correctly with an incorrectly-cased request method. 77 | */ 78 | public function testCaseInsensitiveRequestMethod() 79 | { 80 | $authId = 'efdde334-fe7b-11e4-a322-1697f925ec7b'; 81 | $authSecret = 'W5PeGMxSItNerkNFqQMfYiJvH14WzVJMy54CPoTAYoI='; 82 | $authKey = new Key($authId, $authSecret); 83 | 84 | $nonce = 'd1954337-5319-4821-8427-115542e08d10'; 85 | 86 | $headers = [ 87 | 'X-Authorization-Timestamp' => '1432075982', 88 | 'Content-Type' => 'application/json', 89 | ]; 90 | 91 | 92 | $request1 = new Request('GET', 'http://example.com', $headers); 93 | $builder1 = new AuthorizationHeaderBuilder($request1, $authKey); 94 | $builder1->setId($authId); 95 | $builder1->setNonce($nonce); 96 | $authHeader1 = $builder1->getAuthorizationHeader(); 97 | 98 | // Guzzle requests automatically normalize request methods on set, so 99 | // we need to manually set the property to an un-normalized method. 100 | $request2 = clone $request1; 101 | $refObject = new \ReflectionObject($request2); 102 | $refProperty = $refObject->getProperty('method'); 103 | $refProperty->setAccessible(true); 104 | $refProperty->setValue($request2, 'gEt'); 105 | 106 | $builder2 = new AuthorizationHeaderBuilder($request2, $authKey); 107 | $builder2->setId($authId); 108 | $builder2->setNonce($nonce); 109 | $authHeader2 = $builder2->getAuthorizationHeader(); 110 | 111 | $this->assertEquals((string) $authHeader1, (string) $authHeader2); 112 | } 113 | 114 | /** 115 | * Ensures an exception is thrown if a request does not have an Authorization header. 116 | */ 117 | public function testCreateFromRequestNoAuthorizationHeader() 118 | { 119 | $request = new Request('GET', 'http://example.com'); 120 | 121 | $this->expectException(MalformedRequestException::class); 122 | $this->expectExceptionMessage('Authorization header is required.'); 123 | 124 | try { 125 | AuthorizationHeader::createFromRequest($request); 126 | } catch (MalformedRequestException $e) { 127 | $this->assertSame($request, $e->getRequest()); 128 | throw $e; 129 | } 130 | } 131 | 132 | /** 133 | * Ensures an exception is thrown when a required field is missing. 134 | * 135 | * @param $field 136 | * The authorization header field. 137 | * 138 | * @dataProvider requiredFieldsProvider 139 | */ 140 | public function testParseAuthorizationHeaderRequiredFields($field) 141 | { 142 | $headers = [ 143 | 'Authorization' => preg_replace('/' . $field . '=/', '', $this->header), 144 | ]; 145 | $request = new Request('GET', 'http://example.com', $headers); 146 | 147 | $this->expectException(MalformedRequestException::class); 148 | $this->expectExceptionMessage('Authorization header requires a realm, id, version, nonce and a signature.'); 149 | 150 | try { 151 | AuthorizationHeader::createFromRequest($request); 152 | } catch (MalformedRequestException $e) { 153 | $this->assertSame($request, $e->getRequest()); 154 | throw $e; 155 | } 156 | } 157 | 158 | /** 159 | * Provides a list of required authorization header fields. 160 | */ 161 | public function requiredFieldsProvider() 162 | { 163 | return [ 164 | ['id'], 165 | ['nonce'], 166 | ['realm'], 167 | ['signature'], 168 | ['version'], 169 | ]; 170 | } 171 | 172 | /** 173 | * Ensures an exception is thrown when a required field is missing. 174 | */ 175 | public function testAuthorizationHeaderBuilderRequiresFields() 176 | { 177 | $key = new Key('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059', 'bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA=='); 178 | $headers = [ 179 | 'X-Authorization-Timestamp' => '1432075982', 180 | 'Content-Type' => 'application/json', 181 | ]; 182 | $request = new Request('POST', 'http://example.com?test=true', $headers, 'body text'); 183 | $builder = new AuthorizationHeaderBuilder($request, $key); 184 | $builder->setNonce('a9938d07-d9f0-480c-b007-f1e956bcd027'); 185 | $builder->setVersion('2.0'); 186 | 187 | $this->expectException(MalformedRequestException::class); 188 | $this->expectExceptionMessage('One or more required authorization header fields (ID, nonce, realm, version) are missing.'); 189 | 190 | try { 191 | $builder->getAuthorizationHeader(); 192 | } catch (MalformedRequestException $e) { 193 | $this->assertSame($request, $e->getRequest()); 194 | throw $e; 195 | } 196 | } 197 | 198 | /** 199 | * Ensures an exception is thrown when the required X-Authorization-Timestamp field is missing. 200 | */ 201 | public function testAuthorizationHeaderBuilderRequiresTimestamp() 202 | { 203 | $key = new Key('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059', 'bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA=='); 204 | $headers = [ 205 | 'Content-Type' => 'application/json', 206 | ]; 207 | $request = new Request('POST', 'http://example.com?test=true', $headers, 'body text'); 208 | $builder = new AuthorizationHeaderBuilder($request, $key); 209 | $builder->setId($key->getId()); 210 | $builder->setNonce('a9938d07-d9f0-480c-b007-f1e956bcd027'); 211 | $builder->setVersion('2.0'); 212 | 213 | $this->expectException(MalformedRequestException::class); 214 | $this->expectExceptionMessage('X-Authorization-Timestamp header missing from request.'); 215 | 216 | try { 217 | $builder->getAuthorizationHeader(); 218 | } catch (MalformedRequestException $e) { 219 | $this->assertSame($request, $e->getRequest()); 220 | throw $e; 221 | } 222 | } 223 | 224 | public function testAuthorizationHeaderBuilder() 225 | { 226 | $key = new Key('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059', 'bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA=='); 227 | $headers = [ 228 | 'X-Authorization-Timestamp' => '1432075982', 229 | 'Content-Type' => 'application/json', 230 | ]; 231 | $request = new Request('POST', 'http://example.com?test=true', $headers, 'body text'); 232 | $builder = new AuthorizationHeaderBuilder($request, $key); 233 | $builder->setId($key->getId()); 234 | $builder->setNonce('a9938d07-d9f0-480c-b007-f1e956bcd027'); 235 | $builder->setVersion('2.0'); 236 | 237 | $header = $builder->getAuthorizationHeader(); 238 | $this->assertEquals($header->getId(), $key->getId()); 239 | $this->assertEquals($header->getSignature(), 'f9G/Xu339hw1z2zHTOrHKNv1kWqvYHYI9Nu/phO5dPY='); 240 | 241 | $builder->setSignature('test'); 242 | $header = $builder->getAuthorizationHeader(); 243 | $this->assertEquals($header->getSignature(), 'test'); 244 | } 245 | 246 | /** 247 | * Ensures an authorization header is created correctly with a non-standard port used in request. 248 | */ 249 | public function testNonStandardPortInRequest() 250 | { 251 | $key = new Key('e7fe97fa-a0c8-4a42-ab8e-2c26d52df059', 'bXlzZWNyZXRzZWNyZXR0aGluZ3Rva2VlcA=='); 252 | $headers = [ 253 | 'X-Authorization-Timestamp' => '1432075982', 254 | 'Content-Type' => 'application/json', 255 | ]; 256 | $request = new Request('POST', 'http://example.com:8080?test=true', $headers, 'body text'); 257 | $builder = new AuthorizationHeaderBuilder($request, $key); 258 | $builder->setId($key->getId()); 259 | $builder->setNonce('a9938d07-d9f0-480c-b007-f1e956bcd027'); 260 | $builder->setVersion('2.0'); 261 | 262 | $header = $builder->getAuthorizationHeader(); 263 | $this->assertEquals($header->getId(), $key->getId()); 264 | $this->assertEquals($header->getSignature(), 'vIJoGnHstwQ+SaBboP4/DlUAqTGscSbCZav7ufh8KqM='); 265 | } 266 | } 267 | --------------------------------------------------------------------------------