fix(webauthn): hash-indexed credential ids + reset keys on 2FA disable
- credential_id can exceed the unique-index key length; store it in TEXT and enforce uniqueness on an auto-derived sha256 hash column (lookups use the hash). - Disabling 2FA now also deletes the user's security keys + backup codes, so re-enrolling starts clean and old factors never silently revive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
98a39e1257
commit
139c376056
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue