├── 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 | [](https://travis-ci.org/acquia/http-hmac-php)
4 | [](https://packagist.org/packages/acquia/http-hmac-php)
5 | [](https://packagist.org/packages/acquia/http-hmac-php)
6 | [](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 |
--------------------------------------------------------------------------------