From 068a5a4b49bb33383cced4dad8d0bd92016b67de Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 16 Jun 2026 10:39:19 +0000 Subject: [PATCH] WS-6: Google-Login via Laravel Socialite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Socialite installiert; oauth_provider/oauth_provider_id an users (Migration). - GoogleController (redirect/callback) + SocialAuthService: De-Dup über E-Mail, neuer User aktiv + verifiziert + customer (Verifizierung über den Google- Kanal), offener Selbst-Registrierer wird onboardet, deaktivierter Account wird NICHT reaktiviert. Abschluss über die gemeinsame LoginRedirect-Logik (rollengerecht, 403-sicher). - Routen /auth/google/redirect + /auth/google/callback (guest), "Mit Google anmelden/registrieren"-Buttons auf Login und Register. - config/services.php google + .env.example-Keys; Sicherheits-/Deployment-Doku ergänzt (Keys, Redirect-URI, Migration). Tests: neuer User, De-Dup bestehender User, deaktivierter Account blockiert, unverifizierter Registrierer onboardet, fehlgeschlagener Callback. Co-Authored-By: Claude Fable 5 --- .env.example | 6 + .../Controllers/Auth/GoogleController.php | 62 +++ app/Models/User.php | 2 + app/Services/Auth/SocialAuthService.php | 83 ++++ composer.json | 1 + composer.lock | 376 +++++++++++++++++- config/services.php | 6 + ...38_add_oauth_provider_columns_to_users.php | 31 ++ ...-Hinweise (Auth, Rollen, Verifizierung).md | 15 + resources/views/livewire/auth/login.blade.php | 10 + .../views/livewire/auth/register.blade.php | 10 + routes/auth.php | 10 + tests/Feature/Auth/GoogleLoginTest.php | 104 +++++ 13 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Auth/GoogleController.php create mode 100644 app/Services/Auth/SocialAuthService.php create mode 100644 database/migrations/2026_06_16_103238_add_oauth_provider_columns_to_users.php create mode 100644 tests/Feature/Auth/GoogleLoginTest.php diff --git a/.env.example b/.env.example index 35db1dd..98b8f80 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Google-Login (Laravel Socialite) – Redirect-URI muss in der Google Cloud +# Console exakt der Callback-URL entsprechen, z. B. https://pressekonto.de/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI="${APP_URL}/auth/google/callback" diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php new file mode 100644 index 0000000..227ddbd --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -0,0 +1,62 @@ +redirect(); + } + + public function callback(Request $request, SocialAuthService $social): RedirectResponse + { + try { + $googleUser = Socialite::driver('google')->user(); + } catch (\Throwable) { + return redirect()->route('login')->withErrors([ + 'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'), + ]); + } + + $user = $social->resolveUser( + 'google', + (string) $googleUser->getId(), + $googleUser->getEmail(), + $googleUser->getName(), + ); + + if (! $user || ! $user->is_active) { + return redirect()->route('login')->withErrors([ + 'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'), + ]); + } + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + $user->forceFill([ + 'last_login_at' => now(), + 'last_login_ip' => $request->ip(), + ])->save(); + + return redirect(LoginRedirect::safeTarget( + $user, + $request->session()->pull('url.intended'), + LoginRedirect::homeFor($user), + )); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6ca40d0..3cb77d2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -45,6 +45,8 @@ class User extends Authenticatable implements MustVerifyEmail 'legacy_portal', 'legacy_id', 'password', + 'oauth_provider', + 'oauth_provider_id', 'press_release_quota_used_this_month', ]; diff --git a/app/Services/Auth/SocialAuthService.php b/app/Services/Auth/SocialAuthService.php new file mode 100644 index 0000000..3d6fa19 --- /dev/null +++ b/app/Services/Auth/SocialAuthService.php @@ -0,0 +1,83 @@ +whereRaw('LOWER(email) = ?', [$email])->first(); + + if (! $user) { + return $this->createUser($provider, $providerId, $email, $name); + } + + $user->forceFill([ + 'oauth_provider' => $provider, + 'oauth_provider_id' => $providerId, + ]); + + // Noch nicht verifiziert (offener Selbst-Registrierer): der Provider + // bestätigt die E-Mail → Onboarding abschließen (aktiv + customer). + if ($user->email_verified_at === null) { + $user->forceFill([ + 'email_verified_at' => now(), + 'is_active' => true, + ])->save(); + + if ($user->roles()->doesntExist()) { + $this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); + } + + return $user; + } + + // Bereits verifiziert: nur Provider verknüpfen. is_active bleibt + // unangetastet (deaktivierte Accounts werden nicht reaktiviert). + $user->save(); + + return $user; + } + + private function createUser(string $provider, string $providerId, string $email, ?string $name): User + { + $name = $name !== null ? trim($name) : ''; + + $user = User::create([ + 'name' => $name !== '' ? $name : $email, + 'email' => $email, + 'registration_type' => RegistrationType::Company->value, + 'is_active' => true, + 'oauth_provider' => $provider, + 'oauth_provider_id' => $providerId, + ]); + + $user->forceFill(['email_verified_at' => now()])->save(); + $this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); + + return $user; + } +} diff --git a/composer.json b/composer.json index 3954ef5..5e66ca7 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/fortify": "^1.27", "laravel/framework": "^12.0", "laravel/sanctum": "^4.1", + "laravel/socialite": "^5.27", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.1.1", "livewire/flux-pro": "^2.1", diff --git a/composer.lock b/composer.lock index 4c4be24..6cc3669 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ad3d072c1669ef5d37e58ba10187b58", + "content-hash": "7644392ee85d975d7adeb80988fbee8d", "packages": [ { "name": "bacon/bacon-qr-code", @@ -824,6 +824,72 @@ }, "time": "2025-10-17T16:34:55+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1926,6 +1992,78 @@ }, "time": "2026-04-14T13:33:34+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.27.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-04-24T14:05:47+00:00" + }, { "name": "laravel/tinker", "version": "v2.11.1", @@ -2369,6 +2507,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -3585,6 +3799,56 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3660,6 +3924,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.55", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-06-14T23:24:10+00:00" + }, { "name": "pragmarx/google2fa", "version": "v9.0.0", diff --git a/config/services.php b/config/services.php index 3b354e8..39540df 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,10 @@ return [ 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), 'timeout' => env('OPENAI_TIMEOUT', 60), ], + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI'), + ], ]; diff --git a/database/migrations/2026_06_16_103238_add_oauth_provider_columns_to_users.php b/database/migrations/2026_06_16_103238_add_oauth_provider_columns_to_users.php new file mode 100644 index 0000000..ad1c49e --- /dev/null +++ b/database/migrations/2026_06_16_103238_add_oauth_provider_columns_to_users.php @@ -0,0 +1,31 @@ +string('oauth_provider', 40)->nullable()->after('password'); + $table->string('oauth_provider_id')->nullable()->after('oauth_provider'); + + $table->index(['oauth_provider', 'oauth_provider_id'], 'users_oauth_provider_idx'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex('users_oauth_provider_idx'); + $table->dropColumn(['oauth_provider', 'oauth_provider_id']); + }); + } +}; diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index fca43f0..1be7def 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -94,10 +94,25 @@ Aus einer gezielten Auth-Prüfung umgesetzt: --- +## 6c. Google-Login (Socialite, WS-6) + +`/auth/google/redirect` → Google, `/auth/google/callback` → `GoogleController`. Auflösung in `App\Services\Auth\SocialAuthService`: +- **De-Dup über E-Mail:** bestehende E-Mail wird wiederverwendet (keine Dubletten), nur `oauth_provider`/`oauth_provider_id` werden verknüpft. +- **Neuer User:** aktiv + verifiziert + `customer` (Verifizierung gilt über den Google-Kanal als erfüllt). +- **Offener Selbst-Registrierer (unverifiziert):** wird über Google onboardet (verifiziert + aktiv + customer). +- **Deaktivierter, verifizierter Account:** wird **nicht** reaktiviert → Login blockiert. +- Abschluss über dieselbe `LoginRedirect`-Logik (rollengerecht, 403-sicher). + +**Deployment-Voraussetzungen:** +- `composer install` (neue Abhängigkeit `laravel/socialite`). +- Migration `add_oauth_provider_columns_to_users` (siehe unten). +- ENV: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`. Die Redirect-URI muss in der Google Cloud Console exakt der Callback-URL entsprechen (`https:///auth/google/callback`). + ## 7. Deployment-Reihenfolge (Migrationen dieser Phase) 1. `2026_06_15_101337_backfill_email_verified_at_for_existing_users` — verhindert Lockout. 2. `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` — schließt die Admin-Überberechtigung. +3. `2026_06_16_103238_add_oauth_provider_columns_to_users` — Provider-Verknüpfung für Google-Login. **Nach Deploy prüfen:** - `editor`-Rolle hat keine Legacy-User mehr. diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 9e8ec53..bed67f7 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -286,6 +286,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu Magic-Link senden + + + Mit Google anmelden + + Als Pressekontakt hinterlegt? Zugang anfordern → diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index a7914fb..4108659 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -180,5 +180,15 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell Stattdessen anmelden + + + + Mit Google registrieren + diff --git a/routes/auth.php b/routes/auth.php index 3e97871..127a382 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,5 +1,6 @@ config('fortify.middleware', ['web'])], function ( ->middleware(['guest:'.config('fortify.guard')]) ->name('contact-access.request'); + // Google-Login (WS-6) + Route::get('/auth/google/redirect', [GoogleController::class, 'redirect']) + ->middleware(['guest:'.config('fortify.guard')]) + ->name('oauth.google.redirect'); + + Route::get('/auth/google/callback', [GoogleController::class, 'callback']) + ->middleware(['guest:'.config('fortify.guard')]) + ->name('oauth.google.callback'); + // Registrierung mit Livewire Volt::route('/register', 'auth.register') ->middleware(['guest:'.config('fortify.guard')]) diff --git a/tests/Feature/Auth/GoogleLoginTest.php b/tests/Feature/Auth/GoogleLoginTest.php new file mode 100644 index 0000000..04349a1 --- /dev/null +++ b/tests/Feature/Auth/GoogleLoginTest.php @@ -0,0 +1,104 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function fakeGoogleUser(string $id, string $email, string $name): void +{ + $socialiteUser = (new SocialiteUser)->map([ + 'id' => $id, + 'email' => $email, + 'name' => $name, + ]); + + $provider = Mockery::mock(Provider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); +} + +test('a new google user is created active, verified and as customer', function () { + /** @var TestCase $this */ + fakeGoogleUser('g-new-1', 'new-google@example.test', 'Neu Google'); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('me.dashboard', absolute: false)); + + $user = User::query()->where('email', 'new-google@example.test')->firstOrFail(); + + expect($user->is_active)->toBeTrue(); + expect($user->hasVerifiedEmail())->toBeTrue(); + expect($user->hasRole('customer'))->toBeTrue(); + expect($user->oauth_provider)->toBe('google'); + expect($user->oauth_provider_id)->toBe('g-new-1'); + $this->assertAuthenticatedAs($user); +}); + +test('an existing user is linked by email without creating a duplicate', function () { + /** @var TestCase $this */ + $existing = User::factory()->create(['email' => 'existing-google@example.test', 'is_active' => true]); + $existing->assignRole('customer'); + + fakeGoogleUser('g-existing-1', 'Existing-Google@example.test', 'Existing'); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('me.dashboard', absolute: false)); + + expect(User::query()->where('email', 'existing-google@example.test')->count())->toBe(1); + $existing->refresh(); + expect($existing->oauth_provider)->toBe('google'); + expect($existing->oauth_provider_id)->toBe('g-existing-1'); + $this->assertAuthenticatedAs($existing); +}); + +test('a deactivated verified account is not reactivated by google login', function () { + /** @var TestCase $this */ + $existing = User::factory()->create(['email' => 'deactivated@example.test', 'is_active' => false]); + $existing->assignRole('customer'); + + fakeGoogleUser('g-deact-1', 'deactivated@example.test', 'Deactivated'); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('login')); + + expect($existing->fresh()->is_active)->toBeFalse(); + $this->assertGuest(); +}); + +test('a pending unverified registrant is onboarded via google', function () { + /** @var TestCase $this */ + $pending = User::factory()->unverified()->create([ + 'email' => 'pending@example.test', + 'is_active' => false, + ]); + + fakeGoogleUser('g-pending-1', 'pending@example.test', 'Pending'); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('me.dashboard', absolute: false)); + + $pending->refresh(); + expect($pending->is_active)->toBeTrue(); + expect($pending->hasVerifiedEmail())->toBeTrue(); + expect($pending->hasRole('customer'))->toBeTrue(); +}); + +test('a failed google callback redirects back to login with an error', function () { + /** @var TestCase $this */ + $provider = Mockery::mock(Provider::class); + $provider->shouldReceive('user')->andThrow(new RuntimeException('invalid state')); + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('login')); + + $this->assertGuest(); +});