From b3e5d3a02451acf278e628339d3bd617d4e41e71 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 13:57:02 +0200 Subject: [PATCH] feat: auth + 2FA (login wall + TOTP + forced onboarding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom Livewire auth (full-page, R1/R2): Login (rate-limited), TwoFactorChallenge, TwoFactorSetup (QR via pragmarx/google2fa-qrcode), PasswordChange. - Guard: panel routes behind auth + EnsureSecurityOnboarded middleware — forces password rotation, then 2FA enrolment, before the panel unlocks. - User: two_factor_secret (encrypted), two_factor_confirmed_at, must_change_password. - Centered auth layout, POST logout, dynamic sidebar user + 2FA status. - Seeded admin with a one-time password + must_change_password. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Middleware/EnsureSecurityOnboarded.php | 33 +++ app/Livewire/Auth/Login.php | 66 +++++ app/Livewire/Auth/PasswordChange.php | 45 ++++ app/Livewire/Auth/TwoFactorChallenge.php | 58 +++++ app/Livewire/Auth/TwoFactorSetup.php | 56 +++++ app/Models/User.php | 18 +- composer.json | 2 + composer.lock | 227 +++++++++++++++++- ...36_add_security_columns_to_users_table.php | 24 ++ resources/views/components/sidebar.blade.php | 16 +- resources/views/layouts/auth.blade.php | 28 +++ resources/views/livewire/auth/login.blade.php | 33 +++ .../livewire/auth/password-change.blade.php | 31 +++ .../auth/two-factor-challenge.blade.php | 19 ++ .../livewire/auth/two-factor-setup.blade.php | 28 +++ routes/web.php | 37 ++- 16 files changed, 700 insertions(+), 21 deletions(-) create mode 100644 app/Http/Middleware/EnsureSecurityOnboarded.php create mode 100644 app/Livewire/Auth/Login.php create mode 100644 app/Livewire/Auth/PasswordChange.php create mode 100644 app/Livewire/Auth/TwoFactorChallenge.php create mode 100644 app/Livewire/Auth/TwoFactorSetup.php create mode 100644 database/migrations/2026_06_12_114836_add_security_columns_to_users_table.php create mode 100644 resources/views/layouts/auth.blade.php create mode 100644 resources/views/livewire/auth/login.blade.php create mode 100644 resources/views/livewire/auth/password-change.blade.php create mode 100644 resources/views/livewire/auth/two-factor-challenge.blade.php create mode 100644 resources/views/livewire/auth/two-factor-setup.blade.php diff --git a/app/Http/Middleware/EnsureSecurityOnboarded.php b/app/Http/Middleware/EnsureSecurityOnboarded.php new file mode 100644 index 0000000..1611133 --- /dev/null +++ b/app/Http/Middleware/EnsureSecurityOnboarded.php @@ -0,0 +1,33 @@ +user(); + + if ($user) { + $allowed = $request->routeIs('password.change', 'two-factor.setup', 'logout'); + + if ($user->must_change_password && ! $request->routeIs('password.change', 'logout')) { + return redirect()->route('password.change'); + } + + if (! $user->must_change_password && ! $user->hasTwoFactorEnabled() && ! $allowed) { + return redirect()->route('two-factor.setup'); + } + } + + return $next($request); + } +} diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php new file mode 100644 index 0000000..523701d --- /dev/null +++ b/app/Livewire/Auth/Login.php @@ -0,0 +1,66 @@ +validate(); + + $key = 'login:'.md5(strtolower($this->email).'|'.request()->ip()); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.', + ]); + } + + $user = User::where('email', $this->email)->first(); + + if (! $user || ! Hash::check($this->password, $user->password)) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['email' => 'Diese Zugangsdaten passen nicht.']); + } + + RateLimiter::clear($key); + + // 2FA gate: hold the login until the TOTP code is verified. + if ($user->hasTwoFactorEnabled()) { + session()->put('2fa.user', $user->id); + session()->put('2fa.remember', $this->remember); + + return $this->redirect(route('two-factor.challenge'), navigate: true); + } + + Auth::login($user, $this->remember); + session()->regenerate(); + + return $this->redirectIntended(route('dashboard'), navigate: true); + } + + public function render() + { + return view('livewire.auth.login'); + } +} diff --git a/app/Livewire/Auth/PasswordChange.php b/app/Livewire/Auth/PasswordChange.php new file mode 100644 index 0000000..e6b94a5 --- /dev/null +++ b/app/Livewire/Auth/PasswordChange.php @@ -0,0 +1,45 @@ +validate([ + 'current' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], + ], attributes: [ + 'current' => 'aktuelles Passwort', + 'password' => 'neues Passwort', + ]); + + Auth::user()->forceFill([ + 'password' => Hash::make($this->password), + 'must_change_password' => false, + ])->save(); + + // onboarding continues: the middleware sends an un-enrolled user to 2FA setup. + return $this->redirect(route('two-factor.setup'), navigate: true); + } + + public function render() + { + return view('livewire.auth.password-change'); + } +} diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php new file mode 100644 index 0000000..19f0603 --- /dev/null +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -0,0 +1,58 @@ +has('2fa.user')) { + return $this->redirect(route('login'), navigate: true); + } + } + + public function verify() + { + $this->validate(); + + $user = User::find(session('2fa.user')); + + if (! $user || ! $user->hasTwoFactorEnabled()) { + session()->forget(['2fa.user', '2fa.remember']); + throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']); + } + + $valid = (new Google2FA())->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); + + if (! $valid) { + throw ValidationException::withMessages(['code' => 'Ungültiger Code.']); + } + + $remember = (bool) session('2fa.remember'); + session()->forget(['2fa.user', '2fa.remember']); + + Auth::login($user, $remember); + session()->regenerate(); + + return $this->redirectIntended(route('dashboard'), navigate: true); + } + + public function render() + { + return view('livewire.auth.two-factor-challenge'); + } +} diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php new file mode 100644 index 0000000..affea45 --- /dev/null +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -0,0 +1,56 @@ +hasTwoFactorEnabled()) { + return $this->redirect(route('dashboard'), navigate: true); + } + + $this->secret = (new Google2FA())->generateSecretKey(); + } + + public function confirm() + { + $this->validate(); + + if (! (new Google2FA())->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { + throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']); + } + + Auth::user()->forceFill([ + 'two_factor_secret' => $this->secret, + 'two_factor_confirmed_at' => now(), + ])->save(); + + return $this->redirect(route('dashboard'), navigate: true); + } + + public function qrCode(): string + { + return (new Google2FA())->getQRCodeInline('Clusev', Auth::user()->email, $this->secret); + } + + public function render() + { + return view('livewire.auth.two-factor-setup'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..5e98458 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,6 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -10,23 +9,26 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -#[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] +#[Fillable(['name', 'email', 'password', 'must_change_password'])] +#[Hidden(['password', 'remember_token', 'two_factor_secret'])] class User extends Authenticatable { /** @use HasFactory */ use HasFactory, Notifiable; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'two_factor_secret' => 'encrypted', + 'two_factor_confirmed_at' => 'datetime', + 'must_change_password' => 'boolean', ]; } + + public function hasTwoFactorEnabled(): bool + { + return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); + } } diff --git a/composer.json b/composer.json index bbaffa7..334a97f 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,13 @@ "license": "MIT", "require": { "php": "^8.3", + "bacon/bacon-qr-code": "^3.1", "laravel/framework": "^13.8", "laravel/reverb": "^1.10", "laravel/tinker": "^3.0", "livewire/livewire": "^3.6", "phpseclib/phpseclib": "^3.0", + "pragmarx/google2fa-qrcode": "^4.0", "wire-elements/modal": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 9c2f670..0df02a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eb9a73c8bcdc5dc9ae4dd2425dd6e878", + "content-hash": "0c6b80f9bf7bcbfefa54eab193c9f841", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -265,6 +320,56 @@ ], "time": "2025-01-03T16:18:33+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -3177,6 +3282,126 @@ ], "time": "2026-06-09T18:08:26+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "16159f84fa0838c276f35d46de57fd90dfbb385c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/16159f84fa0838c276f35d46de57fd90dfbb385c", + "reference": "16159f84fa0838c276f35d46de57fd90dfbb385c", + "shasum": "" + }, + "require": { + "php": "^8.1", + "pragmarx/google2fa": "^8.0|^9.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0|^3.0", + "chillerlan/php-qrcode": "^5.0|^6.0", + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13" + }, + "suggest": { + "bacon/bacon-qr-code": "For QR Code generation, requires imagick", + "chillerlan/php-qrcode": "For QR Code generation" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues", + "source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v4.0.0" + }, + "time": "2026-05-08T19:24:44+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/database/migrations/2026_06_12_114836_add_security_columns_to_users_table.php b/database/migrations/2026_06_12_114836_add_security_columns_to_users_table.php new file mode 100644 index 0000000..3076d58 --- /dev/null +++ b/database/migrations/2026_06_12_114836_add_security_columns_to_users_table.php @@ -0,0 +1,24 @@ +text('two_factor_secret')->nullable()->after('password'); + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_secret'); + $table->boolean('must_change_password')->default(false)->after('two_factor_confirmed_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['two_factor_secret', 'two_factor_confirmed_at', 'must_change_password']); + }); + } +}; diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 3e6255f..e0be486 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -32,16 +32,20 @@ {{-- User --}} + @php($u = auth()->user())
- AD + {{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }} - admin - 2FA aktiv + {{ $u?->name ?? '—' }} + {{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }} - - - +
+ @csrf + +
diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..c4005a8 --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,28 @@ + + + + + + + {{ $title ?? 'Clusev' }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + +
+
+ + + + Clusev +
+ +
+ {{ $slot }} +
+ +

Fleet Control · agentless über SSH

+
+ @livewireScripts + + diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php new file mode 100644 index 0000000..4506f09 --- /dev/null +++ b/resources/views/livewire/auth/login.blade.php @@ -0,0 +1,33 @@ +
+

Anmelden

+

Zugang zum Clusev-Control-Panel.

+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ + + + +
+
diff --git a/resources/views/livewire/auth/password-change.blade.php b/resources/views/livewire/auth/password-change.blade.php new file mode 100644 index 0000000..dc24ab5 --- /dev/null +++ b/resources/views/livewire/auth/password-change.blade.php @@ -0,0 +1,31 @@ +
+

Passwort ändern

+

Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).

+ +
+
+ + + @error('current')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ +
+ + +
+ + +
+
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php new file mode 100644 index 0000000..8085a12 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -0,0 +1,19 @@ +
+

Zwei-Faktor-Bestätigung

+

Gib den 6-stelligen Code aus deiner Authenticator-App ein.

+ +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ + +
+
diff --git a/resources/views/livewire/auth/two-factor-setup.blade.php b/resources/views/livewire/auth/two-factor-setup.blade.php new file mode 100644 index 0000000..efe2273 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-setup.blade.php @@ -0,0 +1,28 @@ +
+

Zwei-Faktor einrichten

+

Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.

+ +
+ {!! $this->qrCode() !!} +
+ +
+

Geheimnis (manuell)

+

{{ $secret }}

+
+ +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ + +
+
diff --git a/routes/web.php b/routes/web.php index 208cf03..332a131 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,17 +1,42 @@ name('dashboard'); +Route::middleware('guest')->group(function () { + Route::get('/login', Auth\Login::class)->name('login'); + Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); +}); -Route::get('/servers', Servers\Index::class)->name('servers.index'); -Route::get('/servers/{server}', Servers\Show::class)->name('servers.show'); +Route::middleware('auth')->group(function () { + // Security onboarding — reachable before the panel is unlocked. + Route::get('/password', Auth\PasswordChange::class)->name('password.change'); + Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup'); -Route::get('/services', Services\Index::class)->name('services.index'); -Route::get('/files', Files\Index::class)->name('files.index'); -Route::get('/audit', Audit\Index::class)->name('audit.index'); + Route::post('/logout', function () { + AuthFacade::logout(); + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('login'); + })->name('logout'); + + // The panel — requires completed onboarding (password rotated + 2FA enrolled). + Route::middleware(EnsureSecurityOnboarded::class)->group(function () { + Route::get('/', Dashboard::class)->name('dashboard'); + + Route::get('/servers', Servers\Index::class)->name('servers.index'); + Route::get('/servers/{server}', Servers\Show::class)->name('servers.show'); + + Route::get('/services', Services\Index::class)->name('services.index'); + Route::get('/files', Files\Index::class)->name('files.index'); + Route::get('/audit', Audit\Index::class)->name('audit.index'); + }); +});