First release
This commit is contained in:
36
src/Assertion/Creator/AssertionCreation.php
Normal file
36
src/Assertion/Creator/AssertionCreation.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\JsonTransport;
|
||||
|
||||
class AssertionCreation
|
||||
{
|
||||
/**
|
||||
* The Json Transport helper to build the message.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\JsonTransport
|
||||
*/
|
||||
public JsonTransport $json;
|
||||
|
||||
/**
|
||||
* Create a new Assertion Creation instance.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @param \Illuminate\Database\Eloquent\Collection|null $acceptedCredentials
|
||||
* @param string|null $userVerification
|
||||
*/
|
||||
public function __construct(
|
||||
public Request $request,
|
||||
public ?WebAuthnAuthenticatable $user = null,
|
||||
public ?Collection $acceptedCredentials = null,
|
||||
public ?string $userVerification = null,
|
||||
)
|
||||
{
|
||||
$this->json = new JsonTransport();
|
||||
}
|
||||
}
|
||||
23
src/Assertion/Creator/AssertionCreator.php
Normal file
23
src/Assertion/Creator/AssertionCreator.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
|
||||
*/
|
||||
class AssertionCreator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\AddConfiguration::class,
|
||||
Pipes\MayRetrieveCredentialsIdForUser::class,
|
||||
Pipes\MayRequireUserVerification::class,
|
||||
Pipes\CreateAssertionChallenge::class,
|
||||
];
|
||||
}
|
||||
34
src/Assertion/Creator/Pipes/AddConfiguration.php
Normal file
34
src/Assertion/Creator/Pipes/AddConfiguration.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
|
||||
class AddConfiguration
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
$assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
45
src/Assertion/Creator/Pipes/CreateAssertionChallenge.php
Normal file
45
src/Assertion/Creator/Pipes/CreateAssertionChallenge.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Attestation\SessionChallenge;
|
||||
|
||||
class CreateAssertionChallenge
|
||||
{
|
||||
use SessionChallenge;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
$options = [];
|
||||
|
||||
if ($assertion->acceptedCredentials?->isNotEmpty()) {
|
||||
$options['credentials'] = $assertion->acceptedCredentials->map->getKey()->toArray();
|
||||
}
|
||||
|
||||
$challenge = $this->storeChallenge($assertion->request, $assertion->userVerification, $options);
|
||||
|
||||
$assertion->json->set('challenge', $challenge->data);
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
25
src/Assertion/Creator/Pipes/MayRequireUserVerification.php
Normal file
25
src/Assertion/Creator/Pipes/MayRequireUserVerification.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
|
||||
class MayRequireUserVerification
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
if ($assertion->userVerification) {
|
||||
$assertion->json->set('userVerification', $assertion->userVerification);
|
||||
}
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function array_filter;
|
||||
|
||||
class MayRetrieveCredentialsIdForUser
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
// If there is a user found, we will pluck the IDS and add them as a binary buffer.
|
||||
if ($assertion->user) {
|
||||
$assertion->acceptedCredentials = $assertion->user->webAuthnCredentials()->get(['id', 'transports']);
|
||||
|
||||
if ($assertion->acceptedCredentials->isNotEmpty()) {
|
||||
$assertion->json->set('allowCredentials', $this->parseCredentials($assertion->acceptedCredentials));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt all credentials into an `allowCredentials` digestible array.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $credentials
|
||||
* @return \Illuminate\Support\Collection<int, array>
|
||||
*/
|
||||
protected function parseCredentials(EloquentCollection $credentials): Collection
|
||||
{
|
||||
return $credentials->map(static function (WebAuthnCredential $credential): array {
|
||||
return array_filter([
|
||||
'id' => $credential->getKey(),
|
||||
'type' => 'public-key',
|
||||
'transports' => $credential->transports
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/Assertion/Validator/AssertionValidation.php
Normal file
35
src/Assertion/Validator/AssertionValidation.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class AssertionValidation
|
||||
{
|
||||
/**
|
||||
* Create a new Assertion Validation.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @param \Laragear\WebAuthn\Challenge|null $challenge
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential
|
||||
* @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData|null $authenticatorData
|
||||
*/
|
||||
public function __construct(
|
||||
public Request $request,
|
||||
public ?WebAuthnAuthenticatable $user = null,
|
||||
public ?Challenge $challenge = null,
|
||||
public ?WebAuthnCredential $credential = null,
|
||||
public ?ClientDataJson $clientDataJson = null,
|
||||
public ?AuthenticatorData $authenticatorData = null,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
34
src/Assertion/Validator/AssertionValidator.php
Normal file
34
src/Assertion/Validator/AssertionValidator.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Assertion\Validator\AssertionValidation thenReturn()
|
||||
*/
|
||||
class AssertionValidator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\RetrieveChallenge::class,
|
||||
Pipes\RetrievesCredentialId::class,
|
||||
Pipes\CheckCredentialIsForUser::class,
|
||||
Pipes\CheckTypeIsPublicKey::class,
|
||||
Pipes\CompileAuthenticatorData::class,
|
||||
Pipes\CompileClientDataJson::class,
|
||||
Pipes\CheckCredentialIsWebAuthnGet::class,
|
||||
Pipes\CheckChallengeSame::class,
|
||||
Pipes\CheckOriginSecure::class,
|
||||
Pipes\CheckRelyingPartyIdContained::class,
|
||||
Pipes\CheckRelyingPartyHashSame::class,
|
||||
Pipes\CheckUserInteraction::class,
|
||||
Pipes\CheckPublicKeySignature::class,
|
||||
Pipes\CheckPublicKeyCounterCorrect::class,
|
||||
Pipes\IncrementCredentialCounter::class,
|
||||
];
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CheckChallengeSame.php
Normal file
13
src/Assertion/Validator/Pipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckChallengeSame as BaseChallengeSame;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckChallengeSame extends BaseChallengeSame
|
||||
{
|
||||
//
|
||||
}
|
||||
79
src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php
Normal file
79
src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use function hash_equals;
|
||||
|
||||
/**
|
||||
* 6. Identify the user being authenticated and verify that this user is the owner of the public
|
||||
* key credential source credentialSource identified by credential.id:
|
||||
*
|
||||
* - If the user was identified before the authentication ceremony was initiated, e.g., via a
|
||||
* username or cookie, verify that the identified user is the owner of credentialSource. If
|
||||
* response.userHandle is present, let userHandle be its value. Verify that userHandle also
|
||||
* maps to the same user.
|
||||
*
|
||||
* - If the user was not identified before the authentication ceremony was initiated, verify
|
||||
* that response.userHandle is present, and that the user identified by this value is the
|
||||
* owner of credentialSource.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckCredentialIsForUser
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->user) {
|
||||
$this->validateUser($validation);
|
||||
|
||||
if ($validation->request->json('response.userHandle')) {
|
||||
$this->validateId($validation);
|
||||
}
|
||||
} else {
|
||||
$this->validateId($validation);
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user owns the Credential if it already exists in the validation procedure.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return void
|
||||
*/
|
||||
protected function validateUser(AssertionValidation $validation): void
|
||||
{
|
||||
if ($validation->credential->authenticatable()->isNot($validation->user)) {
|
||||
throw AssertionException::make('User is not owner of the stored credential.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user ID of the response.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return void
|
||||
*/
|
||||
protected function validateId(AssertionValidation $validation): void
|
||||
{
|
||||
$handle = $validation->request->json('response.userHandle');
|
||||
|
||||
if (! $handle || ! hash_equals(Uuid::fromString($validation->credential->user_id)->getHex()->toString(), $handle)) {
|
||||
throw AssertionException::make('User ID is not owner of the stored credential.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckCredentialIsWebAuthnGet
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->type !== 'webauthn.get') {
|
||||
throw AssertionException::make('Client Data type is not [webauthn.get].');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
15
src/Assertion/Validator/Pipes/CheckOriginSecure.php
Normal file
15
src/Assertion/Validator/Pipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
|
||||
|
||||
/**
|
||||
* 9. Verify that the value of C.origin matches the Relying Party's origin.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckOriginSecure extends BaseCheckOriginSame
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Events\CredentialCloned;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
|
||||
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
|
||||
*
|
||||
* - If authData.signCount
|
||||
* -> is greater than storedSignCount:
|
||||
* Update storedSignCount to be the value of authData.signCount.
|
||||
* -> less than or equal to storedSignCount:
|
||||
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
|
||||
* credential private key may exist and are being used in parallel. Relying Parties
|
||||
* should incorporate this information into their risk scoring. Whether the Relying
|
||||
* Party updates storedSignCount in this case, or not, or fails the authentication
|
||||
* ceremony or not, is Relying Party-specific.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckPublicKeyCounterCorrect
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($this->hasCounter($validation) && $this->counterBelowStoredCredential($validation)) {
|
||||
$validation->credential->disable();
|
||||
|
||||
CredentialCloned::dispatch($validation->credential, $validation->authenticatorData->counter);
|
||||
|
||||
throw AssertionException::make('Credential counter not over stored counter.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the incoming credential or the stored credential have a counter.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasCounter(AssertionValidation $validation): bool
|
||||
{
|
||||
return $validation->credential->counter
|
||||
|| $validation->authenticatorData->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential counter is equal or higher than what the authenticator reports.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function counterBelowStoredCredential(AssertionValidation $validation): bool
|
||||
{
|
||||
return $validation->authenticatorData->counter <= $validation->credential->counter;
|
||||
}
|
||||
}
|
||||
68
src/Assertion/Validator/Pipes/CheckPublicKeySignature.php
Normal file
68
src/Assertion/Validator/Pipes/CheckPublicKeySignature.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use OpenSSLAsymmetricKey;
|
||||
use function base64_decode;
|
||||
use function hash;
|
||||
use function openssl_pkey_get_public;
|
||||
use function openssl_verify;
|
||||
use const OPENSSL_ALGO_SHA256;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckPublicKeySignature
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$publicKey = openssl_pkey_get_public($validation->credential->public_key);
|
||||
|
||||
if (!$publicKey) {
|
||||
throw AssertionException::make('Stored Public Key is invalid.');
|
||||
}
|
||||
|
||||
$signature = base64_decode($validation->request->json('response.signature', ''));
|
||||
|
||||
if (!$signature) {
|
||||
throw AssertionException::make('Signature is empty.');
|
||||
}
|
||||
|
||||
$this->validateSignature($validation, $publicKey, $signature);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the signature from the assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param string $signature
|
||||
* @param \OpenSSLAsymmetricKey $publicKey
|
||||
* @return void
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function validateSignature(
|
||||
AssertionValidation $validation,
|
||||
OpenSSLAsymmetricKey $publicKey,
|
||||
string $signature
|
||||
): void {
|
||||
$verifiable = base64_decode($validation->request->json('response.authenticatorData'))
|
||||
.hash('sha256', base64_decode($validation->request->json('response.clientDataJSON')), true);
|
||||
|
||||
if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
|
||||
throw AssertionException::make('Signature is invalid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php
Normal file
36
src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyHashSame as BaseCheckRelyingPartyHashSame;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
|
||||
{
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData
|
||||
{
|
||||
return $validation->authenticatorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
|
||||
{
|
||||
return $validation->credential->rp_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyIdContained as BaseCheckRelyingPartyIdContained;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyIdContained extends BaseCheckRelyingPartyIdContained
|
||||
{
|
||||
//
|
||||
}
|
||||
30
src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php
Normal file
30
src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckTypeIsPublicKey
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->request->json('type') !== 'public-key') {
|
||||
throw AssertionException::make('Response type is not [public-key].');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CheckUserInteraction.php
Normal file
13
src/Assertion/Validator/Pipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckUserInteraction as BaseCheckUserInteraction;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckUserInteraction extends BaseCheckUserInteraction
|
||||
{
|
||||
//
|
||||
}
|
||||
41
src/Assertion/Validator/Pipes/CompileAuthenticatorData.php
Normal file
41
src/Assertion/Validator/Pipes/CompileAuthenticatorData.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_decode;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CompileAuthenticatorData
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$data = base64_decode($validation->request->json('response.authenticatorData', ''));
|
||||
|
||||
if (!$data) {
|
||||
throw AssertionException::make('Authenticator Data does not exist or is empty.');
|
||||
}
|
||||
|
||||
try {
|
||||
$validation->authenticatorData = AuthenticatorData::fromBinary($data);
|
||||
} catch (DataException $e) {
|
||||
throw AssertionException::make($e->getMessage());
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CompileClientDataJson.php
Normal file
13
src/Assertion/Validator/Pipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CompileClientDataJson as BaseCompileClientDataJson;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CompileClientDataJson extends BaseCompileClientDataJson
|
||||
{
|
||||
//
|
||||
}
|
||||
39
src/Assertion/Validator/Pipes/IncrementCredentialCounter.php
Normal file
39
src/Assertion/Validator/Pipes/IncrementCredentialCounter.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
|
||||
/**
|
||||
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
|
||||
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
|
||||
*
|
||||
* - If authData.signCount
|
||||
* -> is greater than storedSignCount:
|
||||
* Update storedSignCount to be the value of authData.signCount.
|
||||
* -> less than or equal to storedSignCount:
|
||||
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
|
||||
* credential private key may exist and are being used in parallel. Relying Parties
|
||||
* should incorporate this information into their risk scoring. Whether the Relying
|
||||
* Party updates storedSignCount in this case, or not, or fails the authentication
|
||||
* ceremony or not, is Relying Party-specific.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class IncrementCredentialCounter
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->credential->syncCounter($validation->authenticatorData->counter);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/RetrieveChallenge.php
Normal file
13
src/Assertion/Validator/Pipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\RetrieveChallenge as BaseRetrieveChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrieveChallenge extends BaseRetrieveChallenge
|
||||
{
|
||||
///
|
||||
}
|
||||
58
src/Assertion/Validator/Pipes/RetrievesCredentialId.php
Normal file
58
src/Assertion/Validator/Pipes/RetrievesCredentialId.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrievesCredentialId
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$id = $validation->request->json('id');
|
||||
|
||||
// First, always check the challenge credentials before finding the real one.
|
||||
if ($this->credentialNotInChallenge($id, $validation->challenge->properties)) {
|
||||
throw AssertionException::make('Credential is not on accepted list.');
|
||||
}
|
||||
|
||||
// We can now find the credential.
|
||||
$validation->credential = WebAuthnCredential::whereKey($id)->first();
|
||||
|
||||
if (!$validation->credential) {
|
||||
throw AssertionException::make('Credential ID does not exist.');
|
||||
}
|
||||
|
||||
if ($validation->credential->isDisabled()) {
|
||||
throw AssertionException::make('Credential ID is blacklisted.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the previous Assertion request specified a credentials list to accept.
|
||||
*
|
||||
* @param string $id
|
||||
* @param array $properties
|
||||
* @return bool
|
||||
*/
|
||||
protected function credentialNotInChallenge(string $id, array $properties): bool
|
||||
{
|
||||
return isset($properties['credentials']) && ! in_array($id, $properties['credentials'], true);
|
||||
}
|
||||
}
|
||||
26
src/Attestation/AttestationObject.php
Normal file
26
src/Attestation/AttestationObject.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Laragear\WebAuthn\Attestation\Formats\Format;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AttestationObject
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Object.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
|
||||
* @param \Laragear\WebAuthn\Attestation\Formats\Format $format
|
||||
* @param string $formatName
|
||||
*/
|
||||
public function __construct(
|
||||
public AuthenticatorData $authenticatorData,
|
||||
public Format $format,
|
||||
public string $formatName)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
530
src/Attestation/AuthenticatorData.php
Normal file
530
src/Attestation/AuthenticatorData.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\CborDecoder;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_encode;
|
||||
use function chr;
|
||||
use function chunk_split;
|
||||
use function intdiv;
|
||||
use function is_array;
|
||||
use function ord;
|
||||
use function strlen;
|
||||
use function substr;
|
||||
use function unpack;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @internal
|
||||
*
|
||||
* DER = Distinguished Encoding Rules;
|
||||
* PEM = Privacy Enhanced Mail, basically BASE64 encoded DER.
|
||||
*/
|
||||
class AuthenticatorData
|
||||
{
|
||||
// COSE encoded keys
|
||||
protected static int $COSE_KTY = 1;
|
||||
protected static int $COSE_ALG = 3;
|
||||
|
||||
// COSE EC2 ES256 P-256 curve
|
||||
protected static int $COSE_CRV = -1;
|
||||
protected static int $COSE_X = -2;
|
||||
protected static int $COSE_Y = -3;
|
||||
|
||||
// COSE RSA PS256
|
||||
protected static int $COSE_N = -1;
|
||||
protected static int $COSE_E = -2;
|
||||
|
||||
protected static int $EC2_TYPE = 2;
|
||||
protected static int $EC2_ES256 = -7;
|
||||
protected static int $EC2_P256 = 1;
|
||||
|
||||
protected static int $RSA_TYPE = 3;
|
||||
protected static int $RSA_RS256 = -257;
|
||||
|
||||
/**
|
||||
* Creates a new Authenticator Data instance from a binary string.
|
||||
*
|
||||
* @param string $relyingPartyIdHash
|
||||
* @param object $flags
|
||||
* @param int $counter
|
||||
* @param object{aaguid: int|bool, credentialId: string, credentialPublicKey: string} $attestedCredentialData
|
||||
* @param array $extensionData
|
||||
*/
|
||||
public function __construct(
|
||||
public string $relyingPartyIdHash,
|
||||
public object $flags,
|
||||
public int $counter,
|
||||
public object $attestedCredentialData,
|
||||
public array $extensionData,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the Relying Party ID hash is the same as the one issued.
|
||||
*
|
||||
* @param string $relyingPartyId
|
||||
* @param bool $hash
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
|
||||
{
|
||||
if ($hash) {
|
||||
$relyingPartyId = hash('sha256', $relyingPartyId, true);
|
||||
}
|
||||
|
||||
return hash_equals($relyingPartyId, $this->relyingPartyIdHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Relying Party ID hash is not the same as the one issued.
|
||||
*
|
||||
* @param string $relyingPartyId
|
||||
* @param bool $hash
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNotSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
|
||||
{
|
||||
return ! $this->hasSameRPIdHash($relyingPartyId, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was present during the authentication.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserPresent(): bool
|
||||
{
|
||||
return $this->flags->userPresent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was absent during the authentication.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserAbsent(): bool
|
||||
{
|
||||
return ! $this->wasUserPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was actively verified by the authenticator.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserVerified(): bool
|
||||
{
|
||||
return $this->flags->userVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was not actively verified by the authenticator.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserNotVerified(): bool
|
||||
{
|
||||
return ! $this->wasUserVerified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key in PEM format.
|
||||
*
|
||||
* @return string
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public function getPublicKeyPem(): string
|
||||
{
|
||||
$der = match ($this->attestedCredentialData->credentialPublicKey->kty) {
|
||||
self::$EC2_TYPE => $this->getEc2Der(),
|
||||
self::$RSA_TYPE => $this->getRsaDer(),
|
||||
default => throw new DataException('Invalid credential public key type [kty].'),
|
||||
};
|
||||
|
||||
$pem = '-----BEGIN PUBLIC KEY-----'."\n";
|
||||
$pem .= chunk_split(base64_encode($der), 64, "\n");
|
||||
$pem .= '-----END PUBLIC KEY-----'."\n";
|
||||
|
||||
return $pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key in U2F format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPublicKeyU2F(): string
|
||||
{
|
||||
return "\x04". // ECC uncompressed
|
||||
$this->attestedCredentialData->credentialPublicKey->x.
|
||||
$this->attestedCredentialData->credentialPublicKey->y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns DER encoded EC2 key
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getEc2Der(): string
|
||||
{
|
||||
return $this->derSequence(
|
||||
$this->derSequence(
|
||||
$this->derOid("\x2A\x86\x48\xCE\x3D\x02\x01"). // OID 1.2.840.10045.2.1 ecPublicKey
|
||||
$this->derOid("\x2A\x86\x48\xCE\x3D\x03\x01\x07") // 1.2.840.10045.3.1.7 prime256v1
|
||||
).
|
||||
$this->derBitString($this->getPublicKeyU2F())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns DER encoded RSA key.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getRsaDer(): string
|
||||
{
|
||||
return $this->derSequence(
|
||||
$this->derSequence(
|
||||
$this->derOid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01"). // OID 1.2.840.113549.1.1.1 rsaEncryption
|
||||
$this->derNullValue()
|
||||
).
|
||||
$this->derBitString(
|
||||
$this->derSequence(
|
||||
$this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->n).
|
||||
$this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->e)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of a DER encoded string.
|
||||
*
|
||||
* @param int $der
|
||||
* @return string
|
||||
*/
|
||||
protected function derLength(int $der): string
|
||||
{
|
||||
if ($der < 128) {
|
||||
return chr($der);
|
||||
}
|
||||
|
||||
$lenBytes = '';
|
||||
|
||||
while ($der > 0) {
|
||||
$lenBytes = chr($der % 256).$lenBytes;
|
||||
$der = intdiv($der, 256);
|
||||
}
|
||||
|
||||
return chr(0x80 | strlen($lenBytes)).$lenBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string as DER.
|
||||
*
|
||||
* @param string $contents
|
||||
* @return string
|
||||
*/
|
||||
protected function derSequence(string $contents): string
|
||||
{
|
||||
return "\x30".$this->derLength(strlen($contents)).$contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode something an ID of zero as DER.
|
||||
*
|
||||
* @param string $encoded
|
||||
* @return string
|
||||
*/
|
||||
protected function derOid(string $encoded): string
|
||||
{
|
||||
return "\x06".$this->derLength(strlen($encoded)).$encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the bit string as DER.
|
||||
*
|
||||
* @param string $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function derBitString(string $bytes): string
|
||||
{
|
||||
return "\x03".$this->derLength(strlen($bytes) + 1)."\x00".$bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a null value as DER.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function derNullValue(): string
|
||||
{
|
||||
return "\x05\x00";
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a unsigned integer as DER.
|
||||
*
|
||||
* @param string $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function derUnsignedInteger(string $bytes): string
|
||||
{
|
||||
$len = strlen($bytes);
|
||||
|
||||
// Remove leading zero bytes
|
||||
for ($i = 0; $i < ($len - 1); $i++) {
|
||||
if (ord($bytes[$i]) !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($i !== 0) {
|
||||
$bytes = substr($bytes, $i);
|
||||
}
|
||||
|
||||
// If most significant bit is set, prefix with another zero to prevent it being seen as negative number
|
||||
if ((ord($bytes[0]) & 0x80) !== 0) {
|
||||
$bytes = "\x00".$bytes;
|
||||
}
|
||||
|
||||
return "\x02".$this->derLength(strlen($bytes)).$bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Authenticator data from a binary string.
|
||||
*
|
||||
* @param string $binary
|
||||
* @return static
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public static function fromBinary(string $binary): static
|
||||
{
|
||||
if (strlen($binary) < 37) {
|
||||
throw new DataException('Authenticator Data: Invalid input.');
|
||||
}
|
||||
|
||||
$relyingPartyIdHash = substr($binary, 0, 32);
|
||||
|
||||
// flags (1 byte)
|
||||
$flags = static::readFlags(unpack('Cflags', $binary[32])['flags']);
|
||||
|
||||
// signature counter: 32-bit unsigned big-endian integer.
|
||||
$counter = unpack('Nsigncount', substr($binary, 33, 4))['signcount'];
|
||||
|
||||
$offset = 37;
|
||||
|
||||
$attestedCredentialData = $flags->attestedDataIncluded
|
||||
? static::readAttestData($binary, $offset)
|
||||
: (object) null;
|
||||
|
||||
$extensionData = $flags->extensionDataIncluded
|
||||
? static::readExtensionData(substr($binary, $offset))
|
||||
: [];
|
||||
|
||||
return new static($relyingPartyIdHash, $flags, $counter, $attestedCredentialData, $extensionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the flags from flag byte array.
|
||||
*
|
||||
* @param string $binFlag
|
||||
* @return object{userPresent: bool, userVerified: bool, attestedDataIncluded: bool, extensionDataIncluded: bool}
|
||||
*/
|
||||
protected static function readFlags(string $binFlag): object
|
||||
{
|
||||
$flags = (object) [
|
||||
'bit_0' => (bool) ($binFlag & 1),
|
||||
'bit_1' => (bool) ($binFlag & 2),
|
||||
'bit_2' => (bool) ($binFlag & 4),
|
||||
'bit_3' => (bool) ($binFlag & 8),
|
||||
'bit_4' => (bool) ($binFlag & 16),
|
||||
'bit_5' => (bool) ($binFlag & 32),
|
||||
'bit_6' => (bool) ($binFlag & 64),
|
||||
'bit_7' => (bool) ($binFlag & 128),
|
||||
'userPresent' => false,
|
||||
'userVerified' => false,
|
||||
'attestedDataIncluded' => false,
|
||||
'extensionDataIncluded' => false,
|
||||
];
|
||||
|
||||
// named flags
|
||||
$flags->userPresent = $flags->bit_0;
|
||||
$flags->userVerified = $flags->bit_2;
|
||||
$flags->attestedDataIncluded = $flags->bit_6;
|
||||
$flags->extensionDataIncluded = $flags->bit_7;
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attestation data.
|
||||
*
|
||||
* @param string $binary
|
||||
* @param int $endOffset
|
||||
* @return object{aaguid: int|bool, credentialId: string, credentialPublicKey: string}
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readAttestData(string $binary, int &$endOffset): object
|
||||
{
|
||||
if (strlen($binary) <= 55) {
|
||||
throw new DataException('Attested data is missing');
|
||||
}
|
||||
|
||||
// Byte length L of Credential ID, 16-bit unsigned big-endian integer.
|
||||
$length = unpack('nlength', substr($binary, 53, 2))['length'];
|
||||
|
||||
// Set end offset
|
||||
$endOffset = 55 + $length;
|
||||
|
||||
return (object) [
|
||||
'aaguid' => substr($binary, 37, 16),
|
||||
'credentialId' => new ByteBuffer(substr($binary, 55, $length)),
|
||||
'credentialPublicKey' => static::readCredentialPublicKey($binary, 55 + $length, $endOffset)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read COSE key-encoded elliptic curve public key in EC2 format.
|
||||
*
|
||||
* @param string $binary
|
||||
* @param int $offset
|
||||
* @param int $endOffset
|
||||
* @return object
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKey(string $binary, int $offset, int &$endOffset): object
|
||||
{
|
||||
$enc = CborDecoder::decodePortion($binary, $offset, $endOffset);
|
||||
|
||||
// COSE key-encoded elliptic curve public key in EC2 format
|
||||
$publicKey = (object) [
|
||||
'kty' => $enc[static::$COSE_KTY],
|
||||
'alg' => $enc[static::$COSE_ALG]
|
||||
];
|
||||
|
||||
switch ($publicKey->alg) {
|
||||
case static::$EC2_ES256:
|
||||
static::readCredentialPublicKeyES256($publicKey, $enc);
|
||||
break;
|
||||
case static::$RSA_RS256:
|
||||
static::readCredentialPublicKeyRS256($publicKey, $enc);
|
||||
break;
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts ES256 information from COSE encoding.
|
||||
*
|
||||
* @param object $publicKey
|
||||
* @param array $cose
|
||||
* @return object
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKeyES256(object $publicKey, array $cose): object
|
||||
{
|
||||
$publicKey->crv = $cose[self::$COSE_CRV];
|
||||
$publicKey->x = $cose[self::$COSE_X] instanceof ByteBuffer ? $cose[self::$COSE_X]->getBinaryString() : null;
|
||||
$publicKey->y = $cose[self::$COSE_Y] instanceof ByteBuffer ? $cose[self::$COSE_Y]->getBinaryString() : null;
|
||||
|
||||
if ($publicKey->kty !== self::$EC2_TYPE) {
|
||||
throw new DataException('Public key not in EC2 format');
|
||||
}
|
||||
|
||||
if ($publicKey->alg !== self::$EC2_ES256) {
|
||||
throw new DataException('Signature algorithm not ES256');
|
||||
}
|
||||
|
||||
if ($publicKey->crv !== self::$EC2_P256) {
|
||||
throw new DataException('Curve not P-256');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->x) !== 32) {
|
||||
throw new DataException('Invalid X-coordinate');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->y) !== 32) {
|
||||
throw new DataException('Invalid Y-coordinate');
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract RS256 information from COSE.
|
||||
*
|
||||
* @param object $publicKey
|
||||
* @param array $enc
|
||||
* @return void
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKeyRS256(object $publicKey, array $enc): void
|
||||
{
|
||||
$publicKey->n = $enc[self::$COSE_N] instanceof ByteBuffer ? $enc[self::$COSE_N]->getBinaryString() : null;
|
||||
$publicKey->e = $enc[self::$COSE_E] instanceof ByteBuffer ? $enc[self::$COSE_E]->getBinaryString() : null;
|
||||
|
||||
if ($publicKey->kty !== self::$RSA_TYPE) {
|
||||
throw new DataException('Public key not in RSA format');
|
||||
}
|
||||
|
||||
if ($publicKey->alg !== self::$RSA_RS256) {
|
||||
throw new DataException('Signature algorithm not ES256');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->n) !== 256) {
|
||||
throw new DataException('Invalid RSA modulus');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->e) !== 3) {
|
||||
throw new DataException('Invalid RSA public exponent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads CBOR encoded extension data.
|
||||
*
|
||||
* @param string $binary
|
||||
* @return array<int, string>
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readExtensionData(string $binary): array
|
||||
{
|
||||
$ext = CborDecoder::decode($binary);
|
||||
|
||||
return is_array($ext) ? $ext : throw new DataException('Invalid extension data');
|
||||
}
|
||||
}
|
||||
40
src/Attestation/Creator/AttestationCreation.php
Normal file
40
src/Attestation/Creator/AttestationCreation.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\JsonTransport;
|
||||
|
||||
class AttestationCreation
|
||||
{
|
||||
|
||||
public const ATTACHMENT_CROSS_PLATFORM = 'cross-platform';
|
||||
public const ATTACHMENT_PLATFORM = 'platform';
|
||||
|
||||
/**
|
||||
* The underlying JSON representation of the Assertion Challenge.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\JsonTransport
|
||||
*/
|
||||
public JsonTransport $json;
|
||||
|
||||
/**
|
||||
* Create a new Attestation Instructions instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string|null $residentKey
|
||||
* @param string|null $userVerification
|
||||
* @param bool $uniqueCredentials
|
||||
*/
|
||||
public function __construct(
|
||||
public WebAuthnAuthenticatable $user,
|
||||
public Request $request,
|
||||
public ?string $residentKey = null,
|
||||
public ?string $userVerification = null,
|
||||
public bool $uniqueCredentials = true,
|
||||
) {
|
||||
$this->json = new JsonTransport();
|
||||
}
|
||||
}
|
||||
28
src/Attestation/Creator/AttestationCreator.php
Normal file
28
src/Attestation/Creator/AttestationCreator.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
|
||||
*/
|
||||
class AttestationCreator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\AddRelyingParty::class,
|
||||
Pipes\SetResidentKeyConfiguration::class,
|
||||
Pipes\MayRequireUserVerification::class,
|
||||
Pipes\AddUserDescriptor::class,
|
||||
Pipes\AddAcceptedAlgorithms::class,
|
||||
Pipes\MayPreventDuplicateCredentials::class,
|
||||
Pipes\CreateAttestationChallenge::class,
|
||||
];
|
||||
}
|
||||
34
src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php
Normal file
34
src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddAcceptedAlgorithms
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('pubKeyCredParams', [
|
||||
['type' => 'public-key', 'alg' => -7],
|
||||
['type' => 'public-key', 'alg' => -257],
|
||||
]);
|
||||
|
||||
// Currently we don't support direct attestation. In other words, it won't ask
|
||||
// for attestation data from the authenticator to cross-check later against
|
||||
// root certificates. We may add this in the future, but not guaranteed.
|
||||
$attestable->json->set('attestation', 'none');
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
41
src/Attestation/Creator/Pipes/AddRelyingParty.php
Normal file
41
src/Attestation/Creator/Pipes/AddRelyingParty.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddRelyingParty
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('rp.name', $this->config->get('webauthn.relying_party.name'));
|
||||
|
||||
if ($id = $this->config->get('webauthn.relying_party.id')) {
|
||||
$attestable->json->set('rp.id', $id);
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
33
src/Attestation/Creator/Pipes/AddUserDescriptor.php
Normal file
33
src/Attestation/Creator/Pipes/AddUserDescriptor.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddUserDescriptor
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$config = $attestable->user->webAuthnData();
|
||||
|
||||
// Create a new User UUID if it doesn't existe already in the credentials.
|
||||
$config['id'] = $attestable->user->webAuthnCredentials()->value('user_id')
|
||||
?: Str::uuid()->getHex()->toString();
|
||||
|
||||
$attestable->json->set('user', $config);
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
49
src/Attestation/Creator/Pipes/CreateAttestationChallenge.php
Normal file
49
src/Attestation/Creator/Pipes/CreateAttestationChallenge.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Cache\Factory;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Attestation\SessionChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CreateAttestationChallenge
|
||||
{
|
||||
use SessionChallenge;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Config\Repository $config
|
||||
* @param \Illuminate\Contracts\Cache\Factory $cache
|
||||
*/
|
||||
public function __construct(protected Repository $config, protected Factory $cache)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
|
||||
|
||||
$challenge = $this->storeChallenge($attestable->request, $attestable->userVerification, [
|
||||
'user_uuid' => $attestable->json->get('user.id'),
|
||||
'user_handle' => $attestable->json->get('user.name'),
|
||||
]);
|
||||
|
||||
$attestable->json->set('challenge', $challenge->data);
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MayPreventDuplicateCredentials
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->uniqueCredentials) {
|
||||
$attestable->json->set('excludeCredentials', $this->credentials($attestable->user));
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of credentials ready to be inserted into the Attestable JSON.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @return array
|
||||
*/
|
||||
protected function credentials(WebAuthnAuthenticatable $user): array
|
||||
{
|
||||
return $user
|
||||
->webAuthnCredentials()
|
||||
->get(['id', 'transports'])
|
||||
->map(static function (WebAuthnCredential $credential): array {
|
||||
return array_filter([
|
||||
'id'=> $credential->getKey(),
|
||||
'type' => 'public-key',
|
||||
'transports' => $credential->transports
|
||||
]);
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
28
src/Attestation/Creator/Pipes/MayRequireUserVerification.php
Normal file
28
src/Attestation/Creator/Pipes/MayRequireUserVerification.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MayRequireUserVerification
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->userVerification) {
|
||||
$attestable->json->set('authenticatorSelection.userVerification', $attestable->userVerification);
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class SetResidentKeyConfiguration
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->residentKey) {
|
||||
$attestable->json->set('authenticatorSelection.residentKey', $attestable->residentKey);
|
||||
|
||||
$verifiesUser = $attestable->residentKey === WebAuthn::RESIDENT_KEY_REQUIRED;
|
||||
|
||||
$attestable->json->set('authenticatorSelection.requireResidentKey', $verifiesUser);
|
||||
|
||||
if ($verifiesUser) {
|
||||
$attestable->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
53
src/Attestation/Formats/Format.php
Normal file
53
src/Attestation/Formats/Format.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Formats;
|
||||
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This is a base class that hold common tasks for different Attestation Statements formats.
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @see https://www.iana.org/assignments/webauthn/webauthn.xhtml
|
||||
* @internal
|
||||
*/
|
||||
abstract class Format
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Format.
|
||||
*
|
||||
* @param array{fmt: string, attStmt: array, authData: \Laragear\WebAuthn\ByteBuffer} $attestationObject
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
|
||||
*/
|
||||
public function __construct(public array $attestationObject, public AuthenticatorData $authenticatorData)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
12
src/Attestation/Formats/None.php
Normal file
12
src/Attestation/Formats/None.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Formats;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class None extends Format
|
||||
{
|
||||
|
||||
}
|
||||
44
src/Attestation/SessionChallenge.php
Normal file
44
src/Attestation/SessionChallenge.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
trait SessionChallenge
|
||||
{
|
||||
/**
|
||||
* Stores an Attestation challenge into the Cache.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string|null $verify
|
||||
* @param array $options
|
||||
* @return \Laragear\WebAuthn\Challenge
|
||||
*/
|
||||
protected function storeChallenge(Request $request, ?string $verify, array $options = []): Challenge
|
||||
{
|
||||
$challenge = $this->createChallenge($verify, $options);
|
||||
|
||||
$request->session()->put($this->config->get('webauthn.challenge.key'), $challenge);
|
||||
|
||||
return $challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Challenge using the default timeout.
|
||||
*
|
||||
* @param string|null $verify
|
||||
* @param array $options
|
||||
* @return \Laragear\WebAuthn\Challenge
|
||||
*/
|
||||
protected function createChallenge(?string $verify, array $options = []): Challenge
|
||||
{
|
||||
return Challenge::random(
|
||||
$this->config->get('webauthn.challenge.bytes'),
|
||||
$this->config->get('webauthn.challenge.timeout'),
|
||||
$verify === WebAuthn::USER_VERIFICATION_REQUIRED,
|
||||
$options,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/Attestation/Validator/AttestationValidation.php
Normal file
35
src/Attestation/Validator/AttestationValidation.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Attestation\AttestationObject;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class AttestationValidation
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Validation procedure
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Challenge|null $challenge
|
||||
* @param \Laragear\WebAuthn\Attestation\AttestationObject|null $attestationObject
|
||||
* @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential
|
||||
*/
|
||||
public function __construct(
|
||||
public WebAuthnAuthenticatable $user,
|
||||
public Request $request,
|
||||
public ?Challenge $challenge = null,
|
||||
public ?AttestationObject $attestationObject = null,
|
||||
public ?ClientDataJson $clientDataJson = null,
|
||||
public ?WebAuthnCredential $credential = null,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
32
src/Attestation/Validator/AttestationValidator.php
Normal file
32
src/Attestation/Validator/AttestationValidator.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @method \Laragear\WebAuthn\Attestation\Validator\AttestationValidation thenReturn()
|
||||
*/
|
||||
class AttestationValidator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\RetrieveChallenge::class,
|
||||
Pipes\CompileClientDataJson::class,
|
||||
Pipes\CompileAttestationObject::class,
|
||||
Pipes\AttestationIsForCreation::class,
|
||||
Pipes\CheckChallengeSame::class,
|
||||
Pipes\CheckOriginSecure::class,
|
||||
Pipes\CheckRelyingPartyIdContained::class,
|
||||
Pipes\CheckRelyingPartyHashSame::class,
|
||||
Pipes\CheckUserInteraction::class,
|
||||
Pipes\CredentialIdShouldNotBeDuplicated::class,
|
||||
Pipes\MakeWebAuthnCredential::class,
|
||||
];
|
||||
}
|
||||
34
src/Attestation/Validator/Pipes/AttestationIsForCreation.php
Normal file
34
src/Attestation/Validator/Pipes/AttestationIsForCreation.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
|
||||
/**
|
||||
* 7. Verify that the value of C.type is webauthn.create.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class AttestationIsForCreation
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->type !== 'webauthn.create') {
|
||||
throw AttestationException::make('Response is not for creating WebAuthn Credentials.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
17
src/Attestation/Validator/Pipes/CheckChallengeSame.php
Normal file
17
src/Attestation/Validator/Pipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckChallengeSame as BaseCheckChallengeSame;
|
||||
|
||||
/**
|
||||
* 8. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckChallengeSame extends BaseCheckChallengeSame
|
||||
{
|
||||
//
|
||||
}
|
||||
10
src/Attestation/Validator/Pipes/CheckOriginSecure.php
Normal file
10
src/Attestation/Validator/Pipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
|
||||
|
||||
class CheckOriginSecure extends BaseCheckOriginSame
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyHashSame as BaseCheckRelyingPartyHashSame;
|
||||
|
||||
/**
|
||||
* 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
|
||||
{
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData
|
||||
{
|
||||
return $validation->attestationObject->authenticatorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
|
||||
{
|
||||
return $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyIdContained as BaseCheckRelyingPartyIdSame;
|
||||
|
||||
/**
|
||||
* 9. Verify that the value of C.origin matches the Relying Party's origin.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyIdContained extends BaseCheckRelyingPartyIdSame
|
||||
{
|
||||
//
|
||||
}
|
||||
17
src/Attestation/Validator/Pipes/CheckUserInteraction.php
Normal file
17
src/Attestation/Validator/Pipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckUserInteraction as BaseCheckUserInteraction;
|
||||
|
||||
/**
|
||||
* 14. Verify that the User Present bit of the flags in authData is set.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckUserInteraction extends BaseCheckUserInteraction
|
||||
{
|
||||
//
|
||||
}
|
||||
100
src/Attestation/Validator/Pipes/CompileAttestationObject.php
Normal file
100
src/Attestation/Validator/Pipes/CompileAttestationObject.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Attestation\AttestationObject;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Formats\None;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\CborDecoder;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_decode;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* 12. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
|
||||
* structure to obtain the attestation statement format fmt, the authenticator data authData,
|
||||
* and the attestation statement attStmt.
|
||||
*
|
||||
* 18. Determine the attestation statement format by performing a USASCII case-sensitive match on
|
||||
* fmt against the set of supported WebAuthn Attestation Statement Format Identifier values.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CompileAttestationObject
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$data = $this->decodeCborBase64($validation->request);
|
||||
|
||||
// Here we would receive the attestation formats and decode them. Since we're
|
||||
// only support the universal "none" we can just check if it's equal or not.
|
||||
// Later we may support multiple authenticator formats through a PHP match.
|
||||
if ($data['fmt'] !== 'none') {
|
||||
throw AttestationException::make("Format name [{$data['fmt']}] is invalid.");
|
||||
}
|
||||
|
||||
try {
|
||||
$authenticatorData = AuthenticatorData::fromBinary($data['authData']->getBinaryString());
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
|
||||
$validation->attestationObject = new AttestationObject(
|
||||
$authenticatorData, new None($data, $authenticatorData), $data['fmt']
|
||||
);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array map from a BASE64 encoded CBOR string.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
#[ArrayShape(["fmt" => "string", "attStmt" => "array", "authData" => ByteBuffer::class])]
|
||||
protected function decodeCborBase64(Request $request): array
|
||||
{
|
||||
try {
|
||||
$data = CborDecoder::decode(base64_decode($request->json('response.attestationObject', '')));
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw AttestationException::make('CBOR Object is anything but an array.');
|
||||
}
|
||||
|
||||
if (!isset($data['fmt']) || !is_string($data['fmt'])) {
|
||||
throw AttestationException::make('Format is missing or invalid.');
|
||||
}
|
||||
|
||||
if (!isset($data['attStmt']) || !is_array($data['attStmt'])) {
|
||||
throw AttestationException::make('Statement is missing or invalid.');
|
||||
}
|
||||
|
||||
if (!isset($data['authData']) || !$data['authData'] instanceof ByteBuffer) {
|
||||
throw AttestationException::make('Authenticator Data is missing or invalid.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
20
src/Attestation/Validator/Pipes/CompileClientDataJson.php
Normal file
20
src/Attestation/Validator/Pipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CompileClientDataJson as BaseCompileClientDataJson;
|
||||
|
||||
/**
|
||||
* 5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.
|
||||
*
|
||||
* 6. Let C, the client data claimed as collected during the credential creation, be the result of
|
||||
* running an implementation-specific JSON parser on JSONtext.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CompileClientDataJson extends BaseCompileClientDataJson
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CredentialIdShouldNotBeDuplicated
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($this->credentialAlreadyExists($validation)) {
|
||||
throw AttestationException::make('Credential ID already exists in the database.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a WebAuthn Credential by the issued ID.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function credentialAlreadyExists(AttestationValidation $validation): bool
|
||||
{
|
||||
return WebAuthnCredential::whereKey($validation->request->json('id'))->exists();
|
||||
}
|
||||
}
|
||||
70
src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php
Normal file
70
src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MakeWebAuthnCredential
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->credential = $validation->user->makeWebAuthnCredential([
|
||||
'id' => $validation->request->json('id'),
|
||||
|
||||
'user_id' => $validation->challenge->properties['user_uuid'],
|
||||
'alias' => $validation->request->json('response.alias'),
|
||||
|
||||
'counter' => $validation->attestationObject->authenticatorData->counter,
|
||||
'rp_id' => $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'),
|
||||
'origin' => $validation->clientDataJson->origin,
|
||||
'transports' => $validation->request->json('response.transports'),
|
||||
'aaguid' => Uuid::fromBytes($validation->attestationObject->authenticatorData->attestedCredentialData->aaguid),
|
||||
|
||||
'public_key' => $this->getPublicKeyAsPem($validation),
|
||||
'attestation_format' => $validation->attestationObject->formatName,
|
||||
]);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a public key from the credentials as a PEM string.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function getPublicKeyAsPem(AttestationValidation $validation): string
|
||||
{
|
||||
try {
|
||||
return $validation->attestationObject->authenticatorData->getPublicKeyPem();
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Attestation/Validator/Pipes/RetrieveChallenge.php
Normal file
13
src/Attestation/Validator/Pipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\RetrieveChallenge as BaseRetrieveChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrieveChallenge extends BaseRetrieveChallenge
|
||||
{
|
||||
//
|
||||
}
|
||||
110
src/Auth/WebAuthnUserProvider.php
Normal file
110
src/Auth/WebAuthnUserProvider.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Auth;
|
||||
|
||||
use Illuminate\Auth\EloquentUserProvider;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use function class_implements;
|
||||
use function config;
|
||||
use function logger;
|
||||
use function request;
|
||||
|
||||
/**
|
||||
* This class is not meant to be used directly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class WebAuthnUserProvider extends EloquentUserProvider
|
||||
{
|
||||
/**
|
||||
* Create a new database user provider.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Hashing\Hasher $hasher
|
||||
* @param string $model
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidator $validator
|
||||
* @param bool $fallback
|
||||
*/
|
||||
public function __construct(
|
||||
HasherContract $hasher,
|
||||
string $model,
|
||||
protected AssertionValidator $validator,
|
||||
protected bool $fallback,
|
||||
) {
|
||||
parent::__construct($hasher, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
if (class_implements($this->model, WebAuthnAuthenticatable::class) && $this->isSignedChallenge($credentials)) {
|
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
||||
return $this->newModelQuery()
|
||||
->whereHas('webAuthnCredentials', static function (Builder $query) use ($credentials): void {
|
||||
$query->whereKey($credentials['id'])->whereEnabled();
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
return parent::retrieveByCredentials($credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credentials are for a public key signed challenge
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return bool
|
||||
*/
|
||||
protected function isSignedChallenge(array $credentials): bool
|
||||
{
|
||||
return isset($credentials['id'], $credentials['rawId'], $credentials['response'], $credentials['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable|\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validateCredentials($user, array $credentials): bool
|
||||
{
|
||||
if ($user instanceof WebAuthnAuthenticatable && $this->isSignedChallenge($credentials)) {
|
||||
return $this->validateWebAuthn();
|
||||
}
|
||||
|
||||
// If the fallback is enabled, we will validate the credential password.
|
||||
return $this->fallback && parent::validateCredentials($user, $credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the WebAuthn assertion.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function validateWebAuthn(): bool
|
||||
{
|
||||
try {
|
||||
$this->validator->send(new AssertionValidation(request()))->thenReturn();
|
||||
} catch (AssertionException $e) {
|
||||
// If we're debugging, like under local development, push the error to the logger.
|
||||
if (config('app.debug')) {
|
||||
logger($e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
472
src/ByteBuffer.php
Normal file
472
src/ByteBuffer.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use InvalidArgumentException;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JsonSerializable;
|
||||
use OutOfBoundsException;
|
||||
use Stringable;
|
||||
use function base64_decode;
|
||||
use function base64_encode;
|
||||
use function bin2hex;
|
||||
use function hash_equals;
|
||||
use function hex2bin;
|
||||
use function json_decode;
|
||||
use function ord;
|
||||
use function random_bytes;
|
||||
use function rtrim;
|
||||
use function str_repeat;
|
||||
use function strlen;
|
||||
use function strtr;
|
||||
use function substr;
|
||||
use function unpack;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2018 Thomas Bleeker
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @author Thomas Bleeker
|
||||
* @internal
|
||||
*/
|
||||
class ByteBuffer implements JsonSerializable, Jsonable, Stringable
|
||||
{
|
||||
/**
|
||||
* Create a new ByteBuffer
|
||||
*
|
||||
* @param string $binaryData
|
||||
* @param int $dataLength
|
||||
*/
|
||||
public function __construct(protected string $binaryData, protected int $dataLength = 0)
|
||||
{
|
||||
$this->dataLength = strlen($binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the ByteBuffer data.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getDataLength(): int
|
||||
{
|
||||
return $this->dataLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the length of the data is greater than zero.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasLength(): bool
|
||||
{
|
||||
return (bool) $this->dataLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the length of the data is zero.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNoLength(): bool
|
||||
{
|
||||
return !$this->hasLength();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the binary string verbatim.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBinaryString(): string
|
||||
{
|
||||
return $this->binaryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both Byte Buffers are equal using `hash_equals`.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
|
||||
* @return bool
|
||||
*/
|
||||
public function hashEqual(self|string $buffer): bool
|
||||
{
|
||||
if ($buffer instanceof static) {
|
||||
$buffer = $buffer->getBinaryString();
|
||||
}
|
||||
|
||||
return hash_equals($this->binaryData, $buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both Byte Buffers are not equal using `hash_equals`.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
|
||||
* @return bool
|
||||
*/
|
||||
public function hashNotEqual(self|string $buffer): bool
|
||||
{
|
||||
return ! $this->hashEqual($buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a certain portion of these bytes.
|
||||
*
|
||||
* @param int $offset
|
||||
* @param int|null $length
|
||||
* @return string
|
||||
*/
|
||||
public function getBytes(int $offset = 0, int $length = null): string
|
||||
{
|
||||
$length ??= $this->dataLength;
|
||||
|
||||
if ($offset < 0 || $length < 0 || ($offset + $length > $this->dataLength)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset or length.');
|
||||
}
|
||||
|
||||
return substr($this->binaryData, $offset, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single byte.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return int
|
||||
*/
|
||||
public function getByteVal(int $offset = 0): int
|
||||
{
|
||||
if (!$byte = $this->binaryData[$offset] ?? null) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return ord($byte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 16-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUint16Val(int $offset = 0): int
|
||||
{
|
||||
if ($offset < 0 || ($offset + 2) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return unpack('n', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 32-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUint32Val(int $offset = 0): int
|
||||
{
|
||||
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
$val = unpack('N', $this->binaryData, $offset)[1];
|
||||
|
||||
// Signed integer overflow causes signed negative numbers
|
||||
if ($val < 0) {
|
||||
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 64-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return int
|
||||
*/
|
||||
public function getUint64Val(int $offset): int
|
||||
{
|
||||
if (PHP_INT_SIZE < 8) {
|
||||
throw new OutOfBoundsException('ByteBuffer: 64-bit values not supported by this system');
|
||||
}
|
||||
|
||||
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
$val = unpack('J', $this->binaryData, $offset)[1];
|
||||
|
||||
// Signed integer overflow causes signed negative numbers
|
||||
if ($val < 0) {
|
||||
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 16-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getHalfFloatVal(int $offset = 0): float
|
||||
{
|
||||
// FROM spec pseudo decode_half(unsigned char *halfp)
|
||||
$half = $this->getUint16Val($offset);
|
||||
|
||||
$exp = ($half >> 10) & 0x1f;
|
||||
$mant = $half & 0x3ff;
|
||||
|
||||
if ($exp === 0) {
|
||||
$val = $mant * (2 ** -24);
|
||||
} elseif ($exp !== 31) {
|
||||
$val = ($mant + 1024) * (2 ** ($exp - 25));
|
||||
} else {
|
||||
$val = ($mant === 0) ? INF : NAN;
|
||||
}
|
||||
|
||||
return ($half & 0x8000) ? -$val : $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 32-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getFloatVal(int $offset = 0): float
|
||||
{
|
||||
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return unpack('G', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 64-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getDoubleVal(int $offset = 0): float
|
||||
{
|
||||
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
return unpack('E', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the ByteBuffer JSON into a generic Object.
|
||||
*
|
||||
* @param int $jsonFlags
|
||||
* @return object
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function toObject(int $jsonFlags = 0): object
|
||||
{
|
||||
return json_decode($this->binaryData, null, 512, JSON_THROW_ON_ERROR | $jsonFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Base64 URL representation of the byte buffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toBase64Url(): string
|
||||
{
|
||||
return static::encodeBase64Url($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->toBase64Url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hexadecimal representation of the ByteBuffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toHex(): string
|
||||
{
|
||||
return bin2hex($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* object to string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toHex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the object to its JSON representation.
|
||||
*
|
||||
* @param int $options
|
||||
* @return string
|
||||
*/
|
||||
public function toJson($options = 0): string
|
||||
{
|
||||
return $this->jsonSerialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of data for serialization.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape(['binaryData' => "string"])]
|
||||
public function __serialize(): array
|
||||
{
|
||||
return ['binaryData' => static::encodeBase64Url($this->binaryData)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable-Interface
|
||||
*
|
||||
* @param array $data
|
||||
*/
|
||||
public function __unserialize(array $data): void
|
||||
{
|
||||
$this->binaryData = static::decodeBase64Url($data['binaryData']);
|
||||
$this->dataLength = strlen($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a BASE64 URL encoded string.
|
||||
*
|
||||
* @param string $base64url
|
||||
* @return static
|
||||
*/
|
||||
public static function fromBase64Url(string $base64url): static
|
||||
{
|
||||
if (false === $bin = self::decodeBase64Url($base64url)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid base64 url string');
|
||||
}
|
||||
|
||||
return new ByteBuffer($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a BASE64 encoded string.
|
||||
*
|
||||
* @param string $base64
|
||||
* @return static
|
||||
*/
|
||||
public static function fromBase64(string $base64): static
|
||||
{
|
||||
if (false === $bin = base64_decode($base64)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid base64 string');
|
||||
}
|
||||
|
||||
return new ByteBuffer($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a hexadecimal string.
|
||||
*
|
||||
* @param string $hex
|
||||
* @return static
|
||||
*/
|
||||
public static function fromHex(string $hex): static
|
||||
{
|
||||
if (false === $bin = hex2bin($hex)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid hex string');
|
||||
}
|
||||
|
||||
return new static($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random ByteBuffer
|
||||
*
|
||||
* @param int $length
|
||||
* @return static
|
||||
*/
|
||||
public static function makeRandom(int $length): static
|
||||
{
|
||||
return new static(random_bytes($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
protected static function decodeBase64Url(string $data): string|false
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/').str_repeat('=', 3 - (3 + strlen($data)) % 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a BASE64 URL string.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
protected static function encodeBase64Url(string $data): string|false
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
335
src/CborDecoder.php
Normal file
335
src/CborDecoder.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2018 Thomas Bleeker
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @author Thomas Bleeker
|
||||
* @internal
|
||||
*/
|
||||
class CborDecoder
|
||||
{
|
||||
public const CBOR_MAJOR_UNSIGNED_INT = 0;
|
||||
public const CBOR_MAJOR_TEXT_STRING = 3;
|
||||
public const CBOR_MAJOR_FLOAT_SIMPLE = 7;
|
||||
public const CBOR_MAJOR_NEGATIVE_INT = 1;
|
||||
public const CBOR_MAJOR_ARRAY = 4;
|
||||
public const CBOR_MAJOR_TAG = 6;
|
||||
public const CBOR_MAJOR_MAP = 5;
|
||||
public const CBOR_MAJOR_BYTE_STRING = 2;
|
||||
|
||||
/**
|
||||
* Decodes the binary data.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $encoded
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public static function decode(ByteBuffer|string $encoded): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
if (is_string($encoded)) {
|
||||
$encoded = new ByteBuffer($encoded);
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
$result = static::parseItem($encoded, $offset);
|
||||
|
||||
if ($offset !== $encoded->getDataLength()) {
|
||||
throw new DataException('CBOR: Unused bytes after data item.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a portion of the Byte Buffer.
|
||||
*
|
||||
* @param ByteBuffer|string $bufOrBin
|
||||
* @param int $startOffset
|
||||
* @param int|null $endOffset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public static function decodePortion(ByteBuffer|string $bufOrBin, int $startOffset, ?int &$endOffset = null): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
$buf = $bufOrBin instanceof ByteBuffer ? $bufOrBin : new ByteBuffer($bufOrBin);
|
||||
|
||||
$offset = $startOffset;
|
||||
$data = static::parseItem($buf, $offset);
|
||||
$endOffset = $offset;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single item of the Byte Buffer.
|
||||
*
|
||||
* @param ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseItem(ByteBuffer $buf, int &$offset): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
$first = $buf->getByteVal($offset++);
|
||||
$type = $first >> 5;
|
||||
$val = $first & 0b11111;
|
||||
|
||||
if ($type === static::CBOR_MAJOR_FLOAT_SIMPLE) {
|
||||
return static::parseFloatSimple($val, $buf, $offset);
|
||||
}
|
||||
|
||||
$val = static::parseExtraLength($val, $buf, $offset);
|
||||
|
||||
try {
|
||||
return static::parseItemData($type, $val, $buf, $offset);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new DataException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a simple float value.
|
||||
*
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return bool|float|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseFloatSimple(int $val, ByteBuffer $buf, int &$offset): bool|float|null
|
||||
{
|
||||
switch ($val) {
|
||||
case 24:
|
||||
$val = $buf->getByteVal($offset);
|
||||
$offset++;
|
||||
return static::parseSimpleValue($val);
|
||||
case 25:
|
||||
$floatValue = $buf->getHalfFloatVal($offset);
|
||||
$offset += 2;
|
||||
return $floatValue;
|
||||
case 26:
|
||||
$floatValue = $buf->getFloatVal($offset);
|
||||
$offset += 4;
|
||||
return $floatValue;
|
||||
case 27:
|
||||
$floatValue = $buf->getDoubleVal($offset);
|
||||
$offset += 8;
|
||||
return $floatValue;
|
||||
case 28:
|
||||
case 29:
|
||||
case 30:
|
||||
throw new DataException('Reserved value used.');
|
||||
case 31:
|
||||
throw new DataException('Indefinite length is not supported.');
|
||||
default:
|
||||
return static::parseSimpleValue($val);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a simple value from CBOR.
|
||||
*
|
||||
* @param int $val
|
||||
* @return bool|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseSimpleValue(int $val): ?bool
|
||||
{
|
||||
return match ($val) {
|
||||
20 => false,
|
||||
21 => true,
|
||||
22 => null,
|
||||
default => throw new DataException(sprintf('Unsupported simple value %d.', $val))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the CBOR extra length.
|
||||
*
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return int
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseExtraLength(int $val, ByteBuffer $buf, int &$offset): int
|
||||
{
|
||||
switch ($val) {
|
||||
case 24:
|
||||
$val = $buf->getByteVal($offset);
|
||||
$offset++;
|
||||
return $val;
|
||||
case 25:
|
||||
$val = $buf->getUint16Val($offset);
|
||||
$offset += 2;
|
||||
return $val;
|
||||
case 26:
|
||||
$val = $buf->getUint32Val($offset);
|
||||
$offset += 4;
|
||||
return $val;
|
||||
case 27:
|
||||
$val = $buf->getUint64Val($offset);
|
||||
$offset += 8;
|
||||
return $val;
|
||||
case 28:
|
||||
case 29:
|
||||
case 30:
|
||||
throw new DataException('Reserved value used.');
|
||||
case 31:
|
||||
throw new DataException('Indefinite length is not supported.');
|
||||
default:
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the data inside a Byte Buffer.
|
||||
*
|
||||
* @param int $type
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param $offset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseItemData(
|
||||
int $type,
|
||||
int $val,
|
||||
ByteBuffer $buf,
|
||||
&$offset
|
||||
): ByteBuffer|array|bool|float|int|string|null {
|
||||
switch ($type) {
|
||||
case static::CBOR_MAJOR_UNSIGNED_INT: // uint
|
||||
return $val;
|
||||
|
||||
case static::CBOR_MAJOR_NEGATIVE_INT:
|
||||
return -1 - $val;
|
||||
|
||||
case static::CBOR_MAJOR_BYTE_STRING:
|
||||
$data = $buf->getBytes($offset, $val);
|
||||
$offset += $val;
|
||||
return new ByteBuffer($data); // bytes
|
||||
|
||||
case static::CBOR_MAJOR_TEXT_STRING:
|
||||
$data = $buf->getBytes($offset, $val);
|
||||
$offset += $val;
|
||||
return $data; // UTF-8
|
||||
|
||||
case static::CBOR_MAJOR_ARRAY:
|
||||
return static::parseArray($buf, $offset, $val);
|
||||
|
||||
case static::CBOR_MAJOR_MAP:
|
||||
return static::parseMap($buf, $offset, $val);
|
||||
|
||||
case static::CBOR_MAJOR_TAG:
|
||||
return static::parseItem($buf, $offset); // 1 embedded data item
|
||||
}
|
||||
|
||||
throw new DataException(sprintf('Unknown major type %d.', $type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array with string keys.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buffer
|
||||
* @param int $offset
|
||||
* @param int $count
|
||||
* @return array<string, mixed>
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseMap(ByteBuffer $buffer, int &$offset, int $count): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$mapKey = static::parseItem($buffer, $offset);
|
||||
$mapVal = static::parseItem($buffer, $offset);
|
||||
|
||||
if (!is_int($mapKey) && !is_string($mapKey)) {
|
||||
throw new DataException('Can only use strings or integers as map keys');
|
||||
}
|
||||
|
||||
$map[$mapKey] = $mapVal;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array from the byte buffer.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @param int $count
|
||||
* @return array
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseArray(ByteBuffer $buf, int &$offset, int $count): array
|
||||
{
|
||||
$arr = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$arr[] = static::parseItem($buf, $offset);
|
||||
}
|
||||
|
||||
return $arr;
|
||||
}
|
||||
}
|
||||
52
src/Challenge.php
Normal file
52
src/Challenge.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\InteractsWithTime;
|
||||
|
||||
class Challenge
|
||||
{
|
||||
use InteractsWithTime;
|
||||
|
||||
/**
|
||||
* Create a new Challenge instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $data
|
||||
* @param int $timeout
|
||||
* @param bool $verify
|
||||
* @param array $properties
|
||||
*/
|
||||
public function __construct(
|
||||
public ByteBuffer $data,
|
||||
public int $timeout,
|
||||
public bool $verify = true,
|
||||
public array $properties = []
|
||||
) {
|
||||
$this->timeout = Date::now()->addSeconds($this->timeout)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current challenge has expired in time and no longer valid.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return Date::createFromTimestamp($this->timeout)->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Challenge instance using a random ByteBuffer of the given length.
|
||||
*
|
||||
* @param int $length
|
||||
* @param int $timeout
|
||||
* @param bool $verify
|
||||
* @param array $options
|
||||
* @return static
|
||||
*/
|
||||
public static function random(int $length, int $timeout, bool $verify = true, array $options = []): static
|
||||
{
|
||||
return new static(ByteBuffer::makeRandom($length), $timeout, $verify, $options);
|
||||
}
|
||||
}
|
||||
18
src/ClientDataJson.php
Normal file
18
src/ClientDataJson.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
class ClientDataJson
|
||||
{
|
||||
/**
|
||||
* Create a new Client Data JSON object.
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $origin
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $challenge
|
||||
*/
|
||||
public function __construct(public string $type, public string $origin, public ByteBuffer $challenge)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
47
src/Contracts/WebAuthnAuthenticatable.php
Normal file
47
src/Contracts/WebAuthnAuthenticatable.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
interface WebAuthnAuthenticatable
|
||||
{
|
||||
/**
|
||||
* Returns displayable data to be used to create WebAuthn Credentials.
|
||||
*
|
||||
* @return array{name: string, displayName: string}
|
||||
*/
|
||||
public function webAuthnData(): array;
|
||||
|
||||
/**
|
||||
* Removes all credentials previously registered.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function flushCredentials(string ...$except): void;
|
||||
|
||||
/**
|
||||
* Disables all credentials for the user.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function disableAllCredentials(string ...$except): void;
|
||||
|
||||
/**
|
||||
* Makes an instance of a WebAuthn Credential attached to this user.
|
||||
*
|
||||
* @param array $properties
|
||||
* @return \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function makeWebAuthnCredential(array $properties): WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* Returns a queryable relationship for its WebAuthn Credentials.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function webAuthnCredentials(): MorphMany;
|
||||
}
|
||||
8
src/Contracts/WebAuthnException.php
Normal file
8
src/Contracts/WebAuthnException.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Contracts;
|
||||
|
||||
interface WebAuthnException
|
||||
{
|
||||
//
|
||||
}
|
||||
22
src/Events/CredentialCloned.php
Normal file
22
src/Events/CredentialCloned.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialCloned
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
* @param int $reportedCount The counter reported by the user authenticator.
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential, public int $reportedCount)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
23
src/Events/CredentialCreated.php
Normal file
23
src/Events/CredentialCreated.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnAuthenticatable $user, public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
21
src/Events/CredentialDisabled.php
Normal file
21
src/Events/CredentialDisabled.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialDisabled
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
21
src/Events/CredentialEnabled.php
Normal file
21
src/Events/CredentialEnabled.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialEnabled
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
20
src/Exceptions/AssertionException.php
Normal file
20
src/Exceptions/AssertionException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class AssertionException extends ValidationException implements WebAuthnException
|
||||
{
|
||||
/**
|
||||
* Create a new Assertion Exception with the error message.
|
||||
*
|
||||
* @param string $message
|
||||
* @return \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public static function make(string $message): static
|
||||
{
|
||||
return static::withMessages(['assertion' => "Assertion Error: $message"]);
|
||||
}
|
||||
}
|
||||
20
src/Exceptions/AttestationException.php
Normal file
20
src/Exceptions/AttestationException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class AttestationException extends ValidationException implements WebAuthnException
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Exception with the error message.
|
||||
*
|
||||
* @param string $message
|
||||
* @return \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public static function make(string $message): static
|
||||
{
|
||||
return static::withMessages(['attestation' => "Attestation Error: $message"]);
|
||||
}
|
||||
}
|
||||
11
src/Exceptions/DataException.php
Normal file
11
src/Exceptions/DataException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class DataException extends Exception implements WebAuthnException
|
||||
{
|
||||
//
|
||||
}
|
||||
65
src/Http/Requests/AssertedRequest.php
Normal file
65
src/Http/Requests/AssertedRequest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
|
||||
class AssertedRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'id' => "string", 'rawId' => "string", 'response.authenticatorData' => "string",
|
||||
'response.clientDataJSON' => "string", 'response.signature' => "string", 'response.userHandle' => "string",
|
||||
'type' => "string"
|
||||
])]
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'required|string',
|
||||
'rawId' => 'required|string',
|
||||
'response.authenticatorData' => 'required|string',
|
||||
'response.clientDataJSON' => 'required|string',
|
||||
'response.signature' => 'required|string',
|
||||
'response.userHandle' => 'sometimes|nullable',
|
||||
'type' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the login request wants to remember the user as stateful.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRemember(): bool
|
||||
{
|
||||
return $this->hasHeader('X-WebAuthn-Remember')
|
||||
|| $this->hasHeader('WebAuthn-Remember')
|
||||
|| $this->filled('remember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user for this assertion request.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable&\Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function login(string $guard = null, bool $remember = null, bool $destroySession = false): ?WebAuthnAuthenticatable
|
||||
{
|
||||
$auth = Auth::guard($guard);
|
||||
|
||||
if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) {
|
||||
$this->session()->regenerate($destroySession);
|
||||
|
||||
return $auth->user();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
149
src/Http/Requests/AssertionRequest.php
Normal file
149
src/Http/Requests/AssertionRequest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use InvalidArgumentException;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
class AssertionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The Assertion Creation instance.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
|
||||
*/
|
||||
protected AssertionCreation $assertion;
|
||||
|
||||
/**
|
||||
* The guard to use to retrieve the user.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $guard = null;
|
||||
|
||||
/**
|
||||
* If the user may or may not be verified on login.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $userVerification = null;
|
||||
|
||||
/**
|
||||
* Validate the class instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validateResolved(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Return or make a new Assertion Creation.
|
||||
*
|
||||
* @return \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
|
||||
*/
|
||||
protected function assertion(): AssertionCreation
|
||||
{
|
||||
return $this->assertion ??= new AssertionCreation($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the WebAuthn-compatible guard to use.
|
||||
*
|
||||
* @param string $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function guard(string $guard): static
|
||||
{
|
||||
$this->guard = $guard;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to only check for user presence on login.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fastLogin(): static
|
||||
{
|
||||
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to always verify the user thoroughly on login.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function secureLogin(): static
|
||||
{
|
||||
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an assertion challenge for a user if found.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|string|int|array|null $credentials
|
||||
* @return \Illuminate\Contracts\Support\Responsable
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function toVerify(WebAuthnAuthenticatable|string|int|array|null $credentials = []): Responsable
|
||||
{
|
||||
$this->assertion()->user = $this->findUser($credentials);
|
||||
|
||||
return $this->container
|
||||
->make(AssertionCreator::class)
|
||||
->send($this->assertion)
|
||||
->then(static function (AssertionCreation $creation): Responsable {
|
||||
return $creation->json;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a user to create an assertion procedure.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|array|int|string|null $credentials
|
||||
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable
|
||||
{
|
||||
if (!$credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($credentials instanceof WebAuthnAuthenticatable) {
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
// If the developer is using a string or integer, we will understand its trying to
|
||||
// retrieve by its ID, otherwise we will fall back to credentials. Once done, we
|
||||
// will check it uses WebAuthn if is not null, otherwise we'll fail miserably.
|
||||
$user = is_string($credentials) || is_int($credentials)
|
||||
? Auth::guard($this->guard)->getProvider()->retrieveById($credentials)
|
||||
: Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials);
|
||||
|
||||
if ($user && ! $user instanceof WebAuthnAuthenticatable) {
|
||||
$guard = $this->guard ?? $this->container->make('config')->get('auth.defaults.guard');
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
"The user found for the [$guard] auth guard is not an instance of [WebAuthnAuthenticatable]."
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
120
src/Http/Requests/AttestationRequest.php
Normal file
120
src/Http/Requests/AttestationRequest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
|
||||
*/
|
||||
class AttestationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The attestation instance that would be returned.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
|
||||
*/
|
||||
protected AttestationCreation $attestation;
|
||||
|
||||
/**
|
||||
* Validate the class instance.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function validateResolved(): void
|
||||
{
|
||||
if (!$this->passesAuthorization()) {
|
||||
$this->failedAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(?WebAuthnAuthenticatable $user): bool
|
||||
{
|
||||
return (bool) $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the existing attestation instance.
|
||||
*
|
||||
* @return \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
|
||||
*/
|
||||
protected function attestation(): AttestationCreation
|
||||
{
|
||||
return $this->attestation ??= new AttestationCreation($this->user(), $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to only check for user presence on registration.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fastRegistration(): static
|
||||
{
|
||||
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to always verify the user thoroughly on registration.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function secureRegistration(): static
|
||||
{
|
||||
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the authenticator use this credential to login instantly, instead of asking for one.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function userless(): static
|
||||
{
|
||||
$this->attestation()->residentKey = WebAuthn::RESIDENT_KEY_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the device to create multiple credentials for the same user for this app.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function allowDuplicates(): static
|
||||
{
|
||||
$this->attestation()->uniqueCredentials = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a response with the instructions to create a WebAuthn Credential.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Support\Responsable
|
||||
*/
|
||||
public function toCreate(): Responsable
|
||||
{
|
||||
return $this->container
|
||||
->make(AttestationCreator::class)
|
||||
->send($this->attestation())
|
||||
->then(static function (AttestationCreation $creation): Responsable {
|
||||
return $creation->json;
|
||||
});
|
||||
}
|
||||
}
|
||||
89
src/Http/Requests/AttestedRequest.php
Normal file
89
src/Http/Requests/AttestedRequest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Events\CredentialCreated;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function is_callable;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
|
||||
*/
|
||||
class AttestedRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The new credential instance.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
protected WebAuthnCredential $credential;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(?WebAuthnAuthenticatable $user): bool
|
||||
{
|
||||
return (bool) $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'id' => "string", 'rawId' => "string", 'response' => "string", 'response.clientDataJSON' => "string",
|
||||
'response.attestationObject' => "string", 'type' => "string"
|
||||
])]
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'required|string',
|
||||
'rawId' => 'required|string',
|
||||
'response' => 'required|array',
|
||||
'response.clientDataJSON' => 'required|string',
|
||||
'response.attestationObject' => 'required|string',
|
||||
'type' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a passed validation attempt.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function passedValidation(): void
|
||||
{
|
||||
$this->credential = $this->container->make(AttestationValidator::class)
|
||||
->send(new AttestationValidation($this->user(), $this))
|
||||
->then(static function (AttestationValidation $validation): WebAuthnCredential {
|
||||
return $validation->credential;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and return the generated WebAuthn Credentials.
|
||||
*
|
||||
* @param array|callable $saving
|
||||
* @return string
|
||||
*/
|
||||
public function save(array|callable $saving = []): string
|
||||
{
|
||||
is_callable($saving) ? $saving($this->credential) : $this->credential->forceFill($saving);
|
||||
|
||||
$this->credential->save();
|
||||
|
||||
CredentialCreated::dispatch($this->user(), $this->credential);
|
||||
|
||||
return $this->credential->getKey();
|
||||
}
|
||||
}
|
||||
106
src/JsonTransport.php
Normal file
106
src/JsonTransport.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use JsonSerializable;
|
||||
use Stringable;
|
||||
use function json_encode;
|
||||
|
||||
/**
|
||||
* This class will help us build JSON responses by setting and checking for its keys.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class JsonTransport implements Arrayable, Jsonable, JsonSerializable, Stringable, Responsable
|
||||
{
|
||||
/**
|
||||
* Create a new JSON transport.
|
||||
*
|
||||
* @param array $json
|
||||
*/
|
||||
public function __construct(public array $json = [])
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a value to the underlying JSON array.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
Arr::set($this->json, $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a value from the underlying JSON array.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string|int|null $default
|
||||
* @return string|int|null
|
||||
*/
|
||||
public function get(string $key, string|int $default = null): string|int|null
|
||||
{
|
||||
return Arr::get($this->json, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the object to its JSON representation.
|
||||
*
|
||||
* @param int $options
|
||||
* @return string
|
||||
*/
|
||||
public function toJson($options = 0): string
|
||||
{
|
||||
return json_encode($this->jsonSerialize(), JSON_THROW_ON_ERROR | $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the instance as an array.
|
||||
*
|
||||
* @return array<string, int|string|\Laragear\WebAuthn\ByteBuffer>
|
||||
*/
|
||||
public function toArray()
|
||||
{
|
||||
return $this->json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representation of the object.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toJson();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP response that represents the object.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function toResponse($request): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this);
|
||||
}
|
||||
}
|
||||
195
src/Models/WebAuthnCredential.php
Normal file
195
src/Models/WebAuthnCredential.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Laragear\WebAuthn\Events\CredentialDisabled;
|
||||
use Laragear\WebAuthn\Events\CredentialEnabled;
|
||||
|
||||
/**
|
||||
* @mixin \Illuminate\Database\Eloquent\Builder<\Laragear\WebAuthn\Models\WebAuthnCredential>
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static query()
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static newQuery()
|
||||
* @method static static make(array $attributes = [])
|
||||
* @method static static create(array $attributes = [])
|
||||
* @method static static forceCreate(array $attributes)
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrNew(array $attributes = [], array $values = [])
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrFail($columns = ['*'])
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrCreate(array $attributes, array $values = [])
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOr($columns = ['*'], \Closure $callback = null)
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstWhere($column, $operator = null, $value = null, $boolean = 'and')
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential updateOrCreate(array $attributes, array $values = [])
|
||||
* @method static|null first($columns = ['*'])
|
||||
* @method static static findOrFail($id, $columns = ['*'])
|
||||
* @method static static findOrNew($id, $columns = ['*'])
|
||||
* @method static static|null find($id, $columns = ['*'])
|
||||
*
|
||||
* @property-read string $id
|
||||
*
|
||||
* @property-read string $user_id
|
||||
* @property string|null $alias
|
||||
*
|
||||
* @property-read int $counter
|
||||
* @property-read string $rp_id
|
||||
* @property-read string $origin
|
||||
* @property-read array<int, string>|null $transports
|
||||
* @property-read string $aaguid
|
||||
*
|
||||
* @property-read string $public_key
|
||||
* @property-read string $attestation_format
|
||||
* @property-read array<int, string> $certificates
|
||||
*
|
||||
* @property-read \Illuminate\Support\Carbon|null $disabled_at
|
||||
*
|
||||
* @property-read \Laragear\WebAuthn\ByteBuffer $binary_id
|
||||
*
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
*
|
||||
* @property-read \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $authenticatable
|
||||
*
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static whereEnabled()
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static whereDisabled()
|
||||
*/
|
||||
class WebAuthnCredential extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'webauthn_credentials';
|
||||
|
||||
/**
|
||||
* The "type" of the primary key ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* Indicates if the IDs are auto-incrementing.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'counter' => 'int',
|
||||
'transports' => 'array',
|
||||
'public_key' => 'encrypted',
|
||||
'certificates' => 'array',
|
||||
'disabled_at' => 'timestamp',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be visible in serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $visible = ['id', 'origin', 'alias', 'aaguid', 'attestation_format', 'disabled_at', 'is_enabled'];
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo&\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
|
||||
*/
|
||||
public function authenticatable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('authenticatable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the query by enabled credentials.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
protected function scopeWhereEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('disabled_at');
|
||||
}
|
||||
/**
|
||||
* Filter the query by disabled credentials.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
protected function scopeWhereDisabled(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('disabled_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return null === $this->attributes['disabled_at'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential is disabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isDisabled(): bool
|
||||
{
|
||||
return !$this->isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the credential to be used with WebAuthn.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
$wasDisabled = (bool) $this->attributes['disabled_at'];
|
||||
|
||||
$this->attributes['disabled_at'] = null;
|
||||
|
||||
$this->save();
|
||||
|
||||
if ($wasDisabled) {
|
||||
CredentialEnabled::dispatch($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the credential for WebAuthn.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function disable(): void
|
||||
{
|
||||
$wasEnabled = ! $this->attributes['disabled_at'];
|
||||
|
||||
$this->setAttribute('disabled_at', $this->freshTimestamp())->save();
|
||||
|
||||
if ($wasEnabled) {
|
||||
CredentialDisabled::dispatch($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the assertion counter by 1.
|
||||
*
|
||||
* @param int $counter
|
||||
* @return void
|
||||
*/
|
||||
public function syncCounter(int $counter): void
|
||||
{
|
||||
$this->attributes['counter'] = $counter;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
37
src/SharedPipes/CheckChallengeSame.php
Normal file
37
src/SharedPipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckChallengeSame
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->challenge->hasNoLength()) {
|
||||
static::throw($validation, "Response has an empty challenge.");
|
||||
}
|
||||
|
||||
if ($validation->clientDataJson->challenge->hashNotEqual($validation->challenge->data)) {
|
||||
static::throw($validation, "Response challenge is not equal.");
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
50
src/SharedPipes/CheckOriginSecure.php
Normal file
50
src/SharedPipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function parse_url;
|
||||
|
||||
abstract class CheckOriginSecure
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if (!$validation->clientDataJson->origin) {
|
||||
static::throw($validation, 'Response has an empty origin.');
|
||||
}
|
||||
|
||||
$origin = parse_url($validation->clientDataJson->origin);
|
||||
|
||||
if (!$origin || !isset($origin['host'], $origin['scheme'])) {
|
||||
static::throw($validation, 'Response origin is invalid.');
|
||||
}
|
||||
|
||||
if ($origin['host'] !== 'localhost' && $origin['scheme'] !== 'https') {
|
||||
static::throw($validation, 'Response not made to a secure server (localhost or HTTPS).');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
70
src/SharedPipes/CheckRelyingPartyHashSame.php
Normal file
70
src/SharedPipes/CheckRelyingPartyHashSame.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function parse_url;
|
||||
use const PHP_URL_HOST;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckRelyingPartyHashSame
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
// This way we can get the app RP ID on attestation, and the Credential RP ID
|
||||
// on assertion. The credential will have the same Relaying Party ID on both
|
||||
// the authenticator and the application so on assertion both should match.
|
||||
$relayingParty = parse_url($this->relyingPartyId($validation), PHP_URL_HOST);
|
||||
|
||||
if ($this->authenticatorData($validation)->hasNotSameRPIdHash($relayingParty)) {
|
||||
static::throw($validation, 'Response has different Relying Party ID hash.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
abstract protected function authenticatorData(
|
||||
AttestationValidation|AssertionValidation $validation
|
||||
): AuthenticatorData;
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string;
|
||||
}
|
||||
57
src/SharedPipes/CheckRelyingPartyIdContained.php
Normal file
57
src/SharedPipes/CheckRelyingPartyIdContained.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Support\Str;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function hash_equals;
|
||||
use function parse_url;
|
||||
use const PHP_URL_HOST;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckRelyingPartyIdContained
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if (!$host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) {
|
||||
static::throw($validation, 'Relaying Party ID is invalid.');
|
||||
}
|
||||
|
||||
$current = parse_url(
|
||||
$this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'), PHP_URL_HOST
|
||||
);
|
||||
|
||||
// Check the host is the same or is a subdomain of the current config domain.
|
||||
if (hash_equals($current, $host) || Str::is("*.$current", $host)) {
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
static::throw($validation, 'Relaying Party ID not scoped to current.');
|
||||
}
|
||||
}
|
||||
48
src/SharedPipes/CheckUserInteraction.php
Normal file
48
src/SharedPipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckUserInteraction
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$notPresent = $validation instanceof AttestationValidation
|
||||
? $validation->attestationObject->authenticatorData->wasUserAbsent()
|
||||
: $validation->authenticatorData->wasUserAbsent();
|
||||
|
||||
if ($notPresent) {
|
||||
static::throw($validation, 'Response did not have the user present.');
|
||||
}
|
||||
|
||||
// Only verify the user if the challenge required it.
|
||||
if ($validation->challenge->verify) {
|
||||
$notVerified = $validation instanceof AttestationValidation
|
||||
? $validation->attestationObject->authenticatorData->wasUserNotVerified()
|
||||
: $validation->authenticatorData->wasUserNotVerified();
|
||||
|
||||
if ($notVerified) {
|
||||
static::throw($validation, 'Response did not verify the user.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
57
src/SharedPipes/CompileClientDataJson.php
Normal file
57
src/SharedPipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use JsonException;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use function base64_decode;
|
||||
use function json_decode;
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CompileClientDataJson
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation|AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
try {
|
||||
$object = json_decode(
|
||||
base64_decode($validation->request->json('response.clientDataJSON', '')), false, 32, JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (JsonException) {
|
||||
static::throw($validation, 'Client Data JSON is invalid or malformed.');
|
||||
}
|
||||
|
||||
if (!$object) {
|
||||
static::throw($validation, 'Client Data JSON is empty.');
|
||||
}
|
||||
|
||||
foreach (['type', 'origin', 'challenge'] as $key) {
|
||||
if (!isset($object->{$key})) {
|
||||
static::throw($validation, "Client Data JSON does not contain the [$key] key.");
|
||||
}
|
||||
}
|
||||
|
||||
$validation->clientDataJson = new ClientDataJson(
|
||||
$object->type, $object->origin, ByteBuffer::fromBase64Url($object->challenge)
|
||||
);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
66
src/SharedPipes/RetrieveChallenge.php
Normal file
66
src/SharedPipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
|
||||
/**
|
||||
* This should be the first pipe to run, as the Challenge may expire by mere milliseconds.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class RetrieveChallenge
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->challenge = $this->retrieveChallenge($validation->request);
|
||||
|
||||
if (!$validation->challenge) {
|
||||
static::throw($validation, 'Challenge does not exist.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls an Attestation challenge from the Cache.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Laragear\WebAuthn\Challenge|null
|
||||
*/
|
||||
protected function retrieveChallenge(Request $request): ?Challenge
|
||||
{
|
||||
/** @var \Laragear\WebAuthn\Challenge|null $challenge */
|
||||
$challenge = $request->session()->pull($this->config->get('webauthn.challenge.key'));
|
||||
|
||||
if (!$challenge || $challenge->hasExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $challenge;
|
||||
}
|
||||
}
|
||||
29
src/SharedPipes/ThrowsCeremonyException.php
Normal file
29
src/SharedPipes/ThrowsCeremonyException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ThrowsCeremonyException
|
||||
{
|
||||
/**
|
||||
* Throws an exception for the validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param string $message
|
||||
* @return void&never
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException|\Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
protected static function throw(AttestationValidation|AssertionValidation $validation, string $message): void
|
||||
{
|
||||
throw $validation instanceof AssertionValidation
|
||||
? AssertionException::make($message)
|
||||
: AttestationException::make($message);
|
||||
}
|
||||
}
|
||||
38
src/WebAuthn.php
Normal file
38
src/WebAuthn.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class WebAuthn
|
||||
{
|
||||
// Constants for user verification in Attestation and Assertion.
|
||||
public const USER_VERIFICATION_PREFERRED = 'preferred';
|
||||
public const USER_VERIFICATION_DISCOURAGED = 'discouraged';
|
||||
public const USER_VERIFICATION_REQUIRED = 'required';
|
||||
|
||||
// Attestation variables to limit the authenticator conveyance.
|
||||
public const PLATFORMS = ['cross-platform', 'platform'];
|
||||
public const TRANSPORTS = ['usb', 'nfc', 'ble', 'internal'];
|
||||
public const FORMATS = ['none', 'android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm'];
|
||||
|
||||
// Resident Keys requirement.
|
||||
public const RESIDENT_KEY_REQUIRED = 'required';
|
||||
public const RESIDENT_KEY_PREFERRED = 'preferred';
|
||||
public const RESIDENT_KEY_DISCOURAGED = 'discouraged';
|
||||
|
||||
/**
|
||||
* Returns all user verifications flags possible.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function userVerifications(): array
|
||||
{
|
||||
return [
|
||||
static::USER_VERIFICATION_REQUIRED,
|
||||
static::USER_VERIFICATION_PREFERRED,
|
||||
static::USER_VERIFICATION_DISCOURAGED,
|
||||
];
|
||||
}
|
||||
}
|
||||
97
src/WebAuthnAuthentication.php
Normal file
97
src/WebAuthnAuthentication.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $webAuthnCredentials
|
||||
*
|
||||
* @see \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
|
||||
* @see \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
trait WebAuthnAuthentication
|
||||
{
|
||||
/**
|
||||
* Returns displayable data to be used to create WebAuthn Credentials.
|
||||
*
|
||||
* @return array{name: string, displayName: string}
|
||||
*/
|
||||
#[ArrayShape(['name' => "string", 'displayName' => "string"])]
|
||||
public function webAuthnData(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->email,
|
||||
'displayName' => $this->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all credentials previously registered.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function flushCredentials(string ...$except): void
|
||||
{
|
||||
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) {
|
||||
$partitioned = $this->webAuthnCredentials
|
||||
->partition(static function (WebAuthnCredential $credential) use ($except): bool {
|
||||
return in_array($credential->getKey(), $except, true);
|
||||
});
|
||||
|
||||
$partitioned->first()->each->delete();
|
||||
|
||||
$this->setRelation('webAuthnCredentials', $partitioned->last());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->webAuthnCredentials()->whereKeyNot($except)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all credentials for the user.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function disableAllCredentials(string ...$except): void
|
||||
{
|
||||
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) {
|
||||
$this->webAuthnCredentials
|
||||
->each(static function (WebAuthnCredential $credential) use ($except): bool {
|
||||
if ($credential->isEnabled() && in_array($credential->getKey(), $except, true)) {
|
||||
$credential->disable();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$this->webAuthnCredentials()->whereKeyNot($except)->update(['is_enabled' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an instance of a WebAuthn Credential attached to this user.
|
||||
*
|
||||
* @param array $properties
|
||||
* @return \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function makeWebAuthnCredential(array $properties): Models\WebAuthnCredential
|
||||
{
|
||||
return $this->webAuthnCredentials()->make()->forceFill($properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a queryable relationship for its WebAuthn Credentials.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function webAuthnCredentials(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Models\WebAuthnCredential::class, 'authenticatable');
|
||||
}
|
||||
}
|
||||
115
src/WebAuthnServiceProvider.php
Normal file
115
src/WebAuthnServiceProvider.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class WebAuthnServiceProvider extends ServiceProvider
|
||||
{
|
||||
public const ROUTES = __DIR__.'/../routes/webauthn.php';
|
||||
public const CONTROLLERS = __DIR__.'/../stubs/controllers';
|
||||
public const CONFIG = __DIR__.'/../config/webauthn.php';
|
||||
public const MIGRATIONS = __DIR__.'/../database/migrations';
|
||||
public const JS = __DIR__.'/../resources/js';
|
||||
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(static::CONFIG, 'webauthn');
|
||||
|
||||
$this->registerUser();
|
||||
|
||||
$this->registerUserProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the service provider.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishesMigrations(static::MIGRATIONS);
|
||||
$this->publishes([static::ROUTES => $this->app->basePath('routes/webauthn.php')], 'routes');
|
||||
$this->publishes([static::CONTROLLERS => $this->app->path('Http/Controllers/WebAuthn')], 'controllers');
|
||||
$this->publishes([static::JS => $this->app->resourcePath('js/vendor/webauthn')], 'js');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes migrations from the given path.
|
||||
*
|
||||
* @param array|string $paths
|
||||
* @param string $groups
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function publishesMigrations(array|string $paths, string $groups = 'migrations'): void
|
||||
{
|
||||
$prefix = now()->format('Y_m_d_His');
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach ($this->app->make('files')->files($paths) as $file) {
|
||||
$filename = preg_replace('/^[\d|_]+/', '', $file->getFilename());
|
||||
|
||||
$files[$file->getRealPath()] = $this->app->databasePath("migrations/{$prefix}_$filename");
|
||||
}
|
||||
|
||||
$this->publishes($files, $groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Web Authenticatable User.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerUser(): void
|
||||
{
|
||||
$this->app->bind(
|
||||
Contracts\WebAuthnAuthenticatable::class,
|
||||
static function (Application $app): ?Contracts\WebAuthnAuthenticatable {
|
||||
$user = $app->make(AuthenticatableContract::class);
|
||||
|
||||
return $user instanceof WebAuthnAuthenticatable ? $user : null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the Authentication Factory with a WebAuthn Eloquent-Compatible User Provider.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function registerUserProvider(): void
|
||||
{
|
||||
$this->callAfterResolving('auth', static function (AuthManager $auth): void {
|
||||
$auth->provider(
|
||||
'eloquent-webauthn',
|
||||
static function (Application $app, array $config): Auth\WebAuthnUserProvider {
|
||||
return new Auth\WebAuthnUserProvider(
|
||||
$app->make('hash'),
|
||||
$config['model'],
|
||||
$app->make(Assertion\Validator\AssertionValidator::class),
|
||||
$config['password_fallback'] ?? true,
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user