presseportale/resources/views/livewire/auth/login.blade.php
Kevin Adametz 068a5a4b49 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>
2026-06-16 10:39:19 +00:00

354 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Mail\MagicLoginLink;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use App\Support\LoginRedirect;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zurück', 'eyebrow' => 'Anmeldung im Publisher-Hub', 'topRightLabel' => 'Noch kein Konto?', 'topRightLinkText' => 'Konto erstellen', 'topRightLinkHref' => '/register'])] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = false;
// Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular.
public string $magicEmail = '';
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
// Zugangsdaten prüfen, OHNE schon einzuloggen sonst würde die
// Fortify-2FA-Pipeline umgangen (2FA-Bypass). Legacy-User ohne Passwort
// (password = null) scheitern hier korrekt und nutzen Magic-Link/Reset.
$user = User::query()->where('email', $this->email)->first();
if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
// 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben
// (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable).
if ($user->hasEnabledTwoFactorAuthentication()) {
Session::put([
'login.id' => $user->getKey(),
'login.remember' => $this->remember,
]);
$this->redirect(route('two-factor.challenge'));
return;
}
Auth::login($user, $this->remember);
// Unverifizierte Selbst-Registrierer → Notice-Seite.
if (! $user->hasVerifiedEmail()) {
Session::regenerate();
$this->redirect(route('verification.notice', absolute: false));
return;
}
// Verifiziert, aber deaktiviert: Login zentral blockieren.
if (! $user->is_active) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
]);
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip(),
]);
Session::regenerate();
// Rollengerechter, 403-sicherer Redirect. Ohne navigate:true, weil das
// Portal ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
$this->redirect(LoginRedirect::safeTarget(
$user,
Session::pull('url.intended'),
LoginRedirect::homeFor($user),
));
}
public function sendMagicLink(): void
{
$this->validate(
['magicEmail' => 'required|string|email'],
attributes: ['magicEmail' => __('E-Mail-Adresse')],
);
$this->ensureMagicLinkNotRateLimited();
RateLimiter::hit($this->magicLinkThrottleKey(), 3600);
RateLimiter::hit($this->magicLinkIpThrottleKey(), 3600);
$user = User::query()->where('email', $this->magicEmail)->first();
if ($user && $user->is_active) {
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
$loginUrl = route('magic-links.consume', ['token' => $generated['plain_token']]);
Mail::to($user->email)->send(
new MagicLoginLink(
user: $user,
loginUrl: $loginUrl,
expiresAt: $generated['expires_at']->format('d.m.Y H:i')
)
);
}
$this->reset('magicEmail');
$this->dispatch('magic-link-sent');
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Magic-Link-Versand drosseln: pro E-Mail+IP (gegen Mail-Fluten eines
* Accounts und das laufende Entwerten alter Links) und zusätzlich pro IP
* (gegen das Durchprobieren vieler Accounts von einer Quelle).
*/
protected function ensureMagicLinkNotRateLimited(): void
{
$withinEmail = ! RateLimiter::tooManyAttempts($this->magicLinkThrottleKey(), 3);
$withinIp = ! RateLimiter::tooManyAttempts($this->magicLinkIpThrottleKey(), 15);
if ($withinEmail && $withinIp) {
return;
}
$seconds = max(
RateLimiter::availableIn($this->magicLinkThrottleKey()),
RateLimiter::availableIn($this->magicLinkIpThrottleKey()),
);
throw ValidationException::withMessages([
'magicEmail' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
'minutes' => max(1, ceil($seconds / 60)),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
protected function magicLinkThrottleKey(): string
{
return 'magic-link|'.Str::transliterate(Str::lower($this->magicEmail).'|'.request()->ip());
}
protected function magicLinkIpThrottleKey(): string
{
return 'magic-link-ip|'.request()->ip();
}
}; ?>
<div x-data="{ magicModal: false }" x-on:magic-link-sent.window="magicModal = false">
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
@endif
<form wire:submit="login" class="space-y-[18px]" x-data="{ showPassword: false }" novalidate>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
autocomplete="username"
required
autofocus
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<div class="flex items-baseline justify-between mb-1.5">
<label class="field-label !mb-0" for="auth-password">Passwort</label>
@if (\Illuminate\Support\Facades\Route::has('password.request'))
<a href="{{ route('password.request') }}" class="link-hub text-[12px]" wire:navigate>
Passwort vergessen?
</a>
@endif
</div>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
class="field-input pr-[72px]"
placeholder="••••••••••"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<label class="flex items-center gap-2.5 text-[12.5px] text-ink-2 cursor-pointer select-none">
<input type="checkbox" wire:model="remember" class="auth-check" />
Angemeldet bleiben
</label>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="login">
<span wire:loading.remove wire:target="login">Anmelden</span>
<span wire:loading wire:target="login">Anmelden …</span>
</button>
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
<span class="flex-1 h-px bg-bg-rule"></span>
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">oder</span>
<span class="flex-1 h-px bg-bg-rule"></span>
</div>
<button
type="button"
@click="magicModal = true; $nextTick(() => $refs.magicEmail?.focus())"
class="auth-btn-outline !mt-0"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />
<path d="M2.5 4l5.5 5 5.5-5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
</svg>
<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>
</form>
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
<div
x-show="magicModal"
x-cloak
style="display: none"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
x-on:keydown.escape.window="magicModal = false"
role="dialog"
aria-modal="true"
>
<div class="absolute inset-0 bg-black/50" @click="magicModal = false"></div>
<div
class="relative w-full max-w-sm rounded-lg border border-bg-rule bg-bg-card p-6 shadow-xl"
x-transition
>
<h3 class="text-[15px] font-semibold text-ink mb-1.5">Magic-Link anfordern</h3>
<p class="text-[12.5px] text-ink-2 leading-[1.55] mb-4">
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Anmeldelink,
mit dem Sie sich ohne Passwort anmelden können.
</p>
<form wire:submit="sendMagicLink">
<label class="field-label" for="magic-email">E-Mail-Adresse</label>
<input
id="magic-email"
x-ref="magicEmail"
type="email"
wire:model="magicEmail"
autocomplete="email"
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('magicEmail') aria-invalid="true" @enderror
/>
@error('magicEmail')
<p class="field-error">{{ $message }}</p>
@enderror
<div class="flex gap-2.5 !mt-4">
<button type="button" class="auth-btn-outline !mt-0 flex-1" @click="magicModal = false">
Abbrechen
</button>
<button type="submit" class="auth-btn-primary !mt-0 flex-1" wire:loading.attr="disabled" wire:target="sendMagicLink">
<span wire:loading.remove wire:target="sendMagicLink">Link senden</span>
<span wire:loading wire:target="sendMagicLink">Senden …</span>
</button>
</div>
</form>
</div>
</div>
</div>