198 lines
8.0 KiB
PHP
198 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Models\WebauthnCredential;
|
|
use App\Support\Webauthn\ZeroTolerantCounterChecker;
|
|
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 rpId — never a bare IP. */
|
|
public function available(): bool
|
|
{
|
|
return $this->deployment->domain() !== null && request()->isSecure();
|
|
}
|
|
|
|
/** The Relying-Party ID = the active domain (asserted present by available()). */
|
|
public function rpId(): string
|
|
{
|
|
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_hash', hash('sha256', $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);
|
|
|
|
// Re-serialize the UPDATED record so the new sign counter is also embedded in
|
|
// public_key — otherwise the next assertion would validate against the stale
|
|
// registration-time counter and clone/rollback detection would never fire.
|
|
$updatedSource = $updated instanceof PublicKeyCredentialSource
|
|
? $updated
|
|
: PublicKeyCredentialSource::fromCredentialRecord($updated);
|
|
|
|
$model->update([
|
|
'public_key' => $serializer->serialize($updatedSource, 'json'),
|
|
'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 register ES256+RS256 + NoneAttestation; constrain the origin to
|
|
// https://<rpId> and swap the strict counter checker for a zero-tolerant one
|
|
// (platform authenticators / passkeys always report counter 0).
|
|
$factory = new CeremonyStepManagerFactory;
|
|
$factory->setAllowedOrigins(['https://'.$this->rpId()]);
|
|
$factory->setCounterChecker(new ZeroTolerantCounterChecker);
|
|
|
|
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, '-_', '+/'));
|
|
}
|
|
}
|