1

everyone. I apologize if this has been asked before, but I’m having a hard time finding anything on this subject.

I have the Authorization plugin loaded in my application, and it works fine, except for the messages the user gets back when they’re not authorized to do something. For example, only Site Admins are allowed to do anything with user accounts:

public function canAdd(IdentityInterface $user, User $resource)
{
    // Site admins can add users
    $session = Router::getRequest()->getSession();
    $groups = $session->read('groups');
    return in_array('site_admin', $groups);
}    

Calling $this->Authorization->authorize($user) in UsersController works, and anyone who doesn't have the Site Admin group permission is barred, but how can I customize the error message an unauthorized user sees and redirect them somewhere else? There doesn’t seem to be an obvious way to do this.

ndm
  • 59,784
  • 9
  • 71
  • 110
Chris
  • 535
  • 3
  • 20
  • You want them to see a custom message and _then_ redirect them? Or the other way around? The latter would sound like you'd want to set a flash message? – ndm Feb 05 '21 at 20:41
  • 1
    You're wanting to know how you can change how your application [handles unauthorized requests](https://book.cakephp.org/authorization/2/en/middleware.html#handling-unauthorized-requests)? – Greg Schmidt Feb 05 '21 at 20:54
  • @Greg Schmidt: That was the first thing I tried, and it didn’t work, even after adding ForbiddenException to the exception list. The middleware-generated exception error always appeared, instead. – Chris Feb 06 '21 at 12:34
  • If it's ignored, then you possibly did not import (the correct) class name. My question still stands, it sounds like you're looking for setting a flash message? Also please always add the things that you've already tried to your question, that helps a lot to get to the point more easily - thanks! – ndm Feb 06 '21 at 12:56
  • @ndm: I would like to redirect them, then display a message saying they're not authorized to access that page. As mentioned above, I did try doing this through the middleware config as outlined in the documentation, but it never worked. I thought that adding `ForbiddenException::class` to the list of exceptions was enough, but clearly I missed something, or the class name is wrong. – Chris Feb 07 '21 at 03:48

1 Answers1

2

Redirecting

As mentioned in the comments, if the redirect doesn't happen, then you've likely not imported the (correct) FQN for that exception, it should be:

\Authorization\Exception\ForbiddenException

Using this in the unauthorizedHandler config of the authorization middleware should work fine using the example shown in the docs, eg:

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

This would cover the redirect part. If you also want to show a message to the user, then you pretty much have two options, either redirect to a page that has a more or less hardcoded message, or use your own redirect handler and set a flash message.

Setting a flash message

A simple example for the latter could look like this:

<?php
// in src/Middleware/UnauthorizedHandler/FlashRedirectHandler.php

namespace App\Middleware\UnauthorizedHandler;

use Authorization\Exception\Exception;
use Authorization\Middleware\UnauthorizedHandler\RedirectHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class FlashRedirectHandler extends RedirectHandler
{
    public function handle(
        Exception $exception,
        ServerRequestInterface $request,
        array $options = []
    ): ResponseInterface
    {
        $response = parent::handle($exception, $request, $options);

        $message = sprintf(
            'You are not authorized to access the requested resource `%s`.',
            $request->getRequestTarget()
        );

        /** @var \Cake\Http\FlashMessage $flash */
        $flash = $request->getAttribute('flash');
        $flash->error($message);

        return $response;
    }
}
'unauthorizedHandler' => [
    'className' => \App\Middleware\UnauthorizedHandler::class,
    // ...
],

The flash message class has been introduced with CakePHP 4.2, in earlier versions you would have to manually write into the session, which isn't overly nice:

/** @var \Cake\Http\Session $session */
$session = $request->getAttribute('session');

$messages = (array)$session->read('Flash.flash', []);
$messages[] = [
    'message' => $message,
    'key' => 'flash',
    'element' => 'flash/error',
    'params' => [],
];
$session->write('Flash.flash', $messages);

ps

You should find a better way to obtain the groups of the identity, IMHO having dependencies like sessions in your policies is destined to cause you trouble further down the road.

Ideally the identity should hold the groups that it belongs to, so that you can simply do:

return in_array('site_admin', $user['groups'], true);

See CakePHP Authentication Plugin Identity Associations

ndm
  • 59,784
  • 9
  • 71
  • 110
  • So, given that I have this association in my UsersTable: `$this->hasMany('UserGroups', [ 'className' => 'UserGroups', 'foreignKey' => 'user_id', 'joinType' => 'INNER', ]);` What would be the best way to get that information into a call to `$this->request->getAttribute('identity')`? A contain statement? I really only need the ID's of the groups from the join table `user_groups` to be able to use them the way you suggest. – Chris Feb 08 '21 at 13:21
  • Also, your suggestion worked. Putting in the full path to the ForbiddenException class was the key. – Chris Feb 08 '21 at 13:29
  • 1
    @Chris There's dozens of ways to query and format the data to only retrieve the the IDs, for example a `list` finder for the containment: `contain('UserGroups', function ($q) { return $q->find('list', ['valueField' => 'id']); })`. However retrieving all the group's data is likely fine too (maybe even better so that your entity doesn't receive unexpected data for a field that would usually hold an array of associated record data), you'd then adjust the policy accordingly, for example use a collection matcher: `collection($user['groups'])->firstMatch(['id' => $siteAdminGroupId]) !== null`. – ndm Feb 08 '21 at 14:05