Over 13 years ago I wrote about my TinyAuth plugin as enhancement to the core Auth component, at the time the state of the art auth tool you needed for a quick auth setup in Cake apps.
Lets revisit that now with the new auth plugins in town.
Auth in CakePHP 5 the easy way.
I had apps that upgraded from Cake 4 to 5, so I also tried out the authentication plugin.
You can actually easily upgrade the whole thing, keeping your code almost identical and working.
Afterwards you can still chose if you want to use the TinyAuth wrappers or the Cake ones.
Same goes for authorization. If you only need simple role checking here, you can stay with TinyAuth and keeps things simple.
But let’s start this tutorial at the beginning.
Setup
Load the TinyAuth and Authentication plugins and add the AuthenticationMiddleware
and your getAuthenticationService()
method to the Application class as per docs.
Most of the time, you will probably use something like this in this order:
$service->loadAuthenticator('Authentication.Session');
$service->loadAuthenticator('Authentication.Form', [...]);
$service->loadAuthenticator('Authentication.Cookie', [...]);
The following examples also use the mighty Tools plugin. This is optional, however.
You could use your own token-handling for example.
For simplicity reasons I now display examples using this plugin.
The User entity should have the password field hidden:
protected array $_hidden = [
'password',
];
With Tools plugin usage nothing else needed, no virtual setter or getter! This is different from the authentication plugin docs.
Using Tools.Passwordable behavior you make it safer as you cannot by accident or "hacked" actions pass in passwords in unrelated actions.
In your AppController::initialize()
you probably want
$this->loadComponent('TinyAuth.Authentication');
$this->loadComponent('TinyAuth.AuthUser');
And AppView::initialize()
:
$this->addHelper('TinyAuth.AuthUser');
TinyAuth allows us to use a central auth_allow.ini file and avoid cluttering the controllers.
So make sure to set up the unauthenticated actions as such:
Account = login,logout,create,resetPwd,setPwd
Pages = *
I usually use an AccountController for this account stuff, as is reads nicer also in URL.
/account
(Account::index) is your dashboard once you logged in.
Usage
Now you want to set up register/login in this AccountController:
public function create()
{
$user = $this->Users->newEmptyEntity();
if ($this->Common->isPosted()) {
$this->Users->addBehavior('Tools.Passwordable', ['confirm' => false]);
$user = $this->Users->patchEntity($user, $this->request->getData(), ['fields' => ['name', 'email']]);
$user->role = UserRole::User;
if ($this->Users->save($user)) {
$this->Flash->success(__('Registration successful.'));
$this->Authentication->setIdentity($user);
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('The form contains errors. Please fix those and try again.'));
}
$this->set(compact('user'));
}
You usually want the user to still confirm the email, so often he would not have a role just yet or is not marked as "active" yet.
An email is sent out to click on a link to fully activate the account in the Table afterSave() hook for create case.
Notes:
Only add the Passwordable behavior in the few places that need it when hashing passwords.
If you want your user to confirm a password (2nd control field), set confirm to true.
The roles are an enum in this case:
// App/Model/Enum
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));
}
This allows bake to automatically populate the role_id for all templates and forms correctly.
Then lets look at login action:
public function login()
{
$user = $this->Users->newEmptyEntity();
$result = $this->Authentication->getResult();
// If the user is logged in send them away.
if ($result && $result->isValid()) {
$this->Users->loginUpdate($result);
$target = $this->Authentication->getLoginRedirect() ?? '/';
$this->Flash->success(__('You are now logged in.'));
return $this->redirect($target);
}
if ($this->request->is('post')) {
$this->Flash->error(__('Invalid email or password'));
$user->email = $this->request->getData('email');
}
$this->set(compact('user'));
}
The call to loginUpdate()
just sets the timestamp and login count + 1. It is optional, but something I like to set up in the UsersTable to know who didnt log in for like forever and can remove those every x months.
It could be as simple as
/** @var \App\Model\Entity\User $user */
$user = $result->getData();
$this->updateAll(['last_login' => new DateTime()], ['id' => $user->id]);
Logout can be as simple as
public function logout()
{
$alreadyLoggedOut = !$this->request->getSession()->read('Auth.User.id');
$whereTo = $this->Authentication->logout() ?: ['action' => 'login'];
if ($alreadyLoggedOut) {
$this->Flash->success(__('You are already logged out.'));
} else {
$this->Flash->success(__('You have successfully logged out.'));
}
return $this->redirect($whereTo);
}
You should already be set for allowing users to register and login/logout.
Additional actions
I usually add specific edit actions for email and password.
The former would require another email confirmation before actually switching, using Tools.Tokens table.
public function changeEmail()
{
$user = $this->Users->get($this->AuthUser->id());
if ($this->Common->isPosted()) {
$data = [];
$data['email'] = $this->request->getData('email');
$user = $this->Users->newEntity($data, ['fields' => ['email']]);
if (!$user->getError('email')) {
$email = $user->email;
# get new Key
$tokensTable = $this->fetchTable('Tools.Tokens');
$key = $tokensTable->newKey('email', null, $this->AuthUser->id(), $email);
$name = $user->name;
$this->Users->sendConfirmEmail($email, $name, $key);
$this->Flash->success(__('You will shortly receive a validation email. Please validate the change by clicking on the link.'));
return $this->redirect(['action' => 'confirmEmail']);
}
$this->Flash->error(__('The form contains errors. Please fix those and try again.'));
} else {
$user->email = '';
}
$this->set(compact('user'));
}
public function confirmEmail($key = null)
{
$keyToCheck = null;
if ($this->Common->isPosted()) {
if ($this->request->getData('key')) {
$keyToCheck = trim((string)$this->request->getData('key'));
}
} elseif ($key) {
$keyToCheck = $key;
}
if ($keyToCheck) {
$tokensTable = $this->fetchTable('Tools.Tokens');
$token = $tokensTable->useKey('email', $keyToCheck);
if ($token && $token->used) {
$this->Flash->warning(__('You already confirmed your email'));
return $this->redirect(['action' => 'index']);
}
if ($token) {
$uid = $token->user_id;
$user = $this->Users->get($uid);
$this->Users->patchEntity($user, ['email' => $token->content]);
$user->email_verified_at = new DateTime();
$this->Users->saveOrFail($user);
$this->Flash->success(__('You confirmed your email `' . $user->email . '`'));
// Update identity here to have the new email in session (following only works for default session)
$this->request->getSession()->write('Auth.User.email', $user->email);
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('Invalid Key'));
}
$user = null;
if ($this->AuthUser->id()) {
$user = $this->Users->get($this->AuthUser->id());
}
$this->set(compact('user'));
}
The confirmation email would just contain the link with the generated one-time key:
$key = TableRegistry::getTableLocator()->get('Tools.Tokens')->newKey('reset_pwd', null, $user->id);
// Send email with the password reset link
For the password we just ask the current password and confirm the new one:
public function changePwd()
{
$user = $this->Users->get($this->AuthUser->id());
$this->Users->addBehavior('Tools.Passwordable', ['current' => (bool)$user->password]);
if ($this->Common->isPosted()) {
$user = $this->Users->patchEntity($user, $this->request->getData(), ['fields' => ['pwd', 'pwd_current', 'pwd_repeat']]);
if ($this->Users->save($user)) {
$this->Flash->success(__('New pwd saved - next time you login you will need your new password.'));
return $this->redirect(['controller' => 'Account', 'action' => 'index']);
}
$this->Flash->error(__('The form contains errors. Please fix those and try again.'));
// pwd should not be passed to the view again for security reasons
$this->request = $this->request->withData('pwd', '');
$this->request = $this->request->withData('pwd_repeat', '');
}
$this->set(compact('user'));
}
Note how we use AuthUser component to quickly access the identity data. You can also use the request content of course, manually.
You also want a reset password functionality in case you are not logged in and/or cannot remember your password:
public function resetPwd()
{
$user = $this->Users->newEmptyEntity();
if ($this->request->getData('login')) {
$login = (string)$this->request->getData('login');
/** @var \App\Model\Entity\User|null $user */
$user = $this->Users->find('all', fields: ['name', 'id', 'email'], conditions: ['OR' => ['name' => $login, 'email' => $login]])
->first();
if ($user) {
$uid = $user->id;
$tokensTable = $this->fetchTable('Tools.Tokens');
$cCode = $tokensTable->newKey('reset_pwd', null, $uid);
$name = $user->name;
// Send email to user with link to setPwd() action
}
$this->Flash->success(__('An email has been sent to your account if there is one for this login - after clicking on the link you can set a new password.'));
return $this->redirect([]);
}
$this->set(compact('user'));
}
public function setPwd($keyToCheck = null)
{
$tokensTable = $this->fetchTable('Tools.Tokens');
$token = $tokensTable->useKey('reset_pwd', $keyToCheck);
if (!$token) {
$this->Flash->error(__('Invalid Key'));
return $this->redirect(['action' => 'login']);
}
if ($token && $token->used) {
$this->Flash->warning(__('You already set a new password. Please now try to log in.'));
return $this->redirect(['action' => 'login']);
}
$uid = $token->user_id;
$user = $this->Users->get($uid);
$this->Users->addBehavior('Tools.Passwordable');
if ($this->Common->isPosted()) {
$data = $this->request->getData();
$user = $this->Users->patchEntity($user, $data, ['fields' => []]);
if ($this->Users->save($user)) {
$this->Flash->success(__('New password saved - you may now log in'));
$name = $user->name ?: $user->email;
return $this->redirect(['action' => 'login', '?' => ['as' => $name]]);
}
$this->Flash->error(__('The form contains errors. Please fix those and try again.'));
}
$this->set(compact('user'));
}
You might also want to have an action to allow a user to remove this account:
public function delete()
{
$this->request->allowMethod(['post']);
$user = $this->Users->get($this->AuthUser->id());
if (!$this->Users->delete($user)) {
throw new InternalErrorException('Could not delete user . ' . $this->AuthUser->id());
}
$this->Flash->success(__('Your account has been removed completely.'));
$whereTo = $this->Authentication->logout() ?: ['action' => 'login'];
return $this->redirect($whereTo);
}
Admin functionality
For some admin functionality you might need to (re)set a users password. You can either do that by generating a reset-password link for them, or you could also reset it in the edit action like so:
public function edit($id = null)
{
$user = $this->Users->get($id);
if ($this->request->is(['patch', 'post', 'put'])) {
$this->Users->addBehavior('Tools.Passwordable', ['require' => false, 'confirm' => false]);
$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'));
}
The form contains a pwd field that is then optional, as long as it is not filled out, no password change would happen.
<fieldset>
<legend><?= __('Password')?></legend>
<?php
echo $this->Form->control('pwd', ['label' => 'Set Password']);
?>
</fieldset>
ACL
We cannot go too deep now into this, too. But basic ACL is now available via TinyAuth and auth_acl.ini file anywhere in your controller/component or template/helper code:
if ($this->AuthUser->hasRole('admin')) ...
You can use the helper even for isMe() and other gotchas.
Auth panel
You can add the Auth panel to your DebugKit bar, making it easy to see what the auth status current action is and get some current auth session infos.
'DebugKit' => [
'variablesPanelMaxDepth' => 9,
'panels' => [
'DebugKit.Packages' => false,
'TinyAuth.Auth' => true,
'Setup.L10n' => true,
],
],
Tips
For a valid username I would recommend not allowing too many UTF8 or emoji characters outside of normal textual range.
This makes it harder to search/filter them.
I use this custom validation rule:
/**
* @param string $value
*
* @return bool
*/
public function validateUsername($value)
{
return (bool)preg_match('/^[{0-9}\p{L}_-]+$/u', $value);
}
When working with demo data or data dumps locally, it can be handy to also have the Setup plugin installed:
bin/cake reset pwd 123
And all your accounts now have a simple password to login with, whatever they might have been before.
Make sure to only use this for local development!
You can also quickly create a (admin) user from CLI:
bin/cake user create {name}
Select the role (configured via app config "Roles" key), enter a password and you should be able to insert one ready to login with.
Demo
Some of the role topics is visible in the sandbox.
Those examples use only TinyAuth so far, though.