Fix: 2FA-Setup funktioniert jetzt korrekt — Profilseite + secret column fix
parent
3bb9afefc3
commit
f20a30839f
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
#LOGOUT ROUTE
|
||||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue