diff --git a/composer.json b/composer.json index 1e3428b..1ebce9d 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,11 @@ "Http\\Message\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "spec\\Http\\Message\\": "spec/" + } + }, "scripts": { "test": "vendor/bin/phpspec run", "test-ci": "vendor/bin/phpspec run -c phpspec.yml.ci" diff --git a/spec/Authentication/AuthenticationBehavior.php b/spec/Authentication/AuthenticationBehavior.php new file mode 100644 index 0000000..6cf8632 --- /dev/null +++ b/spec/Authentication/AuthenticationBehavior.php @@ -0,0 +1,11 @@ +shouldImplement('Http\Message\Authentication'); + } +} diff --git a/spec/Authentication/BasicAuthSpec.php b/spec/Authentication/BasicAuthSpec.php new file mode 100644 index 0000000..3729191 --- /dev/null +++ b/spec/Authentication/BasicAuthSpec.php @@ -0,0 +1,52 @@ +beConstructedWith('john.doe', 'secret'); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Message\Authentication\BasicAuth'); + } + + function it_has_a_username() + { + $this->getUsername()->shouldReturn('john.doe'); + } + + function it_accepts_a_username() + { + $this->setUsername('jane.doe'); + + $this->getUsername()->shouldReturn('jane.doe'); + } + + function it_has_a_password() + { + $this->getPassword()->shouldReturn('secret'); + } + + function it_accepts_a_password() + { + $this->setPassword('very_secret'); + + $this->getPassword()->shouldReturn('very_secret'); + } + + function it_authenticates_a_request(RequestInterface $request, RequestInterface $newRequest) + { + $request->withHeader('Authorization', 'Basic '.base64_encode('john.doe:secret'))->willReturn($newRequest); + + $this->authenticate($request)->shouldReturn($newRequest); + } +} diff --git a/spec/Authentication/BearerSpec.php b/spec/Authentication/BearerSpec.php new file mode 100644 index 0000000..e3d2d88 --- /dev/null +++ b/spec/Authentication/BearerSpec.php @@ -0,0 +1,40 @@ +beConstructedWith('token'); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Message\Authentication\Bearer'); + } + + function it_has_a_token() + { + $this->getToken()->shouldReturn('token'); + } + + function it_accepts_a_token() + { + $this->setToken('another_token'); + + $this->getToken()->shouldReturn('another_token'); + } + + function it_authenticates_a_request(RequestInterface $request, RequestInterface $newRequest) + { + $request->withHeader('Authorization', 'Bearer token')->willReturn($newRequest); + + $this->authenticate($request)->shouldReturn($newRequest); + } +} diff --git a/spec/Authentication/ChainSpec.php b/spec/Authentication/ChainSpec.php new file mode 100644 index 0000000..f16938f --- /dev/null +++ b/spec/Authentication/ChainSpec.php @@ -0,0 +1,81 @@ +shouldHaveType('Http\Message\Authentication\Chain'); + } + + function it_accepts_an_authentication_chain_in_the_constructor(Authentication $auth1, Authentication $auth2) + { + $chain = [$auth1, $auth2]; + + $this->beConstructedWith($chain); + + $this->getAuthenticationChain()->shouldReturn($chain); + } + + function it_sets_the_authentication_chain(Authentication $auth1, Authentication $auth2) + { + // This SHOULD be replaced + $this->beConstructedWith([$auth1]); + + $this->setAuthenticationChain([$auth2]); + + $this->getAuthenticationChain()->shouldReturn([$auth2]); + } + + function it_adds_an_authentication_method(Authentication $auth1, Authentication $auth2) + { + // This SHOULD NOT be replaced + $this->beConstructedWith([$auth1]); + + $this->addAuthentication($auth2); + + $this->getAuthenticationChain()->shouldReturn([$auth1, $auth2]); + } + + function it_clears_the_authentication_chain(Authentication $auth1, Authentication $auth2) + { + // This SHOULD be replaced + $this->beConstructedWith([$auth1]); + + $this->clearAuthenticationChain(); + + $this->addAuthentication($auth2); + + $this->getAuthenticationChain()->shouldReturn([$auth2]); + } + + function it_authenticates_a_request( + Authentication $auth1, + Authentication $auth2, + RequestInterface $originalRequest, + RequestInterface $request1, + RequestInterface $request2 + ) { + $originalRequest->withHeader('AuthMethod1', 'AuthValue')->willReturn($request1); + $request1->withHeader('AuthMethod2', 'AuthValue')->willReturn($request2); + + $auth1->authenticate($originalRequest)->will(function ($args) { + return $args[0]->withHeader('AuthMethod1', 'AuthValue'); + }); + + $auth2->authenticate($request1)->will(function ($args) { + return $args[0]->withHeader('AuthMethod2', 'AuthValue'); + }); + + $this->beConstructedWith([$auth1, $auth2]); + + $this->authenticate($originalRequest)->shouldReturn($request2); + } +} diff --git a/spec/Authentication/MatchingSpec.php b/spec/Authentication/MatchingSpec.php new file mode 100644 index 0000000..caefda6 --- /dev/null +++ b/spec/Authentication/MatchingSpec.php @@ -0,0 +1,75 @@ +matcher = function($request) { return true; }; + + $this->beConstructedWith($authentication, $this->matcher); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Message\Authentication\Matching'); + } + + function it_has_an_authentication(Authentication $authentication) + { + $this->getAuthentication()->shouldReturn($authentication); + } + + function it_accepts_an_authentication(Authentication $anotherAuthentication) + { + $this->setAuthentication($anotherAuthentication); + + $this->getAuthentication()->shouldReturn($anotherAuthentication); + } + + function it_has_a_matcher() + { + $this->getMatcher()->shouldReturn($this->matcher); + } + + function it_accepts_a_matcher() + { + $matcher = function($request) { return false; }; + + $this->setMatcher($matcher); + + $this->getMatcher()->shouldReturn($matcher); + } + + function it_authenticates_a_request(Authentication $authentication, RequestInterface $request, RequestInterface $newRequest) + { + $authentication->authenticate($request)->willReturn($newRequest); + + $this->authenticate($request)->shouldReturn($newRequest); + } + + function it_does_not_authenticate_a_request(Authentication $authentication, RequestInterface $request) + { + $matcher = function($request) { return false; }; + + $this->setMatcher($matcher); + + $authentication->authenticate($request)->shouldNotBeCalled(); + + $this->authenticate($request)->shouldReturn($request); + } + + function it_creates_a_matcher_from_url(Authentication $authentication) + { + $this->createUrlMatcher($authentication, 'url')->shouldHaveType('Http\Message\Authentication\Matching'); + } +} diff --git a/spec/Authentication/WsseSpec.php b/spec/Authentication/WsseSpec.php new file mode 100644 index 0000000..9d511da --- /dev/null +++ b/spec/Authentication/WsseSpec.php @@ -0,0 +1,59 @@ +beConstructedWith('john.doe', 'secret'); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Message\Authentication\Wsse'); + } + + function it_has_a_username() + { + $this->getUsername()->shouldReturn('john.doe'); + } + + function it_accepts_a_username() + { + $this->setUsername('jane.doe'); + + $this->getUsername()->shouldReturn('jane.doe'); + } + + function it_has_a_password() + { + $this->getPassword()->shouldReturn('secret'); + } + + function it_accepts_a_password() + { + $this->setPassword('very_secret'); + + $this->getPassword()->shouldReturn('very_secret'); + } + + function it_authenticates_a_request( + RequestInterface $request, + RequestInterface $newRequest, + RequestInterface $newerRequest + ) { + $request->withHeader('Authorization', 'WSSE profile="UsernameToken"')->willReturn($newRequest); + $newRequest->withHeader('X-WSSE', Argument::that(function($arg) { + return preg_match('/UsernameToken Username=".*", PasswordDigest=".*", Nonce=".*", Created=".*"/', $arg); + }))->willReturn($newerRequest); + + $this->authenticate($request)->shouldReturn($newerRequest); + } +} diff --git a/src/Authentication.php b/src/Authentication.php new file mode 100644 index 0000000..b50366f --- /dev/null +++ b/src/Authentication.php @@ -0,0 +1,22 @@ + + */ +interface Authentication +{ + /** + * Authenticates a request. + * + * @param RequestInterface $request + * + * @return RequestInterface + */ + public function authenticate(RequestInterface $request); +} diff --git a/src/Authentication/BasicAuth.php b/src/Authentication/BasicAuth.php new file mode 100644 index 0000000..2d8d81f --- /dev/null +++ b/src/Authentication/BasicAuth.php @@ -0,0 +1,36 @@ + + */ +final class BasicAuth implements Authentication +{ + use UserPasswordPair; + + /** + * @param string $username + * @param string $password + */ + public function __construct($username, $password) + { + $this->username = $username; + $this->password = $password; + } + + /** + * {@inheritdoc} + */ + public function authenticate(RequestInterface $request) + { + $header = sprintf('Basic %s', base64_encode(sprintf('%s:%s', $this->username, $this->password))); + + return $request->withHeader('Authorization', $header); + } +} diff --git a/src/Authentication/Bearer.php b/src/Authentication/Bearer.php new file mode 100644 index 0000000..fabcf4b --- /dev/null +++ b/src/Authentication/Bearer.php @@ -0,0 +1,57 @@ + + */ +final class Bearer implements Authentication +{ + /** + * @var string + */ + private $token; + + /** + * @param string $token + */ + public function __construct($token) + { + $this->token = $token; + } + + /** + * Returns the token. + * + * @return string + */ + public function getToken() + { + return $this->token; + } + + /** + * Sets the token. + * + * @param string $token + */ + public function setToken($token) + { + $this->token = $token; + } + + /** + * {@inheritdoc} + */ + public function authenticate(RequestInterface $request) + { + $header = sprintf('Bearer %s', $this->token); + + return $request->withHeader('Authorization', $header); + } +} diff --git a/src/Authentication/Chain.php b/src/Authentication/Chain.php new file mode 100644 index 0000000..e5899d1 --- /dev/null +++ b/src/Authentication/Chain.php @@ -0,0 +1,83 @@ + + */ +final class Chain implements Authentication +{ + /** + * @var Authentication[] + */ + private $authenticationChain = []; + + /** + * @param Authentication[] $authenticationChain + */ + public function __construct(array $authenticationChain = []) + { + $this->setAuthenticationChain($authenticationChain); + } + + /** + * Adds an Authentication method to the chain. + * + * The order of authentication methods SHOULD NOT matter. + * + * @param Authentication $authentication + */ + public function addAuthentication(Authentication $authentication) + { + $this->authenticationChain[] = $authentication; + } + + /** + * Returns the current authentication chain. + * + * @return Authentication[] + */ + public function getAuthenticationChain() + { + return $this->authenticationChain; + } + + /** + * Replaces the current authentication chain. + * + * @param array $authenticationChain + */ + public function setAuthenticationChain(array $authenticationChain) + { + $this->clearAuthenticationChain(); + + foreach ($authenticationChain as $authentication) { + $this->addAuthentication($authentication); + } + } + + /** + * Clears the authentication chain. + */ + public function clearAuthenticationChain() + { + $this->authenticationChain = []; + } + + /** + * {@inheritdoc} + */ + public function authenticate(RequestInterface $request) + { + foreach ($this->authenticationChain as $authentication) { + $request = $authentication->authenticate($request); + } + + return $request; + } +} diff --git a/src/Authentication/Matching.php b/src/Authentication/Matching.php new file mode 100644 index 0000000..9502a35 --- /dev/null +++ b/src/Authentication/Matching.php @@ -0,0 +1,109 @@ + + */ +final class Matching implements Authentication +{ + /** + * @var Authentication + */ + private $authentication; + + /** + * @var callable + */ + private $matcher; + + /** + * @param Authentication $authentication + * @param callable|null $matcher + */ + public function __construct(Authentication $authentication, callable $matcher = null) + { + if (is_null($matcher)) { + $matcher = function () { + return true; + }; + } + + $this->authentication = $authentication; + $this->matcher = $matcher; + } + + /** + * Returns the authentication. + * + * @return string + */ + public function getAuthentication() + { + return $this->authentication; + } + + /** + * Sets the authentication. + * + * @param Authentication $authentication + */ + public function setAuthentication(Authentication $authentication) + { + $this->authentication = $authentication; + } + + /** + * Returns the matcher. + * + * @return callable + */ + public function getMatcher() + { + return $this->matcher; + } + + /** + * Sets the matcher. + * + * @param callable $matcher + */ + public function setMatcher(callable $matcher) + { + $this->matcher = $matcher; + } + + /** + * {@inheritdoc} + */ + public function authenticate(RequestInterface $request) + { + if (!call_user_func($this->matcher, $request)) { + return $request; + } + + return $this->authentication->authenticate($request); + } + + /** + * Creates a matching authentication for an URL. + * + * @param Authentication $authentication + * @param string $url + * + * @return self + */ + public static function createUrlMatcher(Authentication $authentication, $url) + { + $matcher = function ($request) use ($url) { + return preg_match($url, $request->getRequestTarget()); + }; + + return new static($authentication, $matcher); + } +} diff --git a/src/Authentication/UserPasswordPair.php b/src/Authentication/UserPasswordPair.php new file mode 100644 index 0000000..14f7cda --- /dev/null +++ b/src/Authentication/UserPasswordPair.php @@ -0,0 +1,61 @@ + + */ +trait UserPasswordPair +{ + /** + * @var string + */ + private $username; + + /** + * @var string + */ + private $password; + + /** + * Returns the username. + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Sets the username. + * + * @param string $username + */ + public function setUsername($username) + { + $this->username = $username; + } + + /** + * Returns the password. + * + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * Sets the password. + * + * @param string $password + */ + public function setPassword($password) + { + $this->password = $password; + } +} diff --git a/src/Authentication/Wsse.php b/src/Authentication/Wsse.php new file mode 100644 index 0000000..d371b6e --- /dev/null +++ b/src/Authentication/Wsse.php @@ -0,0 +1,50 @@ + + */ +final class Wsse implements Authentication +{ + use UserPasswordPair; + + /** + * @param string $username + * @param string $password + */ + public function __construct($username, $password) + { + $this->username = $username; + $this->password = $password; + } + + /** + * {@inheritdoc} + */ + public function authenticate(RequestInterface $request) + { + // TODO: generate better nonce? + $nonce = substr(md5(uniqid(uniqid().'_', true)), 0, 16); + $created = date('c'); + $digest = base64_encode(sha1(base64_decode($nonce).$created.$this->password, true)); + + $wsse = sprintf( + 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"', + $this->username, + $digest, + $nonce, + $created + ); + + return $request + ->withHeader('Authorization', 'WSSE profile="UsernameToken"') + ->withHeader('X-WSSE', $wsse) + ; + } +}