feat(webauthn): install web-auth/webauthn-lib + credential storage

Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:18:05 +02:00
parent eef79d1758
commit 78b1017844
6 changed files with 1126 additions and 1 deletions

View File

@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
@ -95,4 +96,14 @@ class User extends Authenticatable
{
return ! $this->must_change_password && $this->hasTwoFactorEnabled();
}
public function webauthnCredentials(): HasMany
{
return $this->hasMany(WebauthnCredential::class);
}
public function hasWebauthnCredentials(): bool
{
return $this->webauthnCredentials()->exists();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebauthnCredential extends Model
{
protected $guarded = [];
protected $casts = [
'transports' => 'array',
'sign_count' => 'integer',
'last_used_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -14,6 +14,7 @@
"livewire/livewire": "^3.6",
"phpseclib/phpseclib": "^3.0",
"pragmarx/google2fa-qrcode": "^4.0",
"web-auth/webauthn-lib": "^5.3",
"wire-elements/modal": "^2.0"
},
"require-dev": {

1035
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webauthn_credentials', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('credential_id', 512); // base64url of the raw credential id (indexed)
$table->text('public_key'); // serialized PublicKeyCredentialSource (JSON)
$table->string('aaguid')->nullable();
$table->json('transports')->nullable();
$table->unsignedBigInteger('sign_count')->default(0);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique');
});
}
public function down(): void
{
Schema::dropIfExists('webauthn_credentials');
}
};

View File

@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WebauthnCredentialTest extends TestCase
{
use RefreshDatabase;
public function test_user_has_webauthn_credentials(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasWebauthnCredentials());
WebauthnCredential::create([
'user_id' => $user->id, 'name' => 'YubiKey 5C', 'credential_id' => 'abc',
'public_key' => '{}', 'sign_count' => 0,
]);
$this->assertTrue($user->fresh()->hasWebauthnCredentials());
$this->assertCount(1, $user->fresh()->webauthnCredentials);
}
}