diff --git a/FEATURES.md b/FEATURES.md
index 337a10f..a6a3cf6 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -30,6 +30,7 @@ Functional definition for `softspring/user-bundle`.
- invitations
- access history
- Optional OAuth integration points.
+- Optional Google Identity Platform login with Google Sign-In and One Tap.
- Doctrine filters for user/admin visibility.
- Mailers, MIME classes, events, and manipulators for user lifecycle actions.
- Console commands for common user and admin operations.
diff --git a/README.md b/README.md
index 7b84e25..976e82e 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,76 @@ This package is part of [Armonic](https://softspring.es/en/armonic).
[Armonic Documentation](https://armonic.softspring.es/latest/bundles/user-bundle)
+## Optional Google Identity Platform Login
+
+This bundle can render Google Sign-In on the login page and sync users with Google Identity Platform.
+
+The feature is optional.
+
+### 1. Add the optional fields to your user entity
+
+Use the trait and interface only if you enable this feature.
+
+```php
+use Softspring\UserBundle\Entity\GoogleIdentityPlatformTrait;
+use Softspring\UserBundle\Model\GoogleIdentityPlatformAwareInterface;
+
+class User extends UserModel implements GoogleIdentityPlatformAwareInterface
+{
+ use GoogleIdentityPlatformTrait;
+}
+```
+
+You will usually also want these existing optional contracts:
+
+- `UserIdentifierEmailInterface`
+- `NameSurnameInterface`
+- `UserLastLoginInterface`
+- `UserAvatarInterface`
+- `ConfirmableInterface`
+
+### 2. Import the routes you want to expose
+
+Create a dedicated route import in your application:
+
+```yaml
+_sfs_user_google_identity_platform:
+ resource: '@SfsUserBundle/config/routing/login_google_identity_platform.yaml'
+ prefix: /auth/google
+```
+
+With that prefix, the callback URL will be `/auth/google/callback`.
+
+### 3. Enable the feature in `sfs_user`
+
+```yaml
+sfs_user:
+ login:
+ google_identity_platform:
+ enabled: true
+ client_id: '%env(default::GOOGLE_CLIENT_ID)%'
+ api_key: '%env(default::IDENTITY_PLATFORM_API_KEY)%'
+ tenant_id: '%env(default::IDENTITY_PLATFORM_TENANT_ID)%'
+ success_route: app_home
+ failure_route: sfs_user_login
+```
+
+### 4. Configure Google
+
+The Google OAuth web client must allow:
+
+- your login origin, for example `https://example.test`
+- the callback URL from the imported route, for example `https://example.test/auth/google/callback`
+
+The `client_id` must be a Google web client id ending with `.apps.googleusercontent.com`.
+
+### Notes
+
+- The login page only renders the Google widget when the feature is enabled.
+- If the routes are not imported, the login page shows a warning instead of rendering a broken button.
+- The backend exchange uses `IDENTITY_PLATFORM_API_KEY`.
+- `tenant_id` is optional.
+
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
diff --git a/composer.json b/composer.json
index a539520..761f187 100644
--- a/composer.json
+++ b/composer.json
@@ -6,8 +6,8 @@
"require": {
"php": ">=8.4",
"ext-json": "*",
- "doctrine/doctrine-bundle": "^2.10 || ^3.0 || ^4.0",
- "doctrine/orm": "^2.10 || ^3.0 || ^4.0",
+ "doctrine/doctrine-bundle": "^2.17.2 || ^3.0",
+ "doctrine/orm": "^2.10 || ^3.0",
"softspring/crudl-controller": "^6.0@dev",
"softspring/doctrine-paginator": "^6.0@dev",
"softspring/doctrine-query-filters": "^6.0@dev",
@@ -33,12 +33,12 @@
"guzzlehttp/promises": "^2.0",
"hwi/oauth-bundle": "^2.0",
"phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^12.5",
"rector/rector": "^2.3",
"softspring/account-bundle": "^6.0@dev",
"softspring/doctrine-templates": "^6.0@dev",
"softspring/mailer-bundle": "^6.0@dev",
"softspring/media-bundle": "^6.0@dev",
- "phpunit/phpunit": "^12.5",
"symfony/browser-kit": "^6.4 || ^7.4 || ^8.0",
"symfony/css-selector": "^6.4 || ^7.4 || ^8.0",
"symfony/dom-crawler": "^6.4 || ^7.4 || ^8.0",
@@ -54,7 +54,7 @@
"softspring/media-bundle": "Stores media files for avatars"
},
"minimum-stability": "dev",
- "prefer-stable": false,
+ "prefer-stable": true,
"autoload": {
"psr-4": {
"Softspring\\UserBundle\\": "src/"
@@ -80,6 +80,10 @@
}
},
"scripts": {
+ "auto-scripts": {
+ "cache:clear": "symfony-cmd",
+ "assets:install %PUBLIC_DIR%": "symfony-cmd"
+ },
"fix": [
"vendor/bin/rector process src tests",
"vendor/bin/php-cs-fixer fix",
@@ -102,10 +106,6 @@
"test-bc": [
"composer update --no-scripts --ansi --prefer-lowest --prefer-stable",
"@run-tests"
- ],
- "auto-scripts": {
- "cache:clear": "symfony-cmd",
- "assets:install %PUBLIC_DIR%": "symfony-cmd"
- }
+ ]
}
}
diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml
deleted file mode 100644
index 6899b72..0000000
--- a/config/packages/cache.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-framework:
- cache:
- # Unique name of your app: used to compute stable namespaces for cache keys.
- #prefix_seed: your_vendor_name/app_name
-
- # The "app" cache stores to the filesystem by default.
- # The data in this cache should persist between deploys.
- # Other options include:
-
- # Redis
- #app: cache.adapter.redis
- #default_redis_provider: redis://localhost
-
- # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
- #app: cache.adapter.apcu
-
- # Namespaced pools use the above "app" backend by default
- #pools:
- #my.dedicated.cache: null
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
deleted file mode 100644
index d42c52d..0000000
--- a/config/packages/doctrine.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-doctrine:
- dbal:
- url: '%env(resolve:DATABASE_URL)%'
-
- # IMPORTANT: You MUST configure your server version,
- # either here or in the DATABASE_URL env var (see .env file)
- #server_version: '16'
-
- profiling_collect_backtrace: '%kernel.debug%'
- use_savepoints: true
- orm:
- auto_generate_proxy_classes: true
- enable_lazy_ghost_objects: true
- report_fields_where_declared: true
- validate_xml_mapping: true
- naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
- auto_mapping: true
- mappings:
- App:
- type: attribute
- is_bundle: false
- dir: '%kernel.project_dir%/src/Entity'
- prefix: 'App\Entity'
- alias: App
-
-when@test:
- doctrine:
- dbal:
- # "TEST_TOKEN" is typically set by ParaTest
- dbname_suffix: '_test%env(default::TEST_TOKEN)%'
-
-when@prod:
- doctrine:
- orm:
- auto_generate_proxy_classes: false
- proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
- query_cache_driver:
- type: pool
- pool: doctrine.system_cache_pool
- result_cache_driver:
- type: pool
- pool: doctrine.result_cache_pool
-
- framework:
- cache:
- pools:
- doctrine.result_cache_pool:
- adapter: cache.app
- doctrine.system_cache_pool:
- adapter: cache.system
diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml
deleted file mode 100644
index 28095da..0000000
--- a/config/packages/framework.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-# see https://symfony.com/doc/current/reference/configuration/framework.html
-framework:
- secret: '%env(APP_SECRET)%'
- annotations: false
- http_method_override: false
- handle_all_throwables: true
-
- # Enables session support. Note that the session will ONLY be started if you read or write from it.
- # Remove or comment this section to explicitly disable session support.
- session:
- handler_id: null
- cookie_secure: auto
- cookie_samesite: lax
-
- #esi: true
- #fragments: true
- php_errors:
- log: true
-
-when@test:
- framework:
- test: true
- session:
- storage_factory_id: session.storage.factory.mock_file
diff --git a/config/packages/mailer.yaml b/config/packages/mailer.yaml
deleted file mode 100644
index 56a650d..0000000
--- a/config/packages/mailer.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-framework:
- mailer:
- dsn: '%env(MAILER_DSN)%'
diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml
deleted file mode 100644
index 4b766ce..0000000
--- a/config/packages/routing.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-framework:
- router:
- utf8: true
-
- # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
- # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
- #default_uri: http://localhost
-
-when@prod:
- framework:
- router:
- strict_requirements: null
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
deleted file mode 100644
index 367af25..0000000
--- a/config/packages/security.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-security:
- # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
- password_hashers:
- Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
- # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
- providers:
- users_in_memory: { memory: null }
- firewalls:
- dev:
- pattern: ^/(_(profiler|wdt)|css|images|js)/
- security: false
- main:
- lazy: true
- provider: users_in_memory
-
- # activate different ways to authenticate
- # https://symfony.com/doc/current/security.html#the-firewall
-
- # https://symfony.com/doc/current/security/impersonating_user.html
- # switch_user: true
-
- # Easy way to control access for large sections of your site
- # Note: Only the *first* access control that matches will be used
- access_control:
- # - { path: ^/admin, roles: ROLE_ADMIN }
- # - { path: ^/profile, roles: ROLE_USER }
-
-when@test:
- security:
- password_hashers:
- # By default, password hashers are resource intensive and take time. This is
- # important to generate secure password hashes. In tests however, secure hashes
- # are not important, waste resources and increase test times. The following
- # reduces the work factor to the lowest possible values.
- Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
- algorithm: auto
- cost: 4 # Lowest possible value for bcrypt
- time_cost: 3 # Lowest possible value for argon
- memory_cost: 10 # Lowest possible value for argon
diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml
deleted file mode 100644
index 490bfc2..0000000
--- a/config/packages/translation.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-framework:
- default_locale: en
- translator:
- default_path: '%kernel.project_dir%/translations'
- providers:
diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml
deleted file mode 100644
index 3f795d9..0000000
--- a/config/packages/twig.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-twig:
- file_name_pattern: '*.twig'
-
-when@test:
- twig:
- strict_variables: true
diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml
deleted file mode 100644
index 0201281..0000000
--- a/config/packages/validator.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-framework:
- validation:
- email_validation_mode: html5
-
- # Enables validator auto-mapping support.
- # For instance, basic validation constraints will be inferred from Doctrine's metadata.
- #auto_mapping:
- # App\Entity\: []
-
-when@test:
- framework:
- validation:
- not_compromised_password: false
diff --git a/config/routing/login_google_identity_platform.yaml b/config/routing/login_google_identity_platform.yaml
new file mode 100644
index 0000000..0109649
--- /dev/null
+++ b/config/routing/login_google_identity_platform.yaml
@@ -0,0 +1,9 @@
+sfs_user_login_google_identity_platform:
+ path: /one-tap
+ controller: Softspring\UserBundle\Controller\GoogleIdentityPlatformLoginController::authenticate
+ methods: [POST]
+
+sfs_user_login_google_identity_platform_callback:
+ path: /callback
+ controller: Softspring\UserBundle\Controller\GoogleIdentityPlatformLoginController::authenticate
+ methods: [POST]
diff --git a/config/services.yaml b/config/services.yaml
index 2839c4a..1c435a0 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -11,6 +11,7 @@ services:
$resetTokenTTL: '%sfs_user.reset_password.token_ttl%'
$oauthServices: '%sfs_user.oauth.services%'
$targetPathParameter: '%sfs_user.login.target_path_parameter%'
+ $googleIdentityPlatformConfig: '%sfs_user.login.google_identity_platform%'
Softspring\UserBundle\Command\CreateUserCommand: ~
Softspring\UserBundle\Command\PromoteUserCommand: ~
diff --git a/config/services/google_identity_platform.yaml b/config/services/google_identity_platform.yaml
new file mode 100644
index 0000000..95a6c2d
--- /dev/null
+++ b/config/services/google_identity_platform.yaml
@@ -0,0 +1,17 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+ bind:
+ $googleIdentityPlatformConfig: '%sfs_user.login.google_identity_platform%'
+
+ Softspring\UserBundle\Controller\GoogleIdentityPlatformLoginController:
+ public: true
+
+ Softspring\UserBundle\GoogleIdentityPlatform\IdentityPlatformGoogleAuthenticator:
+ arguments:
+ $apiKey: '%sfs_user.login.google_identity_platform.api_key%'
+ $tenantId: '%sfs_user.login.google_identity_platform.tenant_id%'
+
+ Softspring\UserBundle\GoogleIdentityPlatform\IdentityPlatformUserSynchronizer: ~
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 5e0804f..1d56e8f 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -20,15 +20,7 @@
-
-
-
-
-
-
-
-
-
+
diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore
deleted file mode 100644
index e69de29..0000000
diff --git a/src/Controller/GoogleIdentityPlatformLoginController.php b/src/Controller/GoogleIdentityPlatformLoginController.php
new file mode 100644
index 0000000..ec5fe00
--- /dev/null
+++ b/src/Controller/GoogleIdentityPlatformLoginController.php
@@ -0,0 +1,73 @@
+googleIdentityPlatformConfig['enabled']) {
+ $this->addFlash('sfs_user_google_identity_platform_error', 'Google sign-in is disabled.');
+
+ return $this->redirectToConfiguredRoute('failure_route');
+ }
+
+ $cookieToken = $request->cookies->get('g_csrf_token');
+ $bodyToken = $request->request->get('g_csrf_token');
+ $credential = $request->request->get('credential');
+
+ if (!is_string($cookieToken) || !is_string($bodyToken) || '' === $cookieToken || !hash_equals($cookieToken, $bodyToken)) {
+ $this->addFlash('sfs_user_google_identity_platform_error', 'Invalid Google sign-in CSRF token.');
+
+ return $this->redirectToConfiguredRoute('failure_route');
+ }
+
+ if (!is_string($credential) || '' === $credential) {
+ $this->addFlash('sfs_user_google_identity_platform_error', 'Missing Google sign-in credential.');
+
+ return $this->redirectToConfiguredRoute('failure_route');
+ }
+
+ try {
+ $identityUser = $googleAuthenticator->authenticate($request->getUri(), $credential);
+ $result = $userSynchronizer->sync($identityUser);
+ } catch (RuntimeException $exception) {
+ $this->addFlash('sfs_user_google_identity_platform_error', $exception->getMessage());
+
+ return $this->redirectToConfiguredRoute('failure_route');
+ }
+
+ $this->loginManager->loginUser($request, $result->user);
+
+ return $this->redirectToConfiguredRoute('success_route');
+ }
+
+ private function redirectToConfiguredRoute(string $routeKey): RedirectResponse
+ {
+ $routeName = $this->googleIdentityPlatformConfig[$routeKey] ?? null;
+ $routeParams = $this->googleIdentityPlatformConfig["{$routeKey}_params"] ?? [];
+
+ if (is_string($routeName) && '' !== $routeName) {
+ return $this->redirectToRoute($routeName, is_array($routeParams) ? $routeParams : []);
+ }
+
+ return $this->redirect('/');
+ }
+}
diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php
index 831fd15..4588f7e 100644
--- a/src/Controller/LoginController.php
+++ b/src/Controller/LoginController.php
@@ -26,13 +26,16 @@ class LoginController extends AbstractController
protected ?string $targetPathParameter;
+ protected array $googleIdentityPlatformConfig;
+
protected EventDispatcherInterface $eventDispatcher;
- public function __construct(LoginFormInterface $loginForm, array $oauthServices, ?string $targetPathParameter, EventDispatcherInterface $eventDispatcher)
+ public function __construct(LoginFormInterface $loginForm, array $oauthServices, ?string $targetPathParameter, array $googleIdentityPlatformConfig, EventDispatcherInterface $eventDispatcher)
{
$this->loginForm = $loginForm;
$this->oauthServices = $oauthServices;
$this->targetPathParameter = $targetPathParameter;
+ $this->googleIdentityPlatformConfig = $googleIdentityPlatformConfig;
$this->eventDispatcher = $eventDispatcher;
}
@@ -77,6 +80,7 @@ public function login(Request $request, TranslatorInterface $translator): Respon
return $this->render('@SfsUser/login/login.html.twig', [
'login_form' => $form,
'oauth_services' => $this->oauthServices,
+ 'google_identity_platform' => $this->googleIdentityPlatformConfig,
'register_params' => $loginCheckParams,
]);
}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 610f1e5..2257175 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -26,6 +26,35 @@ public function getConfigTreeBuilder(): TreeBuilder
->addDefaultsIfNotSet()
->children()
->scalarNode('target_path_parameter')->defaultNull()->end()
+ ->arrayNode('google_identity_platform')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->booleanNode('enabled')->defaultFalse()->end()
+ ->scalarNode('client_id')->defaultValue('')->end()
+ ->scalarNode('api_key')->defaultValue('')->end()
+ ->scalarNode('tenant_id')->defaultNull()->end()
+ ->scalarNode('context')->defaultValue('signin')->end()
+ ->scalarNode('ux_mode')->defaultValue('redirect')->end()
+ ->booleanNode('auto_prompt')->defaultTrue()->end()
+ ->booleanNode('itp_support')->defaultTrue()->end()
+ ->scalarNode('success_route')->defaultValue('sfs_user_preferences')->end()
+ ->arrayNode('success_route_params')->treatNullLike([])->scalarPrototype()->end()->end()
+ ->scalarNode('failure_route')->defaultValue('sfs_user_login')->end()
+ ->arrayNode('failure_route_params')->treatNullLike([])->scalarPrototype()->end()->end()
+ ->arrayNode('button')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('type')->defaultValue('standard')->end()
+ ->scalarNode('theme')->defaultValue('outline')->end()
+ ->scalarNode('size')->defaultValue('large')->end()
+ ->scalarNode('shape')->defaultValue('rectangular')->end()
+ ->scalarNode('text')->defaultValue('continue_with')->end()
+ ->scalarNode('logo_alignment')->defaultValue('left')->end()
+ ->integerNode('width')->defaultValue(320)->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
->end()
->end()
diff --git a/src/DependencyInjection/SfsUserExtension.php b/src/DependencyInjection/SfsUserExtension.php
index f0a35de..b78fdd6 100644
--- a/src/DependencyInjection/SfsUserExtension.php
+++ b/src/DependencyInjection/SfsUserExtension.php
@@ -37,6 +37,9 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('sfs_user.reset_password.token_ttl', $config['reset_password']['token_ttl']);
$container->setParameter('sfs_user.login.target_path_parameter', $config['login']['target_path_parameter']);
+ $container->setParameter('sfs_user.login.google_identity_platform', $config['login']['google_identity_platform']);
+ $container->setParameter('sfs_user.login.google_identity_platform.api_key', $config['login']['google_identity_platform']['api_key']);
+ $container->setParameter('sfs_user.login.google_identity_platform.tenant_id', $config['login']['google_identity_platform']['tenant_id']);
// load services
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
@@ -63,6 +66,10 @@ public function load(array $configs, ContainerBuilder $container): void
$loader->load('services/controller/settings_change_password.yaml');
$loader->load('services/controller/settings_change_username.yaml');
+ if ($config['login']['google_identity_platform']['enabled']) {
+ $loader->load('services/google_identity_platform.yaml');
+ }
+
$oauthServicesConfig = $config['oauth'] ?? [];
$container->setParameter('sfs_user.oauth.services', $oauthServicesConfig);
diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore
deleted file mode 100644
index e69de29..0000000
diff --git a/src/Entity/GoogleIdentityPlatformTrait.php b/src/Entity/GoogleIdentityPlatformTrait.php
new file mode 100644
index 0000000..c1a1326
--- /dev/null
+++ b/src/Entity/GoogleIdentityPlatformTrait.php
@@ -0,0 +1,23 @@
+apiKey) {
+ throw new RuntimeException('Identity Platform API key is not configured.');
+ }
+
+ try {
+ $response = $this->httpClient->request('POST', 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp', [
+ 'query' => [
+ 'key' => $this->apiKey,
+ ],
+ 'json' => array_filter([
+ 'requestUri' => $requestUri,
+ 'postBody' => http_build_query([
+ 'id_token' => $googleCredential,
+ 'providerId' => 'google.com',
+ ]),
+ 'returnSecureToken' => true,
+ 'returnIdpCredential' => true,
+ 'tenantId' => $this->tenantId ?: null,
+ ], static fn (string|true|null $value): bool => null !== $value),
+ ]);
+ } catch (ClientException $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']);
+
+ 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));
+ }
+ }
+
+ if (!is_string($payload['localId'] ?? null) || !is_string($payload['providerId'] ?? null) || !is_string($payload['federatedId'] ?? null)) {
+ $payloadKeys = implode(', ', array_keys($payload));
+
+ 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,
+ );
+ }
+}
diff --git a/src/GoogleIdentityPlatform/IdentityPlatformGoogleUser.php b/src/GoogleIdentityPlatform/IdentityPlatformGoogleUser.php
new file mode 100644
index 0000000..8a33711
--- /dev/null
+++ b/src/GoogleIdentityPlatform/IdentityPlatformGoogleUser.php
@@ -0,0 +1,21 @@
+assertSupportedUserClass();
+
+ $user = $this->findExistingUser($identityUser);
+ $localUserCreated = false;
+
+ if (!$user instanceof UserInterface) {
+ /** @var UserInterface $user */
+ $user = $this->userManager->createEntity();
+ $localUserCreated = true;
+ }
+
+ if (!$user instanceof GoogleIdentityPlatformAwareInterface) {
+ throw new RuntimeException(sprintf('Configured user class "%s" must implement %s.', $user::class, GoogleIdentityPlatformAwareInterface::class));
+ }
+
+ if ($user instanceof UserWithEmailInterface && $identityUser->email) {
+ $user->setEmail($identityUser->email);
+ }
+
+ if ($user instanceof NameSurnameInterface) {
+ if ($identityUser->firstName || !$user->getName()) {
+ $user->setName($identityUser->firstName ?? $this->extractFirstName($identityUser->displayName));
+ }
+
+ if ($identityUser->lastName || !$user->getSurname()) {
+ $user->setSurname($identityUser->lastName ?? $this->extractLastName($identityUser->displayName));
+ }
+ }
+
+ $user->setAuthSource('identity_platform');
+ $user->setIdentityPlatformUserId($identityUser->identityPlatformUserId);
+ $user->setIdentityProvider($identityUser->identityProvider);
+ $user->setIdentityProviderUserId($identityUser->identityProviderUserId);
+
+ if (method_exists($user, 'setAvatarUrl')) {
+ $user->setAvatarUrl($identityUser->photoUrl);
+ }
+
+ if ($user instanceof UserLastLoginInterface) {
+ $user->setLastLogin(new DateTime());
+ }
+
+ if ($user instanceof ConfirmableInterface && $identityUser->emailVerified && !$user->isConfirmed()) {
+ $user->setConfirmedAt(new DateTime());
+ }
+
+ $this->userManager->saveEntity($user);
+
+ return new IdentityPlatformUserSyncResult(
+ user: $user,
+ localUserCreated: $localUserCreated,
+ newIdentityPlatformUser: $identityUser->isNewIdentityPlatformUser,
+ );
+ }
+
+ private function findExistingUser(IdentityPlatformGoogleUser $identityUser): ?UserInterface
+ {
+ $user = $this->userManager->findUserBy(['identityPlatformUserId' => $identityUser->identityPlatformUserId]);
+
+ if ($user || !$identityUser->email || !$this->supportsEmailLookup()) {
+ return $user;
+ }
+
+ return $this->userManager->findUserBy(['email' => $identityUser->email]);
+ }
+
+ private function assertSupportedUserClass(): void
+ {
+ $userClass = $this->userManager->getEntityClass();
+
+ if (!is_a($userClass, GoogleIdentityPlatformAwareInterface::class, true)) {
+ throw new RuntimeException(sprintf('Configured user class "%s" must implement %s to use Google Identity Platform login.', $userClass, GoogleIdentityPlatformAwareInterface::class));
+ }
+ }
+
+ private function supportsEmailLookup(): bool
+ {
+ return is_a($this->userManager->getEntityClass(), UserWithEmailInterface::class, true);
+ }
+
+ 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/Model/GoogleIdentityPlatformAwareInterface.php b/src/Model/GoogleIdentityPlatformAwareInterface.php
new file mode 100644
index 0000000..012c47d
--- /dev/null
+++ b/src/Model/GoogleIdentityPlatformAwareInterface.php
@@ -0,0 +1,22 @@
+authSource;
+ }
+
+ public function setAuthSource(?string $authSource): void
+ {
+ $this->authSource = $authSource;
+ }
+
+ public function getIdentityPlatformUserId(): ?string
+ {
+ return $this->identityPlatformUserId;
+ }
+
+ public function setIdentityPlatformUserId(?string $identityPlatformUserId): void
+ {
+ $this->identityPlatformUserId = $identityPlatformUserId;
+ }
+
+ public function getIdentityProvider(): ?string
+ {
+ return $this->identityProvider;
+ }
+
+ public function setIdentityProvider(?string $identityProvider): void
+ {
+ $this->identityProvider = $identityProvider;
+ }
+
+ public function getIdentityProviderUserId(): ?string
+ {
+ return $this->identityProviderUserId;
+ }
+
+ public function setIdentityProviderUserId(?string $identityProviderUserId): void
+ {
+ $this->identityProviderUserId = $identityProviderUserId;
+ }
+}
diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore
deleted file mode 100644
index e69de29..0000000
diff --git a/symfony.lock b/symfony.lock
index 6bae337..96b6f16 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -137,6 +137,18 @@
".env.dev"
]
},
+ "symfony/form": {
+ "version": "8.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "7.2",
+ "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
+ },
+ "files": [
+ "config/packages/csrf.yaml"
+ ]
+ },
"symfony/framework-bundle": {
"version": "6.4",
"recipe": {
@@ -168,6 +180,18 @@
"config/packages/mailer.yaml"
]
},
+ "symfony/property-info": {
+ "version": "8.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "7.3",
+ "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
+ },
+ "files": [
+ "config/packages/property_info.yaml"
+ ]
+ },
"symfony/routing": {
"version": "6.4",
"recipe": {
diff --git a/templates/login/login.html.twig b/templates/login/login.html.twig
index 8657ce1..2c5a425 100644
--- a/templates/login/login.html.twig
+++ b/templates/login/login.html.twig
@@ -35,6 +35,18 @@
{{ form_end(login_form) }}
+ {% for message in app.flashes('sfs_user_google_identity_platform_error') %}
+
{{ message }}
+ {% endfor %}
+
+ {% if google_identity_platform.enabled %}
+ {% if google_identity_platform.client_id %}
+ {% include '@SfsUser/oauth_login_integration/google_identity_platform_button.html.twig' %}
+ {% else %}
+ {{ 'login.google_identity_platform.disabled_client_id'|trans }}
+ {% endif %}
+ {% endif %}
+
{% if attribute(oauth_services, 'facebook') is defined %}
{% include '@SfsUser/oauth_login_integration/facebook_oauth_button.html.twig' %}
{% endif %}
@@ -47,4 +59,8 @@
{% if attribute(oauth_services, 'facebook') is defined %}
{% endif %}
-{% endblock javascripts %}
\ No newline at end of file
+
+ {% if google_identity_platform.enabled and google_identity_platform.client_id %}
+
+ {% endif %}
+{% endblock javascripts %}
diff --git a/templates/oauth_login_integration/google_identity_platform_button.html.twig b/templates/oauth_login_integration/google_identity_platform_button.html.twig
new file mode 100644
index 0000000..f0b72a2
--- /dev/null
+++ b/templates/oauth_login_integration/google_identity_platform_button.html.twig
@@ -0,0 +1,23 @@
+{% trans_default_domain 'sfs_user' %}
+
+{% if route_defined('sfs_user_login_google_identity_platform_callback') %}
+
+
+{% else %}
+ {{ 'login.google_identity_platform.missing_route'|trans }}
+{% endif %}
diff --git a/tests/Functional/PublicFlowsTest.php b/tests/Functional/PublicFlowsTest.php
index 7395aa3..f95be2d 100644
--- a/tests/Functional/PublicFlowsTest.php
+++ b/tests/Functional/PublicFlowsTest.php
@@ -12,6 +12,7 @@ public function testLoginPageRenders(): void
self::assertResponseIsSuccessful();
self::assertSelectorExists('form input[name="_username"]');
self::assertSelectorExists('form input[name="_password"]');
+ self::assertSelectorExists('.g_id_signin');
}
public function testLoginWithFixtureUserRedirectsToPreferences(): void
diff --git a/tests/TestApplication/config/packages/doctrine.yaml b/tests/TestApplication/config/packages/doctrine.yaml
index c643e63..50d6561 100644
--- a/tests/TestApplication/config/packages/doctrine.yaml
+++ b/tests/TestApplication/config/packages/doctrine.yaml
@@ -1,5 +1,6 @@
doctrine:
dbal:
+ # Functional tests use SQLite to avoid external database services.
driver: pdo_sqlite
path: '%kernel.cache_dir%/test_database.sqlite'
orm:
diff --git a/tests/TestApplication/config/packages/sfs_user.yaml b/tests/TestApplication/config/packages/sfs_user.yaml
index aa94c39..6da200f 100644
--- a/tests/TestApplication/config/packages/sfs_user.yaml
+++ b/tests/TestApplication/config/packages/sfs_user.yaml
@@ -2,6 +2,12 @@ sfs_user:
class: Softspring\UserBundle\Tests\TestApplication\Entity\User
login:
target_path_parameter: _target_path
+ google_identity_platform:
+ enabled: true
+ client_id: 'test-google-client-id.apps.googleusercontent.com'
+ api_key: 'test-identity-platform-api-key'
+ success_route: sfs_user_preferences
+ failure_route: sfs_user_login
invite:
enabled: false
diff --git a/tests/TestApplication/config/routes.yaml b/tests/TestApplication/config/routes.yaml
index 58cccab..e5e6107 100644
--- a/tests/TestApplication/config/routes.yaml
+++ b/tests/TestApplication/config/routes.yaml
@@ -2,6 +2,10 @@ _sfs_user:
resource: '@SfsUserBundle/config/routing/login.yaml'
prefix: /app
+_sfs_user_google_identity_platform:
+ resource: '@SfsUserBundle/config/routing/login_google_identity_platform.yaml'
+ prefix: /auth/google
+
_sfs_user_register:
resource: '@SfsUserBundle/config/routing/register.yaml'
prefix: /app/register
diff --git a/tests/TestApplication/src/DataFixtures/AppFixtures.php b/tests/TestApplication/src/DataFixtures/AppFixtures.php
index 41e0771..039be84 100644
--- a/tests/TestApplication/src/DataFixtures/AppFixtures.php
+++ b/tests/TestApplication/src/DataFixtures/AppFixtures.php
@@ -1,5 +1,7 @@
addFilterConstraint($metadata, 'user_alias');
- self::assertSame('user_alias.is_admin = 1', $constraint);
+ self::assertSame('user_alias.is_admin = true', $constraint);
}
public function testReturnsEmptyConstraintForUnsupportedEntities(): void
diff --git a/tests/Unit/Doctrine/Filter/UserFilterTest.php b/tests/Unit/Doctrine/Filter/UserFilterTest.php
index 88ee4f1..7eaf082 100644
--- a/tests/Unit/Doctrine/Filter/UserFilterTest.php
+++ b/tests/Unit/Doctrine/Filter/UserFilterTest.php
@@ -18,7 +18,7 @@ public function testAddsRegularUserConstraintForAdminAwareEntities(): void
$constraint = $filter->addFilterConstraint($metadata, 'user_alias');
- self::assertSame('user_alias.is_admin = 0', $constraint);
+ self::assertSame('user_alias.is_admin = false', $constraint);
}
public function testReturnsEmptyConstraintForUnsupportedEntities(): void
diff --git a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php
new file mode 100644
index 0000000..35955e1
--- /dev/null
+++ b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleAuthenticatorTest.php
@@ -0,0 +1,63 @@
+ '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,
+ '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');
+
+ self::assertSame('gcip-user-1', $user->identityPlatformUserId);
+ self::assertSame('google.com', $user->identityProvider);
+ self::assertSame('google-user-1', $user->identityProviderUserId);
+ self::assertSame('user@example.com', $user->email);
+ self::assertTrue($user->emailVerified);
+ self::assertSame('Jane', $user->firstName);
+ self::assertSame('Doe', $user->lastName);
+ self::assertTrue($user->isNewIdentityPlatformUser);
+ }
+
+ public function testAuthenticateSurfacesIdentityPlatformErrorMessage(): void
+ {
+ $httpClient = new MockHttpClient([
+ new MockResponse(json_encode([
+ 'error' => [
+ 'message' => 'REDIRECT_URI_MISMATCH',
+ ],
+ ], JSON_THROW_ON_ERROR), ['http_code' => 400]),
+ ]);
+
+ $authenticator = new IdentityPlatformGoogleAuthenticator($httpClient, 'api-key', null);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Identity Platform error: REDIRECT_URI_MISMATCH');
+
+ $authenticator->authenticate('https://presenciaonline.softspring.dev/auth/google/callback', 'google-credential');
+ }
+}
diff --git a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleUserTest.php b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleUserTest.php
new file mode 100644
index 0000000..8b5824f
--- /dev/null
+++ b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformGoogleUserTest.php
@@ -0,0 +1,40 @@
+identityPlatformUserId);
+ self::assertSame('google.com', $user->identityProvider);
+ self::assertSame('google-user-1', $user->identityProviderUserId);
+ self::assertSame('user@example.com', $user->email);
+ self::assertTrue($user->emailVerified);
+ self::assertSame('Jane Doe', $user->displayName);
+ self::assertSame('Jane', $user->firstName);
+ self::assertSame('Doe', $user->lastName);
+ self::assertSame('https://example.com/avatar.jpg', $user->photoUrl);
+ self::assertTrue($user->isNewIdentityPlatformUser);
+ self::assertSame('tenant-a', $user->tenantId);
+ }
+}
diff --git a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSyncResultTest.php b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSyncResultTest.php
new file mode 100644
index 0000000..f3d0828
--- /dev/null
+++ b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSyncResultTest.php
@@ -0,0 +1,26 @@
+createMock(UserInterface::class);
+ $result = new IdentityPlatformUserSyncResult(
+ user: $user,
+ localUserCreated: true,
+ newIdentityPlatformUser: false,
+ );
+
+ self::assertSame($user, $result->user);
+ self::assertTrue($result->localUserCreated);
+ self::assertFalse($result->newIdentityPlatformUser);
+ }
+}
diff --git a/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSynchronizerTest.php b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSynchronizerTest.php
new file mode 100644
index 0000000..5c5ba51
--- /dev/null
+++ b/tests/Unit/GoogleIdentityPlatform/IdentityPlatformUserSynchronizerTest.php
@@ -0,0 +1,203 @@
+sync(new IdentityPlatformGoogleUser(
+ identityPlatformUserId: 'gcip-user-1',
+ identityProvider: 'google.com',
+ identityProviderUserId: 'google-user-1',
+ email: 'user@example.com',
+ emailVerified: true,
+ displayName: 'Jane Doe',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ photoUrl: 'https://example.com/avatar.jpg',
+ isNewIdentityPlatformUser: true,
+ tenantId: null,
+ ));
+
+ self::assertTrue($result->localUserCreated);
+ self::assertTrue($result->newIdentityPlatformUser);
+ $user = $this->assertTestUser($result->user);
+ self::assertSame('user@example.com', $user->getEmail());
+ self::assertSame('Jane', $user->getName());
+ self::assertSame('Doe', $user->getSurname());
+ self::assertSame('gcip-user-1', $user->getIdentityPlatformUserId());
+ self::assertSame('google.com', $user->getIdentityProvider());
+ self::assertSame('google-user-1', $user->getIdentityProviderUserId());
+ self::assertSame('identity_platform', $user->getAuthSource());
+ self::assertNotNull($user->getLastLogin());
+ self::assertSame('https://example.com/avatar.jpg', $user->getAvatarUrl());
+ }
+
+ public function testSyncReusesExistingUserByEmail(): void
+ {
+ $manager = new InMemoryUserManager();
+ $existingUser = new TestUser();
+ $existingUser->setEmail('user@example.com');
+ $manager->saveEntity($existingUser);
+
+ $synchronizer = new IdentityPlatformUserSynchronizer($manager);
+
+ $result = $synchronizer->sync(new IdentityPlatformGoogleUser(
+ identityPlatformUserId: 'gcip-user-2',
+ identityProvider: 'google.com',
+ identityProviderUserId: 'google-user-2',
+ email: 'user@example.com',
+ emailVerified: false,
+ displayName: 'Existing User',
+ firstName: 'Existing',
+ lastName: 'User',
+ photoUrl: null,
+ isNewIdentityPlatformUser: false,
+ tenantId: null,
+ ));
+
+ self::assertFalse($result->localUserCreated);
+ self::assertSame($existingUser, $result->user);
+ self::assertSame('gcip-user-2', $this->assertTestUser($result->user)->getIdentityPlatformUserId());
+ }
+
+ private function assertTestUser(UserInterface $user): TestUser
+ {
+ self::assertInstanceOf(TestUser::class, $user);
+
+ return $user;
+ }
+}
+
+class InMemoryUserManager implements UserManagerInterface
+{
+ /**
+ * @var UserInterface[]
+ */
+ private array $users = [];
+
+ public function getTargetClass(): string
+ {
+ return TestUser::class;
+ }
+
+ public function getEntityClass(): string
+ {
+ return TestUser::class;
+ }
+
+ public function getEntityClassReflection(): ReflectionClass
+ {
+ return new ReflectionClass(TestUser::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 TestUser();
+ }
+
+ 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 TestUser extends User implements UserIdentifierEmailInterface, NameSurnameInterface, UserLastLoginInterface, ConfirmableInterface, GoogleIdentityPlatformAwareInterface, UserAvatarInterface
+{
+ use ConfirmableTrait;
+ use GoogleIdentityPlatformTrait;
+ use NameSurnameTrait;
+ use UserAvatarTrait;
+ use UserIdentifierEmailTrait;
+ use UserLastLoginTrait;
+
+ protected ?string $id = 'test-user';
+
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+
+ public function getDisplayName(): string
+ {
+ return trim(implode(' ', array_filter([$this->getName(), $this->getSurname()]))) ?: ($this->getEmail() ?? 'test-user');
+ }
+
+ public function eraseCredentials(): void
+ {
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index f46cfb3..80184d5 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -45,8 +45,20 @@
file_put_contents($sourceFilePath, $sourceFileContentsWithoutFinalClasses);
}
-$file = __DIR__.'/../vendor/autoload.php';
-if (!file_exists($file)) {
+$autoloadCandidates = [
+ __DIR__.'/../vendor/autoload.php',
+ __DIR__.'/../../../autoload.php',
+];
+
+$file = null;
+foreach ($autoloadCandidates as $autoloadCandidate) {
+ if (file_exists($autoloadCandidate)) {
+ $file = $autoloadCandidate;
+ break;
+ }
+}
+
+if (null === $file) {
throw new RuntimeException('Install dependencies using Composer to run the test suite.');
}
$autoload = require $file;
diff --git a/translations/sfs_user.en.yaml b/translations/sfs_user.en.yaml
index 6854ca2..9333d63 100644
--- a/translations/sfs_user.en.yaml
+++ b/translations/sfs_user.en.yaml
@@ -11,6 +11,9 @@ login:
facebook_oauth.button: "Facebook login"
resetting.link: "Forgot your password?"
signup.button: "Create your account"
+ google_identity_platform:
+ disabled_client_id: "Google sign-in is disabled because the client id is empty."
+ missing_route: "Google sign-in routes are not loaded."
register:
title: "Create your account"
diff --git a/translations/sfs_user.es.yaml b/translations/sfs_user.es.yaml
index 1760e0b..e1ac4fa 100644
--- a/translations/sfs_user.es.yaml
+++ b/translations/sfs_user.es.yaml
@@ -11,6 +11,9 @@ login:
facebook_oauth.button: "Entrar con Facebook"
resetting.link: "¿No recuerdas tu contraseña?"
signup.button: "Crea tu cuenta"
+ google_identity_platform:
+ disabled_client_id: "Google sign-in está deshabilitado porque el client id está vacío."
+ missing_route: "Las rutas de Google sign-in no están cargadas."
register:
title: "Crea tu cuenta"