Fix: 2FA-Setup funktioniert jetzt korrekt — Profilseite + secret column fix
parent
3bb9afefc3
commit
f20a30839f
|
|
@ -2,90 +2,54 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security\Modal;
|
||||
|
||||
use App\Services\TotpService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Vectorface\GoogleAuthenticator;
|
||||
|
||||
class TotpSetupModal extends ModalComponent
|
||||
{
|
||||
public string $secret;
|
||||
public string $otp = '';
|
||||
public string $qrPng; // PNG Data-URI
|
||||
public bool $alreadyActive = false;
|
||||
public string $step = 'scan';
|
||||
public string $code = '';
|
||||
public string $secret = '';
|
||||
public array $recoveryCodes = [];
|
||||
public string $qrSvg = '';
|
||||
|
||||
// << Wichtig: je Modal eigene Breite >>
|
||||
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 static function modalMaxWidth(): string { return 'md'; }
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
|
||||
// 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);
|
||||
$totp = app(TotpService::class);
|
||||
$this->secret = $totp->generateSecret();
|
||||
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
|
||||
}
|
||||
|
||||
#[On('security:totp:enable')]
|
||||
public function verifyAndEnable(string $code): void
|
||||
public function verify(): void
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code ?? '');
|
||||
if (strlen($code) !== 6) {
|
||||
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
|
||||
$this->validate(['code' => 'required|digits:6']);
|
||||
|
||||
$totp = app(TotpService::class);
|
||||
|
||||
if (!$totp->verify($this->secret, $this->code)) {
|
||||
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
|
||||
return;
|
||||
}
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
|
||||
|
||||
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');
|
||||
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
|
||||
$this->step = 'codes';
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
public function done(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
$user->totp_secret = null;
|
||||
$user->two_factor_enabled = false;
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('totp-disabled');
|
||||
$this->dispatch('toast', body: 'TOTP deaktiviert.');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast', type: 'done', badge: '2FA',
|
||||
title: 'TOTP aktiviert',
|
||||
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
|
||||
$this->dispatch('2fa-status-changed');
|
||||
$this->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()
|
||||
{
|
||||
return view('livewire.ui.security.modal.totp-setup-modal');
|
||||
return view('livewire.ui.system.modal.totp-setup-modal');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -148,11 +148,13 @@
|
|||
</nav>
|
||||
|
||||
<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="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>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;">
|
||||
@csrf
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue