Skip to content

Commit 9f54da8

Browse files
committed
Add Magic Link
1 parent 9e21a37 commit 9f54da8

29 files changed

+726
-57
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
php-version: ['8.1', '8.2', '8.3']
16+
php-version: ['8.1', '8.2', '8.3', '8.4']
1717
db-type: [sqlite, mysql, pgsql]
1818
prefer-lowest: ['']
1919

@@ -45,7 +45,7 @@ jobs:
4545
run: echo "::set-output name=date::$(date +'%Y-%m')"
4646

4747
- name: Cache composer dependencies
48-
uses: actions/cache@v1
48+
uses: actions/cache@v4
4949
with:
5050
path: ${{ steps.composer-cache.outputs.dir }}
5151
key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }}
@@ -59,22 +59,22 @@ jobs:
5959
fi
6060
6161
- name: Setup problem matchers for PHPUnit
62-
if: matrix.php-version == '8.1' && matrix.db-type == 'mysql'
62+
if: matrix.php-version == '8.2' && matrix.db-type == 'mysql'
6363
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
6464

6565
- name: Run PHPUnit
6666
run: |
6767
if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi
6868
if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp?encoding=utf8'; fi
6969
if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi
70-
if [[ ${{ matrix.php-version }} == '8.1' ]]; then
70+
if [[ ${{ matrix.php-version }} == '8.2' ]]; then
7171
export CODECOVERAGE=1 && vendor/bin/phpunit --display-deprecations --display-incomplete --display-skipped --coverage-clover=coverage.xml
7272
else
7373
vendor/bin/phpunit
7474
fi
7575
7676
- name: Submit code coverage
77-
if: matrix.php-version == '8.1'
77+
if: matrix.php-version == '8.2'
7878
uses: codecov/codecov-action@v1
7979

8080
cs-stan:
@@ -87,7 +87,7 @@ jobs:
8787
- name: Setup PHP
8888
uses: shivammathur/setup-php@v2
8989
with:
90-
php-version: '8.1'
90+
php-version: '8.2'
9191
extensions: mbstring, intl, apcu
9292
coverage: none
9393

@@ -100,7 +100,7 @@ jobs:
100100
run: echo "::set-output name=date::$(date +'%Y-%m')"
101101

102102
- name: Cache composer dependencies
103-
uses: actions/cache@v1
103+
uses: actions/cache@v4
104104
with:
105105
path: ${{ steps.composer-cache.outputs.dir }}
106106
key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }}

Docs/Documentation/MagicLink.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Magic Link
2+
===============================
3+
The plugin offers an easy way to add one-click login capabilities (through a link sent to user email)
4+
5+
6+
Installation Requirement
7+
------------------------
8+
There are no package requirements for using this feature. `Users.Email.required` setting must be set to true
9+
10+
By default the feature is enabled. The default configuration is:
11+
12+
```php
13+
'OneTimeLogin' => [
14+
'enabled' => true,
15+
'tokenLifeTime' => 600,
16+
'DeliveryHandlers' => [
17+
'Email' => [
18+
'className' => \CakeDC\Users\Model\Behavior\OneTimeDelivery\EmailDelivery::class
19+
],
20+
],
21+
],
22+
```
23+
* `tokenLifeTime`: 60 minutes by default. You can set how many seconds you want your token to be valid.
24+
* `DelveryHandlers`: Email delivery is included but it can be easily extended implementing `\CakeDC\Users\Model\Behavior\OneTimeDelivery\DeliveryInterface` (i.e SmsDelivery, PushDelivery, etc)
25+
26+
Enabling
27+
--------
28+
29+
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:
30+
31+
```php
32+
Configure::write('OneTimeLogin.enabled', true),
33+
```
34+
35+
Disabling
36+
---------
37+
You can disable it by adding this in your config/users.php file:
38+
39+
```php
40+
'OneTimeLogin.enabled' => false,
41+
```
42+
43+
How does it work
44+
----------------
45+
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.
46+
47+
Two-factor authentication
48+
----------------
49+
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.
50+
51+
If you want to enable it by adding this in your config/users.php file:
52+
53+
```php
54+
'Auth.Authenticators.OneTimeToken.skipTwoFactorVerify' => false,
55+
```
56+
57+
ReCaptcha
58+
----------------
59+
ReCaptcha will be added automatically to the request login link form if `Users.reCaptcha.login` is enabled. We strongly recommend having ReCaptcha enabled, because it's a public form that could be targeted by an attacker to send multiple requests.
60+

Docs/Home.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Documentation
2222
* [Social Authentication](Documentation/SocialAuthentication.md)
2323
* [Two Factor Authenticator](Documentation/Two-Factor-Authenticator.md)
2424
* [Webauthn Two-Factor Authentication (Yubico Key compatible)](Documentation/WebauthnTwoFactorAuthenticator.md)
25+
* [Magic Link](Documentation/MagicLink.md)
2526
* [UserHelper](Documentation/UserHelper.md)
2627
* [AuthLinkHelper](Documentation/AuthLinkHelper.md)
2728
* [Events](Documentation/Events.md)

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"require": {
3131
"php": ">=8.1",
3232
"cakephp/cakephp": "^5.0",
33-
"cakedc/auth": "^10.0",
33+
"cakedc/auth": "^10.1",
3434
"cakephp/authorization": "^3.0",
3535
"cakephp/authentication": "^3.0"
3636
},
@@ -42,7 +42,8 @@
4242
"league/oauth2-linkedin": "@stable",
4343
"luchianenco/oauth2-amazon": "^1.1",
4444
"google/recaptcha": "@stable",
45-
"robthree/twofactorauth": "^2.0",
45+
"robthree/twofactorauth": "^3.0 || ^2.0",
46+
"endroid/qr-code": "^6.0 || ^5.0",
4647
"league/oauth1-client": "^1.7",
4748
"cakephp/cakephp-codesniffer": "^5.0",
4849
"web-auth/webauthn-lib": "^4.4",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
use Migrations\AbstractMigration;
5+
6+
class AddLoginTokenToUsers extends AbstractMigration
7+
{
8+
/**
9+
* Change Method.
10+
*
11+
* More information on this method is available here:
12+
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
13+
* @return void
14+
*/
15+
public function change(): void
16+
{
17+
$table = $this->table('users');
18+
$table->addColumn('login_token', 'string', [
19+
'default' => null,
20+
'limit' => 32,
21+
'null' => true,
22+
])->addColumn('login_token_date', 'datetime', [
23+
'default' => null,
24+
'null' => true,
25+
])->addColumn('token_send_requested', 'boolean', [
26+
'default' => false,
27+
'null' => false,
28+
])->addIndex('login_token');
29+
$table->update();
30+
}
31+
}

config/permissions.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@
7979
'webauthn2faRegisterOptions',
8080
'webauthn2faAuthenticate',
8181
'webauthn2faAuthenticateOptions',
82+
'requestLoginLink',
83+
'sendLoginLink',
84+
'singleTokenLogin',
8285
],
8386
'bypassAuth' => true,
8487
],

config/users.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@
160160
// The algorithm used
161161
'algorithm' => enum_exists(\RobThree\Auth\Algorithm::class) ? \RobThree\Auth\Algorithm::Sha1 : null,
162162
// QR-code provider (more on this later)
163-
'qrcodeprovider' => null,
163+
'qrcodeprovider' => new \RobThree\Auth\Providers\Qr\EndroidQrCodeProvider(),
164164
// Random Number Generator provider (more on this later)
165165
'rngprovider' => null,
166166
],
@@ -174,6 +174,19 @@
174174
\CakeDC\Auth\Authentication\TwoFactorProcessor\Webauthn2faProcessor::class,
175175
\CakeDC\Auth\Authentication\TwoFactorProcessor\OneTimePasswordProcessor::class,
176176
],
177+
/**
178+
* @see https://github.com/CakeDC/users/blob/14.next-cake5/Docs/Documentation/MagicLink.md
179+
*/
180+
'OneTimeLogin' => [
181+
'enabled' => true,
182+
'thresholdTimeout' => 60,
183+
'tokenLifeTime' => 600,
184+
'DeliveryHandlers' => [
185+
'Email' => [
186+
'className' => \CakeDC\Users\Model\Behavior\OneTimeDelivery\EmailDelivery::class
187+
]
188+
]
189+
],
177190
// default configuration used to auto-load the Auth Component, override to change the way Auth works
178191
'Auth' => [
179192
'Authentication' => [
@@ -219,6 +232,13 @@
219232
'className' => 'CakeDC/Users.SocialPendingEmail',
220233
'skipTwoFactorVerify' => true,
221234
],
235+
'OneTimeToken' => [
236+
'className' => 'CakeDC/Auth.OneTimeToken',
237+
'skipTwoFactorVerify' => true,
238+
'loginUrl' => [
239+
'/login',
240+
],
241+
]
222242
],
223243
'Identifiers' => [
224244
'Password' => [

phpstan-baseline.neon

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ parameters:
1010
count: 2
1111
path: src/Controller/Component/LoginComponent.php
1212

13-
-
14-
message: "#^Access to an undefined property CakeDC\\\\Users\\\\Controller\\\\UsersController\\:\\:\\$Authentication\\.$#"
15-
count: 2
16-
path: src/Controller/UsersController.php
17-
1813
-
1914
message: "#^Access to an undefined property CakeDC\\\\Users\\\\Controller\\\\UsersController\\:\\:\\$OneTimePasswordAuthenticator\\.$#"
2015
count: 3
@@ -105,11 +100,6 @@ parameters:
105100
count: 1
106101
path: src/Model/Behavior/BaseTokenBehavior.php
107102

108-
-
109-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$social_accounts\\.$#"
110-
count: 2
111-
path: src/Model/Behavior/LinkSocialBehavior.php
112-
113103
-
114104
message: "#^Access to an undefined property Cake\\\\ORM\\\\Table\\:\\:\\$SocialAccounts\\.$#"
115105
count: 5
@@ -120,21 +110,6 @@ parameters:
120110
count: 1
121111
path: src/Model/Behavior/LinkSocialBehavior.php
122112

123-
-
124-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$password\\.$#"
125-
count: 1
126-
path: src/Model/Behavior/PasswordBehavior.php
127-
128-
-
129-
message: "#^Access to an undefined property Cake\\\\Datasource\\\\EntityInterface\\:\\:\\$password_confirm\\.$#"
130-
count: 1
131-
path: src/Model/Behavior/PasswordBehavior.php
132-
133-
-
134-
message: "#^Call to an undefined method Cake\\\\Datasource\\\\EntityInterface\\:\\:checkPassword\\(\\)\\.$#"
135-
count: 1
136-
path: src/Model/Behavior/PasswordBehavior.php
137-
138113
-
139114
message: "#^Call to an undefined method Cake\\\\ORM\\\\Table\\:\\:findByUsernameOrEmail\\(\\)\\.$#"
140115
count: 1
@@ -209,9 +184,3 @@ parameters:
209184
message: "#^Method CakeDC\\\\Users\\\\Model\\\\Entity\\\\User\\:\\:_setTos\\(\\) should return bool but returns string\\.$#"
210185
count: 1
211186
path: src/Model/Entity/User.php
212-
213-
-
214-
message: "#^Parameter \\#2 \\$usersTable of class CakeDC\\\\Users\\\\Webauthn\\\\Repository\\\\UserCredentialSourceRepository constructor expects CakeDC\\\\Users\\\\Model\\\\Table\\\\UsersTable\\|null, Cake\\\\ORM\\\\Table given\\.$#"
215-
count: 1
216-
path: src/Webauthn/BaseAdapter.php
217-

src/Command/UsersDeleteUserCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public function execute(Arguments $args, ConsoleIo $io)
4646
*/
4747
$UsersTable = $this->getTableLocator()->get('Users');
4848
/**
49-
* @var \Cake\Datasource\EntityInterface $user
49+
* @var \CakeDC\Users\Model\Entity\User $user
5050
*/
5151
$user = $UsersTable->find()->where(['username' => $username])->firstOrFail();
5252
if (isset($UsersTable->SocialAccounts)) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2010 - 2019, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2010 - 2018, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\Users\Controller\Traits;
15+
16+
use Cake\Datasource\Exception\RecordNotFoundException;
17+
use CakeDC\Users\Utility\UsersUrl;
18+
19+
/**
20+
* Covers the login, logout and social login
21+
*
22+
* @property \Cake\Http\ServerRequest $request
23+
*/
24+
trait OneTimeTokenTrait
25+
{
26+
/**
27+
* Request a single token login link.
28+
*
29+
* @return \Cake\Http\Response|null
30+
*/
31+
public function requestLoginLink()
32+
{
33+
if ($this->getRequest()->is('post')) {
34+
$email = $this->getRequest()->getData('email');
35+
try {
36+
/** @var \CakeDC\Users\Model\Table\UsersTable $Users */
37+
$Users = $this->getUsersTable();
38+
/** @uses \CakeDC\Users\Model\Behavior\OneTimeLoginLinkBehavior::sendLoginLink() */
39+
$Users->sendLoginLink($email);
40+
} catch (RecordNotFoundException $e) {
41+
$this->log(
42+
sprintf('A user is trying to get a login link for the email %s but it does not exist.', $email)
43+
);
44+
}
45+
$msg = __d(
46+
'cake_d_c/users',
47+
'If your user is registered in the system you will receive an email ' .
48+
'with a link so you can access your user area.'
49+
);
50+
$this->Flash->success($msg);
51+
$this->setRequest($this->getRequest()->withoutData('email'));
52+
53+
return $this->redirect(UsersUrl::actionUrl('login'));
54+
}
55+
56+
return null;
57+
}
58+
59+
/**
60+
* Single token login.
61+
*
62+
* @return \Cake\Http\Response|null
63+
*/
64+
public function singleTokenLogin()
65+
{
66+
$errorMessage = null;
67+
$token = null;
68+
if ($this->getRequest()->is('get')) {
69+
$token = $this->getRequest()->getQuery('token');
70+
}
71+
72+
if ($this->getRequest()->is('post') || $token) {
73+
$user = $this->Authentication->getIdentity();
74+
$token = $this->getRequest()->getData('token', $token);
75+
if (is_array($token)) {
76+
$token = join($token);
77+
}
78+
if (!$user && !empty($token)) {
79+
$errorMessage = __d('cake_d_c/users', 'Invalid or expired token. Please request a new one.');
80+
}
81+
}
82+
83+
if ($errorMessage) {
84+
$this->Flash->error($errorMessage);
85+
86+
return $this->redirect(UsersUrl::actionUrl('login'));
87+
}
88+
89+
return $this->redirect('/');
90+
}
91+
}

0 commit comments

Comments
 (0)