diff --git a/app/Livewire/Auth/OperatorLogin.php b/app/Livewire/Auth/OperatorLogin.php new file mode 100644 index 0000000..7d7f62d --- /dev/null +++ b/app/Livewire/Auth/OperatorLogin.php @@ -0,0 +1,103 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['string'], + ]); + + $key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + $operator = Operator::where('email', $this->email)->first(); + + // One message for every failure — a different message for "no such + // account" tells a stranger which addresses are operators. + if ($operator === null + || ! Hash::check($this->password, $operator->password) + || ! $operator->isActive() + || ! $operator->isOperator()) { + RateLimiter::hit($key, 60); + $this->reset('password'); + + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + RateLimiter::clear($key); + + if ($operator->two_factor_confirmed_at !== null) { + // Not signed in yet. The id is parked in the session and the guard + // stays untouched until the code checks out. + session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]); + $this->redirectRoute('admin.two-factor', navigate: false); + + return; + } + + $this->completeLoginAndRedirect($operator, $this->remember); + } + + /** Shared with the two-factor challenge, so both exits behave identically. */ + public static function completeLogin(Operator $operator, bool $remember): void + { + Auth::guard('operator')->login($operator, $remember); + session()->regenerate(); + $operator->forceFill(['last_login_at' => now()])->save(); + } + + private function completeLoginAndRedirect(Operator $operator, bool $remember): void + { + self::completeLogin($operator, $remember); + $this->redirect(AdminArea::home(), navigate: false); + } + + public function render() + { + return view('livewire.auth.operator-login'); + } +} diff --git a/app/Livewire/Auth/OperatorTwoFactorChallenge.php b/app/Livewire/Auth/OperatorTwoFactorChallenge.php new file mode 100644 index 0000000..7c8129b --- /dev/null +++ b/app/Livewire/Auth/OperatorTwoFactorChallenge.php @@ -0,0 +1,93 @@ +has('operator.2fa.id'), 403); + } + + public function verify(): void + { + $operator = Operator::find(session('operator.2fa.id')); + abort_if($operator === null, 403); + + $key = 'operator-2fa:'.$operator->id.'|'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + if (! $this->passes($operator)) { + RateLimiter::hit($key, 60); + $this->reset('code', 'recoveryCode'); + + throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]); + } + + RateLimiter::clear($key); + + $remember = (bool) session('operator.2fa.remember', false); + session()->forget(['operator.2fa.id', 'operator.2fa.remember']); + + OperatorLogin::completeLogin($operator, $remember); + $this->redirect(AdminArea::home(), navigate: false); + } + + private function passes(Operator $operator): bool + { + if ($this->usingRecovery) { + $codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: []; + + if (! in_array($this->recoveryCode, $codes, true)) { + return false; + } + + // A recovery code is single use — leaving it valid turns a one-time + // escape hatch into a second password. + $operator->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(array_values( + array_diff($codes, [$this->recoveryCode]) + ))), + ])->save(); + + return true; + } + + return app(TwoFactorAuthenticationProvider::class) + ->verify(decrypt($operator->two_factor_secret), $this->code); + } + + public function render() + { + return view('livewire.auth.operator-two-factor-challenge'); + } +} diff --git a/lang/de/auth.php b/lang/de/auth.php index 35167c4..e3e7af0 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -9,6 +9,8 @@ return [ // Login 'login_title' => 'Bei CluPilot anmelden', 'login_subtitle' => 'Melden Sie sich mit Ihren Zugangsdaten an.', + 'console_login_title' => 'Konsole', + 'console_login_subtitle' => 'Anmeldung für Betreiber.', 'email' => 'E-Mail', 'password_label' => 'Passwort', 'remember' => 'Angemeldet bleiben', @@ -53,6 +55,7 @@ return [ 'verify' => 'Bestätigen', 'use_recovery' => 'Wiederherstellungscode verwenden', 'use_otp' => 'Authenticator-Code verwenden', + 'twofa_invalid' => 'Der Code stimmt nicht.', 'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.', 'account_closed' => 'Dieses Konto wurde geschlossen.', 'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.', diff --git a/lang/en/auth.php b/lang/en/auth.php index ac418cb..ecce6f2 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -9,6 +9,8 @@ return [ // Login 'login_title' => 'Sign in to CluPilot', 'login_subtitle' => 'Sign in with your credentials.', + 'console_login_title' => 'Console', + 'console_login_subtitle' => 'Sign-in for operators.', 'email' => 'Email', 'password_label' => 'Password', 'remember' => 'Stay signed in', @@ -53,6 +55,7 @@ return [ 'verify' => 'Verify', 'use_recovery' => 'Use a recovery code', 'use_otp' => 'Use authenticator code', + 'twofa_invalid' => 'That code does not match.', 'account_suspended' => 'Your account is suspended. Please contact support.', 'account_closed' => 'This account has been closed.', 'not_an_operator' => 'This account has no access to the console.', diff --git a/resources/views/components/ui/otp-input.blade.php b/resources/views/components/ui/otp-input.blade.php index 31f7af1..ab32045 100644 --- a/resources/views/components/ui/otp-input.blade.php +++ b/resources/views/components/ui/otp-input.blade.php @@ -4,14 +4,18 @@ 'label' => null, ]) {{-- Segmented one-time-code input. Alpine keeps the visible segments; a hidden - field carries the joined value for the native form POST. --}} + field carries the joined value for the native form POST — or, inside a + Livewire form, for wire:model. wire:model needs both the attribute (so + Livewire's directive scan finds it — hence spreading $attributes here) and + a real "input" event: Alpine's :value binding only ever writes the DOM + property, which fires nothing on its own. --}}
- +