· Admin

Laravel'de Passkey Kimlik Dogrulama

Sifrelerin sonu geliyor mu bilmiyorum ama alternatifler giderek daha guclu hale geliyor. Passkey teknolojisi 2024'ten beri ciddi ivme kazandi, 2026'da artik neredeyse her cihaz destekliyor. Bu yazida Laravel projenize passkey destegi eklemeyi anlatiyorum.

Passkey Nedir?

Passkey, geleneksel sifre yerine kriptografik anahtar cifti kullanan bir kimlik dogrulama yontemi. FIDO2/WebAuthn standartlarina dayanir.

Nasil Calisiyor?

  1. Kayit: Tarayici bir public/private key cifti olusturur
  2. Public key sunucuya gonderilir ve kaydedilir
  3. Private key cihazda kalir (Secure Enclave, TPM vb.)
  4. Giris: Sunucu bir challenge gonderir, cihaz private key ile imzalar, sunucu public key ile dogrular

Sifre sunucu tarafinda saklanmiyor, phishing mumkun degil, brute force anlamsiz.

Neden 2026'da Passkey?

  • Apple, Google ve Microsoft tam destek sagliyor
  • iOS 16+, Android 9+, Windows 10+, macOS Ventura+ destekli
  • Chrome, Safari, Firefox, Edge hepsi destekliyor
  • AB dijital kimlik regülasyonu passkey'leri tesvik ediyor
  • Buyuk servisler (Google, GitHub, Microsoft) varsayilan olarak sunuyor

spatie/laravel-passkeys Paketi

Spatie, Laravel icin guzel bir passkey paketi sunuyor. Kurulumu ve kullanimi basit.

Kurulum

composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate

Bu migration passkeys tablosunu olusturur:

Schema::create('passkeys', function (Blueprint $table) {
    $table->id();
    $table->morphs('authenticatable');
    $table->text('credential_id');
    $table->text('public_key');
    $table->string('name')->nullable();
    $table->timestamp('last_used_at')->nullable();
    $table->timestamps();
});

Konfigurasyon

php artisan vendor:publish --tag="passkeys-config"
// config/passkeys.php
return [
    'relying_party' => [
        'name' => env('APP_NAME', 'Laravel'),
        'id' => env('PASSKEY_RP_ID', null), // null = current domain
    ],

    'authenticatable_model' => App\Models\User::class,

    // Passkey ile giris yapildiginda
    'redirect_to' => '/dashboard',

    // Kullanici adi alani
    'username' => 'email',

    // Timeout (milisaniye)
    'timeout' => 60000,

    // Attestation tipi
    'attestation' => 'none', // 'none', 'indirect', 'direct'

    // Kullanici dogrulama
    'user_verification' => 'preferred', // 'required', 'preferred', 'discouraged'
];

Model Hazirligi

use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasPasskeys;
}

Route'lari Kaydet

// routes/web.php
use Spatie\LaravelPasskeys\Http\Controllers\PasskeyLoginController;
use Spatie\LaravelPasskeys\Http\Controllers\PasskeyRegistrationController;

// Passkey kayit
Route::get('/passkeys/register/options', [PasskeyRegistrationController::class, 'options'])
    ->middleware('auth')
    ->name('passkeys.register.options');

Route::post('/passkeys/register', [PasskeyRegistrationController::class, 'store'])
    ->middleware('auth')
    ->name('passkeys.register.store');

// Passkey ile giris
Route::get('/passkeys/login/options', [PasskeyLoginController::class, 'options'])
    ->name('passkeys.login.options');

Route::post('/passkeys/login', [PasskeyLoginController::class, 'login'])
    ->name('passkeys.login.store');

Frontend Implementasyonu

Passkey islemleri WebAuthn API'si ile tarayicida gerceklesir. Livewire veya vanilla JS ile yapabilirsin.

Vanilla JavaScript ile Passkey Kayit

// passkey-register.js
async function registerPasskey() {
    try {
        // 1. Sunucudan registration options al
        const optionsResponse = await fetch('/passkeys/register/options', {
            headers: {
                'Accept': 'application/json',
                'X-Requested-With': 'XMLHttpRequest',
            },
        });
        const options = await optionsResponse.json();

        // 2. ArrayBuffer'a cevir
        options.challenge = base64UrlToBuffer(options.challenge);
        options.user.id = base64UrlToBuffer(options.user.id);

        if (options.excludeCredentials) {
            options.excludeCredentials = options.excludeCredentials.map(cred => ({
                ...cred,
                id: base64UrlToBuffer(cred.id),
            }));
        }

        // 3. Tarayicidan credential olustur (biyometrik onay burada olur)
        const credential = await navigator.credentials.create({
            publicKey: options,
        });

        // 4. Sunucuya gonder
        const response = await fetch('/passkeys/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
            body: JSON.stringify({
                id: credential.id,
                rawId: bufferToBase64Url(credential.rawId),
                type: credential.type,
                response: {
                    attestationObject: bufferToBase64Url(credential.response.attestationObject),
                    clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
                },
                name: 'MacBook Pro', // Passkey ismi
            }),
        });

        if (response.ok) {
            alert('Passkey basariyla kaydedildi!');
        }
    } catch (error) {
        console.error('Passkey kayit hatasi:', error);
    }
}

// Yardimci fonksiyonlar
function base64UrlToBuffer(base64url) {
    const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
    const binary = atob(base64);
    const buffer = new ArrayBuffer(binary.length);
    const view = new Uint8Array(buffer);
    for (let i = 0; i < binary.length; i++) {
        view[i] = binary.charCodeAt(i);
    }
    return buffer;
}

function bufferToBase64Url(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.length; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

Vanilla JavaScript ile Passkey Giris

// passkey-login.js
async function loginWithPasskey() {
    try {
        // 1. Sunucudan authentication options al
        const optionsResponse = await fetch('/passkeys/login/options', {
            headers: { 'Accept': 'application/json' },
        });
        const options = await optionsResponse.json();

        // 2. Challenge'i ArrayBuffer'a cevir
        options.challenge = base64UrlToBuffer(options.challenge);

        if (options.allowCredentials) {
            options.allowCredentials = options.allowCredentials.map(cred => ({
                ...cred,
                id: base64UrlToBuffer(cred.id),
            }));
        }

        // 3. Tarayicidan credential al (biyometrik onay)
        const assertion = await navigator.credentials.get({
            publicKey: options,
        });

        // 4. Sunucuya gonder
        const response = await fetch('/passkeys/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
            body: JSON.stringify({
                id: assertion.id,
                rawId: bufferToBase64Url(assertion.rawId),
                type: assertion.type,
                response: {
                    authenticatorData: bufferToBase64Url(assertion.response.authenticatorData),
                    clientDataJSON: bufferToBase64Url(assertion.response.clientDataJSON),
                    signature: bufferToBase64Url(assertion.response.signature),
                    userHandle: assertion.response.userHandle
                        ? bufferToBase64Url(assertion.response.userHandle)
                        : null,
                },
            }),
        });

        if (response.ok) {
            window.location.href = '/dashboard';
        }
    } catch (error) {
        if (error.name === 'NotAllowedError') {
            alert('Islem iptal edildi veya zaman asimina ugradi.');
        } else {
            console.error('Passkey giris hatasi:', error);
        }
    }
}

Livewire Komponenti

// app/Livewire/PasskeyManager.php
use Livewire\Component;

class PasskeyManager extends Component
{
    public function getPasskeys()
    {
        return auth()->user()->passkeys()->latest()->get();
    }

    public function deletePasskey(int $passkeyId)
    {
        $passkey = auth()->user()->passkeys()->findOrFail($passkeyId);
        $passkey->delete();

        $this->dispatch('passkey-deleted');
    }

    public function render()
    {
        return view('livewire.passkey-manager', [
            'passkeys' => $this->getPasskeys(),
        ]);
    }
}
<!-- resources/views/livewire/passkey-manager.blade.php -->
<div>
    <h3>Kayitli Passkey'ler</h3>

    @forelse($passkeys as $passkey)
        <div class="flex items-center justify-between p-4 border rounded mb-2">
            <div>
                <p class="font-medium">{{ $passkey->name ?? 'Isimsiz Passkey' }}</p>
                <p class="text-sm text-gray-500">
                    Son kullanim: {{ $passkey->last_used_at?->diffForHumans() ?? 'Hic kullanilmadi' }}
                </p>
                <p class="text-xs text-gray-400">
                    Olusturulma: {{ $passkey->created_at->format('d.m.Y H:i') }}
                </p>
            </div>
            <button
                wire:click="deletePasskey({{ $passkey->id }})"
                wire:confirm="Bu passkey'i silmek istediginize emin misiniz?"
                class="text-red-600 hover:text-red-800"
            >
                Sil
            </button>
        </div>
    @empty
        <p class="text-gray-500">Henuz kayitli passkey yok.</p>
    @endforelse

    <button
        onclick="registerPasskey()"
        class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
    >
        Yeni Passkey Ekle
    </button>
</div>

Login Sayfasi

<!-- resources/views/auth/login.blade.php -->
<form method="POST" action="/login">
    @csrf
    <input type="email" name="email" placeholder="E-posta" required>
    <input type="password" name="password" placeholder="Sifre" required>
    <button type="submit">Giris Yap</button>
</form>

<div class="mt-4">
    <p class="text-center text-gray-500">veya</p>
    <button
        onclick="loginWithPasskey()"
        class="w-full mt-2 px-4 py-2 border rounded flex items-center justify-center gap-2"
        id="passkey-login-btn"
    >
        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/>
            <circle cx="16.5" cy="7.5" r=".5"/>
        </svg>
        Passkey ile Giris Yap
    </button>
</div>

<script>
    // Passkey destegini kontrol et
    if (!window.PublicKeyCredential) {
        document.getElementById('passkey-login-btn').style.display = 'none';
    }
</script>

Onemli Hususlar

1. HTTPS Zorunlu

WebAuthn sadece guvenli baglam (secure context) altinda calisiyor. Yani HTTPS zorunlu. Tek istisna localhost.

# Production'da
APP_URL=https://your-app.com

2. Fallback Authentication

Passkey'i tek giris yontemi yapma. Kullanicilarin hepsinin passkey destekleyen cihazi olmayabilir.

// Sifre + Passkey birlikte sunulabilir
// Kullanici tercih ettigi yontemle giris yapar

3. Hesap Kurtarma

Kullanici tum cihazlarini kaybederse ne olacak? Mutlaka bir kurtarma mekanizmasi ol:

// Kurtarma kodu olustur (kayit sirasinda)
class RecoveryCodeService
{
    public function generate(User $user): array
    {
        $codes = [];
        for ($i = 0; $i < 8; $i++) {
            $codes[] = Str::random(5) . '-' . Str::random(5);
        }

        $user->recovery_codes = encrypt(json_encode($codes));
        $user->save();

        return $codes; // Kullaniciya bir kez goster
    }

    public function verify(User $user, string $code): bool
    {
        $codes = json_decode(decrypt($user->recovery_codes), true);

        if (in_array($code, $codes)) {
            // Kullanilmis kodu cikar
            $codes = array_diff($codes, [$code]);
            $user->recovery_codes = encrypt(json_encode(array_values($codes)));
            $user->save();

            return true;
        }

        return false;
    }
}

4. Coklu Cihaz Destegi

Bir kullanicinin birden fazla passkey'i olabilir (telefon, laptop, tablet). Her cihaz icin ayri passkey kaydettir.

// Kullanicinin kac passkey'i var?
$count = $user->passkeys()->count();

// Kayitli cihazlar
$devices = $user->passkeys()
    ->select('id', 'name', 'last_used_at', 'created_at')
    ->get();

5. Conditional UI (Autofill)

Modern tarayicilar passkey'leri sifre otomatik doldurma gibi onerebilir:

<input
    type="text"
    name="email"
    autocomplete="username webauthn"
    placeholder="E-posta"
>
// Conditional mediation ile passkey onerisi
async function conditionalLogin() {
    if (!window.PublicKeyCredential ||
        !PublicKeyCredential.isConditionalMediationAvailable) {
        return;
    }

    const available = await PublicKeyCredential.isConditionalMediationAvailable();
    if (!available) return;

    try {
        const options = await fetch('/passkeys/login/options').then(r => r.json());
        options.challenge = base64UrlToBuffer(options.challenge);

        const credential = await navigator.credentials.get({
            publicKey: options,
            mediation: 'conditional', // Bu satir onemli
        });

        // credential'i sunucuya gonder...
    } catch (e) {
        // Kullanici iptal ettiyse sessizce gec
    }
}

// Sayfa yuklendiginde calistir
conditionalLogin();

6. Passkey Destegi Kontrolu

async function isPasskeySupported() {
    // WebAuthn API mevcut mu?
    if (!window.PublicKeyCredential) {
        return false;
    }

    // Platform authenticator (Touch ID, Face ID, Windows Hello) var mi?
    try {
        const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
        return available;
    } catch {
        return false;
    }
}

// Kullanim
const supported = await isPasskeySupported();
if (supported) {
    // Passkey kayit/giris butonlarini goster
} else {
    // Klasik sifre formu goster
}

Test

// tests/Feature/PasskeyTest.php
use Tests\TestCase;

class PasskeyTest extends TestCase
{
    public function test_passkey_registration_options_require_auth()
    {
        $response = $this->getJson('/passkeys/register/options');
        $response->assertUnauthorized();
    }

    public function test_authenticated_user_can_get_registration_options()
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->getJson('/passkeys/register/options');

        $response->assertOk();
        $response->assertJsonStructure([
            'challenge',
            'rp' => ['name', 'id'],
            'user' => ['id', 'name', 'displayName'],
            'pubKeyCredParams',
            'timeout',
            'attestation',
        ]);
    }

    public function test_user_can_delete_own_passkey()
    {
        $user = User::factory()->create();
        $passkey = $user->passkeys()->create([
            'credential_id' => 'test-credential',
            'public_key' => 'test-key',
            'name' => 'Test Device',
        ]);

        $this->actingAs($user)
            ->delete("/passkeys/{$passkey->id}")
            ->assertOk();

        $this->assertDatabaseMissing('passkeys', ['id' => $passkey->id]);
    }
}

Sonuc

Passkey'ler sifrelerden daha guvenli, daha kullanici dostu ve phishing'e karsi dayanikli. 2026'da Laravel projenize passkey destegi eklemek artik zorunluluk degil ama guclu bir diferansiyasyon. Kullanicilariniz "sifresiz giris yap" butonunu gordugunde memnun olacaklar.

Onemli olan passkey'i tek yontem olarak dayatmamak. Sifre + passkey birlikte sunulabilir, kullanici hangisini isterse onu kullanir. Zaman icinde passkey kullanan kullanicilarin orani artacaktir.