Authorization tutorial result: should final state still include policy errors?

Not sure whether I completed the authorization tutorial correctly. At the one hand it is obvious that many Authorization errors occur since the tutorial only focuses on the policy for articles and since

If we forget to check or skip authorization in an controller action the Authorization plugin will raise an exception letting us know we forgot to apply authorization.

But even for the example of articles, the unauthorized edit of an article from a different user still leads to the error page of unallowed authorization.

I checked the policy documentation. Implementing the result object in the article policy did still give an error via templates\Error\error500.php;

 if ($user->id == $article->user_id) {
        return new Result(true);
    }
    // Results let you define a 'reason' for the failure.
    return new Result(false, 'not-owner');

So is one expected to make policy files for each entity (and could this be done on a larger level) and how can unauthorized actions be redirected to a proper designed error page (or just a Flash message) ? I would expect the tutorial to answer these questions, assuming correct following of the instruction/explanation.

I have often wished for more context in the documentation.

In this case I think you’re looking for answers that are perhaps too specific to an implementation. The tutorials are generally aimed at showing how the major parts fit together and not on what you might choose to do with them in any specific case.

This is also a function of what the volunteers who wrote the docs and tutorials felt was necessary based on their experience and learning style.

But possibly you might one day decided to lend a hand in expanding the docs to fill the gaps you see. That is an area I personally see an opportunity.

From a general tutorial point of view, I would say that the making of a wholly functional app is not more specific than setting up a specific policy for one table.

From the policy documentation I don’t see a way either. Maybe it is my lack of prior knowledge, but to me it would be very useful to see an option to generalize the policies.
I thought the answer was mapping the controller actions to one class for which the same canEdit() etc. could be written, but this is not what MapResolver does.

I am trying to properly utilize the documentation. And giving feedback / asking for help since I am easily lost and feel like missing essentials. The question remains if this is of personal or general help.

I would like to improve the docs, but up till now I suggested minor improvements since I first need to understand the working myself before I can shape a tutorial :slight_smile:

So to conclude, I would expect the authorization tutorial to cover essential aspects of authorization:
How to handle unauthorized actions (e.g. redirecting or showing a message)
How to generalize identical policies for multiple tables, without the manual copy pasting of code for each tablePolicy.php.

If not part of the tutorial, I would expect it to refer to these for further reading (or state the execution of these as required prior knowledge, although that would be an odd expectation of tutorial readers).

I feel your pain. The climb to enlightenment is not fast for me.

Hi,

Unauthorized actions throw exceptions to the application, the application then rethrows the exception to the middleware. The middleware (the authorization middleware) has some builtin handlers to catch that exception. We can configure what handle used during the authorization-middleware setup. Check this here.

The MapResolver just maps what policy class to use for a resource(s) in a direct way. So, when you call the authorize method you pass as parameters the resource and the policy method to apply right? To do the authorization, the AuthorizationService will need a policy class to apply the policy in that resource. But what class use for that? To find that policy class, the service will use the policy resolver that was configured in the service initialization generally in your src/Application.php. Once found the policy class, the service executes the policy method that you passed in the authorized parameters.

That, you can solve in many ways. One is: use MapResolver to map all desired resources to one specific policy class.

1 Like

This is the link I had been looking for, thanks! Implementing the example doesn’t work though. It still shows the error page on throw new ForbiddenException($result, [$action, $name]);
(…\vendor\cakephp\authorization\src\Controller\Component\AuthorizationComponent.php).

Could it be since I exactly copied ::class and I should refer to a specific class, which I have no clue what it refers to? in
‘exceptions’ => [
MissingIdentityException::class,
OtherException::class,

Or is it about the following:

Configuration options are passed to the handler’s handle() method as the last parameter.

Handlers catch only those exceptions which extend the Authorization\Exception\Exception class.

Should I set something up in the file that states the AuthorizationMiddleware class? I highly doubt this since there is no exception controller there.

Hi,

By default, only MissingIdentityException is redirected. Your code is throwing ForbiddenException. You need to add the ForbiddenException to the “exceptions” list in the configuration options.

$middlewareQueue->add(new AuthorizationMiddleware($this, [
    'unauthorizedHandler' => [
        'className' => 'Authorization.Redirect',
        'url' => '/users/login',
        'queryParam' => 'redirectUrl',
        'exceptions' => [
            MissingIdentityException::class,
            ForbiddenException::class,
        ],
    ],
]));

This still gives the ForbiddenException error page.
Moreover, how would you implement a temporary (Flash) message in the handler on top of the reference to login?

Could you, please, provide the code that you have modified to implement the authorization? (the policy class file, Appcontroller.php, Application.php, and the code of the controller that you are trying to access.)

After show the message, set a timer to trigger a function that hide the element on timeout. You can do this via javascript or CSS animation in the view layer.

I am still using the tutorial app, where I am logged in as user 2 and try to edit an article created by user 1.

Application.php public function middleware(MiddlewareQueue $queue): MiddlewareQueue:

$queue->add(new AuthorizationMiddleware($this ,[
    'unauthorizedHandler' => [
        'className' => 'Authorization.Redirect',
        'url' => '/users/login',
        #'queryParam' => 'redirectUrl',
        'exceptions' => [
            MissingIdentityException::class,
            ForbiddenException::class,
        ],
    ]

ArticlePolicy.php
public function canEdit(IdentityInterface $user, Article $article)
{
// logged in users can edit their own articles.
return $this->isAuthor($user, $article);

		if ($user->id == $article->user_id) {
        return new Result(true);
    }
    // Results let you define a 'reason' for the failure.
    #return new Result(false, 'not-owner'); //doesn't work like instant message
		
    }

\vendor\cakephp\authorization\src\Controller\Component\AuthorizationComponent.php
Error#:Identity is not authorized to perform edit on App\Model\Entity\Article .
throw new ForbiddenException($result, [$action, $name]);

Hi @HypJ3U50N,

Check my version of the tutorial. I have tested and it’s working. Compare with your files.

src/Application.php
<?php
declare(strict_types=1);

namespace App;

use Cake\Core\Configure;
use Cake\Core\Exception\MissingPluginException;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Http\BaseApplication;
use Cake\Http\MiddlewareQueue;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;

use Authorization\AuthorizationService;
use Authorization\AuthorizationServiceInterface;
use Authorization\AuthorizationServiceProviderInterface;
use Authorization\Middleware\AuthorizationMiddleware;
use Authorization\Exception\AuthorizationRequiredException;
use Authorization\Exception\ForbiddenException;
use Authorization\Policy\OrmResolver;

use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
 * Application setup class.
 *
 * This defines the bootstrapping logic and middleware layers you
 * want to use in your application.
 */
class Application extends BaseApplication implements AuthorizationServiceProviderInterface, AuthenticationServiceProviderInterface
{
    /**
     * Load all the application configuration and bootstrap logic.
     *
     * @return void
     */
    public function bootstrap(): void
    {
        // Call parent to load bootstrap from files.
        parent::bootstrap();

        if (PHP_SAPI === 'cli') {
            $this->bootstrapCli();
        }

        /*
         * Only try to load DebugKit in development mode
         * Debug Kit should not be installed on a production system
         */
        if (Configure::read('debug')) {
            $this->addPlugin('DebugKit');
        }

        // Load more plugins here
        $this->addPlugin('Authentication');
        $this->addPlugin('Authorization');
    }

    /**
     * Setup the middleware queue your application will use.
     *
     * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup.
     * @return \Cake\Http\MiddlewareQueue The updated middleware queue.
     */
    public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
    {
        $middlewareQueue
            // Catch any exceptions in the lower layers,
            // and make an error page/response
            ->add(new ErrorHandlerMiddleware(Configure::read('Error')))

            // Handle plugin/theme assets like CakePHP normally does.
            ->add(new AssetMiddleware([
                'cacheTime' => Configure::read('Asset.cacheTime'),
            ]))

            // Add routing middleware.
            // If you have a large number of routes connected, turning on routes
            // caching in production could improve performance. For that when
            // creating the middleware instance specify the cache config name by
            // using it's second constructor argument:
            // `new RoutingMiddleware($this, '_cake_routes_')`
            ->add(new RoutingMiddleware($this))

            ->add(new AuthenticationMiddleware($this))
            ->add(new AuthorizationMiddleware($this,[
                'unauthorizedHandler' => [
                    'className' => 'Authorization.Redirect',
                    'url' => '/users/login',
                    'queryParam' => 'redirectUrl',
                    'exceptions' => [
                        MissingIdentityException::class,
                        ForbiddenException::class,
                    ],
                ],
            ]));

        return $middlewareQueue;
    }

    /**
     * Bootrapping for CLI application.
     *
     * That is when running commands.
     *
     * @return void
     */
    protected function bootstrapCli(): void
    {
        try {
            $this->addPlugin('Bake');
        } catch (MissingPluginException $e) {
            // Do not halt if the plugin is missing
        }

        $this->addPlugin('Migrations');

        // Load more plugins here
    }

    /*
     */
    public function getAuthorizationService(ServerRequestInterface $request): AuthorizationServiceInterface
    {
        $resolver = new OrmResolver();

        return new AuthorizationService($resolver);
    }

    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => '/users/login',
            'queryParam' => 'redirect',
        ]);

        // Load identifiers, ensure we check email and password fields
        $authenticationService->loadIdentifier('Authentication.Password', [
            'fields' => [
                'username' => 'email',
                'password' => 'password',
            ]
        ]);

        // Load the authenticators, you want session first
        $authenticationService->loadAuthenticator('Authentication.Session');
        // Configure form data check to pick email and password
        $authenticationService->loadAuthenticator('Authentication.Form', [
            'fields' => [
                'username' => 'email',
                'password' => 'password',
            ],
            'loginUrl' => '/users/login',
        ]);

        return $authenticationService;
    }
}
src/Controller/AppController.php
<?php
declare(strict_types=1);

namespace App\Controller;

use Cake\Controller\Controller;

class AppController extends Controller
{
    public function initialize(): void
    {
        parent::initialize();

        $this->loadComponent('RequestHandler');
        $this->loadComponent('Flash');

        /*
         * Enable the following component for recommended CakePHP form protection settings.
         * see https://book.cakephp.org/4/en/controllers/components/form-protection.html
         */
        //$this->loadComponent('FormProtection');

        $this->loadComponent('Authentication.Authentication');
        $this->loadComponent('Authorization.Authorization');
    }

    public function beforeFilter(\Cake\Event\EventInterface $event)
    {
        parent::beforeFilter($event);
        // for all controllers in our application, make index and view
        // actions public, skipping the authentication check
        $this->Authentication->addUnauthenticatedActions(['index', 'view', 'login']);
    }
}
src/Controller/UsersController.php
<?php
declare(strict_types=1);

namespace App\Controller;

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AppController
{
    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $this->Authorization->skipAuthorization();

        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }

    /**
     * View method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Renders view
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $this->Authorization->skipAuthorization();

        $user = $this->Users->get($id, [
            'contain' => [],
        ]);

        $this->set('user', $user);
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $user = $this->Users->newEmptyEntity();
        if ($this->request->is('post')) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Edit method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => [],
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Delete method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $user = $this->Users->get($id);
        if ($this->Users->delete($user)) {
            $this->Flash->success(__('The user has been deleted.'));
        } else {
            $this->Flash->error(__('The user could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }

    public function login()
    {
        $this->Authorization->skipAuthorization();

        $result = $this->Authentication->getResult();
        $this->request->allowMethod(['get', 'post']);
        // regardless of POST or GET, redirect if user is logged in
        if ($result->isValid()) {
            // redirect to /articles after login success
            $redirect = $this->request->getQuery('redirect', [
                'controller' => 'Articles',
                'action' => 'index',
            ]);

            return $this->redirect($redirect);
        }
        // display error if user submitted and authentication failed
        if ($this->request->is('post') && !$result->isValid()) {
            $this->Flash->error(__('Invalid username or password'));
        }
    }

    public function logout()
    {
        $this->Authorization->skipAuthorization();

        $result = $this->Authentication->getResult();
        // regardless of POST or GET, redirect if user is logged in
        if ($result->isValid()) {
            $this->Authentication->logout();
            return $this->redirect(['controller' => 'Users', 'action' => 'login']);
        }
    }
}
src/Policy/ArticlePolicy.php
<?php
declare(strict_types=1);

namespace App\Policy;

use App\Model\Entity\Article;
use Authorization\IdentityInterface;
use Authorization\Policy\Result;

/**
 * Article policy
 */
class ArticlePolicy
{
    /**
     * Check if $user can update Article
     *
     * @param Authorization\IdentityInterface $user The user.
     * @param App\Model\Entity\Article $article
     * @return bool
     */
    public function canUpdate(IdentityInterface $user, Article $article)
    {
        if ($user->id == $article->user_id) {
            return new Result(true);
        }
        // Results let you define a 'reason' for the failure.
        return new Result(false, 'not-owner');
    }

    /**
     * Check if $user can view Article
     *
     * @param Authorization\IdentityInterface $user The user.
     * @param App\Model\Entity\Article $article
     * @return bool
     */
    public function canView(IdentityInterface $user, Article $article)
    {
        return $this->isAuthor($user, $article);
    }

    protected function isAuthor(IdentityInterface $user, Article $article)
    {
        return $article->user_id === $user->getIdentifier();
    }
}
src/Controller/ArticlesController.php
<?php
declare(strict_types=1);

namespace App\Controller;

/**
 * Articles Controller
 *
 * @property \App\Model\Table\ArticlesTable $Articles
 * @method \App\Model\Entity\Article[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class ArticlesController extends AppController
{
    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $this->Authorization->skipAuthorization();

        $this->paginate = [
            'contain' => ['Users'],
        ];
        $articles = $this->paginate($this->Articles);

        $this->set(compact('articles'));
    }

    /**
     * View method
     *
     * @param string|null $id Article id.
     * @return \Cake\Http\Response|null|void Renders view
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $this->Authorization->skipAuthorization();

        $article = $this->Articles->get($id, [
            'contain' => ['Users'],
        ]);

        $this->set('article', $article);
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $article = $this->Articles->newEmptyEntity();
        if ($this->request->is('post')) {
            $article = $this->Articles->patchEntity($article, $this->request->getData());
            if ($this->Articles->save($article)) {
                $this->Flash->success(__('The article has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The article could not be saved. Please, try again.'));
        }
        $users = $this->Articles->Users->find('list', ['limit' => 200]);
        $this->set(compact('article', 'users'));
    }

    /**
     * Edit method
     *
     * @param string|null $id Article id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $article = $this->Articles->get($id, [
            'contain' => [],
        ]);

        $this->Authorization->authorize($article, 'update');

        if ($this->request->is(['patch', 'post', 'put'])) {
            $article = $this->Articles->patchEntity($article, $this->request->getData());
            if ($this->Articles->save($article)) {
                $this->Flash->success(__('The article has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The article could not be saved. Please, try again.'));
        }
        $users = $this->Articles->Users->find('list', ['limit' => 200]);
        $this->set(compact('article', 'users'));
    }

    /**
     * Delete method
     *
     * @param string|null $id Article id.
     * @return \Cake\Http\Response|null|void Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $article = $this->Articles->get($id);
        if ($this->Articles->delete($article)) {
            $this->Flash->success(__('The article has been deleted.'));
        } else {
            $this->Flash->error(__('The article could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }
}

Edit the config/app_local.php to enable Debugkit ignore authorization

'DebugKit.ignoreAuthorization' => true, //add this line before 'debug' line
1 Like

I saw you don’t have $article->user_id = $this->request->getAttribute('identity')->getIdentifier(); and #$this->Authorization->authorize($article); in your ArticlesController.add() . Why not?

Moreover, I also have canAdd and canEdit in addition to your methods.( I was already confused by canEdit and canUpdate, both used in the documentation.)

And what does the ignoring of debug do to the functioning?

Since I have a related issue with the authorization through the identity.can() method, it might be something else.

Because I forgot to implement this, just for it, sorry.

When you call $this->Authorization->authorize($article, 'update') anywhere inside your ArticleController you run the policy check for the resource $article using the method ArticlePolicy.canUpdate(). When you call authorize just with the resource as parameter like $this->Authorization->authorize($article) the request’s action is used to resolve what can method use in policy class, see more here.

You will be able to use the debugkit toolbar when using authorization plugin.