161 lines
5.2 KiB
PHP
161 lines
5.2 KiB
PHP
<?php
|
||
|
||
namespace App\Livewire\Ui\Security\Modal;
|
||
|
||
use App\Models\TwoFactorRecoveryCode;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Str;
|
||
use LivewireUI\Modal\ModalComponent;
|
||
|
||
class RecoveryCodesModal extends ModalComponent
|
||
{
|
||
/** Plain Codes werden nur direkt nach Generierung gezeigt */
|
||
public array $plainCodes = [];
|
||
|
||
/** Zeigt an, dass in der DB bereits Codes existieren */
|
||
public bool $hasExisting = false;
|
||
|
||
/** Ob dieser View-Zyklus gerade neue Codes erzeugt hat (Download erlaubt) */
|
||
public bool $justGenerated = false;
|
||
|
||
/** Anzahl Codes & Länge */
|
||
public int $count = 10; // wie viele Codes
|
||
public int $length = 10; // wie lang (ohne Leerzeichen/Trenner)
|
||
|
||
public static function modalMaxWidth(): string
|
||
{
|
||
// Stelle sicher, dass Tailwind die Klasse kennt (safelisten, falls nötig):
|
||
// 'sm:max-w-md' / 'md:max-w-xl' etc. – wir bleiben hier beim „md“ Preset
|
||
return 'md';
|
||
}
|
||
|
||
public function mount(): void
|
||
{
|
||
$user = Auth::user();
|
||
|
||
$this->hasExisting = TwoFactorRecoveryCode::where('user_id', $user->id)->exists();
|
||
|
||
// Wichtig: $plainCodes bleiben leer, außer nach generate().
|
||
// So werden bereits existierende Codes nie erneut gezeigt.
|
||
$this->plainCodes = [];
|
||
$this->justGenerated = false;
|
||
}
|
||
|
||
/** Erzeugt/rotiert Codes und zeigt sie EINMALIG an */
|
||
public function generate(): void
|
||
{
|
||
$user = Auth::user();
|
||
|
||
// Alte Codes verwerfen/überschreiben
|
||
TwoFactorRecoveryCode::where('user_id', $user->id)->delete();
|
||
|
||
// Neue Codes erzeugen (nur hier im Speicher im Klartext zeigen)
|
||
$new = [];
|
||
for ($i = 0; $i < $this->count; $i++) {
|
||
// 10 zufällige Großbuchstaben/Ziffern
|
||
$raw = strtoupper(Str::random($this->length));
|
||
$new[] = $raw;
|
||
|
||
TwoFactorRecoveryCode::create([
|
||
'user_id' => $user->id,
|
||
'code_hash' => hash('sha256', $raw),
|
||
'used_at' => null,
|
||
]);
|
||
}
|
||
|
||
// Einmalige Anzeige
|
||
$this->plainCodes = $new;
|
||
$this->hasExisting = true;
|
||
$this->justGenerated = true;
|
||
}
|
||
|
||
/** Optional: E-Mail leicht maskieren für die Dateiüberschrift */
|
||
protected function maskEmail(?string $email): string
|
||
{
|
||
if (!$email || !str_contains($email, '@')) return '—';
|
||
[$name, $domain] = explode('@', $email, 2);
|
||
$nameMasked = Str::substr($name, 0, 1) . str_repeat('•', max(1, strlen($name) - 2)) . Str::substr($name, -1);
|
||
$domainMasked = preg_replace('/(^.).*(?=\.)/u', '$1•••', $domain); // z.B. g•••gle.com
|
||
return "{$nameMasked}@{$domainMasked}";
|
||
}
|
||
|
||
/** Formatiert die TXT-Datei hübsch */
|
||
protected function buildTxtContent(array $codes): string
|
||
{
|
||
$app = config('app.name', 'App');
|
||
$user = Auth::user();
|
||
$who = $this->maskEmail($user->email ?? '');
|
||
$now = Carbon::now()->toDateTimeString();
|
||
|
||
// Codes als "ABCDE-FGHIJ" + Nummerierung 01), 02), …
|
||
$lines = [];
|
||
foreach (array_values($codes) as $i => $raw) {
|
||
// in 5er Blöcke mit Bindestrich
|
||
$pretty = trim(chunk_split(strtoupper($raw), 5, '-'), '-');
|
||
$nr = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT);
|
||
$lines[] = "{$nr}) {$pretty}";
|
||
}
|
||
|
||
$body = implode(PHP_EOL, $lines);
|
||
|
||
$header = <<<TXT
|
||
{$app} – Recovery Codes
|
||
Benutzer: {$who}
|
||
Erstellt: {$now}
|
||
|
||
Hinweise:
|
||
• Jeder Code kann genau einmal verwendet werden.
|
||
• Bewahre diese Datei offline & sicher auf (z. B. ausdrucken).
|
||
• Teile diese Codes niemals mit Dritten.
|
||
|
||
Codes:
|
||
{$body}
|
||
|
||
TXT;
|
||
|
||
return $header;
|
||
}
|
||
|
||
/** Nur direkt nach Generierung möglich – schöner Download-Content + Dateiname */
|
||
public function downloadTxt()
|
||
{
|
||
if (!$this->justGenerated || empty($this->plainCodes)) {
|
||
$this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.');
|
||
return null;
|
||
}
|
||
|
||
$app = Str::slug(config('app.name', 'app'));
|
||
$date = now()->format('Y-m-d_His');
|
||
$filename = "{$app}-recovery-codes_{$date}.txt";
|
||
$content = $this->buildTxtContent($this->plainCodes);
|
||
|
||
return response()->streamDownload(function () use ($content) {
|
||
echo $content . PHP_EOL; // final newline
|
||
}, $filename, [
|
||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||
]);
|
||
}
|
||
/** Nur direkt nach Generierung möglich */
|
||
// public function downloadTxt()
|
||
// {
|
||
// if (!$this->justGenerated || empty($this->plainCodes)) {
|
||
// // nichts zurückgeben => Livewire bleibt im Modal
|
||
// $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.');
|
||
// return null;
|
||
// }
|
||
//
|
||
// $filename = 'recovery-codes.txt';
|
||
// $content = implode(PHP_EOL, $this->plainCodes);
|
||
//
|
||
// return response()->streamDownload(function () use ($content) {
|
||
// echo $content;
|
||
// }, $filename);
|
||
// }
|
||
|
||
public function render()
|
||
{
|
||
return view('livewire.ui.security.modal.recovery-codes-modal');
|
||
}
|
||
}
|