29 Commits

Author SHA1 Message Date
Gregory Letellier
cfab865c1d Fix userHandle null, and force using security key when windows hello is activated in windows 2024-02-02 09:43:38 +01:00
b421096758 rename package 2024-02-01 19:00:44 +01:00
Jesse Donat
44ff0a94ad Correct support links (#54)
You're right, thanks.
2023-09-16 01:20:28 -03:00
Toby Evans
7e62ec927e Update README.md (#45) 2023-07-03 21:25:57 -04:00
Italo
872a7c667c Multiple clarifications [skip ci] 2023-06-05 14:20:52 -04:00
Italo
24afe969f6 Fixed block code [skip ci] 2023-06-05 12:12:11 -04:00
Italo
a4b074cdea Adds Passkeys mention [skip ci] 2023-06-05 12:06:43 -04:00
Italo
37f99b2e6d Added Passkeys mention [skip ci] 2023-06-05 11:45:33 -04:00
Italo
26fb8e436f Added Passkeys mention [skip ci] 2023-06-05 11:45:12 -04:00
Viktor Szépe
33f8de061a Use laravel-constraint in CI (#41)
Fixes Laravel version constraints on GitHub Actions
2023-03-12 20:24:35 -03:00
Italo
e57ac258b0 Merge pull request #40 from Laragear/fix/no-internal
[1.x] Removes internal tag #35 [skip ci]
2023-03-09 15:38:16 -03:00
Italo Israel Baeza Cabrera
952c1dcf72 Removes internal tag [skip ci] 2023-03-09 15:37:00 -03:00
Italo
652df193f1 Merge pull request #39 from Laragear/fix/key-length
[1.x] Fixes id length limitation #38
2023-03-09 14:42:04 -03:00
Italo Israel Baeza Cabrera
cd40888eb9 Fixes id length limitation #38 2023-03-09 14:39:27 -03:00
Italo
42558d9787 Merge pull request #34 from Laragear/feat/php-8.2
[1.x] Adds PHP 8.2, Laravel 10 Support
2023-02-22 02:07:52 -03:00
Italo Israel Baeza Cabrera
07ebd2b337 Should have fixed not found models 2023-02-22 02:05:39 -03:00
Italo Israel Baeza Cabrera
f9eee331f9 Fixes PHPUnit config version. 2023-02-22 01:57:15 -03:00
Italo Israel Baeza Cabrera
639ca1aa28 Fixes test for migration publishing. 2023-02-22 01:56:23 -03:00
Italo Israel Baeza Cabrera
0ea8f8d82b Initial PHP 8.2, Laravel 10 support. 2023-02-22 01:47:28 -03:00
Italo
2e420ba518 Merge pull request #30 from deibertf/1.x
Update webauthn.js to prevent wrong request urls
2023-02-15 22:18:36 -03:00
Italo
73502cea4e Use const, minor line break fix. 2023-02-15 22:16:02 -03:00
Felix Deibert
0b381551e0 Update webauthn.js 2023-02-16 00:34:39 +01:00
Felix Deibert
b0aa1974de Update webauthn.js 2023-02-16 00:10:01 +01:00
Italo
3291c57a3a Clarified hypothetical controllers 2023-01-19 11:55:21 -03:00
Italo
2ed7cdeff3 Minor clarification to WEBAUTHN_ID 2023-01-19 11:48:49 -03:00
Italo
e2af6a8395 Merge pull request #24 from Laragear/feat/trait_tests
[1.x] Adds tests for trait, fixing some methods.
2022-11-11 18:30:09 -03:00
Italo Israel Baeza Cabrera
89f15373bc Adds tests for trait, fixing some methods. 2022-11-11 18:26:07 -03:00
Italo
afa5b62107 Merge pull request #23 from Bubka/1.x
[1.1.3] FIX: Query error when disabling all credentials
2022-11-11 17:34:45 -03:00
Bubka
ef3ad38a16 Fixes query error when disabling all credentials 2022-11-11 18:05:43 +01:00
15 changed files with 427 additions and 115 deletions

View File

@@ -52,18 +52,24 @@ jobs:
php-version: php-version:
- "8.0" - "8.0"
- "8.1" - "8.1"
laravel-constrain: - "8.2"
laravel-constraint:
- "9.*" - "9.*"
- "10.*"
dependencies: dependencies:
- "lowest" - "lowest"
- "highest" - "highest"
exclude:
- php-version: "8.0"
laravel-constraint: "10.*"
steps: steps:
- name: "Set up PHP" - name: "Set up PHP"
uses: "shivammathur/setup-php@v2" uses: "shivammathur/setup-php@v2"
with: with:
php-version: "${{ matrix.php-version }}" php-version: "${{ matrix.php-version }}"
extensions: mbstring, intl extensions: "mbstring, intl"
coverage: xdebug coverage: "xdebug"
- name: "Checkout code" - name: "Checkout code"
uses: "actions/checkout@v3" uses: "actions/checkout@v3"
@@ -72,12 +78,13 @@ jobs:
uses: "ramsey/composer-install@v2" uses: "ramsey/composer-install@v2"
with: with:
dependency-versions: "${{ matrix.dependencies }}" dependency-versions: "${{ matrix.dependencies }}"
composer-options: "--with=laravel/framework:${{ matrix.laravel-constraint }}"
- name: "Execute unit tests" - name: "Execute unit tests"
run: "composer run-script test" run: "composer run-script test"
- name: "Upload coverage to Codecov" - name: "Upload coverage to Codecov"
uses: "codecov/codecov-action@v2" uses: "codecov/codecov-action@v3"
static_analysis: static_analysis:
name: "3⃣ Static Analysis" name: "3⃣ Static Analysis"
@@ -91,6 +98,7 @@ jobs:
with: with:
tools: "phpstan" tools: "phpstan"
php-version: "latest" php-version: "latest"
coverage: "none"
- name: "Checkout code" - name: "Checkout code"
uses: "actions/checkout@v3" uses: "actions/checkout@v3"

1
.gitignore vendored
View File

@@ -4,5 +4,6 @@
/.vscode /.vscode
.php-cs-fixer.cache .php-cs-fixer.cache
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache
composer.lock composer.lock
phpunit.xml.bak phpunit.xml.bak

View File

@@ -7,7 +7,7 @@
[![Sonarcloud Status](https://sonarcloud.io/api/project_badges/measure?project=Laragear_WebAuthn&metric=alert_status)](https://sonarcloud.io/dashboard?id=Laragear_WebAuthn) [![Sonarcloud Status](https://sonarcloud.io/api/project_badges/measure?project=Laragear_WebAuthn&metric=alert_status)](https://sonarcloud.io/dashboard?id=Laragear_WebAuthn)
[![Laravel Octane Compatibility](https://img.shields.io/badge/Laravel%20Octane-Compatible-success?style=flat&logo=laravel)](https://laravel.com/docs/9.x/octane#introduction) [![Laravel Octane Compatibility](https://img.shields.io/badge/Laravel%20Octane-Compatible-success?style=flat&logo=laravel)](https://laravel.com/docs/9.x/octane#introduction)
Authenticate users with fingerprints, patterns and biometric data. Authenticate users with Passkeys: fingerprints, patterns and biometric data.
```php ```php
// App\Http\Controllers\LoginController.php // App\Http\Controllers\LoginController.php
@@ -42,15 +42,15 @@ Require this package into your project using Composer:
composer require laragear/webauthn composer require laragear/webauthn
``` ```
## How does it work? ## How Passkeys work?
WebAuthn authentication process consists in two _ceremonies_: attestation, and assertion. Passkeys, hence WebAuthn, consists in two _ceremonies_: attestation, and assertion.
Attestation is the process of asking the authenticator (a phone, laptop, USB key...) to create a private-public key pair, and **register** the public key inside the app. For that to work, the user must exist, and the browser must support WebAuthn, which is what intermediates between the authenticator (OS & device hardware) and the app. Attestation is the process of asking the authenticator (a phone, laptop, USB key...) to create a private-public key pair, save the private key internally, and **store** the public key inside the server. For that to work, the browser must support WebAuthn, which is what intermediates between the authenticator (OS & device hardware) and the server.
Assertion is the process of pushing a cryptographic challenge to the device, which will return back _signed_ by the private key. Upon arrival, the app checks the signature is correct with the stored public key, ready to **log in**. Assertion is the process of pushing a cryptographic challenge to the authenticator, which will return back to the server _signed_ by the private key of the device. Upon arrival, the server checks the signature is correct with the stored public key, ready to **log in**.
The private key doesn't leave the authenticator, and there are no shared passwords to save, let alone remember. The private key doesn't leave the authenticator, there are no shared passwords stored anywhere, and Passkeys only work on the server domain (like google.com) or subdomain (like auth.google.com).
## Set up ## Set up
@@ -65,6 +65,10 @@ After that, you can quickly start WebAuthn with the included controllers and hel
4. [Register the controllers](#4-register-the-routes-and-controllers) 4. [Register the controllers](#4-register-the-routes-and-controllers)
5. [Use the Javascript helper](#5-use-the-javascript-helper) 5. [Use the Javascript helper](#5-use-the-javascript-helper)
> **Info**
>
> While you can use Passkeys without users by invoking the _ceremonies_ manually, Laragear WebAuthn is intended to be used with already existing Users.
### 1. Add the `eloquent-webauthn` driver ### 1. Add the `eloquent-webauthn` driver
Laragear WebAuthn works by extending the Eloquent User Provider with an additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have. Laragear WebAuthn works by extending the Eloquent User Provider with an additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have.
@@ -85,7 +89,7 @@ return [
]; ];
``` ```
The `password_fallback` indicates the User Provider should fall back to validate the password when the request is not a WebAuthn Assertion. It's enabled to seamlessly use both classic and WebAuthn authentication procedures. The `password_fallback` indicates the User Provider should fall back to validate the password when the request is not a WebAuthn Assertion. It's enabled to seamlessly use both classic (password) and WebAuthn authentication procedures.
### 2. Create the `webauthn_credentials` table ### 2. Create the `webauthn_credentials` table
@@ -152,25 +156,37 @@ This package includes a simple but convenient script to handle WebAuthn Attestat
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="js" php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="js"
``` ```
You will receive the `resources/js/vendor/webauthn/webauthn.js` file which you can include into your authentication views and use it programmatically, anyway you want. For example, [compiling it through Vite](https://laravel.com/docs/9.x/vite#loading-your-scripts-and-styles) into your application global JavaScript. You will receive the `resources/js/vendor/webauthn/webauthn.js` file which you can include into your authentication views and use it programmatically
```html ```html
<!doctype html> <!doctype html>
<head> <head>
{{-- ... --}} {{-- ... --}}
@vite(['resources/js/app.js', 'resources/js/vendor/webauthn/webauthn.js']) <script src="{{ Vite::asset('resources/js/vendor/webauthn/webauthn.js') }}"></script>
@vite(['resources/js/app.js'])
</head> </head>
``` ```
Once done, you can easily start registering and login in users. For example, for a logged-in user, you may show a registration view in HTML with the following code: > **Note**
>
> You can also edit the script file to transform it into a module so it can be bundled in your Vite frontend, exporting the class as `export default class WebAuthn { ... }`, and add it to the `@vite` assets.
>
> ```html
> @vite(['resources/js/vendor/webauthn/webauthn.js', 'resources/js/app.js'])
> ```
Once done, you can easily start registering an user device, and login in users that registered a device previusly.
For example, let's imagine an user logs in normally, and enters its profile view. You may show a WebAuthn registration HTML with the following code:
```html ```html
<form id="register-form"> <form id="register-form">
<button type="submit" value="Register authenticator"> <button type="submit" value="Register authenticator">
</form> </form>
<!-- Registering credentials --> <!-- Registering authenticator -->
<script> <script>
const register = event => { const register = event => {
event.preventDefault() event.preventDefault()
@@ -184,7 +200,7 @@ Once done, you can easily start registering and login in users. For example, for
</script> </script>
``` ```
On the other hand, consider a login HTML view with the following code: In our Login view, we can use the WebAuthn credentials to log in the user.
```html ```html
<form id="login-form"> <form id="login-form">
@@ -210,7 +226,7 @@ On the other hand, consider a login HTML view with the following code:
</script> </script>
``` ```
You can copy-paste this helper into your authentication routes, or import it into a bundler like [Laravel Vite](https://laravel.com/docs/9.x/vite), [Webpack](https://webpack.js.org/), [parcel](https://parceljs.org/), or many more. If the script doesn't suit your needs, you're free to create your own. You can copy-paste this helper into your authentication routes, or import it into a bundler like [Laravel Vite](https://laravel.com/docs/9.x/vite), [Webpack](https://webpack.js.org/), [parcel](https://parceljs.org/), or many more, as long you adjust the script to the bundler needs. If the script doesn't suit your needs, you're free to modify it or create your own.
### Requests and Responses parameters ### Requests and Responses parameters
@@ -258,6 +274,8 @@ const webAuthn = new WebAuthn({}, {
Attestation is the _ceremony_ to create WebAuthn Credentials. To create an Attestable Response that the user device can understand, use the `AttestationRequest::toCreate()` form request. Attestation is the _ceremony_ to create WebAuthn Credentials. To create an Attestable Response that the user device can understand, use the `AttestationRequest::toCreate()` form request.
For example, we can create our own `AttestationController` to create it.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php // app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Http\Requests\AttestationRequest;
@@ -292,7 +310,12 @@ public function register(AttestedRequest $request)
{ {
$request->validate(['alias' => 'nullable|string']); $request->validate(['alias' => 'nullable|string']);
$attestation->save($request->input('alias')); $attestation->save($request->only('alias'));
// Same as:
// $attestation->save(function ($credentials) use ($request) {
// $credentials->alias = $request->input('alias');
// })
} }
``` ```
@@ -354,6 +377,8 @@ The Assertion procedure also follows a two-step procedure: the user will input i
First, use the `AssertionRequest::toVerify()` form request. It will automatically create an assertion for the user that matches the credentials, or a blank one in case you're using [userless login](#userlessone-touchtypeless-login). Otherwise, you may set stricter validation rules to always ask for credentials. First, use the `AssertionRequest::toVerify()` form request. It will automatically create an assertion for the user that matches the credentials, or a blank one in case you're using [userless login](#userlessone-touchtypeless-login). Otherwise, you may set stricter validation rules to always ask for credentials.
For example, we can use our own `AssertionController` to handle it.
```php ```php
// app\Http\Controllers\WebAuthn\AssertionController.php // app\Http\Controllers\WebAuthn\AssertionController.php
use Laragear\WebAuthn\Http\Requests\AssertionRequest; use Laragear\WebAuthn\Http\Requests\AssertionRequest;
@@ -573,10 +598,17 @@ return [
The _Relying Party_ is just a way to uniquely identify your application in the user device: The _Relying Party_ is just a way to uniquely identify your application in the user device:
* `name`: The name of the application. Defaults to the application name. * `name`: The name of the application. Defaults to the application name.
* `id`: An unique ID the application, like the site domain. If `null`, the device may fill it internally, usually as the full domain. * `id`: An unique ID the application, like the site URL. If `null`, the device _may_ fill it internally, usually as the full domain.
> WebAuthn authentication only work on the top domain it was registered. > WebAuthn authentication only work on the top domain it was registered.
Instead of modifying the config file, you should use the environment variables to set the name and ID for WebAuthn.
```dotenv
WEBAUTHN_NAME=SecureBank
WEBAUTHN_ID=https://auth.securebank.com
```
### Challenge configuration ### Challenge configuration
```php ```php
@@ -617,7 +649,9 @@ No. WebAuthn only stores a cryptographic public key generated randomly by the de
* **Can a phishing site steal WebAuthn credentials and use them in my site to impersonate an user?** * **Can a phishing site steal WebAuthn credentials and use them in my site to impersonate an user?**
No. WebAuthn _kills the phishing_ because, unlike passwords, the private key never leaves the device. No. WebAuthn _kills the phishing_ because, unlike passwords, the private key never leaves the device, and the key-pair is bound to the top-most domain it was registered.
An user bing _phished_ at `staetbank.com` won't be able to login with a key made on the legit site `statebank.com`, as the device won't be able to find it.
* **Can WebAuthn data identify a particular device?** * **Can WebAuthn data identify a particular device?**
@@ -641,15 +675,15 @@ Yes. If you're not using a [password fallback](#password-fallback), you may need
* **What's the difference between disabling and deleting a credential?** * **What's the difference between disabling and deleting a credential?**
Disabling a credential doesn't delete it, so it's useful as a blacklisting mechanism and these can also be re-enabled. When the credential is deleted, it goes away forever. Disabling a credential doesn't delete it, so it's useful as a blacklisting mechanism and these can also be re-enabled. When the credential is deleted, it goes away forever from the server, so the credential in the authenticator device becomes orphaned.
* **Can a user delete its credentials from its device?** * **Can a user delete its credentials from its device?**
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 in the application to delete them. Yes. If it does, the other part of the credentials in your server gets orphaned. You may want to show the user a list of registered credentials in the application to delete them.
* **How secure is this against passwords or 2FA?** * **How secure is this against passwords or 2FA?**
Extremely secure since it works only on HTTPS (or `localhost`), no password or codes are exchanged nor visible in the screen. Extremely secure since it works only on HTTPS (or `localhost`). Also, no password or codes are exchanged nor visible in the screen.
* **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication and nothing else?** * **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication and nothing else?**
@@ -659,9 +693,13 @@ Extremely secure since it works only on HTTPS (or `localhost`), no password or c
[Yes](#5-use-the-javascript-helper), but it's very _basic_. [Yes](#5-use-the-javascript-helper), but it's very _basic_.
If you need more complex WebAuthn management, consider using the [`navigator.credentials`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/credentials) API directly.
* **Does WebAuthn eliminate bots? Can I forget about _captchas_?** * **Does WebAuthn eliminate bots? Can I forget about _captchas_?**
No, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots. Yes and no. To register users, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots.
Once a user is registered, bots won't be able to login because the real user is the only one that has the private key required for WebAuthn.
* **Does this encode/decode the WebAuthn data automatically in the frontend?** * **Does this encode/decode the WebAuthn data automatically in the frontend?**
@@ -675,9 +713,13 @@ Yes, public keys are encrypted when saved into the database.
No. You're free to create your own flow for recovery. No. You're free to create your own flow for recovery.
My recommendation is to send an email to the user, pointing to a route that registers a new device, and immediately redirect him to blacklist which credential was lost (or blacklist the only one he has).
* **Can I use my smartphone as authenticator through my PC or Mac?** * **Can I use my smartphone as authenticator through my PC or Mac?**
It depends. This is entirely up to hardware, OS and browser vendor themselves. It depends.
This is entirely up to hardware, OS and browser vendor themselves, but mostly the OS. Some OS or browsers may offer a way to sync private keys on the cloud, even letting the assertion challenge to be signed remotely instead of transmitting the private key. Please check your target platforms of choice.
* **Why my device doesn't show Windows Hello/Passkey/TouchId/FaceId/pattern/fingerprint authentication?** * **Why my device doesn't show Windows Hello/Passkey/TouchId/FaceId/pattern/fingerprint authentication?**
@@ -687,15 +729,17 @@ You may [check this site for authenticator support](https://webauthn.me/browser-
* **Why my device doesn't work at all with this package?** * **Why my device doesn't work at all with this package?**
This package supports WebAuthn 2.0, which is [W3C Recommendation](https://www.w3.org/TR/webauthn-2). Your device/OS/browser may be using an unsupported version. There are no plans to support older specs. This package supports WebAuthn 2.0, which is [W3C Recommendation](https://www.w3.org/TR/webauthn-2). Your device/OS/browser may be using an unsupported version.
There are no plans to support older WebAuthn specs. The new [WebAuthn 3.0 draft](https://www.w3.org/TR/webauthn-3) spec needs to be finished to be supported.
* **I'm trying to test this in my development server, but it doesn't work** * **I'm trying to test this in my development server, but it doesn't work**
Use `localhost` exclusively, not `127.0.0.1`, or use a proxy to tunnel your site through HTTPS. WebAuthn only works on `localhost` or under `HTTPS` only. Use `localhost` exclusively (not `127.0.0.1` or `::1`) or use a proxy to tunnel your site through HTTPS. WebAuthn only works on `localhost` or under `HTTPS` only.
* **Why this package supports only `none` attestation conveyance?** * **Why this package supports only `none` attestation conveyance?**
Because `direct`, `indirect` and `enterprise` attestations are mostly used on high-security high-risk scenarios, where an entity has total control on the devices used to authenticate. Because `direct`, `indirect` and `enterprise` attestations are mostly used on high-security high-risk scenarios, where an entity has total control on the devices used to authenticate. Imagine banks, medical, or military.
If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free). If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free).
@@ -705,7 +749,7 @@ No. The user can use whatever to authenticate in your app. This may be enabled o
* **Everytime I make attestations or assertions, it says no challenge exists!** * **Everytime I make attestations or assertions, it says no challenge exists!**
Remember that your WebAuthn routes must use Sessions, because the Challenges are saved there. Remember that your WebAuthn routes **must use Sessions**, because the Challenges are stored there.
More information can be retrieved in your [application logs](https://laravel.com/docs/9.x/logging). More information can be retrieved in your [application logs](https://laravel.com/docs/9.x/logging).
@@ -724,11 +768,11 @@ These are some details about this WebAuthn implementation:
* Registration (attestation) and Login (assertion) challenges use the current request session. * Registration (attestation) and Login (assertion) challenges use the current request session.
* Only one ceremony can be done at a time. * Only one ceremony can be done at a time.
* Challenges are pulled from the session on resolution, independently of their result. * Challenges are pulled (retrieved and deleted from source) from the session on resolution, independently of their result.
* All challenges and ceremonies expire at 60 seconds. * All challenges and ceremonies expire at 60 seconds.
* WebAuthn User Handle is UUID v4, reusable if another credential exists. * WebAuthn User Handle is UUID v4, reusable if another credential exists.
* Credentials can be blacklisted (enabled/disabled). * Credentials can be blacklisted (enabled/disabled).
* Public Keys are encrypted in the database automatically. * Public Keys are encrypted by with application key in the database automatically.
If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker.

View File

@@ -1,6 +1,6 @@
{ {
"name": "laragear/webauthn", "name": "kletellier/webauthn",
"description": "Authenticate your users with biometric data, devices or USB keys.", "description": "Authenticate users with Passkeys: fingerprints, patterns and biometric data.",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"minimum-stability": "stable", "minimum-stability": "stable",
@@ -24,30 +24,33 @@
"name": "Italo Israel Baeza Cabrera", "name": "Italo Israel Baeza Cabrera",
"email": "DarkGhostHunter@Gmail.com", "email": "DarkGhostHunter@Gmail.com",
"role": "Developer", "role": "Developer",
"homepage": "https://patreon.com/packagesforlaravel" "homepage": "https://github.com/sponsors/DarkGhostHunter"
},
{
"name": "Gregory Letellier",
"email": "register@gletellier.com",
"role": "Developer"
} }
], ],
"support": { "support": {
"source": "https://github.com/Laragear/TwoFactor", "source": "https://github.com/Laragear/WebAuthn",
"issues": "https://github.com/Laragear/TwoFactor/issues" "issues": "https://github.com/Laragear/WebAuthn/issues"
}, },
"require": { "require": {
"php": ">=8.0.2", "php": "8.*",
"ext-openssl": "*", "ext-openssl": "*",
"ext-json": "*", "ext-json": "*",
"illuminate/auth": "9.*", "illuminate/auth": "9.*|10.*",
"illuminate/http": "9.*", "illuminate/http": "9.*|10.*",
"illuminate/session": "9.*", "illuminate/session": "9.*|10.*",
"illuminate/support": "9.*", "illuminate/support": "9.*|10.*",
"illuminate/config": "9.*", "illuminate/config": "9.*|10.*",
"illuminate/database": "9.*", "illuminate/database": "9.*|10.*",
"illuminate/encryption": "9.*" "illuminate/encryption": "9.*|10.*"
}, },
"require-dev": { "require-dev": {
"orchestra/testbench": "7.*", "orchestra/testbench": "^7.22|8.*",
"phpunit/phpunit": "^9.5", "jetbrains/phpstorm-attributes": "*"
"mockery/mockery": "^1.5",
"jetbrains/phpstorm-attributes": "^1.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -76,16 +79,8 @@
}, },
"funding": [ "funding": [
{ {
"type": "Patreon", "type": "Github Sponsorship",
"url": "https://patreon.com/PackagesForLaravel" "url": "https://github.com/sponsors/DarkGhostHunter"
},
{
"type": "Ko-Fi",
"url": "https://ko-fi.com/DarkGhostHunter"
},
{
"type": "Buy me a cofee",
"url": "https://www.buymeacoffee.com/darkghosthunter"
}, },
{ {
"type": "Paypal", "type": "Paypal",

View File

@@ -43,7 +43,7 @@ return new class extends Migration {
*/ */
protected static function defaultBlueprint(Blueprint $table): void protected static function defaultBlueprint(Blueprint $table): void
{ {
$table->string('id')->primary(); $table->string('id', 510)->primary();
$table->morphs('authenticatable', 'webauthn_user_index'); $table->morphs('authenticatable', 'webauthn_user_index');

View File

@@ -1,11 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" cacheDirectory=".phpunit.cache">
backupStaticAttributes="false" colors="true" verbose="true" convertErrorsToExceptions="true"
convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false"
stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd">
<coverage> <coverage>
<include> <include>
<directory suffix=".php">src/</directory> <directory suffix=".php">src/</directory>
<directory suffix=".php">stubs/controllers</directory>
</include> </include>
<report> <report>
<clover outputFile="build/logs/clover.xml"/> <clover outputFile="build/logs/clover.xml"/>
@@ -14,14 +12,10 @@
<testsuites> <testsuites>
<testsuite name="Test Suite"> <testsuite name="Test Suite">
<directory>tests</directory> <directory>tests</directory>
<file>stubs/controllers/WebAuthnLoginController.php</file>
<file>stubs/controllers/WebAuthnRegisterController.php</file>
</testsuite> </testsuite>
</testsuites> </testsuites>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
<php> <php>
<includePath>stubs/controllers</includePath>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_DEBUG" value="true"/> <env name="APP_DEBUG" value="true"/>
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/> <env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>

View File

@@ -156,7 +156,9 @@ class WebAuthn {
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
#fetch(data, route, headers = {}) { #fetch(data, route, headers = {}) {
return fetch(route, { const url = new URL(route, window.location.origin).href;
return fetch(url, {
method: "POST", method: "POST",
credentials: this.#includeCredentials ? "include" : "same-origin", credentials: this.#includeCredentials ? "include" : "same-origin",
redirect: "error", redirect: "error",
@@ -267,7 +269,7 @@ class WebAuthn {
] ]
.filter(key => key in credentials.response) .filter(key => key in credentials.response)
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key])); .forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
parseCredentials.response['userId'] = credentials.id;
return parseCredentials; return parseCredentials;
} }
@@ -313,6 +315,7 @@ class WebAuthn {
const publicKeyCredential = this.#parseOutgoingCredentials(credentials); const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
Object.assign(publicKeyCredential, response); Object.assign(publicKeyCredential, response);
Object.assign(publicKeyCredential, request);
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse); return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
} }

View File

@@ -28,7 +28,7 @@ class AddConfiguration
public function handle(AssertionCreation $assertion, Closure $next): mixed public function handle(AssertionCreation $assertion, Closure $next): mixed
{ {
$assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000); $assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
$assertion->json->set('hints', ['security-key']); // Force security proposal for windows 10 and prevent Windows Hello
return $next($assertion); return $next($assertion);
} }
} }

View File

@@ -71,6 +71,11 @@ class CheckCredentialIsForUser
protected function validateId(AssertionValidation $validation): void protected function validateId(AssertionValidation $validation): void
{ {
$handle = $validation->request->json('response.userHandle'); $handle = $validation->request->json('response.userHandle');
$userId = $validation->request->json('response.userId');
if(! $handle && $userId) {
return;
}
if (! $handle || ! hash_equals(Uuid::fromString($validation->credential->user_id)->getHex()->toString(), $handle)) { if (! $handle || ! hash_equals(Uuid::fromString($validation->credential->user_id)->getHex()->toString(), $handle)) {
throw AssertionException::make('User ID is not owner of the stored credential.'); throw AssertionException::make('User ID is not owner of the stored credential.');

View File

@@ -95,7 +95,7 @@ class WebAuthnCredential extends Model
* *
* @var array<int, string> * @var array<int, string>
*/ */
protected $visible = ['id', 'origin', 'alias', 'aaguid', 'attestation_format', 'disabled_at', 'is_enabled']; protected $visible = ['id', 'origin', 'alias', 'aaguid', 'attestation_format', 'disabled_at'];
/** /**
* @phpstan-ignore-next-line * @phpstan-ignore-next-line

View File

@@ -4,9 +4,6 @@ namespace Laragear\WebAuthn;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/**
* @internal
*/
class WebAuthn class WebAuthn
{ {
// Constants for user verification in Attestation and Assertion. // Constants for user verification in Attestation and Assertion.

View File

@@ -4,9 +4,9 @@ namespace Laragear\WebAuthn;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Date;
use JetBrains\PhpStorm\ArrayShape; use JetBrains\PhpStorm\ArrayShape;
use Laragear\WebAuthn\Models\WebAuthnCredential; use Laragear\WebAuthn\Models\WebAuthnCredential;
use function in_array;
/** /**
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $webAuthnCredentials * @property-read \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $webAuthnCredentials
@@ -38,20 +38,17 @@ trait WebAuthnAuthentication
*/ */
public function flushCredentials(string ...$except): void public function flushCredentials(string ...$except): void
{ {
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) { if (! $this->relationLoaded('webAuthnCredentials')) {
$partitioned = $this->webAuthnCredentials $this->webAuthnCredentials()->whereKeyNot($except)->delete();
->partition(static function (WebAuthnCredential $credential) use ($except): bool {
return in_array($credential->getKey(), $except, true);
});
$partitioned->first()->each->delete();
$this->setRelation('webAuthnCredentials', $partitioned->last());
return; return;
} }
$this->webAuthnCredentials()->whereKeyNot($except)->delete(); if ($this->webAuthnCredentials instanceof Collection && $this->webAuthnCredentials->isNotEmpty()) {
$this->webAuthnCredentials->whereNotIn('id', $except)->each->delete();
$this->setRelation('webAuthnCredentials', $this->webAuthnCredentials->whereIn('id', $except));
}
} }
/** /**
@@ -64,13 +61,14 @@ trait WebAuthnAuthentication
{ {
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) { if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) {
$this->webAuthnCredentials $this->webAuthnCredentials
->each(static function (WebAuthnCredential $credential) use ($except): bool { ->when($except)->whereNotIn('id', $except)
if ($credential->isEnabled() && in_array($credential->getKey(), $except, true)) { ->each(static function (WebAuthnCredential $credential): void {
if ($credential->isEnabled()) {
$credential->disable(); $credential->disable();
} }
}); });
} else { } else {
$this->webAuthnCredentials()->whereKeyNot($except)->update(['is_enabled' => false]); $this->webAuthnCredentials()->whereKeyNot($except)->whereEnabled()->update(['disabled_at' => Date::now()]);
} }
} }

View File

@@ -6,8 +6,8 @@ use JetBrains\PhpStorm\ArrayShape;
class FakeAuthenticator class FakeAuthenticator
{ {
public const CREDENTIAL_ID = '-VOLFKPY-_FuMI_sJ7gMllK76L3VoRUINj6lL_Z3qDg'; public const CREDENTIAL_ID = 'owBYu_waGLhAOCg4EFzi6Lr55x51G2dR5yhJi8q2C3tgZQQL2aEi-nK3I54J6ILj70pJzR_6QxvA5XER17d7NA9EFe2QH3VoJYQGpO8G5yDoFQvsdkxNhioyMyhyQHNrAgTMGyfigIMCfhjk9te7LNYl9K5GbWRc4TGeQl1vROjBtTNm3GdpEOqp9RijWd-ShQZ95eHoc8SA_-8vzCyfmy-wI_K4ZqlQNNl85Fzg2GIBcC2zvcJhLYy1A2kw6JoBTAmz1ZCCgkTKWhzUvAJQpMpu40M67FqE0WkGZfSJ9A';
public const CREDENTIAL_ID_RAW = '+VOLFKPY+/FuMI/sJ7gMllK76L3VoRUINj6lL/Z3qDg='; public const CREDENTIAL_ID_RAW = 'owBYu/waGLhAOCg4EFzi6Lr55x51G2dR5yhJi8q2C3tgZQQL2aEi+nK3I54J6ILj70pJzR/6QxvA5XER17d7NA9EFe2QH3VoJYQGpO8G5yDoFQvsdkxNhioyMyhyQHNrAgTMGyfigIMCfhjk9te7LNYl9K5GbWRc4TGeQl1vROjBtTNm3GdpEOqp9RijWd+ShQZ95eHoc8SA/+8vzCyfmy+wI/K4ZqlQNNl85Fzg2GIBcC2zvcJhLYy1A2kw6JoBTAmz1ZCCgkTKWhzUvAJQpMpu40M67FqE0WkGZfSJ9A=';
public const ATTESTATION_USER = [ public const ATTESTATION_USER = [
'id' => 'e8af6f703f8042aa91c30cf72289aa07', 'id' => 'e8af6f703f8042aa91c30cf72289aa07',

View File

@@ -3,6 +3,7 @@
namespace Tests; namespace Tests;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Fluent; use Illuminate\Support\Fluent;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -28,19 +29,25 @@ class ServiceProviderTest extends TestCase
); );
} }
/**
* @define-env usesCustomTestTime
*/
public function test_publishes_migrations(): void public function test_publishes_migrations(): void
{ {
$format = now()->format('Y_m_d_His');
static::assertSame( static::assertSame(
[ [
realpath(WebAuthnServiceProvider::MIGRATIONS . '/2022_07_01_000000_create_webauthn_credentials.php') => realpath(WebAuthnServiceProvider::MIGRATIONS . '/2022_07_01_000000_create_webauthn_credentials.php') =>
$this->app->databasePath("migrations/{$format}_create_webauthn_credentials.php"), $this->app->databasePath("migrations/2020_01_01_163025_create_webauthn_credentials.php"),
], ],
ServiceProvider::pathsToPublish(WebAuthnServiceProvider::class, 'migrations') ServiceProvider::pathsToPublish(WebAuthnServiceProvider::class, 'migrations')
); );
} }
protected function usesCustomTestTime()
{
$this->travelTo(Carbon::create(2020, 01, 01, 16, 30, 25));
}
public function test_bounds_user(): void public function test_bounds_user(): void
{ {
static::assertNull($this->app->make(WebAuthnAuthenticatable::class)); static::assertNull($this->app->make(WebAuthnAuthenticatable::class));

View File

@@ -0,0 +1,260 @@
<?php
namespace Tests;
use Illuminate\Support\Carbon;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use Ramsey\Uuid\Uuid;
use function now;
class WebAuthnAuthenticationTest extends TestCase
{
protected Stubs\WebAuthnAuthenticatableUser $user;
protected function afterRefreshingDatabase(): void
{
$this->user = Stubs\WebAuthnAuthenticatableUser::forceCreate([
'name' => FakeAuthenticator::ATTESTATION_USER['displayName'],
'email' => FakeAuthenticator::ATTESTATION_USER['name'],
'password' => 'test_password',
]);
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id',
'user_id' => Uuid::NIL,
'counter' => 0,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
])->save();
}
public function test_shows_webauthn_data(): void
{
static::assertSame([
'name' => FakeAuthenticator::ATTESTATION_USER['name'],
'displayName' => FakeAuthenticator::ATTESTATION_USER['displayName'],
], $this->user->webAuthnData());
}
public function test_flushes_all_credentials(): void
{
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()
])->save();
$this->user->flushCredentials();
$this->assertDatabaseCount(WebAuthnCredential::class, 0);
}
public function test_flushes_all_credentials_using_loaded_relation(): void
{
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()
])->save();
$this->user->load('webAuthnCredentials');
static::assertCount(2, $this->user->webAuthnCredentials);
$this->user->flushCredentials();
static::assertEmpty($this->user->webAuthnCredentials);
$this->assertDatabaseCount(WebAuthnCredential::class, 0);
}
public function test_flushes_all_credentials_except_given_id(): void
{
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()
])->save();
$this->user->flushCredentials('test_id_2');
$this->assertDatabaseCount(WebAuthnCredential::class, 1);
$this->assertDatabaseMissing(WebAuthnCredential::class, [
'id' => 'test_id'
]);
}
public function test_flushes_all_credentials_using_loaded_relation_except_given_id(): void
{
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()
])->save();
$this->user->load('webAuthnCredentials');
static::assertCount(2, $this->user->webAuthnCredentials);
$this->user->flushCredentials('test_id_2');
static::assertCount(1, $this->user->webAuthnCredentials);
static::assertTrue($this->user->webAuthnCredentials->contains('id', 'test_id_2'));
$this->assertDatabaseCount(WebAuthnCredential::class, 1);
$this->assertDatabaseMissing(WebAuthnCredential::class, [
'id' => 'test_id'
]);
}
public function test_disables_all_credentials(): void
{
$this->travelTo(Carbon::now()->startOfSecond());
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()->subMinute()
])->save();
$this->user->disableAllCredentials();
$this->assertDatabaseCount(WebAuthnCredential::class, 2);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id',
'disabled_at' => now()->toDateTimeString(),
]);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id_2',
'disabled_at' => now()->subMinute()->toDateTimeString(),
]);
}
public function test_disables_all_credentials_with_loaded_relation(): void
{
$this->travelTo(Carbon::now()->startOfSecond());
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
'disabled_at' => now()->subMinute()
])->save();
$this->user->load('webAuthnCredentials');
$this->user->disableAllCredentials();
static::assertTrue($this->user->webAuthnCredentials->firstWhere('id', 'test_id')->isDisabled());
static::assertTrue($this->user->webAuthnCredentials->firstWhere('id', 'test_id_2')->isDisabled());
$this->assertDatabaseCount(WebAuthnCredential::class, 2);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id',
'disabled_at' => now()->toDateTimeString(),
]);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id_2',
'disabled_at' => now()->subMinute()->toDateTimeString(),
]);
}
public function test_disables_all_credentials_except_one(): void
{
$this->travelTo(Carbon::now()->startOfSecond());
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
])->save();
$this->user->disableAllCredentials('test_id');
$this->assertDatabaseCount(WebAuthnCredential::class, 2);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id',
'disabled_at' => null,
]);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id_2',
'disabled_at' => now()->toDateTimeString(),
]);
}
public function test_disables_all_credentials_with_loaded_relation_except_one(): void
{
$this->travelTo(Carbon::now()->startOfSecond());
$this->user->webAuthnCredentials()->make()->forceFill([
'id' => 'test_id_2',
'user_id' => Uuid::NIL,
'counter' => 10,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost:8000',
'aaguid' => Uuid::NIL,
'public_key' => 'test_key',
'attestation_format' => 'none',
])->save();
$this->user->load('webAuthnCredentials');
$this->user->disableAllCredentials('test_id_2');
static::assertTrue($this->user->webAuthnCredentials->firstWhere('id', 'test_id')->isDisabled());
static::assertFalse($this->user->webAuthnCredentials->firstWhere('id', 'test_id_2')->isDisabled());
$this->assertDatabaseCount(WebAuthnCredential::class, 2);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id',
'disabled_at' => now()->toDateTimeString(),
]);
$this->assertDatabaseHas(WebAuthnCredential::class, [
'id' => 'test_id_2',
'disabled_at' => null,
]);
}
}