Skip to content

Commit

Permalink
Add Magic Link
Browse files Browse the repository at this point in the history
  • Loading branch information
ajibarra committed Feb 14, 2025
1 parent 9e21a37 commit 3b648c4
Show file tree
Hide file tree
Showing 28 changed files with 716 additions and 44 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php-version: ['8.1', '8.2', '8.3']
php-version: ['8.2', '8.3', '8.4']
db-type: [sqlite, mysql, pgsql]
prefer-lowest: ['']

Expand Down Expand Up @@ -59,22 +59,22 @@ jobs:
fi
- name: Setup problem matchers for PHPUnit
if: matrix.php-version == '8.1' && matrix.db-type == 'mysql'
if: matrix.php-version == '8.2' && matrix.db-type == 'mysql'
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"

- name: Run PHPUnit
run: |
if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi
if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:[email protected]/cakephp?encoding=utf8'; fi
if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:[email protected]/postgres'; fi
if [[ ${{ matrix.php-version }} == '8.1' ]]; then
if [[ ${{ matrix.php-version }} == '8.2' ]]; then
export CODECOVERAGE=1 && vendor/bin/phpunit --display-deprecations --display-incomplete --display-skipped --coverage-clover=coverage.xml
else
vendor/bin/phpunit
fi
- name: Submit code coverage
if: matrix.php-version == '8.1'
if: matrix.php-version == '8.2'
uses: codecov/codecov-action@v1

cs-stan:
Expand All @@ -87,7 +87,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
php-version: '8.2'
extensions: mbstring, intl, apcu
coverage: none

Expand Down
60 changes: 60 additions & 0 deletions Docs/Documentation/MagicLink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Magic Link
===============================
The plugin offers an easy way to add one-click login capabilities (through a link sent to user email)


Installation Requirement
------------------------
There are no package requirements for using this feature. `Users.Email.required` setting must be set to true

By default the feature is enabled. The default configuration is:

```php
'OneTimeLogin' => [
'enabled' => true,
'tokenLifeTime' => 600,
'DeliveryHandlers' => [
'Email' => [
'className' => \CakeDC\Users\Model\Behavior\OneTimeDelivery\EmailDelivery::class
],
],
],
```
* `tokenLifeTime`: 60 minutes by default. You can set how many seconds you want your token to be valid.
* `DelveryHandlers`: Email delivery is included but it can be easily extended implementing `\CakeDC\Users\Model\Behavior\OneTimeDelivery\DeliveryInterface` (i.e SmsDelivery, PushDelivery, etc)

Enabling
--------

The feature is enabled by default but you can disable it application-wide and enable via Middleware (or any other way) for specific situations using:

```php
Configure::write('OneTimeLogin.enabled', true),
```

Disabling
---------
You can disable it by adding this in your config/users.php file:

```php
'OneTimeLogin.enabled' => false,
```

How does it work
----------------
When the user access the login page, there is a new button `Send me a login link`. On click, the user will be redirected to a page to enter his email address. Once it is submitted, the user will receive an email with the link to automatically login.

Two-factor authentication
----------------
The two-factor authentication is skipped by default for this feature since the user must actively click on a link sent to his email address.

If you want to enable it by adding this in your config/users.php file:

```php
'Auth.Authenticators.OneTimeToken.skipTwoFactorVerify' => false,
```

ReCaptcha
----------------
ReCaptcha will be added automatically to the request login link form if `Users.reCaptcha.login` is enabled.

1 change: 1 addition & 0 deletions Docs/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Documentation
* [Social Authentication](Documentation/SocialAuthentication.md)
* [Two Factor Authenticator](Documentation/Two-Factor-Authenticator.md)
* [Webauthn Two-Factor Authentication (Yubico Key compatible)](Documentation/WebauthnTwoFactorAuthenticator.md)
* [Magic Link](Documentation/MagicLink.md)
* [UserHelper](Documentation/UserHelper.md)
* [AuthLinkHelper](Documentation/AuthLinkHelper.md)
* [Events](Documentation/Events.md)
Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
},
"prefer-stable": true,
"require": {
"php": ">=8.1",
"php": ">=8.2",
"cakephp/cakephp": "^5.0",
"cakedc/auth": "^10.0",
"cakedc/auth": "dev-feature/magic-link as 11.0",
"cakephp/authorization": "^3.0",
"cakephp/authentication": "^3.0"
},
Expand All @@ -42,7 +42,7 @@
"league/oauth2-linkedin": "@stable",
"luchianenco/oauth2-amazon": "^1.1",
"google/recaptcha": "@stable",
"robthree/twofactorauth": "^2.0",
"robthree/twofactorauth": "^3.0",
"league/oauth1-client": "^1.7",
"cakephp/cakephp-codesniffer": "^5.0",
"web-auth/webauthn-lib": "^4.4",
Expand Down
31 changes: 31 additions & 0 deletions config/Migrations/20250124082232_AddLoginTokenToUsers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class AddLoginTokenToUsers extends AbstractMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
* @return void
*/
public function change(): void
{
$table = $this->table('users');
$table->addColumn('login_token', 'string', [
'default' => null,
'limit' => 32,
'null' => true,
])->addColumn('login_token_date', 'datetime', [
'default' => null,
'null' => true,
])->addColumn('token_send_requested', 'boolean', [
'default' => false,
'null' => false,
]);
$table->update();
}
}
3 changes: 3 additions & 0 deletions config/permissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
'webauthn2faRegisterOptions',
'webauthn2faAuthenticate',
'webauthn2faAuthenticateOptions',
'requestLoginLink',
'sendLoginLink',
'singleTokenLogin',
],
'bypassAuth' => true,
],
Expand Down
17 changes: 17 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@
\CakeDC\Auth\Authentication\TwoFactorProcessor\Webauthn2faProcessor::class,
\CakeDC\Auth\Authentication\TwoFactorProcessor\OneTimePasswordProcessor::class,
],
'OneTimeLogin' => [
'enabled' => true,
'thresholdTimeout' => 60,
'tokenLifeTime' => 600,
'DeliveryHandlers' => [
'Email' => [
'className' => \CakeDC\Users\Model\Behavior\OneTimeDelivery\EmailDelivery::class
]
]
],
// default configuration used to auto-load the Auth Component, override to change the way Auth works
'Auth' => [
'Authentication' => [
Expand Down Expand Up @@ -219,6 +229,13 @@
'className' => 'CakeDC/Users.SocialPendingEmail',
'skipTwoFactorVerify' => true,
],
'OneTimeToken' => [
'className' => 'CakeDC/Auth.OneTimeToken',
'skipTwoFactorVerify' => true,
'loginUrl' => [
'/login',
],
]
],
'Identifiers' => [
'Password' => [
Expand Down
25 changes: 0 additions & 25 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@ parameters:
count: 2
path: src/Controller/Component/LoginComponent.php

-
message: "#^Access to an undefined property CakeDC\\\\Users\\\\Controller\\\\UsersController\\:\\:\\$Authentication\\.$#"
count: 2
path: src/Controller/UsersController.php

-
message: "#^Access to an undefined property CakeDC\\\\Users\\\\Controller\\\\UsersController\\:\\:\\$OneTimePasswordAuthenticator\\.$#"
count: 3
Expand Down Expand Up @@ -105,11 +100,6 @@ parameters:
count: 1
path: src/Model/Behavior/BaseTokenBehavior.php

-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$social_accounts\\.$#"
count: 2
path: src/Model/Behavior/LinkSocialBehavior.php

-
message: "#^Access to an undefined property Cake\\\\ORM\\\\Table\\:\\:\\$SocialAccounts\\.$#"
count: 5
Expand All @@ -120,21 +110,6 @@ parameters:
count: 1
path: src/Model/Behavior/LinkSocialBehavior.php

-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$password\\.$#"
count: 1
path: src/Model/Behavior/PasswordBehavior.php

-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$password_confirm\\.$#"
count: 1
path: src/Model/Behavior/PasswordBehavior.php

-
message: "#^Call to an undefined method Cake\\\\Datasource\\\\EntityInterface\\:\\:checkPassword\\(\\)\\.$#"
count: 1
path: src/Model/Behavior/PasswordBehavior.php

-
message: "#^Call to an undefined method Cake\\\\ORM\\\\Table\\:\\:findByUsernameOrEmail\\(\\)\\.$#"
count: 1
Expand Down
2 changes: 1 addition & 1 deletion src/Command/UsersDeleteUserCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public function execute(Arguments $args, ConsoleIo $io)
*/
$UsersTable = $this->getTableLocator()->get('Users');
/**
* @var \Cake\Datasource\EntityInterface $user
* @var \CakeDC\Users\Model\Entity\User $user
*/
$user = $UsersTable->find()->where(['username' => $username])->firstOrFail();
if (isset($UsersTable->SocialAccounts)) {
Expand Down
91 changes: 91 additions & 0 deletions src/Controller/Traits/OneTimeTokenTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);

/**
* Copyright 2010 - 2019, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2010 - 2018, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\Users\Controller\Traits;

use Cake\Datasource\Exception\RecordNotFoundException;
use CakeDC\Users\Utility\UsersUrl;

/**
* Covers the login, logout and social login
*
* @property \Cake\Http\ServerRequest $request
*/
trait OneTimeTokenTrait
{
/**
* Request a single token login link.
*
* @return \Cake\Http\Response|null
*/
public function requestLoginLink()
{
if ($this->getRequest()->is('post')) {
$email = $this->getRequest()->getData('email');
try {
/** @var \CakeDC\Users\Model\Table\UsersTable $Users */
$Users = $this->getUsersTable();
/** @uses \CakeDC\Users\Model\Behavior\OneTimeLoginLinkBehavior::sendLoginLink() */
$Users->sendLoginLink($email);
} catch (RecordNotFoundException $e) {
$this->log(
sprintf('A user is trying to get a login link for the email %s but it does not exist.', $email)
);
}
$msg = __d(
'cake_d_c/users',
'If your user is registered in the system you will receive an email ' .
'with a link so you can access your user area.'
);
$this->Flash->success($msg);
$this->setRequest($this->getRequest()->withoutData('email'));

return $this->redirect(UsersUrl::actionUrl('login'));
}

return null;
}

/**
* Single token login.
*
* @return \Cake\Http\Response|null
*/
public function singleTokenLogin()
{
$errorMessage = null;
$token = null;
if ($this->getRequest()->is('get')) {
$token = $this->getRequest()->getQuery('token');
}

if ($this->getRequest()->is('post') || $token) {
$user = $this->Authentication->getIdentity();
$token = $this->getRequest()->getData('token', $token);
if (is_array($token)) {
$token = join($token);
}
if (!$user && !empty($token)) {
$errorMessage = __d('cake_d_c/users', 'Invalid or expired token. Please request a new one.');
}
}

if ($errorMessage) {
$this->Flash->error($errorMessage);

return $this->redirect(UsersUrl::actionUrl('login'));
}

return $this->redirect('/');
}
}
1 change: 1 addition & 0 deletions src/Controller/Traits/PasswordManagementTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ trait PasswordManagementTrait
*/
public function changePassword($id = null)
{
/** @var \CakeDC\Users\Model\Entity\User $user */
$user = $this->getUsersTable()->newEntity([], ['validate' => false]);
$user->setNew(false);

Expand Down
1 change: 1 addition & 0 deletions src/Controller/Traits/ProfileTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public function profile($id = null)
try {
$appContain = (array)Configure::read('Auth.Profile.contain');
$socialContain = Configure::read('Users.Social.login') ? ['SocialAccounts'] : [];
/** @var \CakeDC\Users\Model\Entity\User $user */
$user = $this->getUsersTable()->get($id, contain: array_merge($appContain, $socialContain));
$this->set('avatarPlaceholder', Configure::read('Users.Avatar.placeholder'));
if ($user->id === $loggedUserId) {
Expand Down
5 changes: 3 additions & 2 deletions src/Controller/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use CakeDC\Users\Controller\Traits\LinkSocialTrait;
use CakeDC\Users\Controller\Traits\LoginTrait;
use CakeDC\Users\Controller\Traits\OneTimePasswordVerifyTrait;
use CakeDC\Users\Controller\Traits\OneTimeTokenTrait;
use CakeDC\Users\Controller\Traits\ProfileTrait;
use CakeDC\Users\Controller\Traits\ReCaptchaTrait;
use CakeDC\Users\Controller\Traits\RegisterTrait;
Expand All @@ -32,6 +33,7 @@
*
* @property \CakeDC\Users\Model\Table\UsersTable $Users
* @property \Cake\Controller\Component\FormProtectionComponent|null $FormProtection
* @property \Authentication\Controller\Component\AuthenticationComponent|null $Authentication
*/
class UsersController extends AppController
{
Expand All @@ -44,8 +46,7 @@ class UsersController extends AppController
use SimpleCrudTrait;
use SocialTrait;
use Webauthn2faTrait;

// use CustomUsersTableTrait;
use OneTimeTokenTrait;

/**
* Initialize
Expand Down
Loading

0 comments on commit 3b648c4

Please sign in to comment.