├── 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 |
33 | @endsection
--------------------------------------------------------------------------------
/resources/views/layout.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
8 |
9 | @yield('title')
10 |
26 |
27 |
28 |
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 |
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 | 
6 |
7 | [](https://packagist.org/packages/darkghosthunter/larapass) [](https://packagist.org/packages/darkghosthunter/larapass)   [](https://coveralls.io/github/DarkGhostHunter/Larapass?branch=master) [](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 |
--------------------------------------------------------------------------------