浏览代码

Add WebAuthn authentication

Bubka 3 年之前
父节点
当前提交
f3c6b9da5b
共有 43 个文件被更改,包括 2157 次插入183 次删除
  1. 26 0
      app/Api/v1/Controllers/UserController.php
  2. 31 0
      app/Extensions/EloquentTwoFAuthProvider.php
  3. 1 1
      app/Http/Controllers/Auth/ForgotPasswordController.php
  4. 2 2
      app/Http/Controllers/Auth/PasswordController.php
  5. 5 2
      app/Http/Controllers/Auth/RegisterController.php
  6. 1 1
      app/Http/Controllers/Auth/ResetPasswordController.php
  7. 3 19
      app/Http/Controllers/Auth/UserController.php
  8. 41 0
      app/Http/Controllers/Auth/WebAuthnConfirmController.php
  9. 71 0
      app/Http/Controllers/Auth/WebAuthnDeviceLostController.php
  10. 79 0
      app/Http/Controllers/Auth/WebAuthnLoginController.php
  11. 78 0
      app/Http/Controllers/Auth/WebAuthnManageController.php
  12. 91 0
      app/Http/Controllers/Auth/WebAuthnRecoveryController.php
  13. 32 0
      app/Http/Controllers/Auth/WebAuthnRegisterController.php
  14. 1 1
      app/Http/Requests/UserPatchPwdRequest.php
  15. 1 1
      app/Http/Requests/UserStoreRequest.php
  16. 1 1
      app/Http/Requests/UserUpdateRequest.php
  17. 31 0
      app/Http/Requests/WebauthnRenameRequest.php
  18. 35 1
      app/Models/User.php
  19. 22 1
      app/Providers/AuthServiceProvider.php
  20. 4 3
      composer.json
  21. 531 86
      composer.lock
  22. 2 0
      config/2fauth.php
  23. 9 6
      config/auth.php
  24. 174 0
      config/larapass.php
  25. 61 0
      database/migrations/2021_12_03_220140_create_web_authn_tables.php
  26. 4 0
      resources/js/components/SettingTabs.vue
  27. 112 1
      resources/js/mixins.js
  28. 2 2
      resources/js/packages/fontawesome.js
  29. 8 1
      resources/js/routes.js
  30. 4 4
      resources/js/views/Groups.vue
  31. 83 18
      resources/js/views/auth/Login.vue
  32. 104 17
      resources/js/views/auth/Register.vue
  33. 53 0
      resources/js/views/auth/webauthn/Lost.vue
  34. 132 0
      resources/js/views/auth/webauthn/Recover.vue
  35. 52 0
      resources/js/views/settings/Credentials/Edit.vue
  36. 14 4
      resources/js/views/settings/OAuth.vue
  37. 177 0
      resources/js/views/settings/WebAuthn.vue
  38. 41 1
      resources/lang/en/auth.php
  39. 4 0
      resources/lang/en/commons.php
  40. 4 0
      resources/lang/en/errors.php
  41. 2 0
      resources/lang/en/settings.php
  42. 2 9
      routes/api/v1.php
  43. 26 1
      routes/web.php

+ 26 - 0
app/Api/v1/Controllers/UserController.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Api\v1\Controllers;
+
+use App\Models\User;
+use App\Api\v1\Requests\UserUpdateRequest;
+use App\Api\v1\Resources\UserResource;
+use App\Http\Controllers\Controller;
+
+class UserController extends Controller
+{
+    /**
+     * Get detailed information about a user
+     * 
+     * @return \App\Api\v1\Resources\UserResource
+     */
+    public function show()
+    {
+        $user = User::first();
+
+        return $user
+            ? new UserResource($user)
+            : response()->json(['name' => null], 200);
+
+    }
+}

+ 31 - 0
app/Extensions/EloquentTwoFAuthProvider.php

@@ -0,0 +1,31 @@
+<?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 Facades\App\Services\SettingService;
+
+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 = !SettingService::get('useWebauthnOnly');
+    }
+}

+ 1 - 1
app/Api/v1/Controllers/Auth/ForgotPasswordController.php → app/Http/Controllers/Auth/ForgotPasswordController.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Api\v1\Controllers\Auth;
+namespace App\Http\Controllers\Auth;
 
 use Illuminate\Http\Request;
 use App\Http\Controllers\Controller;

+ 2 - 2
app/Api/v1/Controllers/Auth/PasswordController.php → app/Http/Controllers/Auth/PasswordController.php

@@ -1,8 +1,8 @@
 <?php
 
-namespace App\Api\v1\Controllers\Auth;
+namespace App\Http\Controllers\Auth;
 
-use App\Api\v1\Requests\UserPatchPwdRequest;
+use App\Http\Requests\UserPatchPwdRequest;
 use App\Http\Controllers\Controller;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;

+ 5 - 2
app/Api/v1/Controllers/Auth/RegisterController.php → app/Http/Controllers/Auth/RegisterController.php

@@ -1,11 +1,12 @@
 <?php
 
-namespace App\Api\v1\Controllers\Auth;
+namespace App\Http\Controllers\Auth;
 
 use App\Models\User;
-use App\Api\v1\Requests\UserStoreRequest;
+use App\Http\Requests\UserStoreRequest;
 use App\Http\Controllers\Controller;
 use Illuminate\Support\Facades\Hash;
+// use Illuminate\Support\Facades\Auth;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Foundation\Auth\RegistersUsers;
 
@@ -37,6 +38,8 @@ class RegisterController extends Controller
         event(new Registered($user = $this->create($validated)));
 
         $this->guard()->login($user);
+        // $this->guard()->loginUsingId($user->id);
+        // Auth::guard('admin')->attempt($credentials);
 
         return response()->json([
             'message' => 'account created',

+ 1 - 1
app/Api/v1/Controllers/Auth/ResetPasswordController.php → app/Http/Controllers/Auth/ResetPasswordController.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Api\v1\Controllers\Auth;
+namespace App\Http\Controllers\Auth;
 
 use Illuminate\Http\Request;
 use App\Http\Controllers\Controller;

+ 3 - 19
app/Api/v1/Controllers/Auth/UserController.php → app/Http/Controllers/Auth/UserController.php

@@ -1,32 +1,16 @@
 <?php
 
-namespace App\Api\v1\Controllers\Auth;
+namespace App\Http\Controllers\Auth;
 
-use App\Models\User;
-use App\Api\v1\Requests\UserUpdateRequest;
+use App\Http\Requests\UserUpdateRequest;
 use App\Api\v1\Resources\UserResource;
 use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;
 
 class UserController extends Controller
 {
-    /**
-     * Get detailed information about a user
-     * 
-     * @return \App\Api\v1\Resources\UserResource
-     */
-    public function show()
-    {
-        $user = User::first();
-
-        return $user
-            ? new UserResource($user)
-            : response()->json(['name' => null], 200);
-
-    }
-
-
     /**
      * Update the user's profile information.
      *

+ 41 - 0
app/Http/Controllers/Auth/WebAuthnConfirmController.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use DarkGhostHunter\Larapass\Http\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.
+    |
+    */
+
+    /**
+     * Where to redirect users when the intended url fails.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('auth');
+        $this->middleware('throttle:10,1')->only('options', 'confirm');
+    }
+}

+ 71 - 0
app/Http/Controllers/Auth/WebAuthnDeviceLostController.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+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.
+    |
+    */
+
+    public function __construct()
+    {
+        // $this->middleware('guest');
+    }
+
+
+    /**
+     * The recovery credentials to retrieve through validation rules.
+     *
+     * @return array|string[]
+     */
+    protected function recoveryRules(): array
+    {
+        return [
+            'email' => 'required|exists:users,email',
+        ];
+    }
+
+
+    /**
+     * Get the response for a successful account recovery link.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $response
+     *
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
+     */
+    protected function sendRecoveryLinkResponse(Request $request, string $response)
+    {
+        return response()->json(['message' => __('auth.webauthn.account_recovery_email_sent')]);
+    }
+
+
+    /**
+     * Get the response for a failed account recovery link.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $response
+     *
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
+     * @throws \Illuminate\Validation\ValidationException
+     */
+    protected function sendRecoveryLinkFailedResponse(Request $request, string $response)
+    {
+        throw ValidationException::withMessages(['email' => [trans($response)]]);
+    }
+}

+ 79 - 0
app/Http/Controllers/Auth/WebAuthnLoginController.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Models\User;
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn;
+
+class WebAuthnLoginController extends Controller
+{
+    // use AuthenticatesWebAuthn;
+    use AuthenticatesWebAuthn {
+		options as traitOptions;
+		login as traitLogin;
+	}
+
+    /*
+    |--------------------------------------------------------------------------
+    | WebAuthn Login Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller allows the WebAuthn user device to request a login and
+    | return the correctly signed challenge. Most of the hard work is done
+    | by your Authentication Guard once the user is attempting to login.
+    |
+    */
+
+    public function __construct()
+    {
+        // $this->middleware(['guest', 'throttle:10,1']);
+    }
+
+
+	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 validated
+        // 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.
+        $user = User::first();
+
+        if (!$user) {
+            return 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
+     *
+     * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
+     */
+    public function login(Request $request)
+    {
+        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
+            if(!$response['userHandle']) {
+                $user = User::getFromCredentialId($request->id);
+                $response['userHandle'] = base64_encode($user->userHandle());
+                $request->merge(['response' => $response]);
+            }
+        }
+
+        return $this->traitLogin($request);
+    }
+}

+ 78 - 0
app/Http/Controllers/Auth/WebAuthnManageController.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Http\Requests\WebauthnRenameRequest;
+use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
+
+class WebAuthnManageController extends Controller
+{
+    // use RecoversWebAuthn;
+
+    /*
+    |--------------------------------------------------------------------------
+    | WebAuthn Manage Controller
+    |--------------------------------------------------------------------------
+    |
+    |
+    */
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        
+    }
+
+    /**
+     * List all WebAuthn registered credentials
+     */
+    public function index(Request $request)
+    {
+        $user = $request->user();
+        $allUserCredentials = $user->webAuthnCredentials()
+                                    ->enabled()
+                                    ->get()
+                                    ->all();
+
+        return response()->json($allUserCredentials, 200);
+    }
+
+
+    /**
+     * Rename a WebAuthn device
+     * 
+     * @param \App\Http\Requests\WebauthnRenameRequest $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function rename(WebauthnRenameRequest $request, string $credential)
+    {
+        $validated = $request->validated();
+
+        $webAuthnCredential = WebAuthnCredential::where('id', $credential)->firstOrFail();
+        $webAuthnCredential->name = $validated['name'];
+        $webAuthnCredential->save();
+
+        return response()->json([
+            'name' => $webAuthnCredential->name,
+        ], 200);
+    }
+
+    /**
+     * Remove the specified credential from storage.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function delete(Request $request, $credential)
+    {
+        $user = $request->user();
+        $user->removeCredential($credential);
+
+        return response()->json(null, 204);
+    }
+}

+ 91 - 0
app/Http/Controllers/Auth/WebAuthnRecoveryController.php

@@ -0,0 +1,91 @@
+<?php
+
+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 Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+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.
+    |
+    */
+
+    /**
+     * Where to redirect users after resetting their password.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        // $this->middleware('guest');
+        // $this->middleware('throttle:10,1')->only('options', 'recover');
+    }
+
+    /**
+     * Returns the credential creation options to the user.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function options(Request $request): JsonResponse
+    {
+        $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));
+    }
+
+    /**
+     * Get the response for a successful account recovery.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $response
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    protected function sendRecoveryResponse(Request $request, string $response): JsonResponse
+    {
+        return response()->json(['message' => __('auth.webauthn.device_successfully_registered')]);
+    }
+
+    /**
+     * Get the response for a failed account recovery.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $response
+     *
+     * @return \Illuminate\Http\JsonResponse|void
+     * @throws \Illuminate\Validation\ValidationException
+     */
+    protected function sendRecoveryFailedResponse(Request $request, string $response): JsonResponse
+    {
+        throw ValidationException::withMessages(['email' => [trans($response)]]);
+    }
+}

+ 32 - 0
app/Http/Controllers/Auth/WebAuthnRegisterController.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use DarkGhostHunter\Larapass\Http\RegistersWebAuthn;
+
+class WebAuthnRegisterController extends Controller
+{
+    use RegistersWebAuthn;
+
+    /*
+    |--------------------------------------------------------------------------
+    | 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.
+    |
+    */
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        // $this->middleware('auth');
+    }
+}

+ 1 - 1
app/Api/v1/Requests/UserPatchPwdRequest.php → app/Http/Requests/UserPatchPwdRequest.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Api\v1\Requests;
+namespace App\Http\Requests;
 
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Support\Facades\Auth;

+ 1 - 1
app/Api/v1/Requests/UserStoreRequest.php → app/Http/Requests/UserStoreRequest.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Api\v1\Requests;
+namespace App\Http\Requests;
 
 use Illuminate\Foundation\Http\FormRequest;
 

+ 1 - 1
app/Api/v1/Requests/UserUpdateRequest.php → app/Http/Requests/UserUpdateRequest.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Api\v1\Requests;
+namespace App\Http\Requests;
 
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Support\Facades\Auth;

+ 31 - 0
app/Http/Requests/WebauthnRenameRequest.php

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

+ 35 - 1
app/Models/User.php

@@ -9,9 +9,13 @@ 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;
 
-class User extends Authenticatable
+class User extends Authenticatable implements WebAuthnAuthenticatable
 {
+    use WebAuthnAuthentication;
     use HasApiTokens, HasFactory, Notifiable;
 
     /**
@@ -62,4 +66,34 @@ class User extends Authenticatable
     {
         $this->attributes['email'] = strtolower($value);
     }
+
+    /**
+     * Sends a credential recovery email to the user.
+     *
+     * @param  string  $token
+     *
+     * @return void
+     */
+    public function sendCredentialRecoveryNotification(string $token): void
+    {
+        $accountRecoveryNotification = new AccountRecoveryNotification($token);
+        $accountRecoveryNotification->toMailUsing(null);
+
+        $accountRecoveryNotification->createUrlUsing(function($notifiable, $token) {
+            $url = url(
+                route(
+                    'webauthn.recover',
+                    [
+                        'token' => $token,
+                        'email' => $notifiable->getEmailForPasswordReset(),
+                    ],
+                    false
+                )
+            );
+
+            return $url;
+        });
+
+        $this->notify($accountRecoveryNotification);
+    }
 }

+ 22 - 1
app/Providers/AuthServiceProvider.php

@@ -4,7 +4,10 @@ namespace App\Providers;
 
 use Illuminate\Support\Facades\Gate;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
-use Laravel\Passport\Passport;
+use Illuminate\Support\Facades\Auth;
+use App\Extensions\EloquentTwoFAuthProvider;
+use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator;
+use Illuminate\Contracts\Hashing\Hasher;
 
 class AuthServiceProvider extends ServiceProvider
 {
@@ -26,6 +29,24 @@ class AuthServiceProvider extends ServiceProvider
     {
         $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']
+                );
+            });
+
+
         // 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())

+ 4 - 3
composer.json

@@ -9,15 +9,16 @@
     "license": "MIT",
     "require": {
         "php": "^7.4|^8.0",
+        "chillerlan/php-qrcode": "^4.3",
+        "darkghosthunter/larapass": "^3.0.2",
+        "doctrine/dbal": "^3.2",
         "fruitcake/laravel-cors": "^2.0",
         "guzzlehttp/guzzle": "^7.0.1",
+        "khanamiryan/qrcode-detector-decoder": "^1.0.5",
         "laravel/framework": "^8.0",
         "laravel/passport": "^10.0",
         "laravel/tinker": "^2.5",
         "laravel/ui": "^3.0",
-        "chillerlan/php-qrcode": "^4.3",
-        "doctrine/dbal": "^3.2",
-        "khanamiryan/qrcode-detector-decoder": "^1.0.5",
         "paragonie/constant_time_encoding": "^2.4",
         "spatie/eloquent-sortable": "^3.11",
         "spomky-labs/otphp": "^10.0"

文件差异内容过多而无法显示
+ 531 - 86
composer.lock


+ 2 - 0
config/2fauth.php

@@ -46,6 +46,8 @@ return [
         'useEncryption' => false,
         'defaultCaptureMode' => 'livescan',
         'useDirectCapture' => false,
+        'useWebauthnAsDefault' => false,
+        'useWebauthnOnly' => false,
     ],
 
 ];

+ 9 - 6
config/auth.php

@@ -67,14 +67,9 @@ return [
 
     'providers' => [
         'users' => [
-            'driver' => 'eloquent',
+            'driver' => 'eloquent-2fauth',
             'model' => App\Models\User::class,
         ],
-
-        // 'users' => [
-        //     'driver' => 'database',
-        //     'table' => 'users',
-        // ],
     ],
 
     /*
@@ -99,6 +94,14 @@ return [
             'expire' => 60,
             'throttle' => 60,
         ],
+
+        // for WebAuthn
+        'webauthn' => [
+            'provider' => 'users', // The user provider using WebAuthn.
+            'table' => 'web_authn_recoveries', // The table to store the recoveries.
+            'expire' => 60,
+            'throttle' => 60,
+        ],
     ],
 
     /*

+ 174 - 0
config/larapass.php

@@ -0,0 +1,174 @@
+<?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_LOGIN_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
+];

+ 61 - 0
database/migrations/2021_12_03_220140_create_web_authn_tables.php

@@ -0,0 +1,61 @@
+<?php
+
+use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateWebAuthnTables extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('web_authn_credentials', function (Blueprint $table) {
+
+            $table->string('id', 255);
+
+            // Change accordingly for your users table if you need to.
+            $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);
+
+            // This saves the external "ID" that identifies the user. We use UUID default
+            // since it's very straightforward. You can change this for a plain string.
+            // It must be nullable because those old U2F keys do not use user handle.
+            $table->uuid('user_handle')->nullable();
+
+            $table->timestamps();
+            $table->softDeletes(WebAuthnCredential::DELETED_AT);
+
+            $table->primary(['id', 'user_id']);
+        });
+
+        Schema::create('web_authn_recoveries', function (Blueprint $table) {
+            $table->string('email')->index();
+            $table->string('token');
+            $table->timestamp('created_at')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('web_authn_credentials');
+        Schema::dropIfExists('web_authn_recoveries');
+    }
+}

+ 4 - 0
resources/js/components/SettingTabs.vue

@@ -34,6 +34,10 @@
                 		'name' : this.$t('settings.oauth'),
                         'view' : 'settings.oauth'
                 	},
+                	{
+                		'name' : this.$t('settings.webauthn'),
+                        'view' : 'settings.webauthn'
+                	},
             	]
             }
         },

+ 112 - 1
resources/js/mixins.js

@@ -38,7 +38,118 @@ Vue.mixin({
             const a = document.createElement('a')
             a.setAttribute('href', uri)
             a.dispatchEvent(new MouseEvent("click", {'view': window, 'bubbles': true, 'cancelable': true}))
-        }
+        },
+
+        /**
+         * 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);
+        },
     }
 
 })

+ 2 - 2
resources/js/packages/fontawesome.js

@@ -9,7 +9,6 @@ import {
     faQrcode,
     faImage,
     faTrash,
-    faEdit,
     faCheck,
     faLock,
     faLockOpen,
@@ -21,6 +20,7 @@ import {
     faLayerGroup,
     faMinusCircle,
     faExclamationCircle,
+    faPenSquare,
     faTh,
     faList,
 } from '@fortawesome/free-solid-svg-icons'
@@ -35,7 +35,6 @@ library.add(
     faQrcode,
     faImage,
     faTrash,
-    faEdit,
     faCheck,
     faLock,
     faLockOpen,
@@ -48,6 +47,7 @@ library.add(
     faLayerGroup,
     faMinusCircle,
     faExclamationCircle,
+    faPenSquare,
     faTh,
     faList,
 );

+ 8 - 1
resources/js/routes.js

@@ -16,9 +16,13 @@ import Login            from './views/auth/Login'
 import Register         from './views/auth/Register'
 import PasswordRequest  from './views/auth/password/Request'
 import PasswordReset    from './views/auth/password/Reset'
+import WebauthnLost     from './views/auth/webauthn/Lost'
+import WebauthnRecover     from './views/auth/webauthn/Recover'
 import SettingsOptions  from './views/settings/Options'
 import SettingsAccount  from './views/settings/Account'
 import SettingsOAuth    from './views/settings/OAuth'
+import SettingsWebAuthn from './views/settings/WebAuthn'
+import EditCredential   from './views/settings/Credentials/Edit'
 import GeneratePAT      from './views/settings/PATokens/Create'
 import Errors           from './views/Error'
 
@@ -40,13 +44,16 @@ const router = new Router({
         { path: '/settings/options', name: 'settings.options', component: SettingsOptions, meta: { requiresAuth: true } },
         { path: '/settings/account', name: 'settings.account', component: SettingsAccount, meta: { requiresAuth: true } },
         { path: '/settings/oauth', name: 'settings.oauth', component: SettingsOAuth, meta: { requiresAuth: true } },
+        { path: '/settings/webauthn/:credentialId/edit', name: 'editCredential', component: EditCredential, meta: { requiresAuth: true }, props: true },
+        { path: '/settings/webauthn', name: 'settings.webauthn', component: SettingsWebAuthn, meta: { requiresAuth: true } },
         { path: '/settings/oauth/pat/create', name: 'settings.oauth.generatePAT', component: GeneratePAT, meta: { requiresAuth: true } },
 
         { path: '/login', name: 'login', component: Login },
         { path: '/register', name: 'register', component: Register },
         { path: '/password/request', name: 'password.request', component: PasswordRequest },
         { path: '/password/reset/:token', name: 'password.reset', component: PasswordReset },
-
+        { path: '/webauthn/lost', name: 'webauthn.lost', component: WebauthnLost },
+        { path: '/webauthn/recover', name: 'webauthn.recover', component: WebauthnRecover },
         { path: '/flooded', name: 'flooded',component: Errors,props: true },
         { path: '/error', name: 'genericError',component: Errors,props: true },
         { path: '/404', name: '404',component: Errors,props: true },

+ 4 - 4
resources/js/views/Groups.vue

@@ -16,12 +16,12 @@
                 <div v-for="group in groups" :key="group.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
                     {{ group.name }}
                     <!-- delete icon -->
-                    <a class="has-text-grey is-pulled-right" @click="deleteGroup(group.id)">
-                        <font-awesome-icon :icon="['fas', 'trash']" />
+                    <a class="tag is-dark is-pulled-right" @click="deleteGroup(group.id)"  :title="$t('commons.delete')">
+                        {{ $t('commons.delete') }}
                     </a>
                     <!-- edit link -->
-                    <router-link :to="{ name: 'editGroup', params: { id: group.id, name: group.name }}" class="tag is-dark">
-                        {{ $t('commons.rename') }}
+                    <router-link :to="{ name: 'editGroup', params: { id: group.id, name: group.name }}" class="has-text-grey pl-1" :title="$t('commons.rename')">
+                        <font-awesome-icon :icon="['fas', 'pen-square']" />
                     </router-link>
                     <span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey">{{ group.twofaccounts_count }} {{ $t('twofaccounts.accounts') }}</span>
                 </div>

+ 83 - 18
resources/js/views/auth/Login.vue

@@ -1,14 +1,33 @@
 <template>
-    <form-wrapper :title="$t('auth.forms.login')" :punchline="punchline" v-if="username">
-        <div v-if="isDemo" class="notification is-info has-text-centered" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
-        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
-            <form-field :form="form" fieldName="password" inputType="password" :label="$t('auth.forms.password')" />
-            <form-buttons :isBusy="form.isBusy" :caption="$t('auth.sign_in')" />
-        </form>
-        <p v-if=" !username ">{{ $t('auth.forms.dont_have_account_yet') }}&nbsp;<router-link :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
-        <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link :to="{ name: 'password.request' }" class="is-link">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
-    </form-wrapper>
+    <div v-if="username">
+        <!-- webauthn authentication -->
+        <form-wrapper v-if="showWebauthn" :title="$t('auth.forms.login')" :punchline="punchline">
+            <div class="field">
+                {{ $t('auth.webauthn.use_security_device_to_sign_in') }}
+            </div>
+            <div class="control">
+                <button type="button" class="button is-link" @click="webauthnLogin">{{ $t('auth.sign_in') }}</button>
+            </div>
+            <p>{{ $t('auth.webauthn.lost_your_device') }}&nbsp;<router-link :to="{ name: 'webauthn.lost' }" class="is-link">{{ $t('auth.webauthn.recover_your_account') }}</router-link></p>
+            <p v-if="!this.$root.appSettings.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp;<a class="is-link" @click="showWebauthn = false">{{ $t('auth.login_and_password') }}</a></p>
+        </form-wrapper>
+        <!-- login/password legacy form -->
+        <form-wrapper v-else :title="$t('auth.forms.login')" :punchline="punchline">
+            <div v-if="isDemo" class="notification is-info has-text-centered" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
+            <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
+                <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
+                <form-field :form="form" fieldName="password" inputType="password" :label="$t('auth.forms.password')" />
+                <form-buttons :isBusy="form.isBusy" :caption="$t('auth.sign_in')" />
+            </form>
+            <div v-if="!username">
+                <p>{{ $t('auth.forms.dont_have_account_yet') }}&nbsp;<router-link :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
+            </div>
+            <div v-else>
+                <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link :to="{ name: 'password.request' }" class="is-link">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
+                <p >{{ $t('auth.sign_in_using') }}&nbsp;<a class="is-link" @click="showWebauthn = true">{{ $t('auth.webauthn.security_device') }}</a></p>
+            </div>
+        </form-wrapper>
+    </div>
 </template>
 
 <script>
@@ -23,7 +42,9 @@
                 form: new Form({
                     email: '',
                     password: ''
-                })
+                }),
+                isBusy: false,
+                showWebauthn: this.$root.appSettings.useWebauthnAsDefault || this.$root.appSettings.useWebauthnOnly,
             }
         },
 
@@ -34,6 +55,9 @@
         },
 
         methods : {
+            /**
+             * Sign in using the login/password form
+             */
             handleSubmit(e) {
                 e.preventDefault()
 
@@ -44,22 +68,63 @@
                 .catch(error => {
                     if( error.response.status === 401 ) {
 
-                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.password_do_not_match'), duration:-1 })
+                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
                     }
                     else if( error.response.status !== 422 ) {
 
                         this.$router.push({ name: 'genericError', params: { err: error.response } });
                     }
                 });
-            }
-            
+            },
+
+            /**
+             * Sign in using the WebAuthn API
+             */
+            async webauthnLogin() {
+                this.isBusy = false
+
+                // 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 loginOptions = await this.axios.post('/webauthn/login/options').then(res => res.data)
+                const publicKey = this.parseIncomingServerOptions(loginOptions)
+                const credentials = await navigator.credentials.get({ publicKey: publicKey })
+                .catch(error => {
+                    this.$notify({ type: 'is-danger', text: this.$t('auth.webauthn.unknown_device') })
+                })
+
+                if (!credentials) return false
+
+                const publicKeyCredential = this.parseOutgoingCredentials(credentials)
+
+                this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
+                    this.$router.push({ name: 'accounts', params: { toRefresh: true } })
+                })
+                .catch(error => {
+                    if( error.response.status === 401 ) {
+
+                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
+                    }
+                    else if( error.response.status !== 422 ) {
+
+                        this.$router.push({ name: 'genericError', params: { err: error.response } });
+                    }
+                });
+
+                this.isBusy = false
+            },
         },
 
         beforeRouteEnter (to, from, next) {
-            // if (localStorage.getItem('jwt')) {
-            //     return next('/');
-            // }
-
             next(async vm => {
                 const { data } = await vm.axios.get('api/v1/user/name')
 

+ 104 - 17
resources/js/views/auth/Register.vue

@@ -1,14 +1,37 @@
 <template>
-    <form-wrapper :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
-        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-            <form-field :form="form" fieldName="name" inputType="text" :label="$t('auth.forms.name')" autofocus />
-            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" />
-            <form-field :form="form" fieldName="password" inputType="password" :label="$t('auth.forms.password')" />
-            <form-field :form="form" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_password')" />
-            <form-buttons :isBusy="form.isBusy" :isDisabled="form.isDisabled" :caption="$t('auth.register')" />
-        </form>
-        <p>{{ $t('auth.forms.already_register') }}&nbsp;<router-link :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</router-link></p>
-    </form-wrapper>
+    <div>
+        <!-- webauthn registration -->
+        <form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enforce_security_using_webauthn')">
+            <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="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)">
+                    <form-field :form="deviceForm" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
+                    <form-buttons :isBusy="deviceForm.isBusy" :isDisabled="deviceForm.isDisabled" :caption="$t('commons.continue')" />
+                </form>
+            </div>
+            <div v-else class="field is-grouped">
+                <!-- register button -->
+                <div class="control">
+                    <button type="button" @click="registerWebauthnDevice()" class="button is-link">{{ $t('auth.webauthn.register_a_new_device') }}</button>
+                </div>
+                <!-- dismiss button -->
+                <div class="control">
+                    <router-link :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-text">{{ $t('auth.maybe_later') }}</router-link>
+                </div>
+            </div>
+        </form-wrapper>
+        <!-- User registration form -->
+        <form-wrapper v-else :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
+            <form @submit.prevent="handleRegisterSubmit" @keydown="registerForm.onKeydown($event)">
+                <form-field :form="registerForm" fieldName="name" inputType="text" :label="$t('auth.forms.name')" autofocus />
+                <form-field :form="registerForm" fieldName="email" inputType="email" :label="$t('auth.forms.email')" />
+                <form-field :form="registerForm" fieldName="password" inputType="password" :label="$t('auth.forms.password')" />
+                <form-field :form="registerForm" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_password')" />
+                <form-buttons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :caption="$t('auth.register')" />
+            </form>
+            <p>{{ $t('auth.forms.already_register') }}&nbsp;<router-link :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</router-link></p>
+        </form-wrapper>
+    </div>
 </template>
 
 <script>
@@ -18,25 +41,33 @@
     export default {
         data(){
             return {
-                form: new Form({
+                registerForm: new Form({
                     name : '',
                     email : '',
                     password : '',
                     password_confirmation : '',
-                })
+                }),
+                deviceForm: new Form({
+                    name : '',
+                }),
+                showWebauthnRegistration: false,
+                deviceRegistered: false,
+                deviceId : null,
             }
         },
 
         methods : {
-            async handleSubmit(e) {
+            /**
+             * Register a new user
+             */
+            async handleRegisterSubmit(e) {
                 e.preventDefault()
 
-                this.form.post('/api/v1/user', {returnError: true})
+                this.registerForm.post('/user', {returnError: true})
                 .then(response => {
-                    this.$router.push({ name: 'accounts', params: { toRefresh: true } })
+                    this.showWebauthnRegistration = true
                 })
                 .catch(error => {
-                    console.log(error.response)
                     if( error.response.status === 422 && error.response.data.errors.name ) {
 
                         this.$notify({ type: 'is-danger', text: this.$t('errors.already_one_user_registered') + ' ' + this.$t('errors.cannot_register_more_user'), duration:-1 })
@@ -46,7 +77,63 @@
                         this.$router.push({ name: 'genericError', params: { err: error.response } });
                     }
                 });
-            }
+            },
+
+
+            /**
+             * Register a new security device
+             */
+            async registerWebauthnDevice() {
+                // 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/register/options').then(res => res.data)
+                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') })
+                    }
+                    else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
+                        this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
+                    }
+                    return false
+                }
+
+                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
+
+                this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
+                    this.deviceId = publicKeyCredential.id
+                    this.deviceRegistered = true
+                })
+            },
+
+
+            /**
+             * Rename the registered device
+             */
+            async handleDeviceSubmit(e) {
+
+                await this.deviceForm.patch('/webauthn/credentials/' + this.deviceId + '/name')
+
+                if( this.deviceForm.errors.any() === false ) {
+                    this.$router.push({name: 'accounts', params: { toRefresh: true }})
+                }
+            },
+
         },
 
         beforeRouteLeave (to, from, next) {

+ 53 - 0
resources/js/views/auth/webauthn/Lost.vue

@@ -0,0 +1,53 @@
+<template>
+    <form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recovery_punchline')">
+        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
+            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
+            <form-buttons :isBusy="form.isBusy" :caption="$t('auth.webauthn.send_recovery_link')" :showCancelButton="true" cancelLandingView="login" />
+        </form>
+    </form-wrapper>
+</template>
+
+<script>
+
+    import Form from './../../../components/Form'
+
+    export default {
+        data(){
+            return {
+                form: new Form({
+                    email: '',
+                })
+            }
+        },
+        methods : {
+            handleSubmit(e) {
+                e.preventDefault()
+
+                this.form.post('/webauthn/lost', {returnError: true})
+                .then(response => {
+                    
+                    this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
+                })
+                .catch(error => {
+                    if( error.response.data.requestFailed ) {
+
+                        this.$notify({ type: 'is-danger', text: error.response.data.requestFailed, duration:-1 })
+                    }
+                    else if( error.response.status !== 422 ) {
+
+                        this.$router.push({ name: 'genericError', params: { err: error.response } });
+                    }
+                });
+
+            }
+        },
+
+        beforeRouteLeave (to, from, next) {
+            this.$notify({
+                clean: true
+            })
+
+            next()
+        }
+    }
+</script>

+ 132 - 0
resources/js/views/auth/webauthn/Recover.vue

@@ -0,0 +1,132 @@
+<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 for="unique" class="label">{{ $t('auth.webauthn.disable_all_other_devices') }}</label>
+            </div>
+            <div class="field is-grouped">
+                <div class="control">
+                    <a class="button is-link" @click="register()">{{ $t('auth.webauthn.register_a_new_device')}}</a>
+                </div>
+                <div class="control">
+                    <router-link :to="{ name: 'login' }" class="button is-text">{{ $t('commons.cancel') }}</router-link>
+                </div>
+            </div>
+        </div>
+    </form-wrapper>
+</template>
+
+<script>
+
+    import Form from './../../../components/Form'
+
+    export default {
+        data(){
+            return {
+                email : '',
+                token: '',
+                unique: false,
+                deviceRegistered: false,
+                deviceId : null,
+                form: new Form({
+                    name : '',
+                }),
+            }
+        },
+
+        created () {
+            this.email = this.$route.query.email
+            this.token = this.$route.query.token
+        },
+
+        methods : {
+
+            /**
+             * 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)
+                .catch(error => {
+                    this.$notify({ type: 'is-danger', text: error.response.data.message })
+                });
+
+                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') })
+                    }
+                    else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
+                        this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
+                    }
+                    return false
+                }
+
+                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
+
+                this.axios.post('/webauthn/recover', publicKeyCredential, {
+                    headers: {
+                        email : this.email,
+                        token: this.token,
+                        unique: this.unique,
+                    }
+                }).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) {
+            this.$notify({
+                clean: true
+            })
+
+            next()
+        }
+    }
+</script>

+ 52 - 0
resources/js/views/settings/Credentials/Edit.vue

@@ -0,0 +1,52 @@
+<template>
+    <form-wrapper :title="$t('auth.webauthn.rename_device')">
+        <form @submit.prevent="updateCredential" @keydown="form.onKeydown($event)">
+            <form-field :form="form" fieldName="name" inputType="text" :label="$t('commons.new_name')" autofocus />
+            <div class="field is-grouped">
+                <div class="control">
+                    <v-button :isLoading="form.isBusy">{{ $t('commons.save') }}</v-button>
+                </div>
+                <div class="control">
+                    <button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
+                </div>
+            </div>
+        </form>
+    </form-wrapper>
+</template>
+
+<script>
+
+    import Form from './../../../components/Form'
+
+    export default {
+        data() {
+            return {
+                form: new Form({
+                    name: this.name,
+                })
+            }
+        },
+
+        props: ['id', 'name'],
+
+        methods: {
+
+            async updateCredential() {
+
+                await this.form.patch('/webauthn/credentials/' + this.id + '/name')
+
+                if( this.form.errors.any() === false ) {
+                    this.$router.push({name: 'settings.webauthn', params: { toRefresh: true }})
+                }
+
+            },
+
+            cancelCreation: function() {
+
+                this.$router.push({ name: 'settings.webauthn' });
+            },
+
+        },
+
+    }
+</script>

+ 14 - 4
resources/js/views/settings/OAuth.vue

@@ -8,27 +8,37 @@
                     <div class="is-size-7-mobile">
                         {{ $t('settings.token_legend')}}
                     </div>
-                    <div class="mt-3 mb-6">
-                        <router-link class="is-link mt-5" :to="{ name: 'settings.oauth.generatePAT' }">
+                    <div class="mt-3">
+                        <router-link class="is-link" :to="{ name: 'settings.oauth.generatePAT' }">
                             <font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
                         </router-link>
                     </div>
                     <div v-if="tokens.length > 0">
                         <div v-for="token in tokens" :key="token.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
                             <font-awesome-icon v-if="token.value" class="has-text-success" :icon="['fas', 'check']" /> {{ token.name }}
+                            <!-- revoke link -->
                             <div class="tags is-pulled-right">
                                 <a v-if="token.value" class="tag" v-clipboard="() => token.value" v-clipboard:success="clipboardSuccessHandler">{{ $t('commons.copy') }}</a>
-                                <a class="tag is-dark " @click="revokeToken(token.id)">{{ $t('settings.revoke') }}</a>
+                                <a class="tag is-dark " @click="revokeToken(token.id)" :title="$t('settings.revoke')">{{ $t('settings.revoke') }}</a>
                             </div>
+                            <!-- edit link -->
+                            <!-- <router-link :to="{ name: 'settings.oauth.editPAT' }" class="has-text-grey pl-1" :title="$t('commons.edit')">
+                                <font-awesome-icon :icon="['fas', 'pen-square']" />
+                            </router-link> -->
+                            <!-- warning msg -->
                             <span v-if="token.value" class="is-size-7-mobile is-size-6 my-3">
                                 {{ $t('settings.make_sure_copy_token') }}
                             </span>
+                            <!-- token value -->
                             <span v-if="token.value" class="pat is-family-monospace is-size-6 is-size-7-mobile has-text-success">
                                 {{ token.value }}
                             </span>
                         </div>
+                        <div class="mt-2 is-size-7 is-pulled-right">
+                            {{ $t('settings.revoking_a_token_is_permanent')}}
+                        </div>
                     </div>
-                    <div v-if="isFetching && tokens.length === 0" class="has-text-centered">
+                    <div v-if="isFetching && tokens.length === 0" class="has-text-centered mt-6">
                         <span class="is-size-4">
                             <font-awesome-icon :icon="['fas', 'spinner']" spin />
                         </span>

+ 177 - 0
resources/js/views/settings/WebAuthn.vue

@@ -0,0 +1,177 @@
+<template>
+    <div>
+        <setting-tabs :activeTab="'settings.webauthn'"></setting-tabs>
+        <div class="options-tabs">
+            <form-wrapper>
+                <h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4>
+                <div class="is-size-7-mobile">
+                    {{ $t('auth.webauthn.security_devices_legend')}}
+                </div>
+                <div class="mt-3">
+                    <a class="is-link" @click="register()">
+                        <font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('auth.webauthn.register_a_new_device')}}
+                    </a>
+                </div>
+                <!-- credentials list -->
+                <div v-if="credentials.length > 0" class="field">
+                    <div v-for="credential in credentials" :key="credential.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
+                        {{ displayName(credential) }}
+                        <!-- revoke link -->
+                        <a class="tag is-dark is-pulled-right" @click="revokeCredential(credential.id)" :title="$t('settings.revoke')">
+                            {{ $t('settings.revoke') }}
+                        </a>
+                        <!-- edit link -->
+                        <!-- <router-link :to="{ name: '' }" class="has-text-grey pl-1" :title="$t('commons.rename')">
+                            <font-awesome-icon :icon="['fas', 'pen-square']" />
+                        </router-link> -->
+                    </div>
+                    <div class="mt-2 is-size-7 is-pulled-right">
+                        {{ $t('auth.webauthn.revoking_a_device_is_permanent')}}
+                    </div>
+                </div>
+                <div v-if="isFetching && credentials.length === 0" class="has-text-centered mt-6">
+                    <span class="is-size-4">
+                        <font-awesome-icon :icon="['fas', 'spinner']" spin />
+                    </span>
+                </div>
+                <h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.options') }}</h4>
+                <form>
+                    <!-- use webauthn only -->
+                    <form-checkbox v-on:useWebauthnOnly="saveSetting('useWebauthnOnly', $event)" :form="form" fieldName="useWebauthnOnly" :label="$t('auth.webauthn.use_webauthn_only.label')" :help="$t('auth.webauthn.use_webauthn_only.help')" />
+                    <!-- default sign in method -->
+                    <form-checkbox v-on:useWebauthnAsDefault="saveSetting('useWebauthnAsDefault', $event)" :form="form" fieldName="useWebauthnAsDefault" :label="$t('auth.webauthn.use_webauthn_as_default.label')" :help="$t('auth.webauthn.use_webauthn_as_default.help')" />
+                </form>
+                <!-- footer -->
+                <vue-footer :showButtons="true">
+                    <!-- close button -->
+                    <p class="control">
+                        <router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
+                    </p>
+                </vue-footer>
+            </form-wrapper>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    import Form from './../../components/Form'
+
+    export default {
+        data(){
+            return {
+                form: new Form({
+                    useWebauthnOnly: null,
+                    useWebauthnAsDefault: null,
+                }),
+                credentials: [],
+                isFetching: false,
+            }
+        },
+
+        async mounted() {
+            // const { data } = await this.form.get('/api/v1/settings/useWebauthnAsDefault')
+
+            // this.form.useWebauthnAsDefault = data.value
+            const { data } = await this.form.get('/api/v1/settings')
+
+            this.form.fillWithKeyValueObject(data)
+            this.form.setOriginal()
+
+            this.fetchCredentials()
+        },
+
+        methods : {
+
+            /**
+             * Save a setting
+             */
+            saveSetting(settingName, event) {
+                this.axios.put('/api/v1/settings/' + settingName, { value: event }).then(response => {
+                    this.$notify({ type: 'is-success', text: this.$t('settings.forms.setting_saved') })
+                    this.$root.appSettings[response.data.key] = response.data.value
+                })
+            },
+
+
+            /**
+             * Get all credentials from backend
+             */
+            async fetchCredentials() {
+
+                this.isFetching = true
+
+                await this.axios.get('/webauthn/credentials').then(response => {
+                    this.credentials = response.data
+                })
+
+                this.isFetching = false
+            },
+
+
+            /**
+             * 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/register/options').then(res => res.data)
+                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') })
+                    }
+                    else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
+                        this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
+                    }
+                    return false
+                }
+
+                const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
+
+                this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
+                    this.$notify({ type: 'is-success', text: this.$t('auth.webauthn.device_successfully_registered') })
+                    this.$router.push({ name: 'editCredential', params: { id: publicKeyCredential.id, name: this.$t('auth.webauthn.my_device') } })
+                })
+            },
+
+
+            /**
+             * revoke a credential
+             */
+            async revokeCredential(credentialId) {
+                if(confirm(this.$t('auth.confirm.revoke_device'))) {
+
+                    await this.axios.delete('/webauthn/credentials/' + credentialId).then(response => {
+                        // Remove the revoked credential from the collection
+                        this.credentials = this.credentials.filter(a => a.id !== credentialId)
+                        this.$notify({ type: 'is-success', text: this.$t('auth.webauthn.device_revoked') })
+                    });
+                }
+            },
+
+            /**
+             * Always display a printable name
+             */
+            displayName(credential) {
+                return credential.name ? credential.name : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
+            },
+
+        },
+    }
+</script>

+ 41 - 1
resources/lang/en/auth.php

@@ -21,11 +21,50 @@ return [
     // 2FAuth
     'sign_out' => 'Sign out',
     'sign_in' => 'Sign in',
+    'sign_in_using' => 'Sign in using',
+    'login_and_password' => 'login & password',
     'register' => 'Register',
     'welcome_back_x' => 'Welcome back {0}',
     'already_authenticated' => 'Already authenticated',
+    'authentication' => 'Authentication',
+    'maybe_later' => 'Maybe later',
     'confirm' => [
         'logout' => 'Are you sure you want to log out?',
+        'revoke_device' => 'Are you sure you want to revoke this device?',
+    ],
+    'webauthn' => [
+        'security_device' => 'a security device',
+        'security_devices' => 'Security devices',
+        'security_devices_legend' => 'Authentication devices you can use to sign in 2FAuth, like security keys (i.e Yubikey) or smartphones with biometric capabilities (i.e. Apple FaceId/TouchId)',
+        'enforce_security_using_webauthn' => 'You can enforce the security of your 2FAuth account by enabling WebAuthn authentication.<br /><br />
+            WebAuthn allows you to use trusted devices (like Yubikeys or smartphones with biometric capabilities) to sign in quickly and more securely.',
+        'use_security_device_to_sign_in' => 'Get ready to authenticate yourself using (one of) your security devices. Plug your key in, remove face mask or gloves, etc.',
+        '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.',
+        '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',
+        'register_a_new_device' => 'Register a new 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.',
+        'invalid_recovery_token' => 'Invalid recovery token',
+        'rename_device' => 'Rename device',
+        'my_device' => 'My device',
+        'unknown_device' => 'Unknown device',
+        'use_webauthn_only' => [
+            'label' => 'Use WebAuthn only (recommended)',
+            'help' => 'Make WebAuthn the only available method to sign in 2FAuth. This is the recommended setup to take advantage of the WebAuthn enforced security.<br />
+                In case of device lost you will always be able to register a new security device to recover your account.'
+        ],
+        'use_webauthn_as_default' => [
+            'label' => 'Use WebAuthn as default sign in method',
+            'help' => 'Set the 2FAuth sign in form to propose the WebAuthn authentication at first. The Login/password method is then available as an alternative/fallback solution.<br />
+                This has no effect if you only use WebAuthn.'
+        ],
     ],
     'forms' => [
         'name' => 'Name',
@@ -36,7 +75,7 @@ return [
         'confirm_new_password' => 'Confirm new password',
         'dont_have_account_yet' => 'Don\'t have your account yet?',
         'already_register' => 'Already registered?',
-        'password_do_not_match' => 'Password does not match',
+        'authentication_failed' => 'Authentication failed',
         'forgot_your_password' => 'Forgot your password?',
         'request_password_reset' => 'Reset it',
         'reset_password' => 'Reset password',
@@ -54,6 +93,7 @@ return [
         'welcome_to_demo_app_use_those_credentials' => 'Welcome to the 2FAuth demo.<br><br>You can connect using the email address <strong>demo@2fauth.app</strong> and the password <strong>demo</demo>',
         'register_punchline' => 'Welcome to 2FAuth.<br/>You need an account to go further. Fill this form to register yourself, and please, choose a strong password, 2FA data are sensitives.',
         'reset_punchline' => '2FAuth will send you a password reset link to this address. Click the link in the received email to set a new password.',
+        'name_this_device' => 'Name this device',
     ],
 
 ];

+ 4 - 0
resources/lang/en/commons.php

@@ -21,6 +21,8 @@ return [
     'profile' => 'Profile',
     'edit' => 'Edit',
     'delete' => 'Delete',
+    'disable' => 'Disable',
+    'enable' => 'Enable',
     'create' => 'Create',
     'save' => 'Save',
     'close' => 'Close',
@@ -34,9 +36,11 @@ return [
     'move' => 'Move',
     'all' => 'All',
     'rename' => 'Rename',
+    'new_name' => 'New name',
     'options' => 'Options',
     'reload' => 'Reload',
     'some_data_have_changed' => 'Some data have changed. You should',
     'generate' => 'Generate',
     'open_in_browser' => 'Open in browser',
+    'continue' => 'Continue',
 ];

+ 4 - 0
resources/lang/en/errors.php

@@ -31,4 +31,8 @@ return [
     'delete_user_setting_only' => 'Only user-created setting can be deleted',
     'indecipherable' => '*indecipherable*',
     'cannot_decipher_secret' => 'The secret cannot be deciphered. This is mainly caused by a wrong APP_KEY set in the .env configuration file of 2Fauth or a corrupted data stored in database.',
+    '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',
 ];

+ 2 - 0
resources/lang/en/settings.php

@@ -16,6 +16,7 @@ return [
     'settings' => 'Settings',
     'account' => 'Account',
     'oauth' => 'OAuth',
+    'webauthn' => 'WebAuthn',
     'tokens' => 'Tokens',
     'options' => 'Options',
     'confirm' => [
@@ -30,6 +31,7 @@ return [
     'generate_new_token' => 'Generate a new token',
     'revoke' => 'Revoke',
     'token_revoked' => 'Token successfully revoked',
+    'revoking_a_token_is_permanent' => 'Revoking a token is permanent',
     'confirm' => [
         'revoke' => 'Are you sure you want to revoke this token?',
     ],

+ 2 - 9
routes/api/v1.php

@@ -14,12 +14,7 @@ use Illuminate\Http\Request;
 */
 
 Route::group(['middleware' => 'guest:api'], function () {
-
-    Route::get('user/name', 'Auth\UserController@show')->name('user.show.name');
-    Route::post('user', 'Auth\RegisterController@register')->name('user.register');
-    Route::post('user/password/lost', 'Auth\ForgotPasswordController@sendResetLinkEmail')->middleware('AvoidResetPassword')->name('user.password.lost');;
-    Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('user.password.reset');
-
+    Route::get('user/name', 'UserController@show')->name('user.show.name');
 });
 
 Route::group(['middleware' => 'auth:api'], function() {
@@ -28,9 +23,7 @@ Route::group(['middleware' => 'auth:api'], function() {
     Route::post('oauth/personal-access-tokens', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@store')->name('passport.personal.tokens.store');
     Route::delete('oauth/personal-access-tokens/{token_id}', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@destroy')->name('passport.personal.tokens.destroy');
 
-    Route::get('user', 'Auth\UserController@show')->name('user.show');
-    Route::put('user', 'Auth\UserController@update')->name('user.update');
-    Route::patch('user/password', 'Auth\PasswordController@update')->name('user.password.update');
+    Route::get('user', 'UserController@show')->name('user.show');
 
     Route::get('settings/{settingName}', 'SettingController@show')->name('settings.show');
     Route::get('settings', 'SettingController@index')->name('settings.index');

+ 26 - 1
routes/web.php

@@ -1,5 +1,12 @@
 <?php
 
+// use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\Auth\WebAuthnManageController;
+use App\Http\Controllers\Auth\WebAuthnRegisterController;
+use App\Http\Controllers\Auth\WebAuthnLoginController;
+use App\Http\Controllers\Auth\WebAuthnDeviceLostController;
+use App\Http\Controllers\Auth\WebAuthnRecoveryController;
+
 /*
 |--------------------------------------------------------------------------
 | Web Routes
@@ -18,11 +25,29 @@
 // Route::get('twofaccount/{TwoFAccount}', 'TwoFAccountController@show');
 
 Route::group(['middleware' => 'guest:web'], function () {
-    Route::post('user/login', 'Auth\LoginController@login')->name('user.login');
+    Route::post('user', 'Auth\RegisterController@register')->name('user.register');
+    Route::post('user/password/lost', 'Auth\ForgotPasswordController@sendResetLinkEmail')->middleware('AvoidResetPassword')->name('user.password.lost');;
+    Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('user.password.reset');
+    Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost');
+    Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options'])->name('webauthn.recover.options');
+    Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
 });
 
 Route::group(['middleware' => 'auth:web'], function () {
+    Route::put('user', 'Auth\UserController@update')->name('user.update');
+    Route::patch('user/password', 'Auth\PasswordController@update')->name('user.password.update');
     Route::get('user/logout', 'Auth\LoginController@logout')->name('user.logout');
+    Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options');
+    Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register');
+    Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index');
+    Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename');
+    Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete');
+});
+
+Route::group(['middleware' => ['guest:web', 'throttle:10,1']], function () {
+    Route::post('user/login', 'Auth\LoginController@login')->name('user.login');
+    Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
+    Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login');
 });
 
 Route::get('/{any}', 'SinglePageController@index')->where('any', '.*')->name('landing');

部分文件因为文件数量过多而无法显示