Browse Source

Replace darkghosthunter/larapass with laragear/webauthn

Bubka 2 years ago
parent
commit
017bbc6304
38 changed files with 1529 additions and 1005 deletions
  1. 10 1
      .env.example
  2. 1 1
      .env.testing
  3. 2 2
      app/Console/Commands/Utils/ResetTrait.php
  4. 0 31
      app/Extensions/EloquentTwoFAuthProvider.php
  5. 66 0
      app/Extensions/WebauthnCredentialBroker.php
  6. 0 2
      app/Http/Controllers/Auth/RegisterController.php
  7. 2 2
      app/Http/Controllers/Auth/UserController.php
  8. 24 24
      app/Http/Controllers/Auth/WebAuthnConfirmController.php
  9. 41 19
      app/Http/Controllers/Auth/WebAuthnDeviceLostController.php
  10. 41 35
      app/Http/Controllers/Auth/WebAuthnLoginController.php
  11. 13 31
      app/Http/Controllers/Auth/WebAuthnManageController.php
  12. 70 36
      app/Http/Controllers/Auth/WebAuthnRecoveryController.php
  13. 40 12
      app/Http/Controllers/Auth/WebAuthnRegisterController.php
  14. 35 0
      app/Http/Requests/WebauthnDeviceLostRequest.php
  15. 33 0
      app/Http/Requests/WebauthnRecoveryRequest.php
  16. 97 0
      app/Models/Traits/WebAuthnManageCredentials.php
  17. 18 32
      app/Models/User.php
  18. 43 0
      app/Models/WebAuthnAuthenticatable.php
  19. 121 0
      app/Notifications/WebauthnRecoveryNotification.php
  20. 67 24
      app/Providers/AuthServiceProvider.php
  21. 1 1
      composer.json
  22. 388 352
      composer.lock
  23. 3 2
      config/auth.php
  24. 0 174
      config/larapass.php
  25. 37 0
      config/webauthn.php
  26. 1 2
      database/migrations/2021_12_03_220140_create_web_authn_tables.php
  27. 101 0
      database/migrations/2022_10_20_122032_create_webauthn_credentials.php
  28. 161 0
      resources/js/components/WebAuthn.js
  29. 1 114
      resources/js/mixins.js
  30. 5 3
      resources/js/views/auth/Login.vue
  31. 5 3
      resources/js/views/auth/Register.vue
  32. 29 90
      resources/js/views/auth/webauthn/Recover.vue
  33. 43 0
      resources/js/views/settings/Settings.vue
  34. 13 5
      resources/js/views/settings/WebAuthn.vue
  35. 7 4
      resources/lang/en/auth.php
  36. 3 1
      resources/lang/en/errors.php
  37. 6 1
      routes/web.php
  38. 1 1
      tests/Feature/Http/Auth/WebAuthnDeviceLostControllerTest.php

+ 10 - 1
.env.example

@@ -107,6 +107,7 @@ MAIL_FROM_ADDRESS=null
 
 AUTHENTICATION_GUARD=web-guard
 
+
 # Name of the HTTP headers sent by the reverse proxy that identifies the authenticated user at proxy level.
 # Check your proxy documentation to find out how these headers are named (i.e 'REMOTE_USER', 'REMOTE_EMAIL', etc...)
 # (only relevant when AUTHENTICATION_GUARD is set to 'reverse-proxy-guard')
@@ -114,10 +115,12 @@ AUTHENTICATION_GUARD=web-guard
 AUTH_PROXY_HEADER_FOR_USER=null
 AUTH_PROXY_HEADER_FOR_EMAIL=null
 
+
 # Custom logout URL to open when using an auth proxy.
 
 PROXY_LOGOUT_URL=null
 
+
 #### WebAuthn settings ####
 
 # Relying Party name, aka the name of the application.
@@ -125,15 +128,19 @@ PROXY_LOGOUT_URL=null
 
 WEBAUTHN_NAME=2FAuth
 
+
 # Relying Party ID. If null, the device will fill it internally.
 # See https://webauthn-doc.spomky-labs.com/pre-requisites/the-relying-party#how-to-determine-the-relying-party-id
 
 WEBAUTHN_ID=null
 
+# [DEPRECATED]
 # Optional image data in BASE64 (128 bytes maximum) or an image url
 # See https://webauthn-doc.spomky-labs.com/pre-requisites/the-relying-party#relying-party-icon
 
-WEBAUTHN_ICON=null
+# WEBAUTHN_ICON=null
+# [/DEPRECATED]
+
 
 # Use this setting to control how user verification behave during the
 # WebAuthn authentication flow.
@@ -150,6 +157,7 @@ WEBAUTHN_ICON=null
 
 WEBAUTHN_USER_VERIFICATION=preferred
 
+
 # Use this setting to declare trusted proxied.
 # Supported:
 #   '*': to trust any proxy
@@ -157,6 +165,7 @@ WEBAUTHN_USER_VERIFICATION=preferred
 
 TRUSTED_PROXIES=null
 
+
 # Leave the following configuration vars as is.
 # Unless you like to tinker and know what you're doing.
 

+ 1 - 1
.env.testing

@@ -5,7 +5,7 @@ APP_DEBUG=true
 APP_URL=http://localhost
 
 WEBAUTHN_NAME=TestApp
-WEBAUTHN_ID=localhost
+WEBAUTHN_ID=null
 WEBAUTHN_USER_VERIFICATION=discouraged
 
 AUTHENTICATION_GUARD=web-guard

+ 2 - 2
app/Console/Commands/Utils/ResetTrait.php

@@ -66,8 +66,8 @@ trait ResetTrait
         DB::table('oauth_access_tokens')->delete();
         DB::table('oauth_personal_access_clients')->delete();
         DB::table('oauth_refresh_tokens')->delete();
-        DB::table('web_authn_credentials')->delete();
-        DB::table('web_authn_recoveries')->delete();
+        DB::table('webauthn_credentials')->delete();
+        DB::table('webauthn_recoveries')->delete();
         DB::table('twofaccounts')->delete();
         DB::table('options')->delete();
         DB::table('groups')->delete();

+ 0 - 31
app/Extensions/EloquentTwoFAuthProvider.php

@@ -1,31 +0,0 @@
-<?php
-
-namespace App\Extensions;
-
-use DarkGhostHunter\Larapass\Auth\EloquentWebAuthnProvider;
-use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator;
-use Illuminate\Contracts\Config\Repository as ConfigContract;
-use Illuminate\Contracts\Hashing\Hasher as HasherContract;
-use App\Facades\Settings;
-
-class EloquentTwoFAuthProvider extends EloquentWebAuthnProvider
-{
-    /**
-     * Create a new database user provider.
-     *
-     * @param  \Illuminate\Contracts\Config\Repository  $config
-     * @param  \DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator  $validator
-     * @param  \Illuminate\Contracts\Hashing\Hasher  $hasher
-     * @param  string  $model
-     */
-    public function __construct(
-        ConfigContract $config,
-        WebAuthnAssertValidator $validator,
-        HasherContract $hasher,
-        string $model
-    ) {
-        parent::__construct($config, $validator, $hasher, $model);
-
-        $this->fallback = !Settings::get('useWebauthnOnly');
-    }
-}

+ 66 - 0
app/Extensions/WebauthnCredentialBroker.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Extensions;
+
+use Closure;
+use App\Models\WebAuthnAuthenticatable;
+use Illuminate\Auth\Passwords\PasswordBroker;
+use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
+
+class WebauthnCredentialBroker extends PasswordBroker
+{
+    /**
+     * Send a password reset link to a user.
+     *
+     * @param  array  $credentials
+     * @param  \Closure|null  $callback
+     *
+     * @return string
+     */
+    public function sendResetLink(array $credentials, Closure $callback = null): string
+    {
+        $user = $this->getUser($credentials);
+
+        if (!$user instanceof WebAuthnAuthenticatable) {
+            return static::INVALID_USER;
+        }
+
+        if ($this->tokens->recentlyCreatedToken($user)) {
+            return static::RESET_THROTTLED;
+        }
+
+        $token = $this->tokens->create($user);
+
+        if ($callback) {
+            $callback($user, $token);
+        } else {
+            $user->sendWebauthnRecoveryNotification($token);
+        }
+
+        return static::RESET_LINK_SENT;
+    }
+
+    
+    /**
+     * Reset the password for the given token.
+     *
+     * @param  array  $credentials
+     * @param  \Closure  $callback
+     *
+     * @return \Illuminate\Contracts\Auth\CanResetPassword|string
+     */
+    public function reset(array $credentials, Closure $callback)
+    {
+        $user = $this->validateReset($credentials);
+
+        if (!$user instanceof CanResetPasswordContract || !$user instanceof WebAuthnAuthenticatable) {
+            return $user;
+        }
+
+        $callback($user);
+
+        $this->tokens->delete($user);
+
+        return static::PASSWORD_RESET;
+    }
+}

+ 0 - 2
app/Http/Controllers/Auth/RegisterController.php

@@ -39,8 +39,6 @@ class RegisterController extends Controller
         Log::info('User created');
 
         $this->guard()->login($user);
-        // $this->guard()->loginUsingId($user->id);
-        // Auth::guard('admin')->attempt($credentials);
 
         return response()->json([
             'message' => 'account created',

+ 2 - 2
app/Http/Controllers/Auth/UserController.php

@@ -62,8 +62,8 @@ class UserController extends Controller
                 DB::table('twofaccounts')->delete();
                 DB::table('groups')->delete();
                 DB::table('options')->delete();
-                DB::table('web_authn_credentials')->delete();
-                DB::table('web_authn_recoveries')->delete();
+                DB::table('webauthn_credentials')->delete();
+                DB::table('webauthn_recoveries')->delete();
                 DB::table('oauth_access_tokens')->delete();
                 DB::table('oauth_auth_codes')->delete();
                 DB::table('oauth_clients')->delete();

+ 24 - 24
app/Http/Controllers/Auth/WebAuthnConfirmController.php

@@ -1,30 +1,30 @@
 <?php
 
-namespace App\Http\Controllers\Auth;
+// namespace App\Http\Controllers\Auth;
 
-use App\Http\Controllers\Controller;
-use App\Providers\RouteServiceProvider;
-use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn;
+// use App\Http\Controllers\Controller;
+// use App\Providers\RouteServiceProvider;
+// // use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn;
 
-class WebAuthnConfirmController extends Controller
-{
-    use ConfirmsWebAuthn;
+// class WebAuthnConfirmController extends Controller
+// {
+//     // use ConfirmsWebAuthn;
 
-    /*
-    |--------------------------------------------------------------------------
-    | Confirm Device Controller
-    |--------------------------------------------------------------------------
-    |
-    | This controller is responsible for handling WebAuthn confirmations and
-    | uses a simple trait to include the behavior. You're free to explore
-    | this trait and override any functions that require customization.
-    |
-    */
+//     /*
+//     |--------------------------------------------------------------------------
+//     | Confirm Device Controller
+//     |--------------------------------------------------------------------------
+//     |
+//     | This controller is responsible for handling WebAuthn confirmations and
+//     | uses a simple trait to include the behavior. You're free to explore
+//     | this trait and override any functions that require customization.
+//     |
+//     */
 
-    /**
-     * Where to redirect users when the intended url fails.
-     *
-     * @var string
-     */
-    protected $redirectTo = RouteServiceProvider::HOME;
-}
+//     /**
+//      * Where to redirect users when the intended url fails.
+//      *
+//      * @var string
+//      */
+//     protected $redirectTo = RouteServiceProvider::HOME;
+// }

+ 41 - 19
app/Http/Controllers/Auth/WebAuthnDeviceLostController.php

@@ -3,35 +3,57 @@
 namespace App\Http\Controllers\Auth;
 
 use App\Http\Controllers\Controller;
-use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
+use App\Extensions\WebauthnCredentialBroker;
+use Illuminate\Foundation\Auth\ResetsPasswords;
+use Illuminate\Support\Facades\Password;
+use App\Http\Requests\WebauthnDeviceLostRequest;
 
 class WebAuthnDeviceLostController extends Controller
 {
-    use SendsWebAuthnRecoveryEmail;
-
-    /*
-    |--------------------------------------------------------------------------
-    | WebAuthn Device Lost Controller
-    |--------------------------------------------------------------------------
-    |
-    | This is a convenience controller that will allow your users who have lost
-    | their WebAuthn device to register another without using passwords. This
-    | will send him a link to his email to create new WebAuthn credentials.
-    |
-    */
+    use ResetsPasswords;
+
+
+    /**
+     * Send a recovery email to the user.
+     *
+     * @param \App\Http\Requests\WebauthnDeviceLostRequest  $request
+     * @param  \App\Extensions\WebauthnCredentialBroker  $broker
+     *
+     * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
+     * @throws \Illuminate\Validation\ValidationException
+     */
+    public function sendRecoveryEmail(WebauthnDeviceLostRequest $request, WebauthnCredentialBroker $broker)
+    {
+        $credentials = $request->validated();
+
+        $response = $broker->sendResetLink($credentials);
+
+        return $response === Password::RESET_LINK_SENT
+            ? $this->sendRecoveryLinkResponse($request, $response)
+            : $this->sendRecoveryLinkFailedResponse($request, $response);
+    }
+
 
     /**
-     * The recovery credentials to retrieve through validation rules.
+     * Get the response for a failed account recovery link.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $response
      *
-     * @return array|string[]
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
+     * @throws \Illuminate\Validation\ValidationException
      */
-    protected function recoveryRules(): array
+    protected function sendRecoveryLinkFailedResponse(Request $request, string $response)
     {
-        return [
-            'email' => 'required|exists:users,email',
-        ];
+        if ($request->wantsJson()) {
+            throw ValidationException::withMessages(['email' => [trans($response)]]);
+        }
+
+        return back()
+            ->withInput($request->only('email'))
+            ->withErrors(['email' => trans($response)]);
     }
 
 

+ 41 - 35
app/Http/Controllers/Auth/WebAuthnLoginController.php

@@ -3,20 +3,17 @@
 namespace App\Http\Controllers\Auth;
 
 use App\Models\User;
-use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
 use App\Http\Controllers\Controller;
-use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Log;
+use Laragear\WebAuthn\Http\Requests\AssertionRequest;
+use Laragear\WebAuthn\Http\Requests\AssertedRequest;
+use Illuminate\Contracts\Support\Responsable;
+use Laragear\WebAuthn\WebAuthn;
 
 class WebAuthnLoginController extends Controller
 {
-    // use AuthenticatesWebAuthn;
-    use AuthenticatesWebAuthn {
-		options as traitOptions;
-		login as traitLogin;
-	}
-
     /*
     |--------------------------------------------------------------------------
     | WebAuthn Login Controller
@@ -29,66 +26,75 @@ class WebAuthnLoginController extends Controller
     */
 
     /**
-     * @return \Illuminate\Http\JsonResponse|\Webauthn\PublicKeyCredentialRequestOptions
+     * Returns the challenge to assertion.
+     *
+     * @param  \Laragear\WebAuthn\Http\Requests\AssertionRequest  $request
+     * @return \Illuminate\Contracts\Support\Responsable|\Illuminate\Http\JsonResponse
      */
-	public function options(Request $request)
-	{
-        // Since 2FAuth is single user designed we fetch the user instance
-        // and merge its email address to the request. This let Larapass validate
-        // the request against a user instance without the need to ask the visitor
-        // for an email address.
-        //
-        // This approach override the Larapass 'userless' config value that seems buggy.
+    public function options(AssertionRequest $request): Responsable|JsonResponse
+    {
+        switch (env('WEBAUTHN_USER_VERIFICATION')) {
+            case WebAuthn::USER_VERIFICATION_DISCOURAGED:
+                $request = $request->fastLogin();    // Makes the authenticator to only check for user presence on registration
+                break;
+            case WebAuthn::USER_VERIFICATION_REQUIRED: 
+                $request = $request->secureLogin();  // Makes the authenticator to always verify the user thoroughly on registration
+                break;
+        }
+
+        // Since 2FAuth is single user designed we fetch the user instance.
+        // This lets Larapass validate the request without the need to ask
+        // the visitor for an email address.
         $user = User::first();
 
-        if (!$user) {
-            return response()->json([
+        return $user
+            ? $request->toVerify($user)
+            : response()->json([
                 'message' => 'no registered user'
             ], 400);
-        }
-        else $request->merge(['email' => $user->email]);
-
-		return $this->traitOptions($request);
-	}
-
+    }
+    
 
     /**
      * Log the user in.
      *
-     * @param  \Illuminate\Http\Request  $request
-     *
+     * @param  \Laragear\WebAuthn\Http\Requests\AssertedRequest  $request
      * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
      */
-    public function login(Request $request)
+    public function login(AssertedRequest $request)
     {
         Log::info('User login via webauthn requested');
-        $request->validate($this->assertionRules());
 
         if ($request->has('response')) {
             $response = $request->response;
 
             // Some authenticators do not send a userHandle so we hack the response to be compliant
-            // with Larapass/webauthn-lib implementation that wait for a userHandle
+            // with Larapass/webauthn-lib implementation that waits for a userHandle
             if(!$response['userHandle']) {
-                $user = User::getFromCredentialId($request->id);
-                $response['userHandle'] = base64_encode($user->userHandle());
+                $response['userHandle'] = User::getFromCredentialId($request->id)?->userHandle();
                 $request->merge(['response' => $response]);
             }
         }
+        
+        $user = $request->login();
+
+        if ($user) {
+            $this->authenticated($user);
+            return response()->noContent();
+        }
 
-        return $this->traitLogin($request);
+        return response()->noContent(422);
     }
 
 
     /**
      * The user has been authenticated.
      *
-     * @param  \Illuminate\Http\Request  $request
      * @param  mixed  $user
      *
      * @return void|\Illuminate\Http\JsonResponse
      */
-    protected function authenticated(Request $request, $user)
+    protected function authenticated($user)
     {
         $user->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
         $user->save();

+ 13 - 31
app/Http/Controllers/Auth/WebAuthnManageController.php

@@ -6,26 +6,10 @@ use App\Facades\Settings;
 use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
 use App\Http\Requests\WebauthnRenameRequest;
-use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
 use Illuminate\Support\Facades\Log;
 
 class WebAuthnManageController extends Controller
-{
-    /*
-    |--------------------------------------------------------------------------
-    | WebAuthn Manage Controller
-    |--------------------------------------------------------------------------
-    |
-    |
-    */
-
-    /**
-     * Create a new controller instance.
-     */
-    public function __construct()
-    {
-    }
-    
+{    
 
     /**
      * List all WebAuthn registered credentials
@@ -34,34 +18,30 @@ class WebAuthnManageController extends Controller
      */
     public function index(Request $request)
     {
-        $user = $request->user();
-        $allUserCredentials = $user->webAuthnCredentials()
-                                    ->enabled()
-                                    ->get()
-                                    ->all();
+        $allUserCredentials = $request->user()->webAuthnCredentials()->WhereEnabled()->get();
 
         return response()->json($allUserCredentials, 200);
     }
 
 
     /**
-     * Rename a WebAuthn device
+     * Rename a WebAuthn credential
      * 
      * @param \App\Http\Requests\WebauthnRenameRequest $request
+     * @param string $credential
      * @return \Illuminate\Http\JsonResponse
      */
     public function rename(WebauthnRenameRequest $request, string $credential)
     {
         $validated = $request->validated();
 
-        $webAuthnCredential = WebAuthnCredential::where('id', $credential)->firstOrFail();
-        $webAuthnCredential->name = $validated['name']; // @phpstan-ignore-line
-        $webAuthnCredential->save();
+        abort_if(! $request->user()->renameCredential($credential, $validated['name']), 404);
 
         return response()->json([
-            'name' => $webAuthnCredential->name,
-        ], 200);
+                    'name' => $validated['name'],
+                ], 200);
     }
+    
 
     /**
      * Remove the specified credential from storage.
@@ -76,13 +56,15 @@ class WebAuthnManageController extends Controller
         Log::info('Deletion of security device requested');
 
         $user = $request->user();
-        $user->removeCredential($credential);
+        $user->flushCredential($credential);
 
-        // Webauthn user options should be reset to prevent impossible login
+        // Webauthn user options need to be reset to prevent impossible login when
+        // no more registered device exists.
         // See #110
-        if (blank($user->allCredentialDescriptors())) {
+        if (blank($user->webAuthnCredentials()->WhereEnabled()->get())) {
             Settings::delete('useWebauthnAsDefault');
             Settings::delete('useWebauthnOnly');
+            Log::notice('No Webauthn credential enabled, Webauthn settings reset to default');
         }
 
         Log::info('Security device deleted');

+ 70 - 36
app/Http/Controllers/Auth/WebAuthnRecoveryController.php

@@ -3,55 +3,83 @@
 namespace App\Http\Controllers\Auth;
 
 use App\Http\Controllers\Controller;
-use App\Providers\RouteServiceProvider;
-use DarkGhostHunter\Larapass\Http\RecoversWebAuthn;
-use DarkGhostHunter\Larapass\Facades\WebAuthn;
+use App\Http\Requests\WebauthnRecoveryRequest;
+use App\Extensions\WebauthnCredentialBroker;
+use App\Facades\Settings;
+use Illuminate\Auth\AuthenticationException;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Foundation\Auth\ResetsPasswords;
+use Illuminate\Support\Facades\Password;
+use Illuminate\Support\Facades\App;
 
 class WebAuthnRecoveryController extends Controller
 {
-    use RecoversWebAuthn;
-
-    /*
-    |--------------------------------------------------------------------------
-    | WebAuthn Recovery Controller
-    |--------------------------------------------------------------------------
-    |
-    | When an user loses his device he will reach this controller to attach a
-    | new device. The user will attach a new device, and optionally, disable
-    | all others. Then he will be authenticated and redirected to your app.
-    |
-    */
+    use ResetsPasswords;  
 
     /**
-     * Where to redirect users after resetting their password.
+     * Let the user regain access to his account using email+password by resetting
+     * the "use webauthn only" setting.
+     *
+     * @param  \App\Http\Requests\WebauthnRecoveryRequest  $request
+     * @param  \App\Extensions\WebauthnCredentialBroker  $broker
      *
-     * @var string
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
+     * @throws \Illuminate\Validation\ValidationException
      */
-    protected $redirectTo = RouteServiceProvider::HOME;
-    
+    public function recover(WebauthnRecoveryRequest $request, WebauthnCredentialBroker $broker)
+    {
+        $credentials = $request->validated();
+
+        $response = $broker->reset(
+            $credentials,
+            function ($user) use ($request) {
+                // At this time, the WebAuthnUserProvider is already registered in the Laravel Service Container,
+                // with a password_fallback value set using the useWebauthnOnly user setting (see AuthServiceProvider.php).
+                // To ensure user login with email+pwd credentials, we replace the registered WebAuthnUserProvider instance
+                // with a new instance configured with password_fallback On.
+                $provider = new \Laragear\WebAuthn\Auth\WebAuthnUserProvider(
+                    app()->make('hash'),
+                    \App\Models\User::class,
+                    app()->make(\Laragear\WebAuthn\Assertion\Validator\AssertionValidator::class),
+                    true,
+                );
+
+                Auth::guard()->setProvider($provider);
+
+                if (Auth::attempt($request->only('email', 'password'))) {
+                    if ($this->shouldRevokeAllCredentials($request)) {
+                        $user->flushCredentials();
+                    }
+                    Settings::delete('useWebauthnOnly');
+                }
+                else throw new AuthenticationException();
+            }
+        );
+        
+        return $response === Password::PASSWORD_RESET
+            ? $this->sendRecoveryResponse($request, $response)
+            : $this->sendRecoveryFailedResponse($request, $response);
+
+    }
+
 
     /**
-     * Returns the credential creation options to the user.
+     * Check if the user has set to revoke all credentials.
      *
-     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Http\Requests\WebauthnRecoveryRequest  $request
      *
-     * @return \Illuminate\Http\JsonResponse
+     * @return bool|mixed
      */
-    public function options(Request $request): JsonResponse
+    protected function shouldRevokeAllCredentials(WebauthnRecoveryRequest $request): mixed
     {
-        $user = WebAuthn::getUser($request->validate($this->rules()));
-
-        // We will proceed only if the broker can find the user and the token is valid.
-        // If the user doesn't exists or the token is invalid, we will bail out with a
-        // HTTP 401 code because the user doing the request is not authorized for it.
-        abort_unless(WebAuthn::tokenExists($user, $request->input('token')), 401, __('auth.webauthn.invalid_recovery_token'));
-
-        return response()->json(WebAuthn::generateAttestation($user));
+        return filter_var($request->header('WebAuthn-Unique'), FILTER_VALIDATE_BOOLEAN)
+            ?: $request->input('revokeAll', true);
     }
 
+
     /**
      * Get the response for a successful account recovery.
      *
@@ -60,13 +88,13 @@ class WebAuthnRecoveryController extends Controller
      *
      * @return \Illuminate\Http\JsonResponse
      * 
-     * @codeCoverageIgnore - already covered by larapass test
      */
     protected function sendRecoveryResponse(Request $request, string $response): JsonResponse
     {
-        return response()->json(['message' => __('auth.webauthn.device_successfully_registered')]);
+        return response()->json(['message' => __('auth.webauthn.webauthn_login_disabled')]);
     }
 
+
     /**
      * Get the response for a failed account recovery.
      *
@@ -76,10 +104,16 @@ class WebAuthnRecoveryController extends Controller
      * @return \Illuminate\Http\JsonResponse
      * @throws \Illuminate\Validation\ValidationException
      * 
-     * @codeCoverageIgnore - already covered by larapass test
      */
     protected function sendRecoveryFailedResponse(Request $request, string $response): JsonResponse
     {
-        throw ValidationException::withMessages(['email' => [trans($response)]]);
+        switch ($response) {
+            case Password::INVALID_TOKEN:
+                throw ValidationException::withMessages(['token' => [__('auth.webauthn.invalid_reset_token')]]);
+
+            default:
+                throw ValidationException::withMessages(['email' => [trans($response)]]);
+        }
+        
     }
-}
+}

+ 40 - 12
app/Http/Controllers/Auth/WebAuthnRegisterController.php

@@ -3,20 +3,48 @@
 namespace App\Http\Controllers\Auth;
 
 use App\Http\Controllers\Controller;
-use DarkGhostHunter\Larapass\Http\RegistersWebAuthn;
+use Illuminate\Contracts\Support\Responsable;
+use Illuminate\Http\Response;
+use Laragear\WebAuthn\Http\Requests\AttestationRequest;
+use Laragear\WebAuthn\Http\Requests\AttestedRequest;
+use Laragear\WebAuthn\WebAuthn;
 
 class WebAuthnRegisterController extends Controller
 {
-    use RegistersWebAuthn;
+    /**
+     * Returns a challenge to be verified by the user device.
+     *
+     * @param  \Laragear\WebAuthn\Http\Requests\AttestationRequest  $request
+     * @return \Illuminate\Contracts\Support\Responsable
+     */
+    public function options(AttestationRequest $request): Responsable
+    {
+        switch (env('WEBAUTHN_USER_VERIFICATION')) {
+            case WebAuthn::USER_VERIFICATION_DISCOURAGED:
+                $request = $request->fastRegistration();    // Makes the authenticator to only check for user presence on registration
+                break;
+            case WebAuthn::USER_VERIFICATION_REQUIRED: 
+                $request = $request->secureRegistration();  // Makes the authenticator to always verify the user thoroughly on registration
+                break;
+        }
 
-    /*
-    |--------------------------------------------------------------------------
-    | WebAuthn Registration Controller
-    |--------------------------------------------------------------------------
-    |
-    | This controller receives an user request to register a device and also
-    | verifies the registration. If everything goes ok, the credential is
-    | persisted into the application, otherwise it will signal failure.
-    |
-    */
+        return $request
+            // ->allowDuplicates() // Allows the device to create multiple credentials for the same user for this app
+            // ->userless()        // Tells the authenticator use this credential to login instantly, instead of asking for one
+            ->toCreate();
+    }
+
+    
+    /**
+     * Registers a device for further WebAuthn authentication.
+     *
+     * @param  \Laragear\WebAuthn\Http\Requests\AttestedRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function register(AttestedRequest $request): Response
+    {
+        $request->save();
+
+        return response()->noContent();
+    }
 }

+ 35 - 0
app/Http/Requests/WebauthnDeviceLostRequest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Auth;
+
+class WebauthnDeviceLostRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'email' => [
+                'required',
+                'email',
+                new \App\Rules\CaseInsensitiveEmailExists
+            ],
+        ];
+    }
+}

+ 33 - 0
app/Http/Requests/WebauthnRecoveryRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Auth;
+
+class WebauthnRecoveryRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'token' => 'required',
+            'email' => 'required|email',
+            'password' => 'required',
+        ];
+    }
+}

+ 97 - 0
app/Models/Traits/WebAuthnManageCredentials.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace App\Models\Traits;
+
+use Illuminate\Support\Str;
+use App\Notifications\WebauthnRecoveryNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * @see \App\Models\WebAuthnAuthenticatable
+ * @see \Laragear\WebAuthn\Models\WebAuthnCredential
+ */
+trait WebAuthnManageCredentials
+{
+    /**
+     * Return the handle used to identify his credentials.
+     *
+     * @return string
+     */
+    public function userHandle(): string
+    {
+        // Laragear\WebAuthn uses Ramsey\Uuid\Uuid::fromString()->getHex()->toString()
+        // to obtain a UUID v4 with dashes removed and uses it as user_id (aka userHandle)
+        // see https://github.com/ramsey/uuid/blob/4.x/src/Uuid.php#L379
+        // and Laragear\WebAuthn\Assertion\Validator\Pipes\CheckCredentialIsForUser::validateId()
+        
+        return $this->webAuthnCredentials()->value('user_id')
+            ?? str_replace('-', '', Str::uuid()->toString());
+    }
+
+
+    /**
+     * Saves a new alias for a given WebAuthn credential.
+     *
+     * @param  string $id
+     * @param  string $alias
+     * @return bool
+     */
+    public function renameCredential(string $id, string $alias): bool
+    {
+        return boolval($this->webAuthnCredentials()->whereKey($id)->update(['alias' => $alias]));
+    }
+
+
+    /**
+     * Removes one or more credentials previously registered.
+     *
+     * @param  string|array  $id
+     * @return void
+     */
+    public function flushCredential($id): void
+    {
+        if (! $this->relationLoaded('webAuthnCredentials')) {
+            $this->webAuthnCredentials()->whereKey($id)->delete();
+
+            return;
+        }
+
+        if ($this->webAuthnCredentials instanceof Collection && $this->webAuthnCredentials->isNotEmpty()) {
+            $this->webAuthnCredentials->whereIn('id', $id)->each->delete();
+
+            $this->setRelation('webAuthnCredentials', $this->webAuthnCredentials->whereNotIn('id', $id));
+        }
+    }
+
+
+    /**
+     * Sends a webauthn recovery email to the user.
+     *
+     * @param  string  $token
+     *
+     * @return void
+     */
+    public function sendWebauthnRecoveryNotification(string $token): void
+    {
+        // $accountRecoveryNotification = new WebauthnRecoveryNotification($token);
+        // $accountRecoveryNotification->toMailUsing(null);
+
+        // $accountRecoveryNotification->createUrlUsing(function(mixed $notifiable, string $token) {
+        //     $url = url(
+        //         route(
+        //             'webauthn.recover',
+        //             [
+        //                 'token' => $token,
+        //                 'email' => $notifiable->getEmailForPasswordReset(),
+        //             ],
+        //             false
+        //         )
+        //     );
+
+        //     return $url;
+        // });
+
+        $this->notify(new WebauthnRecoveryNotification($token));
+        
+    }
+}

+ 18 - 32
app/Models/User.php

@@ -4,18 +4,16 @@ namespace App\Models;
 
 use Illuminate\Auth\Notifications\ResetPassword;
 use Illuminate\Notifications\Notifiable;
-use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Laravel\Passport\HasApiTokens;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
-use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable;
-use DarkGhostHunter\Larapass\WebAuthnAuthentication;
-use DarkGhostHunter\Larapass\Notifications\AccountRecoveryNotification;
+use Laragear\WebAuthn\WebAuthnAuthentication;
+use App\Models\Traits\WebAuthnManageCredentials;
 
 class User extends Authenticatable implements WebAuthnAuthenticatable
 {
-    use WebAuthnAuthentication;
+    use WebAuthnAuthentication, WebAuthnManageCredentials;
     use HasApiTokens, HasFactory, Notifiable;
 
     /**
@@ -30,16 +28,17 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
     /**
      * The attributes that should be hidden for serialization.
      *
-     * @var array
+     * @var array<int,string>
      */
     protected $hidden = [
-        'password', 'remember_token',
+        'password',
+        'remember_token',
     ];
 
     /**
      * The attributes that should be cast.
      *
-     * @var array
+     * @var array<string,string>
      */
     protected $casts = [
         'email_verified_at' => 'datetime',
@@ -67,33 +66,20 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
         $this->attributes['email'] = strtolower($value);
     }
 
+
     /**
-     * Sends a credential recovery email to the user.
-     *
-     * @param  string  $token
+     * Returns an WebAuthnAuthenticatable user from a given Credential ID.
      *
-     * @return void
+     * @param  string  $id
+     * @return WebAuthnAuthenticatable|null
      */
-    public function sendCredentialRecoveryNotification(string $token): void
+    public static function getFromCredentialId(string $id): ?WebAuthnAuthenticatable
     {
-        $accountRecoveryNotification = new AccountRecoveryNotification($token);
-        $accountRecoveryNotification->toMailUsing(null);
-
-        $accountRecoveryNotification->createUrlUsing(function(mixed $notifiable, string $token) {
-            $url = url(
-                route(
-                    'webauthn.recover',
-                    [
-                        'token' => $token,
-                        'email' => $notifiable->getEmailForPasswordReset(),
-                    ],
-                    false
-                )
-            );
-
-            return $url;
-        });
-
-        $this->notify($accountRecoveryNotification);
+        return static::whereHas(
+            'webauthnCredentials',
+            static function ($query) use ($id) {
+                return $query->whereKey($id);
+            }
+        )->first();
     }
 }

+ 43 - 0
app/Models/WebAuthnAuthenticatable.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Models;
+
+use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable as Authenticatable;
+
+interface WebAuthnAuthenticatable extends Authenticatable
+{
+    /**
+     * Return the handle used to identify his credentials.
+     *
+     * @return string
+     */
+    public function userHandle(): string;
+
+
+    /**
+     * Saves a new alias for a given WebAuthn credential.
+     *
+     * @param  string $id
+     * @param  string $alias
+     * @return bool
+     */
+    public function renameCredential(string $id, string $alias): bool;
+
+
+    /**
+     * Removes one or more credentials previously registered.
+     *
+     * @param  string|array  $id
+     * @return void
+     */
+    public function flushCredential($id): void;
+
+    
+    /**
+     * Sends a webauthn recovery email to the user.
+     *
+     * @param  string  $token
+     * @return void
+     */
+    public function sendWebauthnRecoveryNotification(string $token): void;
+}

+ 121 - 0
app/Notifications/WebauthnRecoveryNotification.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Notifications;
+
+use Closure;
+use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Notifications\Notification;
+use Illuminate\Support\Facades\Lang;
+
+class WebauthnRecoveryNotification extends Notification
+{
+    /**
+     * Token for account recovery.
+     *
+     * @var string
+     */
+    protected string $token;
+
+    // /**
+    //  * The callback that should be used to create the reset password URL.
+    //  *
+    //  * @var \Closure|null
+    //  */
+    // protected static ?Closure $createUrlCallback;
+
+    // /**
+    //  * The callback that should be used to build the mail message.
+    //  *
+    //  * @var \Closure|null
+    //  */
+    // protected static ?Closure $toMailCallback;
+
+    /**
+     * AccountRecoveryNotification constructor.
+     *
+     * @param  string  $token
+     */
+    public function __construct(string $token)
+    {
+        $this->token = $token;
+    }
+
+    /**
+     * Get the notification's delivery channels.
+     *
+     * @param  mixed  $notifiable
+     * @return array
+     */
+    public function via($notifiable)
+    {
+        return ['mail'];
+    }
+
+    /**
+     * Get the mail representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return \Illuminate\Notifications\Messages\MailMessage
+     */
+    public function toMail($notifiable)
+    {
+        // if (static::$toMailCallback) {
+        //     return call_user_func(static::$toMailCallback, $notifiable, $this->token);
+        // }
+
+        // if (static::$createUrlCallback) {
+        //     $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
+        // } else {
+            $url = url(
+                route(
+                    'webauthn.recover',
+                    [
+                        'token' => $this->token,
+                        'email' => $notifiable->getEmailForPasswordReset(),
+                    ],
+                    false
+                )
+            );
+        // }
+
+        return (new MailMessage)
+            ->subject(Lang::get('Account Recovery Notification'))
+            ->line(
+                Lang::get(
+                    'You are receiving this email because we received an account recovery request for your account.'
+                )
+            )
+            ->action(Lang::get('Recover Account'), $url)
+            ->line(
+                Lang::get(
+                    'This recovery link will expire in :count minutes.',
+                    ['count' => config('auth.passwords.webauthn.expire')]
+                )
+            )
+            ->line(Lang::get('If you did not request an account recovery, no further action is required.'));
+    }
+
+    // /**
+    //  * Set a callback that should be used when creating the reset password button URL.
+    //  *
+    //  * @param  \Closure|null  $callback
+    //  *
+    //  * @return void
+    //  */
+    // public static function createUrlUsing(?Closure $callback): void
+    // {
+    //     static::$createUrlCallback = $callback;
+    // }
+
+    // /**
+    //  * Set a callback that should be used when building the notification mail message.
+    //  *
+    //  * @param  \Closure|null  $callback
+    //  *
+    //  * @return void
+    //  */
+    // public static function toMailUsing(?Closure $callback): void
+    // {
+    //     static::$toMailCallback = $callback;
+    // }
+}

+ 67 - 24
app/Providers/AuthServiceProvider.php

@@ -5,47 +5,72 @@ namespace App\Providers;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Illuminate\Support\Facades\Auth;
 use App\Services\Auth\ReverseProxyGuard;
-use App\Extensions\EloquentTwoFAuthProvider;
 use App\Extensions\RemoteUserProvider;
-use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator;
-use Illuminate\Contracts\Hashing\Hasher;
+use App\Facades\Settings;
+use Illuminate\Support\Facades\Config;
+use RuntimeException;
+use App\Extensions\WebauthnCredentialBroker;
+use Illuminate\Auth\Passwords\DatabaseTokenRepository;
+use Illuminate\Support\Str;
 
 class AuthServiceProvider extends ServiceProvider
 {
     /**
-     * The policy mappings for the application.
+     * The model to policy mappings for the application.
      *
+     * @var array<class-string, class-string>
      */
-    // protected $policies = [
-    //     'App\Models\Model' => 'App\Policies\ModelPolicy',
-    // ];
+    protected $policies = [
+        // 'App\Models\Model' => 'App\Policies\ModelPolicy',
+    ];
+
 
     /**
-     * Register any authentication / authorization services.
+     * Register the service provider.
      *
      * @return void
+     * @throws \Illuminate\Contracts\Container\BindingResolutionException
      */
-    public function boot()
+    public function register(): void
     {
-        $this->registerPolicies();
 
-        // We use our own user provider derived from the Larapass user provider.
-        // The only difference between the 2 providers is that the custom one sets
-        // the webauthn fallback setting with 2FAuth's 'useWebauthnOnly' option
-        // value instead of the 'larapass.fallback' config value.
-        // This way we can offer the user to change this setting from the 2FAuth UI
-        // rather than from the .env file.
-        Auth::provider(
-            'eloquent-2fauth',
-            static function ($app, $config) {
-                return new EloquentTwoFAuthProvider(
-                    $app['config'],
-                    $app[WebAuthnAssertValidator::class],
-                    $app[Hasher::class],
-                    $config['model']
+        $this->app->singleton(
+            WebauthnCredentialBroker::class,
+            static function ($app) {
+                if (!$config = $app['config']['auth.passwords.webauthn']) {
+                    throw new RuntimeException('You must set the [webauthn] key broker in [auth] config.');
+                }
+
+                $key = $app['config']['app.key'];
+
+                if (Str::startsWith($key, 'base64:')) {
+                    $key = base64_decode(substr($key, 7));
+                }
+
+                return new WebauthnCredentialBroker(
+                    new DatabaseTokenRepository(
+                        $app['db']->connection($config['connection'] ?? null),
+                        $app['hash'],
+                        $config['table'],
+                        $key,
+                        $config['expire'],
+                        $config['throttle'] ?? 0
+                    ),
+                    $app['auth']->createUserProvider($config['provider'] ?? null)
                 );
             }
         );
+    }
+
+
+    /**
+     * Register any authentication / authorization services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        $this->registerPolicies();
 
         // Register a custom provider for reverse-proxy authentication
         Auth::provider('remote-user', function ($app, array $config) {
@@ -62,6 +87,24 @@ class AuthServiceProvider extends ServiceProvider
         });
 
 
+        // Previously we were using a custom user provider derived from the Larapass user provider
+        // in order to honor the "useWebauthnOnly" user option.
+        // Since Laragear\WebAuthn now replaces DarkGhostHunter\Larapass, the new approach is
+        // simplier: We overload the 'eloquent-webauthn' registration from Laragear\WebAuthn\WebAuthnServiceProvider
+        // with a custom closure that uses the "useWebauthnOnly" user option
+        Auth::provider(
+            'eloquent-webauthn',
+            static function (\Illuminate\Contracts\Foundation\Application $app, array $config): \Laragear\WebAuthn\Auth\WebAuthnUserProvider {
+                return new \Laragear\WebAuthn\Auth\WebAuthnUserProvider(
+                    $app->make('hash'),
+                    $config['model'],
+                    $app->make(\Laragear\WebAuthn\Assertion\Validator\AssertionValidator::class),
+                    Settings::get('useWebauthnOnly') ? false : true
+                );
+            }
+        );
+
+
         // Normally we should set the Passport routes here using Passport::routes().
         // If so the passport routes would be set for both 'web' and 'api' middlewares without
         // possibility to exclude the web middleware (we can only pass additional middlewares to Passport::routes())

+ 1 - 1
composer.json

@@ -21,7 +21,7 @@
         "ext-tokenizer": "*",
         "ext-xml": "*",
         "chillerlan/php-qrcode": "^4.3",
-        "darkghosthunter/larapass": "^3.0.2",
+        "laragear/webauthn": "^1.1.0",
         "doctormckay/steam-totp": "^1.0",
         "doctrine/dbal": "^3.4",
         "fruitcake/laravel-cors": "^2.0",

File diff suppressed because it is too large
+ 388 - 352
composer.lock


+ 3 - 2
config/auth.php

@@ -87,8 +87,9 @@ return [
 
     'providers' => [
         'users' => [
-            'driver' => 'eloquent-2fauth',
+            'driver' => 'eloquent-webauthn',
             'model' => App\Models\User::class,
+            // 'password_fallback' => true,
         ],
         'remote-user' => [
             'driver' => 'remote-user',
@@ -122,7 +123,7 @@ return [
         // for WebAuthn
         'webauthn' => [
             'provider' => 'users', // The user provider using WebAuthn.
-            'table' => 'web_authn_recoveries', // The table to store the recoveries.
+            'table' => 'webauthn_recoveries', // The table to store the recoveries.
             'expire' => 60,
             'throttle' => 60,
         ],

+ 0 - 174
config/larapass.php

@@ -1,174 +0,0 @@
-<?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.
-    |
-    */
-
-    'relaying_party' => [
-        'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
-        'id'   => env('WEBAUTHN_ID'),
-        'icon' => env('WEBAUTHN_ICON'),
-    ],
-
-    /*
-    |--------------------------------------------------------------------------
-    | 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 sensible amount of seconds inside your default app cache.
-    |
-    */
-
-    'bytes' => 16,
-    'timeout' => 60,
-    'cache' => env('WEBAUTHN_CACHE'),
-
-    /*
-    |--------------------------------------------------------------------------
-    | Algorithms
-    |--------------------------------------------------------------------------
-    |
-    | Here are default algorithms to use when asking to create sign and encrypt
-    | binary objects like a public key and a challenge. These works almost in
-    | any device, but you can add or change these depending on your devices.
-    |
-    | @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms
-    |
-    */
-
-    'algorithms' => [
-        \Cose\Algorithm\Signature\ECDSA\ES256::class,   // ECDSA with SHA-256
-        \Cose\Algorithm\Signature\EdDSA\Ed25519::class, // EdDSA
-        \Cose\Algorithm\Signature\ECDSA\ES384::class,   // ECDSA with SHA-384
-        \Cose\Algorithm\Signature\ECDSA\ES512::class,   // ECDSA with SHA-512
-        \Cose\Algorithm\Signature\RSA\RS256::class,     // RSASSA-PKCS1-v1_5 with SHA-256
-    ],
-
-
-    /*
-    |--------------------------------------------------------------------------
-    | Credentials Attachment.
-    |--------------------------------------------------------------------------
-    |
-    | Authentication can be tied to the current device (like when using Windows
-    | Hello or Touch ID) or a cross-platform device (like USB Key). When this
-    | is "null" the user will decide where to store his authentication info.
-    |
-    | By default, the user decides what to use for registration. If you wish
-    | to exclusively use a cross-platform authentication (like USB Keys, CA
-    | Servers or Certificates) set this to true, or false if you want to
-    | enforce device-only authentication.
-    |
-    | Supported: "null", "cross-platform", "platform".
-    |
-    */
-
-    'attachment' => null,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Attestation Conveyance
-    |--------------------------------------------------------------------------
-    |
-    | The attestation is the data about the device and the public key used to
-    | sign. Using "none" means the data is meaningless, "indirect" allows to
-    | receive anonymized data, and "direct" means to receive the real data.
-    | 
-    | Attestation Conveyance represents if the device key should be verified
-    | by you or not. While most of the time is not needed, you can change this
-    | to indirect (you verify it comes from a trustful source) or direct
-    | (the device includes validation data).
-    |
-    | Supported: "none", "indirect", "direct".
-    |
-    */
-
-    'conveyance' => 'none',
-
-    /*
-    |--------------------------------------------------------------------------
-    | User presence and verification
-    |--------------------------------------------------------------------------
-    |
-    | Most authenticators and smartphones will ask the user to actively verify
-    | themselves for log in. For example, through a touch plus pin code,
-    | password entry, or biometric recognition (e.g., presenting a fingerprint).
-    | The intent is to distinguish individual users.
-    |
-    | Supported: "required", "preferred", "discouraged".
-    |
-    | Use "required" to always ask verify, "preferred"
-    | to ask when possible, and "discouraged" to just ask for user presence.
-    |
-    */
-
-    'login_verify' => env('WEBAUTHN_USER_VERIFICATION', 'preferred'),
-
-
-    /*
-    |--------------------------------------------------------------------------
-    | Userless (One touch, Typeless) login
-    |--------------------------------------------------------------------------
-    |
-    | By default, users must input their email to receive a list of credentials
-    | ID to use for authentication, but they can also login without specifying
-    | one if the device can remember them, allowing for true one-touch login.
-    |
-    | If required or preferred, login verification will be always required.
-    |
-    | Supported: "null", "required", "preferred", "discouraged".
-    |
-    */
-
-    'userless' => null,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Credential limit
-    |--------------------------------------------------------------------------
-    |
-    | Authenticators can have multiple credentials for the same user account.
-    | To limit one device per user account, you can set this to true. This
-    | will force the attest to fail when registering another credential.
-    |
-    */
-
-    'unique' => false,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Password Fallback
-    |--------------------------------------------------------------------------
-    |
-    | When using the `eloquent-webauthn´ user provider you will be able to use
-    | the same user provider to authenticate users using their password. When
-    | disabling this, users will be strictly authenticated only by WebAuthn.
-    |
-    */
-
-    'fallback' => false,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Device Confirmation
-    |--------------------------------------------------------------------------
-    |
-    | If you're using the "webauthn.confirm" middleware in your routes you may
-    | want to adjust the time the confirmation is remembered in the browser.
-    | This is measured in seconds, but it can be overridden in the route.
-    |
-    */
-
-    'confirm_timeout' => 10800, // 3 hours
-];

+ 37 - 0
config/webauthn.php

@@ -0,0 +1,37 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Relaying Party
+    |--------------------------------------------------------------------------
+    |
+    | We will use your application information to inform the device who is the
+    | relying party. While only the name is enough, you can further set
+    | 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',
+    ],
+];

+ 1 - 2
database/migrations/2021_12_03_220140_create_web_authn_tables.php

@@ -1,6 +1,5 @@
 <?php
 
-use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\Schema;
@@ -38,7 +37,7 @@ class CreateWebAuthnTables extends Migration
             $table->uuid('user_handle')->nullable();
 
             $table->timestamps();
-            $table->softDeletes(WebAuthnCredential::DELETED_AT);
+            $table->softDeletes('disabled_at');
 
             $table->primary(['id', 'user_id']);
         });

+ 101 - 0
database/migrations/2022_10_20_122032_create_webauthn_credentials.php

@@ -0,0 +1,101 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * @see \Laragear\WebAuthn\Models\WebAuthnCredential
+ */
+return new class extends Migration {
+
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up(): void
+    {
+        // We reset the user option 'useWebauthnOnly' to prevent lockout 
+        DB::table('options')->where('key', 'useWebauthnOnly')->delete();
+
+        Schema::create('webauthn_credentials', static function (Blueprint $table): void {
+            static::defaultBlueprint($table);
+        });
+
+        Schema::dropIfExists('web_authn_credentials');
+        Schema::rename('web_authn_recoveries', 'webauthn_recoveries');
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('webauthn_credentials');
+        Schema::rename('webauthn_recoveries', 'web_authn_recoveries');
+
+        Schema::create('web_authn_credentials', function (Blueprint $table) {
+            // This must be the exact same definition as migration 2021_12_03_220140_create_web_authn_tables.php
+            $table->string('id', 191);
+            $table->unsignedBigInteger('user_id');
+            $table->string('name')->nullable();
+            $table->string('type', 16);
+            $table->json('transports');
+            $table->string('attestation_type');
+            $table->json('trust_path');
+            $table->uuid('aaguid');
+            $table->binary('public_key');
+            $table->unsignedInteger('counter')->default(0);
+            $table->uuid('user_handle')->nullable();
+            $table->timestamps();
+            $table->softDeletes('disabled_at');
+            $table->primary(['id', 'user_id']);
+        });
+    }
+
+    /**
+     * 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', 'webauthn_user_index');
+
+        // 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();
+    }
+};

+ 161 - 0
resources/js/components/WebAuthn.js

@@ -0,0 +1,161 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) Italo Israel Baeza Cabrera
+ * https://github.com/Laragear/WebAuthn
+ *
+ * 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.
+ */
+
+export default class WebAuthn {
+
+    /**
+     * Create a new WebAuthn instance.
+     *
+     */
+    constructor () {
+
+    }
+
+    /**
+     * Parses the Public Key Options received from the Server for the browser.
+     *
+     * @param publicKey {Object}
+     * @returns {Object}
+     */
+    parseIncomingServerOptions(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)};
+                });
+            });
+
+        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;
+    }
+
+
+    /**
+     * 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)));
+    }
+
+    
+    /**
+     * 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);
+    }
+
+
+    /**
+     * 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();
+    }
+}

+ 1 - 114
resources/js/mixins.js

@@ -45,120 +45,7 @@ Vue.mixin({
         },
 
         /**
-         * Parses the Public Key Options received from the Server for the browser.
-         *
-         * @param publicKey {Object}
-         * @returns {Object}
-         */
-        parseIncomingServerOptions(publicKey) {
-            publicKey.challenge = this.uint8Array(publicKey.challenge);
-
-            if (publicKey.user !== undefined) {
-                publicKey.user = {
-                    ...publicKey.user,
-                    id: this.uint8Array(publicKey.user.id, true)
-                };
-            }
-
-            ["excludeCredentials", "allowCredentials"]
-                .filter((key) => publicKey[key] !== undefined)
-                .forEach((key) => {
-                    publicKey[key] = publicKey[key].map((data) => {
-                        return { ...data, id: this.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: this.arrayToBase64String(credentials.rawId),
-                response: {}
-            };
-            [
-                "clientDataJSON",
-                "attestationObject",
-                "authenticatorData",
-                "signature",
-                "userHandle"
-            ]
-                .filter((key) => credentials.response[key] !== undefined)
-                .forEach((key) => {
-                    if (credentials.response[key] === null) {
-                        parseCredentials.response[key] = null
-                    }
-                    else {
-                        parseCredentials.response[key] = this.arrayToBase64String(
-                            credentials.response[key]
-                        );
-                    }
-                });
-
-            return parseCredentials;
-        },
-
-        /**
-         * Transform an string into Uint8Array instance.
-         *
-         * @param input {string}
-         * @param atob {boolean}
-         * @returns {Uint8Array}
-         */
-        uint8Array(input, atob = false) {
-            return Uint8Array.from(
-                atob ? window.atob(input) : this.base64UrlDecode(input),
-                (c) => c.charCodeAt(0)
-            );
-        },
-
-        /**
-         * Encodes an array of bytes to a BASE64 URL string
-         *
-         * @param arrayBuffer {ArrayBuffer|Uint8Array}
-         * @returns {string}
-         */
-        arrayToBase64String(arrayBuffer) {
-            return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
-        },
-
-        /**
-         *
-         * Decodes a BASE64 URL string into a normal string.
-         *
-         * @param input {string}
-         * @returns {string|Iterable}
-         */
-        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);
-        },
-
-        /**
-         * Encodes an array of bytes to a BASE64 URL string
-         *
-         * @param arrayBuffer {ArrayBuffer|Uint8Array}
-         * @returns {string}
+         * 
          */
         inputId(fieldType, fieldName) {
             let prefix

+ 5 - 3
resources/js/views/auth/Login.vue

@@ -44,6 +44,7 @@
 <script>
 
     import Form from './../../components/Form'
+    import WebAuthn from './../../components/WebAuthn'
 
     export default {
         data(){
@@ -58,6 +59,7 @@
                 isBusy: false,
                 showWebauthn: this.$root.appSettings.useWebauthnAsDefault || this.$root.appSettings.useWebauthnOnly,
                 csrfRefresher: null,
+                webauthn: new WebAuthn()
             }
         },
 
@@ -107,13 +109,13 @@
                 }
 
                 // Check browser support
-                if (!window.PublicKeyCredential) {
+                if (this.webauthn.doesntSupportWebAuthn) {
                     this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
                     return false
                 }
 
                 const loginOptions = await this.axios.post('/webauthn/login/options').then(res => res.data)
-                const publicKey = this.parseIncomingServerOptions(loginOptions)
+                const publicKey = this.webauthn.parseIncomingServerOptions(loginOptions)
                 const credentials = await navigator.credentials.get({ publicKey: publicKey })
                 .catch(error => {
                     this.$notify({ type: 'is-danger', text: this.$t('auth.webauthn.unknown_device') })
@@ -121,7 +123,7 @@
 
                 if (!credentials) return false
 
-                const publicKeyCredential = this.parseOutgoingCredentials(credentials)
+                const publicKeyCredential = this.webauthn.parseOutgoingCredentials(credentials)
 
                 this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
                     this.$router.push({ name: 'accounts', params: { toRefresh: true } })

+ 5 - 3
resources/js/views/auth/Register.vue

@@ -40,6 +40,7 @@
 <script>
 
     import Form from './../../components/Form'
+    import WebAuthn from './../../components/WebAuthn'
 
     export default {
         data(){
@@ -56,6 +57,7 @@
                 showWebauthnRegistration: false,
                 deviceRegistered: false,
                 deviceId : null,
+                webauthn: new WebAuthn()
             }
         },
 
@@ -95,13 +97,13 @@
                 }
 
                 // Check browser support
-                if (!window.PublicKeyCredential) {
+                if (this.webauthn.doesntSupportWebAuthn) {
                     this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
                     return false
                 }
 
                 const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
-                const publicKey = this.parseIncomingServerOptions(registerOptions)
+                const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
                 let bufferedCredentials
 
                 try {
@@ -117,7 +119,7 @@
                     return false
                 }
 
-                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
+                const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
 
                 this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
                     this.deviceId = publicKeyCredential.id

+ 29 - 90
resources/js/views/auth/webauthn/Recover.vue

@@ -1,27 +1,14 @@
 <template>
-    <form-wrapper :title="$t('auth.webauthn.register_a_new_device')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
-        <div v-if="deviceRegistered" class="field">
-            <label class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label>
-            <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-                <form-field :form="form" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
-                <form-buttons :isBusy="form.isBusy" :isDisabled="form.isDisabled" :caption="$t('commons.continue')" />
-            </form>
-        </div>
-        <div v-else>
-            <div class="field">
-                <input id="unique" name="unique" type="checkbox" class="is-checkradio is-info" v-model="unique" >
-                <label tabindex="0" for="unique" class="label" ref="uniqueLabel" v-on:keypress.space.prevent="unique = true">
-                    {{ $t('auth.webauthn.disable_all_other_devices') }}
-                </label>
-            </div>
-            <div class="field is-grouped">
-                <div class="control">
-                    <button class="button is-link" @click="register()">{{ $t('auth.webauthn.register_a_new_device')}}</button>
-                </div>
-                <div class="control">
-                    <router-link :to="{ name: 'login' }" class="button is-text">{{ $t('commons.cancel') }}</router-link>
+    <form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
+        <div>
+            <form @submit.prevent="recover" @keydown="form.onKeydown($event)">
+                <form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
+                <form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
+                <div class="field">
+                    <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
                 </div>
-            </div>
+                <form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
+            </form>
         </div>
         <!-- footer -->
         <vue-footer></vue-footer>
@@ -35,20 +22,21 @@
     export default {
         data(){
             return {
-                email : '',
-                token: '',
-                unique: false,
+                currentPassword: '',
                 deviceRegistered: false,
                 deviceId : null,
                 form: new Form({
-                    name : '',
+                    email: '',
+                    password: '',
+                    token: '',
+                    revokeAll: false,
                 }),
             }
         },
 
         created () {
-            this.email = this.$route.query.email
-            this.token = this.$route.query.token
+            this.form.email = this.$route.query.email
+            this.form.token = this.$route.query.token
         },
 
         methods : {
@@ -56,73 +44,24 @@
             /**
              * Register a new security device
              */
-            async register() {
-                // Check https context
-                if (!window.isSecureContext) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
-                    return false
-                }
-
-                // Check browser support
-                if (!window.PublicKeyCredential) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
-                    return false
-                }
-
-                const registerOptions = await this.axios.post('/webauthn/recover/options',
-                    {
-                        email : this.email,
-                        token: this.token
-                    },
-                    { returnError: true })
-                .then(res => res.data)
+            recover() {
+                this.form.post('/webauthn/recover', {returnError: true})
+                .then(response => {
+                    this.$router.push({ name: 'login', params: { forceRefresh: true } })
+                })
                 .catch(error => {
-                    this.$notify({ type: 'is-danger', text: error.response.data.message })
-                });
+                    if( error.response.status === 401 ) {
 
-                const publicKey = this.parseIncomingServerOptions(registerOptions)
-                let bufferedCredentials
-
-                try {
-                    bufferedCredentials = await navigator.credentials.create({ publicKey })
-                }
-                catch (error) {
-                    if (error.name == 'AbortError') {
-                        this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
+                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
                     }
-                    else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
-                        this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
+                    else if (error.response.status === 422) {
+                        this.$notify({ type: 'is-danger', text: error.response.data.message })
                     }
-                    return false
-                }
-
-                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
-
-                this.axios.post('/webauthn/recover', publicKeyCredential, {
-                    headers: {
-                        email : this.email,
-                        token: this.token,
-                        unique: this.unique,
+                    else {
+                        this.$router.push({ name: 'genericError', params: { err: error.response } });
                     }
-                }).then(response => {
-                    this.$notify({ type: 'is-success', text: this.$t('auth.webauthn.device_successfully_registered') })
-                    this.deviceId = publicKeyCredential.id
-                    this.deviceRegistered = true
-                })
-            },
-
-
-            /**
-             * Rename the registered device
-             */
-            async handleSubmit(e) {
-
-                await this.form.patch('/webauthn/credentials/' + this.deviceId + '/name')
-
-                if( this.form.errors.any() === false ) {
-                    this.$router.push({name: 'accounts', params: { toRefresh: true }})
-                }
-            },
+                });
+            }
         },
 
         beforeRouteLeave (to, from, next) {

+ 43 - 0
resources/js/views/settings/Settings.vue

@@ -0,0 +1,43 @@
+<template>
+    <aside class="menu">
+        <p class="menu-label">
+            Options
+        </p>
+        <ul class="menu-list">
+            <li><a>General</a></li>
+            <li><a>Groups</a></li>
+            <li><a>Security</a></li>
+        </ul>
+        <p class="menu-label">
+            Administration
+        </p>
+        <ul class="menu-list">
+            <li><a>Team Settings</a></li>
+            <li>
+                <a class="is-active">Manage Your Team</a>
+                <ul>
+                    <li><a>Members</a></li>
+                    <li><a>Plugins</a></li>
+                    <li><a>Add a member</a></li>
+                </ul>
+            </li>
+            <li><a>Invitations</a></li>
+            <li><a>Cloud Storage Environment Settings</a></li>
+            <li><a>Authentication</a></li>
+        </ul>
+        <p class="menu-label">
+            Transactions
+        </p>
+        <ul class="menu-list">
+            <li><a>Payments</a></li>
+            <li><a>Transfers</a></li>
+            <li><a>Balance</a></li>
+        </ul>
+    </aside>
+</template>
+    
+<script>
+    export default {
+
+    }
+</script>

+ 13 - 5
resources/js/views/settings/WebAuthn.vue

@@ -60,6 +60,7 @@
 <script>
 
     import Form from './../../components/Form'
+    import WebAuthn from './../../components/WebAuthn'
 
     export default {
         data(){
@@ -71,6 +72,7 @@
                 credentials: [],
                 isFetching: false,
                 isRemoteUser: false,
+                webauthn: new WebAuthn()
             }
         },
 
@@ -140,13 +142,13 @@
                 }
 
                 // Check browser support
-                if (!window.PublicKeyCredential) {
+                if (this.webauthn.doesntSupportWebAuthn) {
                     this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
                     return false
                 }
 
                 const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
-                const publicKey = this.parseIncomingServerOptions(registerOptions)
+                const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
                 let bufferedCredentials
 
                 try {
@@ -156,13 +158,19 @@
                     if (error.name == 'AbortError') {
                         this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
                     }
-                    else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
+                    else if (error.name == 'SecurityError') {
+                        this.$notify({ type: 'is-danger', text: this.$t('errors.security_error_check_rpid') })
+                    }
+                    else if (error.name == 'InvalidStateError') {
                         this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
                     }
+                    else if (error.name == 'NotAllowedError') {
+                        this.$notify({ type: 'is-danger', text: this.$t('errors.not_allowed_operation') })
+                    }
                     return false
                 }
 
-                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
+                const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
 
                 this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
                     this.$router.push({ name: 'settings.webauthn.editCredential', params: { id: publicKeyCredential.id, name: this.$t('auth.webauthn.my_device') } })
@@ -196,7 +204,7 @@
              * Always display a printable name
              */
             displayName(credential) {
-                return credential.name ? credential.name : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
+                return credential.alias ? credential.alias : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
             },
 
         },

+ 7 - 4
resources/lang/en/auth.php

@@ -49,24 +49,27 @@ return [
         'lost_your_device' => 'Lost your device?',
         'recover_your_account' => 'Recover your account',
         'account_recovery' => 'Account recovery',
-        'recovery_punchline' => '2FAuth will send you a recovery link to this email address. Click the link in the received email to register a new security device.<br /><br />Ensure you open the email on a device you fully own.',
+        'recovery_punchline' => '2FAuth will send you a recovery link to this email address. Click the link in the received email and follow the instructions.<br /><br />Ensure you open the email on a device you fully own.',
         'send_recovery_link' => 'Send recovery link',
         'account_recovery_email_sent' => 'Account recovery email sent!',
-        'disable_all_other_devices' => 'Disable all other devices except this one',
+        'disable_all_security_devices' => 'Disable all security devices',
+        'disable_all_security_devices_help' => 'All your security devices will be revoked. Use this option if you have lost one or its security has been compromised.',
         'register_a_new_device' => 'Register a new device',
         'register_a_device' => 'Register a device',
         'device_successfully_registered' => 'Device successfully registered',
         'device_revoked' => 'Device successfully revoked',
         'revoking_a_device_is_permanent' => 'Revoking a device is permanent',
-        'recover_account_instructions' => 'Click the button below to register a new security device to recover your account. Just follow your browser instructions.',
+        'recover_account_instructions' => 'To recover your account, 2FAuth resets some Webauthn settings so you will be able to sign in using your email and password.',
         'invalid_recovery_token' => 'Invalid recovery token',
+        'webauthn_login_disabled' => 'Webauthn login disabled',
+        'invalid_reset_token' => 'This reset token is invalid.',
         'rename_device' => 'Rename device',
         'my_device' => 'My device',
         'unknown_device' => 'Unknown device',
         'use_webauthn_only' => [
             'label' => 'Use WebAuthn only',
             'help' => 'Make WebAuthn the only available method to sign in 2FAuth. This is the recommended setup to take advantage of the WebAuthn enhanced security.<br />
-                In case of device lost you will always be able to register a new security device to recover your account.'
+                In case of device lost, you will be able to recover your account by resetting this option and signing in using your email and password.'
         ],
         'need_a_security_device_to_enable_options' => 'Set at least one device to enable these options',
         'use_webauthn_as_default' => [

+ 3 - 1
resources/lang/en/errors.php

@@ -34,7 +34,9 @@ return [
     'https_required' => 'HTTPS context required',
     'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser',
     'aborted_by_user' => 'Aborted by user',
-    'security_device_unsupported' => 'Security device unsupported',
+    'security_device_unsupported' => 'Unsupported or in use device',
+    'not_allowed_operation' => 'Operation not allowed',
+    'security_error_check_rpid' => 'Security error<br/>Check your WEBAUTHN_ID env var',
     'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
     'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
     'auth_proxy_failed' => 'Proxy authentication failed',

+ 6 - 1
routes/web.php

@@ -22,7 +22,12 @@ Route::group(['middleware' => ['guest', 'rejectIfDemoMode']], function () {
     Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('password.reset');
     Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
     Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost');
-    Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options'])->name('webauthn.recover.options');
+});
+
+/**
+ * Routes that can be requested max 10 times per minute by the same IP
+ */
+Route::group(['middleware' => ['rejectIfDemoMode', 'throttle:10,1']], function () {
     Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
 });
 

+ 1 - 1
tests/Feature/Http/Auth/WebAuthnDeviceLostControllerTest.php

@@ -36,7 +36,7 @@ class WebAuthnDeviceLostControllerTest extends FeatureTestCase
             'email' => $this->user->email,
         ]);
 
-        Notification::assertSentTo($this->user, \DarkGhostHunter\Larapass\Notifications\AccountRecoveryNotification::class);
+        Notification::assertSentTo($this->user, \App\Notifications\WebauthnRecoveryNotification::class);
 
         $response->assertStatus(200)
         ->assertJsonStructure([

Some files were not shown because too many files changed in this diff