Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 61 additions & 10 deletions docs/en/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,8 @@ controller's default model class and the current action:
$this->Authorization->authorizeModel('index', 'add');
```

You can also mark actions as public by skipping authorization:

```php
$this->loadComponent('Authorization.Authorization', [
'skipAuthorization' => [
'login',
],
]);
```
You can also mark actions as public by skipping authorization. See
[Skipping Authorization](#skipping-authorization) below.

By default, every action requires authorization when authorization checking is
enabled.
Expand Down Expand Up @@ -121,11 +114,69 @@ public function add()

## Skipping Authorization

You can also skip authorization inside an action:
By default every action requires an authorization check, and the middleware
raises an exception when an action performs none. Marking an action as public is
therefore explicit. There are three ways to do it, which differ in where the
knowledge about the action lives.

### For the whole application

Pass the action names when loading the component, usually in `AppController`:

```php
$this->loadComponent('Authorization.Authorization', [
'skipAuthorization' => [
'login',
],
]);
```

Use this for actions that are public everywhere, such as a login action on a
controller every other controller inherits from.

### Per controller

`skipAuthorizationActions()` appends to the same list at runtime, so a controller
can declare its own public actions without `AppController` knowing about them:

```php
public function beforeFilter(\Cake\Event\EventInterface $event)
{
parent::beforeFilter($event);

$this->Authorization->skipAuthorizationActions('verifyEmail', 'webhook');
}
```

Actions listed here are skipped by the automatic check, and `can()`,
`canResult()` and `authorize()` treat the current action as authorized, so a
manual check in `beforeFilter()` does not have to special-case them:

```php
public function beforeFilter(\Cake\Event\EventInterface $event)
{
parent::beforeFilter($event);

$this->Authorization->skipAuthorizationActions('login', 'logout');

if (!$this->Authorization->can($this)) {
return $this->redirect('/');
}
}
```

This applies only to a check on the current action. An explicit action always
runs its policy, so `can($article, 'delete')` is unaffected by `delete` being in
the list.

### Inside a single action

```php
public function view($id)
{
$this->Authorization->skipAuthorization();
}
```

Use this when whether the action needs authorization depends on something you
only know once the action runs.
48 changes: 48 additions & 0 deletions src/Controller/Component/AuthorizationComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Authorization\AuthorizationServiceInterface;
use Authorization\Exception\ForbiddenException;
use Authorization\IdentityInterface;
use Authorization\Policy\Result;
use Authorization\Policy\ResultInterface;
use Cake\Controller\Component;
use Cake\Http\ServerRequest;
Expand Down Expand Up @@ -64,6 +65,12 @@ public function authorize(mixed $resource, ?string $action = null): void
{
if ($action === null) {
$request = $this->getController()->getRequest();
if ($this->isSkippedAction($request)) {
$this->skipAuthorization();

return;
}

$action = $this->getDefaultAction($request);
}

Expand Down Expand Up @@ -127,6 +134,16 @@ protected function performCheck(
): ResultInterface|bool {
$request = $this->getController()->getRequest();
if ($action === null) {
if ($this->isSkippedAction($request)) {
$this->skipAuthorization();

if ($method === 'can') {
return true;
}

return new Result(true);
}

$action = $this->getDefaultAction($request);
}

Expand Down Expand Up @@ -178,6 +195,22 @@ public function skipAuthorization()
return $this;
}

/**
* Adds actions that should skip the automatic authorization check.
*
* Actions registered here are marked as authorized in `authorizeAction()`,
* which runs on the configured `authorizationEvent`.
*
* @param string ...$actions Controller actions to skip authorization for.
* @return $this
*/
public function skipAuthorizationActions(string ...$actions)
{
$this->_config['skipAuthorization'] = array_merge($this->_config['skipAuthorization'], $actions);

return $this;
}

/**
* Allows to map controller action to another authorization policy action.
*
Expand Down Expand Up @@ -298,6 +331,21 @@ public function authorizeAction(): void
}
}

/**
* Whether the current controller action is configured to skip authorization.
*
* Only an implicit check refers to the current controller action, so only that one can
* be skipped. The raw action name is matched, the same key `authorizeAction()` uses,
* so both paths agree when `actionMap` is in play.
*
* @param \Cake\Http\ServerRequest $request Server request.
* @return bool
*/
protected function isSkippedAction(ServerRequest $request): bool
{
return $this->checkAction((string)$request->getParam('action'), 'skipAuthorization');
}

/**
* Checks whether an action should be authorized according to the config key provided.
*
Expand Down
66 changes: 66 additions & 0 deletions tests/TestCase/Controller/Component/AuthorizationComponentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,72 @@ public function testAuthorizeModel(): void
$this->assertEquals(['foo', 'bar', 'baz'], $this->Auth->getConfig('authorizeModel'));
}

public function testSkipAuthorizationActions(): void
{
$this->Auth->skipAuthorizationActions('foo', 'bar');
$this->assertEquals(['foo', 'bar'], $this->Auth->getConfig('skipAuthorization'));

$this->Auth->skipAuthorizationActions('baz');
$this->assertEquals(['foo', 'bar', 'baz'], $this->Auth->getConfig('skipAuthorization'));
}

public function testSkipAuthorizationActionsAppliedOnAuthorizeAction(): void
{
$service = $this->Controller->getRequest()->getAttribute('authorization');

$this->Auth->skipAuthorizationActions('edit');
$this->Auth->authorizeAction();
$this->assertTrue($service->authorizationChecked());
}

public function testSkipAuthorizationActionsAppliedOnCan(): void
{
$service = $this->Controller->getRequest()->getAttribute('authorization');
$article = new Article(['user_id' => 99]);
$this->assertFalse($this->Auth->can($article));

$this->Auth->skipAuthorizationActions('edit');
$this->assertTrue($this->Auth->can($article));
$this->assertTrue($service->authorizationChecked());
}

public function testSkipAuthorizationActionsAppliedOnCanResult(): void
{
$this->Auth->skipAuthorizationActions('edit');

$result = $this->Auth->canResult(new Article(['user_id' => 99]));
$this->assertInstanceOf(ResultInterface::class, $result);
$this->assertTrue($result->getStatus());
}

public function testSkipAuthorizationActionsAppliedOnAuthorize(): void
{
$this->Auth->skipAuthorizationActions('edit');

$this->Auth->authorize(new Article(['user_id' => 99]));
$this->assertTrue($this->Controller->getRequest()->getAttribute('authorization')->authorizationChecked());
}

public function testSkipAuthorizationActionsIgnoredForExplicitAction(): void
{
$article = new Article(['user_id' => 99]);
$this->Auth->skipAuthorizationActions('delete');

$this->assertFalse($this->Auth->can($article, 'delete'));
}

public function testSkipAuthorizationActionsUsesControllerAction(): void
{
$service = $this->Controller->getRequest()->getAttribute('authorization');
$this->Auth->mapAction('edit', 'modify');
$this->Auth->skipAuthorizationActions('edit');

$this->assertTrue($this->Auth->can(new Article(['user_id' => 99])));

$this->Auth->authorizeAction();
$this->assertTrue($service->authorizationChecked());
}

public function testMapAction(): void
{
$this->Auth->mapAction('foo', 'bar');
Expand Down
Loading