47 Commits

Author SHA1 Message Date
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
Italo Baeza Cabrera
af04066dd0 Adds Service Provider tests. 2022-10-28 17:17:07 -03:00
Italo Baeza Cabrera
93d12d12c9 Fixes route registration. 2022-10-28 17:00:52 -03:00
Italo Baeza Cabrera
73e0e6c8ee Fixes static analysis line. 2022-10-28 16:36:00 -03:00
Italo Baeza Cabrera
ed68058bd8 Rewrites controller registration to something more understandable. 2022-10-28 16:35:43 -03:00
Italo Baeza Cabrera
f784f601d4 Merge remote-tracking branch 'origin/1.x' into 1.x 2022-10-28 16:30:56 -03:00
Italo
5ac8f14103 Merge pull request #22 from alresiainc/1.x
[minor] correct publishing the config file not working
2022-10-28 16:30:17 -03:00
Italo Baeza Cabrera
7f4465a4ef Should fix route registration using controller base prefix. 2022-10-28 16:26:53 -03:00
Italo
0b39038873 Merge pull request #20 from Bubka/1.x 2022-10-23 13:43:30 -03:00
Fidelis E. Peter
07283858a9 [minor] correct publishing the config file not working
When Publishing the config file

php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="config"

Will Return

"INFO  No publishable resources for tag [config]."

LINE FOR CONFIG WAS MISSING
2022-10-23 01:32:27 +01:00
Bubka
b98d07c277 Fixes relaying_party typo 2022-10-22 19:22:17 +02:00
Italo
7f628f9a79 Added better Github and actions config. 2022-08-26 00:25:25 -04:00
Italo
1fc7222b9b Merge pull request #16 from ildyria/fix-id-0-user
[1.x] Allow user credential to have id = 0
2022-08-24 23:30:48 -04:00
ildyria
55b73283c6 allow user credential to be a id = 0 2022-08-25 00:25:17 +02:00
Italo
c51cf697a0 Adds GitHub Sponsorship [skip ci] 2022-07-29 18:59:39 -04:00
Italo
5928fb1473 Merge pull request #15 from Laragear/feat/improve-ci
[1.x] Improve CI
2022-07-27 15:06:01 -04:00
Italo
bcd506c169 Runs on all branches [skip ci] 2022-07-27 15:05:47 -04:00
Italo
3225e300cd Fixes static analysis 2022-07-27 15:01:18 -04:00
Italo
397f119432 Fixes static analysis 2022-07-27 14:47:04 -04:00
Italo
d18c9dcc5a Fixes stati analysis 2022-07-27 14:29:24 -04:00
Italo
8277b1dd36 Fixes static analysis 2022-07-27 14:22:05 -04:00
Italo
21db20d103 Fixes static analysis 2022-07-27 14:03:56 -04:00
Italo
c02f826928 Removes non ASCII characters 2022-07-27 13:15:51 -04:00
Italo
8f29b60622 Removed non ASCII characters 2022-07-27 13:14:23 -04:00
Italo
b1f7356095 Improves CI 2022-07-27 13:11:30 -04:00
Italo
cf2294d80c Merge pull request #14 from ildyria/support-XCRF
[1.x] Fix: support for xcrf token
2022-07-10 12:41:18 -04:00
Italo
5d5fcfb376 Merge pull request #12 from ildyria/readme-typos
[1.x] Fix typos in readme.
2022-07-10 12:37:31 -04:00
ildyria
916d25e4e0 comments 2022-07-10 18:30:54 +02:00
ildyria
0b1541096c add support for X-XRSF-TOKEN 2022-07-10 18:25:54 +02:00
ildyria
0bf5972199 gitignore vscode 2022-07-10 18:25:37 +02:00
ildyria
e8189ea565 fix typos 2022-07-10 12:29:46 +02:00
Italo
f80492c3af Merge pull request #8 from ildyria/fix-6
[1.x] FIX #6 - use class_implements as intended
2022-07-05 15:03:02 -04:00
Italo
b453e50e87 Merge pull request #11 from ildyria/missing-composer-deps
[1.x] FIX #10 - missing jetbrains/phpstorm-attributes dependency
2022-07-05 14:55:06 -04:00
Italo
df08094bc6 Remove trailing comma. 2022-07-05 14:53:37 -04:00
Italo
0e629e0ac6 Update composer.json 2022-07-05 14:53:14 -04:00
ildyria
dd01a1ffe8 add missing composer import 2022-07-05 11:12:19 +02:00
ildyria
ef67c9e130 fixes #6 2022-07-05 10:45:04 +02:00
Italo
e5880c97a3 Minor clarifications [skip ci] 2022-06-30 20:08:12 -04:00
47 changed files with 745 additions and 231 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

6
.gitattributes vendored
View File

@@ -2,10 +2,12 @@
# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
# Ignore all test and documentation with "export-ignore". # Ignore all test and documentation with "export-ignore".
/.github export-ignore
/.gitattributes export-ignore /.gitattributes export-ignore
/.gitignore export-ignore /.gitignore export-ignore
/phpunit.xml.dist export-ignore /phpunit.xml.dist export-ignore
/phpunit.xml export-ignore
/tests export-ignore /tests export-ignore
/.editorconfig export-ignore /.editorconfig export-ignore
/.github export-ignore /.styleci.yml export-ignore
/.assets export-ignore /composer.lock export-ignore

8
.github/FUNDING.yml vendored
View File

@@ -1,5 +1,3 @@
# Help me support this package # Let's keep it free and up to date
github: DarkGhostHunter
patreon: PackagesForLaravel custom: "https://paypal.me/darkghosthunter"
ko_fi: DarkGhostHunter
custom: ['https://www.buymeacoffee.com/darkghosthunter', 'https://paypal.me/darkghosthunter']

View File

@@ -1,5 +1,8 @@
name: Bug Report name: Bug Report
description: File a bug report description: |
File a bug report to be fixed.
Sponsors get priority issues, PRs, fixes and requests. Not a sponsor? [You're a just click away!](https://github.com/sponsors/DarkGhostHunter).
title: "[X.x] What does happen that is considered an error or bug?" title: "[X.x] What does happen that is considered an error or bug?"
labels: ["bug"] labels: ["bug"]
assignees: assignees:
@@ -9,6 +12,7 @@ body:
attributes: attributes:
value: | value: |
Thanks for taking the time to fill out this bug report! Thanks for taking the time to fill out this bug report!
The more detailed this bug report is, the faster it can be reviewed and fixed. The more detailed this bug report is, the faster it can be reviewed and fixed.
- type: input - type: input
id: version-php-os id: version-php-os
@@ -18,6 +22,14 @@ body:
placeholder: 8.1.2 - Ubuntu 22.04 x64 placeholder: 8.1.2 - Ubuntu 22.04 x64
validations: validations:
required: true required: true
- type: input
id: version-db
attributes:
label: Database
description: Exact DB version using this package, if applicable.
placeholder: MySQL 8.0.28
validations:
required: false
- type: input - type: input
id: version-laravel id: version-laravel
attributes: attributes:
@@ -26,28 +38,12 @@ body:
placeholder: 9.2.3 placeholder: 9.2.3
validations: validations:
required: true required: true
- type: input
id: version-authenticator
attributes:
label: Authenticator type
description: If applicable, exact authenticator you're using.
placeholder: YubiKey 5, iPhone 7s, Samsung Galaxy S11+...
validations:
required: false
- type: input
id: version-os-browser
attributes:
label: OS and Browser versions
description: If applicable, exact OS and Browser versions
placeholder: Android 12.0 - Chrome 102.0.5005.99
validations:
required: false
- type: checkboxes - type: checkboxes
id: requirements id: requirements
attributes: attributes:
label: Have you done this? label: Have you done this?
options: options:
- label: I am willing to share my stack trace and logs - label: I have checked my logs and I'm sure is a bug in this package.
required: true required: true
- label: I can reproduce this bug in isolation (vanilla Laravel install) - label: I can reproduce this bug in isolation (vanilla Laravel install)
required: true required: true
@@ -58,7 +54,7 @@ body:
attributes: attributes:
label: Expectation label: Expectation
description: Write what you expect to (correctly) happen. description: Write what you expect to (correctly) happen.
placeholder: When I do this, I expect to this to happen. placeholder: When I do this, I expect to happen that.
validations: validations:
required: true required: true
- type: textarea - type: textarea
@@ -73,7 +69,7 @@ body:
id: reproduction id: reproduction
attributes: attributes:
label: Reproduction label: Reproduction
description: Paste the code to assert in a test, or just comment with the repository with the bug. description: Paste the code to assert in a test, or just comment with the repository with the bug to download.
render: php render: php
placeholder: | placeholder: |
$test = Laragear::make()->break(); $test = Laragear::make()->break();
@@ -87,27 +83,8 @@ body:
id: logs id: logs
attributes: attributes:
label: Stack trace & logs label: Stack trace & logs
description: If you have a stack trace, you can copy it here. You may hide sensible information. description: If you have a **full** stack trace, you can copy it here. You may hide sensible information.
placeholder: This is automatically formatted into code, no need for backticks. placeholder: This is automatically formatted into code, no need for ``` backticks.
render: shell render: shell
validations: validations:
required: false required: false
- type: textarea
id: attestation-assertion
attributes:
label: Attestation / Assertion objects
description: If applicable, add the Attestation and Assertion objects you have debugged.
placeholder: This is automatically formatted into Javascript, no need for backticks.
render: javascript
validations:
required: false
- type: dropdown
id: supporter
attributes:
label: Are you a Patreon supporter?
description: Patreon supporters get priority review, fixing and responses. Are you not? [Become one!](https://patreon.com/packagesforlaravel)
options:
- Yes, with my username
- No, don't give priority to this
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -1,5 +1,8 @@
name: Feature request name: Feature request
description: Suggest a feature for this package description: |
Suggest a feature for this package.
Sponsors get priority issues, PRs, fixes and requests. Not a sponsor? [You're a just click away!](https://github.com/sponsors/DarkGhostHunter).
title: "[X.x] Add this cool feature for this package" title: "[X.x] Add this cool feature for this package"
labels: ["enhancement"] labels: ["enhancement"]
assignees: assignees:
@@ -28,7 +31,12 @@ body:
attributes: attributes:
label: Description label: Description
description: Describe how the feature works description: Describe how the feature works
placeholder: This new feature would accomplish this, and would be cool to integrate it to the package because... placeholder: |
This new feature would accomplish...
It could be implemented by doing...
And it would be cool because...
validations: validations:
required: true required: true
- type: textarea - type: textarea
@@ -41,13 +49,3 @@ body:
render: php render: php
validations: validations:
required: true required: true
- type: dropdown
id: supporter
attributes:
label: Are you a Patreon supporter?
description: Patreon supporters get priority review, fixing and responses. Are you not? [Become one!](https://patreon.com/packagesforlaravel)
options:
- Yes, with my username
- No, don't give priority to this
validations:
required: true

View File

@@ -1,23 +1,22 @@
<!-- <!--
Thanks for contributing to this package! We only accept PR to the latest stable version. Thanks for contributing to this package! We only accept PR to the latest stable version.
If you're pushing a Feature: If you're pushing a Feature:
- Title it: "[X.x] This new feature" - Title it: "[X.x] This new feature"
- Describe what the new feature enables - Describe what the new feature enables
- Show a small code snippet of the new feature - Show a small code snippet of the new feature
- Ensure it doesn't break any feature. - Ensure it doesn't break backward compatibility.
If you're pushing a Fix: If you're pushing a Fix:
- Title it: "[X.x] FIX: The bug name" - Title it: "[X.x] FIX: The bug name"
- Describe how it fixes in a few words. - Describe how it fixes in a few words.
- Ensure it doesn't break any feature. - Ensure it doesn't break backward compatibility.
All Pull Requests run with extensive tests for stable and latest versions of PHP and Laravel. All Pull Requests run with extensive tests for stable and latest versions of PHP and Laravel.
Ensure your tests pass or your PR may be taken down. Ensure your tests pass or your PR may be taken down.
If you're a Patreon supporter, this PR will have priority. If you're a Sponsor, this PR will have priority review.
Not a Patreon supporter? Become one at https://patreon.com/packagesforlaravel Not a Sponsor? Become one at https://github.com/sponsors/DarkGhostHunter
--> -->
# Description # Description
@@ -29,5 +28,3 @@ This feature/fix allows to...
```php ```php
Laragear::sample(); Laragear::sample();
``` ```
<!-- You may delete this section if it's a FIX -->

BIN
.github/assets/support.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow
name: Tests name: Tests
on: on:
@@ -5,46 +7,114 @@ on:
pull_request: pull_request:
jobs: jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [ 8.0, 8.1 ]
laravel: [ 9.* ]
dependency-version: [ prefer-stable, prefer-lowest ]
include:
- laravel: 9.*
testbench: 7.*
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }}
byte_level:
name: "0⃣ Byte-level"
runs-on: "ubuntu-latest"
steps: steps:
- name: Checkout - name: "Checkout code"
uses: actions/checkout@v2 uses: "actions/checkout@v3"
- name: Setup PHP - name: "Check file permissions"
uses: shivammathur/setup-php@v2 run: |
test "$(find . -type f -not -path './.git/*' -executable)" == ""
- name: "Find non-printable ASCII characters"
run: |
! LC_ALL=C.UTF-8 find ./src -type f -name "*.php" -print0 | xargs -0 -- grep -PHn "[^ -~]"
syntax_errors:
name: "1⃣ Syntax errors"
runs-on: "ubuntu-latest"
steps:
- name: "Set up PHP"
uses: "shivammathur/setup-php@v2"
with: with:
php-version: ${{ matrix.php }} php-version: "8.1"
tools: "parallel-lint"
- name: "Checkout code"
uses: "actions/checkout@v3"
- name: "Validate Composer configuration"
run: "composer validate --strict"
- name: "Check source code for syntax errors"
run: "composer exec -- parallel-lint src/"
unit_tests:
name: "2⃣ Unit and Feature tests"
needs:
- "byte_level"
- "syntax_errors"
runs-on: "ubuntu-latest"
strategy:
matrix:
php-version:
- "8.0"
- "8.1"
laravel-constrain:
- "9.*"
dependencies:
- "lowest"
- "highest"
steps:
- name: "Set up PHP"
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php-version }}"
extensions: mbstring, intl extensions: mbstring, intl
coverage: xdebug coverage: xdebug
- name: Cache dependencies - name: "Checkout code"
uses: actions/cache@v2 uses: "actions/checkout@v3"
- name: "Install dependencies"
uses: "ramsey/composer-install@v2"
with: with:
path: ~/.composer/cache/files dependency-versions: "${{ matrix.dependencies }}"
key: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
restore-keys: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-
- name: Install dependencies - name: "Execute unit tests"
run: "composer run-script test"
- name: "Upload coverage to Codecov"
uses: "codecov/codecov-action@v2"
static_analysis:
name: "3⃣ Static Analysis"
needs:
- "byte_level"
- "syntax_errors"
runs-on: "ubuntu-latest"
steps:
- name: "Set up PHP"
uses: "shivammathur/setup-php@v2"
with:
tools: "phpstan"
php-version: "latest"
- name: "Checkout code"
uses: "actions/checkout@v3"
- name: "Install dependencies"
uses: "ramsey/composer-install@v2"
- name: "Execute static analysis"
run: "composer exec -- phpstan analyze -l 5 src/"
exported_files:
name: "4⃣ Exported files"
needs:
- "byte_level"
- "syntax_errors"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout code"
uses: "actions/checkout@v3"
- name: "Check exported files"
run: | run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-progress --no-update EXPECTED="LICENSE.md,README.md,composer.json"
composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress CURRENT="$(git archive HEAD | tar --list --exclude="src" --exclude="src/*" --exclude=".stubs" --exclude=".stubs/*" --exclude="routes" --exclude="routes/*" --exclude="stubs" --exclude="stubs/*" --exclude="lang" --exclude="lang/*" --exclude="config" --exclude="config/*" --exclude="database" --exclude="database/*" --exclude="resources" --exclude="resources/*" | paste -s -d ",")"
- name: Run Tests echo "CURRENT =${CURRENT}"
run: composer run-script test echo "EXPECTED=${EXPECTED}"
test "${CURRENT}" == "${EXPECTED}"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2

13
.gitignore vendored
View File

@@ -1,7 +1,8 @@
.idea /build
build /vendor
composer.lock /.idea
docs /.vscode
vendor .php-cs-fixer.cache
coverage
.phpunit.result.cache .phpunit.result.cache
composer.lock
phpunit.xml.bak

131
README.md
View File

@@ -10,6 +10,7 @@
Authenticate users with fingerprints, patterns and biometric data. Authenticate users with fingerprints, patterns and biometric data.
```php ```php
// App\Http\Controllers\LoginController.php
use Laragear\WebAuthn\Http\Requests\AssertedRequest; use Laragear\WebAuthn\Http\Requests\AssertedRequest;
public function login(AssertedRequest $request) public function login(AssertedRequest $request)
@@ -22,9 +23,9 @@ public function login(AssertedRequest $request)
> You want to add two-factor authentication to your app? Check out [Laragear TwoFactor](https://github.com/Laragear/TwoFactor). > You want to add two-factor authentication to your app? Check out [Laragear TwoFactor](https://github.com/Laragear/TwoFactor).
## Keep this package free ## Become a sponsor
[![Patreon](.assets/patreon.png)](https://patreon.com/packagesforlaravel)[![Ko-fi](.assets/ko-fi.png)](https://ko-fi.com/DarkGhostHunter)[![Buymeacoffee](.assets/buymeacoffee.png)](https://www.buymeacoffee.com/darkghosthunter)[![PayPal](.assets/paypal.png)](https://www.paypal.com/paypalme/darkghosthunter) [![](.github/assets/support.png)](https://github.com/sponsors/DarkGhostHunter)
Your support allows me to keep this package free, up-to-date and maintainable. Alternatively, you can **[spread the word!](http://twitter.com/share?text=I%20am%20using%20this%20cool%20PHP%20package&url=https://github.com%2FLaragear%2FWebAuthn&hashtags=PHP,Laravel)** Your support allows me to keep this package free, up-to-date and maintainable. Alternatively, you can **[spread the word!](http://twitter.com/share?text=I%20am%20using%20this%20cool%20PHP%20package&url=https://github.com%2FLaragear%2FWebAuthn&hashtags=PHP,Laravel)**
@@ -45,9 +46,9 @@ composer require laragear/webauthn
WebAuthn authentication process consists in two _ceremonies_: attestation, and assertion. WebAuthn authentication process 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 and the app. 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.
Assertion is the process of pushing a cryptographic challenge to the device, which will return _signed_ by the private key. Upon arrival, the app checks the signature with the public key, ready to **log in**. 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**.
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, and there are no shared passwords to save, let alone remember.
@@ -122,7 +123,7 @@ From here you're ready to work with WebAuthn Authentication. The following steps
### 4. Register the routes and controllers ### 4. Register the routes and controllers
WebAuthn uses exclusive routes to register and authenticate users. Creating these routes and controller may be cumbersome, specially if it's your first time in the WebAuthn real. WebAuthn uses exclusive routes to register and authenticate users. Creating these routes and controller may be cumbersome, specially if it's your first time in the WebAuthn realm.
Instead, go for a quick start and publish the controllers included in Laragear WebAuthn. These controllers will be located at `app\Http\Controllers\WebAuthn`. Instead, go for a quick start and publish the controllers included in Laragear WebAuthn. These controllers will be located at `app\Http\Controllers\WebAuthn`.
@@ -151,10 +152,23 @@ 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 Laravel Mix](https://laravel.com/docs/9.x/mix#working-with-scripts) 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, 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.
```html ```html
<script src="/js/app.js"></script> <!doctype html>
<head>
{{-- ... --}}
@vite(['resources/js/app.js', 'resources/js/vendor/webauthn/webauthn.js'])
</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:
```html
<form id="register-form">
<button type="submit" value="Register authenticator">
</form>
<!-- Registering credentials --> <!-- Registering credentials -->
<script> <script>
@@ -168,6 +182,16 @@ You will receive the `resources/js/vendor/webauthn/webauthn.js` file which you c
document.getElementById('register-form').addEventListener('submit', register) document.getElementById('register-form').addEventListener('submit', register)
</script> </script>
```
On the other hand, consider a login HTML view with the following code:
```html
<form id="login-form">
<input id="email" type="email" value="my@email.com">
<button type="submit" value="Log in with your device">
</form>
<!-- Login users --> <!-- Login users -->
<script> <script>
@@ -186,7 +210,7 @@ You will receive the `resources/js/vendor/webauthn/webauthn.js` file which you c
</script> </script>
``` ```
You can copy-paste this helper into your authentication routes, or import it into a bundler like [Laravel Vite](https://github.com/laravel/vite-plugin), [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. If the script doesn't suit your needs, you're free to create your own.
### Requests and Responses parameters ### Requests and Responses parameters
@@ -194,9 +218,11 @@ Both `register()` and `login()` accept different parameters for the initial requ
```javascript ```javascript
new WebAuthn().login({ new WebAuthn().login({
email: document.getElementById('email').value, // Initial request to the server // Initial request to the server
email: document.getElementById('email').value,
}, { }, {
remember: document.getElementById('remember').checked ? 'on' : null, // Response from the authenticator // Response from the authenticator to the server
remember: document.getElementById('remember').checked ? 'on' : null,
}) })
``` ```
@@ -232,7 +258,10 @@ 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
use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Http\Requests\AttestationRequest;
public function createChallenge(AttestationRequest $request) public function createChallenge(AttestationRequest $request)
@@ -244,6 +273,7 @@ public function createChallenge(AttestationRequest $request)
The device will receive the "instructions" to make a key, and will respond with it. You can use the `AttestedRequest` form request and its `save()` method to persist the WebAuthn key if it is valid. The request will automatically return a Validation exception if something fails. The device will receive the "instructions" to make a key, and will respond with it. You can use the `AttestedRequest` form request and its `save()` method to persist the WebAuthn key if it is valid. The request will automatically return a Validation exception if something fails.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestedRequest; use Laragear\WebAuthn\Http\Requests\AttestedRequest;
public function register(AttestedRequest $attestation) public function register(AttestedRequest $attestation)
@@ -257,6 +287,7 @@ public function register(AttestedRequest $attestation)
You may pass an array, or a callback, to the `save()`, which will allow you to modify the underlying WebAuthn Eloquent Model before saving it. For example, we could add an alias for the key present in the Request data. You may pass an array, or a callback, to the `save()`, which will allow you to modify the underlying WebAuthn Eloquent Model before saving it. For example, we could add an alias for the key present in the Request data.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestedRequest; use Laragear\WebAuthn\Http\Requests\AttestedRequest;
public function register(AttestedRequest $request) public function register(AttestedRequest $request)
@@ -276,6 +307,7 @@ By default, the authenticator decides how to verify user when creating a credent
You can override this using `fastRegistration()` to only check for user presence if possible, or `secureRegistration()` to actively verify the User. You can override this using `fastRegistration()` to only check for user presence if possible, or `secureRegistration()` to actively verify the User.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Http\Requests\AttestationRequest;
public function createChallenge(AttestationRequest $request) public function createChallenge(AttestationRequest $request)
@@ -291,6 +323,7 @@ Userless/One-touch/Typeless login This enables one click/tap login, without the
For this to work, the device has to save the "username id" inside itself. Some authenticators _may_ save it regardless, others may be not compatible. To make this mandatory when creating the WebAuthn Credential, use the `userless()` method of the `AttestationRequest` form request. For this to work, the device has to save the "username id" inside itself. Some authenticators _may_ save it regardless, others may be not compatible. To make this mandatory when creating the WebAuthn Credential, use the `userless()` method of the `AttestationRequest` form request.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Http\Requests\AttestationRequest;
public function registerDevice(AttestationRequest $request) public function registerDevice(AttestationRequest $request)
@@ -308,6 +341,7 @@ By default, during Attestation, the device will be informed about the existing e
You can enable multiple credentials per device using `allowDuplicates()`, which in turn will always return an empty list of credentials to exclude. This way the authenticator will _think_ there are no already stored credentials for your app. You can enable multiple credentials per device using `allowDuplicates()`, which in turn will always return an empty list of credentials to exclude. This way the authenticator will _think_ there are no already stored credentials for your app.
```php ```php
// app\Http\Controllers\WebAuthn\AttestationController.php
use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Http\Requests\AttestationRequest;
public function registerDevice(AttestationRequest $request) public function registerDevice(AttestationRequest $request)
@@ -322,7 +356,10 @@ 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
use Laragear\WebAuthn\Http\Requests\AssertionRequest; use Laragear\WebAuthn\Http\Requests\AssertionRequest;
public function createChallenge(AssertionRequest $request) public function createChallenge(AssertionRequest $request)
@@ -338,6 +375,7 @@ After that, you may receive the challenge using the `AssertedRequest` request ob
Since the authentication is pretty much straightforward, you only need to check if the `login()` method returns the newly authenticated user or `null` when it fails. When it's a success, it will take care of [regenerating the session](https://laravel.com/docs/9.x/session#regenerating-the-session-id) for you. Since the authentication is pretty much straightforward, you only need to check if the `login()` method returns the newly authenticated user or `null` when it fails. When it's a success, it will take care of [regenerating the session](https://laravel.com/docs/9.x/session#regenerating-the-session-id) for you.
```php ```php
// app\Http\Controllers\WebAuthn\AssertionController.php
use Laragear\WebAuthn\Http\Requests\AssertedRequest; use Laragear\WebAuthn\Http\Requests\AssertedRequest;
public function createChallenge(AssertedRequest $request) public function createChallenge(AssertedRequest $request)
@@ -361,6 +399,7 @@ In the same style of [attestation user verification](#attestation-user-verificat
You may only require the user presence with `fastLogin()`, or actively verify the user with `secureLogin()`. You may only require the user presence with `fastLogin()`, or actively verify the user with `secureLogin()`.
```php ```php
// app\Http\Controllers\WebAuthn\AssertionController.php
use Laragear\WebAuthn\Http\Requests\AssertionRequest; use Laragear\WebAuthn\Http\Requests\AssertionRequest;
public function createChallenge(AssertionRequest $request) public function createChallenge(AssertionRequest $request)
@@ -376,6 +415,7 @@ public function createChallenge(AssertionRequest $request)
By default, the `eloquent-webauthn` can be used to log in users with passwords when the credentials are not a WebAuthn JSON payload. This way, your normal Authentication flow is unaffected: By default, the `eloquent-webauthn` can be used to log in users with passwords when the credentials are not a WebAuthn JSON payload. This way, your normal Authentication flow is unaffected:
```php ```php
// app\Http\Controllers\Auth\LoginController.php
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
public function login(Request $request) public function login(Request $request)
@@ -448,7 +488,7 @@ If you want to manually Attest and Assert users, you may instance their respecti
All of these pipelines **require** the current Request to work, as is used to generate Challenges in the Session and validate different parts of the authentication data. All of these pipelines **require** the current Request to work, as is used to generate Challenges in the Session and validate different parts of the authentication data.
For example, you may manually authenticate a user with its WebAuthn Credentials `AssertionValidator` pipeline. For example, you may manually authenticate a user with its WebAuthn Credentials `AssertionValidator` pipeline. We can just type-hint a pipeline in a Controller action argument and Laravel will automatically inject the instance to it.
```php ```php
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation; use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
@@ -468,14 +508,30 @@ public function authenticate(Request $request, AssertionValidator $assertion)
} }
``` ```
Since these are Laravel Pipelines, you're free to push additional pipes: Since these are Laravel Pipelines, you're free to push additional pipes. These pipes can be a class with `handle()`, or just a function that receives the validation procedure.
```php ```php
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator; use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
use Exception;
public function addPipes(AssertionValidator $attestation) public function authenticate(Request $request, AssertionValidator $assertion)
{ {
$attestation->pipe(VerifyUserIsAwesome::class, NotifyIfAssertionFailed::class); $credential = $assertion
->send(new AssertionValidation($request))
// Add new pipes to the validation.
->pipe(function($validation, $next) {
if ($validation->user?->isNotAwesome()) {
throw new Exception('The user is not awesome');
}
return $next($validation);
})
->thenReturn()
->credential;
Auth::login($credential->user);
return "Welcome aboard, {$credential->user->name}!";
} }
``` ```
@@ -495,7 +551,7 @@ After that, you will receive the `config/webauthn.php` config file with an array
<?php <?php
return [ return [
'relaying_party' => [ 'relying_party' => [
'name' => env('WEBAUTHN_NAME', env('APP_NAME')), 'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
'id' => env('WEBAUTHN_ID'), 'id' => env('WEBAUTHN_ID'),
], ],
@@ -507,24 +563,31 @@ return [
]; ];
``` ```
### Relaying Party Information ### Relying Party Information
```php ```php
return [ return [
'relaying_party' => [ 'relying_party' => [
'name' => env('WEBAUTHN_NAME', env('APP_NAME')), 'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
'id' => env('WEBAUTHN_ID'), 'id' => env('WEBAUTHN_ID'),
], ],
]; ];
``` ```
The _Relaying 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
@@ -541,7 +604,7 @@ The outgoing challenges are random string of bytes. This controls how many bytes
## Laravel UI, Jetstream, Fortify, Sanctum, Breeze, Inertia and Livewire ## Laravel UI, Jetstream, Fortify, Sanctum, Breeze, Inertia and Livewire
In _theory_ this package should work without any problems with these packages, but you may need to override or _redirect_ the authentication flow (read: method codes) to one using WebAuthn. In _theory_ this package should work without any problems with these packages, but you may need to override or _redirect_ the authentication flow (read: override methods) to one using WebAuthn.
There is no support for using WebAuthn with these packages because these are meant to be used with classic user-password authentication. Any issue regarding these packages will be shot down with extreme prejudice. There is no support for using WebAuthn with these packages because these are meant to be used with classic user-password authentication. Any issue regarding these packages will be shot down with extreme prejudice.
@@ -551,7 +614,7 @@ If you think WebAuthn is critical for these packages, [consider supporting this
* **Does this work with any browser?** * **Does this work with any browser?**
[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) in a breeze: [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) in a breeze:
```javascript ```javascript
if (WebAuthn.doesntSupportWebAuthn()) { if (WebAuthn.doesntSupportWebAuthn()) {
@@ -563,9 +626,9 @@ if (WebAuthn.doesntSupportWebAuthn()) {
No. WebAuthn only stores a cryptographic public key generated randomly by the device. No. WebAuthn only stores a cryptographic public key generated randomly by the device.
* **Can a phishing site steal WebAuthn credentials and use them in my site?** * **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.
* **Can WebAuthn data identify a particular device?** * **Can WebAuthn data identify a particular device?**
@@ -585,7 +648,7 @@ Not by default, but [you can enable it](#multiple-credentials-per-device).
* **If a user loses his device, can he register a new device?** * **If a user loses his device, can he register a new device?**
Yes. If you're not using a [password fallback](#password-fallback), you may need to create a logic to register a new device using an email. It's assumed he is reading his email using a trusted device. Yes. If you're not using a [password fallback](#password-fallback), you may need to create a logic to register a new device using an email or SMS. It's assumed he is reading his email using a trusted device.
* **What's the difference between disabling and deleting a credential?** * **What's the difference between disabling and deleting a credential?**
@@ -599,11 +662,11 @@ Yes. If it does, the other part of the credentials in your server gets virtually
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`), no password or codes are exchanged nor visible in the screen.
* **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?** * **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication and nothing else?**
[Yes](#password-fallback). Just be sure to create recovery helpers to avoid locking out your users. [Yes](#password-fallback). Just be sure to create recovery helpers to avoid locking out your users.
* **Does this includes a frontend Javascript?** * **Does this include JavaScript to handle WebAuthn in the frontend?**
[Yes](#5-use-the-javascript-helper), but it's very _basic_. [Yes](#5-use-the-javascript-helper), but it's very _basic_.
@@ -611,15 +674,15 @@ Extremely secure since it works only on HTTPS (or `localhost`), no password or c
No, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots. No, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots.
* **Does this encodes/decode the WebAuthn data automatically in the frontend?** * **Does this encode/decode the WebAuthn data automatically in the frontend?**
Yes, the included [WebAuthn Helper](#5-use-the-javascript-helper) does it automatically for you. Yes, the included [WebAuthn Helper](#5-use-the-javascript-helper) does it automatically for you.
* **Does this encrypts the public keys?** * **Does this encrypt the public keys?**
Yes, public keys are encrypted when saved into the database. Yes, public keys are encrypted when saved into the database.
* **Does this include a credential recovery routes?** * **Does this include WebAuthn credential recovery routes?**
No. You're free to create your own flow for recovery. No. You're free to create your own flow for recovery.
@@ -629,7 +692,9 @@ It depends. This is entirely up to hardware, OS and browser vendor themselves.
* **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?**
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. For example, Windows 7 only supports USB keys. 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.
You may [check this site for authenticator support](https://webauthn.me/browser-support).
* **Why my device doesn't work at all with this package?** * **Why my device doesn't work at all with this package?**
@@ -641,7 +706,7 @@ Use `localhost` exclusively, not `127.0.0.1`, or use a proxy to tunnel your site
* **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.
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).
@@ -651,7 +716,9 @@ 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 Challenge gets saved there. Remember that your WebAuthn routes must use Sessions, because the Challenges are saved there.
More information can be retrieved in your [application logs](https://laravel.com/docs/9.x/logging).
## Laravel Octane Compatibility ## Laravel Octane Compatibility

View File

@@ -32,9 +32,9 @@
"issues": "https://github.com/Laragear/TwoFactor/issues" "issues": "https://github.com/Laragear/TwoFactor/issues"
}, },
"require": { "require": {
"php" : ">=8.0.2", "php": ">=8.0.2",
"ext-openssl": "*", "ext-openssl": "*",
"ext-json" : "*", "ext-json": "*",
"illuminate/auth": "9.*", "illuminate/auth": "9.*",
"illuminate/http": "9.*", "illuminate/http": "9.*",
"illuminate/session": "9.*", "illuminate/session": "9.*",
@@ -46,7 +46,8 @@
"require-dev": { "require-dev": {
"orchestra/testbench": "7.*", "orchestra/testbench": "7.*",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"mockery/mockery": "^1.5" "mockery/mockery": "^1.5",
"jetbrains/phpstorm-attributes": "^1.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

View File

@@ -4,11 +4,11 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Relaying Party | Relying Party
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| We will use your application information to inform the device who is the | We will use your application information to inform the device who is the
| relaying party. While only the name is enough, you can further set the | relying party. While only the name is enough, you can further set the
| a custom domain as ID and even an icon image data encoded as BASE64. | a custom domain as ID and even an icon image data encoded as BASE64.
| |
*/ */

View File

@@ -61,25 +61,49 @@ class WebAuthn {
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}} * @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
* @param headers {{string}} * @param headers {{string}}
* @param includeCredentials {boolean} * @param includeCredentials {boolean}
* @param xsrfToken {string|null} * @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
*/ */
constructor(routes = {}, headers = {}, includeCredentials = false, xsrfToken = null) { constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
Object.assign(this.#routes, routes); Object.assign(this.#routes, routes);
Object.assign(this.#headers, headers); Object.assign(this.#headers, headers);
this.#includeCredentials = includeCredentials; this.#includeCredentials = includeCredentials;
// If the developer didn't issue an XSRF token, we will find it ourselves. let xsrfToken;
this.#headers["X-CSRF-TOKEN"] ??= xsrfToken ?? WebAuthn.#firstInputWithXsrfToken; let csrfToken;
if (xcsrfToken === null) {
// If the developer didn't issue an XSRF token, we will find it ourselves.
xsrfToken = WebAuthn.#XsrfToken;
csrfToken = WebAuthn.#firstInputWithCsrfToken;
} else{
// Check if it is a CSRF or XSRF token
if (xcsrfToken.length === 40) {
csrfToken = xcsrfToken;
} else if (xcsrfToken.length === 224) {
xsrfToken = xcsrfToken;
} else {
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
}
}
if (xsrfToken !== null) {
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
} else if (csrfToken !== null) {
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
} else {
// We didn't find it, and since is required, we will bail out.
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
}
} }
/** /**
* Returns the XSRF token if it exists as a form input tag. * Returns the CSRF token if it exists as a form input tag.
* *
* @returns string * @returns string
* @throws TypeError * @throws TypeError
*/ */
static get #firstInputWithXsrfToken() { static get #firstInputWithCsrfToken() {
// First, try finding an CSRF Token in the head. // First, try finding an CSRF Token in the head.
let token = Array.from(document.head.getElementsByTagName("meta")) let token = Array.from(document.head.getElementsByTagName("meta"))
.find(element => element.name === "csrf-token"); .find(element => element.name === "csrf-token");
@@ -96,10 +120,33 @@ class WebAuthn {
return token.value; return token.value;
} }
// We didn't find it, and since is required, we will bail out. return null;
throw new TypeError('Ensure a CSRF token is manually set, or there is meta tag named "csrf-token".');
} }
/**
* Returns the value of the XSRF token if it exists in a cookie.
*
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
*
* @returns {?string}
*/
static get #XsrfToken() {
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
// We must remove all '%3D' from the end of the string.
// Background:
// The actual binary value of the CSFR value is encoded in Base64.
// If the length of original, binary value is not a multiple of 3 bytes,
// the encoding gets padded with `=` on the right; i.e. there might be
// zero, one or two `=` at the end of the encoded value.
// If the value is sent from the server to the client as part of a cookie,
// the `=` character is URL-encoded as `%3D`, because `=` is already used
// to separate a cookie key from its value.
// When we send back the value to the server as part of an AJAX request,
// Laravel expects an unpadded value.
// Hence, we must remove the `%3D`.
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
};
/** /**
* Returns a fetch promise to resolve later. * Returns a fetch promise to resolve later.
* *
@@ -109,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",
@@ -266,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

@@ -4,14 +4,17 @@ use App\Http\Controllers\WebAuthn\WebAuthnLoginController;
use App\Http\Controllers\WebAuthn\WebAuthnRegisterController; use App\Http\Controllers\WebAuthn\WebAuthnRegisterController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('web')->group(static function (): void { Route::middleware('web')
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options']) ->group(static function (): void {
->name('webauthn.register.options'); Route::controller(WebAuthnRegisterController::class)
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register']) ->group(static function (): void {
->name('webauthn.register'); Route::post('webauthn/register/options', 'options')->name('webauthn.register.options');
Route::post('webauthn/register', 'register')->name('webauthn.register');
});
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options']) Route::controller(WebAuthnLoginController::class)
->name('webauthn.login.options'); ->group(static function (): void {
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login']) Route::post('webauthn/login/options', 'options')->name('webauthn.login.options');
->name('webauthn.login'); Route::post('webauthn/login', 'login')->name('webauthn.login');
}); });
});

View File

@@ -33,6 +33,7 @@ class CreateAssertionChallenge
$options = []; $options = [];
if ($assertion->acceptedCredentials?->isNotEmpty()) { if ($assertion->acceptedCredentials?->isNotEmpty()) {
// @phpstan-ignore-next-line
$options['credentials'] = $assertion->acceptedCredentials->map->getKey()->toArray(); $options['credentials'] = $assertion->acceptedCredentials->map->getKey()->toArray();
} }

View File

@@ -36,10 +36,11 @@ class MayRetrieveCredentialsIdForUser
* Adapt all credentials into an `allowCredentials` digestible array. * Adapt all credentials into an `allowCredentials` digestible array.
* *
* @param \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $credentials * @param \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $credentials
* @return \Illuminate\Support\Collection<int, array> * @return \Illuminate\Support\Collection<int, array{id?: mixed, type: string, transports?: non-empty-array<int, string>}>
*/ */
protected function parseCredentials(EloquentCollection $credentials): Collection protected function parseCredentials(EloquentCollection $credentials): Collection
{ {
// @phpstan-ignore-next-line
return $credentials->map(static function (WebAuthnCredential $credential): array { return $credentials->map(static function (WebAuthnCredential $credential): array {
return array_filter([ return array_filter([
'id' => $credential->getKey(), 'id' => $credential->getKey(),

View File

@@ -56,6 +56,7 @@ class CheckCredentialIsForUser
*/ */
protected function validateUser(AssertionValidation $validation): void protected function validateUser(AssertionValidation $validation): void
{ {
// @phpstan-ignore-next-line
if ($validation->credential->authenticatable()->isNot($validation->user)) { if ($validation->credential->authenticatable()->isNot($validation->user)) {
throw AssertionException::make('User is not owner of the stored credential.'); throw AssertionException::make('User is not owner of the stored credential.');
} }

View File

@@ -18,7 +18,7 @@ use function unpack;
/** /**
* MIT License * MIT License
* *
* Copyright © 2021 Lukas Buchs * Copyright (c) 2021 Lukas Buchs
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy * Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal * of this software and associated documentation files (the "Software"), to deal
@@ -76,10 +76,10 @@ class AuthenticatorData
* @param string $relyingPartyIdHash * @param string $relyingPartyIdHash
* @param object $flags * @param object $flags
* @param int $counter * @param int $counter
* @param object{aaguid: int|bool, credentialId: string, credentialPublicKey: string} $attestedCredentialData * @param object $attestedCredentialData
* @param array $extensionData * @param array $extensionData
*/ */
public function __construct( final public function __construct(
public string $relyingPartyIdHash, public string $relyingPartyIdHash,
public object $flags, public object $flags,
public int $counter, public int $counter,
@@ -366,13 +366,21 @@ class AuthenticatorData
protected static function readFlags(string $binFlag): object protected static function readFlags(string $binFlag): object
{ {
$flags = (object) [ $flags = (object) [
// @phpstan-ignore-next-line
'bit_0' => (bool) ($binFlag & 1), 'bit_0' => (bool) ($binFlag & 1),
// @phpstan-ignore-next-line
'bit_1' => (bool) ($binFlag & 2), 'bit_1' => (bool) ($binFlag & 2),
// @phpstan-ignore-next-line
'bit_2' => (bool) ($binFlag & 4), 'bit_2' => (bool) ($binFlag & 4),
// @phpstan-ignore-next-line
'bit_3' => (bool) ($binFlag & 8), 'bit_3' => (bool) ($binFlag & 8),
// @phpstan-ignore-next-line
'bit_4' => (bool) ($binFlag & 16), 'bit_4' => (bool) ($binFlag & 16),
// @phpstan-ignore-next-line
'bit_5' => (bool) ($binFlag & 32), 'bit_5' => (bool) ($binFlag & 32),
// @phpstan-ignore-next-line
'bit_6' => (bool) ($binFlag & 64), 'bit_6' => (bool) ($binFlag & 64),
// @phpstan-ignore-next-line
'bit_7' => (bool) ($binFlag & 128), 'bit_7' => (bool) ($binFlag & 128),
'userPresent' => false, 'userPresent' => false,
'userVerified' => false, 'userVerified' => false,

View File

@@ -23,6 +23,7 @@ class AddUserDescriptor
$config = $attestable->user->webAuthnData(); $config = $attestable->user->webAuthnData();
// Create a new User UUID if it doesn't existe already in the credentials. // Create a new User UUID if it doesn't existe already in the credentials.
// @phpstan-ignore-next-line
$config['id'] = $attestable->user->webAuthnCredentials()->value('user_id') $config['id'] = $attestable->user->webAuthnCredentials()->value('user_id')
?: Str::uuid()->getHex()->toString(); ?: Str::uuid()->getHex()->toString();

View File

@@ -39,6 +39,7 @@ class MayPreventDuplicateCredentials
return $user return $user
->webAuthnCredentials() ->webAuthnCredentials()
->get(['id', 'transports']) ->get(['id', 'transports'])
// @phpstan-ignore-next-line
->map(static function (WebAuthnCredential $credential): array { ->map(static function (WebAuthnCredential $credential): array {
return array_filter([ return array_filter([
'id'=> $credential->getKey(), 'id'=> $credential->getKey(),

View File

@@ -8,7 +8,7 @@ use Laragear\WebAuthn\Attestation\AuthenticatorData;
/** /**
* MIT License * MIT License
* *
* Copyright © 2021 Lukas Buchs * Copyright (c) 2021 Lukas Buchs
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy * Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal * of this software and associated documentation files (the "Software"), to deal

View File

@@ -35,6 +35,6 @@ class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
*/ */
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
{ {
return $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'); return $this->config->get('webauthn.relying_party.id') ?? $this->config->get('app.url');
} }
} }

View File

@@ -41,7 +41,7 @@ class MakeWebAuthnCredential
'alias' => $validation->request->json('response.alias'), 'alias' => $validation->request->json('response.alias'),
'counter' => $validation->attestationObject->authenticatorData->counter, 'counter' => $validation->attestationObject->authenticatorData->counter,
'rp_id' => $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'), 'rp_id' => $this->config->get('webauthn.relying_party.id') ?? $this->config->get('app.url'),
'origin' => $validation->clientDataJson->origin, 'origin' => $validation->clientDataJson->origin,
'transports' => $validation->request->json('response.transports'), 'transports' => $validation->request->json('response.transports'),
'aaguid' => Uuid::fromBytes($validation->attestationObject->authenticatorData->attestedCredentialData->aaguid), 'aaguid' => Uuid::fromBytes($validation->attestationObject->authenticatorData->attestedCredentialData->aaguid),

View File

@@ -46,10 +46,11 @@ class WebAuthnUserProvider extends EloquentUserProvider
*/ */
public function retrieveByCredentials(array $credentials) public function retrieveByCredentials(array $credentials)
{ {
if (class_implements($this->model, WebAuthnAuthenticatable::class) && $this->isSignedChallenge($credentials)) { if (in_array(WebAuthnAuthenticatable::class, class_implements($this->model, true), true) && $this->isSignedChallenge($credentials)) {
/** @noinspection PhpIncompatibleReturnTypeInspection */ /** @noinspection PhpIncompatibleReturnTypeInspection */
return $this->newModelQuery() return $this->newModelQuery()
->whereHas('webAuthnCredentials', static function (Builder $query) use ($credentials): void { ->whereHas('webAuthnCredentials', static function (Builder $query) use ($credentials): void {
// @phpstan-ignore-next-line
$query->whereKey($credentials['id'])->whereEnabled(); $query->whereKey($credentials['id'])->whereEnabled();
}) })
->first(); ->first();

View File

@@ -49,8 +49,8 @@ use function unpack;
* --- * ---
* MIT License * MIT License
* *
* Copyright © 2021 Lukas Buchs * Copyright (c) 2021 Lukas Buchs
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part) * Copyright (c) 2018 Thomas Bleeker (CBOR & ByteBuffer part)
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy * Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal * of this software and associated documentation files (the "Software"), to deal
@@ -86,7 +86,7 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable
* @param string $binaryData * @param string $binaryData
* @param int $dataLength * @param int $dataLength
*/ */
public function __construct(protected string $binaryData, protected int $dataLength = 0) final public function __construct(protected string $binaryData, protected int $dataLength = 0)
{ {
$this->dataLength = strlen($binaryData); $this->dataLength = strlen($binaryData);
} }
@@ -194,7 +194,7 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable
* Returns the value of a single unsigned 16-bit integer. * Returns the value of a single unsigned 16-bit integer.
* *
* @param int $offset * @param int $offset
* @return mixed * @return int
*/ */
public function getUint16Val(int $offset = 0): int public function getUint16Val(int $offset = 0): int
{ {
@@ -209,7 +209,7 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable
* Returns the value of a single unsigned 32-bit integer. * Returns the value of a single unsigned 32-bit integer.
* *
* @param int $offset * @param int $offset
* @return mixed * @return int
*/ */
public function getUint32Val(int $offset = 0): int public function getUint32Val(int $offset = 0): int
{ {
@@ -404,7 +404,7 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable
throw new InvalidArgumentException('ByteBuffer: Invalid base64 url string'); throw new InvalidArgumentException('ByteBuffer: Invalid base64 url string');
} }
return new ByteBuffer($bin); return new static($bin);
} }
/** /**
@@ -415,11 +415,14 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable
*/ */
public static function fromBase64(string $base64): static public static function fromBase64(string $base64): static
{ {
if (false === $bin = base64_decode($base64)) { /** @var string|false $bin */
$bin = base64_decode($base64);
if (false === $bin) {
throw new InvalidArgumentException('ByteBuffer: Invalid base64 string'); throw new InvalidArgumentException('ByteBuffer: Invalid base64 string');
} }
return new ByteBuffer($bin); return new static($bin);
} }
/** /**

View File

@@ -34,8 +34,8 @@ use function sprintf;
* --- * ---
* MIT License * MIT License
* *
* Copyright © 2021 Lukas Buchs * Copyright (c) 2021 Lukas Buchs
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part) * Copyright (c) 2018 Thomas Bleeker (CBOR & ByteBuffer part)
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy * Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal * of this software and associated documentation files (the "Software"), to deal
@@ -248,7 +248,7 @@ class CborDecoder
* @param \Laragear\WebAuthn\ByteBuffer $buf * @param \Laragear\WebAuthn\ByteBuffer $buf
* @param $offset * @param $offset
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null * @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
* @throws \Laragear\WebAuthn\Exceptions\DataException * @throws \Laragear\WebAuthn\Exceptions\DataException|\InvalidArgumentException
*/ */
protected static function parseItemData( protected static function parseItemData(
int $type, int $type,

View File

@@ -17,7 +17,7 @@ class Challenge
* @param bool $verify * @param bool $verify
* @param array $properties * @param array $properties
*/ */
public function __construct( final public function __construct(
public ByteBuffer $data, public ByteBuffer $data,
public int $timeout, public int $timeout,
public bool $verify = true, public bool $verify = true,

View File

@@ -41,7 +41,8 @@ interface WebAuthnAuthenticatable
/** /**
* Returns a queryable relationship for its WebAuthn Credentials. * Returns a queryable relationship for its WebAuthn Credentials.
* *
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential * @phpstan-ignore-next-line
* @return \Illuminate\Database\Eloquent\Relations\MorphMany|\Laragear\WebAuthn\Models\WebAuthnCredential
*/ */
public function webAuthnCredentials(): MorphMany; public function webAuthnCredentials(): MorphMany;
} }

View File

@@ -11,7 +11,7 @@ class AssertionException extends ValidationException implements WebAuthnExceptio
* Create a new Assertion Exception with the error message. * Create a new Assertion Exception with the error message.
* *
* @param string $message * @param string $message
* @return \Laragear\WebAuthn\Exceptions\AssertionException * @return static
*/ */
public static function make(string $message): static public static function make(string $message): static
{ {

View File

@@ -11,7 +11,7 @@ class AttestationException extends ValidationException implements WebAuthnExcept
* Create a new Attestation Exception with the error message. * Create a new Attestation Exception with the error message.
* *
* @param string $message * @param string $message
* @return \Laragear\WebAuthn\Exceptions\AttestationException * @return static
*/ */
public static function make(string $message): static public static function make(string $message): static
{ {

View File

@@ -48,15 +48,18 @@ class AssertedRequest extends FormRequest
* Logs in the user for this assertion request. * Logs in the user for this assertion request.
* *
* @param string|null $guard * @param string|null $guard
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable&\Illuminate\Contracts\Auth\Authenticatable|null * @phpstan-ignore-next-line
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\Authenticatable|null
*/ */
public function login(string $guard = null, bool $remember = null, bool $destroySession = false): ?WebAuthnAuthenticatable public function login(string $guard = null, bool $remember = null, bool $destroySession = false): ?WebAuthnAuthenticatable
{ {
/** @var \Illuminate\Contracts\Auth\StatefulGuard $auth */
$auth = Auth::guard($guard); $auth = Auth::guard($guard);
if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) { if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) {
$this->session()->regenerate($destroySession); $this->session()->regenerate($destroySession);
// @phpstan-ignore-next-line
return $auth->user(); return $auth->user();
} }

View File

@@ -121,7 +121,7 @@ class AssertionRequest extends FormRequest
*/ */
protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable
{ {
if (!$credentials) { if ($credentials === null) {
return null; return null;
} }
@@ -133,7 +133,9 @@ class AssertionRequest extends FormRequest
// retrieve by its ID, otherwise we will fall back to credentials. Once done, we // retrieve by its ID, otherwise we will fall back to credentials. Once done, we
// will check it uses WebAuthn if is not null, otherwise we'll fail miserably. // will check it uses WebAuthn if is not null, otherwise we'll fail miserably.
$user = is_string($credentials) || is_int($credentials) $user = is_string($credentials) || is_int($credentials)
// @phpstan-ignore-next-line
? Auth::guard($this->guard)->getProvider()->retrieveById($credentials) ? Auth::guard($this->guard)->getProvider()->retrieveById($credentials)
// @phpstan-ignore-next-line
: Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials); : Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials);
if ($user && ! $user instanceof WebAuthnAuthenticatable) { if ($user && ! $user instanceof WebAuthnAuthenticatable) {

View File

@@ -9,7 +9,7 @@ use Laragear\WebAuthn\Events\CredentialDisabled;
use Laragear\WebAuthn\Events\CredentialEnabled; use Laragear\WebAuthn\Events\CredentialEnabled;
/** /**
* @mixin \Illuminate\Database\Eloquent\Builder<\Laragear\WebAuthn\Models\WebAuthnCredential> * @mixin \Illuminate\Database\Eloquent\Builder
* *
* @method static \Illuminate\Database\Eloquent\Builder|static query() * @method static \Illuminate\Database\Eloquent\Builder|static query()
* @method \Illuminate\Database\Eloquent\Builder|static newQuery() * @method \Illuminate\Database\Eloquent\Builder|static newQuery()
@@ -22,10 +22,10 @@ use Laragear\WebAuthn\Events\CredentialEnabled;
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOr($columns = ['*'], \Closure $callback = null) * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOr($columns = ['*'], \Closure $callback = null)
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstWhere($column, $operator = null, $value = null, $boolean = 'and') * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstWhere($column, $operator = null, $value = null, $boolean = 'and')
* @method \Laragear\WebAuthn\Models\WebAuthnCredential updateOrCreate(array $attributes, array $values = []) * @method \Laragear\WebAuthn\Models\WebAuthnCredential updateOrCreate(array $attributes, array $values = [])
* @method static|null first($columns = ['*']) * @method ?static first($columns = ['*'])
* @method static static findOrFail($id, $columns = ['*']) * @method static static findOrFail($id, $columns = ['*'])
* @method static static findOrNew($id, $columns = ['*']) * @method static static findOrNew($id, $columns = ['*'])
* @method static static|null find($id, $columns = ['*']) * @method static ?null find($id, $columns = ['*'])
* *
* @property-read string $id * @property-read string $id
* *
@@ -95,10 +95,11 @@ 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'];
/** /**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo&\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable * @phpstan-ignore-next-line
* @return \Illuminate\Database\Eloquent\Relations\MorphTo|\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
*/ */
public function authenticatable(): MorphTo public function authenticatable(): MorphTo
{ {
@@ -113,6 +114,7 @@ class WebAuthnCredential extends Model
*/ */
protected function scopeWhereEnabled(Builder $query): Builder protected function scopeWhereEnabled(Builder $query): Builder
{ {
// @phpstan-ignore-next-line
return $query->whereNull('disabled_at'); return $query->whereNull('disabled_at');
} }
/** /**
@@ -123,6 +125,7 @@ class WebAuthnCredential extends Model
*/ */
protected function scopeWhereDisabled(Builder $query): Builder protected function scopeWhereDisabled(Builder $query): Builder
{ {
// @phpstan-ignore-next-line
return $query->whereNotNull('disabled_at'); return $query->whereNotNull('disabled_at');
} }

View File

@@ -39,11 +39,11 @@ abstract class CheckRelyingPartyHashSame
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{ {
// This way we can get the app RP ID on attestation, and the Credential RP ID // This way we can get the app RP ID on attestation, and the Credential RP ID
// on assertion. The credential will have the same Relaying Party ID on both // on assertion. The credential will have the same Relying Party ID on both
// the authenticator and the application so on assertion both should match. // the authenticator and the application so on assertion both should match.
$relayingParty = parse_url($this->relyingPartyId($validation), PHP_URL_HOST); $relyingParty = parse_url($this->relyingPartyId($validation), PHP_URL_HOST);
if ($this->authenticatorData($validation)->hasNotSameRPIdHash($relayingParty)) { if ($this->authenticatorData($validation)->hasNotSameRPIdHash($relyingParty)) {
static::throw($validation, 'Response has different Relying Party ID hash.'); static::throw($validation, 'Response has different Relying Party ID hash.');
} }

View File

@@ -40,11 +40,11 @@ abstract class CheckRelyingPartyIdContained
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{ {
if (!$host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) { if (!$host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) {
static::throw($validation, 'Relaying Party ID is invalid.'); static::throw($validation, 'Relying Party ID is invalid.');
} }
$current = parse_url( $current = parse_url(
$this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'), PHP_URL_HOST $this->config->get('webauthn.relying_party.id') ?? $this->config->get('app.url'), PHP_URL_HOST
); );
// Check the host is the same or is a subdomain of the current config domain. // Check the host is the same or is a subdomain of the current config domain.
@@ -52,6 +52,6 @@ abstract class CheckRelyingPartyIdContained
return $next($validation); return $next($validation);
} }
static::throw($validation, 'Relaying Party ID not scoped to current.'); static::throw($validation, 'Relying Party ID not scoped to current.');
} }
} }

View File

@@ -31,20 +31,19 @@ class WebAuthn
*/ */
public static function routes(): void public static function routes(): void
{ {
Route::middleware('web')->group(static function (): void { Route::middleware('web')
Route::post('webauthn/register/options') ->group(static function (): void {
->uses([\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class, 'options']) Route::controller(\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class)
->name('webauthn.register.options'); ->group(static function (): void {
Route::post('webauthn/register') Route::post('webauthn/register/options', 'options')->name('webauthn.register.options');
->uses([\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class, 'register']) Route::post('webauthn/register', 'register')->name('webauthn.register');
->name('webauthn.register'); });
Route::post('webauthn/login/options') Route::controller(\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class)
->uses([\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class, 'options']) ->group(static function (): void {
->name('webauthn.login.options'); Route::post('webauthn/login/options', 'options')->name('webauthn.login.options');
Route::post('webauthn/login') Route::post('webauthn/login', 'login')->name('webauthn.login');
->uses([\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class, 'login']) });
->name('webauthn.login');
}); });
} }
} }

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

@@ -45,6 +45,8 @@ class WebAuthnServiceProvider extends ServiceProvider
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->publishesMigrations(static::MIGRATIONS); $this->publishesMigrations(static::MIGRATIONS);
$this->publishes([static::ROUTES => $this->app->basePath('routes/webauthn.php')], 'routes'); $this->publishes([static::ROUTES => $this->app->basePath('routes/webauthn.php')], 'routes');
$this->publishes([static::CONFIG => $this->app->configPath('webauthn.php')], 'config');
// @phpstan-ignore-next-line
$this->publishes([static::CONTROLLERS => $this->app->path('Http/Controllers/WebAuthn')], 'controllers'); $this->publishes([static::CONTROLLERS => $this->app->path('Http/Controllers/WebAuthn')], 'controllers');
$this->publishes([static::JS => $this->app->resourcePath('js/vendor/webauthn')], 'js'); $this->publishes([static::JS => $this->app->resourcePath('js/vendor/webauthn')], 'js');
} }

View File

@@ -457,7 +457,7 @@ class ValidationTest extends TestCase
$this->request->setJson(new ParameterBag($invalid)); $this->request->setJson(new ParameterBag($invalid));
$this->expectException(AssertionException::class); $this->expectException(AssertionException::class);
$this->expectExceptionMessage('Assertion Error: Relaying Party ID not scoped to current.'); $this->expectExceptionMessage('Assertion Error: Relying Party ID not scoped to current.');
$this->validate(); $this->validate();
} }
@@ -477,7 +477,7 @@ class ValidationTest extends TestCase
$this->request->setJson(new ParameterBag($invalid)); $this->request->setJson(new ParameterBag($invalid));
$this->expectException(AssertionException::class); $this->expectException(AssertionException::class);
$this->expectExceptionMessage('Assertion Error: Relaying Party ID not scoped to current.'); $this->expectExceptionMessage('Assertion Error: Relying Party ID not scoped to current.');
$this->validate(); $this->validate();
} }

View File

@@ -80,7 +80,7 @@ class CreatorTest extends TestCase
]); ]);
} }
public function test_uses_relaying_party_config(): void public function test_uses_relying_party_config(): void
{ {
config(['webauthn.relying_party' => [ config(['webauthn.relying_party' => [
'id' => 'https://foo.bar', 'id' => 'https://foo.bar',

View File

@@ -504,7 +504,7 @@ class ValidationTest extends TestCase
public function test_rp_id_fails_if_not_equal(): void public function test_rp_id_fails_if_not_equal(): void
{ {
$this->expectException(AttestationException::class); $this->expectException(AttestationException::class);
$this->expectExceptionMessage('Attestation Error: Relaying Party ID not scoped to current.'); $this->expectExceptionMessage('Attestation Error: Relying Party ID not scoped to current.');
$invalid = FakeAuthenticator::attestationResponse(); $invalid = FakeAuthenticator::attestationResponse();
@@ -524,7 +524,7 @@ class ValidationTest extends TestCase
public function test_rp_id_fails_if_not_contained(): void public function test_rp_id_fails_if_not_contained(): void
{ {
$this->expectException(AttestationException::class); $this->expectException(AttestationException::class);
$this->expectExceptionMessage('Attestation Error: Relaying Party ID not scoped to current.'); $this->expectExceptionMessage('Attestation Error: Relying Party ID not scoped to current.');
$invalid = FakeAuthenticator::attestationResponse(); $invalid = FakeAuthenticator::attestationResponse();
@@ -546,7 +546,7 @@ class ValidationTest extends TestCase
$this->app->when(CheckRelyingPartyHashSame::class) $this->app->when(CheckRelyingPartyHashSame::class)
->needs(ConfigContract::class) ->needs(ConfigContract::class)
->give(static function (): Repository { ->give(static function (): Repository {
return tap(new Repository())->set('webauthn.relaying_party.id', 'https://otherhost.com'); return tap(new Repository())->set('webauthn.relying_party.id', 'https://otherhost.com');
}); });
$this->expectException(AttestationException::class); $this->expectException(AttestationException::class);

View File

@@ -0,0 +1,64 @@
<?php
namespace Tests;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Fluent;
use Illuminate\Support\ServiceProvider;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\WebAuthnAuthentication;
use Laragear\WebAuthn\WebAuthnServiceProvider;
class ServiceProviderTest extends TestCase
{
public function test_merges_config(): void
{
static::assertSame(
File::getRequire(WebAuthnServiceProvider::CONFIG),
$this->app->make('config')->get('webauthn')
);
}
public function test_publishes_config(): void
{
static::assertSame(
[WebAuthnServiceProvider::CONFIG => $this->app->configPath('webauthn.php')],
ServiceProvider::$publishGroups['config']
);
}
public function test_publishes_migrations(): void
{
$format = now()->format('Y_m_d_His');
static::assertSame(
[
realpath(WebAuthnServiceProvider::MIGRATIONS . '/2022_07_01_000000_create_webauthn_credentials.php') =>
$this->app->databasePath("migrations/{$format}_create_webauthn_credentials.php"),
],
ServiceProvider::pathsToPublish(WebAuthnServiceProvider::class, 'migrations')
);
}
public function test_bounds_user(): void
{
static::assertNull($this->app->make(WebAuthnAuthenticatable::class));
$user = new class extends Fluent implements WebAuthnAuthenticatable {
use WebAuthnAuthentication;
};
$this->app->instance(Authenticatable::class, $user);
static::assertSame($user, $this->app->make(WebAuthnAuthenticatable::class));
}
public function test_publishes_routes_file(): void
{
static::assertSame(
[WebAuthnServiceProvider::ROUTES => $this->app->basePath('routes/webauthn.php')],
ServiceProvider::$publishGroups['routes']
);
}
}

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,
]);
}
}