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
parent
54ebb49941
commit
0cb9b073ce
|
|
@ -2,14 +2,42 @@
|
|||
|
||||
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
|
||||
{
|
||||
private const RP_NAME = 'Clusev';
|
||||
|
||||
public function __construct(private DeploymentService $deployment) {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->deployment->domain() !== null && request()->isSecure();
|
||||
|
|
@ -20,4 +48,134 @@ class WebauthnService
|
|||
{
|
||||
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, '-_', '+/'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue