From 2023e67625a4a1aab11e0b3381c1b87d8d5bacf7 Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Sat, 4 Jul 2020 01:43:02 -0400 Subject: [PATCH 1/3] Added convenience script. --- README.md | 105 ++++++++---- resources/js/larapass.js | 288 ++++++++++++++++++++++++++++++++ src/LarapassServiceProvider.php | 4 + 3 files changed, 361 insertions(+), 36 deletions(-) create mode 100644 resources/js/larapass.js diff --git a/README.md b/README.md index 6242059..62c048f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ If you have any doubts about WebAuthn, [check this small FAQ](#faq). 2. Migrate the `webauthn_credentials` table. 3. Implement the `WebAuthnAuthenticatable` contract and `WebAuthnAuthentication` trait to your User(s) classes. 4. Register WebAuthn routes. +4. Add the Javascript helper. ### 1. Add the `eloquent-webauthn` driver. @@ -113,29 +114,80 @@ In your frontend scripts, point the requests to these routes. > If you want full control, you can opt-out of these helper controllers and use your own logic. Use the [`AttestWebAuthn`](src/Http/AttestsWebAuthn.php) and [`AssertsWebAuthn`](src/Http/AssertsWebAuthn.php) traits if you need to start with something. -## Frontend integration +### 5. Frontend integration -You're in charge of registering and authenticating users as you want using your own scripts in your frontend. **Larapass doesn't include scripts** because there is no best all-around script that cover all use cases (Vue.js, React.js, vanilla Javascript, etc). +This package includes a convenient script to handle registration and login via WebAuthn. To use it, just publish the `larapass.js` asset into your application public resources. -The important bit is to point the _attest_ (registration) and _assert_ (login) to the routes used for each of them. + php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="public" -If you want to start with something, I recommend these [WebAuthn Javascript Helpers](https://github.com/web-auth/webauthn-helper) which are simple and straightforward. +You will receive the `vendor/larapass/js/larapass.js` file which you can include into your authentication views and use it programmatically, anyway you want. + +```html + + + + + + + +``` + +You can bypass the route list declaration if you're using the defaults. The example above includes them just for show. + +Also, the helper allows headers on the action request, on both registration and login. ```javascript -import {useLogin} from 'webauthn-helper'; +new Larapass({ + login: 'webauthn/register', + loginOptions: 'webauthn/register/options' +}).login({ + email: document.getElementById('email').value, +}, { + myHeader: 'This is sent with the signed challenge', +}) +``` -const login = useLogin({ - loginOptions: '/webauthn/login/options', - loginUrl: '/webauthn/login', -}); +> If the script doesn't suit your needs, you're free to create your own script to handle WebAuthn, or just copy-paste it and import into a transpiler like [Laravel Mix](https://laravel.com/docs/mix#running-mix), [Babel](https://babeljs.io/) or [Webpack](https://webpack.js.org/). -login({ - username: 'John Doe' +### Remembering Users + +You can enable it by just issuing the `WebAuthn-Remember` header value to `true` when pushing the signed login challenge from your frontend. We can do this easily with the [included Javascript helper](#5-frontend-integration). + +```javascript +new Larapass.login({ + email: document.getElementById('email').value +}, { + 'WebAuthn-Remember': true }) - .then(response => window.location.replace = 'https://myapp.com/welcome') - .catch(error => console.log(error)); ``` +Alternatively, you can add the `remember` key to the outgoing JSON Payload if you're using your own scripts. Both ways are accepted. + +> You can override this behaviour in the [`AssertsWebAuthn`](src/Http/AssertsWebAuthn.php) trait. + ## Events Since all authentication is handled by Laravel itself, the only [event](https://laravel.com/docs/events) included is [`AttestationSuccessful`](src/Events/AttestationSuccessful.php), which fires when the registration is successful. It includes the user and the credentials persisted. @@ -395,25 +447,6 @@ By default, this package allows to re-use the same `eloquent-webauthn` driver to Disabling the fallback will only check for WebAuthn credentials. To handle classic user/password scenarios, you should create a separate guard. -## Remembering Users - -You can enable it by just issuing the `WebAuthn-Remember` header value to `true` when pushing the signed login challenge from your frontend, or adding the `remember` key to the JSON Payload if you're able to. - -```javascript -fetch('/webauthn/login', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'WebAuthn-Remember': true, - }, - body: JSON.stringify(publicKeyCredentialsSource), -}) -``` - -> You can override this in the [`AssertsWebAuthn`](src/Http/AssertsWebAuthn.php) trait. - ## Attestation and Metadata statements support If you need very-high-level of security, you should use attestation and metadata statements. You will basically ask the authenticator for its authenticity and check it in a lot of ways. @@ -451,10 +484,10 @@ If you discover any security related issues, please email darkghosthunter@gmail. * **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: +[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-frontend-integration) in a breeze: ```javascript -if (typeof(PublicKeyCredential) == "undefined") { +if (! Larapass.supportsWebAuthn()) { alert('Your device is not secure enough to use this site!'); } ``` @@ -527,11 +560,11 @@ Yes. Just be sure to disable the password column in the users table, the Passwor * **Does this includes Javascript?** -No, mainly because each application frontend is different. A given script may not work for you. +[Yes.](#5-frontend-integration) * **Does this encodes/decode the strings automatically in the frontend?** -No, you must ensure to encode/decode to binary forms some strings in your frontend because the nature of WebAuthn. This [WebAuthn Javascript Helpers](https://github.com/web-auth/webauthn-helper) package does it automatically for you. +No, you must ensure to encode/decode to binary forms some strings in your frontend because the nature of WebAuthn. The included [WebAuthn Helper](#5-frontend-integration) does it automatically for you. ## License diff --git a/resources/js/larapass.js b/resources/js/larapass.js new file mode 100644 index 0000000..51cc6b0 --- /dev/null +++ b/resources/js/larapass.js @@ -0,0 +1,288 @@ +/** + * 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 Larapass +{ + /** + * Headers to use in ALL requests done. + * + * @type {{Accept: string, "X-Requested-With": string, "Content-Type": string}} + */ + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }; + + /** + * Routes for WebAuthn assertion (login) and attestation (register). + * + * @type {{registerOptions: string, loginOptions: string, login: string, register: string}} + */ + routes = { + loginOptions: 'webauthn/login/options', + login: 'webauthn/login', + registerOptions: 'webauthn/register/options', + register: 'webauthn/register', + } + + /** + * Create a new Larapass instance. + * + * @param routes {{registerOptions: string, loginOptions: string, login: string, register: string}} + * @param headers {{string}} + */ + constructor(routes = {}, headers = {}) + { + this.routes = {...this.routes, ...routes}; + + this.headers = { + ...this.headers, + ...headers + } + + // If the developer didn't issue an XSRF token, we will find it ourselves. + if (headers['X-XSRF-TOKEN'] === undefined) { + this.headers['X-XSRF-TOKEN'] = Larapass.#getXsrfToken() + } + } + + /** + * Returns the XSRF token if it exists. + * + * @returns string|undefined + * @throws TypeError + */ + static #getXsrfToken() + { + let tokenContainer; + + // First, let's get the token if it exists as a cookie, since most apps use it by default. + tokenContainer = document.cookie.split('; ').find(row => row.startsWith('XSRF-TOKEN')) + if (tokenContainer !== undefined) { + return decodeURIComponent(tokenContainer.split('=')[1]); + } + + // If it doesn't exists, we will try to get it from the head meta tags as last resort. + tokenContainer = document.getElementsByName('csrf-token')[0]; + if (tokenContainer !== undefined) { + return tokenContainer.content; + } + + throw new TypeError('There is no cookie with "X-XSRF-TOKEN" or meta tag with "csrf-token".') + } + + /** + * Returns a fetch promise to resolve later. + * + * @param data {{string}} + * @param route {string} + * @param headers {{string}} + * @returns {Promise} + */ + #fetch(data, route, headers = {}) + { + return fetch(route, { + method: 'POST', + credentials: '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 window.atob(input); + } + + /** + * Transform an string into Uint8Array instance. + * + * @param input {string} + * @param atob {boolean} + * @returns {Uint8Array} + */ + static #uint8Array(input, atob = false) + { + return Uint8Array.from( + atob ? window.atob(input) : Larapass.#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) + { + publicKey.challenge = Larapass.#uint8Array(publicKey.challenge) + + if (publicKey.user !== undefined) { + publicKey.user = { + ...publicKey.user, + id: Larapass.#uint8Array(publicKey.user.id, true), + }; + } + + ['excludeCredentials', 'allowCredentials'] + .filter(key => publicKey[key] !== undefined) + .forEach(key => { + publicKey[key] = publicKey[key].map( + data => { + return { + ...data, + id: Larapass.#uint8Array(data.id), + }; + } + ) + }) + + 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: Larapass.#arrayToBase64String(credentials.rawId), + response: {}, + }; + + ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle'] + .filter(key => credentials.response[key] !== undefined) + .forEach(key => { + parseCredentials.response[key] = Larapass.#arrayToBase64String(credentials.response[key]); + }) + + return parseCredentials; + } + + /** + * Checks if the browser supports WebAuthn. + * + * @returns {boolean} + */ + static supportsWebAuthn() + { + return typeof(PublicKeyCredential) != 'undefined'; + } + + /** + * Handles the response from the Server. + * + * Throws the response if is not OK (HTTP 2XX). + * + * @param response {Response} + * @returns Response + * @throws Response + */ + static #handleResponse(response) + { + if (response.ok) { + return response; + } + + throw response; + } + + /** + * Log in an user with his credentials. + * + * If no credentials are given, Larapass can return a blank assertion for typeless login. + * + * @param data {{string}} + * @param headers {{string}} + * @returns {Promise} + */ + async login(data = {}, headers = {}) + { + const optionsResponse = await this.#fetch(data, 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) + + return await this.#fetch(publicKeyCredential, this.routes.login, headers) + .then(Larapass.#handleResponse); + } + + /** + * Register the user credentials from the browser/device. + * + * You can add data if you are planning to register an user with WebAuthn from scratch. + * + * @param data {{string}} + * @param headers {{string}} + * @returns {Promise} + */ + async register(data = {}, headers = {}) + { + const optionsResponse = await this.#fetch(data, 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) + + return await this.#fetch(publicKeyCredential, this.routes.register, headers) + .then(Larapass.#handleResponse); + } +} \ No newline at end of file diff --git a/src/LarapassServiceProvider.php b/src/LarapassServiceProvider.php index 752f15c..46a4543 100644 --- a/src/LarapassServiceProvider.php +++ b/src/LarapassServiceProvider.php @@ -204,6 +204,10 @@ protected function publishFiles() __DIR__ . '/../stubs' => app_path('Http/Controllers/Auth'), ], 'controllers'); + $this->publishes([ + __DIR__.'/../resources/js/larapass.js' => public_path('vendor/larapass'), + ], 'public'); + if (! class_exists('CreateWebAuthnCredentialsTable')) { $this->publishes([ __DIR__ . From 38901441d940e6b6cde4c234c135024af9f25d65 Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Sat, 4 Jul 2020 02:15:11 -0400 Subject: [PATCH 2/3] Fixed script publishing location. --- src/LarapassServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LarapassServiceProvider.php b/src/LarapassServiceProvider.php index 46a4543..d49963d 100644 --- a/src/LarapassServiceProvider.php +++ b/src/LarapassServiceProvider.php @@ -205,7 +205,7 @@ protected function publishFiles() ], 'controllers'); $this->publishes([ - __DIR__.'/../resources/js/larapass.js' => public_path('vendor/larapass'), + __DIR__.'/../resources/js' => public_path('vendor/larapass/js'), ], 'public'); if (! class_exists('CreateWebAuthnCredentialsTable')) { From 3940d6a4ac61ad36ac6936d20c4018939c44b70c Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Sat, 4 Jul 2020 02:21:15 -0400 Subject: [PATCH 3/3] Fixed some typos and phrasing. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 62c048f..f801535 100644 --- a/README.md +++ b/README.md @@ -522,7 +522,7 @@ $this->app->bind(CounterChecker::class, function () { }); ``` -Then, you can add your logic to . +Inside your counter checker, you may want to throw an exception if the counter is below what is reported. ```php To blacklist a device, use `disableDevice()` in the user instance. +> To blacklist a device, use `disableDevice()` in the user instance. That allows the user to re-enable it when he recovers the device. * **How secure is this against passwords or 2FA?** @@ -556,9 +556,9 @@ Extremely secure since it works only on HTTPS, and no password or codes are exch * **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?** -Yes. Just be sure to disable the password column in the users table, the Password Broker, and have some logic to register new devices and invalidate old ones. The [`WebAuthnAuthentication`](src/WebAuthnAuthentication.php) trait helps with this. +Yes. Just be sure to disable the password column in the users table, the Password Broker, and have some logic to recover the account with new devices and invalidate old ones. The [`WebAuthnAuthentication`](src/WebAuthnAuthentication.php) trait helps with this. -* **Does this includes Javascript?** +* **Does this includes a frontend Javascript?** [Yes.](#5-frontend-integration)