From ed81b30e5f3f2d2f211eaa77d66855d365ab3359 Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Tue, 5 May 2026 10:04:34 +0200 Subject: [PATCH 1/2] Add identity platform api authentication --- config/services/google_identity_platform.yaml | 10 + src/Controller/Admin/UsersController.php | 10 + src/Controller/LoginController.php | 40 ++- src/Entity/UserAvatarTrait.php | 2 + src/Form/LoginForm.php | 10 + src/Form/LoginFormInterface.php | 3 + .../IdentityPlatformAuthenticationResult.php | 14 + ...tityPlatformGoogleAuthenticationResult.php | 14 + .../IdentityPlatformGoogleAuthenticator.php | 64 +++- .../IdentityPlatformTokens.php | 15 + .../IdentityPlatformUserSynchronizer.php | 2 +- .../LocalUserAlreadyExistsException.php | 11 + .../LocalUserNotFoundException.php | 11 + .../IdentityPlatformSessionManager.php | 288 ++++++++++++++++ ...dentityPlatformSessionManagerInterface.php | 22 ++ .../IdentityPlatformAccessTokenHandler.php | 24 ++ templates/admin/users/list-widgets.html.twig | 58 ++-- .../users/widget-pending-confirm.html.twig | 18 + templates/admin/users/widget-total.html.twig | 18 + templates/login/login.html.twig | 50 +-- ...dentityPlatformGoogleAuthenticatorTest.php | 9 +- .../IdentityPlatformSessionManagerTest.php | 319 ++++++++++++++++++ 22 files changed, 914 insertions(+), 98 deletions(-) create mode 100644 src/GoogleIdentityPlatform/IdentityPlatformAuthenticationResult.php create mode 100644 src/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticationResult.php create mode 100644 src/GoogleIdentityPlatform/IdentityPlatformTokens.php create mode 100644 src/GoogleIdentityPlatform/LocalUserAlreadyExistsException.php create mode 100644 src/GoogleIdentityPlatform/LocalUserNotFoundException.php create mode 100644 src/Manager/IdentityPlatformSessionManager.php create mode 100644 src/Manager/IdentityPlatformSessionManagerInterface.php create mode 100644 src/Security/IdentityPlatformAccessTokenHandler.php create mode 100644 templates/admin/users/widget-pending-confirm.html.twig create mode 100644 templates/admin/users/widget-total.html.twig create mode 100644 tests/Unit/Manager/IdentityPlatformSessionManagerTest.php diff --git a/config/services/google_identity_platform.yaml b/config/services/google_identity_platform.yaml index 95a6c2d..e8e1db0 100644 --- a/config/services/google_identity_platform.yaml +++ b/config/services/google_identity_platform.yaml @@ -15,3 +15,13 @@ services: $tenantId: '%sfs_user.login.google_identity_platform.tenant_id%' Softspring\UserBundle\GoogleIdentityPlatform\IdentityPlatformUserSynchronizer: ~ + + Softspring\UserBundle\Manager\IdentityPlatformSessionManager: + arguments: + $apiKey: '%sfs_user.login.google_identity_platform.api_key%' + $tenantId: '%sfs_user.login.google_identity_platform.tenant_id%' + + Softspring\UserBundle\Manager\IdentityPlatformSessionManagerInterface: + alias: Softspring\UserBundle\Manager\IdentityPlatformSessionManager + + Softspring\UserBundle\Security\IdentityPlatformAccessTokenHandler: ~ diff --git a/src/Controller/Admin/UsersController.php b/src/Controller/Admin/UsersController.php index fdc6c4c..20e5b2d 100644 --- a/src/Controller/Admin/UsersController.php +++ b/src/Controller/Admin/UsersController.php @@ -81,11 +81,16 @@ public function usersCountWidget(): Response 'users' => $this->userManager->getRepository()->count(['admin' => false]), 'administrators' => $this->userManager->getRepository()->count(['admin' => true]), 'total' => $this->userManager->getRepository()->count([]), + 'supports_confirmable' => $this->supportsConfirmableUsers(), ]); } public function usersPendingConfirmCountWidget(): Response { + if (!$this->supportsConfirmableUsers()) { + return new Response('', Response::HTTP_NO_CONTENT); + } + return $this->render('@SfsUser/admin/users/widget-pending-confirm-count.html.twig', [ 'count' => $this->userManager->getRepository()->count(['confirmedAt' => null]), ]); @@ -188,4 +193,9 @@ public function resendConfirmationEmail(string $user, Request $request): Respons return $this->redirectToRoute('sfs_user_admin_users_details', ['user' => $user]); } + + private function supportsConfirmableUsers(): bool + { + return $this->userManager->getEntityClassReflection()->implementsInterface(ConfirmableInterface::class); + } } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 4588f7e..fb8b919 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -43,6 +43,7 @@ public function login(Request $request, TranslatorInterface $translator): Respon { /** @var Session $session */ $session = $request->getSession(); + $manualLoginEnabled = $this->loginForm->supportsManualLogin(); $loginCheckParams = []; if ($this->targetPathParameter && $targetPath = $request->query->get($this->targetPathParameter, $request->request->get($this->targetPathParameter))) { @@ -52,33 +53,38 @@ public function login(Request $request, TranslatorInterface $translator): Respon $authenticationErrorKey = class_exists('Symfony\Component\Security\Http\SecurityRequestAttributes') ? constant('Symfony\Component\Security\Http\SecurityRequestAttributes::AUTHENTICATION_ERROR') : (class_exists('Symfony\Component\Security\Core\Security') ? constant('Symfony\Component\Security\Core\Security::AUTHENTICATION_ERROR') : null); $lastUserNameKey = class_exists('Symfony\Component\Security\Http\SecurityRequestAttributes') ? constant('Symfony\Component\Security\Http\SecurityRequestAttributes::LAST_USERNAME') : (class_exists('Symfony\Component\Security\Core\Security') ? constant('Symfony\Component\Security\Core\Security::LAST_USERNAME') : null); - $form = $this->createForm(get_class($this->loginForm), [ - '_username' => $session->get($lastUserNameKey) ?? '', - '_password' => '', - ], [ - 'action' => $this->generateUrl('sfs_user_login_check', $loginCheckParams), - ]); + $form = null; - if ($request->attributes->has($authenticationErrorKey)) { - $form->addError(new FormError($request->attributes->get($authenticationErrorKey))); - } elseif ($session->has($authenticationErrorKey)) { - $error = $session->get($authenticationErrorKey); + if ($manualLoginEnabled) { + $form = $this->createForm(get_class($this->loginForm), [ + '_username' => $session->get($lastUserNameKey) ?? '', + '_password' => '', + ], [ + 'action' => $this->generateUrl('sfs_user_login_check', $loginCheckParams), + ]); - if ($error instanceof TooManyLoginAttemptsAuthenticationException) { - $form->addError(new FormError($translator->trans($error->getMessageKey(), $error->getMessageData(), 'security'))); - } else { - $form->addError(new FormError($session->get($authenticationErrorKey)->getMessage())); - } + if ($request->attributes->has($authenticationErrorKey)) { + $form->addError(new FormError($request->attributes->get($authenticationErrorKey))); + } elseif ($session->has($authenticationErrorKey)) { + $error = $session->get($authenticationErrorKey); - $session->remove($authenticationErrorKey); + if ($error instanceof TooManyLoginAttemptsAuthenticationException) { + $form->addError(new FormError($translator->trans($error->getMessageKey(), $error->getMessageData(), 'security'))); + } else { + $form->addError(new FormError($session->get($authenticationErrorKey)->getMessage())); + } + + $session->remove($authenticationErrorKey); + } } - if (($response = $this->dispatchGetResponse(SfsUserEvents::LOGIN_ATTEMPT, new GetResponseFormEvent($form, $request))) instanceof Response) { + if ($form && ($response = $this->dispatchGetResponse(SfsUserEvents::LOGIN_ATTEMPT, new GetResponseFormEvent($form, $request))) instanceof Response) { return $response; } return $this->render('@SfsUser/login/login.html.twig', [ 'login_form' => $form, + 'manual_login_enabled' => $manualLoginEnabled, 'oauth_services' => $this->oauthServices, 'google_identity_platform' => $this->googleIdentityPlatformConfig, 'register_params' => $loginCheckParams, diff --git a/src/Entity/UserAvatarTrait.php b/src/Entity/UserAvatarTrait.php index c729462..866168b 100644 --- a/src/Entity/UserAvatarTrait.php +++ b/src/Entity/UserAvatarTrait.php @@ -1,5 +1,7 @@ supportsManualLogin()) { + return; + } + $builder->add('_username', TextType::class, [ 'label' => $this->userManager->getEntityClassReflection()->implementsInterface(UserIdentifierEmailInterface::class) ? 'login.form.email.label' : 'login.form.username.label', 'attr' => [ @@ -79,4 +84,9 @@ protected function isRememberMeEnabled(): bool return in_array('remember_me', $firewallConfig->getAuthenticators()); } + + public function supportsManualLogin(): bool + { + return $this->userManager->getEntityClassReflection()->implementsInterface(UserPasswordInterface::class); + } } diff --git a/src/Form/LoginFormInterface.php b/src/Form/LoginFormInterface.php index b1d18b1..526a620 100644 --- a/src/Form/LoginFormInterface.php +++ b/src/Form/LoginFormInterface.php @@ -1,9 +1,12 @@ authenticateWithTokens($requestUri, $googleCredential)->user; + } + + public function authenticateWithTokens(string $requestUri, string $googleCredential): IdentityPlatformGoogleAuthenticationResult { if ('' === $this->apiKey) { throw new RuntimeException('Identity Platform API key is not configured.'); @@ -37,12 +42,11 @@ public function authenticate(string $requestUri, string $googleCredential): Iden 'tenantId' => $this->tenantId ?: null, ], static fn (string|true|null $value): bool => null !== $value), ]); - } catch (ClientException $exception) { + $payload = $response->toArray(false); + } catch (ExceptionInterface $exception) { throw new RuntimeException('Identity Platform rejected the Google credential.', $exception->getCode(), previous: $exception); } - $payload = $response->toArray(false); - if (is_array($payload['error'] ?? null)) { $errorMessage = $payload['error']['message'] ?? 'Identity Platform returned an error.'; $errorPayload = json_encode($payload['error']); @@ -62,18 +66,46 @@ public function authenticate(string $requestUri, string $googleCredential): Iden throw new RuntimeException(sprintf('Identity Platform response is missing required fields. Keys: %s', $payloadKeys)); } - return new IdentityPlatformGoogleUser( - identityPlatformUserId: $payload['localId'], - identityProvider: $payload['providerId'], - identityProviderUserId: $payload['federatedId'], - email: is_string($payload['email'] ?? null) ? $payload['email'] : null, - emailVerified: (bool) ($payload['emailVerified'] ?? false), - displayName: is_string($payload['displayName'] ?? null) ? $payload['displayName'] : null, - firstName: is_string($payload['firstName'] ?? null) ? $payload['firstName'] : null, - lastName: is_string($payload['lastName'] ?? null) ? $payload['lastName'] : null, - photoUrl: is_string($payload['photoUrl'] ?? null) ? $payload['photoUrl'] : null, - isNewIdentityPlatformUser: (bool) ($payload['isNewUser'] ?? false), - tenantId: is_string($payload['tenantId'] ?? null) ? $payload['tenantId'] : null, + $accessToken = is_string($payload['idToken'] ?? null) ? trim($payload['idToken']) : ''; + $refreshToken = is_string($payload['refreshToken'] ?? null) ? trim($payload['refreshToken']) : ''; + $expiresIn = $this->parseExpiresIn($payload['expiresIn'] ?? null); + + if ('' === $accessToken || '' === $refreshToken || null === $expiresIn) { + throw new RuntimeException('Identity Platform response is missing token fields.'); + } + + return new IdentityPlatformGoogleAuthenticationResult( + user: new IdentityPlatformGoogleUser( + identityPlatformUserId: $payload['localId'], + identityProvider: $payload['providerId'], + identityProviderUserId: $payload['federatedId'], + email: is_string($payload['email'] ?? null) ? $payload['email'] : null, + emailVerified: (bool) ($payload['emailVerified'] ?? false), + displayName: is_string($payload['displayName'] ?? null) ? $payload['displayName'] : null, + firstName: is_string($payload['firstName'] ?? null) ? $payload['firstName'] : null, + lastName: is_string($payload['lastName'] ?? null) ? $payload['lastName'] : null, + photoUrl: is_string($payload['photoUrl'] ?? null) ? $payload['photoUrl'] : null, + isNewIdentityPlatformUser: (bool) ($payload['isNewUser'] ?? false), + tenantId: is_string($payload['tenantId'] ?? null) ? $payload['tenantId'] : null, + ), + tokens: new IdentityPlatformTokens( + accessToken: $accessToken, + refreshToken: $refreshToken, + expiresIn: $expiresIn, + ), ); } + + private function parseExpiresIn(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) && ctype_digit($value) && (int) $value > 0) { + return (int) $value; + } + + return null; + } } diff --git a/src/GoogleIdentityPlatform/IdentityPlatformTokens.php b/src/GoogleIdentityPlatform/IdentityPlatformTokens.php new file mode 100644 index 0000000..6b80d13 --- /dev/null +++ b/src/GoogleIdentityPlatform/IdentityPlatformTokens.php @@ -0,0 +1,15 @@ +userManager->findUserBy(['identityPlatformUserId' => $identityUser->identityPlatformUserId]); diff --git a/src/GoogleIdentityPlatform/LocalUserAlreadyExistsException.php b/src/GoogleIdentityPlatform/LocalUserAlreadyExistsException.php new file mode 100644 index 0000000..e371100 --- /dev/null +++ b/src/GoogleIdentityPlatform/LocalUserAlreadyExistsException.php @@ -0,0 +1,11 @@ +googleAuthenticator->authenticateWithTokens($requestUri, $googleCredential); + + return new IdentityPlatformAuthenticationResult( + syncResult: $this->userSynchronizer->sync($authentication->user), + tokens: $authentication->tokens, + ); + } + + public function loginWithGoogleCredential(string $requestUri, string $googleCredential): IdentityPlatformAuthenticationResult + { + $authentication = $this->googleAuthenticator->authenticateWithTokens($requestUri, $googleCredential); + $existingUser = $this->userSynchronizer->findExistingUser($authentication->user); + + if (!$existingUser instanceof UserInterface) { + throw new LocalUserNotFoundException('No local account matches this Google account. Use registration first.'); + } + + return new IdentityPlatformAuthenticationResult( + syncResult: $this->userSynchronizer->sync($authentication->user), + tokens: $authentication->tokens, + ); + } + + public function registerWithGoogleCredential(string $requestUri, string $googleCredential): IdentityPlatformAuthenticationResult + { + $authentication = $this->googleAuthenticator->authenticateWithTokens($requestUri, $googleCredential); + $existingUser = $this->userSynchronizer->findExistingUser($authentication->user); + + if ($existingUser instanceof UserInterface) { + throw new LocalUserAlreadyExistsException('A local account already exists for this Google account. Use sign in instead.'); + } + + return new IdentityPlatformAuthenticationResult( + syncResult: $this->userSynchronizer->sync($authentication->user), + tokens: $authentication->tokens, + ); + } + + public function loadUserFromIdToken(string $idToken): UserInterface + { + $identityUser = $this->loadIdentityUser($idToken); + + $existingUser = $this->userManager->findUserBy(['identityPlatformUserId' => $identityUser->identityPlatformUserId]); + + if ($existingUser instanceof UserInterface) { + return $existingUser; + } + + return $this->userSynchronizer->sync($identityUser)->user; + } + + public function refreshTokens(string $refreshToken): IdentityPlatformTokens + { + $this->assertApiKeyConfigured(); + + if ('' === trim($refreshToken)) { + throw new RuntimeException('Identity Platform refresh token is required.'); + } + + $payload = $this->requestJson( + 'POST', + 'https://securetoken.googleapis.com/v1/token', + [ + 'query' => [ + 'key' => $this->apiKey, + ], + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]), + ], + 'Identity Platform rejected the refresh token.' + ); + + $accessToken = is_string($payload['id_token'] ?? null) ? trim($payload['id_token']) : ''; + $nextRefreshToken = is_string($payload['refresh_token'] ?? null) ? trim($payload['refresh_token']) : ''; + $expiresIn = $this->parseExpiresIn($payload['expires_in'] ?? null); + + if ('' === $accessToken || '' === $nextRefreshToken || null === $expiresIn) { + throw new RuntimeException('Identity Platform refresh response is missing required fields.'); + } + + return new IdentityPlatformTokens($accessToken, $nextRefreshToken, $expiresIn); + } + + private function loadIdentityUser(string $idToken): IdentityPlatformGoogleUser + { + $this->assertApiKeyConfigured(); + + if ('' === trim($idToken)) { + throw new RuntimeException('Identity Platform id token is required.'); + } + + $payload = $this->requestJson( + 'POST', + 'https://identitytoolkit.googleapis.com/v1/accounts:lookup', + [ + 'query' => [ + 'key' => $this->apiKey, + ], + 'json' => [ + 'idToken' => trim($idToken), + ], + ], + 'Identity Platform rejected the id token.' + ); + + $users = $payload['users'] ?? null; + + if (!is_array($users) || !is_array($users[0] ?? null)) { + throw new RuntimeException('Identity Platform lookup response does not contain a valid user.'); + } + + return $this->mapLookupUser($users[0]); + } + + /** + * @param array $userPayload + */ + private function mapLookupUser(array $userPayload): IdentityPlatformGoogleUser + { + $identityPlatformUserId = is_string($userPayload['localId'] ?? null) ? trim($userPayload['localId']) : ''; + + if ('' === $identityPlatformUserId) { + throw new RuntimeException('Identity Platform lookup response is missing the local user id.'); + } + + $providerInfo = $this->extractProviderInfo($userPayload['providerUserInfo'] ?? null); + $providerId = is_string($providerInfo['providerId'] ?? null) && '' !== trim($providerInfo['providerId']) + ? trim($providerInfo['providerId']) + : 'identity_platform'; + $providerUserId = is_string($providerInfo['federatedId'] ?? null) && '' !== trim($providerInfo['federatedId']) + ? trim($providerInfo['federatedId']) + : $identityPlatformUserId; + + $displayName = is_string($userPayload['displayName'] ?? null) ? $userPayload['displayName'] : null; + $email = is_string($userPayload['email'] ?? null) ? $userPayload['email'] : null; + + return new IdentityPlatformGoogleUser( + identityPlatformUserId: $identityPlatformUserId, + identityProvider: $providerId, + identityProviderUserId: $providerUserId, + email: $email, + emailVerified: (bool) ($userPayload['emailVerified'] ?? false), + displayName: $displayName, + firstName: $this->extractFirstName($displayName), + lastName: $this->extractLastName($displayName), + photoUrl: is_string($userPayload['photoUrl'] ?? null) ? $userPayload['photoUrl'] : null, + isNewIdentityPlatformUser: false, + tenantId: is_string($userPayload['tenantId'] ?? null) ? $userPayload['tenantId'] : $this->tenantId, + ); + } + + /** + * @return array + */ + private function extractProviderInfo(mixed $providerInfoList): array + { + if (!is_array($providerInfoList)) { + return []; + } + + foreach ($providerInfoList as $providerInfo) { + if (is_array($providerInfo) && 'google.com' === ($providerInfo['providerId'] ?? null)) { + return $providerInfo; + } + } + + foreach ($providerInfoList as $providerInfo) { + if (is_array($providerInfo)) { + return $providerInfo; + } + } + + return []; + } + + /** + * @param array $options + * + * @return array + */ + private function requestJson(string $method, string $url, array $options, string $clientErrorMessage): array + { + try { + $response = $this->httpClient->request($method, $url, $options); + $payload = $response->toArray(false); + } catch (ExceptionInterface $exception) { + throw new RuntimeException($clientErrorMessage, (int) $exception->getCode(), $exception); + } + + if (is_array($payload['error'] ?? null)) { + $errorMessage = $payload['error']['message'] ?? 'Identity Platform returned an error.'; + $errorPayload = json_encode($payload['error']); + + if (is_string($errorMessage) && '' !== $errorMessage) { + if (false !== $errorPayload) { + throw new RuntimeException(sprintf('Identity Platform error: %s. Payload: %s', $errorMessage, $errorPayload)); + } + + throw new RuntimeException(sprintf('Identity Platform error: %s', $errorMessage)); + } + } + + return is_array($payload) ? $payload : []; + } + + private function assertApiKeyConfigured(): void + { + if ('' === $this->apiKey) { + throw new RuntimeException('Identity Platform API key is not configured.'); + } + } + + private function parseExpiresIn(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) && ctype_digit($value) && (int) $value > 0) { + return (int) $value; + } + + return null; + } + + private function extractFirstName(?string $displayName): ?string + { + if (!$displayName) { + return null; + } + + $parts = preg_split('/\s+/', trim($displayName)) ?: []; + + return $parts[0] ?? null; + } + + private function extractLastName(?string $displayName): ?string + { + if (!$displayName) { + return null; + } + + $parts = preg_split('/\s+/', trim($displayName)) ?: []; + + if (count($parts) < 2) { + return null; + } + + array_shift($parts); + + return implode(' ', $parts); + } +} diff --git a/src/Manager/IdentityPlatformSessionManagerInterface.php b/src/Manager/IdentityPlatformSessionManagerInterface.php new file mode 100644 index 0000000..ff8d318 --- /dev/null +++ b/src/Manager/IdentityPlatformSessionManagerInterface.php @@ -0,0 +1,22 @@ +identityPlatformSessionManager->loadUserFromIdToken($accessToken); + + return new UserBadge($user->getUserIdentifier(), static fn (): UserInterface => $user); + } +} diff --git a/templates/admin/users/list-widgets.html.twig b/templates/admin/users/list-widgets.html.twig index a321127..c4b7be6 100644 --- a/templates/admin/users/list-widgets.html.twig +++ b/templates/admin/users/list-widgets.html.twig @@ -1,45 +1,25 @@ {% trans_default_domain 'sfs_user' %} -
-
-
-
-
-
{{ 'admin_users.list.widgets.total.title'|trans }}
-

{#{{ render(url('sfs_user_admin_users_count_widget')) }}#}

- -
-
-
+{% set render_row = render_row|default(true) %} +{% set fill_remaining_columns = fill_remaining_columns|default(render_row) %} + +{% if render_row %} +
+{% endif %} +
+ {% include '@SfsUser/admin/users/widget-total.html.twig' %} +
+ {% if supports_confirmable|default(sfs_user_is('confirmable')) %}
-
-
-
{{ 'admin_users.list.widgets.pending_confirm.title'|trans }}
-

{#{{ render(url('sfs_user_admin_users_pending_confirm_count_widget')) }}#}

- -
-
+ {% include '@SfsUser/admin/users/widget-pending-confirm.html.twig' %}
-
-
-
+ {% endif %} + + {% if fill_remaining_columns %} +
+
+ {% endif %} +{% if render_row %}
+{% endif %} diff --git a/templates/admin/users/widget-pending-confirm.html.twig b/templates/admin/users/widget-pending-confirm.html.twig new file mode 100644 index 0000000..8df420b --- /dev/null +++ b/templates/admin/users/widget-pending-confirm.html.twig @@ -0,0 +1,18 @@ +{% trans_default_domain 'sfs_user' %} + +{% embed '@SfsComponents/admin/widget.html.twig' %} + {% block title %}{{ 'admin_users.list.widgets.pending_confirm.title'|trans({}, 'sfs_user') }}{% endblock title %} + {% block body %} +

+ + {% endblock body %} +{% endembed %} diff --git a/templates/admin/users/widget-total.html.twig b/templates/admin/users/widget-total.html.twig new file mode 100644 index 0000000..27a00bd --- /dev/null +++ b/templates/admin/users/widget-total.html.twig @@ -0,0 +1,18 @@ +{% trans_default_domain 'sfs_user' %} + +{% embed '@SfsComponents/admin/widget.html.twig' %} + {% block title %}{{ 'admin_users.list.widgets.total.title'|trans({}, 'sfs_user') }}{% endblock title %} + {% block body %} +

+ + {% endblock body %} +{% endembed %} diff --git a/templates/login/login.html.twig b/templates/login/login.html.twig index 2c5a425..06e1110 100644 --- a/templates/login/login.html.twig +++ b/templates/login/login.html.twig @@ -3,37 +3,39 @@ {% block content %}
- {{ form_start(login_form) }} -

{{ 'login.title'|trans }}

+

{{ 'login.title'|trans }}

- {% if route_defined('sfs_user_register') %} -

- {{ 'login.signup.question'|trans }} - {{ 'login.actions.signup.button'|trans }} -

- {% endif %} + {% if route_defined('sfs_user_register') %} +

+ {{ 'login.signup.question'|trans }} + {{ 'login.actions.signup.button'|trans }} +

+ {% endif %} - {{ form_row(login_form._username) }} + {% if manual_login_enabled %} + {{ form_start(login_form) }} + {{ form_row(login_form._username) }} -
-
-
{{ form_label(login_form._password) }}
-
- {% if route_defined('sfs_user_reset_password_request') %} - {{ 'login.actions.resetting.link'|trans }} - {% endif %} +
+
+
{{ form_label(login_form._password) }}
+
+ {% if route_defined('sfs_user_reset_password_request') %} + {{ 'login.actions.resetting.link'|trans }} + {% endif %} +
+ {{ form_widget(login_form._password) }}
- {{ form_widget(login_form._password) }} -
- {{ form_rest(login_form) }} - {{ form_errors(login_form) }} + {{ form_rest(login_form) }} + {{ form_errors(login_form) }} -
- -
- {{ form_end(login_form) }} +
+ +
+ {{ form_end(login_form) }} + {% endif %} {% for message in app.flashes('sfs_user_google_identity_platform_error') %}
{{ message }}
diff --git a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php index 35955e1..0211108 100644 --- a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php +++ b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php @@ -26,12 +26,16 @@ public function testAuthenticateMapsIdentityPlatformResponse(): void 'lastName' => 'Doe', 'photoUrl' => 'https://example.com/avatar.jpg', 'isNewUser' => true, + 'idToken' => 'identity-platform-id-token', + 'refreshToken' => 'identity-platform-refresh-token', + 'expiresIn' => '3600', 'tenantId' => 'tenant-a', ], JSON_THROW_ON_ERROR)), ]); $authenticator = new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', 'tenant-a'); - $user = $authenticator->authenticate('https://presenciaonline.softspring.dev/auth/google/one-tap', 'google-credential'); + $result = $authenticator->authenticateWithTokens('https://presenciaonline.softspring.dev/auth/google/one-tap', 'google-credential'); + $user = $result->user; self::assertSame('gcip-user-1', $user->identityPlatformUserId); self::assertSame('google.com', $user->identityProvider); @@ -41,6 +45,9 @@ public function testAuthenticateMapsIdentityPlatformResponse(): void self::assertSame('Jane', $user->firstName); self::assertSame('Doe', $user->lastName); self::assertTrue($user->isNewIdentityPlatformUser); + self::assertSame('identity-platform-id-token', $result->tokens->accessToken); + self::assertSame('identity-platform-refresh-token', $result->tokens->refreshToken); + self::assertSame(3600, $result->tokens->expiresIn); } public function testAuthenticateSurfacesIdentityPlatformErrorMessage(): void diff --git a/tests/Unit/Manager/IdentityPlatformSessionManagerTest.php b/tests/Unit/Manager/IdentityPlatformSessionManagerTest.php new file mode 100644 index 0000000..3dcb002 --- /dev/null +++ b/tests/Unit/Manager/IdentityPlatformSessionManagerTest.php @@ -0,0 +1,319 @@ + 'gcip-user-1', + 'providerId' => 'google.com', + 'federatedId' => 'google-user-1', + 'email' => 'user@example.com', + 'emailVerified' => true, + 'displayName' => 'Jane Doe', + 'firstName' => 'Jane', + 'lastName' => 'Doe', + 'photoUrl' => 'https://example.com/avatar.jpg', + 'isNewUser' => true, + 'idToken' => 'identity-platform-id-token', + 'refreshToken' => 'identity-platform-refresh-token', + 'expiresIn' => '3600', + ], JSON_THROW_ON_ERROR)), + ]); + + $userManager = new InMemoryIdentityPlatformUserManager(); + $manager = new IdentityPlatformSessionManager( + $httpClient, + new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null), + new IdentityPlatformUserSynchronizer($userManager), + $userManager, + 'api-key', + null, + ); + + $result = $manager->authenticateGoogleCredential('https://example.test/api/app/auth/google', 'google-credential'); + + self::assertSame('identity-platform-id-token', $result->tokens->accessToken); + self::assertSame('identity-platform-refresh-token', $result->tokens->refreshToken); + self::assertSame(3600, $result->tokens->expiresIn); + self::assertTrue($result->syncResult->localUserCreated); + self::assertSame('gcip-user-1', $this->assertTestUser($result->syncResult->user)->getIdentityPlatformUserId()); + } + + public function testLoadUserFromIdTokenReusesExistingLocalUser(): void + { + $httpClient = new MockHttpClient([ + new MockResponse(json_encode([ + 'users' => [[ + 'localId' => 'gcip-user-2', + 'email' => 'existing@example.com', + 'emailVerified' => true, + 'displayName' => 'Existing User', + 'photoUrl' => 'https://example.com/existing.jpg', + 'providerUserInfo' => [[ + 'providerId' => 'google.com', + 'federatedId' => 'google-user-2', + ]], + ]], + ], JSON_THROW_ON_ERROR)), + ]); + + $userManager = new InMemoryIdentityPlatformUserManager(); + $existingUser = new IdentityPlatformTestUser(); + $existingUser->setEmail('existing@example.com'); + $existingUser->setIdentityPlatformUserId('gcip-user-2'); + $userManager->saveEntity($existingUser); + + $manager = new IdentityPlatformSessionManager( + $httpClient, + new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null), + new IdentityPlatformUserSynchronizer($userManager), + $userManager, + 'api-key', + null, + ); + + self::assertSame($existingUser, $manager->loadUserFromIdToken('identity-platform-id-token')); + } + + public function testRefreshTokensMapsSecureTokenResponse(): void + { + $httpClient = new MockHttpClient([ + new MockResponse(json_encode([ + 'id_token' => 'next-id-token', + 'refresh_token' => 'next-refresh-token', + 'expires_in' => '1800', + ], JSON_THROW_ON_ERROR)), + ]); + + $userManager = new InMemoryIdentityPlatformUserManager(); + $manager = new IdentityPlatformSessionManager( + $httpClient, + new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null), + new IdentityPlatformUserSynchronizer($userManager), + $userManager, + 'api-key', + null, + ); + + $tokens = $manager->refreshTokens('refresh-token'); + + self::assertSame('next-id-token', $tokens->accessToken); + self::assertSame('next-refresh-token', $tokens->refreshToken); + self::assertSame(1800, $tokens->expiresIn); + } + + public function testLoginWithGoogleCredentialFailsWhenNoLocalUserExists(): void + { + $httpClient = new MockHttpClient([ + new MockResponse(json_encode([ + 'localId' => 'gcip-user-3', + 'providerId' => 'google.com', + 'federatedId' => 'google-user-3', + 'email' => 'missing@example.com', + 'emailVerified' => true, + 'displayName' => 'Missing User', + 'idToken' => 'identity-platform-id-token', + 'refreshToken' => 'identity-platform-refresh-token', + 'expiresIn' => '3600', + ], JSON_THROW_ON_ERROR)), + ]); + + $userManager = new InMemoryIdentityPlatformUserManager(); + $manager = new IdentityPlatformSessionManager( + $httpClient, + new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null), + new IdentityPlatformUserSynchronizer($userManager), + $userManager, + 'api-key', + null, + ); + + $this->expectException(LocalUserNotFoundException::class); + $this->expectExceptionMessage('No local account matches this Google account. Use registration first.'); + + $manager->loginWithGoogleCredential('https://example.test/api/app/login/google', 'google-credential'); + } + + public function testRegisterWithGoogleCredentialFailsWhenLocalUserExists(): void + { + $httpClient = new MockHttpClient([ + new MockResponse(json_encode([ + 'localId' => 'gcip-user-4', + 'providerId' => 'google.com', + 'federatedId' => 'google-user-4', + 'email' => 'existing@example.com', + 'emailVerified' => true, + 'displayName' => 'Existing User', + 'idToken' => 'identity-platform-id-token', + 'refreshToken' => 'identity-platform-refresh-token', + 'expiresIn' => '3600', + ], JSON_THROW_ON_ERROR)), + ]); + + $userManager = new InMemoryIdentityPlatformUserManager(); + $existingUser = new IdentityPlatformTestUser(); + $existingUser->setEmail('existing@example.com'); + $userManager->saveEntity($existingUser); + + $manager = new IdentityPlatformSessionManager( + $httpClient, + new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null), + new IdentityPlatformUserSynchronizer($userManager), + $userManager, + 'api-key', + null, + ); + + $this->expectException(LocalUserAlreadyExistsException::class); + $this->expectExceptionMessage('A local account already exists for this Google account. Use sign in instead.'); + + $manager->registerWithGoogleCredential('https://example.test/api/app/register/google', 'google-credential'); + } + + private function assertTestUser(UserInterface $user): IdentityPlatformTestUser + { + self::assertInstanceOf(IdentityPlatformTestUser::class, $user); + + return $user; + } +} + +class InMemoryIdentityPlatformUserManager implements UserManagerInterface +{ + /** + * @var UserInterface[] + */ + private array $users = []; + + public function getTargetClass(): string + { + return IdentityPlatformTestUser::class; + } + + public function getEntityClass(): string + { + return IdentityPlatformTestUser::class; + } + + public function getEntityClassReflection(): ReflectionClass + { + return new ReflectionClass(IdentityPlatformTestUser::class); + } + + public function getRepository(): EntityRepository + { + throw new BadMethodCallException('Not needed in this test.'); + } + + public function getEntityManager(): EntityManagerInterface + { + throw new BadMethodCallException('Not needed in this test.'); + } + + public function createEntity(): object + { + return new IdentityPlatformTestUser(); + } + + public function saveEntity(object $entity): void + { + $this->users[spl_object_hash($entity)] = $entity; + } + + public function deleteEntity(object $entity): void + { + unset($this->users[spl_object_hash($entity)]); + } + + public function findUserBy(array $criteria): ?UserInterface + { + foreach ($this->users as $user) { + $matched = true; + + foreach ($criteria as $field => $value) { + $getter = 'get'.ucfirst($field); + + if (!method_exists($user, $getter) || $user->$getter() !== $value) { + $matched = false; + break; + } + } + + if ($matched) { + return $user; + } + } + + return null; + } + + public function findUserByIdentifier(string $identifier): ?UserInterface + { + return $this->findUserBy(['email' => $identifier]); + } + + public function findUserByConfirmationToken(string $token): ?ConfirmableInterface + { + return null; + } +} + +class IdentityPlatformTestUser extends User implements UserIdentifierEmailInterface, NameSurnameInterface, UserLastLoginInterface, ConfirmableInterface, GoogleIdentityPlatformAwareInterface, UserAvatarInterface +{ + use ConfirmableTrait; + use GoogleIdentityPlatformTrait; + use NameSurnameTrait; + use UserAvatarTrait; + use UserIdentifierEmailTrait; + use UserLastLoginTrait; + + protected ?string $id = 'identity-platform-test-user'; + + public function getId(): ?string + { + return $this->id; + } + + public function getDisplayName(): string + { + return trim(implode(' ', array_filter([$this->getName(), $this->getSurname()]))) ?: ($this->getEmail() ?? 'identity-platform-test-user'); + } + + public function eraseCredentials(): void + { + } +} From d250f64334314363d20690067f672f6213393b1b Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Tue, 5 May 2026 10:52:55 +0200 Subject: [PATCH 2/2] Fix code --- src/Controller/GoogleIdentityPlatformLoginController.php | 2 ++ src/Controller/OauthLoginIntegrationController.php | 2 ++ .../Compiler/AliasDoctrineEntityManagerPass.php | 2 ++ src/DependencyInjection/Configuration.php | 2 ++ src/Doctrine/Filter/AdminFilter.php | 2 ++ src/Doctrine/Filter/UserFilter.php | 2 ++ src/Entity/ConfirmableTrait.php | 2 ++ src/Entity/EmailTrait.php | 2 ++ src/Entity/EnabledTrait.php | 2 ++ src/Entity/GoogleIdentityPlatformTrait.php | 2 ++ src/Entity/NameSurnameTrait.php | 2 ++ src/Entity/OwnerTrait.php | 2 ++ src/Entity/PasswordRequestTrait.php | 2 ++ src/Entity/RolesAdminTrait.php | 2 ++ src/Entity/RolesFullTrait.php | 2 ++ src/Entity/RolesTrait.php | 2 ++ src/Entity/UserAccessLatLongTrait.php | 2 ++ src/Entity/UserAccessLocationTrait.php | 2 ++ src/Entity/UserHasLocalePreferenceTrait.php | 2 ++ src/Entity/UserIdentifierEmailTrait.php | 2 ++ src/Entity/UserIdentifierUsernameTrait.php | 2 ++ src/Entity/UserLastLoginTrait.php | 2 ++ src/Entity/UserMediaAvatarTrait.php | 2 ++ src/Entity/UserPasswordTrait.php | 2 ++ src/Event/GetResponseUserEvent.php | 2 ++ src/Event/RegisterExceptionEvent.php | 2 ++ src/Event/UserEvent.php | 2 ++ src/Event/UserInvitationEvent.php | 2 ++ src/EventListener/Admin/AdministratorControllerListener.php | 2 ++ src/EventListener/Admin/UserControllerListener.php | 2 ++ src/EventListener/EmailInvitationListener.php | 2 ++ src/EventListener/UserAccessEventSubscriber.php | 2 ++ src/Form/AcceptInvitationForm.php | 2 ++ src/Form/AcceptInvitationFormInterface.php | 2 ++ src/Form/Admin/AccessHistoryListFilterForm.php | 2 ++ src/Form/Admin/AccessHistoryListFilterFormInterface.php | 2 ++ src/Form/Admin/AdministratorDeleteForm.php | 2 ++ src/Form/Admin/AdministratorDeleteFormInterface.php | 2 ++ src/Form/Admin/AdministratorListFilterForm.php | 2 ++ src/Form/Admin/AdministratorListFilterFormInterface.php | 2 ++ src/Form/Admin/AdministratorUpdateForm.php | 2 ++ src/Form/Admin/AdministratorUpdateFormInterface.php | 2 ++ src/Form/Admin/InvitationCreateForm.php | 2 ++ src/Form/Admin/InvitationCreateFormInterface.php | 2 ++ src/Form/Admin/InvitationListFilterForm.php | 2 ++ src/Form/Admin/InvitationListFilterFormInterface.php | 2 ++ src/Form/Admin/UserDeleteForm.php | 2 ++ src/Form/Admin/UserDeleteFormInterface.php | 2 ++ src/Form/Admin/UserListFilterForm.php | 2 ++ src/Form/Admin/UserListFilterFormInterface.php | 2 ++ src/Form/Admin/UserUpdateForm.php | 2 ++ src/Form/Admin/UserUpdateFormInterface.php | 2 ++ src/Form/RegisterForm.php | 2 ++ src/Form/RegisterFormInterface.php | 2 ++ src/Form/ResetPasswordForm.php | 2 ++ src/Form/ResetPasswordFormInterface.php | 2 ++ src/Form/ResetPasswordRequestForm.php | 2 ++ src/Form/ResetPasswordRequestFormInterface.php | 2 ++ src/Form/Settings/ChangeEmailForm.php | 2 ++ src/Form/Settings/ChangeEmailFormInterface.php | 2 ++ src/Form/Settings/ChangePasswordForm.php | 2 ++ src/Form/Settings/ChangePasswordFormInterface.php | 2 ++ src/Form/Settings/ChangeUsernameForm.php | 2 ++ src/Form/Settings/ChangeUsernameFormInterface.php | 2 ++ src/Form/Settings/PreferencesFormInterface.php | 2 ++ src/GoogleIdentityPlatform/IdentityPlatformGoogleUser.php | 2 ++ src/GoogleIdentityPlatform/IdentityPlatformUserSyncResult.php | 2 ++ src/Kernel.php | 2 ++ src/Mailer/Exception/InvalidUserClassException.php | 2 ++ src/Mailer/UserMailerInterface.php | 2 ++ src/Manager/AdminUserManagerInterface.php | 2 ++ src/Manager/IdentityPlatformSessionManager.php | 2 +- src/Manager/UserAccessManager.php | 2 ++ src/Manager/UserAccessManagerInterface.php | 2 ++ src/Manager/UserInvitationManagerInterface.php | 2 ++ src/Manager/UserManagerInterface.php | 2 ++ src/Mime/Example/Model/ExampleInvitation.php | 2 ++ src/Mime/Example/Model/ExampleUser.php | 2 ++ src/Model/ConfirmableInterface.php | 2 ++ src/Model/EnablableInterface.php | 2 ++ src/Model/GoogleIdentityPlatformAwareInterface.php | 2 ++ src/Model/MultiUsersInterface.php | 2 ++ src/Model/NameSurnameInterface.php | 2 ++ src/Model/Oauth/FacebookOauthInterface.php | 2 ++ src/Model/OwnerInterface.php | 2 ++ src/Model/PasswordRequestInterface.php | 2 ++ src/Model/RolesAdminInterface.php | 2 ++ src/Model/RolesFullInterface.php | 2 ++ src/Model/RolesInterface.php | 2 ++ src/Model/SingleUserInterface.php | 2 ++ src/Model/UserAccessInterface.php | 2 ++ src/Model/UserAccessLatLongInterface.php | 2 ++ src/Model/UserAccessLocationInterface.php | 2 ++ src/Model/UserAvatarInterface.php | 2 ++ src/Model/UserHasLocalePreferenceInterface.php | 2 ++ src/Model/UserIdentifierEmailInterface.php | 2 ++ src/Model/UserIdentifierUsernameInterface.php | 2 ++ src/Model/UserInterface.php | 2 ++ src/Model/UserInvitationInterface.php | 2 ++ src/Model/UserLastLoginInterface.php | 2 ++ src/Model/UserMediaAvatarInterface.php | 2 ++ src/Model/UserPasswordInterface.php | 2 ++ src/Model/UserWithEmailInterface.php | 2 ++ src/SfsUserEvents.php | 2 ++ src/Twig/Extension/ModelExtension.php | 2 ++ src/Util/TokenGenerator.php | 2 ++ src/Util/TokenGeneratorInterface.php | 2 ++ tests/TestApplication/config/packages/framework.yaml | 1 + tests/TestApplication/src/Kernel.php | 2 ++ .../templates/sfs_components/admin/widget.html.twig | 1 + 110 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 tests/TestApplication/templates/sfs_components/admin/widget.html.twig diff --git a/src/Controller/GoogleIdentityPlatformLoginController.php b/src/Controller/GoogleIdentityPlatformLoginController.php index ec5fe00..f30b07b 100644 --- a/src/Controller/GoogleIdentityPlatformLoginController.php +++ b/src/Controller/GoogleIdentityPlatformLoginController.php @@ -1,5 +1,7 @@