From 373112bda3746c55fefd5d1b7b9dc90655a46ec1 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 24 Jan 2025 12:25:03 +0100 Subject: [PATCH] Add Magic Link --- .github/workflows/ci.yml | 10 +- Docs/Documentation/MagicLink.md | 60 +++++++ Docs/Home.md | 1 + composer.json | 7 +- .../20250124082232_AddLoginTokenToUsers.php | 31 ++++ config/permissions.php | 3 + config/users.php | 19 ++- phpstan-baseline.neon | 25 --- src/Command/UsersDeleteUserCommand.php | 2 +- src/Controller/Traits/OneTimeTokenTrait.php | 91 +++++++++++ .../Traits/PasswordManagementTrait.php | 1 + src/Controller/Traits/ProfileTrait.php | 1 + src/Controller/UsersController.php | 5 +- src/Mailer/UsersMailer.php | 28 ++++ src/Model/Behavior/LinkSocialBehavior.php | 9 +- .../OneTimeDelivery/DeliveryInterface.php | 21 +++ .../OneTimeDelivery/EmailDelivery.php | 42 +++++ .../Behavior/OneTimeLoginLinkBehavior.php | 147 ++++++++++++++++++ src/Model/Behavior/PasswordBehavior.php | 3 +- src/Model/Entity/User.php | 5 + src/Model/Table/UsersTable.php | 2 + src/Webauthn/BaseAdapter.php | 7 +- .../UserCredentialSourceRepository.php | 4 +- templates/Users/login.php | 4 + templates/Users/request_login_link.php | 25 +++ templates/Users/single_token_login.php | 91 +++++++++++ templates/email/html/onetime_token.php | 28 ++++ .../Component/SetupComponentTest.php | 4 +- webroot/js/singleTokenLogin.js | 91 +++++++++++ 29 files changed, 720 insertions(+), 47 deletions(-) create mode 100644 Docs/Documentation/MagicLink.md create mode 100644 config/Migrations/20250124082232_AddLoginTokenToUsers.php create mode 100644 src/Controller/Traits/OneTimeTokenTrait.php create mode 100644 src/Model/Behavior/OneTimeDelivery/DeliveryInterface.php create mode 100644 src/Model/Behavior/OneTimeDelivery/EmailDelivery.php create mode 100644 src/Model/Behavior/OneTimeLoginLinkBehavior.php create mode 100644 templates/Users/request_login_link.php create mode 100644 templates/Users/single_token_login.php create mode 100644 templates/email/html/onetime_token.php create mode 100644 webroot/js/singleTokenLogin.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 114d93e3d..38eb64329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: [''] @@ -59,7 +59,7 @@ 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 @@ -67,14 +67,14 @@ jobs: if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp?encoding=utf8'; fi if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/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: @@ -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 diff --git a/Docs/Documentation/MagicLink.md b/Docs/Documentation/MagicLink.md new file mode 100644 index 000000000..32860b9cd --- /dev/null +++ b/Docs/Documentation/MagicLink.md @@ -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. + diff --git a/Docs/Home.md b/Docs/Home.md index 253ec9c59..9406d93e4 100644 --- a/Docs/Home.md +++ b/Docs/Home.md @@ -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) diff --git a/composer.json b/composer.json index 4688acd09..1099b662f 100644 --- a/composer.json +++ b/composer.json @@ -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" }, @@ -42,7 +42,8 @@ "league/oauth2-linkedin": "@stable", "luchianenco/oauth2-amazon": "^1.1", "google/recaptcha": "@stable", - "robthree/twofactorauth": "^2.0", + "robthree/twofactorauth": "^3.0", + "endroid/qr-code": "^6.0", "league/oauth1-client": "^1.7", "cakephp/cakephp-codesniffer": "^5.0", "web-auth/webauthn-lib": "^4.4", diff --git a/config/Migrations/20250124082232_AddLoginTokenToUsers.php b/config/Migrations/20250124082232_AddLoginTokenToUsers.php new file mode 100644 index 000000000..0950a6389 --- /dev/null +++ b/config/Migrations/20250124082232_AddLoginTokenToUsers.php @@ -0,0 +1,31 @@ +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(); + } +} diff --git a/config/permissions.php b/config/permissions.php index a7d884d8d..a365cac1b 100644 --- a/config/permissions.php +++ b/config/permissions.php @@ -79,6 +79,9 @@ 'webauthn2faRegisterOptions', 'webauthn2faAuthenticate', 'webauthn2faAuthenticateOptions', + 'requestLoginLink', + 'sendLoginLink', + 'singleTokenLogin', ], 'bypassAuth' => true, ], diff --git a/config/users.php b/config/users.php index 46c07db42..9cca9e011 100644 --- a/config/users.php +++ b/config/users.php @@ -160,7 +160,7 @@ // The algorithm used 'algorithm' => enum_exists(\RobThree\Auth\Algorithm::class) ? \RobThree\Auth\Algorithm::Sha1 : null, // QR-code provider (more on this later) - 'qrcodeprovider' => null, + 'qrcodeprovider' => new \RobThree\Auth\Providers\Qr\EndroidQrCodeProvider(), // Random Number Generator provider (more on this later) 'rngprovider' => null, ], @@ -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' => [ @@ -219,6 +229,13 @@ 'className' => 'CakeDC/Users.SocialPendingEmail', 'skipTwoFactorVerify' => true, ], + 'OneTimeToken' => [ + 'className' => 'CakeDC/Auth.OneTimeToken', + 'skipTwoFactorVerify' => true, + 'loginUrl' => [ + '/login', + ], + ] ], 'Identifiers' => [ 'Password' => [ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6fa398443..4f78c596e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 @@ -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 @@ -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 diff --git a/src/Command/UsersDeleteUserCommand.php b/src/Command/UsersDeleteUserCommand.php index 6a6f45050..595fc5015 100644 --- a/src/Command/UsersDeleteUserCommand.php +++ b/src/Command/UsersDeleteUserCommand.php @@ -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)) { diff --git a/src/Controller/Traits/OneTimeTokenTrait.php b/src/Controller/Traits/OneTimeTokenTrait.php new file mode 100644 index 000000000..ec5fad9d9 --- /dev/null +++ b/src/Controller/Traits/OneTimeTokenTrait.php @@ -0,0 +1,91 @@ +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('/'); + } +} diff --git a/src/Controller/Traits/PasswordManagementTrait.php b/src/Controller/Traits/PasswordManagementTrait.php index 9625429de..f942f5f35 100644 --- a/src/Controller/Traits/PasswordManagementTrait.php +++ b/src/Controller/Traits/PasswordManagementTrait.php @@ -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); diff --git a/src/Controller/Traits/ProfileTrait.php b/src/Controller/Traits/ProfileTrait.php index 15e455fd2..5e24f60ac 100644 --- a/src/Controller/Traits/ProfileTrait.php +++ b/src/Controller/Traits/ProfileTrait.php @@ -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) { diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index abac3aa8a..558378e22 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -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; @@ -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 { @@ -44,8 +46,7 @@ class UsersController extends AppController use SimpleCrudTrait; use SocialTrait; use Webauthn2faTrait; - - // use CustomUsersTableTrait; + use OneTimeTokenTrait; /** * Initialize diff --git a/src/Mailer/UsersMailer.php b/src/Mailer/UsersMailer.php index ca5a25de3..88d1271e3 100644 --- a/src/Mailer/UsersMailer.php +++ b/src/Mailer/UsersMailer.php @@ -15,6 +15,7 @@ use Cake\Datasource\EntityInterface; use Cake\Mailer\Mailer; use Cake\Mailer\Message; +use Cake\Routing\Router; use CakeDC\Users\Utility\UsersUrl; /** @@ -127,4 +128,31 @@ protected function socialAccountValidation(EntityInterface $user, EntityInterfac ->viewBuilder() ->setTemplate('CakeDC/Users.socialAccountValidation'); } + + /** + * Send a one-time login token. + * + * @param \CakeDC\Users\Model\Entity\User $user User. + * @param string $token Token. + * @return void + */ + public function sendToken(EntityInterface $user, string $token): void + { + $this->viewBuilder()->setTemplate('CakeDC/Users.onetimeToken'); + $loginLink = Router::url([ + 'controller' => 'Users', + 'action' => 'singleTokenLogin', + '?' => [ + 'token' => $token, + ], + ], true); + $this->setTo($user->email); + $this->setSubject(__d('cake_d_c/users', 'Your One-Time Login Token')); + $this->setEmailFormat('html'); + $this->setViewVars([ + 'user' => $user, + 'loginLink' => $loginLink, + 'token' => $token, + ]); + } } diff --git a/src/Model/Behavior/LinkSocialBehavior.php b/src/Model/Behavior/LinkSocialBehavior.php index 9f5c6ec0b..189067ed8 100644 --- a/src/Model/Behavior/LinkSocialBehavior.php +++ b/src/Model/Behavior/LinkSocialBehavior.php @@ -30,7 +30,7 @@ class LinkSocialBehavior extends Behavior /** * Link an user account with a social account (facebook, google) * - * @param \Cake\Datasource\EntityInterface $user User to link. + * @param \CakeDC\Users\Model\Entity\User $user User to link. * @param array $data Social account information. * @return \Cake\Datasource\EntityInterface */ @@ -60,22 +60,25 @@ public function linkSocialAccount(EntityInterface $user, $data) /** * Create or update a new social account linking to the user. * - * @param \Cake\Datasource\EntityInterface $user User to link. + * @param \CakeDC\Users\Model\Entity\User $user User to link. * @param array $data Social account information. - * @param \Cake\Datasource\EntityInterface $socialAccount to update or create. + * @param \CakeDC\Users\Model\Entity\SocialAccount $socialAccount to update or create. * @return \Cake\Datasource\EntityInterface */ protected function createOrUpdateSocialAccount(EntityInterface $user, $data, $socialAccount) { if (!$socialAccount) { + /** @var \CakeDC\Users\Model\Entity\SocialAccount $socialAccount */ $socialAccount = $this->_table->SocialAccounts->newEntity([]); } $data['user_id'] = $user->id; + /** @var \CakeDC\Users\Model\Entity\SocialAccount $socialAccount */ $socialAccount = $this->populateSocialAccount($socialAccount, $data); $result = $this->_table->SocialAccounts->save($socialAccount); + /** @var array<\CakeDC\Users\Model\Entity\SocialAccount> $accounts */ $accounts = (array)$user->social_accounts; $found = false; foreach ($accounts as $key => $account) { diff --git a/src/Model/Behavior/OneTimeDelivery/DeliveryInterface.php b/src/Model/Behavior/OneTimeDelivery/DeliveryInterface.php new file mode 100644 index 000000000..d4416b290 --- /dev/null +++ b/src/Model/Behavior/OneTimeDelivery/DeliveryInterface.php @@ -0,0 +1,21 @@ +options = $options; + } + + /** + * Send a delivery. + * + * @param \CakeDC\Users\Model\Entity\User $user User. + * @param string $token Token. + * @return void + */ + public function send(EntityInterface $user, string $token): void + { + $this->getMailer('CakeDC/Users.Users')->send('sendToken', [$user, $token]); + } +} diff --git a/src/Model/Behavior/OneTimeLoginLinkBehavior.php b/src/Model/Behavior/OneTimeLoginLinkBehavior.php new file mode 100644 index 000000000..fc832a2bd --- /dev/null +++ b/src/Model/Behavior/OneTimeLoginLinkBehavior.php @@ -0,0 +1,147 @@ +table(); + /** @var \CakeDC\Users\Model\Entity\User|null $user */ + $user = $table->find('byUsernameOrEmail', ['username' => $name])->first(); + if ($user === null) { + throw new RecordNotFoundException(__('Username not found.')); + } + + if ($user->login_token_date > DateTime::now()->subSeconds(10)) { + $this->requestTokenSend($name); + } else { + $token = bin2hex(random_bytes(32 / 2)); + $this->table()->updateAll([ + 'login_token' => $token, + 'token_send_requested' => 0, + 'login_token_date' => DateTime::now(), + ], [ + 'id' => $user->id, + ]); + $user = $this->table()->get($user->id); + + $deliveries = Configure::read('OneTimeLogin.DeliveryHandlers'); + + foreach ($deliveries as $deliverySettings) { + $deliveryClass = $deliverySettings['className']; + $delivery = new $deliveryClass($deliverySettings['options'] ?? []); + $delivery->send($user, $token); + } + } + } + + /** + * Login with a token. + * + * @param string $token Token. + * @return \Cake\Datasource\EntityInterface|null + */ + public function loginWithToken(string $token): ?EntityInterface + { + $lifeTime = Configure::read('Auth.OneTimeLogin.tokenLifeTime', 600); + /** @var \CakeDC\Users\Model\Entity\User|null $user */ + $user = $this->table() + ->find('byOneTimeToken', ['token' => $token]) + ->first(); + + if ($user && ($user->login_token_date >= DateTime::now()->subSeconds($lifeTime))) { + $this->table()->updateAll([ + 'login_token' => null, + ], [ + 'id' => $user->id, + ]); + /** @var \CakeDC\Users\Model\Entity\User|null $user */ + $user = $this->table()->get($user->id); + + return $user; + } + + return null; + } + + /** + * Request a token send. + * + * @param string $username Username or email + * @return void + */ + public function requestTokenSend(string $username): void + { + /** @var \CakeDC\Users\Model\Entity\User|null $user */ + $user = $this->table()->find('byUsernameOrEmail', ['username' => $username])->first(); + if ($user) { + $this->table()->updateAll([ + 'token_send_requested' => true, + ], [ + 'id' => $user->id, + ]); + } + } + + /** + * Find by username or email. + * + * @param \Cake\ORM\Query $query The query builder. + * @param array $options Options. + * @return \Cake\ORM\Query + */ + public function findByUsernameOrEmail(Query $query, array $options = []): Query + { + $username = $options['username'] ?? null; + if (empty($username)) { + throw new OutOfBoundsException('Missing username'); + } + + return $query->where([ + 'OR' => [ + $this->table()->aliasField('username') => $username, + $this->table()->aliasField('email') => $username, + ], + ]); + } + + /** + * Find by token + * + * @param \Cake\ORM\Query $query + * @param array $options + * @return \Cake\ORM\Query + */ + public function findByOneTimeToken(Query $query, array $options = []): Query + { + $token = $options['token'] ?? null; + if (empty($token)) { + throw new OutOfBoundsException('Missing token'); + } + + return $query->where([ + 'OR' => [ + $this->table()->aliasField('login_token') => $token, + ], + ]); + } +} diff --git a/src/Model/Behavior/PasswordBehavior.php b/src/Model/Behavior/PasswordBehavior.php index 609f95bb1..4f3ec2379 100644 --- a/src/Model/Behavior/PasswordBehavior.php +++ b/src/Model/Behavior/PasswordBehavior.php @@ -128,13 +128,14 @@ protected function _getUser($reference) /** * Change password method * - * @param \Cake\Datasource\EntityInterface $user user data. + * @param \CakeDC\Users\Model\Entity\User $user user data. * @throws \CakeDC\Users\Exception\WrongPasswordException * @return mixed */ public function changePassword(EntityInterface $user) { try { + /** @var \CakeDC\Users\Model\Entity\User $currentUser */ $currentUser = $this->_table->get($user->id, contain: []); } catch (RecordNotFoundException $e) { throw new UserNotFoundException(__d('cake_d_c/users', 'User not found')); diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index cd454f375..99d4624f3 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -25,6 +25,8 @@ * @property string $email * @property string $role * @property string $username + * @property string $first_name + * @property string $last_name * @property bool $is_superuser * @property \Cake\I18n\Time|\Cake\I18n\DateTime $token_expires * @property string $token @@ -32,6 +34,9 @@ * @property array|string $additional_data * @property \CakeDC\Users\Model\Entity\SocialAccount[] $social_accounts * @property string $password + * @property string $password_confirm + * @property string $login_token + * @property \CakeDC\Users\Model\Entity\FrozenDate $login_token_date * @property \Cake\I18n\DateTime $lockout_time */ class User extends Entity diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 5f176492e..26f631b67 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -35,6 +35,7 @@ * @mixin \CakeDC\Users\Model\Behavior\RegisterBehavior * @mixin \CakeDC\Users\Model\Behavior\SocialAccountBehavior * @mixin \CakeDC\Users\Model\Behavior\SocialBehavior + * @mixin \CakeDC\Users\Model\Behavior\OneTimeLoginLinkBehavior * @property \CakeDC\Users\Model\Table\SocialAccountsTable $SocialAccounts */ class UsersTable extends Table @@ -71,6 +72,7 @@ public function initialize(array $config): void $this->addBehavior('CakeDC/Users.Social'); $this->addBehavior('CakeDC/Users.LinkSocial'); $this->addBehavior('CakeDC/Users.AuthFinder'); + $this->addBehavior('CakeDC/Users.OneTimeLoginLink'); $this->hasMany('SocialAccounts')->setForeignKey('user_id')->setClassName('CakeDC/Users.SocialAccounts'); } diff --git a/src/Webauthn/BaseAdapter.php b/src/Webauthn/BaseAdapter.php index ed6d815d6..3bc1ed4b7 100644 --- a/src/Webauthn/BaseAdapter.php +++ b/src/Webauthn/BaseAdapter.php @@ -40,7 +40,7 @@ class BaseAdapter protected $repository; /** - * @var \Cake\Datasource\EntityInterface|\CakeDC\Users\Model\Entity\User + * @var \CakeDC\Users\Model\Entity\User */ private $user; /** @@ -73,7 +73,9 @@ public function __construct(ServerRequest $request, ?UsersTable $usersTable = nu $userSession = $request->getSession()->read('Webauthn2fa.User'); $usersTable = $usersTable ?? TableRegistry::getTableLocator() ->get($userSession->getSource()); - $this->user = $usersTable->get($userSession->id); + /** @var \CakeDC\Users\Model\Entity\User $user */ + $user = $usersTable->get($userSession->id); + $this->user = $user; $this->repository = new UserCredentialSourceRepository( $this->user, $usersTable @@ -85,6 +87,7 @@ public function __construct(ServerRequest $request, ?UsersTable $usersTable = nu */ protected function getUserEntity(): PublicKeyCredentialUserEntity { + /** @var \CakeDC\Users\Model\Entity\User $user */ $user = $this->getUser(); return new PublicKeyCredentialUserEntity( diff --git a/src/Webauthn/Repository/UserCredentialSourceRepository.php b/src/Webauthn/Repository/UserCredentialSourceRepository.php index 5c1127a21..531317036 100644 --- a/src/Webauthn/Repository/UserCredentialSourceRepository.php +++ b/src/Webauthn/Repository/UserCredentialSourceRepository.php @@ -13,7 +13,7 @@ class UserCredentialSourceRepository implements PublicKeyCredentialSourceRepository { /** - * @var \Cake\Datasource\EntityInterface + * @var \CakeDC\Users\Model\Entity\User $user */ private $user; /** @@ -22,7 +22,7 @@ class UserCredentialSourceRepository implements PublicKeyCredentialSourceReposit private $usersTable; /** - * @param \Cake\Datasource\EntityInterface $user The user. + * @param \CakeDC\Users\Model\Entity\User $user The user. * @param \CakeDC\Users\Model\Table\UsersTable|null $usersTable The table. */ public function __construct(EntityInterface $user, ?UsersTable $usersTable = null) diff --git a/templates/Users/login.php b/templates/Users/login.php index 2df2dab9d..449025037 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -41,6 +41,10 @@ echo ' | '; } echo $this->Html->link(__d('cake_d_c/users', 'Reset Password'), ['action' => 'requestResetPassword']); + if (Configure::read('OneTimeLogin.enabled')) { + echo ' | '; + echo $this->Html->link(__d('cake_d_c/users', 'Send me a login link'), ['plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'requestLoginLink'], ['allowed' => true, 'escape' => false]); + } } ?> diff --git a/templates/Users/request_login_link.php b/templates/Users/request_login_link.php new file mode 100644 index 000000000..6a40e03cc --- /dev/null +++ b/templates/Users/request_login_link.php @@ -0,0 +1,25 @@ + +
+ Flash->render('auth') ?> + Form->create(null) ?> +
+ + Form->control('email') ?> + User->addReCaptcha(); + } ?> +
+ User->button(__d('cake_d_c/users', 'Submit')); ?> + Form->end() ?> +
diff --git a/templates/Users/single_token_login.php b/templates/Users/single_token_login.php new file mode 100644 index 000000000..8638795d0 --- /dev/null +++ b/templates/Users/single_token_login.php @@ -0,0 +1,91 @@ + + +
+

+
+ + 0): ?> +
+ {0}}. We will send you a new token soon.', h($remainingTime)) ?> +
+ + +
+ Form->create(null, ['url' => ['action' => 'singleTokenLogin']]) ?> +
+ + Form->text("token[$i]", [ + 'label' => false, + 'maxlength' => 1, + 'class' => 'token-input', + 'autocomplete' => 'off', + 'required' => true, + 'pattern' => '[0-9]*', + 'inputmode' => 'numeric', + 'onkeyup' => "moveToNext(this, event)", + 'oninput' => "this.value = this.value.replace(/[^0-9]/g, '')" + ]) ?> + +
+
+
+ Html->link(__('Request new token'), ['action' => 'requestLoginLink'], ['class' => 'btn btn-primary btn-block btn-flat', 'style' => 'margin: auto;']) ?> +
+
+ + Form->end() ?> +
+ + 0): ?> + + + +Html->script('CakeDC/Users.singleTokenLogin', ['block' => 'script']); ?> diff --git a/templates/email/html/onetime_token.php b/templates/email/html/onetime_token.php new file mode 100644 index 000000000..b3df1604d --- /dev/null +++ b/templates/email/html/onetime_token.php @@ -0,0 +1,28 @@ + +

+ first_name ?? ($user->username ?? '')) ?>, +

+

+ Html->link(__d('cake_d_c/users', 'here'), $loginLink)) ?> +

+

+Url->build($loginLink) +) ?> +

+

+ , +

diff --git a/tests/TestCase/Controller/Component/SetupComponentTest.php b/tests/TestCase/Controller/Component/SetupComponentTest.php index 7649ea749..d1a2799a4 100644 --- a/tests/TestCase/Controller/Component/SetupComponentTest.php +++ b/tests/TestCase/Controller/Component/SetupComponentTest.php @@ -81,8 +81,8 @@ public static function dataProviderInitialization() * Test initial setup * * @param bool $authentication Should use authentication component - * @param booll $authorization Should use authorization component - * @param booll $oneTimePass Should use OneTimePassword component + * @param bool $authorization Should use authorization component + * @param bool $oneTimePass Should use OneTimePassword component * @throws \Exception * @dataProvider dataProviderInitialization * @return void diff --git a/webroot/js/singleTokenLogin.js b/webroot/js/singleTokenLogin.js new file mode 100644 index 000000000..2585da901 --- /dev/null +++ b/webroot/js/singleTokenLogin.js @@ -0,0 +1,91 @@ +function moveToNext(current, event) { + if (event.key === 'Backspace') { + if (current.value.length > 0) { + current.value = ''; + event.preventDefault(); + return; + } else { + const previousInput = current.previousElementSibling; + if (previousInput) { + previousInput.focus(); + } + event.preventDefault(); + return; + } + } + + if (event.key >= '0' && event.key <= '9') { + current.value = event.key; + const nextInput = current.nextElementSibling; + if (nextInput) { + nextInput.focus(); + } + event.preventDefault(); + } else { + event.preventDefault(); + } + checkAndSubmit(); +} + +function checkAndSubmit() { + const inputs = document.querySelectorAll('.token-input'); + const allFilled = Array.from(inputs).every(input => input.value.length === 1); + + if (allFilled) { + document.querySelector('form').submit(); + } +} +document.querySelectorAll('.token-input').forEach(input => { + input.addEventListener('paste', function(event) { + const pasteData = event.clipboardData.getData('text'); + const digits = pasteData.match(/\d/g); + + if (digits && digits.length === 6) { + const inputs = document.querySelectorAll('.token-input'); + inputs.forEach((input, index) => { + if (index < digits.length) { + input.value = digits[index]; + } else { + input.value = ''; + } + }); + const lastInput = document.querySelector('#token-5'); + if (lastInput) { + lastInput.focus(); + } + + checkAndSubmit(); + } + event.preventDefault(); + }); + + input.addEventListener('input', function(event) { + if (event.inputType === "deleteContentBackward") { + return; + } + if (event.inputType === "insertText" && this.value.length >= 1) { + event.preventDefault(); + return; + } + + if (!/^\d$/.test(this.value)) { + event.preventDefault(); + this.value = ''; + return; + } + + const nextInput = this.nextElementSibling; + if (nextInput) { + if (nextInput) { + nextInput.focus(); + } + } + }); +}); + +document.addEventListener('DOMContentLoaded', function() { + const firstInput = document.querySelector('#token-0'); + if (firstInput) { + firstInput.focus(); + } +});