WS-6: Google-Login via Laravel Socialite

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 10:39:19 +00:00
parent ae79d5bee4
commit 068a5a4b49
13 changed files with 715 additions and 1 deletions

View file

@ -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"

View file

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
/**
* Google-Login (WS-6). Erst-Login legt de-dupliziert über die E-Mail einen
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert.
*/
class GoogleController extends Controller
{
public function redirect(): RedirectResponse
{
return Socialite::driver('google')->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),
));
}
}

View file

@ -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',
];

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\Auth;
use App\Enums\RegistrationType;
use App\Models\User;
/**
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
*
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt
* (Entscheidung 15.06.) kein zusätzlicher E-Mail-Verifizierungsschritt.
*/
class SocialAuthService
{
public function __construct(private readonly UserRolePermissionSyncService $roleSync) {}
/**
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
* deaktivierter Bestands-Account wird NICHT reaktiviert der Aufrufer prüft
* danach is_active und blockiert.
*/
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User
{
$email = $email ? mb_strtolower(trim($email)) : null;
if ($email === null || $email === '') {
return null;
}
$user = User::query()->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;
}
}

View file

@ -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",

376
composer.lock generated
View file

@ -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",

View file

@ -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'),
],
];

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Verknüpfung mit OAuth-Providern (Google, später ggf. weitere). Die
* eigentliche Account-Identität bleibt die E-Mail (De-Dup darüber); die
* Provider-Spalten merken nur, über welche Provider-ID der Login lief.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -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://<domain>/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.

View file

@ -286,6 +286,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
<span>Magic-Link senden</span>
</button>
<a href="{{ route('oauth.google.redirect') }}" class="auth-btn-outline !mt-3">
<svg width="15" height="15" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
<span>Mit Google anmelden</span>
</a>
<a href="{{ route('contact-access.request') }}" class="block text-center text-[12px] text-ink-3 hover:text-hub transition-colors !mt-4" wire:navigate>
Als Pressekontakt hinterlegt? Zugang anfordern
</a>

View file

@ -180,5 +180,15 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
<a href="{{ route('login') }}" class="auth-btn-outline !mt-0" wire:navigate>
Stattdessen anmelden
</a>
<a href="{{ route('oauth.google.redirect') }}" class="auth-btn-outline !mt-3">
<svg width="15" height="15" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
<span>Mit Google registrieren</span>
</a>
</form>
</div>

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Auth\GoogleController;
use App\Http\Controllers\Auth\MagicLinkConsumeController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
@ -22,6 +23,15 @@ Route::group(['middleware' => 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')])

View file

@ -0,0 +1,104 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Tests\TestCase;
beforeEach(function () {
$this->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();
});