Part 2 of 2.
We continue on the auth topic of the previous post.
Let’s talk ACL
In this second post, we’ll dive into role-based access and how to achieve it easily and quickly.
We use the authentication plugin by wrapping it via TinyAuth.
Role field
Now the "role" field in your Users table becomes relevant.
In my case it is a simple string (enum in the implementation).
The Users table in my case has this added:
$this->getSchema()->setColumnType('role', EnumType::from(UserRole::class));
And UserRole enum most likely something like
enum UserRole: string implements EnumLabelInterface
{
case Admin = 'admin';
case Manager = 'manager';
case User = 'user';
/**
* @return string
*/
public function label(): string
{
return Inflector::humanize(Inflector::underscore($this->name));
}
}
You can also use a Roles table and join it into the Users one via role_id
field.
In my case I just put the roles into Configure:
'Roles' => [
ROLE_ADMIN => ROLE_ADMIN,
ROLE_MANAGER => ROLE_MANAGER,
ROLE_USER => ROLE_USER,
],
and specific it for TinyAuth:
'TinyAuth' => [
'roleColumn' => 'role',
],
Note: The constants are defined in the bootstrap and just so I don’t have to use magic strings here too much:
define('ROLE_USER', 'user');
define('ROLE_MANAGER', 'manager');
define('ROLE_ADMIN', 'admin');
You can also make a more complex multi-role setup using a "user_roles" pivot table and HABTM.
The plugin would be able to handle this. In my experience often this is not needed, though, unless the app becomes
really huge.
Quick Setup
In your AppController::initialize()
you probably want
$this->loadComponent('TinyAuth.Authentication');
$this->loadComponent('TinyAuth.Authorization'); // Add this now
You can follow the docs on how to set up your Application class.
It depends a bit on your strategy, but TinyAuth specifically uses route-based "request policy" using a handy ACL file.
It helps to keep it all centralized in one place to keep things concise and clear.
You probably add something like this to the Application.php
right after the Authentication middleware:
->add(new AuthorizationMiddleware($this, [
'unauthorizedHandler' => [
'className' => 'Authorization.Redirect',
'url' => '/account/login',
],
]))
and
/**
* @param \Psr\Http\Message\ServerRequestInterface $request
*
* @return \Authorization\AuthorizationServiceInterface
*/
public function getAuthorizationService(ServerRequestInterface $request):
AuthorizationServiceInterface
{
$map = [
ServerRequest::class => new RequestPolicy(),
];
$resolver = new MapResolver($map);
return new AuthorizationService($resolver);
}
The latter requires you to also implement the AuthorizationServiceProviderInterface
:
class Application extends BaseApplication implements
AuthenticationServiceProviderInterface,
AuthorizationServiceProviderInterface
{
Then we set up a CONFIG/auth_acl.ini
with basic ACL info.
Could look like this:
[Account]
* = *
[Products]
index, view = user
* = manager
[Users]
* = manager
[Admin/Users]
* = admin
Unauthorized Handling
By default there would be an exception through, so we want to catch them and provide a user-friendly redirect to the login page here.
There are two reasons:
- Not logged in (no authentication) via MissingIdentityException
- Logged in but not allowed (no authorization) via ForbiddenException
They come from different respective components, but we want to catch them both.
For this I use the normal Redirect handler above for authentication.
For authorization, we’d need a second one on top.
The latter would usually not go back to /account/login
, since you are already logged in, but maybe to the dashboard or /account
,
an action that every role has access to, in order to prevent potential redirect loops.
I personally like the routes to be fallback generated for most of my apps.
Since that is just more flexible to quickly adding more (backend) functionality.
In this case we need to make sure to inject a redirect handler into the route middleware stack from the controller(s).
You can put this at the end of AppController::initialize()
:
$this->middleware(function (ServerRequest $request, $handler): ResponseInterface {
$config = [
'unauthorizedHandler' => [
'className' => 'TinyAuth.ForbiddenRedirect',
'url' => '/account',
],
];
$middleware = new RequestAuthorizationMiddleware($config);
return $middleware->process($request, $handler);
});
This way this is caught before the fallback routing attempts to find any nonexistent controllers or actions.
DebugKit
If you installed the TinyAuth DebugKit panel in the previous post, you should be able to see each pages’ access listed there for each role.
Dynamic role checking
Throughout the app you can use the AuthUser component and helper to check for more granular access to certain elements:
if ($this->AuthUser->hasRole(ROLE_MANAGER)) {
// Do something on top
}
Or maybe you want to only display certain links for the right roles in your templates:
if ($this->AuthUser->hasAccess(['action' => 'moderate', $id])) {
echo ...;
}
You can also further use the Authorization plugin’s own Policy functionality.
That’s a complete new topic on top here, though. I didn’t have to use them yet, as the above covered it for me so far.
Impersonation
With roles in place, it now makes sense for some apps to support impersonation—where an admin or manager can temporarily log in as another user with a single click.
You don’t need to share any passwords, just switch user and then once done switch back and be directly logged in as original user again.
This feature is built in the Authentication component, we can now allow this for only roles with enough rights and provide a link here in the /admin/users
backend for example:
<?php if ($this->AuthUser->hasRole(ROLE_ADMIN) && $this->AuthUser->id() !== $user->id) {
echo $this->Form->postLink($this->Icon->render('user', [], ['title' => 'Impersonate']),
['prefix' => false, 'controller' => 'Account', 'action' => 'impersonate', $user->id],
['confirm' => 'Sure?'],
);
} ?>
The corresponding action might just look like this:
public function impersonate(?int $userId) {
$this->request->allowMethod(['post']);
// You don't need this if your ACL INI already restricts this action to the admin role.
if (!$this->AuthUser->hasRole(ROLE_ADMIN)) {
throw new NotFoundException();
}
// Fetch the user we want to impersonate.
$targetUser = $this->Users->get($this->request->getData('user_id') ?: $userId);
$this->Authentication->impersonate($targetUser);
return $this->redirect(['action' => 'index']);
}
By the way: Some actions should not be allowed here, like modifying their own personal data maybe:
if ($this->Authentication->isImpersonating()) {
$this->Flash->warning('Personal data cannot be edited in impersonation mode.');
return $this->redirect($this->referer(['action' => 'index']));
}
Redirecting away from login actions
When your user is already logged in, if any browser tab still is on the login page and they refresh, they should not see
the login screen again and try to login.
Instead, the more user friendly approach here would be to redirect them to the login redirect page (home).
For this I usually put a list of actions into the AppController:
/**
* @var array<array<string>>
*/
protected array $loggedInRedirect = ['Account' => ['login', 'register', ...]];
Add in AppController::beforeFilter()
:
if ($this->request->getSession()->check('Auth.id') && isset($this->loggedInRedirect[$this->request->getParam('controller')])) {
if (in_array($this->request->getParam('action'), $this->loggedInRedirect[$this->request->getParam('controller')], true)) {
$loginRedirect = '/account';
if ($this->components()->has('Authentication')) {
$loginRedirect = Router::normalize($this->Authentication->getLoginRedirect() ?? $loginRedirect);
}
return $this->redirect($loginRedirect);
}
}
Usually they won’t even notice the quick redirect and can directly continue what they wanted to do.
Summary
This demonstrates a super simple ACL approach that’s transparent and avoids bloating your codebase.
If you need more sophisticated strategies, you can check the Auth internal policy functionality.