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"