Skip to content

Commit

Permalink
Improve authorization and authentication mechanics (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
Firehed authored Jun 15, 2018
1 parent 4b01a56 commit 6b9cebc
Show file tree
Hide file tree
Showing 11 changed files with 474 additions and 17 deletions.
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,64 @@ The path to a file which returns a `PSR-11`-compliant container for config value

### Container

If you set a `container` value in `.apiconfig`, the API will be made aware of the container.
If you set a `container` value in `.apiconfig`, the API will be made aware of the container (if you do not use the generated front controller, you may also do this manually).
This is how to configure API endpoints at runtime.
By convention, if the container `has()` an endpoint's fully-qualified class name, the dispatcher will `get()` and use that value when the route is dispatched.
If no container is configured, or the container does not have a configuration for the routed endpoint, the routed endpoint will simply be instanciated via `new $routedEndpointClassName`.

Further, if your container `has()` a `Psr\Log\LoggerInterface`, the default error handler will automatically be configured to use it.
If it does not, it will use `Psr\Log\NullLogger`, resulting in no logs being written anywhere.
It is therefore *highly recommended* to provide a `PSR-3` logger through the container.
Other auto-detected container entries:

### `.apiconfig` example
| Key | Usage | Detected |
|---|---|---|
| Psr\Log\LoggerInterface | Internal logging | generated front controller |
| Firehed\API\Authentication\ProviderInterface | Authentication Provider | Always if an AuthorizationProvider is set |
| Firehed\API\Authorization\ProviderInterface | Authorization Provider | Always if an AuthenticationProvider is set |


### Example

`.apiconfig`:

```json
{
"webroot": "public",
"namespace": "Company\\Project",
"namespace": "Your\\Application",
"source": "src",
"container": "config/config.php"
"container": "config.php"
}
```

`config.php`:

```php
<?php
use Firehed\API;
use Psr\Log\LoggerInterface;
use Your\Application\Endpoints;

$container = new Pimple\Container();
// Endpoint config
$container[Endpoints\UserPost::class] = function ($c) {
return new Endpoints\UserPost($c['some-dependency']);
};

// Other services
$container[API\Authentication\ProviderInterface::class] = function ($c) {
// return your provider
};
$container[API\Authorization\ProviderInterface::class] = function ($c) {
// return your provider
};
$container[LoggerInterface::class] = function ($c) {
return new Monolog\Logger('your-application');
};

// ...
return new Pimple\Psr11\Container($container);
```

In this example, when your `UserPost` endpoint is routed, it will use the endpoint defined in the container - this allows for endpoints with required constructor arguments or other configuration.

If you have e.g. a `UserGet` endpoint which is _not_ in the container, the dispatcher will automatically attempt to instantiate it with `new`.
If that endpoint has no constructor arguments, this will be fine.
However, this means your application will crash at runtime if it does - so any endpoints with required constructors **must** be configured in the container.
10 changes: 10 additions & 0 deletions src/Authentication/Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Authentication;

use RuntimeException;

class Exception extends RuntimeException
{
}
20 changes: 20 additions & 0 deletions src/Authentication/ProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Authentication;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Container\ContainerInterface;

interface ProviderInterface
{
/**
* Upon successful authentication, the provider MUST return a
* ContainerInterface. It is RECOMMENDED that implementations make authn
* data available with fully-qualified class names when possible.
*
* If authentication fails, the provider MUST throw
* a Firehed\API\Authentication\Exception.
*/
public function authenticate(ServerRequestInterface $request): ContainerInterface;
}
10 changes: 10 additions & 0 deletions src/Authorization/Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Authorization;

use RuntimeException;

class Exception extends RuntimeException
{
}
13 changes: 13 additions & 0 deletions src/Authorization/Ok.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Authorization;

/**
* This class exists to loosely mimic a Result type, forcing ProviderInterface
* implementations to affirmatively return a success state in order to reduce
* the chance of accidentally failing "open".
*/
class Ok
{
}
22 changes: 22 additions & 0 deletions src/Authorization/ProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Authorization;

use Firehed\API\Interfaces\AuthenticatedEndpointInterface;
use Psr\Container\ContainerInterface;

interface ProviderInterface
{
/**
* Authorize the endpoint using the authentication data provided in the
* container. Implementations MUST throw an Exception upon failure, and
* MUST return an Ok upon success.
*
* @throws Exception
*/
public function authorize(
AuthenticatedEndpointInterface $endpoint,
ContainerInterface $auth
): Ok;
}
36 changes: 36 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);

namespace Firehed\API;

use Psr\Container as Psr;

/**
* Ultra simple array wrapper for a PSR container. No closures, no evaluation,
* nothing else. Just a dumb-as-rocks key/value store.
*/
class Container implements Psr\ContainerInterface
{
/** @var array */
private $data;

public function __construct(array $data)
{
$this->data = $data;
}

public function has($id)
{
return array_key_exists($id, $this->data);
}

public function get($id)
{
if (!$this->has($id)) {
throw new class extends \Exception implements Psr\NotFoundExceptionInterface
{
};
}
return $this->data[$id];
}
}
43 changes: 43 additions & 0 deletions src/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
class Dispatcher
{

private $authenticationProvider;
private $authorizationProvider;
private $container;
private $endpoint_list;
private $error_handler;
Expand Down Expand Up @@ -52,6 +54,21 @@ public function addResponseMiddleware(callable $callback): self
return $this;
}

/**
* Provide the authentication and authorization providers. These will be
* run after routing but before the endpoint is executed.
*
* @return $this
*/
public function setAuthProviders(
Authentication\ProviderInterface $authn,
Authorization\ProviderInterface $authz
): self {
$this->authenticationProvider = $authn;
$this->authorizationProvider = $authz;
return $this;
}

/**
* Provide a DI Container/Service Locator class or array. During
* dispatching, this structure will be queried for the routed endpoint by
Expand All @@ -65,6 +82,22 @@ public function addResponseMiddleware(callable $callback): self
public function setContainer(ContainerInterface $container = null): self
{
$this->container = $container;

if (!$container) {
return $this;
}
// Auto-detect auth components
if (!$this->authenticationProvider && !$this->authorizationProvider) {
if ($container->has(Authentication\ProviderInterface::class)
&& $container->has(Authorization\ProviderInterface::class)
) {
$this->setAuthProviders(
$container->get(Authentication\ProviderInterface::class),
$container->get(Authorization\ProviderInterface::class)
);
}
}

return $this;
}

Expand Down Expand Up @@ -153,8 +186,18 @@ public function dispatch(): ResponseInterface
);
}

$isSRI = $this->request instanceof ServerRequestInterface;

$endpoint = $this->getEndpoint();
try {
if ($isSRI
&& $this->authenticationProvider
&& $endpoint instanceof Interfaces\AuthenticatedEndpointInterface
) {
$auth = $this->authenticationProvider->authenticate($this->request);
$endpoint->setAuthentication($auth);
$this->authorizationProvider->authorize($endpoint, $auth);
}
$endpoint->authenticate($this->request);
$safe_input = $this->parseInput()
->addData($this->getUriData())
Expand Down
11 changes: 11 additions & 0 deletions src/Interfaces/AuthenticatedEndpointInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Interfaces;

use Psr\Container\ContainerInterface;

interface AuthenticatedEndpointInterface extends EndpointInterface
{
public function setAuthentication(ContainerInterface $auth);
}
59 changes: 59 additions & 0 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);

namespace Firehed\API;

use Psr\Container as Psr;

/**
* @coversDefaultClass Firehed\API\Container
* @covers ::<protected>
* @covers ::<private>
*/
class ContainerTest extends \PHPUnit\Framework\TestCase
{
/** @var Container */
private $c;

public function setUp()
{
$this->c = new Container(['key' => 'value']);
}

/** @covers ::__construct */
public function testConstruct()
{
$this->assertInstanceOf(Psr\ContainerInterface::class, $this->c);
}

/** @covers ::has */
public function testHas()
{
$this->assertTrue($this->c->has('key'));
$this->assertFalse($this->c->has('nokey'));
}

/** @covers ::get */
public function testGet()
{
$this->assertSame('value', $this->c->get('key'));
}

/** @covers ::get */
public function testGetDoesNotEvaluateCallables()
{
$loader = function () {
return new Container([]);
};

$container = new Container(['loader' => $loader]);
$this->assertSame($loader, $container->get('loader'));
}

/** @covers ::get */
public function testGetThrowsOnMissingKey()
{
$this->expectException(Psr\NotFoundExceptionInterface::class);
$this->c->get('nokey');
}
}
Loading

0 comments on commit 6b9cebc

Please sign in to comment.