diff --git a/docs/en/component.md b/docs/en/component.md index 30d456b..17a4a3b 100644 --- a/docs/en/component.md +++ b/docs/en/component.md @@ -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. @@ -121,7 +114,62 @@ 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) @@ -129,3 +177,6 @@ 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. diff --git a/src/Controller/Component/AuthorizationComponent.php b/src/Controller/Component/AuthorizationComponent.php index c87a5e8..34cedfa 100644 --- a/src/Controller/Component/AuthorizationComponent.php +++ b/src/Controller/Component/AuthorizationComponent.php @@ -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; @@ -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); } @@ -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); } @@ -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. * @@ -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. * diff --git a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php index b1d17e6..0a88b9b 100644 --- a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php @@ -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');