First release
This commit is contained in:
BIN
.assets/buymeacoffee.png
Normal file
BIN
.assets/buymeacoffee.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
.assets/ko-fi.png
Normal file
BIN
.assets/ko-fi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
.assets/patreon.png
Normal file
BIN
.assets/patreon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
.assets/paypal.png
Normal file
BIN
.assets/paypal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
; This file is for unifying the coding style for different editors and IDEs.
|
||||
; More information at http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Path-based git attributes
|
||||
# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
|
||||
|
||||
# Ignore all test and documentation with "export-ignore".
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/tests export-ignore
|
||||
/.editorconfig export-ignore
|
||||
/.github export-ignore
|
||||
/.assets export-ignore
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Help me support this package
|
||||
|
||||
patreon: PackagesForLaravel
|
||||
ko_fi: DarkGhostHunter
|
||||
custom: ['https://www.buymeacoffee.com/darkghosthunter', 'https://paypal.me/darkghosthunter']
|
||||
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Bug Report
|
||||
description: File a bug report
|
||||
title: "[X.x] What does happen that is considered an error or bug?"
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- DarkGhostHunter
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
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.
|
||||
- type: input
|
||||
id: version-php-os
|
||||
attributes:
|
||||
label: PHP & Platform
|
||||
description: Exact PHP and Platform (OS) versions using this package.
|
||||
placeholder: 8.1.2 - Ubuntu 22.04 x64
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version-laravel
|
||||
attributes:
|
||||
label: Laravel version
|
||||
description: Exact Laravel version using this package.
|
||||
placeholder: 9.2.3
|
||||
validations:
|
||||
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
|
||||
id: requirements
|
||||
attributes:
|
||||
label: Have you done this?
|
||||
options:
|
||||
- label: I am willing to share my stack trace and logs
|
||||
required: true
|
||||
- label: I can reproduce this bug in isolation (vanilla Laravel install)
|
||||
required: true
|
||||
- label: I can suggest a workaround as a Pull Request
|
||||
required: false
|
||||
- type: textarea
|
||||
id: expectation
|
||||
attributes:
|
||||
label: Expectation
|
||||
description: Write what you expect to (correctly) happen.
|
||||
placeholder: When I do this, I expect to this to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Write what (incorrectly) happens instead.
|
||||
placeholder: Instead, when I do this, I receive that.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Paste the code to assert in a test, or just comment with the repository with the bug.
|
||||
render: php
|
||||
placeholder: |
|
||||
$test = Laragear::make()->break();
|
||||
|
||||
static::assertFalse($test);
|
||||
|
||||
// or comment with "https://github.com/my-name/my-bug-report"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Stack trace & logs
|
||||
description: If you have a stack trace, you can copy it here. You may hide sensible information.
|
||||
placeholder: This is automatically formatted into code, no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
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
|
||||
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature for this package
|
||||
title: "[X.x] Add this cool feature for this package"
|
||||
labels: ["enhancement"]
|
||||
assignees:
|
||||
- DarkGhostHunter
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing to this package!
|
||||
New features keep this package fresh and fun for everybody to use.
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: Please check these requirements
|
||||
options:
|
||||
- label: This feature helps everyone using this package
|
||||
required: true
|
||||
- label: It's feasible and maintainable
|
||||
required: true
|
||||
- label: It's non breaking
|
||||
required: true
|
||||
- label: I issued a PR with the implementation (optional)
|
||||
required: false
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe how the feature works
|
||||
placeholder: This new feature would accomplish this, and would be cool to integrate it to the package because...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: sample
|
||||
attributes:
|
||||
label: Code sample
|
||||
description: Sample a small snippet on how the feature works
|
||||
placeholder: |
|
||||
Laragear::newFeature()->cool();
|
||||
render: php
|
||||
validations:
|
||||
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
|
||||
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
|
||||
Thanks for contributing to this package! We only accept PR to the latest stable version.
|
||||
|
||||
If you're pushing a Feature:
|
||||
- Title it: "[X.x] This new feature"
|
||||
- Describe what the new feature enables
|
||||
- Show a small code snippet of the new feature
|
||||
- Ensure it doesn't break any feature.
|
||||
|
||||
If you're pushing a Fix:
|
||||
- Title it: "[X.x] FIX: The bug name"
|
||||
- Describe how it fixes in a few words.
|
||||
- Ensure it doesn't break any feature.
|
||||
|
||||
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.
|
||||
|
||||
If you're a Patreon supporter, this PR will have priority.
|
||||
Not a Patreon supporter? Become one at https://patreon.com/packagesforlaravel
|
||||
-->
|
||||
|
||||
# Description
|
||||
|
||||
This feature/fix allows to...
|
||||
|
||||
# Code samples
|
||||
|
||||
```php
|
||||
Laragear::sample();
|
||||
```
|
||||
|
||||
<!-- You may delete this section if it's a FIX -->
|
||||
8
.github/dependabot.yml
vendored
Normal file
8
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: composer
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 10
|
||||
50
.github/workflows/php.yml
vendored
Normal file
50
.github/workflows/php.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: [ 8.0, 8.1 ]
|
||||
laravel: [ 9.* ]
|
||||
dependency-version: [ prefer-stable, prefer-lowest ]
|
||||
include:
|
||||
- laravel: 9.*
|
||||
testbench: 7.*
|
||||
|
||||
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mbstring, intl
|
||||
coverage: xdebug
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
|
||||
restore-keys: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-progress --no-update
|
||||
composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress
|
||||
- name: Run Tests
|
||||
run: composer run-script test
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v2
|
||||
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.idea
|
||||
build
|
||||
composer.lock
|
||||
docs
|
||||
vendor
|
||||
coverage
|
||||
.phpunit.result.cache
|
||||
2
.styleci.yml
Normal file
2
.styleci.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
preset: laravel
|
||||
version: 8.1
|
||||
23
LICENSE.md
Normal file
23
LICENSE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Italo Israel Baeza Cabrera
|
||||
Copyright (c) 2021 Lukas Buchs (Attestation Object & Formats, Authenticator Data parts)
|
||||
Copyright (c) 2018 Thomas Bleeker (CBOR & ByteBuffer part)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
683
README.md
Normal file
683
README.md
Normal file
@@ -0,0 +1,683 @@
|
||||
# WebAuthn
|
||||
[](https://packagist.org/packages/laragear/webauthn) [](https://github.com/Laragear/WebAuthn/actions) [](https://codecov.io/gh/Laragear/WebAuthn) [](https://codeclimate.com/github/Laragear/WebAuthn/maintainability) [](https://sonarcloud.io/dashboard?id=Laragear_WebAuthn) [](https://laravel.com/docs/9.x/octane#introduction)
|
||||
|
||||
Authenticate users with fingerprints, patterns and biometric data.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
|
||||
public function login(AssertedRequest $request)
|
||||
{
|
||||
$user = $request->login();
|
||||
|
||||
return response()->json(['message' => "Welcome back, $user->name!"]);
|
||||
}
|
||||
```
|
||||
|
||||
## Keep this package free
|
||||
|
||||
[](https://patreon.com/packagesforlaravel)[](https://ko-fi.com/DarkGhostHunter)[](https://www.buymeacoffee.com/darkghosthunter)[](https://www.paypal.com/paypalme/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)**
|
||||
|
||||
## Requirements
|
||||
|
||||
* PHP 8.0 or later, with `ext-openssl`.
|
||||
* Laravel 9.x or later.
|
||||
|
||||
## Installation
|
||||
|
||||
Require this package into your project using Composer:
|
||||
|
||||
```bash
|
||||
composer require laragear/webauthn
|
||||
```
|
||||
|
||||
## How does it work?
|
||||
|
||||
WebAuthn authentication process consists in two _ceremonies_: attestation, and assertion.
|
||||
|
||||
Attestation is the process of registering in the app a new public key from the authenticated user device. For that to work, the user must exist, and the device or browser must support WebAuthn.
|
||||
|
||||
Assertion is the process of pushing a cryptographic challenge to the device, and checking the response is valid using the public key already registered inside the application.
|
||||
|
||||
The private key doesn't leave the device, and there are no shared passwords to remember.
|
||||
|
||||
## Set up
|
||||
|
||||
We need to make sure your users can register their devices and authenticate with them.
|
||||
|
||||
1. [Add the `eloquent-webauthn` driver](#1-add-the-eloquent-webauthn-driver)
|
||||
2. [Create the `webauthn_credentials` table](#2-create-the-webauthn_credentials-table)
|
||||
3. [Implement the contract and trait](#3-implement-the-contract-and-trait)
|
||||
|
||||
After that, you can quickly start WebAuthn with the included controllers and helpers to make your life easier.
|
||||
|
||||
4. [Register the controllers](#4-register-the-controllers)
|
||||
5. [Use the Javascript helper](#5-use-the-javascript-helper)
|
||||
|
||||
### 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.
|
||||
|
||||
Simply go into your `auth.php` configuration file, change the driver from `eloquent` to `eloquent-webauthn`, and add the `password_fallback` to `true`.
|
||||
|
||||
```php
|
||||
return [
|
||||
// ...
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent-webauthn',
|
||||
'model' => App\User::class,
|
||||
'password_fallback' => true,
|
||||
],
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### 2. Create the `webauthn_credentials` table
|
||||
|
||||
Create the `webauthn_credentials` table by publishing the migration file and migrating the table:
|
||||
|
||||
```shell
|
||||
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="migrations"
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
> You may edit the migration to your liking, like adding new columns, but **not** to remove them or change their name.
|
||||
|
||||
### 3. Implement the contract and trait
|
||||
|
||||
Add the `WebAuthnAuthenticatable` contract and the `WebAuthnAuthentication` trait to the User class, or any other that uses authentication.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\WebAuthnAuthentication;
|
||||
|
||||
class User extends Authenticatable implements WebAuthnAuthenticatable
|
||||
{
|
||||
use WebAuthnAuthentication;
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
From here you're ready to work with WebAuthn Authentication. The following steps will help you close the gap to a full implementation.
|
||||
|
||||
### 4. Register the controllers
|
||||
|
||||
WebAuthn uses exclusive controller actions to registering and authenticating users. If you want a quick start, just publish the controllers and routes included in Laragear WebAuthn.
|
||||
|
||||
```shell
|
||||
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="routes"
|
||||
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="controllers"
|
||||
```
|
||||
|
||||
The `webauthn.php` route file should be added to your `routes` directory. You can pick them up easily in your `RouteServiceProvider`.
|
||||
|
||||
```php
|
||||
public function boot()
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web.php'));
|
||||
|
||||
// WebAuthn Routes
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/webauthn.php'));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Along with the routes, the authentication controllers will be located at `App\Http\Controllers\WebAuthn`, which these routes point them toward automatically.
|
||||
|
||||
### 5. Use the Javascript helper
|
||||
|
||||
This package includes a simple but convenient script to handle WebAuthn Attestation and Assertion. To use it, just publish the `webauthn.js` asset into your application public resources.
|
||||
|
||||
```shell
|
||||
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.
|
||||
|
||||
```html
|
||||
<script src="/js/app.js"></script>
|
||||
|
||||
<!-- Registering credentials -->
|
||||
<script>
|
||||
const register = event => {
|
||||
event.preventDefault()
|
||||
|
||||
new WebAuthn().register()
|
||||
.then(response => alert('Registration successful!'))
|
||||
.catch(error => alert('Something went wrong, try again!'))
|
||||
}
|
||||
|
||||
document.getElementById('register-form').addEventListener('submit', register)
|
||||
</script>
|
||||
|
||||
<!-- Login users -->
|
||||
<script>
|
||||
const login = event => {
|
||||
event.preventDefault()
|
||||
|
||||
new WebAuthn().login({
|
||||
email: document.getElementById('email').value,
|
||||
}, {
|
||||
remember: document.getElementById('remember').checked ? 'on' : null,
|
||||
}).then(response => alert('Authentication successful!'))
|
||||
.catch(error => alert('Something went wrong, try again!'))
|
||||
}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', login)
|
||||
</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.
|
||||
|
||||
### Requests and Responses parameters
|
||||
|
||||
Both `register()` and `login()` accept different parameters for the initial request to the server, and the subsequent response to the server. For example, you can use this to _remember_ the user being authenticated.
|
||||
|
||||
```javascript
|
||||
new WebAuthn().login({
|
||||
email: document.getElementById('email').value, // Initial request to the server
|
||||
}, {
|
||||
remember: document.getElementById('remember').checked ? 'on' : null, // Response from the authenticator
|
||||
})
|
||||
```
|
||||
|
||||
### Custom routes
|
||||
|
||||
By default, the helper assumes you're using the [default WebAuthn routes](#4-register-the-controllers). If you're using different routes for WebAuthn, you can set them at runtime. Here is good place to use [ziggy](https://github.com/tighten/ziggy) if it's in your project.
|
||||
|
||||
```javascript
|
||||
const webAuthn = new WebAuthn({
|
||||
registerOptions: 'webauthn/register/options',
|
||||
register: 'webauthn/register',
|
||||
|
||||
loginOptions: 'webauthn/login/options',
|
||||
login: 'webauthn/login',
|
||||
});
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
You may add headers to all WebAuthn authentication requests using the second parameter of the `WebAuthn` constructor. These headers will be present on all requests made by the instance.
|
||||
|
||||
```javascript
|
||||
const webAuthn = new WebAuthn({}, {
|
||||
'X-Colors': 'red',
|
||||
});
|
||||
```
|
||||
|
||||
> You may use a different `WebAuthn` instances with different headers for both Attestation and Assertion.
|
||||
|
||||
## Attestation
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
|
||||
public function createChallenge(AttestationRequest $request)
|
||||
{
|
||||
return $request->toCreate();
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
public function register(AttestedRequest $attestation)
|
||||
{
|
||||
$attestation->save();
|
||||
|
||||
return 'Now you can login without passwords!';
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
public function register(AttestedRequest $request)
|
||||
{
|
||||
$request->validate(['alias' => 'nullable|string']);
|
||||
|
||||
$attestation->save($request->input('alias'));
|
||||
}
|
||||
```
|
||||
|
||||
### Attestation User verification
|
||||
|
||||
By default, the authenticator decides how to verify user when creating a credential. Some may ask to press a "Continue" button to confirm presence, others will verify the User with biometrics, patterns or passwords.
|
||||
|
||||
You can override this using `fastRegistration()` to only check for user presence if possible, or `secureRegistration()` to actively verify the User.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
|
||||
public function createChallenge(AttestationRequest $request)
|
||||
{
|
||||
return $request->fastRegistration()->toCreate();
|
||||
}
|
||||
```
|
||||
|
||||
### Userless/One-touch/Typeless Login
|
||||
|
||||
Userless/One-touch/Typeless login This enables one click/tap login, without the need to specify the user credentials (like the email) beforehand.
|
||||
|
||||
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
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
|
||||
public function registerDevice(AttestationRequest $request)
|
||||
{
|
||||
return $request->userless()->toCreate();
|
||||
}
|
||||
```
|
||||
|
||||
> The Authenticator WILL require [user verification](#attestation-user-verification) on login when using `userless()`. Its highly probable the user will also be asked for [user verification on login](#assertion-user-verification), as it will depend on the authenticator itself.
|
||||
|
||||
### Multiple credentials per device
|
||||
|
||||
By default, during Attestation, the device will be informed about the existing enabled credentials already registered in the application. This way the device can avoid creating another one for the same purpose.
|
||||
|
||||
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
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
|
||||
public function registerDevice(AttestationRequest $request)
|
||||
{
|
||||
return $request->allowDuplicates()->make();
|
||||
}
|
||||
```
|
||||
|
||||
## Assertion
|
||||
|
||||
The Assertion procedure also follows a two-step procedure: the user will input its username, the server will return the IDs of the WebAuthn credentials to use, and the device pick one to sign the response. If you're using [userless login](#userlessone-touchtypeless-login), only the challenge is returned.
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
public function createChallenge(AssertionRequest $request)
|
||||
{
|
||||
$request->validate(['email' => 'sometimes|email']);
|
||||
|
||||
return $request->toVerify($request->only('email'));
|
||||
}
|
||||
```
|
||||
|
||||
After that, you may receive the challenge using the `AssertedRequest` request object by just type-hinting it in the controller.
|
||||
|
||||
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
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
|
||||
public function createChallenge(AssertedRequest $request)
|
||||
{
|
||||
$user = $request->login();
|
||||
|
||||
return $user
|
||||
? response("Welcome back, $user->name!");
|
||||
: response('Something went wrong, try again!');
|
||||
}
|
||||
```
|
||||
|
||||
If you need greater control on the Assertion procedure, you may want to [Assert manually](#manually-attesting-and-asserting).
|
||||
|
||||
> If you have [debugging enabled](https://laravel.com/docs/9.x/configuration#debug-mode) the assertion error during authentication will be logged in your application logs, which by default is `storage/logs/laravel.log`.
|
||||
|
||||
### Assertion User Verification
|
||||
|
||||
In the same style of [attestation user verification](#attestation-user-verification), the authenticator decides if it should verify the user on login or not.
|
||||
|
||||
You may only require the user presence with `fastLogin()`, or actively verify the user with `secureLogin()`.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
public function createChallenge(AssertionRequest $request)
|
||||
{
|
||||
$request->validate(['email' => 'sometimes|email']);
|
||||
|
||||
return $request->fastLogin()->toVerify($request->only('email'));
|
||||
}
|
||||
```
|
||||
|
||||
### Password Fallback
|
||||
|
||||
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
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate(['email' => 'required|email', 'password' => 'required|string']);
|
||||
|
||||
if (Auth::attempt($request->only('email', 'password'))) {
|
||||
return redirect()->home();
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => 'No user found with these credentials']);
|
||||
}
|
||||
```
|
||||
|
||||
You may disable the fallback to only allow WebAuthn authentication by [setting `password_fallback` to `false`](#1-add-the-eloquent-webauthn-driver). This may force you to handle classic user/password using a separate guard.
|
||||
|
||||
### Detecting Cloned Credentials
|
||||
|
||||
During assertion, the package will automatically detect if a Credential as been cloned by comparing how many times the user has logged in with it.
|
||||
|
||||
If it's detected as cloned, the Credential gets blacklisted, a [`CredentialCloned`](#events) event is fired, and the Assertion gets denied.
|
||||
|
||||
You can use the event to warn the user:
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laragear\WebAuthn\Events\CredentialCloned;
|
||||
use App\Notifications\SecureYourDevice;
|
||||
|
||||
Event::listen(CredentialCloned::class, function ($cloned) {
|
||||
$notification = new SecureYourDevice($cloned->credential);
|
||||
|
||||
$cloned->credential->user->notify($notification);
|
||||
});
|
||||
```
|
||||
|
||||
## Managing Credentials
|
||||
|
||||
The purpose of the `WebAuthnAuthenticatable` contract is to allow managing credentials within the User instance. The most useful methods are:
|
||||
|
||||
* `webAuthnData()`: Returns the non-variable WebAuthn user data to create credentials.
|
||||
* `flushCredentials()`: Removes all credentials. You can exclude credentials by their id.
|
||||
* `disableAllCredentials()`: Disables all credentials. You can exclude credentials by their id.
|
||||
* `makeWebAuthnCredential()`: Creates a new WebAuthn Credential instance.
|
||||
* `webAuthnCredentials()`: [One-to-Many](https://laravel.com/docs/9.x/eloquent-relationships#one-to-many-polymorphic-relations) relation to query for WebAuthn Credentials.
|
||||
|
||||
You can use these methods to, for example, find a credential to blacklist, or disable WebAuthn completely by flushing all registered devices.
|
||||
|
||||
## Events
|
||||
|
||||
The following events are fired by this package, which you can [hook into in your application](https://laravel.com/docs/9.x/events):
|
||||
|
||||
| Event | Description |
|
||||
|----------------------|-----------------------------------------------------------------------|
|
||||
| `CredentialCreated` | An User has registered a new WebAuthn Credential through Attestation. |
|
||||
| `CredentialEnabled` | A disabled WebAuthn Credential was enabled using `enable()`. |
|
||||
| `CredentialDisabled` | A enabled WebAuthn Credential was disabled using `disable()`. |
|
||||
| `CredentialCloned` | A WebAuthn Credential was detected as cloned dring Assertion. |
|
||||
|
||||
## Manually Attesting and Asserting
|
||||
|
||||
If you want to manually Attest and Assert users, you may instance their respective pipelines used for both WebAuthn Ceremonies:
|
||||
|
||||
| Pipeline | Description |
|
||||
|------------------------|------------------------------------------------------------------|
|
||||
| `AttestationCreator` | Creates a request to create a WebAuthn Credential. |
|
||||
| `AttestationValidator` | Validates a response with the WebAuthn Credential and stores it. |
|
||||
| `AssertionCreator` | Creates a request to validate a WebAuthn Credential. |
|
||||
| `AssertionValidator` | Validates a response for a WebAuthn Credential. |
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
public function authenticate(Request $request, AssertionValidator $assertion)
|
||||
{
|
||||
$credential = $assertion
|
||||
->send(new AssertionValidation($request))
|
||||
->thenReturn()
|
||||
->credential;
|
||||
|
||||
Auth::login($credential->user);
|
||||
|
||||
return "Welcome aboard, {$credential->user->name}!";
|
||||
}
|
||||
```
|
||||
|
||||
Since these are Laravel Pipelines, you're free to push additional pipes:
|
||||
|
||||
```php
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
|
||||
|
||||
public function addPipes(AssertionValidator $attestation)
|
||||
{
|
||||
$attestation->pipe(VerifyUserIsAwesome::class, NotifyIfAssertionFailed::class);
|
||||
}
|
||||
```
|
||||
|
||||
> The pipes list and the pipes themselves are **not** covered by API changes, and are marked as `internal`. These may change between versions without notice.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
Laragear WebAuthn was made to work out-of-the-box, but you can override the configuration by simply publishing the config file.
|
||||
|
||||
```shell
|
||||
php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="config"
|
||||
```
|
||||
|
||||
After that, you will receive the `config/webauthn.php` config file with an array like this:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
'relaying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
### Relaying Party Information
|
||||
|
||||
```php
|
||||
return [
|
||||
'relaying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
The _Relaying 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.
|
||||
* `id`: An unique ID the application, like the site domain. If `null`, the device may fill it internally, usually as the full domain.
|
||||
|
||||
> WebAuthn authentication only work on the top domain it was registered.
|
||||
|
||||
### Challenge configuration
|
||||
|
||||
```php
|
||||
return [
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
The outgoing challenges are random string of bytes. This controls how many bytes, the seconds which the challenge is valid, and the session key used to store the challenge while its being resolved by the device.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
If you think WebAuthn is critical for these packages, [consider supporting this package](#keep-this-package-free).
|
||||
|
||||
## FAQ
|
||||
|
||||
* **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:
|
||||
|
||||
```javascript
|
||||
if (WebAuthn.doesntSupportWebAuthn()) {
|
||||
alert('Your device is not secure enough to use this site!');
|
||||
}
|
||||
```
|
||||
|
||||
* **Does this store the user fingerprints, PINs or patterns in my site?**
|
||||
|
||||
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?**
|
||||
|
||||
No. WebAuthn _kills the phishing_ because, unlike passwords, the private key never leaves the device.
|
||||
|
||||
* **Can WebAuthn data identify a particular device?**
|
||||
|
||||
No, unless explicitly [requested](https://www.w3.org/TR/webauthn-2/#attestation-conveyance) and consented. This package doesn't support other attestation conveyances than `none`, so it's never transmitted.
|
||||
|
||||
* **Are my user's classic passwords safe?**
|
||||
|
||||
Yes, as long you are hashing them as you should. This is done by Laravel by default. You can also [disable them](#password-fallback).
|
||||
|
||||
* **Can a user register two or more different _devices_ for the same account?**
|
||||
|
||||
Yes.
|
||||
|
||||
* **Can a user register two or more credentials in the same device?**
|
||||
|
||||
Not by default, but [you can enable it](#multiple-credentials-per-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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?**
|
||||
|
||||
[Yes](#password-fallback). Just be sure to create recovery helpers to avoid locking out your users.
|
||||
|
||||
* **Does this includes a frontend Javascript?**
|
||||
|
||||
[Yes](#5-use-the-javascript-helper), but it's very _basic_.
|
||||
|
||||
* **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.
|
||||
|
||||
* **Does this encodes/decode the WebAuthn data automatically in the frontend?**
|
||||
|
||||
Yes, the included [WebAuthn Helper](#5-use-the-javascript-helper) does it automatically for you.
|
||||
|
||||
* **Does this encrypts the public keys?**
|
||||
|
||||
Yes, public keys are encrypted when saved into the database.
|
||||
|
||||
* **Does this include a credential recovery routes?**
|
||||
|
||||
No. You're free to create your own flow for recovery.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
* **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.
|
||||
|
||||
If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free).
|
||||
|
||||
* **Can I allow logins with only USB keys?**
|
||||
|
||||
No. The user can use whatever to authenticate in your app. This may be enabled on future versions.
|
||||
|
||||
* **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.
|
||||
|
||||
## Laravel Octane Compatibility
|
||||
|
||||
* There are no singletons using a stale application instance.
|
||||
* There are no singletons using a stale config instance.
|
||||
* There are no singletons using a stale request instance.
|
||||
* There are no static properties written during a request.
|
||||
|
||||
There should be no problems using this package with Laravel Octane.
|
||||
|
||||
## Security
|
||||
|
||||
These are some details about this WebAuthn implementation:
|
||||
|
||||
* Registration (attestation) and Login (assertion) challenges use the current request session.
|
||||
* Only one ceremony can be done at a time.
|
||||
* Challenges are pulled from the session on resolution, independently of their result.
|
||||
* All challenges and ceremonies expire at 60 seconds.
|
||||
* WebAuthn User Handle is UUID v4, reusable if another credential exists.
|
||||
* Credentials can be blacklisted (enabled/disabled).
|
||||
* Public Keys are encrypted in the database automatically.
|
||||
|
||||
If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker.
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
|
||||
|
||||
Contains Code from [Lukas Buchs WebAuthn 2.0](https://github.com/lbuchs/WebAuthn) implementation. The MIT License (MIT) where applicable.
|
||||
|
||||
Laravel is a Trademark of Taylor Otwell. Copyright © 2011-2022 Laravel LLC.
|
||||
94
composer.json
Normal file
94
composer.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "laragear/webauthn",
|
||||
"description": "Authenticate your users with biometric data, devices or USB keys.",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"webauthn",
|
||||
"authentication",
|
||||
"faceid",
|
||||
"touchid",
|
||||
"windows hello",
|
||||
"passkeys"
|
||||
],
|
||||
"homepage": "https://github.com/laragear/webauthn",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lukas Buchs",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Italo Israel Baeza Cabrera",
|
||||
"email": "DarkGhostHunter@Gmail.com",
|
||||
"role": "Developer",
|
||||
"homepage": "https://patreon.com/packagesforlaravel"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/Laragear/TwoFactor",
|
||||
"issues": "https://github.com/Laragear/TwoFactor/issues"
|
||||
},
|
||||
"require": {
|
||||
"php" : ">=8.0.2",
|
||||
"ext-openssl": "*",
|
||||
"ext-json" : "*",
|
||||
"illuminate/auth": "9.*",
|
||||
"illuminate/http": "9.*",
|
||||
"illuminate/session": "9.*",
|
||||
"illuminate/support": "9.*",
|
||||
"illuminate/config": "9.*",
|
||||
"illuminate/database": "9.*",
|
||||
"illuminate/encryption": "9.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "7.*",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"mockery/mockery": "^1.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laragear\\WebAuthn\\": "src"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests",
|
||||
"App\\Http\\Controllers\\WebAuthn\\": "stubs/controllers"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit --coverage-clover build/logs/clover.xml",
|
||||
"test-coverage": "vendor/bin/phpunit --coverage-html coverage"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laragear\\WebAuthn\\WebAuthnServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"type": "Patreon",
|
||||
"url": "https://patreon.com/PackagesForLaravel"
|
||||
},
|
||||
{
|
||||
"type": "Ko-Fi",
|
||||
"url": "https://ko-fi.com/DarkGhostHunter"
|
||||
},
|
||||
{
|
||||
"type": "Buy me a cofee",
|
||||
"url": "https://www.buymeacoffee.com/darkghosthunter"
|
||||
},
|
||||
{
|
||||
"type": "Paypal",
|
||||
"url": "https://paypal.me/darkghosthunter"
|
||||
}
|
||||
]
|
||||
}
|
||||
37
config/webauthn.php
Normal file
37
config/webauthn.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relaying Party
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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
|
||||
| a custom domain as ID and even an icon image data encoded as BASE64.
|
||||
|
|
||||
*/
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', config('app.name')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Challenge configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When making challenges your application needs to push at least 16 bytes
|
||||
| of randomness. Since we need to later check them, we'll also store the
|
||||
| bytes for a small amount of time inside this current request session.
|
||||
|
|
||||
*/
|
||||
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* @see \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
return new class extends Migration {
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('webauthn_credentials', static function (Blueprint $table): void {
|
||||
static::defaultBlueprint($table);
|
||||
|
||||
// You may add here your own columns...
|
||||
//
|
||||
// $table->timestamp('last_login_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('webauthn_credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the default blueprint for the WebAuthn credentials table.
|
||||
*
|
||||
* @param \Illuminate\Database\Schema\Blueprint $table
|
||||
* @return void
|
||||
*/
|
||||
protected static function defaultBlueprint(Blueprint $table): void
|
||||
{
|
||||
$table->string('id')->primary();
|
||||
|
||||
$table->morphs('authenticatable');
|
||||
|
||||
// This is the user UUID that is generated automatically when a credential for the
|
||||
// given user is created. If a second credential is created, this UUID is queried
|
||||
// and then copied on top of the new one, this way the real User ID doesn't change.
|
||||
$table->uuid('user_id');
|
||||
|
||||
// The app may allow the user to name or rename a credential to a friendly name,
|
||||
// like "John's iPhone" or "Office Computer".
|
||||
$table->string('alias')->nullable();
|
||||
|
||||
// Allows to detect cloned credentials when the assertion does not have this same counter.
|
||||
$table->unsignedBigInteger('counter')->nullable();
|
||||
// Who created the credential. Should be the same reported by the Authenticator.
|
||||
$table->string('rp_id');
|
||||
// Where the credential was created. Should be the same reported by the Authenticator.
|
||||
$table->string('origin');
|
||||
$table->json('transports')->nullable();
|
||||
$table->uuid('aaguid')->nullable(); // GUID are essentially UUID
|
||||
|
||||
// This is the public key the credential uses to verify the challenges.
|
||||
$table->text('public_key');
|
||||
// The attestation of the public key.
|
||||
$table->string('attestation_format')->default('none');
|
||||
// This would hold the certificate chain for other different attestation formats.
|
||||
$table->json('certificates')->nullable();
|
||||
|
||||
// A way to disable the credential without deleting it.
|
||||
$table->timestamp('disabled_at')->nullable();
|
||||
$table->timestamps();
|
||||
}
|
||||
};
|
||||
30
phpunit.xml
Normal file
30
phpunit.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false"
|
||||
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>
|
||||
<include>
|
||||
<directory suffix=".php">src/</directory>
|
||||
</include>
|
||||
<report>
|
||||
<clover outputFile="build/logs/clover.xml"/>
|
||||
</report>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Test Suite">
|
||||
<directory>tests</directory>
|
||||
<file>stubs/controllers/WebAuthnLoginController.php</file>
|
||||
<file>stubs/controllers/WebAuthnRegisterController.php</file>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<logging>
|
||||
<junit outputFile="build/report.junit.xml"/>
|
||||
</logging>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_DEBUG" value="true"/>
|
||||
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
|
||||
<env name="DB_CONNECTION" value="testing"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
313
resources/js/webauthn.js
Normal file
313
resources/js/webauthn.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xsrfToken {string|null}
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xsrfToken = null) {
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
this.#headers["X-CSRF-TOKEN"] ??= xsrfToken ?? WebAuthn.#firstInputWithXsrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the XSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithXsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF token is manually set, or there is meta tag named "csrf-token".');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
return fetch(route, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: {...this.#headers, ...headers},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return {...data, id: WebAuthn.#uint8Array(data.id)};
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
response: {}
|
||||
};
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
}
|
||||
17
routes/webauthn.php
Normal file
17
routes/webauthn.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnLoginController;
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnRegisterController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('web')->group(static function (): void {
|
||||
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])
|
||||
->name('webauthn.register.options');
|
||||
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])
|
||||
->name('webauthn.register');
|
||||
|
||||
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])
|
||||
->name('webauthn.login.options');
|
||||
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])
|
||||
->name('webauthn.login');
|
||||
});
|
||||
36
src/Assertion/Creator/AssertionCreation.php
Normal file
36
src/Assertion/Creator/AssertionCreation.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\JsonTransport;
|
||||
|
||||
class AssertionCreation
|
||||
{
|
||||
/**
|
||||
* The Json Transport helper to build the message.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\JsonTransport
|
||||
*/
|
||||
public JsonTransport $json;
|
||||
|
||||
/**
|
||||
* Create a new Assertion Creation instance.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @param \Illuminate\Database\Eloquent\Collection|null $acceptedCredentials
|
||||
* @param string|null $userVerification
|
||||
*/
|
||||
public function __construct(
|
||||
public Request $request,
|
||||
public ?WebAuthnAuthenticatable $user = null,
|
||||
public ?Collection $acceptedCredentials = null,
|
||||
public ?string $userVerification = null,
|
||||
)
|
||||
{
|
||||
$this->json = new JsonTransport();
|
||||
}
|
||||
}
|
||||
23
src/Assertion/Creator/AssertionCreator.php
Normal file
23
src/Assertion/Creator/AssertionCreator.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
|
||||
*/
|
||||
class AssertionCreator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\AddConfiguration::class,
|
||||
Pipes\MayRetrieveCredentialsIdForUser::class,
|
||||
Pipes\MayRequireUserVerification::class,
|
||||
Pipes\CreateAssertionChallenge::class,
|
||||
];
|
||||
}
|
||||
34
src/Assertion/Creator/Pipes/AddConfiguration.php
Normal file
34
src/Assertion/Creator/Pipes/AddConfiguration.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
|
||||
class AddConfiguration
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
$assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
45
src/Assertion/Creator/Pipes/CreateAssertionChallenge.php
Normal file
45
src/Assertion/Creator/Pipes/CreateAssertionChallenge.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Attestation\SessionChallenge;
|
||||
|
||||
class CreateAssertionChallenge
|
||||
{
|
||||
use SessionChallenge;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
$options = [];
|
||||
|
||||
if ($assertion->acceptedCredentials?->isNotEmpty()) {
|
||||
$options['credentials'] = $assertion->acceptedCredentials->map->getKey()->toArray();
|
||||
}
|
||||
|
||||
$challenge = $this->storeChallenge($assertion->request, $assertion->userVerification, $options);
|
||||
|
||||
$assertion->json->set('challenge', $challenge->data);
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
25
src/Assertion/Creator/Pipes/MayRequireUserVerification.php
Normal file
25
src/Assertion/Creator/Pipes/MayRequireUserVerification.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
|
||||
class MayRequireUserVerification
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
if ($assertion->userVerification) {
|
||||
$assertion->json->set('userVerification', $assertion->userVerification);
|
||||
}
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function array_filter;
|
||||
|
||||
class MayRetrieveCredentialsIdForUser
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionCreation $assertion, Closure $next): mixed
|
||||
{
|
||||
// If there is a user found, we will pluck the IDS and add them as a binary buffer.
|
||||
if ($assertion->user) {
|
||||
$assertion->acceptedCredentials = $assertion->user->webAuthnCredentials()->get(['id', 'transports']);
|
||||
|
||||
if ($assertion->acceptedCredentials->isNotEmpty()) {
|
||||
$assertion->json->set('allowCredentials', $this->parseCredentials($assertion->acceptedCredentials));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($assertion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt all credentials into an `allowCredentials` digestible array.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $credentials
|
||||
* @return \Illuminate\Support\Collection<int, array>
|
||||
*/
|
||||
protected function parseCredentials(EloquentCollection $credentials): Collection
|
||||
{
|
||||
return $credentials->map(static function (WebAuthnCredential $credential): array {
|
||||
return array_filter([
|
||||
'id' => $credential->getKey(),
|
||||
'type' => 'public-key',
|
||||
'transports' => $credential->transports
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/Assertion/Validator/AssertionValidation.php
Normal file
35
src/Assertion/Validator/AssertionValidation.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class AssertionValidation
|
||||
{
|
||||
/**
|
||||
* Create a new Assertion Validation.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @param \Laragear\WebAuthn\Challenge|null $challenge
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential
|
||||
* @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData|null $authenticatorData
|
||||
*/
|
||||
public function __construct(
|
||||
public Request $request,
|
||||
public ?WebAuthnAuthenticatable $user = null,
|
||||
public ?Challenge $challenge = null,
|
||||
public ?WebAuthnCredential $credential = null,
|
||||
public ?ClientDataJson $clientDataJson = null,
|
||||
public ?AuthenticatorData $authenticatorData = null,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
34
src/Assertion/Validator/AssertionValidator.php
Normal file
34
src/Assertion/Validator/AssertionValidator.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Assertion\Validator\AssertionValidation thenReturn()
|
||||
*/
|
||||
class AssertionValidator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\RetrieveChallenge::class,
|
||||
Pipes\RetrievesCredentialId::class,
|
||||
Pipes\CheckCredentialIsForUser::class,
|
||||
Pipes\CheckTypeIsPublicKey::class,
|
||||
Pipes\CompileAuthenticatorData::class,
|
||||
Pipes\CompileClientDataJson::class,
|
||||
Pipes\CheckCredentialIsWebAuthnGet::class,
|
||||
Pipes\CheckChallengeSame::class,
|
||||
Pipes\CheckOriginSecure::class,
|
||||
Pipes\CheckRelyingPartyIdContained::class,
|
||||
Pipes\CheckRelyingPartyHashSame::class,
|
||||
Pipes\CheckUserInteraction::class,
|
||||
Pipes\CheckPublicKeySignature::class,
|
||||
Pipes\CheckPublicKeyCounterCorrect::class,
|
||||
Pipes\IncrementCredentialCounter::class,
|
||||
];
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CheckChallengeSame.php
Normal file
13
src/Assertion/Validator/Pipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckChallengeSame as BaseChallengeSame;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckChallengeSame extends BaseChallengeSame
|
||||
{
|
||||
//
|
||||
}
|
||||
79
src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php
Normal file
79
src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use function hash_equals;
|
||||
|
||||
/**
|
||||
* 6. Identify the user being authenticated and verify that this user is the owner of the public
|
||||
* key credential source credentialSource identified by credential.id:
|
||||
*
|
||||
* - If the user was identified before the authentication ceremony was initiated, e.g., via a
|
||||
* username or cookie, verify that the identified user is the owner of credentialSource. If
|
||||
* response.userHandle is present, let userHandle be its value. Verify that userHandle also
|
||||
* maps to the same user.
|
||||
*
|
||||
* - If the user was not identified before the authentication ceremony was initiated, verify
|
||||
* that response.userHandle is present, and that the user identified by this value is the
|
||||
* owner of credentialSource.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckCredentialIsForUser
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->user) {
|
||||
$this->validateUser($validation);
|
||||
|
||||
if ($validation->request->json('response.userHandle')) {
|
||||
$this->validateId($validation);
|
||||
}
|
||||
} else {
|
||||
$this->validateId($validation);
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user owns the Credential if it already exists in the validation procedure.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return void
|
||||
*/
|
||||
protected function validateUser(AssertionValidation $validation): void
|
||||
{
|
||||
if ($validation->credential->authenticatable()->isNot($validation->user)) {
|
||||
throw AssertionException::make('User is not owner of the stored credential.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user ID of the response.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return void
|
||||
*/
|
||||
protected function validateId(AssertionValidation $validation): void
|
||||
{
|
||||
$handle = $validation->request->json('response.userHandle');
|
||||
|
||||
if (! $handle || ! hash_equals(Uuid::fromString($validation->credential->user_id)->getHex()->toString(), $handle)) {
|
||||
throw AssertionException::make('User ID is not owner of the stored credential.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckCredentialIsWebAuthnGet
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->type !== 'webauthn.get') {
|
||||
throw AssertionException::make('Client Data type is not [webauthn.get].');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
15
src/Assertion/Validator/Pipes/CheckOriginSecure.php
Normal file
15
src/Assertion/Validator/Pipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
|
||||
|
||||
/**
|
||||
* 9. Verify that the value of C.origin matches the Relying Party's origin.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckOriginSecure extends BaseCheckOriginSame
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Events\CredentialCloned;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
|
||||
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
|
||||
*
|
||||
* - If authData.signCount
|
||||
* -> is greater than storedSignCount:
|
||||
* Update storedSignCount to be the value of authData.signCount.
|
||||
* -> less than or equal to storedSignCount:
|
||||
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
|
||||
* credential private key may exist and are being used in parallel. Relying Parties
|
||||
* should incorporate this information into their risk scoring. Whether the Relying
|
||||
* Party updates storedSignCount in this case, or not, or fails the authentication
|
||||
* ceremony or not, is Relying Party-specific.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckPublicKeyCounterCorrect
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($this->hasCounter($validation) && $this->counterBelowStoredCredential($validation)) {
|
||||
$validation->credential->disable();
|
||||
|
||||
CredentialCloned::dispatch($validation->credential, $validation->authenticatorData->counter);
|
||||
|
||||
throw AssertionException::make('Credential counter not over stored counter.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the incoming credential or the stored credential have a counter.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasCounter(AssertionValidation $validation): bool
|
||||
{
|
||||
return $validation->credential->counter
|
||||
|| $validation->authenticatorData->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential counter is equal or higher than what the authenticator reports.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function counterBelowStoredCredential(AssertionValidation $validation): bool
|
||||
{
|
||||
return $validation->authenticatorData->counter <= $validation->credential->counter;
|
||||
}
|
||||
}
|
||||
68
src/Assertion/Validator/Pipes/CheckPublicKeySignature.php
Normal file
68
src/Assertion/Validator/Pipes/CheckPublicKeySignature.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use OpenSSLAsymmetricKey;
|
||||
use function base64_decode;
|
||||
use function hash;
|
||||
use function openssl_pkey_get_public;
|
||||
use function openssl_verify;
|
||||
use const OPENSSL_ALGO_SHA256;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckPublicKeySignature
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$publicKey = openssl_pkey_get_public($validation->credential->public_key);
|
||||
|
||||
if (!$publicKey) {
|
||||
throw AssertionException::make('Stored Public Key is invalid.');
|
||||
}
|
||||
|
||||
$signature = base64_decode($validation->request->json('response.signature', ''));
|
||||
|
||||
if (!$signature) {
|
||||
throw AssertionException::make('Signature is empty.');
|
||||
}
|
||||
|
||||
$this->validateSignature($validation, $publicKey, $signature);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the signature from the assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param string $signature
|
||||
* @param \OpenSSLAsymmetricKey $publicKey
|
||||
* @return void
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function validateSignature(
|
||||
AssertionValidation $validation,
|
||||
OpenSSLAsymmetricKey $publicKey,
|
||||
string $signature
|
||||
): void {
|
||||
$verifiable = base64_decode($validation->request->json('response.authenticatorData'))
|
||||
.hash('sha256', base64_decode($validation->request->json('response.clientDataJSON')), true);
|
||||
|
||||
if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
|
||||
throw AssertionException::make('Signature is invalid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php
Normal file
36
src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyHashSame as BaseCheckRelyingPartyHashSame;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
|
||||
{
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData
|
||||
{
|
||||
return $validation->authenticatorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
|
||||
{
|
||||
return $validation->credential->rp_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyIdContained as BaseCheckRelyingPartyIdContained;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyIdContained extends BaseCheckRelyingPartyIdContained
|
||||
{
|
||||
//
|
||||
}
|
||||
30
src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php
Normal file
30
src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckTypeIsPublicKey
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->request->json('type') !== 'public-key') {
|
||||
throw AssertionException::make('Response type is not [public-key].');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CheckUserInteraction.php
Normal file
13
src/Assertion/Validator/Pipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckUserInteraction as BaseCheckUserInteraction;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CheckUserInteraction extends BaseCheckUserInteraction
|
||||
{
|
||||
//
|
||||
}
|
||||
41
src/Assertion/Validator/Pipes/CompileAuthenticatorData.php
Normal file
41
src/Assertion/Validator/Pipes/CompileAuthenticatorData.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_decode;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CompileAuthenticatorData
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$data = base64_decode($validation->request->json('response.authenticatorData', ''));
|
||||
|
||||
if (!$data) {
|
||||
throw AssertionException::make('Authenticator Data does not exist or is empty.');
|
||||
}
|
||||
|
||||
try {
|
||||
$validation->authenticatorData = AuthenticatorData::fromBinary($data);
|
||||
} catch (DataException $e) {
|
||||
throw AssertionException::make($e->getMessage());
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/CompileClientDataJson.php
Normal file
13
src/Assertion/Validator/Pipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CompileClientDataJson as BaseCompileClientDataJson;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CompileClientDataJson extends BaseCompileClientDataJson
|
||||
{
|
||||
//
|
||||
}
|
||||
39
src/Assertion/Validator/Pipes/IncrementCredentialCounter.php
Normal file
39
src/Assertion/Validator/Pipes/IncrementCredentialCounter.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
|
||||
/**
|
||||
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
|
||||
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
|
||||
*
|
||||
* - If authData.signCount
|
||||
* -> is greater than storedSignCount:
|
||||
* Update storedSignCount to be the value of authData.signCount.
|
||||
* -> less than or equal to storedSignCount:
|
||||
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
|
||||
* credential private key may exist and are being used in parallel. Relying Parties
|
||||
* should incorporate this information into their risk scoring. Whether the Relying
|
||||
* Party updates storedSignCount in this case, or not, or fails the authentication
|
||||
* ceremony or not, is Relying Party-specific.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class IncrementCredentialCounter
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->credential->syncCounter($validation->authenticatorData->counter);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
13
src/Assertion/Validator/Pipes/RetrieveChallenge.php
Normal file
13
src/Assertion/Validator/Pipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\RetrieveChallenge as BaseRetrieveChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrieveChallenge extends BaseRetrieveChallenge
|
||||
{
|
||||
///
|
||||
}
|
||||
58
src/Assertion/Validator/Pipes/RetrievesCredentialId.php
Normal file
58
src/Assertion/Validator/Pipes/RetrievesCredentialId.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrievesCredentialId
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$id = $validation->request->json('id');
|
||||
|
||||
// First, always check the challenge credentials before finding the real one.
|
||||
if ($this->credentialNotInChallenge($id, $validation->challenge->properties)) {
|
||||
throw AssertionException::make('Credential is not on accepted list.');
|
||||
}
|
||||
|
||||
// We can now find the credential.
|
||||
$validation->credential = WebAuthnCredential::whereKey($id)->first();
|
||||
|
||||
if (!$validation->credential) {
|
||||
throw AssertionException::make('Credential ID does not exist.');
|
||||
}
|
||||
|
||||
if ($validation->credential->isDisabled()) {
|
||||
throw AssertionException::make('Credential ID is blacklisted.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the previous Assertion request specified a credentials list to accept.
|
||||
*
|
||||
* @param string $id
|
||||
* @param array $properties
|
||||
* @return bool
|
||||
*/
|
||||
protected function credentialNotInChallenge(string $id, array $properties): bool
|
||||
{
|
||||
return isset($properties['credentials']) && ! in_array($id, $properties['credentials'], true);
|
||||
}
|
||||
}
|
||||
26
src/Attestation/AttestationObject.php
Normal file
26
src/Attestation/AttestationObject.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Laragear\WebAuthn\Attestation\Formats\Format;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AttestationObject
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Object.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
|
||||
* @param \Laragear\WebAuthn\Attestation\Formats\Format $format
|
||||
* @param string $formatName
|
||||
*/
|
||||
public function __construct(
|
||||
public AuthenticatorData $authenticatorData,
|
||||
public Format $format,
|
||||
public string $formatName)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
530
src/Attestation/AuthenticatorData.php
Normal file
530
src/Attestation/AuthenticatorData.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\CborDecoder;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_encode;
|
||||
use function chr;
|
||||
use function chunk_split;
|
||||
use function intdiv;
|
||||
use function is_array;
|
||||
use function ord;
|
||||
use function strlen;
|
||||
use function substr;
|
||||
use function unpack;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @internal
|
||||
*
|
||||
* DER = Distinguished Encoding Rules;
|
||||
* PEM = Privacy Enhanced Mail, basically BASE64 encoded DER.
|
||||
*/
|
||||
class AuthenticatorData
|
||||
{
|
||||
// COSE encoded keys
|
||||
protected static int $COSE_KTY = 1;
|
||||
protected static int $COSE_ALG = 3;
|
||||
|
||||
// COSE EC2 ES256 P-256 curve
|
||||
protected static int $COSE_CRV = -1;
|
||||
protected static int $COSE_X = -2;
|
||||
protected static int $COSE_Y = -3;
|
||||
|
||||
// COSE RSA PS256
|
||||
protected static int $COSE_N = -1;
|
||||
protected static int $COSE_E = -2;
|
||||
|
||||
protected static int $EC2_TYPE = 2;
|
||||
protected static int $EC2_ES256 = -7;
|
||||
protected static int $EC2_P256 = 1;
|
||||
|
||||
protected static int $RSA_TYPE = 3;
|
||||
protected static int $RSA_RS256 = -257;
|
||||
|
||||
/**
|
||||
* Creates a new Authenticator Data instance from a binary string.
|
||||
*
|
||||
* @param string $relyingPartyIdHash
|
||||
* @param object $flags
|
||||
* @param int $counter
|
||||
* @param object{aaguid: int|bool, credentialId: string, credentialPublicKey: string} $attestedCredentialData
|
||||
* @param array $extensionData
|
||||
*/
|
||||
public function __construct(
|
||||
public string $relyingPartyIdHash,
|
||||
public object $flags,
|
||||
public int $counter,
|
||||
public object $attestedCredentialData,
|
||||
public array $extensionData,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the Relying Party ID hash is the same as the one issued.
|
||||
*
|
||||
* @param string $relyingPartyId
|
||||
* @param bool $hash
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
|
||||
{
|
||||
if ($hash) {
|
||||
$relyingPartyId = hash('sha256', $relyingPartyId, true);
|
||||
}
|
||||
|
||||
return hash_equals($relyingPartyId, $this->relyingPartyIdHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Relying Party ID hash is not the same as the one issued.
|
||||
*
|
||||
* @param string $relyingPartyId
|
||||
* @param bool $hash
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNotSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
|
||||
{
|
||||
return ! $this->hasSameRPIdHash($relyingPartyId, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was present during the authentication.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserPresent(): bool
|
||||
{
|
||||
return $this->flags->userPresent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was absent during the authentication.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserAbsent(): bool
|
||||
{
|
||||
return ! $this->wasUserPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was actively verified by the authenticator.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserVerified(): bool
|
||||
{
|
||||
return $this->flags->userVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user was not actively verified by the authenticator.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function wasUserNotVerified(): bool
|
||||
{
|
||||
return ! $this->wasUserVerified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key in PEM format.
|
||||
*
|
||||
* @return string
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public function getPublicKeyPem(): string
|
||||
{
|
||||
$der = match ($this->attestedCredentialData->credentialPublicKey->kty) {
|
||||
self::$EC2_TYPE => $this->getEc2Der(),
|
||||
self::$RSA_TYPE => $this->getRsaDer(),
|
||||
default => throw new DataException('Invalid credential public key type [kty].'),
|
||||
};
|
||||
|
||||
$pem = '-----BEGIN PUBLIC KEY-----'."\n";
|
||||
$pem .= chunk_split(base64_encode($der), 64, "\n");
|
||||
$pem .= '-----END PUBLIC KEY-----'."\n";
|
||||
|
||||
return $pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key in U2F format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPublicKeyU2F(): string
|
||||
{
|
||||
return "\x04". // ECC uncompressed
|
||||
$this->attestedCredentialData->credentialPublicKey->x.
|
||||
$this->attestedCredentialData->credentialPublicKey->y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns DER encoded EC2 key
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getEc2Der(): string
|
||||
{
|
||||
return $this->derSequence(
|
||||
$this->derSequence(
|
||||
$this->derOid("\x2A\x86\x48\xCE\x3D\x02\x01"). // OID 1.2.840.10045.2.1 ecPublicKey
|
||||
$this->derOid("\x2A\x86\x48\xCE\x3D\x03\x01\x07") // 1.2.840.10045.3.1.7 prime256v1
|
||||
).
|
||||
$this->derBitString($this->getPublicKeyU2F())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns DER encoded RSA key.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getRsaDer(): string
|
||||
{
|
||||
return $this->derSequence(
|
||||
$this->derSequence(
|
||||
$this->derOid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01"). // OID 1.2.840.113549.1.1.1 rsaEncryption
|
||||
$this->derNullValue()
|
||||
).
|
||||
$this->derBitString(
|
||||
$this->derSequence(
|
||||
$this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->n).
|
||||
$this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->e)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of a DER encoded string.
|
||||
*
|
||||
* @param int $der
|
||||
* @return string
|
||||
*/
|
||||
protected function derLength(int $der): string
|
||||
{
|
||||
if ($der < 128) {
|
||||
return chr($der);
|
||||
}
|
||||
|
||||
$lenBytes = '';
|
||||
|
||||
while ($der > 0) {
|
||||
$lenBytes = chr($der % 256).$lenBytes;
|
||||
$der = intdiv($der, 256);
|
||||
}
|
||||
|
||||
return chr(0x80 | strlen($lenBytes)).$lenBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string as DER.
|
||||
*
|
||||
* @param string $contents
|
||||
* @return string
|
||||
*/
|
||||
protected function derSequence(string $contents): string
|
||||
{
|
||||
return "\x30".$this->derLength(strlen($contents)).$contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode something an ID of zero as DER.
|
||||
*
|
||||
* @param string $encoded
|
||||
* @return string
|
||||
*/
|
||||
protected function derOid(string $encoded): string
|
||||
{
|
||||
return "\x06".$this->derLength(strlen($encoded)).$encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the bit string as DER.
|
||||
*
|
||||
* @param string $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function derBitString(string $bytes): string
|
||||
{
|
||||
return "\x03".$this->derLength(strlen($bytes) + 1)."\x00".$bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a null value as DER.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function derNullValue(): string
|
||||
{
|
||||
return "\x05\x00";
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a unsigned integer as DER.
|
||||
*
|
||||
* @param string $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function derUnsignedInteger(string $bytes): string
|
||||
{
|
||||
$len = strlen($bytes);
|
||||
|
||||
// Remove leading zero bytes
|
||||
for ($i = 0; $i < ($len - 1); $i++) {
|
||||
if (ord($bytes[$i]) !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($i !== 0) {
|
||||
$bytes = substr($bytes, $i);
|
||||
}
|
||||
|
||||
// If most significant bit is set, prefix with another zero to prevent it being seen as negative number
|
||||
if ((ord($bytes[0]) & 0x80) !== 0) {
|
||||
$bytes = "\x00".$bytes;
|
||||
}
|
||||
|
||||
return "\x02".$this->derLength(strlen($bytes)).$bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Authenticator data from a binary string.
|
||||
*
|
||||
* @param string $binary
|
||||
* @return static
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public static function fromBinary(string $binary): static
|
||||
{
|
||||
if (strlen($binary) < 37) {
|
||||
throw new DataException('Authenticator Data: Invalid input.');
|
||||
}
|
||||
|
||||
$relyingPartyIdHash = substr($binary, 0, 32);
|
||||
|
||||
// flags (1 byte)
|
||||
$flags = static::readFlags(unpack('Cflags', $binary[32])['flags']);
|
||||
|
||||
// signature counter: 32-bit unsigned big-endian integer.
|
||||
$counter = unpack('Nsigncount', substr($binary, 33, 4))['signcount'];
|
||||
|
||||
$offset = 37;
|
||||
|
||||
$attestedCredentialData = $flags->attestedDataIncluded
|
||||
? static::readAttestData($binary, $offset)
|
||||
: (object) null;
|
||||
|
||||
$extensionData = $flags->extensionDataIncluded
|
||||
? static::readExtensionData(substr($binary, $offset))
|
||||
: [];
|
||||
|
||||
return new static($relyingPartyIdHash, $flags, $counter, $attestedCredentialData, $extensionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the flags from flag byte array.
|
||||
*
|
||||
* @param string $binFlag
|
||||
* @return object{userPresent: bool, userVerified: bool, attestedDataIncluded: bool, extensionDataIncluded: bool}
|
||||
*/
|
||||
protected static function readFlags(string $binFlag): object
|
||||
{
|
||||
$flags = (object) [
|
||||
'bit_0' => (bool) ($binFlag & 1),
|
||||
'bit_1' => (bool) ($binFlag & 2),
|
||||
'bit_2' => (bool) ($binFlag & 4),
|
||||
'bit_3' => (bool) ($binFlag & 8),
|
||||
'bit_4' => (bool) ($binFlag & 16),
|
||||
'bit_5' => (bool) ($binFlag & 32),
|
||||
'bit_6' => (bool) ($binFlag & 64),
|
||||
'bit_7' => (bool) ($binFlag & 128),
|
||||
'userPresent' => false,
|
||||
'userVerified' => false,
|
||||
'attestedDataIncluded' => false,
|
||||
'extensionDataIncluded' => false,
|
||||
];
|
||||
|
||||
// named flags
|
||||
$flags->userPresent = $flags->bit_0;
|
||||
$flags->userVerified = $flags->bit_2;
|
||||
$flags->attestedDataIncluded = $flags->bit_6;
|
||||
$flags->extensionDataIncluded = $flags->bit_7;
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attestation data.
|
||||
*
|
||||
* @param string $binary
|
||||
* @param int $endOffset
|
||||
* @return object{aaguid: int|bool, credentialId: string, credentialPublicKey: string}
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readAttestData(string $binary, int &$endOffset): object
|
||||
{
|
||||
if (strlen($binary) <= 55) {
|
||||
throw new DataException('Attested data is missing');
|
||||
}
|
||||
|
||||
// Byte length L of Credential ID, 16-bit unsigned big-endian integer.
|
||||
$length = unpack('nlength', substr($binary, 53, 2))['length'];
|
||||
|
||||
// Set end offset
|
||||
$endOffset = 55 + $length;
|
||||
|
||||
return (object) [
|
||||
'aaguid' => substr($binary, 37, 16),
|
||||
'credentialId' => new ByteBuffer(substr($binary, 55, $length)),
|
||||
'credentialPublicKey' => static::readCredentialPublicKey($binary, 55 + $length, $endOffset)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read COSE key-encoded elliptic curve public key in EC2 format.
|
||||
*
|
||||
* @param string $binary
|
||||
* @param int $offset
|
||||
* @param int $endOffset
|
||||
* @return object
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKey(string $binary, int $offset, int &$endOffset): object
|
||||
{
|
||||
$enc = CborDecoder::decodePortion($binary, $offset, $endOffset);
|
||||
|
||||
// COSE key-encoded elliptic curve public key in EC2 format
|
||||
$publicKey = (object) [
|
||||
'kty' => $enc[static::$COSE_KTY],
|
||||
'alg' => $enc[static::$COSE_ALG]
|
||||
];
|
||||
|
||||
switch ($publicKey->alg) {
|
||||
case static::$EC2_ES256:
|
||||
static::readCredentialPublicKeyES256($publicKey, $enc);
|
||||
break;
|
||||
case static::$RSA_RS256:
|
||||
static::readCredentialPublicKeyRS256($publicKey, $enc);
|
||||
break;
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts ES256 information from COSE encoding.
|
||||
*
|
||||
* @param object $publicKey
|
||||
* @param array $cose
|
||||
* @return object
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKeyES256(object $publicKey, array $cose): object
|
||||
{
|
||||
$publicKey->crv = $cose[self::$COSE_CRV];
|
||||
$publicKey->x = $cose[self::$COSE_X] instanceof ByteBuffer ? $cose[self::$COSE_X]->getBinaryString() : null;
|
||||
$publicKey->y = $cose[self::$COSE_Y] instanceof ByteBuffer ? $cose[self::$COSE_Y]->getBinaryString() : null;
|
||||
|
||||
if ($publicKey->kty !== self::$EC2_TYPE) {
|
||||
throw new DataException('Public key not in EC2 format');
|
||||
}
|
||||
|
||||
if ($publicKey->alg !== self::$EC2_ES256) {
|
||||
throw new DataException('Signature algorithm not ES256');
|
||||
}
|
||||
|
||||
if ($publicKey->crv !== self::$EC2_P256) {
|
||||
throw new DataException('Curve not P-256');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->x) !== 32) {
|
||||
throw new DataException('Invalid X-coordinate');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->y) !== 32) {
|
||||
throw new DataException('Invalid Y-coordinate');
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract RS256 information from COSE.
|
||||
*
|
||||
* @param object $publicKey
|
||||
* @param array $enc
|
||||
* @return void
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readCredentialPublicKeyRS256(object $publicKey, array $enc): void
|
||||
{
|
||||
$publicKey->n = $enc[self::$COSE_N] instanceof ByteBuffer ? $enc[self::$COSE_N]->getBinaryString() : null;
|
||||
$publicKey->e = $enc[self::$COSE_E] instanceof ByteBuffer ? $enc[self::$COSE_E]->getBinaryString() : null;
|
||||
|
||||
if ($publicKey->kty !== self::$RSA_TYPE) {
|
||||
throw new DataException('Public key not in RSA format');
|
||||
}
|
||||
|
||||
if ($publicKey->alg !== self::$RSA_RS256) {
|
||||
throw new DataException('Signature algorithm not ES256');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->n) !== 256) {
|
||||
throw new DataException('Invalid RSA modulus');
|
||||
}
|
||||
|
||||
if (strlen($publicKey->e) !== 3) {
|
||||
throw new DataException('Invalid RSA public exponent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads CBOR encoded extension data.
|
||||
*
|
||||
* @param string $binary
|
||||
* @return array<int, string>
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function readExtensionData(string $binary): array
|
||||
{
|
||||
$ext = CborDecoder::decode($binary);
|
||||
|
||||
return is_array($ext) ? $ext : throw new DataException('Invalid extension data');
|
||||
}
|
||||
}
|
||||
40
src/Attestation/Creator/AttestationCreation.php
Normal file
40
src/Attestation/Creator/AttestationCreation.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\JsonTransport;
|
||||
|
||||
class AttestationCreation
|
||||
{
|
||||
|
||||
public const ATTACHMENT_CROSS_PLATFORM = 'cross-platform';
|
||||
public const ATTACHMENT_PLATFORM = 'platform';
|
||||
|
||||
/**
|
||||
* The underlying JSON representation of the Assertion Challenge.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\JsonTransport
|
||||
*/
|
||||
public JsonTransport $json;
|
||||
|
||||
/**
|
||||
* Create a new Attestation Instructions instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string|null $residentKey
|
||||
* @param string|null $userVerification
|
||||
* @param bool $uniqueCredentials
|
||||
*/
|
||||
public function __construct(
|
||||
public WebAuthnAuthenticatable $user,
|
||||
public Request $request,
|
||||
public ?string $residentKey = null,
|
||||
public ?string $userVerification = null,
|
||||
public bool $uniqueCredentials = true,
|
||||
) {
|
||||
$this->json = new JsonTransport();
|
||||
}
|
||||
}
|
||||
28
src/Attestation/Creator/AttestationCreator.php
Normal file
28
src/Attestation/Creator/AttestationCreator.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
|
||||
*/
|
||||
class AttestationCreator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\AddRelyingParty::class,
|
||||
Pipes\SetResidentKeyConfiguration::class,
|
||||
Pipes\MayRequireUserVerification::class,
|
||||
Pipes\AddUserDescriptor::class,
|
||||
Pipes\AddAcceptedAlgorithms::class,
|
||||
Pipes\MayPreventDuplicateCredentials::class,
|
||||
Pipes\CreateAttestationChallenge::class,
|
||||
];
|
||||
}
|
||||
34
src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php
Normal file
34
src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddAcceptedAlgorithms
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('pubKeyCredParams', [
|
||||
['type' => 'public-key', 'alg' => -7],
|
||||
['type' => 'public-key', 'alg' => -257],
|
||||
]);
|
||||
|
||||
// Currently we don't support direct attestation. In other words, it won't ask
|
||||
// for attestation data from the authenticator to cross-check later against
|
||||
// root certificates. We may add this in the future, but not guaranteed.
|
||||
$attestable->json->set('attestation', 'none');
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
41
src/Attestation/Creator/Pipes/AddRelyingParty.php
Normal file
41
src/Attestation/Creator/Pipes/AddRelyingParty.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddRelyingParty
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('rp.name', $this->config->get('webauthn.relying_party.name'));
|
||||
|
||||
if ($id = $this->config->get('webauthn.relying_party.id')) {
|
||||
$attestable->json->set('rp.id', $id);
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
33
src/Attestation/Creator/Pipes/AddUserDescriptor.php
Normal file
33
src/Attestation/Creator/Pipes/AddUserDescriptor.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class AddUserDescriptor
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$config = $attestable->user->webAuthnData();
|
||||
|
||||
// Create a new User UUID if it doesn't existe already in the credentials.
|
||||
$config['id'] = $attestable->user->webAuthnCredentials()->value('user_id')
|
||||
?: Str::uuid()->getHex()->toString();
|
||||
|
||||
$attestable->json->set('user', $config);
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
49
src/Attestation/Creator/Pipes/CreateAttestationChallenge.php
Normal file
49
src/Attestation/Creator/Pipes/CreateAttestationChallenge.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Cache\Factory;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Attestation\SessionChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CreateAttestationChallenge
|
||||
{
|
||||
use SessionChallenge;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Config\Repository $config
|
||||
* @param \Illuminate\Contracts\Cache\Factory $cache
|
||||
*/
|
||||
public function __construct(protected Repository $config, protected Factory $cache)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
$attestable->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
|
||||
|
||||
$challenge = $this->storeChallenge($attestable->request, $attestable->userVerification, [
|
||||
'user_uuid' => $attestable->json->get('user.id'),
|
||||
'user_handle' => $attestable->json->get('user.name'),
|
||||
]);
|
||||
|
||||
$attestable->json->set('challenge', $challenge->data);
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MayPreventDuplicateCredentials
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->uniqueCredentials) {
|
||||
$attestable->json->set('excludeCredentials', $this->credentials($attestable->user));
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of credentials ready to be inserted into the Attestable JSON.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @return array
|
||||
*/
|
||||
protected function credentials(WebAuthnAuthenticatable $user): array
|
||||
{
|
||||
return $user
|
||||
->webAuthnCredentials()
|
||||
->get(['id', 'transports'])
|
||||
->map(static function (WebAuthnCredential $credential): array {
|
||||
return array_filter([
|
||||
'id'=> $credential->getKey(),
|
||||
'type' => 'public-key',
|
||||
'transports' => $credential->transports
|
||||
]);
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
28
src/Attestation/Creator/Pipes/MayRequireUserVerification.php
Normal file
28
src/Attestation/Creator/Pipes/MayRequireUserVerification.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MayRequireUserVerification
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->userVerification) {
|
||||
$attestable->json->set('authenticatorSelection.userVerification', $attestable->userVerification);
|
||||
}
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class SetResidentKeyConfiguration
|
||||
{
|
||||
/**
|
||||
* Handle the Attestation creation
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationCreation $attestable, Closure $next): mixed
|
||||
{
|
||||
if ($attestable->residentKey) {
|
||||
$attestable->json->set('authenticatorSelection.residentKey', $attestable->residentKey);
|
||||
|
||||
$verifiesUser = $attestable->residentKey === WebAuthn::RESIDENT_KEY_REQUIRED;
|
||||
|
||||
$attestable->json->set('authenticatorSelection.requireResidentKey', $verifiesUser);
|
||||
|
||||
if ($verifiesUser) {
|
||||
$attestable->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $next($attestable);
|
||||
}
|
||||
}
|
||||
53
src/Attestation/Formats/Format.php
Normal file
53
src/Attestation/Formats/Format.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Formats;
|
||||
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This is a base class that hold common tasks for different Attestation Statements formats.
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @see https://www.iana.org/assignments/webauthn/webauthn.xhtml
|
||||
* @internal
|
||||
*/
|
||||
abstract class Format
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Format.
|
||||
*
|
||||
* @param array{fmt: string, attStmt: array, authData: \Laragear\WebAuthn\ByteBuffer} $attestationObject
|
||||
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
|
||||
*/
|
||||
public function __construct(public array $attestationObject, public AuthenticatorData $authenticatorData)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
12
src/Attestation/Formats/None.php
Normal file
12
src/Attestation/Formats/None.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Formats;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class None extends Format
|
||||
{
|
||||
|
||||
}
|
||||
44
src/Attestation/SessionChallenge.php
Normal file
44
src/Attestation/SessionChallenge.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
trait SessionChallenge
|
||||
{
|
||||
/**
|
||||
* Stores an Attestation challenge into the Cache.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string|null $verify
|
||||
* @param array $options
|
||||
* @return \Laragear\WebAuthn\Challenge
|
||||
*/
|
||||
protected function storeChallenge(Request $request, ?string $verify, array $options = []): Challenge
|
||||
{
|
||||
$challenge = $this->createChallenge($verify, $options);
|
||||
|
||||
$request->session()->put($this->config->get('webauthn.challenge.key'), $challenge);
|
||||
|
||||
return $challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Challenge using the default timeout.
|
||||
*
|
||||
* @param string|null $verify
|
||||
* @param array $options
|
||||
* @return \Laragear\WebAuthn\Challenge
|
||||
*/
|
||||
protected function createChallenge(?string $verify, array $options = []): Challenge
|
||||
{
|
||||
return Challenge::random(
|
||||
$this->config->get('webauthn.challenge.bytes'),
|
||||
$this->config->get('webauthn.challenge.timeout'),
|
||||
$verify === WebAuthn::USER_VERIFICATION_REQUIRED,
|
||||
$options,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/Attestation/Validator/AttestationValidation.php
Normal file
35
src/Attestation/Validator/AttestationValidation.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Attestation\AttestationObject;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class AttestationValidation
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Validation procedure
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laragear\WebAuthn\Challenge|null $challenge
|
||||
* @param \Laragear\WebAuthn\Attestation\AttestationObject|null $attestationObject
|
||||
* @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential
|
||||
*/
|
||||
public function __construct(
|
||||
public WebAuthnAuthenticatable $user,
|
||||
public Request $request,
|
||||
public ?Challenge $challenge = null,
|
||||
public ?AttestationObject $attestationObject = null,
|
||||
public ?ClientDataJson $clientDataJson = null,
|
||||
public ?WebAuthnCredential $credential = null,
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
32
src/Attestation/Validator/AttestationValidator.php
Normal file
32
src/Attestation/Validator/AttestationValidator.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator;
|
||||
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @method \Laragear\WebAuthn\Attestation\Validator\AttestationValidation thenReturn()
|
||||
*/
|
||||
class AttestationValidator extends Pipeline
|
||||
{
|
||||
/**
|
||||
* The array of class pipes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pipes = [
|
||||
Pipes\RetrieveChallenge::class,
|
||||
Pipes\CompileClientDataJson::class,
|
||||
Pipes\CompileAttestationObject::class,
|
||||
Pipes\AttestationIsForCreation::class,
|
||||
Pipes\CheckChallengeSame::class,
|
||||
Pipes\CheckOriginSecure::class,
|
||||
Pipes\CheckRelyingPartyIdContained::class,
|
||||
Pipes\CheckRelyingPartyHashSame::class,
|
||||
Pipes\CheckUserInteraction::class,
|
||||
Pipes\CredentialIdShouldNotBeDuplicated::class,
|
||||
Pipes\MakeWebAuthnCredential::class,
|
||||
];
|
||||
}
|
||||
34
src/Attestation/Validator/Pipes/AttestationIsForCreation.php
Normal file
34
src/Attestation/Validator/Pipes/AttestationIsForCreation.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
|
||||
/**
|
||||
* 7. Verify that the value of C.type is webauthn.create.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class AttestationIsForCreation
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->type !== 'webauthn.create') {
|
||||
throw AttestationException::make('Response is not for creating WebAuthn Credentials.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
17
src/Attestation/Validator/Pipes/CheckChallengeSame.php
Normal file
17
src/Attestation/Validator/Pipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckChallengeSame as BaseCheckChallengeSame;
|
||||
|
||||
/**
|
||||
* 8. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckChallengeSame extends BaseCheckChallengeSame
|
||||
{
|
||||
//
|
||||
}
|
||||
10
src/Attestation/Validator/Pipes/CheckOriginSecure.php
Normal file
10
src/Attestation/Validator/Pipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
|
||||
|
||||
class CheckOriginSecure extends BaseCheckOriginSame
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyHashSame as BaseCheckRelyingPartyHashSame;
|
||||
|
||||
/**
|
||||
* 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
|
||||
{
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData
|
||||
{
|
||||
return $validation->attestationObject->authenticatorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
|
||||
{
|
||||
return $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyIdContained as BaseCheckRelyingPartyIdSame;
|
||||
|
||||
/**
|
||||
* 9. Verify that the value of C.origin matches the Relying Party's origin.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckRelyingPartyIdContained extends BaseCheckRelyingPartyIdSame
|
||||
{
|
||||
//
|
||||
}
|
||||
17
src/Attestation/Validator/Pipes/CheckUserInteraction.php
Normal file
17
src/Attestation/Validator/Pipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CheckUserInteraction as BaseCheckUserInteraction;
|
||||
|
||||
/**
|
||||
* 14. Verify that the User Present bit of the flags in authData is set.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CheckUserInteraction extends BaseCheckUserInteraction
|
||||
{
|
||||
//
|
||||
}
|
||||
100
src/Attestation/Validator/Pipes/CompileAttestationObject.php
Normal file
100
src/Attestation/Validator/Pipes/CompileAttestationObject.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Attestation\AttestationObject;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Formats\None;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\CborDecoder;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function base64_decode;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* 12. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
|
||||
* structure to obtain the attestation statement format fmt, the authenticator data authData,
|
||||
* and the attestation statement attStmt.
|
||||
*
|
||||
* 18. Determine the attestation statement format by performing a USASCII case-sensitive match on
|
||||
* fmt against the set of supported WebAuthn Attestation Statement Format Identifier values.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CompileAttestationObject
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$data = $this->decodeCborBase64($validation->request);
|
||||
|
||||
// Here we would receive the attestation formats and decode them. Since we're
|
||||
// only support the universal "none" we can just check if it's equal or not.
|
||||
// Later we may support multiple authenticator formats through a PHP match.
|
||||
if ($data['fmt'] !== 'none') {
|
||||
throw AttestationException::make("Format name [{$data['fmt']}] is invalid.");
|
||||
}
|
||||
|
||||
try {
|
||||
$authenticatorData = AuthenticatorData::fromBinary($data['authData']->getBinaryString());
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
|
||||
$validation->attestationObject = new AttestationObject(
|
||||
$authenticatorData, new None($data, $authenticatorData), $data['fmt']
|
||||
);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array map from a BASE64 encoded CBOR string.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
#[ArrayShape(["fmt" => "string", "attStmt" => "array", "authData" => ByteBuffer::class])]
|
||||
protected function decodeCborBase64(Request $request): array
|
||||
{
|
||||
try {
|
||||
$data = CborDecoder::decode(base64_decode($request->json('response.attestationObject', '')));
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw AttestationException::make('CBOR Object is anything but an array.');
|
||||
}
|
||||
|
||||
if (!isset($data['fmt']) || !is_string($data['fmt'])) {
|
||||
throw AttestationException::make('Format is missing or invalid.');
|
||||
}
|
||||
|
||||
if (!isset($data['attStmt']) || !is_array($data['attStmt'])) {
|
||||
throw AttestationException::make('Statement is missing or invalid.');
|
||||
}
|
||||
|
||||
if (!isset($data['authData']) || !$data['authData'] instanceof ByteBuffer) {
|
||||
throw AttestationException::make('Authenticator Data is missing or invalid.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
20
src/Attestation/Validator/Pipes/CompileClientDataJson.php
Normal file
20
src/Attestation/Validator/Pipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\CompileClientDataJson as BaseCompileClientDataJson;
|
||||
|
||||
/**
|
||||
* 5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.
|
||||
*
|
||||
* 6. Let C, the client data claimed as collected during the credential creation, be the result of
|
||||
* running an implementation-specific JSON parser on JSONtext.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CompileClientDataJson extends BaseCompileClientDataJson
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CredentialIdShouldNotBeDuplicated
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($this->credentialAlreadyExists($validation)) {
|
||||
throw AttestationException::make('Credential ID already exists in the database.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a WebAuthn Credential by the issued ID.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return bool
|
||||
*/
|
||||
protected function credentialAlreadyExists(AttestationValidation $validation): bool
|
||||
{
|
||||
return WebAuthnCredential::whereKey($validation->request->json('id'))->exists();
|
||||
}
|
||||
}
|
||||
70
src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php
Normal file
70
src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MakeWebAuthnCredential
|
||||
{
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Attestation Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->credential = $validation->user->makeWebAuthnCredential([
|
||||
'id' => $validation->request->json('id'),
|
||||
|
||||
'user_id' => $validation->challenge->properties['user_uuid'],
|
||||
'alias' => $validation->request->json('response.alias'),
|
||||
|
||||
'counter' => $validation->attestationObject->authenticatorData->counter,
|
||||
'rp_id' => $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'),
|
||||
'origin' => $validation->clientDataJson->origin,
|
||||
'transports' => $validation->request->json('response.transports'),
|
||||
'aaguid' => Uuid::fromBytes($validation->attestationObject->authenticatorData->attestedCredentialData->aaguid),
|
||||
|
||||
'public_key' => $this->getPublicKeyAsPem($validation),
|
||||
'attestation_format' => $validation->attestationObject->formatName,
|
||||
]);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a public key from the credentials as a PEM string.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
protected function getPublicKeyAsPem(AttestationValidation $validation): string
|
||||
{
|
||||
try {
|
||||
return $validation->attestationObject->authenticatorData->getPublicKeyPem();
|
||||
} catch (DataException $e) {
|
||||
throw AttestationException::make($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Attestation/Validator/Pipes/RetrieveChallenge.php
Normal file
13
src/Attestation/Validator/Pipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
|
||||
|
||||
use Laragear\WebAuthn\SharedPipes\RetrieveChallenge as BaseRetrieveChallenge;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RetrieveChallenge extends BaseRetrieveChallenge
|
||||
{
|
||||
//
|
||||
}
|
||||
110
src/Auth/WebAuthnUserProvider.php
Normal file
110
src/Auth/WebAuthnUserProvider.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Auth;
|
||||
|
||||
use Illuminate\Auth\EloquentUserProvider;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use function class_implements;
|
||||
use function config;
|
||||
use function logger;
|
||||
use function request;
|
||||
|
||||
/**
|
||||
* This class is not meant to be used directly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class WebAuthnUserProvider extends EloquentUserProvider
|
||||
{
|
||||
/**
|
||||
* Create a new database user provider.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Hashing\Hasher $hasher
|
||||
* @param string $model
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidator $validator
|
||||
* @param bool $fallback
|
||||
*/
|
||||
public function __construct(
|
||||
HasherContract $hasher,
|
||||
string $model,
|
||||
protected AssertionValidator $validator,
|
||||
protected bool $fallback,
|
||||
) {
|
||||
parent::__construct($hasher, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
if (class_implements($this->model, WebAuthnAuthenticatable::class) && $this->isSignedChallenge($credentials)) {
|
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
||||
return $this->newModelQuery()
|
||||
->whereHas('webAuthnCredentials', static function (Builder $query) use ($credentials): void {
|
||||
$query->whereKey($credentials['id'])->whereEnabled();
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
return parent::retrieveByCredentials($credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credentials are for a public key signed challenge
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return bool
|
||||
*/
|
||||
protected function isSignedChallenge(array $credentials): bool
|
||||
{
|
||||
return isset($credentials['id'], $credentials['rawId'], $credentials['response'], $credentials['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable|\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validateCredentials($user, array $credentials): bool
|
||||
{
|
||||
if ($user instanceof WebAuthnAuthenticatable && $this->isSignedChallenge($credentials)) {
|
||||
return $this->validateWebAuthn();
|
||||
}
|
||||
|
||||
// If the fallback is enabled, we will validate the credential password.
|
||||
return $this->fallback && parent::validateCredentials($user, $credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the WebAuthn assertion.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function validateWebAuthn(): bool
|
||||
{
|
||||
try {
|
||||
$this->validator->send(new AssertionValidation(request()))->thenReturn();
|
||||
} catch (AssertionException $e) {
|
||||
// If we're debugging, like under local development, push the error to the logger.
|
||||
if (config('app.debug')) {
|
||||
logger($e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
472
src/ByteBuffer.php
Normal file
472
src/ByteBuffer.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use InvalidArgumentException;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JsonSerializable;
|
||||
use OutOfBoundsException;
|
||||
use Stringable;
|
||||
use function base64_decode;
|
||||
use function base64_encode;
|
||||
use function bin2hex;
|
||||
use function hash_equals;
|
||||
use function hex2bin;
|
||||
use function json_decode;
|
||||
use function ord;
|
||||
use function random_bytes;
|
||||
use function rtrim;
|
||||
use function str_repeat;
|
||||
use function strlen;
|
||||
use function strtr;
|
||||
use function substr;
|
||||
use function unpack;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2018 Thomas Bleeker
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @author Thomas Bleeker
|
||||
* @internal
|
||||
*/
|
||||
class ByteBuffer implements JsonSerializable, Jsonable, Stringable
|
||||
{
|
||||
/**
|
||||
* Create a new ByteBuffer
|
||||
*
|
||||
* @param string $binaryData
|
||||
* @param int $dataLength
|
||||
*/
|
||||
public function __construct(protected string $binaryData, protected int $dataLength = 0)
|
||||
{
|
||||
$this->dataLength = strlen($binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the ByteBuffer data.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getDataLength(): int
|
||||
{
|
||||
return $this->dataLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the length of the data is greater than zero.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasLength(): bool
|
||||
{
|
||||
return (bool) $this->dataLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the length of the data is zero.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNoLength(): bool
|
||||
{
|
||||
return !$this->hasLength();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the binary string verbatim.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBinaryString(): string
|
||||
{
|
||||
return $this->binaryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both Byte Buffers are equal using `hash_equals`.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
|
||||
* @return bool
|
||||
*/
|
||||
public function hashEqual(self|string $buffer): bool
|
||||
{
|
||||
if ($buffer instanceof static) {
|
||||
$buffer = $buffer->getBinaryString();
|
||||
}
|
||||
|
||||
return hash_equals($this->binaryData, $buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both Byte Buffers are not equal using `hash_equals`.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
|
||||
* @return bool
|
||||
*/
|
||||
public function hashNotEqual(self|string $buffer): bool
|
||||
{
|
||||
return ! $this->hashEqual($buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a certain portion of these bytes.
|
||||
*
|
||||
* @param int $offset
|
||||
* @param int|null $length
|
||||
* @return string
|
||||
*/
|
||||
public function getBytes(int $offset = 0, int $length = null): string
|
||||
{
|
||||
$length ??= $this->dataLength;
|
||||
|
||||
if ($offset < 0 || $length < 0 || ($offset + $length > $this->dataLength)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset or length.');
|
||||
}
|
||||
|
||||
return substr($this->binaryData, $offset, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single byte.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return int
|
||||
*/
|
||||
public function getByteVal(int $offset = 0): int
|
||||
{
|
||||
if (!$byte = $this->binaryData[$offset] ?? null) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return ord($byte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 16-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUint16Val(int $offset = 0): int
|
||||
{
|
||||
if ($offset < 0 || ($offset + 2) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return unpack('n', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 32-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUint32Val(int $offset = 0): int
|
||||
{
|
||||
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
$val = unpack('N', $this->binaryData, $offset)[1];
|
||||
|
||||
// Signed integer overflow causes signed negative numbers
|
||||
if ($val < 0) {
|
||||
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single unsigned 64-bit integer.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return int
|
||||
*/
|
||||
public function getUint64Val(int $offset): int
|
||||
{
|
||||
if (PHP_INT_SIZE < 8) {
|
||||
throw new OutOfBoundsException('ByteBuffer: 64-bit values not supported by this system');
|
||||
}
|
||||
|
||||
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
$val = unpack('J', $this->binaryData, $offset)[1];
|
||||
|
||||
// Signed integer overflow causes signed negative numbers
|
||||
if ($val < 0) {
|
||||
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 16-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getHalfFloatVal(int $offset = 0): float
|
||||
{
|
||||
// FROM spec pseudo decode_half(unsigned char *halfp)
|
||||
$half = $this->getUint16Val($offset);
|
||||
|
||||
$exp = ($half >> 10) & 0x1f;
|
||||
$mant = $half & 0x3ff;
|
||||
|
||||
if ($exp === 0) {
|
||||
$val = $mant * (2 ** -24);
|
||||
} elseif ($exp !== 31) {
|
||||
$val = ($mant + 1024) * (2 ** ($exp - 25));
|
||||
} else {
|
||||
$val = ($mant === 0) ? INF : NAN;
|
||||
}
|
||||
|
||||
return ($half & 0x8000) ? -$val : $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 32-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getFloatVal(int $offset = 0): float
|
||||
{
|
||||
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
|
||||
return unpack('G', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single 64-bit float.
|
||||
*
|
||||
* @param int $offset
|
||||
* @return float
|
||||
*/
|
||||
public function getDoubleVal(int $offset = 0): float
|
||||
{
|
||||
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
|
||||
}
|
||||
return unpack('E', $this->binaryData, $offset)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the ByteBuffer JSON into a generic Object.
|
||||
*
|
||||
* @param int $jsonFlags
|
||||
* @return object
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function toObject(int $jsonFlags = 0): object
|
||||
{
|
||||
return json_decode($this->binaryData, null, 512, JSON_THROW_ON_ERROR | $jsonFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Base64 URL representation of the byte buffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toBase64Url(): string
|
||||
{
|
||||
return static::encodeBase64Url($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->toBase64Url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hexadecimal representation of the ByteBuffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toHex(): string
|
||||
{
|
||||
return bin2hex($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* object to string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toHex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the object to its JSON representation.
|
||||
*
|
||||
* @param int $options
|
||||
* @return string
|
||||
*/
|
||||
public function toJson($options = 0): string
|
||||
{
|
||||
return $this->jsonSerialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of data for serialization.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape(['binaryData' => "string"])]
|
||||
public function __serialize(): array
|
||||
{
|
||||
return ['binaryData' => static::encodeBase64Url($this->binaryData)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable-Interface
|
||||
*
|
||||
* @param array $data
|
||||
*/
|
||||
public function __unserialize(array $data): void
|
||||
{
|
||||
$this->binaryData = static::decodeBase64Url($data['binaryData']);
|
||||
$this->dataLength = strlen($this->binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a BASE64 URL encoded string.
|
||||
*
|
||||
* @param string $base64url
|
||||
* @return static
|
||||
*/
|
||||
public static function fromBase64Url(string $base64url): static
|
||||
{
|
||||
if (false === $bin = self::decodeBase64Url($base64url)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid base64 url string');
|
||||
}
|
||||
|
||||
return new ByteBuffer($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a BASE64 encoded string.
|
||||
*
|
||||
* @param string $base64
|
||||
* @return static
|
||||
*/
|
||||
public static function fromBase64(string $base64): static
|
||||
{
|
||||
if (false === $bin = base64_decode($base64)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid base64 string');
|
||||
}
|
||||
|
||||
return new ByteBuffer($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ByteBuffer from a hexadecimal string.
|
||||
*
|
||||
* @param string $hex
|
||||
* @return static
|
||||
*/
|
||||
public static function fromHex(string $hex): static
|
||||
{
|
||||
if (false === $bin = hex2bin($hex)) {
|
||||
throw new InvalidArgumentException('ByteBuffer: Invalid hex string');
|
||||
}
|
||||
|
||||
return new static($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random ByteBuffer
|
||||
*
|
||||
* @param int $length
|
||||
* @return static
|
||||
*/
|
||||
public static function makeRandom(int $length): static
|
||||
{
|
||||
return new static(random_bytes($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
protected static function decodeBase64Url(string $data): string|false
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/').str_repeat('=', 3 - (3 + strlen($data)) % 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a BASE64 URL string.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
protected static function encodeBase64Url(string $data): string|false
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
335
src/CborDecoder.php
Normal file
335
src/CborDecoder.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Laragear\WebAuthn\Exceptions\DataException;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2018 Thomas Bleeker
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
* MIT License
|
||||
*
|
||||
* Copyright © 2021 Lukas Buchs
|
||||
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* This file has been modernized to fit Laravel.
|
||||
*
|
||||
* @author Lukas Buchs
|
||||
* @author Thomas Bleeker
|
||||
* @internal
|
||||
*/
|
||||
class CborDecoder
|
||||
{
|
||||
public const CBOR_MAJOR_UNSIGNED_INT = 0;
|
||||
public const CBOR_MAJOR_TEXT_STRING = 3;
|
||||
public const CBOR_MAJOR_FLOAT_SIMPLE = 7;
|
||||
public const CBOR_MAJOR_NEGATIVE_INT = 1;
|
||||
public const CBOR_MAJOR_ARRAY = 4;
|
||||
public const CBOR_MAJOR_TAG = 6;
|
||||
public const CBOR_MAJOR_MAP = 5;
|
||||
public const CBOR_MAJOR_BYTE_STRING = 2;
|
||||
|
||||
/**
|
||||
* Decodes the binary data.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer|string $encoded
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public static function decode(ByteBuffer|string $encoded): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
if (is_string($encoded)) {
|
||||
$encoded = new ByteBuffer($encoded);
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
$result = static::parseItem($encoded, $offset);
|
||||
|
||||
if ($offset !== $encoded->getDataLength()) {
|
||||
throw new DataException('CBOR: Unused bytes after data item.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a portion of the Byte Buffer.
|
||||
*
|
||||
* @param ByteBuffer|string $bufOrBin
|
||||
* @param int $startOffset
|
||||
* @param int|null $endOffset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
public static function decodePortion(ByteBuffer|string $bufOrBin, int $startOffset, ?int &$endOffset = null): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
$buf = $bufOrBin instanceof ByteBuffer ? $bufOrBin : new ByteBuffer($bufOrBin);
|
||||
|
||||
$offset = $startOffset;
|
||||
$data = static::parseItem($buf, $offset);
|
||||
$endOffset = $offset;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single item of the Byte Buffer.
|
||||
*
|
||||
* @param ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseItem(ByteBuffer $buf, int &$offset): ByteBuffer|array|bool|float|int|string|null
|
||||
{
|
||||
$first = $buf->getByteVal($offset++);
|
||||
$type = $first >> 5;
|
||||
$val = $first & 0b11111;
|
||||
|
||||
if ($type === static::CBOR_MAJOR_FLOAT_SIMPLE) {
|
||||
return static::parseFloatSimple($val, $buf, $offset);
|
||||
}
|
||||
|
||||
$val = static::parseExtraLength($val, $buf, $offset);
|
||||
|
||||
try {
|
||||
return static::parseItemData($type, $val, $buf, $offset);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new DataException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a simple float value.
|
||||
*
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return bool|float|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseFloatSimple(int $val, ByteBuffer $buf, int &$offset): bool|float|null
|
||||
{
|
||||
switch ($val) {
|
||||
case 24:
|
||||
$val = $buf->getByteVal($offset);
|
||||
$offset++;
|
||||
return static::parseSimpleValue($val);
|
||||
case 25:
|
||||
$floatValue = $buf->getHalfFloatVal($offset);
|
||||
$offset += 2;
|
||||
return $floatValue;
|
||||
case 26:
|
||||
$floatValue = $buf->getFloatVal($offset);
|
||||
$offset += 4;
|
||||
return $floatValue;
|
||||
case 27:
|
||||
$floatValue = $buf->getDoubleVal($offset);
|
||||
$offset += 8;
|
||||
return $floatValue;
|
||||
case 28:
|
||||
case 29:
|
||||
case 30:
|
||||
throw new DataException('Reserved value used.');
|
||||
case 31:
|
||||
throw new DataException('Indefinite length is not supported.');
|
||||
default:
|
||||
return static::parseSimpleValue($val);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a simple value from CBOR.
|
||||
*
|
||||
* @param int $val
|
||||
* @return bool|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseSimpleValue(int $val): ?bool
|
||||
{
|
||||
return match ($val) {
|
||||
20 => false,
|
||||
21 => true,
|
||||
22 => null,
|
||||
default => throw new DataException(sprintf('Unsupported simple value %d.', $val))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the CBOR extra length.
|
||||
*
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @return int
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseExtraLength(int $val, ByteBuffer $buf, int &$offset): int
|
||||
{
|
||||
switch ($val) {
|
||||
case 24:
|
||||
$val = $buf->getByteVal($offset);
|
||||
$offset++;
|
||||
return $val;
|
||||
case 25:
|
||||
$val = $buf->getUint16Val($offset);
|
||||
$offset += 2;
|
||||
return $val;
|
||||
case 26:
|
||||
$val = $buf->getUint32Val($offset);
|
||||
$offset += 4;
|
||||
return $val;
|
||||
case 27:
|
||||
$val = $buf->getUint64Val($offset);
|
||||
$offset += 8;
|
||||
return $val;
|
||||
case 28:
|
||||
case 29:
|
||||
case 30:
|
||||
throw new DataException('Reserved value used.');
|
||||
case 31:
|
||||
throw new DataException('Indefinite length is not supported.');
|
||||
default:
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the data inside a Byte Buffer.
|
||||
*
|
||||
* @param int $type
|
||||
* @param int $val
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param $offset
|
||||
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseItemData(
|
||||
int $type,
|
||||
int $val,
|
||||
ByteBuffer $buf,
|
||||
&$offset
|
||||
): ByteBuffer|array|bool|float|int|string|null {
|
||||
switch ($type) {
|
||||
case static::CBOR_MAJOR_UNSIGNED_INT: // uint
|
||||
return $val;
|
||||
|
||||
case static::CBOR_MAJOR_NEGATIVE_INT:
|
||||
return -1 - $val;
|
||||
|
||||
case static::CBOR_MAJOR_BYTE_STRING:
|
||||
$data = $buf->getBytes($offset, $val);
|
||||
$offset += $val;
|
||||
return new ByteBuffer($data); // bytes
|
||||
|
||||
case static::CBOR_MAJOR_TEXT_STRING:
|
||||
$data = $buf->getBytes($offset, $val);
|
||||
$offset += $val;
|
||||
return $data; // UTF-8
|
||||
|
||||
case static::CBOR_MAJOR_ARRAY:
|
||||
return static::parseArray($buf, $offset, $val);
|
||||
|
||||
case static::CBOR_MAJOR_MAP:
|
||||
return static::parseMap($buf, $offset, $val);
|
||||
|
||||
case static::CBOR_MAJOR_TAG:
|
||||
return static::parseItem($buf, $offset); // 1 embedded data item
|
||||
}
|
||||
|
||||
throw new DataException(sprintf('Unknown major type %d.', $type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array with string keys.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buffer
|
||||
* @param int $offset
|
||||
* @param int $count
|
||||
* @return array<string, mixed>
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseMap(ByteBuffer $buffer, int &$offset, int $count): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$mapKey = static::parseItem($buffer, $offset);
|
||||
$mapVal = static::parseItem($buffer, $offset);
|
||||
|
||||
if (!is_int($mapKey) && !is_string($mapKey)) {
|
||||
throw new DataException('Can only use strings or integers as map keys');
|
||||
}
|
||||
|
||||
$map[$mapKey] = $mapVal;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array from the byte buffer.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $buf
|
||||
* @param int $offset
|
||||
* @param int $count
|
||||
* @return array
|
||||
* @throws \Laragear\WebAuthn\Exceptions\DataException
|
||||
*/
|
||||
protected static function parseArray(ByteBuffer $buf, int &$offset, int $count): array
|
||||
{
|
||||
$arr = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$arr[] = static::parseItem($buf, $offset);
|
||||
}
|
||||
|
||||
return $arr;
|
||||
}
|
||||
}
|
||||
52
src/Challenge.php
Normal file
52
src/Challenge.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\InteractsWithTime;
|
||||
|
||||
class Challenge
|
||||
{
|
||||
use InteractsWithTime;
|
||||
|
||||
/**
|
||||
* Create a new Challenge instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $data
|
||||
* @param int $timeout
|
||||
* @param bool $verify
|
||||
* @param array $properties
|
||||
*/
|
||||
public function __construct(
|
||||
public ByteBuffer $data,
|
||||
public int $timeout,
|
||||
public bool $verify = true,
|
||||
public array $properties = []
|
||||
) {
|
||||
$this->timeout = Date::now()->addSeconds($this->timeout)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current challenge has expired in time and no longer valid.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return Date::createFromTimestamp($this->timeout)->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Challenge instance using a random ByteBuffer of the given length.
|
||||
*
|
||||
* @param int $length
|
||||
* @param int $timeout
|
||||
* @param bool $verify
|
||||
* @param array $options
|
||||
* @return static
|
||||
*/
|
||||
public static function random(int $length, int $timeout, bool $verify = true, array $options = []): static
|
||||
{
|
||||
return new static(ByteBuffer::makeRandom($length), $timeout, $verify, $options);
|
||||
}
|
||||
}
|
||||
18
src/ClientDataJson.php
Normal file
18
src/ClientDataJson.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
class ClientDataJson
|
||||
{
|
||||
/**
|
||||
* Create a new Client Data JSON object.
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $origin
|
||||
* @param \Laragear\WebAuthn\ByteBuffer $challenge
|
||||
*/
|
||||
public function __construct(public string $type, public string $origin, public ByteBuffer $challenge)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
47
src/Contracts/WebAuthnAuthenticatable.php
Normal file
47
src/Contracts/WebAuthnAuthenticatable.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
interface WebAuthnAuthenticatable
|
||||
{
|
||||
/**
|
||||
* Returns displayable data to be used to create WebAuthn Credentials.
|
||||
*
|
||||
* @return array{name: string, displayName: string}
|
||||
*/
|
||||
public function webAuthnData(): array;
|
||||
|
||||
/**
|
||||
* Removes all credentials previously registered.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function flushCredentials(string ...$except): void;
|
||||
|
||||
/**
|
||||
* Disables all credentials for the user.
|
||||
*
|
||||
* @param string ...$except
|
||||
* @return void
|
||||
*/
|
||||
public function disableAllCredentials(string ...$except): void;
|
||||
|
||||
/**
|
||||
* Makes an instance of a WebAuthn Credential attached to this user.
|
||||
*
|
||||
* @param array $properties
|
||||
* @return \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function makeWebAuthnCredential(array $properties): WebAuthnCredential;
|
||||
|
||||
/**
|
||||
* Returns a queryable relationship for its WebAuthn Credentials.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
public function webAuthnCredentials(): MorphMany;
|
||||
}
|
||||
8
src/Contracts/WebAuthnException.php
Normal file
8
src/Contracts/WebAuthnException.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Contracts;
|
||||
|
||||
interface WebAuthnException
|
||||
{
|
||||
//
|
||||
}
|
||||
22
src/Events/CredentialCloned.php
Normal file
22
src/Events/CredentialCloned.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialCloned
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
* @param int $reportedCount The counter reported by the user authenticator.
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential, public int $reportedCount)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
23
src/Events/CredentialCreated.php
Normal file
23
src/Events/CredentialCreated.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnAuthenticatable $user, public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
21
src/Events/CredentialDisabled.php
Normal file
21
src/Events/CredentialDisabled.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialDisabled
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
21
src/Events/CredentialEnabled.php
Normal file
21
src/Events/CredentialEnabled.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
class CredentialEnabled
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
|
||||
*/
|
||||
public function __construct(public WebAuthnCredential $credential)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
20
src/Exceptions/AssertionException.php
Normal file
20
src/Exceptions/AssertionException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class AssertionException extends ValidationException implements WebAuthnException
|
||||
{
|
||||
/**
|
||||
* Create a new Assertion Exception with the error message.
|
||||
*
|
||||
* @param string $message
|
||||
* @return \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public static function make(string $message): static
|
||||
{
|
||||
return static::withMessages(['assertion' => "Assertion Error: $message"]);
|
||||
}
|
||||
}
|
||||
20
src/Exceptions/AttestationException.php
Normal file
20
src/Exceptions/AttestationException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class AttestationException extends ValidationException implements WebAuthnException
|
||||
{
|
||||
/**
|
||||
* Create a new Attestation Exception with the error message.
|
||||
*
|
||||
* @param string $message
|
||||
* @return \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public static function make(string $message): static
|
||||
{
|
||||
return static::withMessages(['attestation' => "Attestation Error: $message"]);
|
||||
}
|
||||
}
|
||||
11
src/Exceptions/DataException.php
Normal file
11
src/Exceptions/DataException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnException;
|
||||
|
||||
class DataException extends Exception implements WebAuthnException
|
||||
{
|
||||
//
|
||||
}
|
||||
65
src/Http/Requests/AssertedRequest.php
Normal file
65
src/Http/Requests/AssertedRequest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
|
||||
class AssertedRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'id' => "string", 'rawId' => "string", 'response.authenticatorData' => "string",
|
||||
'response.clientDataJSON' => "string", 'response.signature' => "string", 'response.userHandle' => "string",
|
||||
'type' => "string"
|
||||
])]
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'required|string',
|
||||
'rawId' => 'required|string',
|
||||
'response.authenticatorData' => 'required|string',
|
||||
'response.clientDataJSON' => 'required|string',
|
||||
'response.signature' => 'required|string',
|
||||
'response.userHandle' => 'sometimes|nullable',
|
||||
'type' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the login request wants to remember the user as stateful.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRemember(): bool
|
||||
{
|
||||
return $this->hasHeader('X-WebAuthn-Remember')
|
||||
|| $this->hasHeader('WebAuthn-Remember')
|
||||
|| $this->filled('remember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user for this assertion request.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable&\Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function login(string $guard = null, bool $remember = null, bool $destroySession = false): ?WebAuthnAuthenticatable
|
||||
{
|
||||
$auth = Auth::guard($guard);
|
||||
|
||||
if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) {
|
||||
$this->session()->regenerate($destroySession);
|
||||
|
||||
return $auth->user();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
149
src/Http/Requests/AssertionRequest.php
Normal file
149
src/Http/Requests/AssertionRequest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use InvalidArgumentException;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
|
||||
use Laragear\WebAuthn\Assertion\Creator\AssertionCreator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
class AssertionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The Assertion Creation instance.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
|
||||
*/
|
||||
protected AssertionCreation $assertion;
|
||||
|
||||
/**
|
||||
* The guard to use to retrieve the user.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $guard = null;
|
||||
|
||||
/**
|
||||
* If the user may or may not be verified on login.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $userVerification = null;
|
||||
|
||||
/**
|
||||
* Validate the class instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validateResolved(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Return or make a new Assertion Creation.
|
||||
*
|
||||
* @return \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
|
||||
*/
|
||||
protected function assertion(): AssertionCreation
|
||||
{
|
||||
return $this->assertion ??= new AssertionCreation($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the WebAuthn-compatible guard to use.
|
||||
*
|
||||
* @param string $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function guard(string $guard): static
|
||||
{
|
||||
$this->guard = $guard;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to only check for user presence on login.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fastLogin(): static
|
||||
{
|
||||
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to always verify the user thoroughly on login.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function secureLogin(): static
|
||||
{
|
||||
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an assertion challenge for a user if found.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|string|int|array|null $credentials
|
||||
* @return \Illuminate\Contracts\Support\Responsable
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function toVerify(WebAuthnAuthenticatable|string|int|array|null $credentials = []): Responsable
|
||||
{
|
||||
$this->assertion()->user = $this->findUser($credentials);
|
||||
|
||||
return $this->container
|
||||
->make(AssertionCreator::class)
|
||||
->send($this->assertion)
|
||||
->then(static function (AssertionCreation $creation): Responsable {
|
||||
return $creation->json;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a user to create an assertion procedure.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|array|int|string|null $credentials
|
||||
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable
|
||||
{
|
||||
if (!$credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($credentials instanceof WebAuthnAuthenticatable) {
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
// If the developer is using a string or integer, we will understand its trying to
|
||||
// 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.
|
||||
$user = is_string($credentials) || is_int($credentials)
|
||||
? Auth::guard($this->guard)->getProvider()->retrieveById($credentials)
|
||||
: Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials);
|
||||
|
||||
if ($user && ! $user instanceof WebAuthnAuthenticatable) {
|
||||
$guard = $this->guard ?? $this->container->make('config')->get('auth.defaults.guard');
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
"The user found for the [$guard] auth guard is not an instance of [WebAuthnAuthenticatable]."
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
120
src/Http/Requests/AttestationRequest.php
Normal file
120
src/Http/Requests/AttestationRequest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
|
||||
use Laragear\WebAuthn\Attestation\Creator\AttestationCreator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
|
||||
*/
|
||||
class AttestationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The attestation instance that would be returned.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
|
||||
*/
|
||||
protected AttestationCreation $attestation;
|
||||
|
||||
/**
|
||||
* Validate the class instance.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function validateResolved(): void
|
||||
{
|
||||
if (!$this->passesAuthorization()) {
|
||||
$this->failedAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(?WebAuthnAuthenticatable $user): bool
|
||||
{
|
||||
return (bool) $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the existing attestation instance.
|
||||
*
|
||||
* @return \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
|
||||
*/
|
||||
protected function attestation(): AttestationCreation
|
||||
{
|
||||
return $this->attestation ??= new AttestationCreation($this->user(), $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to only check for user presence on registration.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fastRegistration(): static
|
||||
{
|
||||
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the authenticator to always verify the user thoroughly on registration.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function secureRegistration(): static
|
||||
{
|
||||
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the authenticator use this credential to login instantly, instead of asking for one.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function userless(): static
|
||||
{
|
||||
$this->attestation()->residentKey = WebAuthn::RESIDENT_KEY_REQUIRED;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the device to create multiple credentials for the same user for this app.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function allowDuplicates(): static
|
||||
{
|
||||
$this->attestation()->uniqueCredentials = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a response with the instructions to create a WebAuthn Credential.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Support\Responsable
|
||||
*/
|
||||
public function toCreate(): Responsable
|
||||
{
|
||||
return $this->container
|
||||
->make(AttestationCreator::class)
|
||||
->send($this->attestation())
|
||||
->then(static function (AttestationCreation $creation): Responsable {
|
||||
return $creation->json;
|
||||
});
|
||||
}
|
||||
}
|
||||
89
src/Http/Requests/AttestedRequest.php
Normal file
89
src/Http/Requests/AttestedRequest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidator;
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
|
||||
use Laragear\WebAuthn\Events\CredentialCreated;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
use function is_callable;
|
||||
|
||||
/**
|
||||
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
|
||||
*/
|
||||
class AttestedRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The new credential instance.
|
||||
*
|
||||
* @var \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
protected WebAuthnCredential $credential;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(?WebAuthnAuthenticatable $user): bool
|
||||
{
|
||||
return (bool) $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'id' => "string", 'rawId' => "string", 'response' => "string", 'response.clientDataJSON' => "string",
|
||||
'response.attestationObject' => "string", 'type' => "string"
|
||||
])]
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'required|string',
|
||||
'rawId' => 'required|string',
|
||||
'response' => 'required|array',
|
||||
'response.clientDataJSON' => 'required|string',
|
||||
'response.attestationObject' => 'required|string',
|
||||
'type' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a passed validation attempt.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function passedValidation(): void
|
||||
{
|
||||
$this->credential = $this->container->make(AttestationValidator::class)
|
||||
->send(new AttestationValidation($this->user(), $this))
|
||||
->then(static function (AttestationValidation $validation): WebAuthnCredential {
|
||||
return $validation->credential;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and return the generated WebAuthn Credentials.
|
||||
*
|
||||
* @param array|callable $saving
|
||||
* @return string
|
||||
*/
|
||||
public function save(array|callable $saving = []): string
|
||||
{
|
||||
is_callable($saving) ? $saving($this->credential) : $this->credential->forceFill($saving);
|
||||
|
||||
$this->credential->save();
|
||||
|
||||
CredentialCreated::dispatch($this->user(), $this->credential);
|
||||
|
||||
return $this->credential->getKey();
|
||||
}
|
||||
}
|
||||
106
src/JsonTransport.php
Normal file
106
src/JsonTransport.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use JsonSerializable;
|
||||
use Stringable;
|
||||
use function json_encode;
|
||||
|
||||
/**
|
||||
* This class will help us build JSON responses by setting and checking for its keys.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class JsonTransport implements Arrayable, Jsonable, JsonSerializable, Stringable, Responsable
|
||||
{
|
||||
/**
|
||||
* Create a new JSON transport.
|
||||
*
|
||||
* @param array $json
|
||||
*/
|
||||
public function __construct(public array $json = [])
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a value to the underlying JSON array.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
Arr::set($this->json, $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a value from the underlying JSON array.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string|int|null $default
|
||||
* @return string|int|null
|
||||
*/
|
||||
public function get(string $key, string|int $default = null): string|int|null
|
||||
{
|
||||
return Arr::get($this->json, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the object to its JSON representation.
|
||||
*
|
||||
* @param int $options
|
||||
* @return string
|
||||
*/
|
||||
public function toJson($options = 0): string
|
||||
{
|
||||
return json_encode($this->jsonSerialize(), JSON_THROW_ON_ERROR | $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the instance as an array.
|
||||
*
|
||||
* @return array<string, int|string|\Laragear\WebAuthn\ByteBuffer>
|
||||
*/
|
||||
public function toArray()
|
||||
{
|
||||
return $this->json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representation of the object.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toJson();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP response that represents the object.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function toResponse($request): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this);
|
||||
}
|
||||
}
|
||||
195
src/Models/WebAuthnCredential.php
Normal file
195
src/Models/WebAuthnCredential.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Laragear\WebAuthn\Events\CredentialDisabled;
|
||||
use Laragear\WebAuthn\Events\CredentialEnabled;
|
||||
|
||||
/**
|
||||
* @mixin \Illuminate\Database\Eloquent\Builder<\Laragear\WebAuthn\Models\WebAuthnCredential>
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static query()
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static newQuery()
|
||||
* @method static static make(array $attributes = [])
|
||||
* @method static static create(array $attributes = [])
|
||||
* @method static static forceCreate(array $attributes)
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrNew(array $attributes = [], array $values = [])
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrFail($columns = ['*'])
|
||||
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrCreate(array $attributes, array $values = [])
|
||||
* @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 updateOrCreate(array $attributes, array $values = [])
|
||||
* @method static|null first($columns = ['*'])
|
||||
* @method static static findOrFail($id, $columns = ['*'])
|
||||
* @method static static findOrNew($id, $columns = ['*'])
|
||||
* @method static static|null find($id, $columns = ['*'])
|
||||
*
|
||||
* @property-read string $id
|
||||
*
|
||||
* @property-read string $user_id
|
||||
* @property string|null $alias
|
||||
*
|
||||
* @property-read int $counter
|
||||
* @property-read string $rp_id
|
||||
* @property-read string $origin
|
||||
* @property-read array<int, string>|null $transports
|
||||
* @property-read string $aaguid
|
||||
*
|
||||
* @property-read string $public_key
|
||||
* @property-read string $attestation_format
|
||||
* @property-read array<int, string> $certificates
|
||||
*
|
||||
* @property-read \Illuminate\Support\Carbon|null $disabled_at
|
||||
*
|
||||
* @property-read \Laragear\WebAuthn\ByteBuffer $binary_id
|
||||
*
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
*
|
||||
* @property-read \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $authenticatable
|
||||
*
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static whereEnabled()
|
||||
* @method \Illuminate\Database\Eloquent\Builder|static whereDisabled()
|
||||
*/
|
||||
class WebAuthnCredential extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'webauthn_credentials';
|
||||
|
||||
/**
|
||||
* The "type" of the primary key ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* Indicates if the IDs are auto-incrementing.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'counter' => 'int',
|
||||
'transports' => 'array',
|
||||
'public_key' => 'encrypted',
|
||||
'certificates' => 'array',
|
||||
'disabled_at' => 'timestamp',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be visible in serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $visible = ['id', 'origin', 'alias', 'aaguid', 'attestation_format', 'disabled_at', 'is_enabled'];
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo&\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
|
||||
*/
|
||||
public function authenticatable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('authenticatable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the query by enabled credentials.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
protected function scopeWhereEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('disabled_at');
|
||||
}
|
||||
/**
|
||||
* Filter the query by disabled credentials.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
protected function scopeWhereDisabled(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('disabled_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return null === $this->attributes['disabled_at'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credential is disabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isDisabled(): bool
|
||||
{
|
||||
return !$this->isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the credential to be used with WebAuthn.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
$wasDisabled = (bool) $this->attributes['disabled_at'];
|
||||
|
||||
$this->attributes['disabled_at'] = null;
|
||||
|
||||
$this->save();
|
||||
|
||||
if ($wasDisabled) {
|
||||
CredentialEnabled::dispatch($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the credential for WebAuthn.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function disable(): void
|
||||
{
|
||||
$wasEnabled = ! $this->attributes['disabled_at'];
|
||||
|
||||
$this->setAttribute('disabled_at', $this->freshTimestamp())->save();
|
||||
|
||||
if ($wasEnabled) {
|
||||
CredentialDisabled::dispatch($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the assertion counter by 1.
|
||||
*
|
||||
* @param int $counter
|
||||
* @return void
|
||||
*/
|
||||
public function syncCounter(int $counter): void
|
||||
{
|
||||
$this->attributes['counter'] = $counter;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
37
src/SharedPipes/CheckChallengeSame.php
Normal file
37
src/SharedPipes/CheckChallengeSame.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckChallengeSame
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if ($validation->clientDataJson->challenge->hasNoLength()) {
|
||||
static::throw($validation, "Response has an empty challenge.");
|
||||
}
|
||||
|
||||
if ($validation->clientDataJson->challenge->hashNotEqual($validation->challenge->data)) {
|
||||
static::throw($validation, "Response challenge is not equal.");
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
50
src/SharedPipes/CheckOriginSecure.php
Normal file
50
src/SharedPipes/CheckOriginSecure.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function parse_url;
|
||||
|
||||
abstract class CheckOriginSecure
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if (!$validation->clientDataJson->origin) {
|
||||
static::throw($validation, 'Response has an empty origin.');
|
||||
}
|
||||
|
||||
$origin = parse_url($validation->clientDataJson->origin);
|
||||
|
||||
if (!$origin || !isset($origin['host'], $origin['scheme'])) {
|
||||
static::throw($validation, 'Response origin is invalid.');
|
||||
}
|
||||
|
||||
if ($origin['host'] !== 'localhost' && $origin['scheme'] !== 'https') {
|
||||
static::throw($validation, 'Response not made to a secure server (localhost or HTTPS).');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
70
src/SharedPipes/CheckRelyingPartyHashSame.php
Normal file
70
src/SharedPipes/CheckRelyingPartyHashSame.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\AuthenticatorData;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function parse_url;
|
||||
use const PHP_URL_HOST;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckRelyingPartyHashSame
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
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
|
||||
// on assertion. The credential will have the same Relaying Party ID on both
|
||||
// the authenticator and the application so on assertion both should match.
|
||||
$relayingParty = parse_url($this->relyingPartyId($validation), PHP_URL_HOST);
|
||||
|
||||
if ($this->authenticatorData($validation)->hasNotSameRPIdHash($relayingParty)) {
|
||||
static::throw($validation, 'Response has different Relying Party ID hash.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Attestation data to check the RP ID Hash.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
|
||||
*/
|
||||
abstract protected function authenticatorData(
|
||||
AttestationValidation|AssertionValidation $validation
|
||||
): AuthenticatorData;
|
||||
|
||||
/**
|
||||
* Return the Relying Party ID from the config or credential.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string;
|
||||
}
|
||||
57
src/SharedPipes/CheckRelyingPartyIdContained.php
Normal file
57
src/SharedPipes/CheckRelyingPartyIdContained.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Support\Str;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use function hash_equals;
|
||||
use function parse_url;
|
||||
use const PHP_URL_HOST;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckRelyingPartyIdContained
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
if (!$host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) {
|
||||
static::throw($validation, 'Relaying Party ID is invalid.');
|
||||
}
|
||||
|
||||
$current = parse_url(
|
||||
$this->config->get('webauthn.relaying_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.
|
||||
if (hash_equals($current, $host) || Str::is("*.$current", $host)) {
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
static::throw($validation, 'Relaying Party ID not scoped to current.');
|
||||
}
|
||||
}
|
||||
48
src/SharedPipes/CheckUserInteraction.php
Normal file
48
src/SharedPipes/CheckUserInteraction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CheckUserInteraction
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$notPresent = $validation instanceof AttestationValidation
|
||||
? $validation->attestationObject->authenticatorData->wasUserAbsent()
|
||||
: $validation->authenticatorData->wasUserAbsent();
|
||||
|
||||
if ($notPresent) {
|
||||
static::throw($validation, 'Response did not have the user present.');
|
||||
}
|
||||
|
||||
// Only verify the user if the challenge required it.
|
||||
if ($validation->challenge->verify) {
|
||||
$notVerified = $validation instanceof AttestationValidation
|
||||
? $validation->attestationObject->authenticatorData->wasUserNotVerified()
|
||||
: $validation->authenticatorData->wasUserNotVerified();
|
||||
|
||||
if ($notVerified) {
|
||||
static::throw($validation, 'Response did not verify the user.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
57
src/SharedPipes/CompileClientDataJson.php
Normal file
57
src/SharedPipes/CompileClientDataJson.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use JsonException;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\ByteBuffer;
|
||||
use Laragear\WebAuthn\ClientDataJson;
|
||||
use function base64_decode;
|
||||
use function json_decode;
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class CompileClientDataJson
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Handle the incoming WebAuthn Ceremony Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
|
||||
*/
|
||||
public function handle(AssertionValidation|AttestationValidation $validation, Closure $next): mixed
|
||||
{
|
||||
try {
|
||||
$object = json_decode(
|
||||
base64_decode($validation->request->json('response.clientDataJSON', '')), false, 32, JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (JsonException) {
|
||||
static::throw($validation, 'Client Data JSON is invalid or malformed.');
|
||||
}
|
||||
|
||||
if (!$object) {
|
||||
static::throw($validation, 'Client Data JSON is empty.');
|
||||
}
|
||||
|
||||
foreach (['type', 'origin', 'challenge'] as $key) {
|
||||
if (!isset($object->{$key})) {
|
||||
static::throw($validation, "Client Data JSON does not contain the [$key] key.");
|
||||
}
|
||||
}
|
||||
|
||||
$validation->clientDataJson = new ClientDataJson(
|
||||
$object->type, $object->origin, ByteBuffer::fromBase64Url($object->challenge)
|
||||
);
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
}
|
||||
66
src/SharedPipes/RetrieveChallenge.php
Normal file
66
src/SharedPipes/RetrieveChallenge.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Http\Request;
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Challenge;
|
||||
|
||||
/**
|
||||
* This should be the first pipe to run, as the Challenge may expire by mere milliseconds.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class RetrieveChallenge
|
||||
{
|
||||
use ThrowsCeremonyException;
|
||||
|
||||
/**
|
||||
* Create a new pipe instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(protected Repository $config)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming Assertion Validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
|
||||
{
|
||||
$validation->challenge = $this->retrieveChallenge($validation->request);
|
||||
|
||||
if (!$validation->challenge) {
|
||||
static::throw($validation, 'Challenge does not exist.');
|
||||
}
|
||||
|
||||
return $next($validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls an Attestation challenge from the Cache.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Laragear\WebAuthn\Challenge|null
|
||||
*/
|
||||
protected function retrieveChallenge(Request $request): ?Challenge
|
||||
{
|
||||
/** @var \Laragear\WebAuthn\Challenge|null $challenge */
|
||||
$challenge = $request->session()->pull($this->config->get('webauthn.challenge.key'));
|
||||
|
||||
if (!$challenge || $challenge->hasExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $challenge;
|
||||
}
|
||||
}
|
||||
29
src/SharedPipes/ThrowsCeremonyException.php
Normal file
29
src/SharedPipes/ThrowsCeremonyException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Laragear\WebAuthn\SharedPipes;
|
||||
|
||||
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
|
||||
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
|
||||
use Laragear\WebAuthn\Exceptions\AssertionException;
|
||||
use Laragear\WebAuthn\Exceptions\AttestationException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ThrowsCeremonyException
|
||||
{
|
||||
/**
|
||||
* Throws an exception for the validation.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
|
||||
* @param string $message
|
||||
* @return void&never
|
||||
* @throws \Laragear\WebAuthn\Exceptions\AssertionException|\Laragear\WebAuthn\Exceptions\AttestationException
|
||||
*/
|
||||
protected static function throw(AttestationValidation|AssertionValidation $validation, string $message): void
|
||||
{
|
||||
throw $validation instanceof AssertionValidation
|
||||
? AssertionException::make($message)
|
||||
: AttestationException::make($message);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user