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
parent
db0a1de92f
commit
b3e5d3a024
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -32,16 +32,20 @@
|
|||
</nav>
|
||||
|
||||
{{-- User --}}
|
||||
@php($u = auth()->user())
|
||||
<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">
|
||||
<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="block truncate text-sm text-ink">admin</span>
|
||||
<span class="block truncate font-mono text-[11px] text-online">2FA aktiv</span>
|
||||
<span class="block truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span>
|
||||
<span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }}</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">
|
||||
<x-icon name="logout" class="h-[18px] w-[18px]" />
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="shrink-0">
|
||||
@csrf
|
||||
<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>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 & weiter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,17 +1,42 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||
use App\Livewire\Audit;
|
||||
use App\Livewire\Auth;
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Files;
|
||||
use App\Livewire\Servers;
|
||||
use App\Livewire\Services;
|
||||
use Illuminate\Support\Facades\Auth as AuthFacade;
|
||||
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::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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue