diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 9393eb5..ae4267a 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -77,10 +77,16 @@ class Index extends Component #[On('twoFactorDisabled')] public function disableTwoFactor(): void { + // Reset the whole second factor: clearing 2FA must also drop the backup codes + // and security keys, so re-enrolling later starts clean (old keys/codes never + // silently become valid again). Auth::user()->forceFill([ 'two_factor_secret' => null, 'two_factor_confirmed_at' => null, + 'two_factor_recovery_codes' => null, ])->save(); + + Auth::user()->webauthnCredentials()->delete(); } private function audit(string $action, string $target): void diff --git a/app/Models/WebauthnCredential.php b/app/Models/WebauthnCredential.php index f2d3c76..7f03dc3 100644 --- a/app/Models/WebauthnCredential.php +++ b/app/Models/WebauthnCredential.php @@ -19,4 +19,13 @@ class WebauthnCredential extends Model { return $this->belongsTo(User::class); } + + protected static function booted(): void + { + // The unique index is on a fixed-length hash, since credential_id can exceed the + // index key-length limit. Derive it automatically from credential_id. + static::saving(function (self $cred): void { + $cred->credential_id_hash = hash('sha256', (string) $cred->credential_id); + }); + } } diff --git a/app/Services/WebauthnService.php b/app/Services/WebauthnService.php index a90a5f6..5f28ee7 100644 --- a/app/Services/WebauthnService.php +++ b/app/Services/WebauthnService.php @@ -132,7 +132,9 @@ class WebauthnService return false; } - $model = $user->webauthnCredentials()->where('credential_id', $this->toB64u($credential->rawId))->first(); + $model = $user->webauthnCredentials() + ->where('credential_id_hash', hash('sha256', $this->toB64u($credential->rawId))) + ->first(); if (! $model) { return false; } diff --git a/database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php b/database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php index 85832f8..7f5081c 100644 --- a/database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php +++ b/database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php @@ -12,7 +12,11 @@ return new class extends Migration $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('credential_id', 512); // base64url of the raw credential id (indexed) + // Credential IDs can be up to 1023 bytes (base64url ~1364 chars) — too long for + // a unique index, so store the full value in TEXT and enforce uniqueness on a + // fixed-length sha256 hash of it. + $table->text('credential_id'); + $table->char('credential_id_hash', 64); $table->text('public_key'); // serialized PublicKeyCredentialSource (JSON) $table->string('aaguid')->nullable(); $table->json('transports')->nullable(); @@ -20,7 +24,7 @@ return new class extends Migration $table->timestamp('last_used_at')->nullable(); $table->timestamps(); - $table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique'); + $table->unique(['user_id', 'credential_id_hash'], 'webauthn_user_credential_unique'); }); }