From 29b5b7b73ca641ed543fb460104261631a37cf56 Mon Sep 17 00:00:00 2001 From: DDEV User Date: Sun, 30 Aug 2026 07:39:37 +0200 Subject: [PATCH 1/3] fix(tests,phpstan): CSP delete-form assertion and drop non-generic EventDispatcherTrait annotations --- phpstan-baseline.neon | 48 ++++++++++++++++--- src/Identifier/SocialIdentifier.php | 3 -- src/Model/Behavior/SocialBehavior.php | 3 -- .../SimpleCrudTraitIntegrationTest.php | 8 ++-- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1550fe42..81ba24ac 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,12 +12,6 @@ parameters: count: 1 path: src/Controller/Component/LoginComponent.php - - - message: '#^Call to function method_exists\(\) with Authentication\\Authenticator\\AuthenticatorInterface and ''getIdentifier'' will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Controller/Component/LoginComponent.php - - message: '#^Parameter \#1 \$request of method CakeDC\\Users\\Middleware\\SocialAuthMiddleware\:\:goNext\(\) expects Cake\\Http\\ServerRequest, Psr\\Http\\Message\\ServerRequestInterface given\.$#' identifier: argument.type @@ -60,6 +54,18 @@ parameters: count: 1 path: src/Model/Behavior/LinkSocialBehavior.php + - + message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$id\.$#' + identifier: property.notFound + count: 5 + path: src/Model/Behavior/OneTimeLoginLinkBehavior.php + + - + message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$login_token_date\.$#' + identifier: property.notFound + count: 1 + path: src/Model/Behavior/OneTimeLoginLinkBehavior.php + - message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$active\.$#' identifier: property.notFound @@ -78,6 +84,12 @@ parameters: count: 1 path: src/Model/Behavior/RegisterBehavior.php + - + message: '#^Call to an undefined method Cake\\Datasource\\EntityInterface\:\:tokenExpired\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Behavior/RegisterBehavior.php + - message: '#^Call to an undefined method Cake\\ORM\\Table\:\:validationRegister\(\)\.$#' identifier: method.notFound @@ -90,6 +102,24 @@ parameters: count: 1 path: src/Model/Behavior/SocialAccountBehavior.php + - + message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$active\.$#' + identifier: property.notFound + count: 2 + path: src/Model/Behavior/SocialAccountBehavior.php + + - + message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$token\.$#' + identifier: property.notFound + count: 1 + path: src/Model/Behavior/SocialAccountBehavior.php + + - + message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: src/Model/Behavior/SocialAccountBehavior.php + - message: '#^Method CakeDC\\Users\\Model\\Behavior\\SocialAccountBehavior\:\:resendValidation\(\) should return CakeDC\\Users\\Model\\Entity\\User but returns array\.$#' identifier: return.type @@ -102,6 +132,12 @@ parameters: count: 1 path: src/Model/Behavior/SocialAccountBehavior.php + - + message: '#^Parameter \#1 \$socialAccount of method CakeDC\\Users\\Model\\Behavior\\SocialAccountBehavior\:\:_activateAccount\(\) expects CakeDC\\Users\\Model\\Entity\\SocialAccount, Cake\\Datasource\\EntityInterface given\.$#' + identifier: argument.type + count: 1 + path: src/Model/Behavior/SocialAccountBehavior.php + - message: '#^Access to an undefined property Cake\\ORM\\Table\:\:\$SocialAccounts\.$#' identifier: property.notFound diff --git a/src/Identifier/SocialIdentifier.php b/src/Identifier/SocialIdentifier.php index bf05da41..2a369a88 100644 --- a/src/Identifier/SocialIdentifier.php +++ b/src/Identifier/SocialIdentifier.php @@ -21,9 +21,6 @@ class SocialIdentifier extends AbstractIdentifier { - /** - * @use \Cake\Event\EventDispatcherTrait<\CakeDC\Users\Identifier\SocialIdentifier> - */ use EventDispatcherTrait; use LocatorAwareTrait; diff --git a/src/Model/Behavior/SocialBehavior.php b/src/Model/Behavior/SocialBehavior.php index 7ad6258a..47116eb9 100644 --- a/src/Model/Behavior/SocialBehavior.php +++ b/src/Model/Behavior/SocialBehavior.php @@ -31,9 +31,6 @@ */ class SocialBehavior extends BaseTokenBehavior { - /** - * @use \Cake\Event\EventDispatcherTrait<\CakeDC\Users\Model\Behavior\SocialBehavior> - */ use EventDispatcherTrait; use RandomStringTrait; diff --git a/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php b/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php index 017d0af5..1a5e09ed 100644 --- a/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php +++ b/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php @@ -59,7 +59,7 @@ public function testCrud() $this->assertResponseContains('View'); $this->assertResponseContains('Change password'); $this->assertResponseContains('Edit'); - $this->assertResponseContains('method="post" style="display:none;" action="/users/delete/00000000-0000-0000-0000-000000000001"'); + $this->assertResponseContains('method="post" hidden="hidden" action="/users/delete/00000000-0000-0000-0000-000000000001"'); $this->assertResponseContains('>Delete<'); $this->assertResponseContains('user-6'); @@ -69,7 +69,7 @@ public function testCrud() $this->assertResponseContains('View'); $this->assertResponseContains('Change password'); $this->assertResponseContains('Edit'); - $this->assertResponseContains('method="post" style="display:none;" action="/users/delete/00000000-0000-0000-0000-000000000006"'); + $this->assertResponseContains('method="post" hidden="hidden" action="/users/delete/00000000-0000-0000-0000-000000000006"'); $this->get('/users/change-password/00000000-0000-0000-0000-000000000006'); $this->assertFlashMessage('Changing another user\'s password is not allowed'); @@ -101,7 +101,7 @@ public function testCrud() $this->assertResponseContains('Active'); - $this->assertResponseContains('method="post" style="display:none;" action="/users/delete/00000000-0000-0000-0000-000000000006"'); + $this->assertResponseContains('method="post" hidden="hidden" action="/users/delete/00000000-0000-0000-0000-000000000006"'); $this->assertResponseContains('List Users'); $this->enableSecurityToken(); @@ -124,7 +124,7 @@ public function testCrud() $this->assertResponseContains('Edit User'); $this->assertResponseContains('New User'); $this->assertResponseContains('List Users'); - $this->assertResponseContains('method="post" style="display:none;" action="/users/delete/00000000-0000-0000-0000-000000000006"'); + $this->assertResponseContains('method="post" hidden="hidden" action="/users/delete/00000000-0000-0000-0000-000000000006"'); $this->post('/users/delete/00000000-0000-0000-0000-000000000006'); $this->assertRedirect('/users/index'); From 2bcec63d961cf677ec70a3d3f75a9eff3b9c00a8 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Wed, 1 Jul 2026 09:09:54 +0200 Subject: [PATCH 2/3] feat(ajax): opt-in AJAX/HTMX support for login and user forms --- Docs/Documentation/AjaxAndHtmx.md | 138 +++++++++++ Docs/Home.md | 13 + config/users.php | 8 + src/Controller/AppController.php | 21 +- .../Component/AjaxResponseComponent.php | 163 ++++++++++++ src/Loader/MiddlewareQueueLoader.php | 15 ++ src/Middleware/AjaxRedirectMiddleware.php | 84 +++++++ src/Utility/AjaxFlash.php | 69 ++++++ templates/Users/change_password.php | 6 +- templates/Users/login.php | 6 +- templates/Users/register.php | 6 +- templates/Users/request_reset_password.php | 6 +- templates/Users/verify.php | 6 +- templates/layout/ajax.php | 1 + .../Controller/AjaxIntegrationTest.php | 232 ++++++++++++++++++ .../Component/AjaxResponseComponentTest.php | 141 +++++++++++ .../Middleware/AjaxRedirectMiddlewareTest.php | 166 +++++++++++++ 17 files changed, 1075 insertions(+), 6 deletions(-) create mode 100644 Docs/Documentation/AjaxAndHtmx.md create mode 100644 src/Controller/Component/AjaxResponseComponent.php create mode 100644 src/Middleware/AjaxRedirectMiddleware.php create mode 100644 src/Utility/AjaxFlash.php create mode 100644 templates/layout/ajax.php create mode 100644 tests/TestCase/Controller/AjaxIntegrationTest.php create mode 100644 tests/TestCase/Controller/Component/AjaxResponseComponentTest.php create mode 100644 tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php diff --git a/Docs/Documentation/AjaxAndHtmx.md b/Docs/Documentation/AjaxAndHtmx.md new file mode 100644 index 00000000..0e38a83d --- /dev/null +++ b/Docs/Documentation/AjaxAndHtmx.md @@ -0,0 +1,138 @@ +Ajax, JSON & HTMX Responses +=========================== +The plugin can return its user-facing actions (login, register, password flows, profile) as +JSON — for SPA / programmatic consumers — or as HTML fragments driven by +[HTMX](https://htmx.org/) — for modal / progressive-enhancement logins — in addition to the +normal full-page HTML responses. + +The feature is **opt-in and disabled by default**. When disabled, the plugin behaves exactly +as before and no extra middleware or component is loaded. + +Enabling +-------- +Add this to your `config/users.php`: + +```php +'Users.Ajax.enabled' => true, +'Users.Ajax.skipFormProtectionForJson' => true, // default, see "FormProtection & CSRF" +``` + +The host application is responsible for loading the HTMX library if you use the HTMX channel — +the plugin only emits HTML attributes and response headers, it ships no JavaScript: + +```html + +``` + +How does it work +---------------- +When enabled, a lightweight component and a thin middleware are loaded for the plugin's +controllers. They inspect each request and adapt the response: + +* **JSON** — when the request sends `Accept: application/json`, the response body is JSON. +* **HTMX** — when the request sends the `HX-Request` header (HTMX adds it automatically), the + response is an HTML fragment (no page skeleton), suitable for swapping into a modal or + container. +* **Normal** — neither header present: behaves exactly as today (full-page HTML). + +JSON responses +-------------- +CakePHP's `JsonView` serializes each key that is listed in `_serialize` at the **top level** — +there is no `data` envelope. + +`GET /users/profile` (the only action that returns a populated user on success): + +```json +{ "user": { "id": "...", "username": "...", "email": "..." }, "isCurrentUser": true, "success": true, "flash": null } +``` + +Actions that finish with a redirect — `login`, `register`, `changePassword`, `resetPassword`, +two-factor `verify` — return the redirect target rather than a user object (the middleware +intercepts the server-side redirect and rewrites it). The HTTP status is always `200`; read the +`success` flag for the outcome: + +```json +{ "success": true, "redirect": "http://example.com/dashboard", "flash": { "type": "success", "message": "..." } } +``` + +Not every plugin redirect means success — a failed two-factor `verify`, for instance, redirects +back to the login action. When the action queued an error flash before redirecting, the middleware +reports the redirect as a failure and echoes the message, so a client can tell the two apart: + +```json +{ "success": false, "redirect": "http://example.com/login", "error": "Verification code is invalid. Try again", "flash": { "type": "error", "message": "..." } } +``` + +A SPA that needs the full user object after login can request `GET /users/profile` with +`Accept: application/json`. + +Authentication failure (login / 2FA) returns `401`: + +```json +{ "success": false, "error": "Username or password is incorrect", "flash": { "type": "error", "message": "..." } } +``` + +Validation failure (register, password, profile) returns `422`: + +```json +{ "success": false, "errors": { "username": { "_required": "This field is required" } }, "flash": { "type": "error", "message": "..." } } +``` + +### Sensitive fields +The `user` object never includes `password`, `token`, `secret` or `api_token` — these are +hidden by the User entity's `$_hidden` list. To expose or hide more fields, edit `$_hidden` on +your (extended) User entity. + +HTMX responses +-------------- +With the feature enabled, the plugin's forms (`login`, `register`, `change_password`, +`request_reset_password`, `verify`) emit HTMX attributes so they submit via HTMX and swap the +response into a target container (default target `#ajax-login-container`). + +* On a successful login the response carries an `HX-Redirect` header, so HTMX performs a + full-page redirect. +* On a validation/authentication failure the re-rendered form fragment is returned and swapped + back in, showing the flash error. + +### Using it in a modal +1. Enable the feature and include HTMX (see [Enabling](#enabling)). +2. Add a trigger that loads the login form fragment into your modal body: + + ```html + + + ``` +3. The plugin returns the login form rendered with the `ajax` layout (fragment only). +4. The form submits via HTMX; on success the `HX-Redirect` header redirects the page. + +FormProtection & CSRF +--------------------- +The plugin protects its forms with CakePHP's `FormProtection` (field tampering) and CSRF +components. The HTMX channel posts the rendered form, so both checks pass with no extra work. + +Programmatic JSON clients build the request body themselves and cannot satisfy `FormProtection` +(it locks the exact rendered fields). When `Users.Ajax.skipFormProtectionForJson` is `true` +(default), `FormProtection` is skipped for JSON-negotiated requests only; CSRF protection still +applies — send the token in the `X-CSRF-Token` header (read it from the `csrfToken` cookie). Set +the option to `false` to instead require JSON clients to round-trip the rendered form: + +```php +'Users.Ajax.skipFormProtectionForJson' => false, +``` + +Events +------ +This feature changes only response formatting. All plugin events +(`Users.Authentication.afterLogin`, `Users.Global.afterRegister`, etc.) still fire exactly as +before, so your existing listeners keep working. See [Events](Events.md). + +Notes & limitations +------------------- +* Session-based authentication only (no JWT / token issuance). +* Two-factor `verify` failures redirect to the login action rather than returning a `401` body. + They are reported as `{ "success": false, "redirect": "", "error": "..." }` (HTTP `200`), + so a JSON client must check `success` rather than assume a redirect means the step passed. +* The feature acts only on the Users plugin's own routes; your application's other routes are + untouched. +* `socialLogin` is an OAuth browser callback (full-page navigation), not a JSON/HTMX channel, + and is unaffected by this feature. diff --git a/Docs/Home.md b/Docs/Home.md index be8944f5..739cd134 100644 --- a/Docs/Home.md +++ b/Docs/Home.md @@ -23,6 +23,7 @@ Documentation * [Two Factor Authenticator](Documentation/Two-Factor-Authenticator.md) * [Webauthn Two-Factor Authentication (Yubico Key compatible)](Documentation/WebauthnTwoFactorAuthenticator.md) * [Magic Link](Documentation/MagicLink.md) +* [Ajax, JSON & HTMX Responses](Documentation/AjaxAndHtmx.md) * [UserHelper](Documentation/UserHelper.md) * [AuthLinkHelper](Documentation/AuthLinkHelper.md) * [Events](Documentation/Events.md) @@ -166,6 +167,18 @@ I want to ``` + *
+ Ajax / JSON / HTMX responses + + Add this to your config/users.php file to enable JSON and HTMX responses (disabled by default): + + ```php + 'Users.Ajax.enabled' => true, + ``` + + See [Ajax, JSON & HTMX Responses](Documentation/AjaxAndHtmx.md) for details. +
+ - allow access to - [public actions (non-logged user)](./Documentation/Permissions.md#i-want-to-allow-access-to-public-actions-non-logged-user) - [one specific action](./Documentation/Permissions.md#i-want-to-allow-access-to-one-specific-action) diff --git a/config/users.php b/config/users.php index 1210d07b..23721059 100644 --- a/config/users.php +++ b/config/users.php @@ -137,6 +137,14 @@ ], // Avatar placeholder 'Avatar' => ['placeholder' => 'CakeDC/Users.avatar_placeholder.png'], + 'Ajax' => [ + // Master opt-in switch. When false the AjaxResponseComponent and + // AjaxRedirectMiddleware are never loaded; behavior is unchanged. + 'enabled' => false, + // Skip FormProtection for JSON-negotiated requests (field-locking is + // meaningless without a server-rendered form). CSRF stays on via header. + 'skipFormProtectionForJson' => true, + ], 'RememberMe' => [ // configure Remember Me component 'active' => true, diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 8869fdf1..24d30f34 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -14,6 +14,7 @@ namespace CakeDC\Users\Controller; use App\Controller\AppController as BaseController; +use Cake\Core\Configure; /** * AppController for Users Plugin @@ -28,10 +29,28 @@ class AppController extends BaseController public function initialize(): void { parent::initialize(); - $this->loadComponent('FormProtection'); + if (!$this->shouldSkipFormProtection()) { + $this->loadComponent('FormProtection'); + } if ($this->request->getParam('_csrfToken') === false) { $this->loadComponent('Csrf'); } $this->loadComponent('CakeDC/Users.Setup'); + if (Configure::read('Users.Ajax.enabled')) { + $this->loadComponent('CakeDC/Users.AjaxResponse'); + } + } + + /** + * Skip FormProtection only for JSON-negotiated ajax requests (field-locking is + * meaningless without a server-rendered form). CSRF protection still applies. + * + * @return bool + */ + protected function shouldSkipFormProtection(): bool + { + return Configure::read('Users.Ajax.enabled') + && Configure::read('Users.Ajax.skipFormProtectionForJson') + && $this->request->is('json'); } } diff --git a/src/Controller/Component/AjaxResponseComponent.php b/src/Controller/Component/AjaxResponseComponent.php new file mode 100644 index 00000000..4a621f4d --- /dev/null +++ b/src/Controller/Component/AjaxResponseComponent.php @@ -0,0 +1,163 @@ + 'beforeRender', + 'Users.Authentication.afterLoginFailure' => 'afterLoginFailure', + ]; + } + + /** + * Is the current request an HTMX request? + * + * @return bool + */ + protected function isHtmx(): bool + { + return $this->getController()->getRequest()->hasHeader('HX-Request'); + } + + /** + * Does the current request negotiate JSON? + * + * @return bool + */ + protected function isJson(): bool + { + return $this->getController()->getRequest()->is('json'); + } + + /** + * Flag a JSON login failure so beforeRender() can shape a 401 response. + * (HTMX login failures re-render the form fragment, so they are not flagged here.) + * + * @param \Cake\Event\EventInterface $event The afterLoginFailure event. + * @return void + */ + public function afterLoginFailure(EventInterface $event): void + { + if (!$this->isJson()) { + return; + } + $this->loginFailed = true; + $controller = $this->getController(); + $controller->setResponse($controller->getResponse()->withStatus(401)); + } + + /** + * Apply HTMX layout / JSON view negotiation just before rendering. + * + * @param \Cake\Event\EventInterface $event The beforeRender event. + * @return void + */ + public function beforeRender(EventInterface $event): void + { + $controller = $this->getController(); + $controller->set('ajaxEnabled', $this->isHtmx()); + + if ($this->isHtmx()) { + $controller->viewBuilder()->setLayout('ajax'); + + return; + } + + if ($this->isJson()) { + $this->renderJson($controller); + } + } + + /** + * Switch the view to JSON and assemble the response envelope. + * + * @param \Cake\Controller\Controller $controller The controller being rendered. + * @return void + */ + protected function renderJson(Controller $controller): void + { + $builder = $controller->viewBuilder(); + $builder->setClassName('Json'); + + $flash = AjaxFlash::consume($controller->getRequest()->getSession()); + $controller->set('flash', $flash); + + if ($this->loginFailed) { + $controller->set('success', false); + $controller->set('error', $flash['message'] ?? __d('cake_d_c/users', 'Authentication failed')); + $builder->setOption('serialize', ['success', 'error', 'flash']); + + return; + } + + $errorEntity = $this->findEntityWithErrors($builder->getVars()); + if ($errorEntity !== null) { + $controller->set('success', false); + $controller->set('errors', $errorEntity->getErrors()); + $controller->setResponse($controller->getResponse()->withStatus(422)); + $builder->setOption('serialize', ['success', 'errors', 'flash']); + + return; + } + + $controller->set('success', true); + $existing = (array)$builder->getOption('serialize'); + $builder->setOption('serialize', array_values(array_unique(array_merge($existing, ['success', 'flash'])))); + } + + /** + * Find the first view var that is an entity carrying validation errors. + * + * Keyed off "has errors" rather than a fixed var name so any action's entity + * (register's `user`, a profile form, etc.) drives the 422 response. + * + * @param array $vars The view vars set on the controller. + * @return \Cake\Datasource\EntityInterface|null The entity with errors, or null. + */ + protected function findEntityWithErrors(array $vars): ?EntityInterface + { + foreach ($vars as $value) { + if ($value instanceof EntityInterface && $value->hasErrors()) { + return $value; + } + } + + return null; + } +} diff --git a/src/Loader/MiddlewareQueueLoader.php b/src/Loader/MiddlewareQueueLoader.php index eff36fd8..9a30da60 100644 --- a/src/Loader/MiddlewareQueueLoader.php +++ b/src/Loader/MiddlewareQueueLoader.php @@ -22,6 +22,7 @@ use Cake\Http\MiddlewareQueue; use CakeDC\Auth\Authentication\TwoFactorProcessorLoader; use CakeDC\Auth\Middleware\TwoFactorMiddleware; +use CakeDC\Users\Middleware\AjaxRedirectMiddleware; use CakeDC\Users\Middleware\SocialAuthMiddleware; use CakeDC\Users\Middleware\SocialEmailMiddleware; @@ -54,6 +55,7 @@ public function __invoke( $this->loadSocialMiddleware($middlewareQueue); $this->loadAuthenticationMiddleware($middlewareQueue, $authenticationServiceProvider); $this->load2faMiddleware($middlewareQueue); + $this->loadAjaxMiddleware($middlewareQueue); return $this->loadAuthorizationMiddleware($middlewareQueue, $authorizationServiceProvider); } @@ -102,6 +104,19 @@ protected function load2faMiddleware(MiddlewareQueue $middlewareQueue) } } + /** + * Load AjaxRedirectMiddleware when 'Users.Ajax.enabled' is true. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue queue of middleware + * @return void + */ + protected function loadAjaxMiddleware(MiddlewareQueue $middlewareQueue) + { + if (Configure::read('Users.Ajax.enabled')) { + $middlewareQueue->add(new AjaxRedirectMiddleware()); + } + } + /** * Load authorization middleware based on Auth.Authorization. * diff --git a/src/Middleware/AjaxRedirectMiddleware.php b/src/Middleware/AjaxRedirectMiddleware.php new file mode 100644 index 00000000..ea3750a0 --- /dev/null +++ b/src/Middleware/AjaxRedirectMiddleware.php @@ -0,0 +1,84 @@ + 200 with an `HX-Redirect` header (HTMX performs the redirect client-side). + * - JSON request -> 200 with `{"success": , "redirect": "", "flash": ...}`. + * `success` is false (and an `error` message is added) when the plugin queued an + * error flash before redirecting — e.g. a failed two-factor `verify`, which + * redirects back to the login action. A redirect alone never implies success. + * + * The middleware echoes the response's existing `Location` verbatim; it never + * builds a destination from request input, so it adds no open-redirect surface of + * its own. For the login flow that `Location` was already host-validated by + * LoginComponent::afterIdentifyUser(); the other plugin redirects target fixed + * plugin actions. + */ +class AjaxRedirectMiddleware implements MiddlewareInterface +{ + /** + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + /** @var \Cake\Http\ServerRequest $request */ + if ($request->getParam('plugin') !== 'CakeDC/Users') { + return $response; + } + + $location = $response->getHeaderLine('Location'); + $status = $response->getStatusCode(); + if ($status < 300 || $status >= 400 || $location === '') { + return $response; + } + + /** @var \Cake\Http\Response $response */ + if ($request->hasHeader('HX-Request')) { + return $response + ->withStatus(200) + ->withoutHeader('Location') + ->withHeader('HX-Redirect', $location); + } + + if ($request->is('json')) { + $flash = AjaxFlash::consume($request->getSession()); + $success = ($flash['type'] ?? null) !== 'error'; + $payload = ['success' => $success, 'redirect' => $location, 'flash' => $flash]; + if (!$success) { + $payload['error'] = $flash['message']; + } + + return $response + ->withStatus(200) + ->withoutHeader('Location') + ->withType('application/json') + ->withStringBody((string)json_encode($payload)); + } + + return $response; + } +} diff --git a/src/Utility/AjaxFlash.php b/src/Utility/AjaxFlash.php new file mode 100644 index 00000000..7a964360 --- /dev/null +++ b/src/Utility/AjaxFlash.php @@ -0,0 +1,69 @@ + element `error`, `success()` -> + * `success`, etc.). + * + * `consume()` drains the whole Flash stack on purpose: a JSON/ajax response is a + * terminal transition for the client, so any queued message is delivered in the + * body and must not linger to be rendered on a later full-page load. + */ +class AjaxFlash +{ + /** + * Read the first pending flash message and clear the Flash stack. + * + * @param \Cake\Http\Session $session The session holding the Flash stack. + * @return array|null `['type' => 'error'|'success'|'info', 'message' => string]`, + * or null when no flash is set. + */ + public static function consume(Session $session): ?array + { + $stack = $session->read('Flash'); + if (empty($stack)) { + return null; + } + + $result = null; + foreach ($stack as $messages) { + foreach ((array)$messages as $entry) { + if (!is_array($entry) || !isset($entry['message'])) { + continue; + } + $element = (string)($entry['element'] ?? ''); + $type = 'info'; + if (str_contains($element, 'error')) { + $type = 'error'; + } elseif (str_contains($element, 'success')) { + $type = 'success'; + } + $result = ['type' => $type, 'message' => $entry['message']]; + break 2; + } + } + $session->delete('Flash'); + + return $result; + } +} diff --git a/templates/Users/change_password.php b/templates/Users/change_password.php index 233343c2..3ce1438d 100644 --- a/templates/Users/change_password.php +++ b/templates/Users/change_password.php @@ -1,6 +1,10 @@
Flash->render('auth') ?> - Form->create($user) ?> + Form->create($user, ($ajaxEnabled ?? false) ? [ + 'hx-post' => $this->Url->build(['action' => 'changePassword']), + 'hx-target' => '#ajax-login-container', + 'hx-swap' => 'innerHTML', + ] : []) ?>
diff --git a/templates/Users/login.php b/templates/Users/login.php index 7b7291a1..289415d4 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -14,7 +14,11 @@ ?>
Flash->render('auth') ?> - Form->create() ?> + Form->create(null, ($ajaxEnabled ?? false) ? [ + 'hx-post' => $this->Url->build(['action' => 'login']), + 'hx-target' => '#ajax-login-container', + 'hx-swap' => 'innerHTML', + ] : []) ?>
Form->control('username', ['label' => __d('cake_d_c/users', 'Username'), 'required' => true, 'autofocus' => 'autofocus']) ?> diff --git a/templates/Users/register.php b/templates/Users/register.php index f0eaa2a2..4cea7636 100644 --- a/templates/Users/register.php +++ b/templates/Users/register.php @@ -13,7 +13,11 @@ ?>
- Form->create($user); ?> + Form->create($user, ($ajaxEnabled ?? false) ? [ + 'hx-post' => $this->Url->build(['action' => 'register']), + 'hx-target' => '#ajax-login-container', + 'hx-swap' => 'innerHTML', + ] : []); ?>
Flash->render('auth') ?> - Form->create($user) ?> + Form->create($user, ($ajaxEnabled ?? false) ? [ + 'hx-post' => $this->Url->build(['action' => 'requestResetPassword']), + 'hx-target' => '#ajax-login-container', + 'hx-swap' => 'innerHTML', + ] : []) ?>
Form->control('reference') ?> diff --git a/templates/Users/verify.php b/templates/Users/verify.php index 92648a02..51a6f0fd 100644 --- a/templates/Users/verify.php +++ b/templates/Users/verify.php @@ -2,7 +2,11 @@
- Form->create() ?> + Form->create(null, ($ajaxEnabled ?? false) ? [ + 'hx-post' => $this->Url->build(['action' => 'verify']), + 'hx-target' => '#ajax-login-container', + 'hx-swap' => 'innerHTML', + ] : []) ?> Flash->render('auth') ?> Flash->render() ?> diff --git a/templates/layout/ajax.php b/templates/layout/ajax.php new file mode 100644 index 00000000..a855365b --- /dev/null +++ b/templates/layout/ajax.php @@ -0,0 +1 @@ +fetch('content') ?> diff --git a/tests/TestCase/Controller/AjaxIntegrationTest.php b/tests/TestCase/Controller/AjaxIntegrationTest.php new file mode 100644 index 00000000..ea796ecf --- /dev/null +++ b/tests/TestCase/Controller/AjaxIntegrationTest.php @@ -0,0 +1,232 @@ +on('TestApp.afterPluginBootstrap', function () { + Configure::write('Users.Ajax.enabled', true); + }); + } + + public function testFormProtectionLoadedForHtmlWhenAjaxEnabled(): void + { + Configure::write('Users.Ajax.enabled', true); + $controller = new UsersController(new ServerRequest()); + + $this->assertTrue($controller->components()->has('FormProtection')); + $this->assertTrue($controller->components()->has('AjaxResponse')); + } + + public function testFormProtectionSkippedForJsonWhenAjaxEnabled(): void + { + Configure::write('Users.Ajax.enabled', true); + Configure::write('Users.Ajax.skipFormProtectionForJson', true); + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + $controller = new UsersController($request); + + $this->assertFalse($controller->components()->has('FormProtection')); + $this->assertTrue($controller->components()->has('AjaxResponse')); + } + + public function testNothingLoadsWhenAjaxDisabled(): void + { + Configure::write('Users.Ajax.enabled', false); + $controller = new UsersController(new ServerRequest()); + + $this->assertTrue($controller->components()->has('FormProtection')); + $this->assertFalse($controller->components()->has('AjaxResponse')); + } + + public function testLoginSuccessRedirectBecomesJson(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->post('/login', ['username' => 'user-2', 'password' => '12345']); + + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertTrue($body['success']); + $this->assertArrayHasKey('redirect', $body); + } + + public function testLoginSuccessRedirectBecomesHxRedirect(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['HX-Request' => 'true']]); + $this->post('/login', ['username' => 'user-2', 'password' => '12345']); + + $this->assertResponseCode(200); + // CakePHP's Location header is absolute (built from App.fullBaseUrl); the + // middleware echoes it verbatim so allowed cross-host redirects via + // Users.AllowedRedirectHosts survive (rather than being collapsed to a path). + $this->assertHeader('HX-Redirect', 'http://example.com/pages/home'); + } + + public function testJsonLoginFailureReturns401(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->post('/login', ['username' => 'user-2', 'password' => 'wrong-password']); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertFalse($body['success']); + $this->assertNotEmpty($body['error']); + } + + public function testHtmxLoginReturnsFragmentNotFullPage(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['HX-Request' => 'true']]); + $this->get('/login'); + + $this->assertResponseOk(); + // The form content is present... + $this->assertResponseContains('action="/login"'); + // ...but the full-page skeleton (rendered by the default layout) is not. + $this->assertResponseNotContains('assertResponseNotContains('enableAjax(); + $this->get('/login'); + + $this->assertResponseOk(); + $this->assertResponseContains('action="/login"'); + } + + public function testJsonRegisterValidationErrorsReturn422(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + // Missing required fields -> validation errors. + $this->post('/register', ['username' => '', 'email' => '', 'password' => '']); + + $this->assertResponseCode(422); + $this->assertContentType('application/json'); + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertFalse($body['success']); + $this->assertNotEmpty($body['errors']); + } + + public function testJsonProfileExcludesSensitiveFields(): void + { + $this->enableAjax(); + // Authenticate as a known fixture user. + $user = \Cake\ORM\TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000002'); + $this->session(['Auth' => $user]); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/users/profile'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $raw = (string)$this->_response->getBody(); + + // Positive: the user IS serialized at the top level (no data envelope). + $body = json_decode($raw, true); + $this->assertArrayNotHasKey('data', $body); + $this->assertArrayHasKey('user', $body); + $this->assertNotEmpty($body['user']['username']); + $this->assertSame('user-2', $body['user']['username']); + + // Negative: sensitive fields must be absent. + $this->assertStringNotContainsString('"password"', $raw); + $this->assertStringNotContainsString('"api_token"', $raw); + $this->assertStringNotContainsString('"secret"', $raw); + $this->assertStringNotContainsString('"token"', $raw); + } + + public function testFormProtectionStillLoadedForJsonWhenSkipDisabled(): void + { + Configure::write('Users.Ajax.enabled', true); + Configure::write('Users.Ajax.skipFormProtectionForJson', false); + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + $controller = new UsersController($request); + + $this->assertTrue($controller->components()->has('FormProtection')); + } + + public function testHtmxAttributesPresentOnLoginFormWhenEnabled(): void + { + $this->enableAjax(); + $this->configRequest(['headers' => ['HX-Request' => 'true']]); + $this->get('/login'); + + $this->assertResponseContains('hx-post="/login"'); + $this->assertResponseContains('hx-target="#ajax-login-container"'); + } + + public function testHtmxAttributesAbsentWhenDisabled(): void + { + // Ajax disabled (default): the form must be unchanged. + $this->get('/login'); + + $this->assertResponseOk(); + $this->assertResponseNotContains('hx-post'); + } + + public function testJsonLoginFailureFlashTypeIsError(): void + { + // The JSON `flash` envelope must classify a real auth-failure flash as + // type "error" (the element name FlashComponent stores for ->error()). + $this->enableAjax(); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->post('/login', ['username' => 'user-2', 'password' => 'wrong-password']); + + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertSame('error', $body['flash']['type']); + $this->assertNotEmpty($body['flash']['message']); + } + + public function testJsonFailureRedirectIsNotReportedAsSuccess(): void + { + // A two-factor `verify` failure sets an error flash and redirects to + // login. With 2FA disabled the same guard fires ("enable Google + // Authenticator first") without any TOTP setup. The middleware must + // report that redirect as success:false, not the old hardcoded true. + $this->enableAjax(); + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/users/verify'); + + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertFalse($body['success']); + $this->assertNotEmpty($body['error']); + $this->assertSame('error', $body['flash']['type']); + } +} diff --git a/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php b/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php new file mode 100644 index 00000000..25414a56 --- /dev/null +++ b/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php @@ -0,0 +1,141 @@ +withHeader('HX-Request', 'true'); + [$controller, $component] = $this->makeComponent($request); + + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertSame('ajax', $controller->viewBuilder()->getLayout()); + $this->assertTrue($controller->viewBuilder()->getVar('ajaxEnabled')); + } + + public function testNormalRequestSetsAjaxEnabledFalseAndNoJsonView(): void + { + [$controller, $component] = $this->makeComponent(new ServerRequest()); + + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertFalse($controller->viewBuilder()->getVar('ajaxEnabled')); + $this->assertNotSame('Json', $controller->viewBuilder()->getClassName()); + } + + public function testJsonSuccessSwitchesToJsonViewAndSerializesSuccessFlash(): void + { + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + [$controller, $component] = $this->makeComponent($request); + $controller->set('user', new Entity(['username' => 'jdoe'])); + $controller->viewBuilder()->setOption('serialize', ['user']); + + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertSame('Json', $controller->viewBuilder()->getClassName()); + $this->assertTrue($controller->viewBuilder()->getVar('success')); + $serialize = (array)$controller->viewBuilder()->getOption('serialize'); + $this->assertContains('success', $serialize); + $this->assertContains('flash', $serialize); + $this->assertContains('user', $serialize); // pre-seeded key survives the merge + } + + public function testJsonValidationErrorsSet422AndSerializeErrors(): void + { + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + [$controller, $component] = $this->makeComponent($request); + $user = new Entity(['username' => '']); + $user->setError('username', ['_required' => 'This field is required']); + $controller->set('user', $user); + + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertSame(422, $controller->getResponse()->getStatusCode()); + $this->assertFalse($controller->viewBuilder()->getVar('success')); + $serialize = (array)$controller->viewBuilder()->getOption('serialize'); + $this->assertContains('errors', $serialize); + } + + public function testJsonValidationErrorsDetectedOnNonUserEntityVar(): void + { + // Validation detection must not be hard-coded to the view var named 'user'; + // any serialized entity carrying errors should drive the 422 response. + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + [$controller, $component] = $this->makeComponent($request); + $entity = new Entity(['email' => '']); + $entity->setError('email', ['_required' => 'This field is required']); + $controller->set('profile', $entity); + $controller->viewBuilder()->setOption('serialize', ['profile']); + + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertSame(422, $controller->getResponse()->getStatusCode()); + $this->assertFalse($controller->viewBuilder()->getVar('success')); + $serialize = (array)$controller->viewBuilder()->getOption('serialize'); + $this->assertContains('errors', $serialize); + } + + public function testAfterLoginFailureSets401ForJson(): void + { + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + [$controller, $component] = $this->makeComponent($request); + + $component->afterLoginFailure(new Event('Users.Authentication.afterLoginFailure', $controller, ['result' => null])); + + $this->assertSame(401, $controller->getResponse()->getStatusCode()); + } + + public function testAfterLoginFailureIgnoresNonAjax(): void + { + [$controller, $component] = $this->makeComponent(new ServerRequest()); + + $component->afterLoginFailure(new Event('Users.Authentication.afterLoginFailure', $controller, ['result' => null])); + + $this->assertSame(200, $controller->getResponse()->getStatusCode()); + } + + public function testJsonLoginFailureRendersErrorBodyWith401(): void + { + $request = (new ServerRequest())->withHeader('Accept', 'application/json'); + [$controller, $component] = $this->makeComponent($request); + + $component->afterLoginFailure(new Event('Users.Authentication.afterLoginFailure', $controller, ['result' => null])); + $component->beforeRender(new Event('Controller.beforeRender', $controller)); + + $this->assertSame(401, $controller->getResponse()->getStatusCode()); + $this->assertSame('Json', $controller->viewBuilder()->getClassName()); + $this->assertFalse($controller->viewBuilder()->getVar('success')); + $this->assertNotEmpty($controller->viewBuilder()->getVar('error')); + $serialize = (array)$controller->viewBuilder()->getOption('serialize'); + $this->assertContains('error', $serialize); + } +} diff --git a/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php b/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php new file mode 100644 index 00000000..a54e7124 --- /dev/null +++ b/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php @@ -0,0 +1,166 @@ + $url]) + ->withParam('plugin', 'CakeDC/Users'); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request; + } + + protected function redirectHandler(string $location): TestRequestHandler + { + return new TestRequestHandler(function ($request) use ($location) { + return (new Response())->withStatus(302)->withHeader('Location', $location); + }); + } + + /** + * Seed the request session with a flash message shaped exactly as + * FlashComponent stores it (the `element` is the method name, e.g. `error`). + */ + protected function withFlash($request, string $element, string $message) + { + $request->getSession()->write('Flash', [ + 'auth' => [ + ['message' => $message, 'key' => 'auth', 'element' => $element, 'params' => []], + ], + ]); + + return $request; + } + + public function testHtmxRedirectBecomesHxRedirectHeader(): void + { + $request = $this->pluginRequest('/login', ['HX-Request' => 'true']); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/dashboard')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('/dashboard', $response->getHeaderLine('HX-Redirect')); + $this->assertSame('', (string)$response->getBody()); + } + + public function testJsonRedirectBecomesJsonBody(): void + { + $request = $this->pluginRequest('/login', ['Accept' => 'application/json']); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/dashboard')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('application/json', $response->getHeaderLine('Content-Type')); + $body = json_decode((string)$response->getBody(), true); + $this->assertTrue($body['success']); + $this->assertSame('/dashboard', $body['redirect']); + } + + public function testJsonRedirectWithErrorFlashReportsFailure(): void + { + // A 2FA verify failure sets an error flash, then redirects to login. + // The middleware must NOT label that redirect success:true. + $request = $this->pluginRequest('/verify', ['Accept' => 'application/json']); + $this->withFlash($request, 'error', 'Verification code is invalid. Try again'); + + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/login')); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string)$response->getBody(), true); + $this->assertFalse($body['success']); + $this->assertSame('Verification code is invalid. Try again', $body['error']); + $this->assertSame('error', $body['flash']['type']); + $this->assertSame('/login', $body['redirect']); + } + + public function testJsonRedirectWithSuccessFlashReportsSuccessAndIncludesFlash(): void + { + // A genuine login success sets a success/welcome flash, then redirects. + $request = $this->pluginRequest('/login', ['Accept' => 'application/json']); + $this->withFlash($request, 'success', 'Welcome, user-2'); + + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/dashboard')); + + $body = json_decode((string)$response->getBody(), true); + $this->assertTrue($body['success']); + $this->assertArrayNotHasKey('error', $body); + $this->assertSame('success', $body['flash']['type']); + $this->assertSame('Welcome, user-2', $body['flash']['message']); + } + + public function testHtmxRedirectDoesNotConsumeFlash(): void + { + // HTMX performs a full-page redirect; the flash must survive so the + // destination page can render it (the middleware must not drain it). + $request = $this->pluginRequest('/verify', ['HX-Request' => 'true']); + $this->withFlash($request, 'error', 'Verification code is invalid. Try again'); + + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/login')); + + $this->assertSame('/login', $response->getHeaderLine('HX-Redirect')); + $this->assertNotEmpty($request->getSession()->read('Flash')); + } + + public function testNonRedirectIsPassedThrough(): void + { + $request = $this->pluginRequest('/login', ['HX-Request' => 'true']); + $original = (new Response())->withStatus(200); + $handler = new TestRequestHandler(fn($req) => $original); + + $this->assertSame($original, (new AjaxRedirectMiddleware())->process($request, $handler)); + } + + public function testOutOfPluginScopeIsPassedThrough(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']) + ->withHeader('HX-Request', 'true'); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/x')); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('', $response->getHeaderLine('HX-Redirect')); + } + + public function testNonAjaxRedirectIsPassedThrough(): void + { + $request = $this->pluginRequest('/login'); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/dashboard')); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('/dashboard', $response->getHeaderLine('Location')); + } + + public function testSetCookieHeadersArePreserved(): void + { + $request = $this->pluginRequest('/login', ['HX-Request' => 'true']); + $handler = new TestRequestHandler(function ($req) { + return (new Response()) + ->withStatus(302) + ->withHeader('Location', '/dashboard') + ->withHeader('Set-Cookie', 'remember_me=abc; Path=/'); + }); + $response = (new AjaxRedirectMiddleware())->process($request, $handler); + + $this->assertSame('/dashboard', $response->getHeaderLine('HX-Redirect')); + $this->assertSame('remember_me=abc; Path=/', $response->getHeaderLine('Set-Cookie')); + } +} From 5f47bacf111d74b620a826daa753b3e200fbee65 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 28 Aug 2026 14:36:25 +0200 Subject: [PATCH 3/3] feat(ajax): full-page HTMX enhancement and reCaptcha (v2/v3) support Builds on the opt-in AJAX/HTMX support: - Progressively enhance the plugin's full-page forms (login, register, password flows, two-factor verify) with HTMX; the #ajax-login-container swap target is rendered on the full page only and omitted from the swapped-in fragment. - Wrap the authentication and two-factor middleware so the redirects they issue (e.g. a successful login into the verify action) also convert to JSON / HX-Redirect, and scope conversion to the configured Users controller. - Make reCaptcha work on the HTMX channel for v2 and v3 via a small glue script, gated on the ajax render context so forms outside it keep native reCaptcha. - Hardening: validate the reCaptcha version, cap the glue's readiness retries, forward the widget theme/size, and use the login-failure event constant. - Tests: correct and extend the component and middleware unit tests (fragment contract, controller scope). - Docs: AjaxAndHtmx.md covers full-page enhancement, two-factor, reCaptcha, the JSON mass-assignment warning and the modal caveats. --- Docs/Documentation/AjaxAndHtmx.md | 91 ++++++++++- .../Component/AjaxResponseComponent.php | 10 +- src/Loader/MiddlewareQueueLoader.php | 7 +- src/Middleware/AjaxRedirectMiddleware.php | 11 +- src/View/Helper/UserHelper.php | 95 +++++++++-- templates/Users/change_password.php | 8 + templates/Users/login.php | 8 + templates/Users/register.php | 8 + templates/Users/request_reset_password.php | 8 + templates/Users/verify.php | 8 + .../Controller/AjaxIntegrationTest.php | 3 +- .../Component/AjaxResponseComponentTest.php | 9 +- .../Middleware/AjaxRedirectMiddlewareTest.php | 37 ++++- webroot/js/reCaptchaHtmx.js | 148 ++++++++++++++++++ 14 files changed, 428 insertions(+), 23 deletions(-) create mode 100644 webroot/js/reCaptchaHtmx.js diff --git a/Docs/Documentation/AjaxAndHtmx.md b/Docs/Documentation/AjaxAndHtmx.md index 0e38a83d..38fbb0e2 100644 --- a/Docs/Documentation/AjaxAndHtmx.md +++ b/Docs/Documentation/AjaxAndHtmx.md @@ -18,12 +18,51 @@ Add this to your `config/users.php`: ``` The host application is responsible for loading the HTMX library if you use the HTMX channel — -the plugin only emits HTML attributes and response headers, it ships no JavaScript: +the plugin emits HTML attributes and response headers and ships no HTMX runtime of its own (the +only script it ships is the optional reCaptcha glue, see [reCaptcha](#recaptcha)): ```html ``` +reCaptcha +--------- +**HTMX channel:** reCaptcha works with HTMX. reCaptcha assumes a full-page lifecycle +(load → render → submit → reload) that HTMX breaks in two places — a widget arriving via swap +is never auto-rendered, and the one-time token is spent when the form re-renders without a +reload — so, when the feature is enabled, `addReCaptcha()` loads a small glue script +(`reCaptchaHtmx.js`) that bridges both, for the two versions the plugin supports: + +* **v2 (checkbox):** the glue renders every widget on load and after each swap + (`grecaptcha.render`); its token rides along in the serialized form, and a failed submit swaps + in a fresh widget, so no manual reset is needed. +* **v3 (invisible):** the plugin drops its default button-bound flow (which does a native + `form.submit()` that bypasses HTMX) for a plain submit button plus a hidden + `g-recaptcha-response` field; on `htmx:confirm` the glue runs `grecaptcha.execute()` and + injects a fresh token into the request via `htmx:configRequest` before it is issued. + +Nothing changes server-side — the token is validated against `siteverify` exactly as for a +normal form. This is all automatic once `Users.Ajax.enabled` is on; you only load HTMX itself. + +The **v3** HTMX rendering follows the plugin's ajax context: `addReCaptcha()`/`button()` switch to +the HTMX flow only where `AjaxResponseComponent` is active (the plugin's ajax controllers set the +`ajaxEnabled` view var), so a v3 reCaptcha on a form rendered elsewhere — a custom app form, or a +non-plugin page — keeps the normal button-bound flow and works unchanged. The one case to avoid is +stripping the `hx-*` attributes from an *overridden plugin template* while the feature is on: the +reCaptcha would then render in HTMX mode but the form would submit natively with an empty token. +(v2 is unaffected — its token comes from the checkbox regardless of how the form submits.) + +**JSON channel:** a programmatic JSON client runs no browser JavaScript and so can never +produce a token — such a request always fails the reCaptcha check (a tokenless request is +reported as a failed reCaptcha; note that `cakedc/auth` before the empty-token guard raised a +`500` TypeError here instead). Disable reCaptcha on any form you expose over JSON — login +(`Users.reCaptcha.login`) and registration (`Users.reCaptcha.registration`): + +```php +'Users.reCaptcha.login' => false, +'Users.reCaptcha.registration' => false, +``` + How does it work ---------------- When enabled, a lightweight component and a thin middleware are loaded for the plugin's @@ -66,7 +105,8 @@ reports the redirect as a failure and echoes the message, so a client can tell t A SPA that needs the full user object after login can request `GET /users/profile` with `Accept: application/json`. -Authentication failure (login / 2FA) returns `401`: +A failed JSON **login** returns `401` (a two-factor `verify` failure is different — it +redirects and is reported as a `200` with `success: false`; see [Notes & limitations](#notes--limitations)): ```json { "success": false, "error": "Username or password is incorrect", "flash": { "type": "error", "message": "..." } } @@ -94,6 +134,27 @@ response into a target container (default target `#ajax-login-container`). * On a validation/authentication failure the re-rendered form fragment is returned and swapped back in, showing the flash error. +### Full-page progressive enhancement +When the feature is enabled, the plugin's own full-page views (`/login`, `/register`, the +password flows, two-factor `verify`) render the form inside the `#ajax-login-container` swap +target and carry the `hx-*` attributes. So — once the host app has loaded HTMX — the full page +submits via HTMX and swaps in place instead of reloading: validation/auth failures swap the +re-rendered fragment back into the container, and successes (as well as the two-factor +challenge) navigate via `HX-Redirect`. If HTMX is not loaded the same form still carries a +normal `action`/`method`, so it degrades gracefully to a standard full-page POST. + +The `#ajax-login-container` wrapper is emitted on the full page only; the HTMX fragment +re-render (which is swapped *into* that container) omits it, so nothing nests or duplicates. + +Load HTMX from the host app — the plugin ships no HTMX runtime. A common pattern is to load it +only while the feature is on, e.g. in your layout ``: + +```php + + + +``` + ### Using it in a modal 1. Enable the feature and include HTMX (see [Enabling](#enabling)). 2. Add a trigger that loads the login form fragment into your modal body: @@ -105,6 +166,14 @@ response into a target container (default target `#ajax-login-container`). 3. The plugin returns the login form rendered with the `ajax` layout (fragment only). 4. The form submits via HTMX; on success the `HX-Redirect` header redirects the page. +> **reCaptcha in the modal flow:** the `ajax` layout renders only the content fragment, so the +> reCaptcha `api.js` and glue that `addReCaptcha()` registers into the `script` block are dropped +> from the fragment. When the login form is loaded into a modal on a page that is not itself one +> of the plugin's reCaptcha pages, the host page must load `https://www.google.com/recaptcha/api.js` +> (v3: `?render=`, v2: `?render=explicit`) and `CakeDC/Users.reCaptchaHtmx` itself, **and** +> set the config the glue reads — `window.CakeDCUsersReCaptcha = {version: 2|3, siteKey: ''}` +> (the glue gates every path on it) — or disable reCaptcha for that flow. + FormProtection & CSRF --------------------- The plugin protects its forms with CakePHP's `FormProtection` (field tampering) and CSRF @@ -120,6 +189,15 @@ the option to `false` to instead require JSON clients to round-trip the rendered 'Users.Ajax.skipFormProtectionForJson' => false, ``` +> ⚠️ **Mass-assignment on the JSON register.** On the HTML channel `FormProtection`'s field +> locking is also what stops a client from POSTing columns the form never rendered. With +> `skipFormProtectionForJson => true` that guard is gone for JSON, so a JSON `register` is bounded +> only by the User entity's `$_accessible`. The plugin's default entity is `'*' => true`, which +> lets a JSON registrant set `secret`/`secret_verified` (pre-seeding a known TOTP secret), +> `api_token`, `additional_data`, and any custom column your app added to `users`. Before exposing +> `register` over JSON, tighten `$_accessible` on your (extended) User entity — allow only the +> real registration fields — or set `skipFormProtectionForJson => false` and round-trip the form. + Events ------ This feature changes only response formatting. All plugin events @@ -129,10 +207,15 @@ before, so your existing listeners keep working. See [Events](Events.md). Notes & limitations ------------------- * Session-based authentication only (no JWT / token issuance). +* With two-factor login enabled (e.g. `OneTimePasswordAuthenticator.login`), a successful + password step redirects to the `verify` action for the second factor. The middleware wraps the + authentication and two-factor middleware, so that redirect is converted like any other: JSON + receives `{ "success": true, "redirect": "" }` and HTMX an `HX-Redirect` to the verify + page. A `success: true` here means "password accepted, now complete 2FA", not a finished login. * Two-factor `verify` failures redirect to the login action rather than returning a `401` body. They are reported as `{ "success": false, "redirect": "", "error": "..." }` (HTTP `200`), so a JSON client must check `success` rather than assume a redirect means the step passed. -* The feature acts only on the Users plugin's own routes; your application's other routes are - untouched. +* The feature acts only on the configured Users controller's routes — it honors a custom + `Users.controller`, and your application's other routes are untouched. * `socialLogin` is an OAuth browser callback (full-page navigation), not a JSON/HTMX channel, and is unaffected by this feature. diff --git a/src/Controller/Component/AjaxResponseComponent.php b/src/Controller/Component/AjaxResponseComponent.php index 4a621f4d..c666c0b6 100644 --- a/src/Controller/Component/AjaxResponseComponent.php +++ b/src/Controller/Component/AjaxResponseComponent.php @@ -17,6 +17,7 @@ use Cake\Controller\Controller; use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; +use CakeDC\Users\UsersPlugin; use CakeDC\Users\Utility\AjaxFlash; /** @@ -41,7 +42,7 @@ public function implementedEvents(): array { return [ 'Controller.beforeRender' => 'beforeRender', - 'Users.Authentication.afterLoginFailure' => 'afterLoginFailure', + UsersPlugin::EVENT_AFTER_LOGIN_FAILURE => 'afterLoginFailure', ]; } @@ -91,7 +92,12 @@ public function afterLoginFailure(EventInterface $event): void public function beforeRender(EventInterface $event): void { $controller = $this->getController(); - $controller->set('ajaxEnabled', $this->isHtmx()); + // This component is only loaded while the feature is enabled, so the plugin's + // forms are always HTMX-enhanced. `ajaxFragment` tells a full-page render + // (which emits the outer #ajax-login-container swap target) from an HTMX + // fragment re-render (which is swapped *into* that container, so omits it). + $controller->set('ajaxEnabled', true); + $controller->set('ajaxFragment', $this->isHtmx()); if ($this->isHtmx()) { $controller->viewBuilder()->setLayout('ajax'); diff --git a/src/Loader/MiddlewareQueueLoader.php b/src/Loader/MiddlewareQueueLoader.php index 9a30da60..679836c7 100644 --- a/src/Loader/MiddlewareQueueLoader.php +++ b/src/Loader/MiddlewareQueueLoader.php @@ -52,10 +52,15 @@ public function __invoke( AuthenticationServiceProviderInterface $authenticationServiceProvider, AuthorizationServiceProviderInterface $authorizationServiceProvider, ) { + // Ajax middleware must wrap the authentication, two-factor and authorization + // middleware so it can rewrite the redirects THEY emit (e.g. a successful + // login redirecting into the two-factor `verify` action, or an unauthorized + // access redirecting to login) into JSON / HX-Redirect responses. Loading it + // first makes it the outermost of the plugin middleware. + $this->loadAjaxMiddleware($middlewareQueue); $this->loadSocialMiddleware($middlewareQueue); $this->loadAuthenticationMiddleware($middlewareQueue, $authenticationServiceProvider); $this->load2faMiddleware($middlewareQueue); - $this->loadAjaxMiddleware($middlewareQueue); return $this->loadAuthorizationMiddleware($middlewareQueue, $authorizationServiceProvider); } diff --git a/src/Middleware/AjaxRedirectMiddleware.php b/src/Middleware/AjaxRedirectMiddleware.php index ea3750a0..794a6700 100644 --- a/src/Middleware/AjaxRedirectMiddleware.php +++ b/src/Middleware/AjaxRedirectMiddleware.php @@ -14,6 +14,7 @@ namespace CakeDC\Users\Middleware; use CakeDC\Users\Utility\AjaxFlash; +use CakeDC\Users\Utility\UsersUrl; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -45,8 +46,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface { $response = $handler->handle($request); + // Scope to the configured Users controller (honors a custom `Users.controller`) + // rather than a hardcoded plugin name, so an app controller is not left in a + // half-enabled state where the component/templates emit hx-* but redirects + // are never converted. /** @var \Cake\Http\ServerRequest $request */ - if ($request->getParam('plugin') !== 'CakeDC/Users') { + $scope = UsersUrl::actionParams('login'); + if ( + $request->getParam('plugin') !== $scope['plugin'] + || $request->getParam('controller') !== $scope['controller'] + ) { return $response; } diff --git a/src/View/Helper/UserHelper.php b/src/View/Helper/UserHelper.php index 0a41301b..7018bd12 100644 --- a/src/View/Helper/UserHelper.php +++ b/src/View/Helper/UserHelper.php @@ -19,6 +19,7 @@ use Cake\View\Helper; use Cake\View\StringTemplateTrait; use CakeDC\Users\Utility\UsersUrl; +use Exception; use InvalidArgumentException; /** @@ -198,6 +199,21 @@ public function addPasswordMeter(): string $this->Html->tag('div', '', ['id' => 'pswmeter-message']) . $script; } + /** + * Whether the current render is the plugin's HTMX-enhanced ajax context. + * + * Gated on the `ajaxEnabled` view var (set by AjaxResponseComponent only for the + * plugin's ajax-enabled controllers) rather than the raw `Users.Ajax.enabled` + * config, so reCaptcha on a form rendered outside that context — a custom app + * form, or a non-plugin page — keeps its normal (non-HTMX) rendering and works. + * + * @return bool + */ + protected function isAjaxHtmxContext(): bool + { + return (bool)$this->getView()->get('ajaxEnabled'); + } + /** * Add reCaptcha to the form * @@ -214,20 +230,69 @@ public function addReCaptcha(): mixed ), ); } - $this->addReCaptchaScript(); $version = Configure::read('Users.reCaptcha.version', 2); - $method = "addReCaptchaV$version"; - if (method_exists($this, $method)) { - try { - $this->Form->unlockField('g-recaptcha-response'); - } catch (\Exception $e) { - } + if (!in_array($version, [2, 3, '2', '3'], true)) { + throw new InvalidArgumentException( + __d('cake_d_c/users', 'reCaptcha version is wrong. Please configure Users.reCaptcha.version as 2 or 3'), + ); + } + $version = (int)$version; + try { + $this->Form->unlockField('g-recaptcha-response'); + } catch (Exception $e) { + } - return $this->{$method}(); + // In the plugin's HTMX render, reCaptcha's page-load lifecycle breaks: a v2 + // widget swapped into the DOM is never auto-rendered, and v3's default + // button-bound flow submits the form natively (bypassing HTMX). There the + // integration renders differently and loads a glue script instead. Outside + // that render (a custom app form) the normal reCaptcha is kept. + if ($this->isAjaxHtmxContext()) { + return $this->addReCaptchaHtmx($version); } - throw new InvalidArgumentException( - __d('cake_d_c/users', 'reCaptcha version is wrong. Please configure Users.reCaptcha.version as 2 or 3'), + + $this->addReCaptchaScript(); + + return $this->{"addReCaptchaV$version"}(); + } + + /** + * Render reCaptcha for the AJAX/HTMX channel and load the glue script that + * bridges Google's page-load lifecycle to HTMX: + * - v2: the checkbox widget is rendered by the glue on load and after every + * swap (`grecaptcha.render`), and its token rides along in the serialized form. + * - v3: a hidden `g-recaptcha-response` field is filled by the glue from + * `grecaptcha.execute()` on `htmx:confirm`, just before the request is issued + * (a fresh token per submit, so the single-use limitation is handled too). + * + * @param int $version reCaptcha version (2 or 3) + * @return string markup to place inside the form + */ + private function addReCaptchaHtmx(int $version): string + { + $key = (string)Configure::read('Users.reCaptcha.key'); + // v3 needs api.js loaded with ?render= so execute() is available; + // v2 needs ?render=explicit so the glue controls when widgets render. + $apiUrl = $version === 3 + ? 'https://www.google.com/recaptcha/api.js?render=' . urlencode($key) + : 'https://www.google.com/recaptcha/api.js?render=explicit'; + $this->Html->script($apiUrl, ['block' => 'script']); + $this->Html->scriptBlock( + sprintf('window.CakeDCUsersReCaptcha = {version: %d, siteKey: %s};', $version, json_encode($key)), + ['block' => 'script'], ); + $this->Html->script('CakeDC/Users.reCaptchaHtmx', ['block' => 'script']); + + if ($version === 3) { + return $this->Form->hidden('g-recaptcha-response', ['id' => false]); + } + + return $this->Html->tag('div', '', [ + 'class' => 'g-recaptcha', + 'data-sitekey' => $key, + 'data-theme' => Configure::read('Users.reCaptcha.theme') ?: 'light', + 'data-size' => Configure::read('Users.reCaptcha.size') ?: 'normal', + ]); } /** @@ -268,7 +333,15 @@ private function addReCaptchaV3(): void public function button(string $title, array $options = []): string { $key = Configure::read('Users.reCaptcha.key'); - if ($key && Configure::read('Users.reCaptcha.version', 2) === 3) { + // The v3 "button-bound" flow (grecaptcha binds the click and calls onSubmit, + // which does a native form.submit()) bypasses HTMX. In the plugin's HTMX + // render the button stays a plain submit and reCaptchaHtmx.js runs execute() + // on htmx:confirm instead; outside it (a custom app form) the button-bound + // native flow is kept. See addReCaptchaHtmx(). + if ( + $key && (int)Configure::read('Users.reCaptcha.version', 2) === 3 + && !$this->isAjaxHtmxContext() + ) { $options = array_merge($options, [ 'class' => 'g-recaptcha', 'data-sitekey' => $key, diff --git a/templates/Users/change_password.php b/templates/Users/change_password.php index 3ce1438d..cd4288a5 100644 --- a/templates/Users/change_password.php +++ b/templates/Users/change_password.php @@ -1,3 +1,8 @@ + + +
+
Flash->render('auth') ?> Form->create($user, ($ajaxEnabled ?? false) ? [ @@ -33,3 +38,6 @@ Form->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']); ?> Form->end() ?>
+ +
+ diff --git a/templates/Users/login.php b/templates/Users/login.php index 289415d4..bb922788 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -12,6 +12,11 @@ use Cake\Core\Configure; ?> + + +
+
Flash->render('auth') ?> Form->create(null, ($ajaxEnabled ?? false) ? [ @@ -56,3 +61,6 @@ User->button(__d('cake_d_c/users', 'Login')); ?> Form->end() ?>
+ +
+ diff --git a/templates/Users/register.php b/templates/Users/register.php index 4cea7636..98254951 100644 --- a/templates/Users/register.php +++ b/templates/Users/register.php @@ -12,6 +12,11 @@ use Cake\Core\Configure; ?> + + +
+
Form->create($user, ($ajaxEnabled ?? false) ? [ 'hx-post' => $this->Url->build(['action' => 'register']), @@ -45,3 +50,6 @@ User->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']) ?> Form->end() ?>
+ +
+ diff --git a/templates/Users/request_reset_password.php b/templates/Users/request_reset_password.php index 506d9b3b..1927a084 100644 --- a/templates/Users/request_reset_password.php +++ b/templates/Users/request_reset_password.php @@ -13,6 +13,11 @@ * @var \CakeDC\Users\Model\Entity\User $user */ ?> + + +
+
Flash->render('auth') ?> Form->create($user, ($ajaxEnabled ?? false) ? [ @@ -27,3 +32,6 @@ Form->button(__d('cake_d_c/users', 'Submit')); ?> Form->end() ?>
+ +
+ diff --git a/templates/Users/verify.php b/templates/Users/verify.php index 51a6f0fd..e3d90f4d 100644 --- a/templates/Users/verify.php +++ b/templates/Users/verify.php @@ -1,3 +1,8 @@ + + +
+
@@ -22,3 +27,6 @@
+ +
+ diff --git a/tests/TestCase/Controller/AjaxIntegrationTest.php b/tests/TestCase/Controller/AjaxIntegrationTest.php index ea796ecf..8d48a15b 100644 --- a/tests/TestCase/Controller/AjaxIntegrationTest.php +++ b/tests/TestCase/Controller/AjaxIntegrationTest.php @@ -16,6 +16,7 @@ use Cake\Core\Configure; use Cake\Event\EventManager; use Cake\Http\ServerRequest; +use Cake\ORM\TableRegistry; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use CakeDC\Users\Controller\UsersController; @@ -145,7 +146,7 @@ public function testJsonProfileExcludesSensitiveFields(): void { $this->enableAjax(); // Authenticate as a known fixture user. - $user = \Cake\ORM\TableRegistry::getTableLocator() + $user = TableRegistry::getTableLocator() ->get('CakeDC/Users.Users') ->get('00000000-0000-0000-0000-000000000002'); $this->session(['Auth' => $user]); diff --git a/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php b/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php index 25414a56..be091be2 100644 --- a/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php +++ b/tests/TestCase/Controller/Component/AjaxResponseComponentTest.php @@ -40,15 +40,20 @@ public function testHtmxSetsAjaxLayoutAndFlag(): void $this->assertSame('ajax', $controller->viewBuilder()->getLayout()); $this->assertTrue($controller->viewBuilder()->getVar('ajaxEnabled')); + // An HTMX request re-renders only the fragment, so the outer wrapper is omitted. + $this->assertTrue($controller->viewBuilder()->getVar('ajaxFragment')); } - public function testNormalRequestSetsAjaxEnabledFalseAndNoJsonView(): void + public function testNormalRequestKeepsHtmlViewAndMarksFullPage(): void { [$controller, $component] = $this->makeComponent(new ServerRequest()); $component->beforeRender(new Event('Controller.beforeRender', $controller)); - $this->assertFalse($controller->viewBuilder()->getVar('ajaxEnabled')); + // The component is only loaded while the feature is enabled, so the forms are + // always HTMX-enhanced; a full page renders the wrapper (ajaxFragment false). + $this->assertTrue($controller->viewBuilder()->getVar('ajaxEnabled')); + $this->assertFalse($controller->viewBuilder()->getVar('ajaxFragment')); $this->assertNotSame('Json', $controller->viewBuilder()->getClassName()); } diff --git a/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php b/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php index a54e7124..471a64b1 100644 --- a/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php +++ b/tests/TestCase/Middleware/AjaxRedirectMiddlewareTest.php @@ -13,6 +13,7 @@ namespace CakeDC\Users\Test\TestCase\Middleware; +use Cake\Core\Configure; use Cake\Http\Response; use Cake\Http\ServerRequestFactory; use Cake\TestSuite\TestCase; @@ -24,7 +25,8 @@ class AjaxRedirectMiddlewareTest extends TestCase protected function pluginRequest(string $url, array $headers = []) { $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => $url]) - ->withParam('plugin', 'CakeDC/Users'); + ->withParam('plugin', 'CakeDC/Users') + ->withParam('controller', 'Users'); foreach ($headers as $name => $value) { $request = $request->withHeader($name, $value); } @@ -140,6 +142,39 @@ public function testOutOfPluginScopeIsPassedThrough(): void $this->assertSame('', $response->getHeaderLine('HX-Redirect')); } + public function testCustomUsersControllerIsConverted(): void + { + // The scope honors a custom `Users.controller` (here an app controller with + // no plugin), so its redirects are still converted. + $original = Configure::read('Users.controller'); + Configure::write('Users.controller', 'MyUsers'); + try { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/login']) + ->withParam('controller', 'MyUsers') + ->withHeader('HX-Request', 'true'); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/dashboard')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('/dashboard', $response->getHeaderLine('HX-Redirect')); + } finally { + Configure::write('Users.controller', $original); + } + } + + public function testOtherPluginControllerIsNotConverted(): void + { + // Another controller in the plugin (e.g. SocialAccounts) is out of scope now + // that conversion is narrowed to the configured Users controller only. + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/social-accounts']) + ->withParam('plugin', 'CakeDC/Users') + ->withParam('controller', 'SocialAccounts') + ->withHeader('HX-Request', 'true'); + $response = (new AjaxRedirectMiddleware())->process($request, $this->redirectHandler('/x')); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('', $response->getHeaderLine('HX-Redirect')); + } + public function testNonAjaxRedirectIsPassedThrough(): void { $request = $this->pluginRequest('/login'); diff --git a/webroot/js/reCaptchaHtmx.js b/webroot/js/reCaptchaHtmx.js new file mode 100644 index 00000000..37d405e3 --- /dev/null +++ b/webroot/js/reCaptchaHtmx.js @@ -0,0 +1,148 @@ +/** + * CakeDC/Users - reCaptcha <-> HTMX glue. + * + * reCaptcha assumes a full-page lifecycle (load -> render -> submit -> reload). + * HTMX breaks that in two places: a form arriving via swap is never auto-rendered, + * and the one-time token is spent when the form is re-rendered without a reload. + * This script bridges both, for the two versions the plugin supports: + * + * v2 (checkbox): renders every `.g-recaptcha` widget on load and after each swap. + * The token lives in the widget's hidden field, so HTMX serializes + * it with the form; a failed submit swaps in a fresh unrendered + * widget, which we render again -> fresh token, no manual reset. + * + * v3 (invisible): on `htmx:confirm` we defer the request, run grecaptcha.execute() + * (a fresh token every submit), write it into the hidden + * `g-recaptcha-response` field, then let HTMX issue the request. + * + * Config is published by the helper as `window.CakeDCUsersReCaptcha = {version, siteKey}`. + */ +(function () { + 'use strict'; + + function cfg() { + return window.CakeDCUsersReCaptcha || {}; + } + + function grecaptchaReady(cb, attempt) { + if (typeof grecaptcha !== 'undefined' && grecaptcha.ready) { + grecaptcha.ready(cb); + } else if ((attempt || 0) < 40) { + // api.js not loaded yet - retry shortly (capped at ~6s so a blocked + // api.js does not spin forever; the v3 path also has its own timeout). + setTimeout(function () { grecaptchaReady(cb, (attempt || 0) + 1); }, 150); + } + } + + // --- v2: render unrendered widgets under `root` (defaults to document) --- + function renderV2(root) { + var scope = root && root.querySelectorAll ? root : document; + var widgets = scope.querySelectorAll('.g-recaptcha:not([data-rendered])'); + if (!widgets.length) { + return; + } + grecaptchaReady(function () { + widgets.forEach(function (el) { + if (el.getAttribute('data-rendered')) { + return; + } + try { + // data-theme / data-size only apply to auto-render, so forward + // them explicitly to render(). + grecaptcha.render(el, { + sitekey: cfg().siteKey, + theme: el.getAttribute('data-theme') || undefined, + size: el.getAttribute('data-size') || undefined, + }); + el.setAttribute('data-rendered', '1'); + } catch (e) { + // Already rendered or transient error - ignore. + } + }); + }); + } + + // --- v3: resolve the form element a request originated from --- + function formOf(el) { + if (!el) { + return null; + } + if (el.matches && el.matches('form')) { + return el; + } + return el.closest ? el.closest('form') : null; + } + + document.addEventListener('DOMContentLoaded', function () { + if (cfg().version === 2) { + renderV2(document); + } + }); + + document.addEventListener('htmx:afterSwap', function (evt) { + if (cfg().version === 2) { + renderV2(evt.detail && evt.detail.target); + } + }); + + // v3 tokens waiting to be attached to a form's next issued request. HTMX snapshots + // the form parameters *before* htmx:confirm, so setting the hidden field there is + // too late - the token has to be injected in htmx:configRequest instead. + var pendingTokens = new WeakMap(); + + document.addEventListener('htmx:confirm', function (evt) { + if (cfg().version !== 3) { + return; + } + var form = formOf(evt.detail && evt.detail.elt); + if (!form || !form.querySelector('[name="g-recaptcha-response"]')) { + // Not one of our reCaptcha forms - let HTMX proceed normally. + return; + } + evt.preventDefault(); + + // Guard against grecaptcha.execute() never settling (e.g. a site key not + // registered for this domain): after a timeout, issue the request anyway so + // the form never hangs. The server then rejects the empty token and swaps the + // error fragment back in. issueRequest is latched so it fires at most once. + var issued = false; + function issue(token) { + if (issued) { + return; + } + issued = true; + if (token) { + pendingTokens.set(form, token); + } + evt.detail.issueRequest(true); // true = skip re-confirmation + } + var timer = setTimeout(function () { issue(null); }, 4000); + + grecaptchaReady(function () { + try { + grecaptcha.execute(cfg().siteKey, { action: 'submit' }).then(function (token) { + clearTimeout(timer); + issue(token); + }).catch(function () { + clearTimeout(timer); + issue(null); + }); + } catch (e) { + clearTimeout(timer); + issue(null); + } + }); + }); + + // Inject the freshly-minted token into the request HTMX is about to send. + document.addEventListener('htmx:configRequest', function (evt) { + if (cfg().version !== 3) { + return; + } + var form = formOf(evt.detail && evt.detail.elt); + if (form && pendingTokens.has(form)) { + evt.detail.parameters['g-recaptcha-response'] = pendingTokens.get(form); + pendingTokens.delete(form); + } + }); +})();