Compare commits
64 Commits
v1.1.1
...
44ff0a94ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ff0a94ad | ||
|
|
7e62ec927e | ||
|
|
872a7c667c | ||
|
|
24afe969f6 | ||
|
|
a4b074cdea | ||
|
|
37f99b2e6d | ||
|
|
26fb8e436f | ||
|
|
33f8de061a | ||
|
|
e57ac258b0 | ||
|
|
952c1dcf72 | ||
|
|
652df193f1 | ||
|
|
cd40888eb9 | ||
|
|
42558d9787 | ||
|
|
07ebd2b337 | ||
|
|
f9eee331f9 | ||
|
|
639ca1aa28 | ||
|
|
0ea8f8d82b | ||
|
|
2e420ba518 | ||
|
|
73502cea4e | ||
|
|
0b381551e0 | ||
|
|
b0aa1974de | ||
|
|
3291c57a3a | ||
|
|
2ed7cdeff3 | ||
|
|
e2af6a8395 | ||
|
|
89f15373bc | ||
|
|
afa5b62107 | ||
|
|
ef3ad38a16 | ||
|
|
af04066dd0 | ||
|
|
93d12d12c9 | ||
|
|
73e0e6c8ee | ||
|
|
ed68058bd8 | ||
|
|
f784f601d4 | ||
|
|
5ac8f14103 | ||
|
|
7f4465a4ef | ||
|
|
0b39038873 | ||
|
|
07283858a9 | ||
|
|
b98d07c277 | ||
|
|
7f628f9a79 | ||
|
|
1fc7222b9b | ||
|
|
55b73283c6 | ||
|
|
c51cf697a0 | ||
|
|
5928fb1473 | ||
|
|
bcd506c169 | ||
|
|
3225e300cd | ||
|
|
397f119432 | ||
|
|
d18c9dcc5a | ||
|
|
8277b1dd36 | ||
|
|
21db20d103 | ||
|
|
c02f826928 | ||
|
|
8f29b60622 | ||
|
|
b1f7356095 | ||
|
|
cf2294d80c | ||
|
|
5d5fcfb376 | ||
|
|
916d25e4e0 | ||
|
|
0b1541096c | ||
|
|
0bf5972199 | ||
|
|
e8189ea565 | ||
|
|
f80492c3af | ||
|
|
b453e50e87 | ||
|
|
df08094bc6 | ||
|
|
0e629e0ac6 | ||
|
|
dd01a1ffe8 | ||
|
|
ef67c9e130 | ||
|
|
e5880c97a3 |
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
6
.gitattributes
vendored
@@ -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
8
.github/FUNDING.yml
vendored
@@ -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']
|
|
||||||
|
|||||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
22
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
22
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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
|
|
||||||
|
|||||||
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
BIN
.github/assets/support.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
144
.github/workflows/php.yml
vendored
144
.github/workflows/php.yml
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow
|
||||||
|
|
||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -5,46 +7,122 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
byte_level:
|
||||||
|
name: "0️⃣ Byte-level"
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: "actions/checkout@v3"
|
||||||
|
|
||||||
|
- name: "Check file permissions"
|
||||||
|
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:
|
||||||
|
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:
|
strategy:
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
matrix:
|
||||||
php: [ 8.0, 8.1 ]
|
php-version:
|
||||||
laravel: [ 9.* ]
|
- "8.0"
|
||||||
dependency-version: [ prefer-stable, prefer-lowest ]
|
- "8.1"
|
||||||
include:
|
- "8.2"
|
||||||
- laravel: 9.*
|
laravel-constraint:
|
||||||
testbench: 7.*
|
- "9.*"
|
||||||
|
- "10.*"
|
||||||
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }}
|
dependencies:
|
||||||
|
- "lowest"
|
||||||
|
- "highest"
|
||||||
|
exclude:
|
||||||
|
- php-version: "8.0"
|
||||||
|
laravel-constraint: "10.*"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: "Set up PHP"
|
||||||
uses: actions/checkout@v2
|
uses: "shivammathur/setup-php@v2"
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
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') }}
|
composer-options: "--with=laravel/framework:${{ matrix.laravel-constraint }}"
|
||||||
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@v3"
|
||||||
|
|
||||||
|
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"
|
||||||
|
coverage: "none"
|
||||||
|
|
||||||
|
- 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
|
|
||||||
|
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,7 +1,9 @@
|
|||||||
.idea
|
/build
|
||||||
build
|
/vendor
|
||||||
composer.lock
|
/.idea
|
||||||
docs
|
/.vscode
|
||||||
vendor
|
.php-cs-fixer.cache
|
||||||
coverage
|
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache
|
||||||
|
composer.lock
|
||||||
|
phpunit.xml.bak
|
||||||
|
|||||||
196
README.md
196
README.md
@@ -7,9 +7,10 @@
|
|||||||
[](https://sonarcloud.io/dashboard?id=Laragear_WebAuthn)
|
[](https://sonarcloud.io/dashboard?id=Laragear_WebAuthn)
|
||||||
[](https://laravel.com/docs/9.x/octane#introduction)
|
[](https://laravel.com/docs/9.x/octane#introduction)
|
||||||
|
|
||||||
Authenticate users with fingerprints, patterns and biometric data.
|
Authenticate users with Passkeys: fingerprints, patterns and biometric data.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
// App\Http\Controllers\LoginController.php
|
||||||
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
|
||||||
|
|
||||||
[](https://patreon.com/packagesforlaravel)[](https://ko-fi.com/DarkGhostHunter)[](https://www.buymeacoffee.com/darkghosthunter)[](https://www.paypal.com/paypalme/darkghosthunter)
|
[](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)**
|
||||||
|
|
||||||
@@ -41,15 +42,15 @@ Require this package into your project using Composer:
|
|||||||
composer require laragear/webauthn
|
composer require laragear/webauthn
|
||||||
```
|
```
|
||||||
|
|
||||||
## How does it work?
|
## How Passkeys work?
|
||||||
|
|
||||||
WebAuthn authentication process consists in two _ceremonies_: attestation, and assertion.
|
Passkeys, hence WebAuthn, consists in two _ceremonies_: attestation, and assertion.
|
||||||
|
|
||||||
Attestation is the process of asking the authenticator (a phone, laptop, USB key...) to create a private-public key pair, and **register** the public key inside the app. For that to work, the user must exist, and the browser must support WebAuthn, which is what intermediates between the authenticator and the app.
|
Attestation is the process of asking the authenticator (a phone, laptop, USB key...) to create a private-public key pair, save the private key internally, and **store** the public key inside the server. For that to work, the browser must support WebAuthn, which is what intermediates between the authenticator (OS & device hardware) and the server.
|
||||||
|
|
||||||
Assertion is the process of pushing a cryptographic challenge to the device, which will return _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 authenticator, which will return back to the server _signed_ by the private key of the device. Upon arrival, the server checks the signature is correct with the stored public key, ready to **log in**.
|
||||||
|
|
||||||
The private key doesn't leave the authenticator, and there are no shared passwords to save, let alone remember.
|
The private key doesn't leave the authenticator, there are no shared passwords stored anywhere, and Passkeys only work on the server domain (like google.com) or subdomain (like auth.google.com).
|
||||||
|
|
||||||
## Set up
|
## Set up
|
||||||
|
|
||||||
@@ -64,6 +65,10 @@ After that, you can quickly start WebAuthn with the included controllers and hel
|
|||||||
4. [Register the controllers](#4-register-the-routes-and-controllers)
|
4. [Register the controllers](#4-register-the-routes-and-controllers)
|
||||||
5. [Use the Javascript helper](#5-use-the-javascript-helper)
|
5. [Use the Javascript helper](#5-use-the-javascript-helper)
|
||||||
|
|
||||||
|
> **Info**
|
||||||
|
>
|
||||||
|
> While you can use Passkeys without users by invoking the _ceremonies_ manually, Laragear WebAuthn is intended to be used with already existing Users.
|
||||||
|
|
||||||
### 1. Add the `eloquent-webauthn` driver
|
### 1. Add the `eloquent-webauthn` driver
|
||||||
|
|
||||||
Laragear WebAuthn works by extending the Eloquent User Provider with an additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have.
|
Laragear WebAuthn works by extending the Eloquent User Provider with an additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have.
|
||||||
@@ -84,7 +89,7 @@ return [
|
|||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
The `password_fallback` indicates the User Provider should fall back to validate the password when the request is not a WebAuthn Assertion. It's enabled to seamlessly use both classic and WebAuthn authentication procedures.
|
The `password_fallback` indicates the User Provider should fall back to validate the password when the request is not a WebAuthn Assertion. It's enabled to seamlessly use both classic (password) and WebAuthn authentication procedures.
|
||||||
|
|
||||||
### 2. Create the `webauthn_credentials` table
|
### 2. Create the `webauthn_credentials` table
|
||||||
|
|
||||||
@@ -122,7 +127,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,12 +156,37 @@ This package includes a simple but convenient script to handle WebAuthn Attestat
|
|||||||
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="js"
|
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="js"
|
||||||
```
|
```
|
||||||
|
|
||||||
You will receive the `resources/js/vendor/webauthn/webauthn.js` file which you can include into your authentication views and use it programmatically, anyway you want. For example, compiling it [through 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
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="/js/app.js"></script>
|
<!doctype html>
|
||||||
|
<head>
|
||||||
|
{{-- ... --}}
|
||||||
|
|
||||||
<!-- Registering credentials -->
|
<script src="{{ Vite::asset('resources/js/vendor/webauthn/webauthn.js') }}"></script>
|
||||||
|
|
||||||
|
@vite(['resources/js/app.js'])
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**
|
||||||
|
>
|
||||||
|
> You can also edit the script file to transform it into a module so it can be bundled in your Vite frontend, exporting the class as `export default class WebAuthn { ... }`, and add it to the `@vite` assets.
|
||||||
|
>
|
||||||
|
> ```html
|
||||||
|
> @vite(['resources/js/vendor/webauthn/webauthn.js', 'resources/js/app.js'])
|
||||||
|
> ```
|
||||||
|
|
||||||
|
Once done, you can easily start registering an user device, and login in users that registered a device previusly.
|
||||||
|
|
||||||
|
For example, let's imagine an user logs in normally, and enters its profile view. You may show a WebAuthn registration HTML with the following code:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form id="register-form">
|
||||||
|
<button type="submit" value="Register authenticator">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Registering authenticator -->
|
||||||
<script>
|
<script>
|
||||||
const register = event => {
|
const register = event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -168,6 +198,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>
|
||||||
|
```
|
||||||
|
|
||||||
|
In our Login view, we can use the WebAuthn credentials to log in the user.
|
||||||
|
|
||||||
|
```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 +226,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, as long you adjust the script to the bundler needs. If the script doesn't suit your needs, you're free to modify it or create your own.
|
||||||
|
|
||||||
### Requests and Responses parameters
|
### Requests and Responses parameters
|
||||||
|
|
||||||
@@ -194,9 +234,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 +274,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 +289,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,13 +303,19 @@ 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)
|
||||||
{
|
{
|
||||||
$request->validate(['alias' => 'nullable|string']);
|
$request->validate(['alias' => 'nullable|string']);
|
||||||
|
|
||||||
$attestation->save($request->input('alias'));
|
$attestation->save($request->only('alias'));
|
||||||
|
|
||||||
|
// Same as:
|
||||||
|
// $attestation->save(function ($credentials) use ($request) {
|
||||||
|
// $credentials->alias = $request->input('alias');
|
||||||
|
// })
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -276,6 +328,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 +344,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 +362,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 +377,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 +396,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 +420,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 +436,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 +509,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 +529,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 +572,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 +584,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 +625,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 +635,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 +647,11 @@ 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, and the key-pair is bound to the top-most domain it was registered.
|
||||||
|
|
||||||
|
An user bing _phished_ at `staetbank.com` won't be able to login with a key made on the legit site `statebank.com`, as the device won't be able to find it.
|
||||||
|
|
||||||
* **Can WebAuthn data identify a particular device?**
|
* **Can WebAuthn data identify a particular device?**
|
||||||
|
|
||||||
@@ -585,63 +671,75 @@ 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?**
|
||||||
|
|
||||||
Disabling a credential doesn't delete it, so it's useful as a blacklisting mechanism and these can also be re-enabled. When the credential is deleted, it goes away forever.
|
Disabling a credential doesn't delete it, so it's useful as a blacklisting mechanism and these can also be re-enabled. When the credential is deleted, it goes away forever from the server, so the credential in the authenticator device becomes orphaned.
|
||||||
|
|
||||||
* **Can a user delete its credentials from its device?**
|
* **Can a user delete its credentials from its device?**
|
||||||
|
|
||||||
Yes. If it does, the other part of the credentials in your server gets virtually orphaned. You may want to show the user a list of registered credentials in the application to delete them.
|
Yes. If it does, the other part of the credentials in your server gets orphaned. You may want to show the user a list of registered credentials in the application to delete them.
|
||||||
|
|
||||||
* **How secure is this against passwords or 2FA?**
|
* **How secure is this against passwords or 2FA?**
|
||||||
|
|
||||||
Extremely secure since it works only on HTTPS (or `localhost`), no password or codes are exchanged nor visible in the screen.
|
Extremely secure since it works only on HTTPS (or `localhost`). Also, no password or codes are exchanged nor visible in the screen.
|
||||||
|
|
||||||
* **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?**
|
* **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_.
|
||||||
|
|
||||||
|
If you need more complex WebAuthn management, consider using the [`navigator.credentials`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/credentials) API directly.
|
||||||
|
|
||||||
* **Does WebAuthn eliminate bots? Can I forget about _captchas_?**
|
* **Does WebAuthn eliminate bots? Can I forget about _captchas_?**
|
||||||
|
|
||||||
No, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots.
|
Yes and no. To register users, you still need to use [captcha](https://github.com/Laragear/ReCaptcha), honeypots, or other mechanisms to stop bots.
|
||||||
|
|
||||||
* **Does this encodes/decode the WebAuthn data automatically in the frontend?**
|
Once a user is registered, bots won't be able to login because the real user is the only one that has the private key required for WebAuthn.
|
||||||
|
|
||||||
|
* **Does this encode/decode the WebAuthn data automatically in the frontend?**
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
My recommendation is to send an email to the user, pointing to a route that registers a new device, and immediately redirect him to blacklist which credential was lost (or blacklist the only one he has).
|
||||||
|
|
||||||
* **Can I use my smartphone as authenticator through my PC or Mac?**
|
* **Can I use my smartphone as authenticator through my PC or Mac?**
|
||||||
|
|
||||||
It depends. This is entirely up to hardware, OS and browser vendor themselves.
|
It depends.
|
||||||
|
|
||||||
|
This is entirely up to hardware, OS and browser vendor themselves, but mostly the OS. Some OS or browsers may offer a way to sync private keys on the cloud, even letting the assertion challenge to be signed remotely instead of transmitting the private key. Please check your target platforms of choice.
|
||||||
|
|
||||||
* **Why my device doesn't show Windows Hello/Passkey/TouchId/FaceId/pattern/fingerprint authentication?**
|
* **Why my device doesn't show Windows Hello/Passkey/TouchId/FaceId/pattern/fingerprint authentication?**
|
||||||
|
|
||||||
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?**
|
||||||
|
|
||||||
This package supports WebAuthn 2.0, which is [W3C Recommendation](https://www.w3.org/TR/webauthn-2). Your device/OS/browser may be using an unsupported version. There are no plans to support older specs.
|
This package supports WebAuthn 2.0, which is [W3C Recommendation](https://www.w3.org/TR/webauthn-2). Your device/OS/browser may be using an unsupported version.
|
||||||
|
|
||||||
|
There are no plans to support older WebAuthn specs. The new [WebAuthn 3.0 draft](https://www.w3.org/TR/webauthn-3) spec needs to be finished to be supported.
|
||||||
|
|
||||||
* **I'm trying to test this in my development server, but it doesn't work**
|
* **I'm trying to test this in my development server, but it doesn't work**
|
||||||
|
|
||||||
Use `localhost` exclusively, not `127.0.0.1`, or use a proxy to tunnel your site through HTTPS. WebAuthn only works on `localhost` or under `HTTPS` only.
|
Use `localhost` exclusively (not `127.0.0.1` or `::1`) or use a proxy to tunnel your site through HTTPS. WebAuthn only works on `localhost` or under `HTTPS` only.
|
||||||
|
|
||||||
* **Why this package supports only `none` attestation conveyance?**
|
* **Why this package supports only `none` attestation conveyance?**
|
||||||
|
|
||||||
Because `direct`, `indirect` and `enterprise` attestations are mostly used on high-security high-risk scenarios, where an entity has total control on the devices used to authenticate.
|
Because `direct`, `indirect` and `enterprise` attestations are mostly used on high-security high-risk scenarios, where an entity has total control on the devices used to authenticate. Imagine banks, medical, or military.
|
||||||
|
|
||||||
If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free).
|
If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free).
|
||||||
|
|
||||||
@@ -651,7 +749,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 stored there.
|
||||||
|
|
||||||
|
More information can be retrieved in your [application logs](https://laravel.com/docs/9.x/logging).
|
||||||
|
|
||||||
## Laravel Octane Compatibility
|
## Laravel Octane Compatibility
|
||||||
|
|
||||||
@@ -668,11 +768,11 @@ These are some details about this WebAuthn implementation:
|
|||||||
|
|
||||||
* Registration (attestation) and Login (assertion) challenges use the current request session.
|
* Registration (attestation) and Login (assertion) challenges use the current request session.
|
||||||
* Only one ceremony can be done at a time.
|
* Only one ceremony can be done at a time.
|
||||||
* Challenges are pulled from the session on resolution, independently of their result.
|
* Challenges are pulled (retrieved and deleted from source) from the session on resolution, independently of their result.
|
||||||
* All challenges and ceremonies expire at 60 seconds.
|
* All challenges and ceremonies expire at 60 seconds.
|
||||||
* WebAuthn User Handle is UUID v4, reusable if another credential exists.
|
* WebAuthn User Handle is UUID v4, reusable if another credential exists.
|
||||||
* Credentials can be blacklisted (enabled/disabled).
|
* Credentials can be blacklisted (enabled/disabled).
|
||||||
* Public Keys are encrypted in the database automatically.
|
* Public Keys are encrypted by with application key in the database automatically.
|
||||||
|
|
||||||
If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker.
|
If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laragear/webauthn",
|
"name": "laragear/webauthn",
|
||||||
"description": "Authenticate your users with biometric data, devices or USB keys.",
|
"description": "Authenticate users with Passkeys: fingerprints, patterns and biometric data.",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
@@ -24,29 +24,28 @@
|
|||||||
"name": "Italo Israel Baeza Cabrera",
|
"name": "Italo Israel Baeza Cabrera",
|
||||||
"email": "DarkGhostHunter@Gmail.com",
|
"email": "DarkGhostHunter@Gmail.com",
|
||||||
"role": "Developer",
|
"role": "Developer",
|
||||||
"homepage": "https://patreon.com/packagesforlaravel"
|
"homepage": "https://github.com/sponsors/DarkGhostHunter"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/Laragear/TwoFactor",
|
"source": "https://github.com/Laragear/WebAuthn",
|
||||||
"issues": "https://github.com/Laragear/TwoFactor/issues"
|
"issues": "https://github.com/Laragear/WebAuthn/issues"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php" : ">=8.0.2",
|
"php": "8.*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"illuminate/auth": "9.*",
|
"illuminate/auth": "9.*|10.*",
|
||||||
"illuminate/http": "9.*",
|
"illuminate/http": "9.*|10.*",
|
||||||
"illuminate/session": "9.*",
|
"illuminate/session": "9.*|10.*",
|
||||||
"illuminate/support": "9.*",
|
"illuminate/support": "9.*|10.*",
|
||||||
"illuminate/config": "9.*",
|
"illuminate/config": "9.*|10.*",
|
||||||
"illuminate/database": "9.*",
|
"illuminate/database": "9.*|10.*",
|
||||||
"illuminate/encryption": "9.*"
|
"illuminate/encryption": "9.*|10.*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"orchestra/testbench": "7.*",
|
"orchestra/testbench": "^7.22|8.*",
|
||||||
"phpunit/phpunit": "^9.5",
|
"jetbrains/phpstorm-attributes": "*"
|
||||||
"mockery/mockery": "^1.5"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -75,16 +74,8 @@
|
|||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "Patreon",
|
"type": "Github Sponsorship",
|
||||||
"url": "https://patreon.com/PackagesForLaravel"
|
"url": "https://github.com/sponsors/DarkGhostHunter"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Ko-Fi",
|
|
||||||
"url": "https://ko-fi.com/DarkGhostHunter"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Buy me a cofee",
|
|
||||||
"url": "https://www.buymeacoffee.com/darkghosthunter"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Paypal",
|
"type": "Paypal",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ return new class extends Migration {
|
|||||||
*/
|
*/
|
||||||
protected static function defaultBlueprint(Blueprint $table): void
|
protected static function defaultBlueprint(Blueprint $table): void
|
||||||
{
|
{
|
||||||
$table->string('id')->primary();
|
$table->string('id', 510)->primary();
|
||||||
|
|
||||||
$table->morphs('authenticatable', 'webauthn_user_index');
|
$table->morphs('authenticatable', 'webauthn_user_index');
|
||||||
|
|
||||||
|
|||||||
12
phpunit.xml
12
phpunit.xml
@@ -1,11 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false"
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" cacheDirectory=".phpunit.cache">
|
||||||
backupStaticAttributes="false" colors="true" verbose="true" convertErrorsToExceptions="true"
|
|
||||||
convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false"
|
|
||||||
stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd">
|
|
||||||
<coverage>
|
<coverage>
|
||||||
<include>
|
<include>
|
||||||
<directory suffix=".php">src/</directory>
|
<directory suffix=".php">src/</directory>
|
||||||
|
<directory suffix=".php">stubs/controllers</directory>
|
||||||
</include>
|
</include>
|
||||||
<report>
|
<report>
|
||||||
<clover outputFile="build/logs/clover.xml"/>
|
<clover outputFile="build/logs/clover.xml"/>
|
||||||
@@ -14,14 +12,10 @@
|
|||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Test Suite">
|
<testsuite name="Test Suite">
|
||||||
<directory>tests</directory>
|
<directory>tests</directory>
|
||||||
<file>stubs/controllers/WebAuthnLoginController.php</file>
|
|
||||||
<file>stubs/controllers/WebAuthnRegisterController.php</file>
|
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<logging>
|
|
||||||
<junit outputFile="build/report.junit.xml"/>
|
|
||||||
</logging>
|
|
||||||
<php>
|
<php>
|
||||||
|
<includePath>stubs/controllers</includePath>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_DEBUG" value="true"/>
|
<env name="APP_DEBUG" value="true"/>
|
||||||
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
|
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
let xsrfToken;
|
||||||
|
let csrfToken;
|
||||||
|
|
||||||
|
if (xcsrfToken === null) {
|
||||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||||
this.#headers["X-CSRF-TOKEN"] ??= xsrfToken ?? WebAuthn.#firstInputWithXsrfToken;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'])
|
});
|
||||||
->name('webauthn.login.options');
|
|
||||||
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])
|
Route::controller(WebAuthnLoginController::class)
|
||||||
->name('webauthn.login');
|
->group(static function (): void {
|
||||||
|
Route::post('webauthn/login/options', 'options')->name('webauthn.login.options');
|
||||||
|
Route::post('webauthn/login', 'login')->name('webauthn.login');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ namespace Laragear\WebAuthn;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
class WebAuthn
|
class WebAuthn
|
||||||
{
|
{
|
||||||
// Constants for user verification in Attestation and Assertion.
|
// Constants for user verification in Attestation and Assertion.
|
||||||
@@ -31,20 +28,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');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use JetBrains\PhpStorm\ArrayShape;
|
|||||||
|
|
||||||
class FakeAuthenticator
|
class FakeAuthenticator
|
||||||
{
|
{
|
||||||
public const CREDENTIAL_ID = '-VOLFKPY-_FuMI_sJ7gMllK76L3VoRUINj6lL_Z3qDg';
|
public const CREDENTIAL_ID = 'owBYu_waGLhAOCg4EFzi6Lr55x51G2dR5yhJi8q2C3tgZQQL2aEi-nK3I54J6ILj70pJzR_6QxvA5XER17d7NA9EFe2QH3VoJYQGpO8G5yDoFQvsdkxNhioyMyhyQHNrAgTMGyfigIMCfhjk9te7LNYl9K5GbWRc4TGeQl1vROjBtTNm3GdpEOqp9RijWd-ShQZ95eHoc8SA_-8vzCyfmy-wI_K4ZqlQNNl85Fzg2GIBcC2zvcJhLYy1A2kw6JoBTAmz1ZCCgkTKWhzUvAJQpMpu40M67FqE0WkGZfSJ9A';
|
||||||
public const CREDENTIAL_ID_RAW = '+VOLFKPY+/FuMI/sJ7gMllK76L3VoRUINj6lL/Z3qDg=';
|
public const CREDENTIAL_ID_RAW = 'owBYu/waGLhAOCg4EFzi6Lr55x51G2dR5yhJi8q2C3tgZQQL2aEi+nK3I54J6ILj70pJzR/6QxvA5XER17d7NA9EFe2QH3VoJYQGpO8G5yDoFQvsdkxNhioyMyhyQHNrAgTMGyfigIMCfhjk9te7LNYl9K5GbWRc4TGeQl1vROjBtTNm3GdpEOqp9RijWd+ShQZ95eHoc8SA/+8vzCyfmy+wI/K4ZqlQNNl85Fzg2GIBcC2zvcJhLYy1A2kw6JoBTAmz1ZCCgkTKWhzUvAJQpMpu40M67FqE0WkGZfSJ9A=';
|
||||||
|
|
||||||
public const ATTESTATION_USER = [
|
public const ATTESTATION_USER = [
|
||||||
'id' => 'e8af6f703f8042aa91c30cf72289aa07',
|
'id' => 'e8af6f703f8042aa91c30cf72289aa07',
|
||||||
|
|||||||
71
tests/ServiceProviderTest.php
Normal file
71
tests/ServiceProviderTest.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @define-env usesCustomTestTime
|
||||||
|
*/
|
||||||
|
public function test_publishes_migrations(): void
|
||||||
|
{
|
||||||
|
static::assertSame(
|
||||||
|
[
|
||||||
|
realpath(WebAuthnServiceProvider::MIGRATIONS . '/2022_07_01_000000_create_webauthn_credentials.php') =>
|
||||||
|
$this->app->databasePath("migrations/2020_01_01_163025_create_webauthn_credentials.php"),
|
||||||
|
],
|
||||||
|
ServiceProvider::pathsToPublish(WebAuthnServiceProvider::class, 'migrations')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function usesCustomTestTime()
|
||||||
|
{
|
||||||
|
$this->travelTo(Carbon::create(2020, 01, 01, 16, 30, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
tests/WebAuthnAuthenticationTest.php
Normal file
260
tests/WebAuthnAuthenticationTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user