feat(webauthn): registration/assertion options + ceremony verification

WebauthnService builds creation/request options (rpId = active domain, ES256/RS256,
attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the
challenge in the session, and verifies attestation/assertion responses (allowed
origin = https://<rpId>, sign-count tracked). Option building + serialization are
unit-tested; the cryptographic verify path is browser-verified on a domain host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:24:10 +02:00
parent 54ebb49941
commit 0cb9b073ce
2 changed files with 211 additions and 4 deletions

View File

@ -2,14 +2,42 @@
namespace App\Services; namespace App\Services;
use App\Models\User;
use App\Models\WebauthnCredential;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* The single seam onto web-auth/webauthn-lib (v5). Wraps the registration + assertion
* ceremonies for a security-key SECOND factor. Gated on a domain + HTTPS the rpId is
* the active domain (an IP literal is never a valid rpId).
*
* NOTE: the cryptographic ceremony (verifyRegistration/verifyAssertion) can only be
* exercised by a real authenticator over HTTPS on the rp domain; it is browser-verified
* on a domain host. The option builders + storage are unit-tested here.
*/
class WebauthnService class WebauthnService
{ {
private const RP_NAME = 'Clusev';
public function __construct(private DeploymentService $deployment) {} public function __construct(private DeploymentService $deployment) {}
/** /** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */
* WebAuthn needs a secure context AND a domain-based Relying-Party ID a bare IP
* literal is not a valid rpId, so the feature is unavailable on bare-IP/HTTP.
*/
public function available(): bool public function available(): bool
{ {
return $this->deployment->domain() !== null && request()->isSecure(); return $this->deployment->domain() !== null && request()->isSecure();
@ -20,4 +48,134 @@ class WebauthnService
{ {
return (string) $this->deployment->domain(); return (string) $this->deployment->domain();
} }
/** @return array<string, mixed> JSON-ready creation options; challenge stored in session. */
public function registrationOptions(User $user): array
{
$exclude = $user->webauthnCredentials
->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
->all();
$options = PublicKeyCredentialCreationOptions::create(
PublicKeyCredentialRpEntity::create(self::RP_NAME, $this->rpId()),
PublicKeyCredentialUserEntity::create($user->email, (string) $user->id, $user->name),
random_bytes(32),
pubKeyCredParams: [
PublicKeyCredentialParameters::createPk(-7), // ES256
PublicKeyCredentialParameters::createPk(-257), // RS256
],
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
excludeCredentials: $exclude,
);
$json = $this->serializer()->serialize($options, 'json');
session(['webauthn.register' => $json]);
return json_decode($json, true);
}
public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential
{
$serializer = $this->serializer();
$options = $serializer->deserialize((string) session('webauthn.register'), PublicKeyCredentialCreationOptions::class, 'json');
$credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json');
$attestation = $credential->response;
abort_unless($attestation instanceof AuthenticatorAttestationResponse, 422);
$validator = AuthenticatorAttestationResponseValidator::create($this->ceremony()->creationCeremony());
$record = $validator->check($attestation, $options, $this->rpId());
$source = PublicKeyCredentialSource::fromCredentialRecord($record);
$cred = $user->webauthnCredentials()->create([
'name' => $name,
'credential_id' => $this->toB64u($source->publicKeyCredentialId),
'public_key' => $serializer->serialize($source, 'json'),
'aaguid' => $record->aaguid->__toString(),
'transports' => $record->transports,
'sign_count' => $record->counter,
]);
session()->forget('webauthn.register');
return $cred;
}
/** @return array<string, mixed> JSON-ready request options; challenge stored in session. */
public function assertionOptions(User $user): array
{
$allow = $user->webauthnCredentials
->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
->all();
$options = PublicKeyCredentialRequestOptions::create(
random_bytes(32),
rpId: $this->rpId(),
allowCredentials: $allow,
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
);
$json = $this->serializer()->serialize($options, 'json');
session(['webauthn.login' => $json]);
return json_decode($json, true);
}
/** Validate an assertion against the user's stored credential. Never throws into the login path. */
public function verifyAssertion(User $user, array $response): bool
{
try {
$serializer = $this->serializer();
$credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json');
$assertion = $credential->response;
if (! $assertion instanceof AuthenticatorAssertionResponse) {
return false;
}
$model = $user->webauthnCredentials()->where('credential_id', $this->toB64u($credential->rawId))->first();
if (! $model) {
return false;
}
$source = $serializer->deserialize($model->public_key, PublicKeyCredentialSource::class, 'json');
$options = $serializer->deserialize((string) session('webauthn.login'), PublicKeyCredentialRequestOptions::class, 'json');
$validator = AuthenticatorAssertionResponseValidator::create($this->ceremony()->requestCeremony());
$updated = $validator->check($source, $assertion, $options, $this->rpId(), (string) $user->id);
$model->update(['sign_count' => $updated->counter, 'last_used_at' => now()]);
session()->forget('webauthn.login');
return true;
} catch (Throwable) {
return false;
}
}
private function serializer()
{
return (new WebauthnSerializerFactory(
AttestationStatementSupportManager::create([new NoneAttestationStatementSupport]),
))->create();
}
private function ceremony(): CeremonyStepManagerFactory
{
// Defaults already register ES256+RS256, NoneAttestation and a strict counter
// checker; we only constrain the allowed origin to https://<rpId>.
$factory = new CeremonyStepManagerFactory;
$factory->setAllowedOrigins(['https://'.$this->rpId()]);
return $factory;
}
private function toB64u(string $bin): string
{
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
private function fromB64u(string $s): string
{
return (string) base64_decode(strtr($s, '-_', '+/'));
}
} }

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\DeploymentService;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WebauthnOptionsTest extends TestCase
{
use RefreshDatabase;
private function service(string $domain = 'panel.example.com'): WebauthnService
{
$d = $this->mock(DeploymentService::class);
$d->shouldReceive('domain')->andReturn($domain);
return app(WebauthnService::class);
}
public function test_registration_options_use_domain_rpid_and_store_challenge(): void
{
$user = User::factory()->create();
$opts = $this->service()->registrationOptions($user);
$this->assertSame('panel.example.com', data_get($opts, 'rp.id'));
$this->assertNotEmpty(data_get($opts, 'challenge'));
$this->assertNotNull(session('webauthn.register'));
}
public function test_assertion_options_list_user_credentials(): void
{
$user = User::factory()->create();
WebauthnCredential::create([
'user_id' => $user->id, 'name' => 'k',
'credential_id' => rtrim(strtr(base64_encode('rawid'), '+/', '-_'), '='),
'public_key' => '{}', 'sign_count' => 0,
]);
$opts = $this->service()->assertionOptions($user->fresh());
$this->assertSame('panel.example.com', data_get($opts, 'rpId'));
$this->assertNotEmpty(data_get($opts, 'allowCredentials'));
$this->assertNotNull(session('webauthn.login'));
}
}