feat: auth + 2FA (login wall + TOTP + forced onboarding)

- 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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 13:57:02 +02:00
parent db0a1de92f
commit b3e5d3a024
16 changed files with 700 additions and 21 deletions

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* After login, force the security onboarding before the panel is reachable:
* 1) rotate the seeded password, then 2) enrol 2FA.
*/
class EnsureSecurityOnboarded
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->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);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Anmelden — Clusev')]
class Login extends Component
{
#[Validate('required|email')]
public string $email = '';
#[Validate('required')]
public string $password = '';
public bool $remember = false;
public function authenticate()
{
$this->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');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Passwort ändern — Clusev')]
class PasswordChange extends Component
{
public string $current = '';
public string $password = '';
public string $password_confirmation = '';
public function update()
{
$this->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');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('Bestätigung — Clusev')]
class TwoFactorChallenge extends Component
{
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (! session()->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');
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('2FA einrichten — Clusev')]
class TwoFactorSetup extends Component
{
public string $secret = '';
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (Auth::user()->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');
}
}

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; 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\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password', 'must_change_password'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token', 'two_factor_secret'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', '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);
}
} }

View File

@ -7,11 +7,13 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"bacon/bacon-qr-code": "^3.1",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/reverb": "^1.10", "laravel/reverb": "^1.10",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"livewire/livewire": "^3.6", "livewire/livewire": "^3.6",
"phpseclib/phpseclib": "^3.0", "phpseclib/phpseclib": "^3.0",
"pragmarx/google2fa-qrcode": "^4.0",
"wire-elements/modal": "^2.0" "wire-elements/modal": "^2.0"
}, },
"require-dev": { "require-dev": {

227
composer.lock generated
View File

@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "eb9a73c8bcdc5dc9ae4dd2425dd6e878", "content-hash": "0c6b80f9bf7bcbfefa54eab193c9f841",
"packages": [ "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", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@ -265,6 +320,56 @@
], ],
"time": "2025-01-03T16:18:33+00:00" "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", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@ -3177,6 +3282,126 @@
], ],
"time": "2026-06-09T18:08:26+00:00" "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", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -32,16 +32,20 @@
</nav> </nav>
{{-- User --}} {{-- User --}}
@php($u = auth()->user())
<div class="shrink-0 border-t border-line p-3"> <div class="shrink-0 border-t border-line p-3">
<div class="flex items-center gap-2.5 rounded-md px-2 py-1.5"> <div class="flex items-center gap-2.5 rounded-md px-2 py-1.5">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">AD</span> <span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">{{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }}</span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-sm text-ink">admin</span> <span class="block truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span>
<span class="block truncate font-mono text-[11px] text-online">2FA aktiv</span> <span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }}</span>
</span> </span>
<a href="#" class="grid min-h-11 min-w-11 shrink-0 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="Abmelden"> <form method="POST" action="{{ route('logout') }}" class="shrink-0">
<x-icon name="logout" class="h-[18px] w-[18px]" /> @csrf
</a> <button type="submit" class="grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="Abmelden">
<x-icon name="logout" class="h-[18px] w-[18px]" />
</button>
</form>
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Clusev' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="grid min-h-screen place-items-center bg-void px-4 py-8 font-sans text-ink antialiased selection:bg-accent/25">
<div class="relative z-10 w-full max-w-sm">
<div class="mb-6 flex items-center justify-center gap-2.5">
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
<x-icon name="server" class="h-5 w-5" />
</span>
<span class="font-display text-2xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
</div>
<div class="rounded-lg border border-line bg-surface p-6 shadow-panel">
{{ $slot }}
</div>
<p class="mt-4 text-center font-mono text-[11px] text-ink-4">Fleet Control · agentless über SSH</p>
</div>
@livewireScripts
</body>
</html>

View File

@ -0,0 +1,33 @@
<div>
<h1 class="font-display text-lg font-semibold text-ink">Anmelden</h1>
<p class="mt-1 text-sm text-ink-3">Zugang zum Clusev-Control-Panel.</p>
<form wire:submit="authenticate" class="mt-5 space-y-4">
<div>
<label for="email" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">E-Mail</label>
<input wire:model="email" id="email" type="email" autocomplete="username" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
placeholder="admin@clusev.local" />
@error('email') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passwort</label>
<input wire:model="password" id="password" type="password" autocomplete="current-password"
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
placeholder="••••••••" />
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<label class="flex items-center gap-2 text-sm text-ink-2">
<input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" />
Angemeldet bleiben
</label>
<button type="submit" wire:loading.attr="disabled" wire:target="authenticate"
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
<span wire:loading.remove wire:target="authenticate">Anmelden</span>
<span wire:loading wire:target="authenticate">Prüfe…</span>
</button>
</form>
</div>

View File

@ -0,0 +1,31 @@
<div>
<h1 class="font-display text-lg font-semibold text-ink">Passwort ändern</h1>
<p class="mt-1 text-sm text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p>
<form wire:submit="update" class="mt-5 space-y-4">
<div>
<label for="current" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Aktuelles Passwort</label>
<input wire:model="current" id="current" type="password" autocomplete="current-password"
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
@error('current') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort</label>
<input wire:model="password" id="password" type="password" autocomplete="new-password"
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label for="password_confirmation" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort bestätigen</label>
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password"
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="update"
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
Speichern &amp; weiter
</button>
</form>
</div>

View File

@ -0,0 +1,19 @@
<div>
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor-Bestätigung</h1>
<p class="mt-1 text-sm text-ink-3">Gib den 6-stelligen Code aus deiner Authenticator-App ein.</p>
<form wire:submit="verify" class="mt-5 space-y-4">
<div>
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Code</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
placeholder="000000" />
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="verify"
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
Bestätigen
</button>
</form>
</div>

View File

@ -0,0 +1,28 @@
<div>
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor einrichten</h1>
<p class="mt-1 text-sm text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, ) und bestätige mit einem Code.</p>
<div class="mt-5 flex justify-center rounded-md border border-line bg-ink p-3">
{!! $this->qrCode() !!}
</div>
<div class="mt-3 text-center">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
</div>
<form wire:submit="confirm" class="mt-5 space-y-4">
<div>
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Bestätigungs-Code</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
placeholder="000000" />
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="confirm"
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
Aktivieren
</button>
</form>
</div>

View File

@ -1,17 +1,42 @@
<?php <?php
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Livewire\Audit; use App\Livewire\Audit;
use App\Livewire\Auth;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Livewire\Files; use App\Livewire\Files;
use App\Livewire\Servers; use App\Livewire\Servers;
use App\Livewire\Services; use App\Livewire\Services;
use Illuminate\Support\Facades\Auth as AuthFacade;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', Dashboard::class)->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::middleware('auth')->group(function () {
Route::get('/servers/{server}', Servers\Show::class)->name('servers.show'); // 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::post('/logout', function () {
Route::get('/files', Files\Index::class)->name('files.index'); AuthFacade::logout();
Route::get('/audit', Audit\Index::class)->name('audit.index'); 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');
});
});