Laravel Passkeys: Xác Thực Không Mật Khẩu với WebAuthn
·
16 min read
Giới Thiệu
Passkeys là phương thức xác thực thế hệ mới, thay thế mật khẩu truyền thống bằng cryptographic keys. Dựa trên tiêu chuẩn WebAuthn (Web Authentication API), passkeys cung cấp bảo mật cao hơn và trải nghiệm người dùng tốt hơn.
Tại Sao Passkeys?
| Vấn Đề với Mật Khẩu | Passkeys Giải Quyết |
|---|---|
| Dễ bị phishing | Chống phishing hoàn toàn |
| Có thể bị brute force | Không có gì để guess |
| Người dùng tái sử dụng | Unique cho mỗi site |
| Dễ quên | Lưu trên thiết bị |
| Có thể bị đánh cắp từ DB | Chỉ lưu public key |
Cách Passkeys Hoạt Động
Registration:
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Browser │────▶│ Server │────▶│ Server │
│ │ │ (Challenge)│ │(Store Key) │
└────────────┘ └────────────┘ └────────────┘
│ ▲
▼ │
┌────────────┐ │
│Authenticator│─────────────────────────────┘
│(Biometric) │ (Create keypair, send public key)
└────────────┘
Authentication:
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Browser │────▶│ Server │────▶│ Server │
│ │ │ (Challenge)│ │ (Verify) │
└────────────┘ └────────────┘ └────────────┘
│ ▲
▼ │
┌────────────┐ │
│Authenticator│─────────────────────────────┘
│(Biometric) │ (Sign challenge with private key)
└────────────┘
Cài Đặt Package
Sử Dụng webauthn-framework
composer require web-auth/webauthn-lib
composer require web-auth/webauthn-symfony-bundle
Hoặc sử dụng laragear/webauthn
composer require laragear/webauthn
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider"
php artisan migrate
Database Schema
Migration
// database/migrations/2026_05_04_create_webauthn_credentials_table.php
<?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->string('id', 255)->primary();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name')->nullable();
$table->string('type', 32)->default('public-key');
$table->json('transports')->nullable();
$table->string('attestation_type')->default('none');
$table->json('trust_path')->nullable();
$table->uuid('aaguid');
$table->binary('public_key');
$table->unsignedInteger('counter')->default(0);
$table->json('other_ui')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('webauthn_credentials');
}
};
Model Setup
User Model
// app/Models/User.php
<?php
namespace App\Models;
use App\Models\WebAuthnCredential;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function webauthnCredentials()
{
return $this->hasMany(WebAuthnCredential::class);
}
public function hasPasskeys(): bool
{
return $this->webauthnCredentials()->exists();
}
}
WebAuthn Credential Model
// app/Models/WebAuthnCredential.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebAuthnCredential extends Model
{
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'user_id',
'name',
'type',
'transports',
'attestation_type',
'trust_path',
'aaguid',
'public_key',
'counter',
'other_ui',
'last_used_at',
];
protected function casts(): array
{
return [
'transports' => 'array',
'trust_path' => 'array',
'other_ui' => 'array',
'last_used_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function updateCounter(int $counter): void
{
$this->update([
'counter' => $counter,
'last_used_at' => now(),
]);
}
}
WebAuthn Service
// app/Services/WebAuthnService.php
<?php
namespace App\Services;
use App\Models\User;
use App\Models\WebAuthnCredential;
use Illuminate\Support\Str;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
class WebAuthnService
{
private PublicKeyCredentialRpEntity $rpEntity;
public function __construct()
{
$this->rpEntity = new PublicKeyCredentialRpEntity(
name: config('app.name'),
id: parse_url(config('app.url'), PHP_URL_HOST),
);
}
/**
* Generate registration options for a user
*/
public function generateRegistrationOptions(User $user): PublicKeyCredentialCreationOptions
{
$userEntity = new PublicKeyCredentialUserEntity(
name: $user->email,
id: (string) $user->id,
displayName: $user->name,
);
// Get existing credentials to exclude
$excludeCredentials = $user->webauthnCredentials
->map(fn ($cred) => [
'type' => 'public-key',
'id' => base64_decode($cred->id),
])
->toArray();
$authenticatorSelection = new AuthenticatorSelectionCriteria(
authenticatorAttachment: AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
);
$options = new PublicKeyCredentialCreationOptions(
rp: $this->rpEntity,
user: $userEntity,
challenge: random_bytes(32),
pubKeyCredParams: $this->getSupportedAlgorithms(),
authenticatorSelection: $authenticatorSelection,
timeout: 60000,
excludeCredentials: $excludeCredentials,
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
);
// Store challenge in session for verification
session(['webauthn_registration_options' => $options]);
return $options;
}
/**
* Verify registration response and store credential
*/
public function verifyRegistration(
User $user,
array $response,
?string $credentialName = null
): WebAuthnCredential {
$options = session('webauthn_registration_options');
if (!$options) {
throw new \Exception('Registration session expired');
}
// Create attestation support manager
$attestationManager = new AttestationStatementSupportManager();
$attestationManager->add(new NoneAttestationStatementSupport());
// Parse and validate the response
$authenticatorResponse = $this->parseAttestationResponse($response);
$validator = new AuthenticatorAttestationResponseValidator($attestationManager);
$publicKeyCredentialSource = $validator->check(
$authenticatorResponse,
$options,
$this->rpEntity->getId(),
);
// Store the credential
$credential = $user->webauthnCredentials()->create([
'id' => base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId()),
'name' => $credentialName ?? 'Passkey ' . now()->format('Y-m-d H:i'),
'type' => $publicKeyCredentialSource->getType(),
'transports' => $publicKeyCredentialSource->getTransports(),
'attestation_type' => $publicKeyCredentialSource->getAttestationType(),
'trust_path' => $publicKeyCredentialSource->getTrustPath()->jsonSerialize(),
'aaguid' => $publicKeyCredentialSource->getAaguid()->toString(),
'public_key' => $publicKeyCredentialSource->getCredentialPublicKey(),
'counter' => $publicKeyCredentialSource->getCounter(),
'other_ui' => $publicKeyCredentialSource->getOtherUI(),
]);
// Clear session
session()->forget('webauthn_registration_options');
return $credential;
}
/**
* Generate authentication options
*/
public function generateAuthenticationOptions(?User $user = null): PublicKeyCredentialRequestOptions
{
$allowCredentials = [];
if ($user) {
$allowCredentials = $user->webauthnCredentials
->map(fn ($cred) => [
'type' => 'public-key',
'id' => base64_decode($cred->id),
'transports' => $cred->transports ?? [],
])
->toArray();
}
$options = new PublicKeyCredentialRequestOptions(
challenge: random_bytes(32),
timeout: 60000,
rpId: $this->rpEntity->getId(),
allowCredentials: $allowCredentials,
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED,
);
session(['webauthn_authentication_options' => $options]);
return $options;
}
/**
* Verify authentication response
*/
public function verifyAuthentication(array $response): User
{
$options = session('webauthn_authentication_options');
if (!$options) {
throw new \Exception('Authentication session expired');
}
// Find credential by ID
$credentialId = base64_encode(base64_decode($response['id']));
$credential = WebAuthnCredential::find($credentialId);
if (!$credential) {
throw new \Exception('Credential not found');
}
// Parse and validate response
$authenticatorResponse = $this->parseAssertionResponse($response);
$validator = new AuthenticatorAssertionResponseValidator();
$publicKeyCredentialSource = $validator->check(
$this->credentialToSource($credential),
$authenticatorResponse,
$options,
$this->rpEntity->getId(),
$credential->user_id,
);
// Update counter to prevent replay attacks
$credential->updateCounter($publicKeyCredentialSource->getCounter());
// Clear session
session()->forget('webauthn_authentication_options');
return $credential->user;
}
private function getSupportedAlgorithms(): array
{
return [
['type' => 'public-key', 'alg' => -7], // ES256
['type' => 'public-key', 'alg' => -257], // RS256
];
}
private function parseAttestationResponse(array $response): AuthenticatorAttestationResponse
{
// Implementation depends on the library used
// This is a simplified version
return AuthenticatorAttestationResponse::create(
clientDataJSON: base64_decode($response['response']['clientDataJSON']),
attestationObject: base64_decode($response['response']['attestationObject']),
);
}
private function parseAssertionResponse(array $response): AuthenticatorAssertionResponse
{
return AuthenticatorAssertionResponse::create(
clientDataJSON: base64_decode($response['response']['clientDataJSON']),
authenticatorData: base64_decode($response['response']['authenticatorData']),
signature: base64_decode($response['response']['signature']),
userHandle: isset($response['response']['userHandle'])
? base64_decode($response['response']['userHandle'])
: null,
);
}
private function credentialToSource(WebAuthnCredential $credential): PublicKeyCredentialSource
{
return new PublicKeyCredentialSource(
publicKeyCredentialId: base64_decode($credential->id),
type: $credential->type,
transports: $credential->transports ?? [],
attestationType: $credential->attestation_type,
trustPath: $credential->trust_path,
aaguid: $credential->aaguid,
credentialPublicKey: $credential->public_key,
userHandle: (string) $credential->user_id,
counter: $credential->counter,
otherUI: $credential->other_ui,
);
}
}
Controllers
Passkey Registration Controller
// app/Http/Controllers/PasskeyController.php
<?php
namespace App\Http\Controllers;
use App\Services\WebAuthnService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PasskeyController extends Controller
{
public function __construct(
private WebAuthnService $webAuthn
) {}
/**
* Get registration options
*/
public function registerOptions(Request $request): JsonResponse
{
$options = $this->webAuthn->generateRegistrationOptions($request->user());
return response()->json([
'publicKey' => [
'challenge' => $this->base64UrlEncode($options->getChallenge()),
'rp' => [
'name' => $options->getRp()->getName(),
'id' => $options->getRp()->getId(),
],
'user' => [
'id' => $this->base64UrlEncode($options->getUser()->getId()),
'name' => $options->getUser()->getName(),
'displayName' => $options->getUser()->getDisplayName(),
],
'pubKeyCredParams' => $options->getPubKeyCredParams(),
'timeout' => $options->getTimeout(),
'attestation' => $options->getAttestation(),
'authenticatorSelection' => [
'authenticatorAttachment' => $options->getAuthenticatorSelection()?->getAuthenticatorAttachment(),
'residentKey' => $options->getAuthenticatorSelection()?->getResidentKey(),
'userVerification' => $options->getAuthenticatorSelection()?->getUserVerification(),
],
'excludeCredentials' => $options->getExcludeCredentials(),
],
]);
}
/**
* Store new passkey
*/
public function register(Request $request): JsonResponse
{
$request->validate([
'id' => 'required|string',
'rawId' => 'required|string',
'type' => 'required|string|in:public-key',
'response.clientDataJSON' => 'required|string',
'response.attestationObject' => 'required|string',
'name' => 'nullable|string|max:255',
]);
try {
$credential = $this->webAuthn->verifyRegistration(
$request->user(),
$request->all(),
$request->input('name')
);
return response()->json([
'success' => true,
'credential' => [
'id' => $credential->id,
'name' => $credential->name,
'created_at' => $credential->created_at,
],
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 400);
}
}
/**
* List user's passkeys
*/
public function index(Request $request): JsonResponse
{
$credentials = $request->user()
->webauthnCredentials()
->orderBy('created_at', 'desc')
->get(['id', 'name', 'created_at', 'last_used_at']);
return response()->json([
'credentials' => $credentials,
]);
}
/**
* Delete a passkey
*/
public function destroy(Request $request, string $id): JsonResponse
{
$credential = $request->user()
->webauthnCredentials()
->findOrFail($id);
$credential->delete();
return response()->json([
'success' => true,
]);
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
Passkey Login Controller
// app/Http/Controllers/PasskeyLoginController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WebAuthnService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PasskeyLoginController extends Controller
{
public function __construct(
private WebAuthnService $webAuthn
) {}
/**
* Get authentication options
*/
public function options(Request $request): JsonResponse
{
$request->validate([
'email' => 'nullable|email',
]);
$user = null;
if ($request->has('email')) {
$user = User::where('email', $request->email)->first();
}
$options = $this->webAuthn->generateAuthenticationOptions($user);
return response()->json([
'publicKey' => [
'challenge' => $this->base64UrlEncode($options->getChallenge()),
'timeout' => $options->getTimeout(),
'rpId' => $options->getRpId(),
'allowCredentials' => collect($options->getAllowCredentials())->map(fn ($cred) => [
'type' => $cred['type'],
'id' => $this->base64UrlEncode($cred['id']),
'transports' => $cred['transports'] ?? [],
])->toArray(),
'userVerification' => $options->getUserVerification(),
],
]);
}
/**
* Authenticate with passkey
*/
public function authenticate(Request $request): JsonResponse
{
$request->validate([
'id' => 'required|string',
'rawId' => 'required|string',
'type' => 'required|string|in:public-key',
'response.clientDataJSON' => 'required|string',
'response.authenticatorData' => 'required|string',
'response.signature' => 'required|string',
'response.userHandle' => 'nullable|string',
]);
try {
$user = $this->webAuthn->verifyAuthentication($request->all());
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
return response()->json([
'success' => true,
'redirect' => route('dashboard'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 401);
}
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
Routes
// routes/web.php
use App\Http\Controllers\PasskeyController;
use App\Http\Controllers\PasskeyLoginController;
// Public routes for passkey login
Route::prefix('passkey')->group(function () {
Route::post('login/options', [PasskeyLoginController::class, 'options']);
Route::post('login', [PasskeyLoginController::class, 'authenticate']);
});
// Protected routes for managing passkeys
Route::middleware('auth')->prefix('passkey')->group(function () {
Route::get('/', [PasskeyController::class, 'index']);
Route::post('register/options', [PasskeyController::class, 'registerOptions']);
Route::post('register', [PasskeyController::class, 'register']);
Route::delete('{id}', [PasskeyController::class, 'destroy']);
});
Frontend Implementation
JavaScript Helper
// resources/js/passkey.js
class PasskeyManager {
constructor() {
this.supported = this.checkSupport();
}
checkSupport() {
return window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === 'function';
}
async checkPlatformAuthenticator() {
if (!this.supported) return false;
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
base64UrlDecode(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
return Uint8Array.from(binary, c => c.charCodeAt(0));
}
base64UrlEncode(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, '');
}
async register(name = null) {
if (!this.supported) {
throw new Error('WebAuthn is not supported in this browser');
}
// Get registration options from server
const optionsResponse = await fetch('/passkey/register/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
const { publicKey } = await optionsResponse.json();
// Convert base64url to ArrayBuffer
publicKey.challenge = this.base64UrlDecode(publicKey.challenge);
publicKey.user.id = this.base64UrlDecode(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
...cred,
id: this.base64UrlDecode(cred.id),
}));
}
// Create credential
const credential = await navigator.credentials.create({ publicKey });
// Send to server
const response = await fetch('/passkey/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
id: credential.id,
rawId: this.base64UrlEncode(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.base64UrlEncode(credential.response.clientDataJSON),
attestationObject: this.base64UrlEncode(credential.response.attestationObject),
},
name: name,
}),
});
return await response.json();
}
async authenticate(email = null) {
if (!this.supported) {
throw new Error('WebAuthn is not supported in this browser');
}
// Get authentication options from server
const optionsResponse = await fetch('/passkey/login/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ email }),
});
const { publicKey } = await optionsResponse.json();
// Convert base64url to ArrayBuffer
publicKey.challenge = this.base64UrlDecode(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => ({
...cred,
id: this.base64UrlDecode(cred.id),
}));
}
// Get credential
const credential = await navigator.credentials.get({ publicKey });
// Send to server
const response = await fetch('/passkey/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
id: credential.id,
rawId: this.base64UrlEncode(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.base64UrlEncode(credential.response.clientDataJSON),
authenticatorData: this.base64UrlEncode(credential.response.authenticatorData),
signature: this.base64UrlEncode(credential.response.signature),
userHandle: credential.response.userHandle
? this.base64UrlEncode(credential.response.userHandle)
: null,
},
}),
});
return await response.json();
}
}
// Export for use
window.PasskeyManager = PasskeyManager;
Login Component
<!-- resources/views/auth/login.blade.php -->
<div class="login-form">
<!-- Traditional login form -->
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" id="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
</div>
<button type="submit">Sign In</button>
</form>
<!-- Passkey login button -->
<div class="passkey-section" id="passkey-section" style="display: none;">
<div class="divider">or</div>
<button type="button" id="passkey-login" class="btn-passkey">
<svg><!-- Passkey icon --></svg>
Sign in with Passkey
</button>
</div>
</div>
<script src="{{ asset('js/passkey.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const passkey = new PasskeyManager();
// Check if passkeys are supported
if (passkey.supported && await passkey.checkPlatformAuthenticator()) {
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-login').addEventListener('click', async function() {
try {
const email = document.getElementById('email').value || null;
const result = await passkey.authenticate(email);
if (result.success) {
window.location.href = result.redirect;
} else {
alert('Authentication failed: ' + result.error);
}
} catch (error) {
console.error('Passkey authentication error:', error);
alert('Failed to authenticate with passkey');
}
});
}
});
</script>
Settings Page
<!-- resources/views/settings/passkeys.blade.php -->
<div class="passkey-settings">
<h2>Passkeys</h2>
<p>Passkeys are a more secure and convenient way to sign in.</p>
<div id="passkey-list">
<!-- Loaded via JavaScript -->
</div>
<button type="button" id="add-passkey" class="btn-primary">
Add Passkey
</button>
</div>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const passkey = new PasskeyManager();
async function loadPasskeys() {
const response = await fetch('/passkey');
const { credentials } = await response.json();
const list = document.getElementById('passkey-list');
list.innerHTML = credentials.map(cred => `
<div class="passkey-item" data-id="${cred.id}">
<div class="passkey-info">
<strong>${cred.name}</strong>
<span>Added: ${new Date(cred.created_at).toLocaleDateString()}</span>
${cred.last_used_at ? `<span>Last used: ${new Date(cred.last_used_at).toLocaleDateString()}</span>` : ''}
</div>
<button class="btn-delete" onclick="deletePasskey('${cred.id}')">Delete</button>
</div>
`).join('');
}
window.deletePasskey = async function(id) {
if (!confirm('Are you sure you want to delete this passkey?')) return;
await fetch(`/passkey/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
loadPasskeys();
};
document.getElementById('add-passkey').addEventListener('click', async function() {
try {
const name = prompt('Name for this passkey (optional):');
const result = await passkey.register(name);
if (result.success) {
alert('Passkey added successfully!');
loadPasskeys();
} else {
alert('Failed to add passkey: ' + result.error);
}
} catch (error) {
console.error('Registration error:', error);
alert('Failed to add passkey');
}
});
loadPasskeys();
});
</script>
Security Considerations
1. Challenge Validation
// Luôn validate challenge từ session
if (!hash_equals(
$storedChallenge,
$receivedChallenge
)) {
throw new SecurityException('Challenge mismatch');
}
2. Origin Validation
// Verify origin matches your domain
$clientData = json_decode($clientDataJSON, true);
$origin = $clientData['origin'];
if ($origin !== config('app.url')) {
throw new SecurityException('Invalid origin');
}
3. Counter Validation
// Prevent replay attacks
if ($newCounter <= $credential->counter) {
throw new SecurityException('Possible cloned authenticator detected');
}
Kết Luận
Passkeys mang lại:
- Bảo mật cao hơn: Không thể bị phishing hoặc stolen
- Trải nghiệm tốt hơn: Chỉ cần biometric, không cần nhớ password
- Tiêu chuẩn mở: Hoạt động trên mọi platform hỗ trợ WebAuthn
- Dễ triển khai: Các library có sẵn cho Laravel
Checklist Implementation
- Database migration cho credentials
- WebAuthn service implementation
- Registration flow (options + verify)
- Authentication flow (options + verify)
- Frontend JavaScript helper
- UI cho login và settings
- Counter validation để chống replay
- Fallback cho browsers không hỗ trợ