Fix: 2FA-Setup funktioniert jetzt korrekt — Profilseite + secret column fix

main v1.1.436
boban 2026-04-27 19:17:29 +02:00
parent 3bb9afefc3
commit f20a30839f
6 changed files with 274 additions and 63 deletions

View File

@ -2,90 +2,54 @@
namespace App\Livewire\Ui\Security\Modal; namespace App\Livewire\Ui\Security\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
use Vectorface\GoogleAuthenticator;
class TotpSetupModal extends ModalComponent class TotpSetupModal extends ModalComponent
{ {
public string $secret; public string $step = 'scan';
public string $otp = ''; public string $code = '';
public string $qrPng; // PNG Data-URI public string $secret = '';
public bool $alreadyActive = false; public array $recoveryCodes = [];
public string $qrSvg = '';
// << Wichtig: je Modal eigene Breite >> public static function modalMaxWidth(): string { return 'md'; }
public static function modalMaxWidth(): string
{
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
return 'xl'; // kompakt für TOTP
}
public function mount(): void public function mount(): void
{ {
$user = Auth::user(); $totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$ga = new GoogleAuthenticator(); $this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
$this->secret = $user->totp_secret ?: $ga->createSecret();
$issuer = config('app.name', 'MailWolt');
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
} }
#[On('security:totp:enable')] public function verify(): void
public function verifyAndEnable(string $code): void
{ {
$code = preg_replace('/\D/', '', $code ?? ''); $this->validate(['code' => 'required|digits:6']);
if (strlen($code) !== 6) {
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.'); $totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
return; return;
} }
$ga = new GoogleAuthenticator(); $this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz $this->step = 'codes';
if (!$ok) {
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
return;
}
$user = Auth::user();
$user->totp_secret = $this->secret;
$user->two_factor_enabled = true;
$user->save();
$this->dispatch('totp-enabled');
$this->dispatch('toast', body: 'TOTP aktiviert.');
$this->dispatch('closeModal');
} }
public function disable(): void public function done(): void
{ {
$user = Auth::user(); $this->dispatch('toast', type: 'done', badge: '2FA',
$user->totp_secret = null; title: 'TOTP aktiviert',
$user->two_factor_enabled = false; text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$user->save(); $this->dispatch('2fa-status-changed');
$this->closeModal();
$this->dispatch('totp-disabled');
$this->dispatch('toast', body: 'TOTP deaktiviert.');
$this->dispatch('closeModal');
} }
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
public function changePassword() { /* validate & set */ }
public function changeEmail() { /* validate, send verify link, etc. */ }
public function openRecovery() { /* optional modal or page */ }
public function logoutOthers() { /* … */ }
public function logoutSession(string $id) { /* … */ }
public function render() public function render()
{ {
return view('livewire.ui.security.modal.totp-setup-modal'); return view('livewire.ui.system.modal.totp-setup-modal');
} }
} }

View File

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Ui\System;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Profil · Mailwolt')]
class ProfilePage extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
public bool $totpEnabled = false;
public function mount(): void
{
$u = Auth::user();
$this->name = $u->name ?? '';
$this->email = $u->email ?? '';
$this->totpEnabled = app(TotpService::class)->isEnabled($u);
}
#[On('2fa-status-changed')]
public function refreshTotpStatus(): void
{
$this->totpEnabled = app(TotpService::class)->isEnabled(Auth::user());
}
public function saveProfile(): void
{
$this->validate([
'name' => 'required|string|max:100',
]);
$u = Auth::user();
$u->name = $this->name;
$u->save();
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Gespeichert',
text: 'Profilname wurde aktualisiert.', duration: 4000);
}
public function changePassword(): void
{
if ($this->new_password === '') {
$this->addError('new_password', 'Kein neues Passwort eingegeben.');
return;
}
$this->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8',
'new_password_confirmation' => 'required|same:new_password',
]);
$u = Auth::user();
if (!Hash::check($this->current_password, $u->password)) {
$this->addError('current_password', 'Aktuelles Passwort ist falsch.');
return;
}
$u->password = Hash::make($this->new_password);
$u->save();
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Passwort geändert',
text: 'Dein Passwort wurde aktualisiert.', duration: 4000);
}
public function disableTotp(): void
{
app(TotpService::class)->disable(Auth::user());
session()->forget('2fa_verified');
$this->totpEnabled = false;
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP deaktiviert',
text: 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', duration: 5000);
}
public function render()
{
return view('livewire.ui.system.profile-page');
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('two_factor_methods', function (Blueprint $table) {
$table->text('secret')->nullable()->change();
});
}
public function down(): void
{
Schema::table('two_factor_methods', function (Blueprint $table) {
$table->string('secret', 255)->nullable()->change();
});
}
};

View File

@ -148,11 +148,13 @@
</nav> </nav>
<div class="mw-sb-foot"> <div class="mw-sb-foot">
<div class="mw-avatar">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div> <a href="{{ route('ui.profile') }}" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;overflow:hidden;text-decoration:none;border-radius:7px;padding:2px 4px;margin:-2px -4px;transition:background .15s" onmouseover="this.style.background='var(--mw-bg4)'" onmouseout="this.style.background=''">
<div class="mw-avatar" style="{{ request()->routeIs('ui.profile') ? 'background:var(--mw-v)' : '' }}">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
<div style="min-width:0;flex:1;overflow:hidden;"> <div style="min-width:0;flex:1;overflow:hidden;">
<div style="font-size:12px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->name ?? 'Admin' }}</div> <div style="font-size:12px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->name ?? 'Admin' }}</div>
<div style="font-size:10px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->email ?? '' }}</div> <div style="font-size:10px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->email ?? '' }}</div>
</div> </div>
</a>
<form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;"> <form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;">
@csrf @csrf
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden"> <button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden">

View File

@ -0,0 +1,118 @@
<x-slot:breadcrumb>Mein Profil</x-slot:breadcrumb>
<div style="max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:16px">
{{-- Profil --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Profil</span>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
<div>
<label class="mw-modal-label">Name</label>
<input type="text" wire:model.defer="name" class="mw-modal-input">
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">E-Mail</label>
<input type="text" value="{{ $email }}" class="mw-modal-input" disabled style="opacity:.6">
</div>
<div>
<button wire:click="saveProfile" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Speichern
</button>
</div>
</div>
</div>
{{-- Passwort --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Passwort ändern</span>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
<div>
<label class="mw-modal-label">Aktuelles Passwort</label>
<input type="password" wire:model.defer="current_password" class="mw-modal-input" autocomplete="current-password">
@error('current_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Neues Passwort</label>
<input type="password" wire:model.defer="new_password" class="mw-modal-input" autocomplete="new-password">
@error('new_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Bestätigung</label>
<input type="password" wire:model.defer="new_password_confirmation" class="mw-modal-input" autocomplete="new-password">
@error('new_password_confirmation') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<button wire:click="changePassword" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Passwort setzen
</button>
</div>
</div>
</div>
{{-- 2FA --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Zwei-Faktor-Authentifizierung</span>
</div>
@if($totpEnabled)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Nicht eingerichtet</span>
@endif
</div>
<div style="padding:14px 18px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style="flex-shrink:0;margin-top:1px;color:var(--mw-t4)">
<circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<circle cx="6.5" cy="4" r=".7" fill="currentColor"/>
</svg>
<div style="font-size:11.5px;color:var(--mw-t3);line-height:1.6">
Bei aktiviertem 2FA wird beim Login nach dem Passwort ein
<strong style="color:var(--mw-t2)">6-stelliger TOTP-Code</strong>
aus deiner Authenticator-App verlangt.
</div>
</div>
@if($totpEnabled)
<div style="font-size:12px;color:var(--mw-t3)">
Dein Konto ist mit TOTP geschützt (Google Authenticator, Authy oder kompatible App).
</div>
<div>
<button wire:click="disableTotp"
wire:confirm="2FA wirklich deaktivieren? Dein Konto wird dann nur mit Passwort geschützt."
class="mw-btn-del" style="font-size:12px;padding:5px 12px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
TOTP deaktivieren
</button>
</div>
@else
<div style="font-size:12px;color:var(--mw-t3)">
Schütze dein Konto mit einem Einmalcode-Generator (TOTP). Einzurichten in unter einer Minute.
</div>
<div>
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.totp-setup-modal'})"
class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M5.5 1v9M1 5.5h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
TOTP einrichten
</button>
</div>
@endif
</div>
</div>
</div>

View File

@ -97,6 +97,9 @@ Route::middleware(['auth.user', 'require2fa'])->name('ui.')->group(function () {
// }); // });
// }); // });
#PROFILE ROUTE
Route::get('/profile', \App\Livewire\Ui\System\ProfilePage::class)->name('profile');
#LOGOUT ROUTE #LOGOUT ROUTE
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
}); });