├── resources ├── lang │ └── en │ │ ├── confirm.php │ │ └── recovery.php ├── views │ ├── confirm.blade.php │ ├── lost.blade.php │ ├── layout.blade.php │ └── recover.blade.php └── js │ └── larapass.js ├── src ├── WebAuthn │ ├── PublicKeyCredentialParametersCollection.php │ ├── AuthenticatorSelectionCriteria.php │ ├── WebAuthnAttestValidator.php │ ├── WebAuthnAttestCreator.php │ └── WebAuthnAssertValidator.php ├── Events │ └── AttestationSuccessful.php ├── Eloquent │ ├── Casting │ │ ├── Base64UrlCast.php │ │ ├── TrustPathCast.php │ │ └── UuidCast.php │ ├── WebAuthnCredential.php │ └── ManagesCredentialRepository.php ├── Http │ ├── WebAuthnRules.php │ ├── RegistersWebAuthn.php │ ├── ConfirmsWebAuthn.php │ ├── Middleware │ │ └── RequireWebAuthn.php │ ├── SendsWebAuthnRecoveryEmail.php │ ├── AuthenticatesWebAuthn.php │ └── RecoversWebAuthn.php ├── Auth │ ├── CredentialBroker.php │ └── EloquentWebAuthnProvider.php ├── Notifications │ └── AccountRecoveryNotification.php ├── Contracts │ └── WebAuthnAuthenticatable.php ├── Facades │ └── WebAuthn.php ├── WebAuthnAuthentication.php └── LarapassServiceProvider.php ├── stubs ├── WebAuthnLoginController.php ├── WebAuthnDeviceLostController.php ├── WebAuthnRegisterController.php ├── WebAuthnConfirmController.php └── WebAuthnRecoveryController.php ├── LICENSE.md ├── composer.json ├── database └── migrations │ └── 2020_04_02_000000_create_web_authn_tables.php ├── config └── larapass.php └── README.md /resources/lang/en/confirm.php: -------------------------------------------------------------------------------- 1 | 'Please confirm with your device before continuing', 5 | 'button' => 'Confirm' 6 | ]; -------------------------------------------------------------------------------- /src/WebAuthn/PublicKeyCredentialParametersCollection.php: -------------------------------------------------------------------------------- 1 | middleware(['guest', 'throttle:10,1']); 26 | } 27 | } -------------------------------------------------------------------------------- /stubs/WebAuthnDeviceLostController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 26 | } 27 | } -------------------------------------------------------------------------------- /stubs/WebAuthnRegisterController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 31 | } 32 | } -------------------------------------------------------------------------------- /resources/lang/en/recovery.php: -------------------------------------------------------------------------------- 1 | 'Account recovery', 5 | 6 | 'description' => 'If you can\'t login with your device, you can register another by opening an email there.', 7 | 'details' => 'Ensure you open the email on a device you fully own.', 8 | 9 | 'instructions' => 'Press the button to use this device for your account and follow your the instructions.', 10 | 'unique' => 'Disable all others devices except this.', 11 | 12 | 'button' => [ 13 | 'send' => 'Send account recovery', 14 | 'register' => 'Register this device', 15 | ], 16 | 17 | 'sent' => 'If the email is correct, you should receive an email with a recovery link shortly.', 18 | 'attached' => 'A new device has been attached to your account to authenticate.', 19 | 'user' => 'We can\'t find a user with that email address.', 20 | 'token' => 'The token is invalid or has expired.', 21 | 'throttled' => 'Please wait before retrying.', 22 | 23 | 'failed' => 'The recovery failed. Try again.', 24 | ]; -------------------------------------------------------------------------------- /src/Events/AttestationSuccessful.php: -------------------------------------------------------------------------------- 1 | user = $user; 35 | $this->credential = $credential; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Italo Israel Baeza Cabrera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Eloquent/Casting/Base64UrlCast.php: -------------------------------------------------------------------------------- 1 | 'required|string', 16 | 'rawId' => 'required|string', 17 | 'response.attestationObject' => 'required|string', 18 | 'response.clientDataJSON' => 'required|string', 19 | 'type' => 'required|string', 20 | ]; 21 | } 22 | 23 | /** 24 | * The assertion rules to validate the incoming JSON payload. 25 | * 26 | * @return array|string[] 27 | */ 28 | protected function assertionRules(): array 29 | { 30 | return [ 31 | 'id' => 'required|string', 32 | 'rawId' => 'required|string', 33 | 'response.authenticatorData' => 'required|string', 34 | 'response.clientDataJSON' => 'required|string', 35 | 'response.signature' => 'required|string', 36 | 'response.userHandle' => 'sometimes|nullable', 37 | 'type' => 'required|string', 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /stubs/WebAuthnConfirmController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 39 | $this->middleware('throttle:10,1')->only('options', 'confirm'); 40 | } 41 | } -------------------------------------------------------------------------------- /stubs/WebAuthnRecoveryController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 39 | $this->middleware('throttle:10,1')->only('options', 'recover'); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Eloquent/Casting/TrustPathCast.php: -------------------------------------------------------------------------------- 1 | 7 |

{{ __('Please confirm with your device before continuing') }}

8 |
9 |
10 | 13 |
14 | 15 | @endsection 16 | 17 | @push('scripts') 18 | 19 | 36 | @endpush -------------------------------------------------------------------------------- /src/Eloquent/Casting/UuidCast.php: -------------------------------------------------------------------------------- 1 | toString(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/views/lost.blade.php: -------------------------------------------------------------------------------- 1 | @extends('larapass::layout') 2 | 3 | @section('title', trans('larapass::recovery.title')) 4 | 5 | @section('body') 6 |
7 | @csrf 8 |

{{ trans('larapass::recovery.title') }}

9 |
10 |

{{ trans('larapass::recovery.description') }}

11 | @if($errors->any()) 12 |
13 | 18 |
19 | @elseif(session('status')) 20 |
21 | {{ session('status') }} 22 |
23 | @endif 24 |
25 | 26 | 27 | {{ trans('larapass::recovery.details') }} 28 |
29 |
30 | 31 |
32 |
33 | @endsection -------------------------------------------------------------------------------- /resources/views/layout.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | @yield('title') 10 | 26 | 27 | 28 |
29 |
30 |
31 |
32 |
33 | @yield('body') 34 |
35 |
36 |
37 |
38 |
39 | @stack('scripts') 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/WebAuthn/AuthenticatorSelectionCriteria.php: -------------------------------------------------------------------------------- 1 | residentKey = $residentKey; 34 | 35 | return $this; 36 | } 37 | 38 | public function getResidentKey(): ?string 39 | { 40 | return $this->residentKey; 41 | } 42 | 43 | /** 44 | * @return array 45 | */ 46 | public function jsonSerialize(): array 47 | { 48 | $serialied = parent::jsonSerialize(); 49 | 50 | if (null !== $this->residentKey) { 51 | $serialied['residentKey'] = $this->residentKey; 52 | } 53 | 54 | return $serialied; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "darkghosthunter/larapass", 3 | "description": "Authenticate users with just their device, fingerprint or biometric data. Goodbye passwords!", 4 | "keywords": [ 5 | "darkghosthunter", 6 | "webauthn", 7 | "laravel" 8 | ], 9 | "homepage": "https://github.com/darkghosthunter/larapass", 10 | "license": "MIT", 11 | "type": "library", 12 | "authors": [ 13 | { 14 | "name": "Italo Israel Baeza Cabrera", 15 | "email": "darkghosthunter@gmail.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=7.4.0", 21 | "ext-json": "*", 22 | "illuminate/support": "^8.0", 23 | "nyholm/psr7": "^1.3", 24 | "ramsey/uuid": "^4.0", 25 | "symfony/psr-http-message-bridge": "^2.0", 26 | "web-auth/webauthn-lib": "^3.3", 27 | "thecodingmachine/safe": "^1.3.3" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^9.5.2", 31 | "laravel/framework": "8.*", 32 | "orchestra/testbench": "^6.7.2" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "DarkGhostHunter\\Larapass\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Tests\\": "tests/" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "vendor/bin/phpunit", 46 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 47 | }, 48 | "config": { 49 | "sort-packages": true 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "DarkGhostHunter\\Larapass\\LarapassServiceProvider" 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /database/migrations/2020_04_02_000000_create_web_authn_tables.php: -------------------------------------------------------------------------------- 1 | string('id', 255); 20 | 21 | // Change accordingly for your users table if you need to. 22 | $table->unsignedBigInteger('user_id'); 23 | 24 | $table->string('name')->nullable(); 25 | $table->string('type', 16); 26 | $table->json('transports'); 27 | $table->string('attestation_type'); 28 | $table->json('trust_path'); 29 | $table->uuid('aaguid'); 30 | $table->binary('public_key'); 31 | $table->unsignedInteger('counter')->default(0); 32 | 33 | // This saves the external "ID" that identifies the user. We use UUID default 34 | // since it's very straightforward. You can change this for a plain string. 35 | // It must be nullable because those old U2F keys do not use user handle. 36 | $table->uuid('user_handle')->nullable(); 37 | 38 | $table->timestamps(); 39 | $table->softDeletes(WebAuthnCredential::DELETED_AT); 40 | 41 | $table->primary(['id', 'user_id']); 42 | }); 43 | 44 | Schema::create('web_authn_recoveries', function (Blueprint $table) { 45 | $table->string('email')->index(); 46 | $table->string('token'); 47 | $table->timestamp('created_at')->nullable(); 48 | }); 49 | } 50 | 51 | /** 52 | * Reverse the migrations. 53 | * 54 | * @return void 55 | */ 56 | public function down() 57 | { 58 | Schema::dropIfExists('web_authn_credentials'); 59 | Schema::dropIfExists('web_authn_recoveries'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /resources/views/recover.blade.php: -------------------------------------------------------------------------------- 1 | @extends('larapass::layout') 2 | 3 | @section('title', trans('larapass::recovery.title')) 4 | 5 | @section('body') 6 |
7 | 8 | 9 |

{{ trans('larapass::recovery.title') }}

10 |
11 |

{{ trans('larapass::recovery.instructions') }}

12 | @if ($errors->any()) 13 |
14 |
    15 | @foreach ($errors->all() as $error) 16 |
  • {{ $error }}
  • 17 | @endforeach 18 |
19 |
20 | @endif 21 |
22 |
23 | 24 | 25 |
26 |
27 |
28 | 31 |
32 |
33 | @endsection 34 | 35 | @push('scripts') 36 | 37 | 60 | @endpush -------------------------------------------------------------------------------- /src/Http/RegistersWebAuthn.php: -------------------------------------------------------------------------------- 1 | json(WebAuthn::generateAttestation($user)); 27 | } 28 | 29 | /** 30 | * Registers a device for further WebAuthn authentication. 31 | * 32 | * @param \Illuminate\Http\Request $request 33 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 34 | * 35 | * @return \Illuminate\Http\Response 36 | */ 37 | public function register(Request $request, WebAuthnAuthenticatable $user): Response 38 | { 39 | $input = $request->validate($this->attestationRules()); 40 | 41 | // We'll validate the challenge coming from the authenticator and instantly 42 | // save it into the credentials store. If the data is invalid we will bail 43 | // out and return a non-authorized response since we can't save the data. 44 | $validCredential = WebAuthn::validateAttestation($input, $user); 45 | 46 | if ($validCredential) { 47 | $user->addCredential($validCredential); 48 | 49 | event(new AttestationSuccessful($user, $validCredential)); 50 | 51 | return $this->credentialRegistered($user, $validCredential) ?? response()->noContent(); 52 | } 53 | 54 | return response()->noContent(422); 55 | } 56 | 57 | /** 58 | * The user has registered a credential. 59 | * 60 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 61 | * @param \Webauthn\PublicKeyCredentialSource $credentials 62 | * 63 | * @return void|mixed 64 | */ 65 | protected function credentialRegistered(WebAuthnAuthenticatable $user, PublicKeyCredentialSource $credentials) 66 | { 67 | // ... 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Http/ConfirmsWebAuthn.php: -------------------------------------------------------------------------------- 1 | validate($this->assertionRules()); 46 | 47 | if (WebAuthn::validateAssertion($credential)) { 48 | $this->resetAuthenticatorConfirmationTimeout($request); 49 | 50 | return response()->json(['redirectTo' => redirect()->intended($this->redirectPath())->getTargetUrl()]); 51 | } 52 | 53 | return response()->noContent(422); 54 | } 55 | 56 | /** 57 | * Reset the password confirmation timeout. 58 | * 59 | * @param \Illuminate\Http\Request $request 60 | * 61 | * @return void 62 | */ 63 | protected function resetAuthenticatorConfirmationTimeout(Request $request): void 64 | { 65 | $request->session()->put('auth.webauthn.confirm', now()->timestamp); 66 | } 67 | 68 | /** 69 | * Get the post recovery redirect path. 70 | * 71 | * @return string 72 | */ 73 | public function redirectPath(): string 74 | { 75 | if (method_exists($this, 'redirectTo')) { 76 | return $this->redirectTo(); 77 | } 78 | 79 | return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Http/Middleware/RequireWebAuthn.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 54 | $this->urlGenerator = $urlGenerator; 55 | $this->session = $session; 56 | $this->remember = $config->get('larapass.confirm_timeout', 10800); 57 | } 58 | 59 | /** 60 | * Handle an incoming request. 61 | * 62 | * @param \Illuminate\Http\Request $request 63 | * @param \Closure $next 64 | * @param string $redirectToRoute 65 | * 66 | * @return mixed 67 | */ 68 | public function handle($request, Closure $next, $redirectToRoute = 'webauthn.confirm.form') 69 | { 70 | if ($this->shouldConfirmAuthenticator()) { 71 | if ($request->expectsJson()) { 72 | return $this->responseFactory->json(['message' => 'Authenticator assertion required.'], 423); 73 | } 74 | 75 | return $this->responseFactory->redirectGuest( 76 | $this->urlGenerator->route($redirectToRoute) 77 | ); 78 | } 79 | 80 | return $next($request); 81 | } 82 | 83 | /** 84 | * Determine if the confirmation timeout has expired. 85 | * 86 | * @return bool 87 | */ 88 | protected function shouldConfirmAuthenticator(): bool 89 | { 90 | $confirmedAt = now()->timestamp - $this->session->get('auth.webauthn.confirm', 0); 91 | 92 | return $confirmedAt > $this->remember; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Auth/CredentialBroker.php: -------------------------------------------------------------------------------- 1 | getUser($credentials); 58 | 59 | if (!$user instanceof WebAuthnAuthenticatable) { 60 | return static::INVALID_USER; 61 | } 62 | 63 | if ($this->tokens->recentlyCreatedToken($user)) { 64 | return static::RESET_THROTTLED; 65 | } 66 | 67 | $token = $this->tokens->create($user); 68 | 69 | if ($callback) { 70 | $callback($user, $token); 71 | } else { 72 | $user->sendCredentialRecoveryNotification($token); 73 | } 74 | 75 | return static::RESET_LINK_SENT; 76 | } 77 | 78 | /** 79 | * Reset the password for the given token. 80 | * 81 | * @param array $credentials 82 | * @param \Closure $callback 83 | * 84 | * @return \Illuminate\Contracts\Auth\CanResetPassword|string 85 | */ 86 | public function reset(array $credentials, Closure $callback) 87 | { 88 | $user = $this->validateReset($credentials); 89 | 90 | if (!$user instanceof CanResetPasswordContract || !$user instanceof WebAuthnAuthenticatable) { 91 | return $user; 92 | } 93 | 94 | $callback($user); 95 | 96 | $this->tokens->delete($user); 97 | 98 | return static::PASSWORD_RESET; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Http/SendsWebAuthnRecoveryEmail.php: -------------------------------------------------------------------------------- 1 | validate($this->recoveryRules()); 33 | 34 | $response = WebAuthn::sendRecoveryLink($credentials); 35 | 36 | return $response === WebAuthn::RECOVERY_SENT 37 | ? $this->sendRecoveryLinkResponse($request, $response) 38 | : $this->sendRecoveryLinkFailedResponse($request, $response); 39 | } 40 | 41 | /** 42 | * The recovery credentials to retrieve through validation rules. 43 | * 44 | * @return array|string[] 45 | */ 46 | protected function recoveryRules(): array 47 | { 48 | return [ 49 | 'email' => 'required|email', 50 | ]; 51 | } 52 | 53 | /** 54 | * Get the response for a successful account recovery link. 55 | * 56 | * @param \Illuminate\Http\Request $request 57 | * @param string $response 58 | * 59 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse 60 | */ 61 | protected function sendRecoveryLinkResponse(Request $request, string $response) 62 | { 63 | return $request->wantsJson() 64 | ? new JsonResponse(['message' => trans($response)], 200) 65 | : back()->with('status', trans($response)); 66 | } 67 | 68 | /** 69 | * Get the response for a failed account recovery link. 70 | * 71 | * @param \Illuminate\Http\Request $request 72 | * @param string $response 73 | * 74 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse 75 | * @throws \Illuminate\Validation\ValidationException 76 | */ 77 | protected function sendRecoveryLinkFailedResponse(Request $request, string $response) 78 | { 79 | if ($request->wantsJson()) { 80 | throw ValidationException::withMessages(['email' => [trans($response)]]); 81 | } 82 | 83 | return back() 84 | ->withInput($request->only('email')) 85 | ->withErrors(['email' => trans($response)]); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Auth/EloquentWebAuthnProvider.php: -------------------------------------------------------------------------------- 1 | fallback = $config->get('larapass.fallback'); 41 | $this->validator = $validator; 42 | 43 | parent::__construct($hasher, $model); 44 | } 45 | 46 | /** 47 | * Retrieve a user by the given credentials. 48 | * 49 | * @param array $credentials 50 | * 51 | * @return \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null|void 52 | */ 53 | public function retrieveByCredentials(array $credentials) 54 | { 55 | if ($this->isSignedChallenge($credentials)) { 56 | return $this->model::getFromCredentialId($credentials['id']); 57 | } 58 | 59 | return parent::retrieveByCredentials($credentials); 60 | } 61 | 62 | /** 63 | * Check if the credentials are for a public key signed challenge 64 | * 65 | * @param array $credentials 66 | * 67 | * @return bool 68 | */ 69 | protected function isSignedChallenge(array $credentials): bool 70 | { 71 | return isset($credentials['id'], $credentials['rawId'], $credentials['type'], $credentials['response']); 72 | } 73 | 74 | /** 75 | * Validate a user against the given credentials. 76 | * 77 | * @param \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 78 | * @param array $credentials 79 | * 80 | * @return bool 81 | */ 82 | public function validateCredentials($user, array $credentials): bool 83 | { 84 | if ($this->isSignedChallenge($credentials)) { 85 | return (bool)$this->validator->validate($credentials); 86 | } 87 | 88 | // If the fallback is enabled, we will validate the credential password. 89 | if ($this->fallback) { 90 | return parent::validateCredentials($user, $credentials); 91 | } 92 | 93 | return false; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Notifications/AccountRecoveryNotification.php: -------------------------------------------------------------------------------- 1 | token = $token; 41 | } 42 | 43 | /** 44 | * Get the notification's channels. 45 | * 46 | * @param mixed $notifiable 47 | * 48 | * @return array|string 49 | */ 50 | public function via($notifiable) 51 | { 52 | return ['mail']; 53 | } 54 | 55 | /** 56 | * Build the mail representation of the notification. 57 | * 58 | * @param mixed $notifiable 59 | * 60 | * @return \Illuminate\Notifications\Messages\MailMessage 61 | */ 62 | public function toMail($notifiable) 63 | { 64 | if (static::$toMailCallback) { 65 | return call_user_func(static::$toMailCallback, $notifiable, $this->token); 66 | } 67 | 68 | if (static::$createUrlCallback) { 69 | $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token); 70 | } else { 71 | $url = url( 72 | route( 73 | 'webauthn.recover.form', 74 | [ 75 | 'token' => $this->token, 76 | 'email' => $notifiable->getEmailForPasswordReset(), 77 | ], 78 | false 79 | ) 80 | ); 81 | } 82 | 83 | return (new MailMessage()) 84 | ->subject(Lang::get('Account Recovery Notification')) 85 | ->line( 86 | Lang::get( 87 | 'You are receiving this email because we received an account recovery request for your account.' 88 | ) 89 | ) 90 | ->action(Lang::get('Recover Account'), $url) 91 | ->line( 92 | Lang::get( 93 | 'This recovery link will expire in :count minutes.', 94 | ['count' => config('auth.passwords.webauthn.expire')] 95 | ) 96 | ) 97 | ->line(Lang::get('If you did not request an account recovery, no further action is required.')); 98 | } 99 | 100 | /** 101 | * Set a callback that should be used when creating the reset password button URL. 102 | * 103 | * @param \Closure|null $callback 104 | * 105 | * @return void 106 | */ 107 | public static function createUrlUsing(?Closure $callback): void 108 | { 109 | static::$createUrlCallback = $callback; 110 | } 111 | 112 | /** 113 | * Set a callback that should be used when building the notification mail message. 114 | * 115 | * @param \Closure|null $callback 116 | * 117 | * @return void 118 | */ 119 | public static function toMailUsing(?Closure $callback): void 120 | { 121 | static::$toMailCallback = $callback; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Eloquent/WebAuthnCredential.php: -------------------------------------------------------------------------------- 1 | 'collection', 77 | 'counter' => 'integer', 78 | 'trust_path' => Casting\TrustPathCast::class, 79 | 'aaguid' => Casting\UuidCast::class, 80 | ]; 81 | 82 | /** 83 | * The attributes that are mass assignable. 84 | * 85 | * @var array 86 | */ 87 | protected $fillable = [ 88 | 'id', 89 | 'name', 90 | 'type', 91 | 'transports', 92 | 'attestation_type', 93 | 'trust_path', 94 | 'aaguid', 95 | 'public_key', 96 | 'user_handle', 97 | 'counter', 98 | ]; 99 | 100 | /** 101 | * Returns if the Credential is enabled. 102 | * 103 | * @return bool 104 | */ 105 | public function isEnabled(): bool 106 | { 107 | return !$this->disabled_at; 108 | } 109 | 110 | /** 111 | * Returns if the Credential is disabled. 112 | * 113 | * @return bool 114 | */ 115 | public function isDisabled(): bool 116 | { 117 | return !$this->isEnabled(); 118 | } 119 | 120 | /** 121 | * Filter the credentials for those explicitly enabled. 122 | * 123 | * @param \Illuminate\Database\Eloquent\Builder $builder 124 | * 125 | * @return \Illuminate\Database\Eloquent\Builder 126 | */ 127 | public function scopeEnabled(Builder $builder): Builder 128 | { 129 | return $builder->withoutTrashed(); 130 | } 131 | 132 | /** 133 | * Convert the object into something JSON serializable. 134 | * 135 | * @return array 136 | */ 137 | public function jsonSerialize() 138 | { 139 | return array_merge($this->toArray(), [$this->getKeyName() => $this->getRawOriginal($this->getKeyName())]); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Contracts/WebAuthnAuthenticatable.php: -------------------------------------------------------------------------------- 1 | mergeCasts([$this->getKeyName() => Casting\Base64UrlCast::class]); 20 | } 21 | 22 | /** 23 | * Finds a source of the credentials. 24 | * 25 | * @param string $binaryId 26 | * 27 | * @return null|\Webauthn\PublicKeyCredentialSource 28 | */ 29 | public function findOneByCredentialId(string $binaryId): ?CredentialSource 30 | { 31 | return optional($this->find(Base64Url::encode($binaryId)))->toCredentialSource(); 32 | } 33 | 34 | /** 35 | * Return an array of all credentials for a given user. 36 | * 37 | * @param \Webauthn\PublicKeyCredentialUserEntity $entity 38 | * 39 | * @return array|\Webauthn\PublicKeyCredentialSource[] 40 | */ 41 | public function findAllForUserEntity(UserEntity $entity): array 42 | { 43 | return static::where('user_handle', $entity->getId())->get()->map->toCredentialSource()->all(); 44 | } 45 | 46 | /** 47 | * Update the credentials source into the storage. 48 | * 49 | * @param \Webauthn\PublicKeyCredentialSource $source 50 | */ 51 | public function saveCredentialSource(CredentialSource $source): void 52 | { 53 | // We will only update the credential counter only if it exists. 54 | static::where([$this->getKeyName() => Base64Url::encode($source->getPublicKeyCredentialId())]) 55 | ->update(['counter' => $source->getCounter()]); 56 | } 57 | 58 | /** 59 | * Creates a new Eloquent Model from a Credential Source. 60 | * 61 | * @param \Webauthn\PublicKeyCredentialSource $source 62 | * 63 | * @return self 64 | */ 65 | public static function fromCredentialSource(CredentialSource $source) 66 | { 67 | return ($model = new static())->fill( 68 | [ 69 | $model->getKeyName() => $source->getPublicKeyCredentialId(), 70 | 'user_handle' => $source->getUserHandle(), 71 | 'type' => $source->getType(), 72 | 'transports' => $source->getTransports(), 73 | 'attestation_type' => $source->getAttestationType(), 74 | 'trust_path' => $source->getTrustPath()->jsonSerialize(), 75 | 'aaguid' => $source->getAaguid()->toString(), 76 | 'public_key' => $source->getCredentialPublicKey(), 77 | 'counter' => $source->getCounter(), 78 | ] 79 | ); 80 | } 81 | 82 | /** 83 | * Transform the current Eloquent model to a Credential Source. 84 | * 85 | * @return \Webauthn\PublicKeyCredentialSource 86 | */ 87 | public function toCredentialSource(): CredentialSource 88 | { 89 | return new CredentialSource( 90 | $this->getKey(), 91 | $this->type, 92 | $this->transports->all(), 93 | $this->attestation_type, 94 | $this->trust_path, 95 | $this->aaguid, 96 | $this->public_key, 97 | $this->user_handle, 98 | $this->counter 99 | ); 100 | } 101 | 102 | /** 103 | * Returns a Credential Descriptor (anything except the public key). 104 | * 105 | * @return \Webauthn\PublicKeyCredentialDescriptor 106 | */ 107 | public function toCredentialDescriptor(): CredentialDescriptor 108 | { 109 | return $this->toCredentialSource()->getPublicKeyCredentialDescriptor(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Http/AuthenticatesWebAuthn.php: -------------------------------------------------------------------------------- 1 | validate($this->optionRules()); 26 | 27 | return WebAuthn::generateAssertion( 28 | $this->getUserFromCredentials($credentials) 29 | ); 30 | } 31 | 32 | /** 33 | * Return the rules for validate the Request. 34 | * 35 | * @return array 36 | */ 37 | protected function optionRules(): array 38 | { 39 | return [ 40 | $this->username() => 'sometimes|email', 41 | ]; 42 | } 43 | 44 | /** 45 | * Get the login user name to retrieve credentials ID. 46 | * 47 | * @return string 48 | */ 49 | protected function username(): string 50 | { 51 | return 'email'; 52 | } 53 | 54 | /** 55 | * Return the user that should authenticate via WebAuthn. 56 | * 57 | * @param array $credentials 58 | * 59 | * @return \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null 60 | */ 61 | protected function getUserFromCredentials(array $credentials) 62 | { 63 | // We will try to ask the User Provider for any user for the given credentials. 64 | // If there is one, we will then return an array of credentials ID that the 65 | // authenticator may use to sign the subsequent challenge by the server. 66 | return $this->userProvider()->retrieveByCredentials($credentials); 67 | } 68 | 69 | /** 70 | * Get the User Provider for WebAuthn Authenticatable users. 71 | * 72 | * @return \Illuminate\Contracts\Auth\UserProvider 73 | */ 74 | protected function userProvider(): UserProvider 75 | { 76 | return Auth::createUserProvider('users'); 77 | } 78 | 79 | /** 80 | * Log the user in. 81 | * 82 | * @param \Illuminate\Http\Request $request 83 | * 84 | * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse 85 | */ 86 | public function login(Request $request) 87 | { 88 | $credential = $request->validate($this->assertionRules()); 89 | 90 | if ($authenticated = $this->attemptLogin($credential, $this->hasRemember($request))) { 91 | return $this->authenticated($request, $this->guard()->user()) ?? response()->noContent(); 92 | } 93 | 94 | return response()->noContent(422); 95 | } 96 | 97 | /** 98 | * Check if the Request has a "Remember" value present. 99 | * 100 | * @param \Illuminate\Http\Request $request 101 | * 102 | * @return bool 103 | */ 104 | protected function hasRemember(Request $request): bool 105 | { 106 | return filter_var($request->header('WebAuthn-Remember'), FILTER_VALIDATE_BOOLEAN) 107 | ?: $request->filled('remember'); 108 | } 109 | 110 | /** 111 | * Attempt to log the user into the application. 112 | * 113 | * @param array $challenge 114 | * @param bool $remember 115 | * 116 | * @return bool 117 | */ 118 | protected function attemptLogin(array $challenge, bool $remember = false): bool 119 | { 120 | return $this->guard()->attempt($challenge, $remember); 121 | } 122 | 123 | /** 124 | * The user has been authenticated. 125 | * 126 | * @param \Illuminate\Http\Request $request 127 | * @param mixed $user 128 | * 129 | * @return void|\Illuminate\Http\JsonResponse 130 | */ 131 | protected function authenticated(Request $request, $user) 132 | { 133 | // 134 | } 135 | 136 | /** 137 | * Get the guard to be used during authentication. 138 | * 139 | * @return \Illuminate\Contracts\Auth\StatefulGuard 140 | */ 141 | protected function guard(): StatefulGuard 142 | { 143 | return Auth::guard(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/WebAuthn/WebAuthnAttestValidator.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 68 | $this->loader = $loader; 69 | $this->request = $request; 70 | 71 | parent::__construct( 72 | $config, 73 | $cache, 74 | $relyingParty, 75 | $criteria, 76 | $parameters, 77 | $extensions, 78 | $laravelRequest 79 | ); 80 | } 81 | 82 | /** 83 | * Validates the incoming response from the Client. 84 | * 85 | * @param array $data 86 | * @param \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 87 | * 88 | * @return bool|\Webauthn\PublicKeyCredentialSource 89 | */ 90 | public function validate(array $data, $user) 91 | { 92 | if (!$attestation = $this->retrieveAttestation($user)) { 93 | return false; 94 | } 95 | 96 | try { 97 | $credentials = $this->loader->loadArray($data)->getResponse(); 98 | 99 | if (!$credentials instanceof AuthenticatorAttestationResponse) { 100 | return false; 101 | } 102 | 103 | return $this->validator->check( 104 | $credentials, 105 | $attestation, 106 | $this->request, 107 | [$this->getCurrentRpId($attestation)] 108 | ); 109 | } catch (InvalidArgumentException $exception) { 110 | return false; 111 | } finally { 112 | $this->cache->forget($this->cacheKey($user)); 113 | } 114 | } 115 | 116 | /** 117 | * Returns the current Relaying Party ID to validate the response. 118 | * 119 | * @param \Webauthn\PublicKeyCredentialCreationOptions $attestation 120 | * 121 | * @return string 122 | */ 123 | protected function getCurrentRpId(CreationOptions $attestation): string 124 | { 125 | return $attestation->getRp()->getId() ?? $this->laravelRequest->getHost(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Facades/WebAuthn.php: -------------------------------------------------------------------------------- 1 | generateAttestation($user); 62 | } 63 | 64 | /** 65 | * Validates the attestation response, and returns the validated credentials. 66 | * 67 | * It returns `false` when the validation fails. 68 | * 69 | * @param array $data 70 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 71 | * 72 | * @return bool|\Webauthn\PublicKeyCredentialSource 73 | */ 74 | public static function validateAttestation(array $data, WebAuthnAuthenticatable $user) 75 | { 76 | return static::$app[WebAuthnAttestValidator::class]->validate($data, $user); 77 | } 78 | 79 | /** 80 | * Creates a new assertion request for a given user, or blank if there is no user given. 81 | * 82 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null $user 83 | * 84 | * @return \Webauthn\PublicKeyCredentialRequestOptions 85 | */ 86 | public static function generateAssertion(?WebAuthnAuthenticatable $user = null): PublicKeyCredentialRequestOptions 87 | { 88 | return static::$app[WebAuthnAssertValidator::class]->generateAssertion($user); 89 | } 90 | 91 | /** 92 | * Validates the attestation response, and returns the used credentials. 93 | * 94 | * It returns `false` when the validation fails. 95 | * 96 | * @param array $data 97 | * 98 | * @return bool 99 | */ 100 | public static function validateAssertion(array $data): bool 101 | { 102 | return (bool)static::$app[WebAuthnAssertValidator::class]->validate($data); 103 | } 104 | 105 | /** 106 | * Sends an account recovery email to an user by the credentials. 107 | * 108 | * @param array $credentials 109 | * 110 | * @return string 111 | */ 112 | public static function sendRecoveryLink(array $credentials): string 113 | { 114 | return static::$app[CredentialBroker::class]->sendResetLink($credentials); 115 | } 116 | 117 | /** 118 | * Recover the account for the given token. 119 | * 120 | * @param array $credentials 121 | * @param \Closure $callback 122 | * 123 | * @return \Illuminate\Contracts\Auth\CanResetPassword|mixed|string 124 | */ 125 | public static function recover(array $credentials, Closure $callback) 126 | { 127 | return static::$app[CredentialBroker::class]->reset($credentials, $callback); 128 | } 129 | 130 | /** 131 | * Get the user for the given credentials. 132 | * 133 | * @param array $credentials 134 | * 135 | * @return null|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\CanResetPassword 136 | */ 137 | public static function getUser(array $credentials) 138 | { 139 | return static::$app[CredentialBroker::class]->getUser($credentials); 140 | } 141 | 142 | /** 143 | * Validate the given account recovery token. 144 | * 145 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\CanResetPassword|null $user 146 | * @param string $token 147 | * 148 | * @return bool 149 | */ 150 | public static function tokenExists($user, string $token): bool 151 | { 152 | return $user ? static::$app[CredentialBroker::class]->tokenExists($user, $token) : false; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /config/larapass.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'name' => env('WEBAUTHN_NAME', env('APP_NAME')), 18 | 'id' => env('WEBAUTHN_ID'), 19 | 'icon' => env('WEBAUTHN_ICON'), 20 | ], 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Challenge configuration 25 | |-------------------------------------------------------------------------- 26 | | 27 | | When making challenges your application needs to push at least 16 bytes 28 | | of randomness. Since we need to later check them, we'll also store the 29 | | bytes for a sensible amount of seconds inside your default app cache. 30 | | 31 | */ 32 | 33 | 'bytes' => 16, 34 | 'timeout' => 60, 35 | 'cache' => env('WEBAUTHN_CACHE'), 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Algorithms 40 | |-------------------------------------------------------------------------- 41 | | 42 | | Here are default algorithms to use when asking to create sign and encrypt 43 | | binary objects like a public key and a challenge. These works almost in 44 | | any device, but you can add or change these depending on your devices. 45 | | 46 | | @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms 47 | | 48 | */ 49 | 50 | 'algorithms' => [ 51 | \Cose\Algorithm\Signature\ECDSA\ES256::class, // ECDSA with SHA-256 52 | \Cose\Algorithm\Signature\EdDSA\Ed25519::class, // EdDSA 53 | \Cose\Algorithm\Signature\ECDSA\ES384::class, // ECDSA with SHA-384 54 | \Cose\Algorithm\Signature\ECDSA\ES512::class, // ECDSA with SHA-512 55 | \Cose\Algorithm\Signature\RSA\RS256::class, // RSASSA-PKCS1-v1_5 with SHA-256 56 | ], 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Credentials Attachment. 61 | |-------------------------------------------------------------------------- 62 | | 63 | | Authentication can be tied to the current device (like when using Windows 64 | | Hello or Touch ID) or a cross-platform device (like USB Key). When this 65 | | is "null" the user will decide where to store his authentication info. 66 | | 67 | | Supported: "null", "cross-platform", "platform". 68 | | 69 | */ 70 | 71 | 'attachment' => null, 72 | 73 | /* 74 | |-------------------------------------------------------------------------- 75 | | Attestation Conveyance 76 | |-------------------------------------------------------------------------- 77 | | 78 | | The attestation is the data about the device and the public key used to 79 | | sign. Using "none" means the data is meaningless, "indirect" allows to 80 | | receive anonymized data, and "direct" means to receive the real data. 81 | | 82 | | Supported: "none", "indirect", "direct". 83 | | 84 | */ 85 | 86 | 'conveyance' => 'none', 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | User presence and verification 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Most authenticators and smartphones will ask the user to actively verify 94 | | themselves for log in. Use "required" to always ask verify, "preferred" 95 | | to ask when possible, and "discouraged" to just ask for user presence. 96 | | 97 | | Supported: "required", "preferred", "discouraged". 98 | | 99 | */ 100 | 101 | 'login_verify' => 'preferred', 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Userless (One touch, Typeless) login 106 | |-------------------------------------------------------------------------- 107 | | 108 | | By default, users must input their email to receive a list of credentials 109 | | ID to use for authentication, but they can also login without specifying 110 | | one if the device can remember them, allowing for true one-touch login. 111 | | 112 | | If required or preferred, login verification will be always required. 113 | | 114 | | Supported: "null", "required", "preferred", "discouraged". 115 | | 116 | */ 117 | 118 | 'userless' => null, 119 | 120 | /* 121 | |-------------------------------------------------------------------------- 122 | | Credential limit 123 | |-------------------------------------------------------------------------- 124 | | 125 | | Authenticators can have multiple credentials for the same user account. 126 | | To limit one device per user account, you can set this to true. This 127 | | will force the attest to fail when registering another credential. 128 | | 129 | */ 130 | 131 | 'unique' => false, 132 | 133 | /* 134 | |-------------------------------------------------------------------------- 135 | | Password Fallback 136 | |-------------------------------------------------------------------------- 137 | | 138 | | When using the `eloquent-webauthn´ user provider you will be able to use 139 | | the same user provider to authenticate users using their password. When 140 | | disabling this, users will be strictly authenticated only by WebAuthn. 141 | | 142 | */ 143 | 144 | 'fallback' => true, 145 | 146 | /* 147 | |-------------------------------------------------------------------------- 148 | | Device Confirmation 149 | |-------------------------------------------------------------------------- 150 | | 151 | | If you're using the "webauthn.confirm" middleware in your routes you may 152 | | want to adjust the time the confirmation is remembered in the browser. 153 | | This is measured in seconds, but it can be overridden in the route. 154 | | 155 | */ 156 | 157 | 'confirm_timeout' => 10800, // 3 hours 158 | ]; 159 | -------------------------------------------------------------------------------- /src/Http/RecoversWebAuthn.php: -------------------------------------------------------------------------------- 1 | missing('token', 'email')) { 28 | return redirect()->route('webauthn.lost.form'); 29 | } 30 | 31 | return view('larapass::recover')->with( 32 | ['token' => $request->query('token'), 'email' => $request->query('email')] 33 | ); 34 | } 35 | 36 | /** 37 | * Returns the credential creation options to the user. 38 | * 39 | * @param \Illuminate\Http\Request $request 40 | * 41 | * @return \Illuminate\Http\JsonResponse 42 | */ 43 | public function options(Request $request): JsonResponse 44 | { 45 | $user = WebAuthn::getUser($request->validate($this->rules())); 46 | 47 | // We will proceed only if the broker can find the user and the token is valid. 48 | // If the user doesn't exists or the token is invalid, we will bail out with a 49 | // HTTP 401 code because the user doing the request is not authorized for it. 50 | abort_unless(WebAuthn::tokenExists($user, $request->input('token')), 401); 51 | 52 | return response()->json(WebAuthn::generateAttestation($user)); 53 | } 54 | 55 | /** 56 | * Get the account recovery validation rules. 57 | * 58 | * @return array 59 | */ 60 | protected function rules(): array 61 | { 62 | return [ 63 | 'token' => 'required', 64 | 'email' => 'required|email', 65 | ]; 66 | } 67 | 68 | /** 69 | * Recover the user account and log him in. 70 | * 71 | * @param \Illuminate\Http\Request $request 72 | * 73 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse 74 | * @throws \Illuminate\Validation\ValidationException 75 | */ 76 | public function recover(Request $request) 77 | { 78 | $credentials = validator( 79 | [ 80 | 'email' => $request->header('email'), 81 | 'token' => $request->header('token'), 82 | ], 83 | $this->rules() 84 | )->validate(); 85 | 86 | $response = WebAuthn::recover( 87 | $credentials, 88 | function ($user) use ($request) { 89 | if (!$this->register($request, $user)) { 90 | $this->sendRecoveryFailedResponse($request, 'larapass::recovery.failed'); 91 | } 92 | } 93 | ); 94 | 95 | return $response === WebAuthn::RECOVERY_ATTACHED 96 | ? $this->sendRecoveryResponse($request, $response) 97 | : $this->sendRecoveryFailedResponse($request, $response); 98 | } 99 | 100 | /** 101 | * Registers a device for further WebAuthn authentication. 102 | * 103 | * @param \Illuminate\Http\Request $request 104 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 105 | * 106 | * @return bool 107 | */ 108 | protected function register(Request $request, WebAuthnAuthenticatable $user): bool 109 | { 110 | $validCredential = WebAuthn::validateAttestation( 111 | $request->validate($this->attestationRules()), 112 | $user 113 | ); 114 | 115 | if ($validCredential) { 116 | if ($this->shouldDisableAllCredentials($request)) { 117 | $user->disableAllCredentials(); 118 | } 119 | 120 | $user->addCredential($validCredential); 121 | 122 | event(new AttestationSuccessful($user, $validCredential)); 123 | 124 | $this->guard()->login($user); 125 | 126 | return true; 127 | } 128 | 129 | return false; 130 | } 131 | 132 | /** 133 | * Check if the user has set to disable all others credentials. 134 | * 135 | * @param \Illuminate\Http\Request $request 136 | * 137 | * @return bool|mixed 138 | */ 139 | protected function shouldDisableAllCredentials(Request $request): bool 140 | { 141 | return filter_var($request->header('WebAuthn-Unique'), FILTER_VALIDATE_BOOLEAN) 142 | ?: $request->filled('unique'); 143 | } 144 | 145 | /** 146 | * Get the response for a successful account recovery. 147 | * 148 | * @param \Illuminate\Http\Request $request 149 | * @param string $response 150 | * 151 | * @return \Illuminate\Http\JsonResponse 152 | */ 153 | protected function sendRecoveryResponse(Request $request, string $response): JsonResponse 154 | { 155 | return new JsonResponse( 156 | [ 157 | 'message' => trans($response), 158 | 'redirectTo' => $this->redirectPath(), 159 | ], 200 160 | ); 161 | } 162 | 163 | /** 164 | * Get the response for a failed account recovery. 165 | * 166 | * @param \Illuminate\Http\Request $request 167 | * @param string $response 168 | * 169 | * @return \Illuminate\Http\JsonResponse|void 170 | * @throws \Illuminate\Validation\ValidationException 171 | */ 172 | protected function sendRecoveryFailedResponse(Request $request, string $response): JsonResponse 173 | { 174 | throw ValidationException::withMessages( 175 | [ 176 | 'email' => [trans($response)], 177 | ] 178 | ); 179 | } 180 | 181 | /** 182 | * Returns the Authentication guard. 183 | * 184 | * @return \Illuminate\Contracts\Auth\StatefulGuard 185 | */ 186 | protected function guard(): StatefulGuard 187 | { 188 | return Auth::guard(); 189 | } 190 | 191 | /** 192 | * Get the post recovery redirect path. 193 | * 194 | * @return string 195 | */ 196 | public function redirectPath(): string 197 | { 198 | if (method_exists($this, 'redirectTo')) { 199 | return $this->redirectTo(); 200 | } 201 | 202 | return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/WebAuthnAuthentication.php: -------------------------------------------------------------------------------- 1 | hasMany(WebAuthnCredential::class); 23 | } 24 | 25 | /** 26 | * Creates an user entity information for attestation (registration). 27 | * 28 | * @return \Webauthn\PublicKeyCredentialUserEntity 29 | */ 30 | public function userEntity(): UserEntity 31 | { 32 | return new UserEntity($this->email, $this->userHandle(), $this->name, $this->avatar); 33 | } 34 | 35 | /** 36 | * Return the handle used to identify his credentials. 37 | * 38 | * @return string 39 | */ 40 | public function userHandle(): string 41 | { 42 | return $this->webAuthnCredentials()->withTrashed()->value('user_handle') 43 | ?? $this->generateUserHandle(); 44 | } 45 | 46 | /** 47 | * Generate a new User Handle when it doesn't exists. 48 | * 49 | * @return string 50 | */ 51 | protected function generateUserHandle() 52 | { 53 | return Str::uuid()->toString(); 54 | } 55 | 56 | /** 57 | * Return a list of "blacklisted" credentials for attestation. 58 | * 59 | * @return array 60 | */ 61 | public function attestationExcludedCredentials(): array 62 | { 63 | return $this->webAuthnCredentials() 64 | ->enabled() 65 | ->get() 66 | ->map->toCredentialDescriptor() 67 | ->values() 68 | ->all(); 69 | } 70 | 71 | /** 72 | * Checks if a given credential exists. 73 | * 74 | * @param string $id 75 | * 76 | * @return bool 77 | */ 78 | public function hasCredential(string $id): bool 79 | { 80 | return $this->webAuthnCredentials()->whereKey($id)->exists(); 81 | } 82 | 83 | /** 84 | * Register a new credential by its ID for this user. 85 | * 86 | * @param \Webauthn\PublicKeyCredentialSource $source 87 | * 88 | * @return void 89 | */ 90 | public function addCredential(CredentialSource $source): void 91 | { 92 | $this->webAuthnCredentials()->save( 93 | WebAuthnCredential::fromCredentialSource($source) 94 | ); 95 | } 96 | 97 | /** 98 | * Removes a credential previously registered. 99 | * 100 | * @param string|array $id 101 | * 102 | * @return void 103 | */ 104 | public function removeCredential($id): void 105 | { 106 | $this->webAuthnCredentials()->whereKey($id)->forceDelete(); 107 | } 108 | 109 | /** 110 | * Removes all credentials previously registered. 111 | * 112 | * @param string|array|null $except 113 | * 114 | * @return void 115 | */ 116 | public function flushCredentials($except = null): void 117 | { 118 | $this->webAuthnCredentials()->whereKeyNot($except)->forceDelete(); 119 | } 120 | 121 | /** 122 | * Checks if a given credential exists and is enabled. 123 | * 124 | * @param string $id 125 | * 126 | * @return mixed 127 | */ 128 | public function hasCredentialEnabled(string $id): bool 129 | { 130 | return $this->webAuthnCredentials()->whereKey($id)->enabled()->exists(); 131 | } 132 | 133 | /** 134 | * Enable the credential for authentication. 135 | * 136 | * @param string|array $id 137 | * 138 | * @return void 139 | */ 140 | public function enableCredential($id): void 141 | { 142 | $this->webAuthnCredentials()->whereKey($id)->restore(); 143 | } 144 | 145 | /** 146 | * Disable the credential for authentication. 147 | * 148 | * @param string|array $id 149 | * 150 | * @return void 151 | */ 152 | public function disableCredential($id): void 153 | { 154 | $this->webAuthnCredentials()->whereKey($id)->delete(); 155 | } 156 | 157 | /** 158 | * Disables all credentials for the user. 159 | * 160 | * @param string|array|null $except 161 | * 162 | * @return void 163 | */ 164 | public function disableAllCredentials($except = null): void 165 | { 166 | $this->webAuthnCredentials()->whereKeyNot($except)->delete(); 167 | } 168 | 169 | /** 170 | * Returns all credentials descriptors of the user. 171 | * 172 | * @return array|\Webauthn\PublicKeyCredentialDescriptor[] 173 | */ 174 | public function allCredentialDescriptors(): array 175 | { 176 | return $this->webAuthnCredentials() 177 | ->enabled() 178 | ->get() 179 | ->map->toCredentialDescriptor() 180 | ->values() 181 | ->all(); 182 | } 183 | 184 | /** 185 | * Sends a credential recovery email to the user. 186 | * 187 | * @param string $token 188 | * 189 | * @return void 190 | */ 191 | public function sendCredentialRecoveryNotification(string $token): void 192 | { 193 | $this->notify(new Notifications\AccountRecoveryNotification($token)); 194 | } 195 | 196 | /** 197 | * Returns an WebAuthnAuthenticatable user from a given Credential ID. 198 | * 199 | * @param string $id 200 | * 201 | * @return \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null 202 | */ 203 | public static function getFromCredentialId(string $id): ?WebAuthnAuthenticatable 204 | { 205 | return static::whereHas( 206 | 'webAuthnCredentials', 207 | static function ($query) use ($id) { 208 | return $query->whereKey($id); 209 | } 210 | )->first(); 211 | } 212 | 213 | /** 214 | * Returns a WebAuthAuthenticatable user from a given User Handle. 215 | * 216 | * @param string $handle 217 | * 218 | * @return \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null 219 | */ 220 | public static function getFromCredentialUserHandle(string $handle): ?WebAuthnAuthenticatable 221 | { 222 | return static::whereHas( 223 | 'webAuthnCredentials', 224 | static function ($query) use ($handle) { 225 | return $query->where('user_handle', $handle); 226 | } 227 | )->first(); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/WebAuthn/WebAuthnAttestCreator.php: -------------------------------------------------------------------------------- 1 | cache = $cache->store($config->get('larapass.cache')); 109 | $this->relyingParty = $relyingParty; 110 | $this->criteria = $criteria; 111 | $this->parameters = $parameters; 112 | $this->extensions = $extensions; 113 | $this->laravelRequest = $request; 114 | 115 | $this->timeout = $config->get('larapass.timeout') * 1000; 116 | $this->bytes = $config->get('larapass.bytes'); 117 | $this->conveyance = $config->get('larapass.conveyance'); 118 | $this->unique = $config->get('larapass.unique'); 119 | } 120 | 121 | /** 122 | * Retrieves an Attestation if it exists. 123 | * 124 | * @param \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 125 | * 126 | * @return \Webauthn\PublicKeyCredentialCreationOptions|null 127 | */ 128 | public function retrieveAttestation($user): ?PublicKeyCredentialCreationOptions 129 | { 130 | return $this->cache->get($this->cacheKey($user)); 131 | } 132 | 133 | /** 134 | * Generates a new Attestation for a given user. 135 | * 136 | * @param \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 137 | * 138 | * @return \Webauthn\PublicKeyCredentialCreationOptions 139 | */ 140 | public function generateAttestation($user): PublicKeyCredentialCreationOptions 141 | { 142 | $attestation = $this->makeAttestationRequest($user); 143 | 144 | $this->cache->put($this->cacheKey($user), $attestation, $this->timeout); 145 | 146 | return $attestation; 147 | } 148 | 149 | /** 150 | * Returns the challenge that is remembered specifically for the user. 151 | * 152 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\Authenticatable $user 153 | * 154 | * @return mixed 155 | */ 156 | protected function makeAttestationRequest($user): PublicKeyCredentialCreationOptions 157 | { 158 | return new PublicKeyCredentialCreationOptions( 159 | $this->relyingParty, 160 | $user->userEntity(), 161 | random_bytes($this->bytes), 162 | $this->parameters->all(), 163 | $this->timeout, 164 | $this->getExcludedCredentials($user), 165 | $this->criteria, 166 | $this->conveyance, 167 | $this->extensions 168 | ); 169 | } 170 | 171 | /** 172 | * Returns the cache key to remember the challenge for the user. 173 | * 174 | * @param \Illuminate\Contracts\Auth\Authenticatable $user 175 | * 176 | * @return string 177 | */ 178 | protected function cacheKey(Authenticatable $user): string 179 | { 180 | return implode( 181 | '|', 182 | [ 183 | 'larapass.attestation', 184 | get_class($user) . ':' . $user->getAuthIdentifier(), 185 | sha1($this->laravelRequest->getHttpHost() . '|' . $this->laravelRequest->ip()), 186 | ] 187 | ); 188 | } 189 | 190 | /** 191 | * Return the excluded credentials if the configuration demands it. 192 | * 193 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user 194 | * 195 | * @return array 196 | */ 197 | protected function getExcludedCredentials(WebAuthnAuthenticatable $user): array 198 | { 199 | return $this->unique ? $user->attestationExcludedCredentials() : []; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/WebAuthn/WebAuthnAssertValidator.php: -------------------------------------------------------------------------------- 1 | cache = $cache->store($config->get('larapass.cache')); 113 | $this->relyingParty = $relyingParty; 114 | $this->extensions = $extensions; 115 | $this->validator = $validator; 116 | $this->loader = $loader; 117 | $this->request = $request; 118 | 119 | $this->laravelRequest = $laravelRequest; 120 | $this->timeout = $config->get('larapass.timeout') * 1000; 121 | $this->bytes = $config->get('larapass.bytes'); 122 | 123 | $this->verifyLogin = $this->shouldVerifyLogin($config); 124 | } 125 | 126 | /** 127 | * Check if the login verification should be mandatory. 128 | * 129 | * @param \Illuminate\Contracts\Config\Repository $config 130 | * 131 | * @return string 132 | */ 133 | protected function shouldVerifyLogin(ConfigContract $config): string 134 | { 135 | if (in_array($config->get('larapass.userless'), ['required', 'preferred'])) { 136 | return 'required'; 137 | } 138 | 139 | return $config->get('larapass.login_verify'); 140 | } 141 | 142 | /** 143 | * Retrieves a previously created assertion for a given request. 144 | * 145 | * @return \Webauthn\PublicKeyCredentialRequestOptions|null 146 | */ 147 | public function retrieveAssertion(): ?RequestOptions 148 | { 149 | return $this->cache->get($this->cacheKey()); 150 | } 151 | 152 | /** 153 | * Returns a challenge for the given request fingerprint. 154 | * 155 | * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null $user 156 | * 157 | * @return \Webauthn\PublicKeyCredentialRequestOptions 158 | */ 159 | public function generateAssertion($user = null): RequestOptions 160 | { 161 | $assertion = $this->makeAssertionRequest($user); 162 | 163 | $this->cache->put($this->cacheKey(), $assertion, $this->timeout); 164 | 165 | return $assertion; 166 | } 167 | 168 | /** 169 | * Creates a new Assertion Request for the request, and user if issued. 170 | * 171 | * @param \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|null $user 172 | * 173 | * @return \Webauthn\PublicKeyCredentialRequestOptions 174 | */ 175 | protected function makeAssertionRequest($user = null): RequestOptions 176 | { 177 | return new RequestOptions( 178 | random_bytes($this->bytes), 179 | $this->timeout, 180 | $this->relyingParty->getId(), 181 | $user ? $user->allCredentialDescriptors() : [], 182 | $this->verifyLogin, 183 | $this->extensions 184 | ); 185 | } 186 | 187 | /** 188 | * Return the cache key for the given unique request. 189 | * 190 | * @return string 191 | */ 192 | protected function cacheKey(): string 193 | { 194 | return 'larapass.assertation|' . sha1($this->laravelRequest->getHttpHost() . '|' . $this->laravelRequest->ip()); 195 | } 196 | 197 | /** 198 | * Verifies if the assertion is correct. 199 | * 200 | * @param array $data 201 | * 202 | * @return bool|\Webauthn\PublicKeyCredentialSource 203 | */ 204 | public function validate(array $data) 205 | { 206 | if (!$assertion = $this->retrieveAssertion()) { 207 | return false; 208 | } 209 | 210 | try { 211 | $credentials = $this->loader->loadArray($data); 212 | $response = $credentials->getResponse(); 213 | 214 | if (!$response instanceof AuthenticatorAssertionResponse) { 215 | return false; 216 | } 217 | 218 | return $this->validator->check( 219 | $credentials->getRawId(), 220 | $response, 221 | $this->retrieveAssertion(), 222 | $this->request, 223 | $response->getUserHandle(), 224 | [$this->getCurrentRpId($assertion)] 225 | ); 226 | } catch (InvalidArgumentException $exception) { 227 | return false; 228 | } finally { 229 | $this->cache->forget($this->cacheKey()); 230 | } 231 | } 232 | 233 | /** 234 | * Returns the current Relaying Party ID to validate the response. 235 | * 236 | * @param \Webauthn\PublicKeyCredentialRequestOptions $assertion 237 | * 238 | * @return string 239 | */ 240 | protected function getCurrentRpId(RequestOptions $assertion): string 241 | { 242 | return $assertion->getRpId() ?? $this->laravelRequest->getHost(); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /resources/js/larapass.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MIT License 3 | * 4 | * Copyright (c) Italo Israel Baeza Cabrera 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | function _defineProperty(obj, key, value) { 26 | if (key in obj) { 27 | Object.defineProperty(obj, key, { 28 | value: value, 29 | enumerable: true, 30 | configurable: true, 31 | writable: true 32 | }); 33 | } else { 34 | obj[key] = value; 35 | } 36 | return obj; 37 | } 38 | 39 | class Larapass { 40 | 41 | 42 | /** 43 | * Create a new Larapass instance. 44 | * 45 | * @param routes {{registerOptions: string, loginOptions: string, login: string, register: string}} 46 | * @param headers {{string}} 47 | * @param includeCredentials {{boolean}}` 48 | */ 49 | constructor(routes = {}, headers = {}, includeCredentials = false) { 50 | /** 51 | * Headers to use in ALL requests done. 52 | * 53 | * @type {{Accept: string, "X-Requested-With": string, "Content-Type": string}} 54 | */ 55 | _defineProperty(this, "headers", { 56 | "Content-Type": "application/json", 57 | Accept: "application/json", 58 | "X-Requested-With": "XMLHttpRequest" 59 | }); 60 | 61 | /** 62 | * If set to true, the credentials option will be set to 'include', on all fetch calls, 63 | * else it will use the default 'same-origin'. Use this if the backend is not on the same origin as the client or CSFR protection will break 64 | * 65 | * @type {boolean} 66 | */ 67 | _defineProperty(this, "includeCredentials", false); 68 | 69 | /** 70 | * Routes for WebAuthn assertion (login) and attestation (register). 71 | * 72 | * @type {{registerOptions: string, loginOptions: string, login: string, register: string}} 73 | */ 74 | _defineProperty(this, "routes", { 75 | loginOptions: "webauthn/login/options", 76 | login: "webauthn/login", 77 | registerOptions: "webauthn/register/options", 78 | register: "webauthn/register" 79 | }); 80 | 81 | this.routes = { ...this.routes, ...routes }; 82 | this.headers = { ...this.headers, ...headers }; 83 | this.includeCredentials = includeCredentials; // If the developer didn't issue an XSRF token, we will find it ourselves. 84 | 85 | if (headers["X-XSRF-TOKEN"] === undefined) { 86 | this.headers["X-XSRF-TOKEN"] = this.getXsrfToken(); 87 | } 88 | } 89 | 90 | /** 91 | * Returns the XSRF token if it exists. 92 | * 93 | * @returns string|undefined 94 | * @throws TypeError 95 | */ 96 | getXsrfToken() { 97 | let tokenContainer; // First, let's get the token if it exists as a cookie, since most apps use it by default. 98 | 99 | tokenContainer = document.cookie 100 | .split("; ") 101 | .find((row) => row.startsWith("XSRF-TOKEN")); 102 | 103 | if (tokenContainer !== undefined) { 104 | return decodeURIComponent(tokenContainer.split("=")[1]); 105 | } // If it doesn't exists, we will try to get it from the head meta tags as last resort. 106 | 107 | tokenContainer = document.getElementsByName("csrf-token")[0]; 108 | 109 | if (tokenContainer !== undefined) { 110 | return tokenContainer.content; 111 | } 112 | 113 | throw new TypeError( 114 | 'There is no cookie with "X-XSRF-TOKEN" or meta tag with "csrf-token".' 115 | ); 116 | } 117 | 118 | /** 119 | * Returns a fetch promise to resolve later. 120 | * 121 | * @param data {{string}} 122 | * @param route {string} 123 | * @param headers {{string}} 124 | * @returns {Promise} 125 | */ 126 | fetch(data, route, headers = {}) { 127 | return fetch(route, { 128 | method: "POST", 129 | credentials: this.includeCredentials ? "include" : "same-origin", 130 | redirect: "error", 131 | headers: { ...this.headers, ...headers }, 132 | body: JSON.stringify(data) 133 | }); 134 | } 135 | 136 | /** 137 | * 138 | * Decodes a BASE64 URL string into a normal string. 139 | * 140 | * @param input {string} 141 | * @returns {string|Iterable} 142 | */ 143 | base64UrlDecode(input) { 144 | input = input.replace(/-/g, "+").replace(/_/g, "/"); 145 | const pad = input.length % 4; 146 | 147 | if (pad) { 148 | if (pad === 1) { 149 | throw new Error( 150 | "InvalidLengthError: Input base64url string is the wrong length to determine padding" 151 | ); 152 | } 153 | 154 | input += new Array(5 - pad).join("="); 155 | } 156 | 157 | return window.atob(input); 158 | } 159 | 160 | /** 161 | * Transform an string into Uint8Array instance. 162 | * 163 | * @param input {string} 164 | * @param atob {boolean} 165 | * @returns {Uint8Array} 166 | */ 167 | uint8Array(input, atob = false) { 168 | return Uint8Array.from( 169 | atob ? window.atob(input) : this.base64UrlDecode(input), 170 | (c) => c.charCodeAt(0) 171 | ); 172 | } 173 | 174 | /** 175 | * Encodes an array of bytes to a BASE64 URL string 176 | * 177 | * @param arrayBuffer {ArrayBuffer|Uint8Array} 178 | * @returns {string} 179 | */ 180 | arrayToBase64String(arrayBuffer) { 181 | return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); 182 | } 183 | 184 | /** 185 | * Parses the Public Key Options received from the Server for the browser. 186 | * 187 | * @param publicKey {Object} 188 | * @returns {Object} 189 | */ 190 | parseIncomingServerOptions(publicKey) { 191 | publicKey.challenge = this.uint8Array(publicKey.challenge); 192 | 193 | if (publicKey.user !== undefined) { 194 | publicKey.user = { 195 | ...publicKey.user, 196 | id: this.uint8Array(publicKey.user.id, true) 197 | }; 198 | } 199 | 200 | ["excludeCredentials", "allowCredentials"] 201 | .filter((key) => publicKey[key] !== undefined) 202 | .forEach((key) => { 203 | publicKey[key] = publicKey[key].map((data) => { 204 | return { ...data, id: this.uint8Array(data.id) }; 205 | }); 206 | }); 207 | return publicKey; 208 | } 209 | 210 | /** 211 | * Parses the outgoing credentials from the browser to the server. 212 | * 213 | * @param credentials {Credential|PublicKeyCredential} 214 | * @return {{response: {string}, rawId: string, id: string, type: string}} 215 | */ 216 | parseOutgoingCredentials(credentials) { 217 | let parseCredentials = { 218 | id: credentials.id, 219 | type: credentials.type, 220 | rawId: this.arrayToBase64String(credentials.rawId), 221 | response: {} 222 | }; 223 | [ 224 | "clientDataJSON", 225 | "attestationObject", 226 | "authenticatorData", 227 | "signature", 228 | "userHandle" 229 | ] 230 | .filter((key) => credentials.response[key] !== undefined) 231 | .forEach((key) => { 232 | parseCredentials.response[key] = this.arrayToBase64String( 233 | credentials.response[key] 234 | ); 235 | }); 236 | return parseCredentials; 237 | } 238 | 239 | /** 240 | * Checks if the browser supports WebAuthn. 241 | * 242 | * @returns {boolean} 243 | */ 244 | supportsWebAuthn() { 245 | return typeof PublicKeyCredential != "undefined"; 246 | } 247 | 248 | /** 249 | * Handles the response from the Server. 250 | * 251 | * Throws the entire response if is not OK (HTTP 2XX). 252 | * 253 | * @param response {Response} 254 | * @returns Promise 255 | * @throws Response 256 | */ 257 | handleResponse(response) { 258 | if (!response.ok) { 259 | throw response; 260 | } // Here we will do a small trick. Since most of the responses from the server 261 | // are JSON, we will automatically parse the JSON body from the response. If 262 | // it's not JSON, we will push the body verbatim and let the dev handle it. 263 | 264 | return new Promise((resolve) => { 265 | response 266 | .json() 267 | .then((json) => resolve(json)) 268 | .catch(() => resolve(response.body)); 269 | }); 270 | } 271 | 272 | /** 273 | * Log in an user with his credentials. 274 | * 275 | * If no credentials are given, Larapass can return a blank assertion for typeless login. 276 | * 277 | * @param data {{string}} 278 | * @param headers {{string}} 279 | * @returns Promise 280 | */ 281 | async login(data = {}, headers = {}) { 282 | const optionsResponse = await this.fetch(data, this.routes.loginOptions); 283 | const json = await optionsResponse.json(); 284 | const publicKey = this.parseIncomingServerOptions(json); 285 | const credentials = await navigator.credentials.get({ 286 | publicKey 287 | }); 288 | const publicKeyCredential = this.parseOutgoingCredentials(credentials); 289 | return await this.fetch( 290 | publicKeyCredential, 291 | this.routes.login, 292 | headers 293 | ).then(this.handleResponse); 294 | } 295 | 296 | /** 297 | * Register the user credentials from the browser/device. 298 | * 299 | * You can add data if you are planning to register an user with WebAuthn from scratch. 300 | * 301 | * @param data {{string}} 302 | * @param headers {{string}} 303 | * @returns Promise 304 | */ 305 | async register(data = {}, headers = {}) { 306 | const optionsResponse = await this.fetch(data, this.routes.registerOptions); 307 | const json = await optionsResponse.json(); 308 | const publicKey = this.parseIncomingServerOptions(json); 309 | const credentials = await navigator.credentials.create({ 310 | publicKey 311 | }); 312 | 313 | const publicKeyCredential = this.parseOutgoingCredentials(credentials); 314 | 315 | return await this.fetch( 316 | publicKeyCredential, 317 | this.routes.register, 318 | headers 319 | ).then(this.handleResponse); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/LarapassServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/../config/larapass.php', 'larapass'); 56 | 57 | $this->app->alias(Authenticatable::class, WebAuthnAuthenticatable::class); 58 | 59 | $this->bindWebAuthnBasePackage(); 60 | 61 | $this->app->bind(WebAuthnAttestCreator::class); 62 | $this->app->bind(WebAuthnAttestValidator::class); 63 | $this->app->bind(WebAuthnAssertValidator::class); 64 | } 65 | 66 | /** 67 | * Bind all the WebAuthn package services to the Service Container. 68 | * 69 | * @return void 70 | */ 71 | protected function bindWebAuthnBasePackage(): void 72 | { 73 | // And from here the shit hits the fan. But it's needed to make the package modular, 74 | // testable and catchable by the developer when he needs to override anything. 75 | $this->app->singleton( 76 | AttestationStatementSupportManager::class, 77 | static function () { 78 | return tap(new AttestationStatementSupportManager())->add(new NoneAttestationStatementSupport()); 79 | } 80 | ); 81 | 82 | $this->app->singleton( 83 | MetadataStatementRepository::class, 84 | static function () { 85 | return null; 86 | } 87 | ); 88 | 89 | $this->app->singleton( 90 | AttestationObjectLoader::class, 91 | static function ($app) { 92 | return new AttestationObjectLoader( 93 | $app[AttestationStatementSupportManager::class], 94 | $app[MetadataStatementRepository::class], 95 | $app[LoggerInterface::class] 96 | ); 97 | } 98 | ); 99 | 100 | $this->app->singleton( 101 | PublicKeyCredentialLoader::class, 102 | static function ($app) { 103 | return new PublicKeyCredentialLoader( 104 | $app[AttestationObjectLoader::class], 105 | $app['log'] 106 | ); 107 | } 108 | ); 109 | 110 | $this->app->bind( 111 | PublicKeyCredentialSourceRepository::class, 112 | static function () { 113 | return new WebAuthnAuthenticationModel(); 114 | } 115 | ); 116 | 117 | $this->app->bind( 118 | TokenBindingHandler::class, 119 | static function () { 120 | return new IgnoreTokenBindingHandler(); 121 | } 122 | ); 123 | 124 | $this->app->bind( 125 | ExtensionOutputCheckerHandler::class, 126 | static function () { 127 | return new ExtensionOutputCheckerHandler(); 128 | } 129 | ); 130 | 131 | $this->app->bind( 132 | CoseAlgorithmManager::class, 133 | static function ($app) { 134 | $manager = new CoseAlgorithmManager(); 135 | 136 | foreach ($app['config']->get('larapass.algorithms') as $algorithm) { 137 | $manager->add(new $algorithm()); 138 | } 139 | 140 | return $manager; 141 | } 142 | ); 143 | 144 | $this->app->bind( 145 | AuthenticatorAttestationResponseValidator::class, 146 | static function ($app) { 147 | return new AuthenticatorAttestationResponseValidator( 148 | $app[AttestationStatementSupportManager::class], 149 | $app[PublicKeyCredentialSourceRepository::class], 150 | $app[TokenBindingHandler::class], 151 | $app[ExtensionOutputCheckerHandler::class], 152 | $app[MetadataStatementRepository::class], 153 | $app['log'] 154 | ); 155 | } 156 | ); 157 | 158 | $this->app->bind( 159 | CounterChecker::class, 160 | static function ($app) { 161 | return new ThrowExceptionIfInvalid($app['log']); 162 | } 163 | ); 164 | 165 | $this->app->bind( 166 | AuthenticatorAssertionResponseValidator::class, 167 | static function ($app) { 168 | return new AuthenticatorAssertionResponseValidator( 169 | $app[PublicKeyCredentialSourceRepository::class], 170 | $app[TokenBindingHandler::class], 171 | $app[ExtensionOutputCheckerHandler::class], 172 | $app[CoseAlgorithmManager::class], 173 | $app[CounterChecker::class], 174 | $app['log'] 175 | ); 176 | } 177 | ); 178 | 179 | $this->app->bind( 180 | PublicKeyCredentialRpEntity::class, 181 | static function ($app) { 182 | $config = $app['config']; 183 | 184 | return new PublicKeyCredentialRpEntity( 185 | $config->get('larapass.relaying_party.name'), 186 | $config->get('larapass.relaying_party.id'), 187 | $config->get('larapass.relaying_party.icon') 188 | ); 189 | } 190 | ); 191 | 192 | $this->app->bind( 193 | AuthenticatorSelectionCriteria::class, 194 | static function ($app) { 195 | $config = $app['config']; 196 | 197 | $selection = new WebAuthn\AuthenticatorSelectionCriteria( 198 | $config->get('larapass.attachment') 199 | ); 200 | 201 | if ($userless = $config->get('larapass.userless')) { 202 | $selection->setResidentKey($userless); 203 | } 204 | 205 | return $selection; 206 | } 207 | ); 208 | 209 | $this->app->bind( 210 | PublicKeyCredentialParametersCollection::class, 211 | static function ($app) { 212 | return PublicKeyCredentialParametersCollection::make($app[CoseAlgorithmManager::class]->list()) 213 | ->map( 214 | static function ($algorithm) { 215 | return new PublicKeyCredentialParameters('public-key', $algorithm); 216 | } 217 | ); 218 | } 219 | ); 220 | 221 | $this->app->bind( 222 | AuthenticationExtensionsClientInputs::class, 223 | static function () { 224 | return new AuthenticationExtensionsClientInputs(); 225 | } 226 | ); 227 | 228 | $this->app->singleton( 229 | CredentialBroker::class, 230 | static function ($app) { 231 | if (!$config = $app['config']['auth.passwords.webauthn']) { 232 | throw new RuntimeException('You must set the [webauthn] key broker in [auth] config.'); 233 | } 234 | 235 | $key = $app['config']['app.key']; 236 | 237 | if (Str::startsWith($key, 'base64:')) { 238 | $key = base64_decode(substr($key, 7)); 239 | } 240 | 241 | return new CredentialBroker( 242 | new DatabaseTokenRepository( 243 | $app['db']->connection($config['connection'] ?? null), 244 | $app['hash'], 245 | $config['table'], 246 | $key, 247 | $config['expire'], 248 | $config['throttle'] ?? 0 249 | ), 250 | $app['auth']->createUserProvider($config['provider'] ?? null) 251 | ); 252 | } 253 | ); 254 | } 255 | 256 | /** 257 | * Bootstrap the application services. 258 | * 259 | * @return void 260 | */ 261 | public function boot(): void 262 | { 263 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'larapass'); 264 | $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'larapass'); 265 | 266 | $this->app['auth']->provider( 267 | 'eloquent-webauthn', 268 | static function ($app, $config) { 269 | return new EloquentWebAuthnProvider( 270 | $app['config'], 271 | $app[WebAuthnAssertValidator::class], 272 | $app[Hasher::class], 273 | $config['model'] 274 | ); 275 | } 276 | ); 277 | 278 | $this->app['router']->aliasMiddleware('webauthn.confirm', Http\Middleware\RequireWebAuthn::class); 279 | 280 | if ($this->app->runningInConsole()) { 281 | $this->publishFiles(); 282 | } 283 | } 284 | 285 | /** 286 | * Publish config, view and migrations files. 287 | * 288 | * @return void 289 | */ 290 | protected function publishFiles() : void 291 | { 292 | $this->publishes([__DIR__ . '/../config/larapass.php' => config_path('larapass.php')], 'config'); 293 | $this->publishes([__DIR__ . '/../stubs' => app_path('Http/Controllers/Auth')], 'controllers'); 294 | $this->publishes([__DIR__ . '/../resources/js' => public_path('vendor/larapass/js')], 'public'); 295 | $this->publishes([__DIR__ . '/../resources/views' => resource_path('views/vendor/larapass')], 'views'); 296 | 297 | $this->publishes([static::MIGRATION_FILE => static::generateDatabasePublishPath()], 'migrations'); 298 | } 299 | 300 | /** 301 | * Creates a final database path for the migration. 302 | * 303 | * @return string 304 | */ 305 | protected static function generateDatabasePublishPath(): string 306 | { 307 | return database_path('migrations/' . now()->format('Y_m_d_His') . '_create_web_authn_tables.php'); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This package has been superseded by [Laragear WebAuthn](https://github.com/Laragear/WebAuthn). 2 | 3 | --- 4 | 5 | ![Lukenn Sabellano - Unsplash (UL) #RDufjtg6JpQ](https://images.unsplash.com/photo-1567826722186-9ecdf689f122?ixlib=rb-1.2.1&auto=format&fit=crop&w=1280&h=400&q=80) 6 | 7 | [![Latest Stable Version](https://poser.pugx.org/darkghosthunter/larapass/v/stable)](https://packagist.org/packages/darkghosthunter/larapass) [![License](https://poser.pugx.org/darkghosthunter/larapass/license)](https://packagist.org/packages/darkghosthunter/larapass) ![](https://img.shields.io/packagist/php-v/darkghosthunter/larapass.svg) ![](https://github.com/DarkGhostHunter/Larapass/workflows/PHP%20Composer/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/DarkGhostHunter/Larapass/badge.svg?branch=master)](https://coveralls.io/github/DarkGhostHunter/Larapass?branch=master) [![Laravel Octane Compatible](https://img.shields.io/badge/Laravel%20Octane-Compatible-success?style=flat&logo=laravel)](https://github.com/laravel/octane) 8 | 9 | ## Larapass 10 | 11 | Authenticate users with just their device, fingerprint or biometric data. Goodbye passwords! 12 | 13 | This enables WebAuthn authentication inside Laravel authentication driver, and comes with _everything but the kitchen sink_. 14 | 15 | ## Requisites 16 | 17 | * PHP 7.4 or PHP 8.0 18 | * Laravel 7.18 (July 2020) or Laravel 8.x 19 | 20 | > For Laravel 9.x supports and onwards, use [Laragear WebAuthn](https://github.com/Laragear/WebAuthn). 21 | 22 | ## Installation 23 | 24 | Just hit the console and require it with Composer. 25 | 26 | composer require darkghosthunter/larapass 27 | 28 | Unfortunately, using WebAuthn is not a "walk in the park", this package allows you to enable WebAuthn in the most **easiest way possible**. 29 | 30 | # Table of contents 31 | 32 | - [What is WebAuthn? How it uses fingerprints or else?](#what-is-webauthn-how-it-uses-fingerprints-or-else) 33 | - [Set up](#set-up) 34 | - [Confirmation Middleware](#confirmation-middleware) 35 | - [Events](#events) 36 | - [Operations with WebAuthn](#operations-with-webauthn) 37 | - [Advanced Configuration](#advanced-configuration) 38 | - [Relaying Party Information](#relaying-party-information) 39 | - [Challenge configuration](#challenge-configuration) 40 | - [Algorithms](#algorithms) 41 | - [Key Attachment](#key-attachment) 42 | - [Attestation conveyance](#attestation-conveyance) 43 | - [Login verification](#login-verification) 44 | - [Userless login (One touch, Typeless)](#userless-login-one-touch-typeless) 45 | - [Unique](#unique) 46 | - [Password Fallback](#password-fallback) 47 | - [Confirmation timeout](#confirmation-timeout) 48 | - [Attestation and Metadata statements support](#attestation-and-metadata-statements-support) 49 | - [Security](#security) 50 | - [FAQ](#faq) 51 | - [License](#license) 52 | 53 | ## What is WebAuthn? How it uses fingerprints or else? 54 | 55 | In a nutshell, [major browsers are compatible with Web Authentication API](https://caniuse.com/#feat=webauthn), pushing authentication to the device (fingerprints, Face ID, patterns, codes, etc) instead of plain-text passwords. 56 | 57 | This package validates the WebAuthn payload from the devices using a custom [user provider](https://laravel.com/docs/authentication#adding-custom-user-providers). 58 | 59 | If you have any doubts about WebAuthn, [check this small FAQ](#faq). For a more deep dive, check [WebAuthn.io](https://webauthn.io/), [WebAuthn.me](https://webauthn.me/) and [Google WebAuthn tutorial](https://codelabs.developers.google.com/codelabs/webauthn-reauth/). 60 | 61 | ## Set up 62 | 63 | We need to make sure your users can register their devices and authenticate with them. 64 | 65 | 1. [Add the `eloquent-webauthn` driver](#1-add-the-eloquent-webauthn-driver). 66 | 2. [Create the `webauthn_credentials` table.](#2-create-the-webauthn_credentials-table) 67 | 3. [Implement the contract and trait](#3-implement-the-contract-and-trait) 68 | 69 | After that, you can quickly start WebAuthn with the included controllers and helpers to make your life easier. 70 | 71 | 4. [Register the routes](#4-register-the-routes-optional) 72 | 5. [Use the Javascript helper](#5-use-the-javascript-helper-optional) 73 | 6. [Set up account recovery](#6-set-up-account-recovery-optional) 74 | 75 | ### 1. Add the `eloquent-webauthn` driver 76 | 77 | This package comes with an Eloquent-compatible [user provider](https://laravel.com/docs/authentication#adding-custom-user-providers) that validates WebAuthn responses from the devices. 78 | 79 | Go to your `config/auth.php` configuration file, and change the driver of the provider you're using to `eloquent-webauthn`. 80 | 81 | ```php 82 | return [ 83 | // ... 84 | 85 | 'providers' => [ 86 | 'users' => [ 87 | // 'driver' => 'eloquent', // Default Eloquent User Provider 88 | 'driver' => 'eloquent-webauthn', 89 | 'model' => App\User::class, 90 | ], 91 | ] 92 | ]; 93 | ``` 94 | 95 | > If you plan to create your own user provider driver for WebAuthn, remember to inject the [`WebAuthnAssertValidator`](src/WebAuthn/WebAuthnAssertValidator.php) to properly validate the user with the incoming response. 96 | 97 | ### 2. Create the `webauthn_credentials` table 98 | 99 | Create the `webauthn_credentials` table by publishing the migration files and migrating the table: 100 | 101 | php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="migrations" 102 | php artisan migrate 103 | 104 | ### 3. Implement the contract and trait 105 | 106 | Add the `WebAuthnAuthenticatable` contract and the `WebAuthnAuthentication` trait to the `Authenticatable` user class, or any that uses authentication. 107 | 108 | ```php 109 | The trait is used to tie the User model to the WebAuthn data contained in the database. 126 | 127 | ### 4. Register the routes (optional) 128 | 129 | Finally, you will need to add the routes for registering and authenticating users. If you want a quick start, just publish the controllers included in Larapass. 130 | 131 | php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="controllers" 132 | 133 | You can copy-paste these route definitions in your `routes/web.php` file. 134 | 135 | ```php 136 | use App\Http\Controllers\Auth\WebAuthnRegisterController; 137 | use App\Http\Controllers\Auth\WebAuthnLoginController; 138 | 139 | Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options']) 140 | ->name('webauthn.register.options'); 141 | Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register']) 142 | ->name('webauthn.register'); 143 | 144 | Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options']) 145 | ->name('webauthn.login.options'); 146 | Route::post('webauthn/login', [WebAuthnLoginController::class, 'login']) 147 | ->name('webauthn.login'); 148 | ``` 149 | 150 | In your frontend scripts, point the requests to these routes. 151 | 152 | > If you want full control, you can opt-out of these helper controllers and use your own logic. Use the [`AttestWebAuthn`](src/Http/RegistersWebAuthn.php) and [`AssertsWebAuthn`](src/Http/AuthenticatesWebAuthn.php) traits if you need to start with something. 153 | 154 | ### 5. Use the Javascript helper (optional) 155 | 156 | This package includes a convenient script to handle registration and login via WebAuthn. To use it, just publish the `larapass.js` asset into your application public resources. 157 | 158 | php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="public" 159 | 160 | You will receive the `vendor/larapass/js/larapass.js` file which you can include into your authentication views and use it programmatically, anyway you want. 161 | 162 | ```html 163 | 164 | 165 | 166 | 179 | 180 | 181 | 195 | ``` 196 | 197 | > You can bypass the route list declaration if you're using the defaults. The example above includes them just for show. Be sure to create modify this script for your needs. 198 | 199 | Also, the helper allows headers on the action request, on both registration and login. 200 | 201 | ```javascript 202 | new Larapass({ 203 | login: 'webauthn/login', 204 | loginOptions: 'webauthn/login/options' 205 | }).login({ 206 | email: document.getElementById('email').value, 207 | }, { 208 | 'My-Custom-Header': 'This is sent with the signed challenge', 209 | }) 210 | ``` 211 | 212 | > You can copy-paste it and import into a transpiler like [Laravel Mix](https://laravel.com/docs/mix#running-mix), [Babel](https://babeljs.io/) or [Webpack](https://webpack.js.org/). If the script doesn't suit your needs, you're free to create your own. 213 | 214 | #### Remembering Users 215 | 216 | You can enable it by just issuing the `WebAuthn-Remember` header value to `true` when pushing the signed login challenge from your frontend. We can do this easily with the [included Javascript helper](#5-use-the-javascript-helper-optional). 217 | 218 | ```javascript 219 | new Larapass.login({ 220 | email: document.getElementById('email').value 221 | }, { 222 | 'WebAuthn-Remember': true 223 | }) 224 | ``` 225 | 226 | Alternatively, you can add the `remember` key to the outgoing JSON Payload if you're using your own scripts. Both ways are accepted. 227 | 228 | > You can override this behaviour in the [`AssertsWebAuthn`](src/Http/AuthenticatesWebAuthn.php) trait. 229 | 230 | ### 6. Set up account recovery (optional) 231 | 232 | Probably you will want to offer a way to "recover" an account if the user loses his credentials, which is basically a way to attach a new one. You can use controllers [which are also published](#4-register-the-routes-optional), along with these routes: 233 | 234 | ```php 235 | use App\Http\Controllers\Auth\WebAuthnDeviceLostController; 236 | use App\Http\Controllers\Auth\WebAuthnRecoveryController; 237 | 238 | Route::get('webauthn/lost', [WebAuthnDeviceLostController::class, 'showDeviceLostForm']) 239 | ->name('webauthn.lost.form'); 240 | Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail']) 241 | ->name('webauthn.lost.send'); 242 | 243 | Route::get('webauthn/recover', [WebAuthnRecoveryController::class, 'showResetForm']) 244 | ->name('webauthn.recover.form'); 245 | Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options']) 246 | ->name('webauthn.recover.options'); 247 | Route::post('webauthn/recover/register', [WebAuthnRecoveryController::class, 'recover']) 248 | ->name('webauthn.recover'); 249 | ``` 250 | 251 | These come with [new views](resources/views) and [translation lines](resources/lang), so you can override them if you're not happy with what is included. 252 | 253 | You can also override the views in `resources/vendor/larapass` and the notification being sent using the `sendCredentialRecoveryNotification` method of the user. 254 | 255 | After that, don't forget to add a new token broker in your `config/auth.php`. We will need it to store the tokens from the recovery procedure. 256 | 257 | ```php 258 | return [ 259 | // ... 260 | 261 | 'passwords' => [ 262 | 'users' => [ 263 | 'provider' => 'users', 264 | 'table' => 'password_resets', 265 | 'expire' => 60, 266 | 'throttle' => 60, 267 | ], 268 | 269 | // New for WebAuthn 270 | 'webauthn' => [ 271 | 'provider' => 'users', // The user provider using WebAuthn. 272 | 'table' => 'web_authn_recoveries', // The table to store the recoveries. 273 | 'expire' => 60, 274 | 'throttle' => 60, 275 | ], 276 | ], 277 | ]; 278 | ``` 279 | 280 | ## Confirmation middleware 281 | 282 | Following the same principle of the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), Larapass includes a the `webauthn.confirm` middleware that will ask the user to confirm with his device before entering a given route. 283 | 284 | ```php 285 | Route::get('this/is/important', function () { 286 | return 'This is very important!'; 287 | })->middleware('webauthn.confirm'); 288 | ``` 289 | 290 | When [publishing the controllers](#4-register-the-routes-optional), the `WebAuthnConfirmController` will be in your controller files ready to accept confirmations. You just need to register the route by just copy-pasting these: 291 | 292 | ```php 293 | Route::get('webauthn/confirm', 'Auth\WebAuthnConfirmController@showConfirmForm') 294 | ->name('webauthn.confirm.form'); 295 | Route::post('webauthn/confirm/options', 'Auth\WebAuthnConfirmController@options') 296 | ->name('webauthn.confirm.options'); 297 | Route::post('webauthn/confirm', 'Auth\WebAuthnConfirmController@confirm') 298 | ->name('webauthn.confirm'); 299 | ``` 300 | 301 | As always, you can opt-out with your own logic. For these case take a look into the [`ConfirmsWebAuthn`](src/Http/ConfirmsWebAuthn.php) trait to start. 302 | 303 | > You can change how much time to remember the confirmation [in the configuration](#confirmation-timeout). 304 | 305 | ## Events 306 | 307 | Since all authentication is handled by Laravel itself, the only [event](https://laravel.com/docs/events) included is [`AttestationSuccessful`](src/Events/AttestationSuccessful.php), which fires when the registration is successful. It includes the user with the credentials persisted. 308 | 309 | You can use this event to, for example, notify the user a new device has been registered. For that, you can use a [listener](https://laravel.com/docs/events#defining-listeners). 310 | 311 | ```php 312 | public function handle(AttestationSuccessful $event) 313 | { 314 | $event->user->notify( 315 | new DeviceRegisteredNotification($event->credential->getId()) 316 | ); 317 | } 318 | ``` 319 | 320 | ## Operations with WebAuthn 321 | 322 | This package simplifies operating with the WebAuthn _ceremonies_ (attestation and assertion). For this, use the convenient [`WebAuthn`](src/Facades/WebAuthn.php) facade. 323 | 324 | ### Attestation (Register) 325 | 326 | Use the `generateAttestation` and `validateAttestation` for your user. The latter returns the credentials validated, so you can save them manually. 327 | 328 | ```php 329 | json()->all(), $user 355 | ); 356 | 357 | // And save it. 358 | if ($credential) { 359 | $user->addCredential($credential); 360 | } else { 361 | return 'Something went wrong with your device!'; 362 | } 363 | ``` 364 | 365 | ### Assertion (Login) 366 | 367 | For assertion, simply create a request using `generateAssertion` and validate it with `validateAssertion`. 368 | 369 | ```php 370 | input('email'))->first(); 377 | 378 | // Create an assertion for the given user (or a blank one if not found); 379 | return WebAuthn::generateAssertion($user); 380 | ``` 381 | 382 | Then later we can verify it: 383 | 384 | ```php 385 | json()->all() 394 | ); 395 | 396 | // If is valid, login the user of the credentials. 397 | if ($credentials) { 398 | Auth::login( 399 | User::getFromCredentialId($credentials->getPublicKeyCredentialId()) 400 | ); 401 | } 402 | ``` 403 | 404 | ### Credentials 405 | 406 | You can manage the user credentials thanks to the [`WebAuthnAuthenticatable`](src/Contracts/WebAuthnAuthenticatable.php) contract directly from within the user instance. The most useful methods are: 407 | 408 | * `hasCredential()`: Checks if the user has a given Credential ID. 409 | * `addCredential()`: Adds a new Credential Source. 410 | * `removeCredential()`: Removes an existing Credential by its ID. 411 | * `flushCredentials()`: Removes all credentials. You can exclude credentials by their id. 412 | * `enableCredential()`: Includes an existing Credential ID from authentication. 413 | * `disableCredential()`: Excludes an existing Credential ID from authentication. 414 | * `getFromCredentialId()`: Returns the user using the given Credential ID, if any. 415 | 416 | You can use these methods to, for example, blacklist a stolen device/credential and register a new one, or disable WebAuthn completely by flushing all registered devices. 417 | 418 | ## Advanced Configuration 419 | 420 | Larapass was made to work out-of-the-box, but you can override the configuration by simply publishing the config file. 421 | 422 | php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="config" 423 | 424 | After that, you will receive the `config/larapass.php` config file with an array like this: 425 | 426 | ```php 427 | [ 431 | 'name' => env('WEBAUTHN_NAME', env('APP_NAME')), 432 | 'id' => env('WEBAUTHN_ID'), 433 | 'icon' => env('WEBAUTHN_ICON'), 434 | ], 435 | 'bytes' => 16, 436 | 'timeout' => 60, 437 | 'cache' => env('WEBAUTHN_CACHE'), 438 | 'algorithms' => [ 439 | \Cose\Algorithm\Signature\ECDSA\ES256::class, 440 | \Cose\Algorithm\Signature\EdDSA\Ed25519::class, 441 | \Cose\Algorithm\Signature\ECDSA\ES384::class, 442 | \Cose\Algorithm\Signature\ECDSA\ES512::class, 443 | \Cose\Algorithm\Signature\RSA\RS256::class, 444 | ], 445 | 'attachment' => null, 446 | 'conveyance' => 'none', 447 | 'login_verify' => 'preferred', 448 | 'userless' => null, 449 | 'unique' => false, 450 | 'fallback' => true, 451 | 'confirm_timeout' => 10800, 452 | ]; 453 | ``` 454 | 455 | ### Relaying Party Information 456 | 457 | ```php 458 | return [ 459 | 'relaying_party' => [ 460 | 'name' => env('WEBAUTHN_NAME', env('APP_NAME')), 461 | 'id' => env('WEBAUTHN_ID'), 462 | 'icon' => env('WEBAUTHN_ICON'), 463 | ], 464 | ]; 465 | ``` 466 | 467 | The _Relaying Party_ is just a way to uniquely identify your application in the user device: 468 | 469 | * `name`: The name of the application. Defaults to the application name. 470 | * `id`: Optional domain of the application. If null, the device will fill it internally. 471 | * `icon`: Optional image data in BASE64 (128 bytes maximum) or an image url. 472 | 473 | > Consider using the base domain like `myapp.com` as `id` to allow all the credential on subdomains like `foo.myapp.com`. 474 | 475 | ### Challenge configuration 476 | 477 | ```php 478 | return [ 479 | 'bytes' => 16, 480 | 'timeout' => 60, 481 | 'cache' => env('WEBAUTHN_CACHE'), 482 | ]; 483 | ``` 484 | 485 | The outgoing challenge to be signed is a random string of bytes. This controls how many bytes, the timeout of the challenge (which after is marked as invalid), and the cache used to store the challenge while its being resolved by the device. 486 | 487 | ### Algorithms 488 | 489 | ```php 490 | return [ 491 | 'algorithms' => [ 492 | \Cose\Algorithm\Signature\ECDSA\ES256::class, // ECDSA with SHA-256 493 | \Cose\Algorithm\Signature\EdDSA\Ed25519::class, // EdDSA 494 | \Cose\Algorithm\Signature\ECDSA\ES384::class, // ECDSA with SHA-384 495 | \Cose\Algorithm\Signature\ECDSA\ES512::class, // ECDSA with SHA-512 496 | \Cose\Algorithm\Signature\RSA\RS256::class, // RSASSA-PKCS1-v1_5 with SHA-256 497 | ], 498 | ]; 499 | ``` 500 | 501 | This controls how the authenticator (device) will operate to create the public-private keys. These [COSE Algorithms](https://w3c.github.io/webauthn/#typedefdef-cosealgorithmidentifier) are the most compatible ones for in-device and roaming keys, since some must be transmitted on low bandwidth protocols. 502 | 503 | > Add or remove the classes unless you don't know what you're doing. Really. Just leave them as they are. 504 | 505 | ### Key Attachment 506 | 507 | ```php 508 | return [ 509 | 'attachment' => null, 510 | ]; 511 | ``` 512 | 513 | By default, the user decides what to use for registration. If you wish to exclusively use a cross-platform authentication (like USB Keys, CA Servers or Certificates) set this to `true`, or `false` if you want to enforce device-only authentication. 514 | 515 | ### Attestation conveyance 516 | 517 | ```php 518 | return [ 519 | 'conveyance' => null, 520 | ]; 521 | ``` 522 | 523 | Attestation Conveyance represents if the device key should be verified by you or not. While most of the time is not needed, you can change this to `indirect` (you verify it comes from a trustful source) or `direct` (the device includes validation data). 524 | 525 | > Leave as it if you don't know what you're doing. 526 | 527 | ### Login verification 528 | 529 | ```php 530 | return [ 531 | 'login_verify' => 'preferred', 532 | ]; 533 | ``` 534 | 535 | By default, most authenticators will require the user verification when login in. You can override this and set it as `required` if you want no exceptions. 536 | 537 | You can also use `discouraged` to only check for user presence (like a "Continue" button), which may make the login faster but making it slightly less secure. 538 | 539 | > When setting [userless](#userless-login-one-touch-typeless) as `preferred` or `required` will override this to `required` automatically. 540 | 541 | ### Userless login (One touch, Typeless) 542 | 543 | ```php 544 | return [ 545 | 'userless' => null, 546 | ]; 547 | ``` 548 | 549 | You can activate _userless_ login, also known as one-touch login or typless login, for devices when they're being registered. You should change this to `preferred` in that case, since not all devices support the feature. 550 | 551 | If this is activated (not `null` or `discouraged`), login verification will be mandatory. 552 | 553 | > This doesn't affect the login procedure, only the attestation (registration). 554 | 555 | ### Unique 556 | 557 | ```php 558 | return [ 559 | 'unique' => false, 560 | ]; 561 | ``` 562 | 563 | If true, the device will limit the creation of only one credential by device. This is done by telling the device the list of credentials ID the user already has. If at least one if already present in the device, the latter will return an error. 564 | 565 | ### Password Fallback 566 | 567 | ```php 568 | return [ 569 | 'fallback' => true, 570 | ]; 571 | ``` 572 | 573 | By default, this package allows to re-use the same `eloquent-webauthn` driver to log in users with passwords when the credentials are not a WebAuthn JSON payload. 574 | 575 | Disabling the fallback will only validate the WebAuthn credentials. To handle classic user/password scenarios, you may create a separate guard. 576 | 577 | ### Confirmation timeout 578 | 579 | ```php 580 | return [ 581 | 'confirm_timeout' => 10800, 582 | ]; 583 | ``` 584 | 585 | When using the [Confirmation middleware](#confirmation-middleware), the confirmation will be remembered for a set amount of seconds. By default, is 3 hours, which is enough for most scenarios. 586 | 587 | ## Attestation and Metadata statements support 588 | 589 | If you need very-high-level of security, you should use attestation and metadata statements. You will basically ask the authenticator for its authenticity and check it in a lot of ways. 590 | 591 | For that, [check this article](https://webauthn-doc.spomky-labs.com/deep-into-the-framework/attestation-and-metadata-statement) and extend the classes in the Service Container as you need: 592 | 593 | ```php 594 | app->extend(AttestationStatementSupport::class, function ($manager) { 600 | $manager->add(new AndroidSafetyNetAttestationStatementSupport()); 601 | }); 602 | ``` 603 | 604 | ## Security 605 | 606 | These are some details about this WebAuthn implementation: 607 | 608 | * Registration (attestation) is exclusive to the domain, IP and user. 609 | * Login (assertion) is exclusive to the domain, IP, and the user if specified 610 | * Cached challenge is always forgotten after resolution, independently of the result. 611 | * Cached challenge TTL is the same as the WebAuthn timeout (60 seconds default). 612 | * Included controllers include throttling for WebAuthn endpoints. 613 | * Users ID (handle) is a random UUID v4. 614 | * Credentials can be blacklisted (enabled/disabled). 615 | 616 | If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. 617 | 618 | > As a sidenote, remember to [configure your application properly if it's behind a load balancer](https://laravel.com/docs/requests#configuring-trusted-proxies). 619 | 620 | ## FAQ 621 | 622 | * **Does this work with any browser?** 623 | 624 | [Yes](https://caniuse.com/#feat=webauthn). In the case of old browsers, you should have a fallback detection script. This can be asked with [the included Javascript helper](#5-use-the-javascript-helper-optional) in a breeze: 625 | 626 | ```javascript 627 | if (! Larapass.supportsWebAuthn()) { 628 | alert('Your device is not secure enough to use this site!'); 629 | } 630 | ``` 631 | 632 | * **Does this stores the user's fingerprint, PIN or patterns in my site?** 633 | 634 | No. It stores the public key generated by the device. 635 | 636 | * **Can a phishing site steal WebAuthn credentials and use them in my site?** 637 | 638 | No. WebAuthn kills phishing. 639 | 640 | * **Can the WebAuthn data identify a particular device?** 641 | 642 | No, unless explicitly requested and consented. 643 | 644 | * **Are my user's classic passwords safe?** 645 | 646 | Yes, as long you are hashing them as you should, and you have secured your application key. This is done by Laravel by default. You can also [disable them](#password-fallback). 647 | 648 | * **Can a user register two or more _devices_?** 649 | 650 | Yes. 651 | 652 | * **What happens if a credential is cloned?** 653 | 654 | The user won't be authenticated since the "logins" counter will be greater than the reported by the original device. To intercede in the procedure, modify the Assertion Validator in the Service Container and add your own `CounterChecker`: 655 | 656 | ```php 657 | $this->app->bind(CounterChecker::class, function () { 658 | return new \App\WebAuthn\MyCountChecker; 659 | }); 660 | ``` 661 | 662 | Inside your counter checker, you may want to throw an exception if the counter is below what is reported. 663 | 664 | ```php 665 | getCounter() <= $currentCounter) { 678 | throw new CredentialCloned($credentials); 679 | } 680 | } 681 | } 682 | ``` 683 | 684 | * **If a user loses his device, can he register a new device?** 685 | 686 | Yes, [use these recovery helpers](#6-set-up-account-recovery-optional). 687 | 688 | * **What's the difference between disabling and deleting a credential?** 689 | 690 | Disabling a credential doesn't delete it, so it can be later enabled manually in the case the user recovers it. When the credential is deleted, it goes away forever. 691 | 692 | * **Can a user delete its credentials from its device?** 693 | 694 | Yes. If it does, the other part of the credentials in your server gets virtually orphaned. You may want to show the user a list of registered credentials to delete them. 695 | 696 | * **How secure is this against passwords or 2FA?** 697 | 698 | Extremely secure since it works only on HTTPS (or `localhost`), and no password are exchanged, or codes are visible in the screen. 699 | 700 | * **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?** 701 | 702 | Yes. Just be sure to [use the recovery helpers](#6-set-up-account-recovery-optional) to avoid locking out your users.. 703 | 704 | * **Does this includes a frontend Javascript?** 705 | 706 | [Yes](#5-use-the-javascript-helper-optional), but it's very _basic_. 707 | 708 | * **Does this encodes/decode the strings automatically in the frontend?** 709 | 710 | Yes, the included [WebAuthn Helper](#5-use-the-javascript-helper-optional) does it automatically for you. 711 | 712 | * **Does this include a credential recovery routes?** 713 | 714 | [Yes.](#6-set-up-account-recovery-optional) 715 | 716 | * **Can I use my smartphone as authenticator through a PC desktop/laptop/terminal?** 717 | 718 | Depends on the OS and hardware. Some will require previously pairing the device to an "account". Others will only work with USB keys. This is up to hardware and software vendor themselves. 719 | 720 | * **Why my device doesn't show Windows Hello/TouchId/FaceId/fingerprint authentication?** 721 | 722 | By default, this WebAuthn implementation accepts almost everything. Some combinations of devices, OS and web browsers may differ on what to make available for WebAuthn authentication. In other words, it's not my fault. 723 | 724 | * **I'm trying to test this in my development server but it doesn't work** 725 | 726 | Use `localhost` exclusively, or use [ngrok](https://ngrok.com/) (or similar) to tunnel your site through HTTPS. WebAuthn only works on `localhost` or `HTTPS` only. 727 | 728 | ## License 729 | 730 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 731 | 732 | Laravel is a Trademark of Taylor Otwell. Copyright © 2011-2020 Laravel LLC. 733 | --------------------------------------------------------------------------------