mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-22 08:13:11 +01:00
Add WebAuthn authentication
This commit is contained in:
parent
9f574feada
commit
f3c6b9da5b
26
app/Api/v1/Controllers/UserController.php
Normal file
26
app/Api/v1/Controllers/UserController.php
Normal file
@ -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
app/Extensions/EloquentTwoFAuthProvider.php
Normal file
31
app/Extensions/EloquentTwoFAuthProvider.php
Normal file
@ -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,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\v1\Controllers\Auth;
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
@ -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;
|
@ -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 @@ public function register(UserStoreRequest $request)
|
||||
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,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\v1\Controllers\Auth;
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
@ -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
app/Http/Controllers/Auth/WebAuthnConfirmController.php
Normal file
41
app/Http/Controllers/Auth/WebAuthnConfirmController.php
Normal file
@ -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
app/Http/Controllers/Auth/WebAuthnDeviceLostController.php
Normal file
71
app/Http/Controllers/Auth/WebAuthnDeviceLostController.php
Normal file
@ -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
app/Http/Controllers/Auth/WebAuthnLoginController.php
Normal file
79
app/Http/Controllers/Auth/WebAuthnLoginController.php
Normal file
@ -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
app/Http/Controllers/Auth/WebAuthnManageController.php
Normal file
78
app/Http/Controllers/Auth/WebAuthnManageController.php
Normal file
@ -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
app/Http/Controllers/Auth/WebAuthnRecoveryController.php
Normal file
91
app/Http/Controllers/Auth/WebAuthnRecoveryController.php
Normal file
@ -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
app/Http/Controllers/Auth/WebAuthnRegisterController.php
Normal file
32
app/Http/Controllers/Auth/WebAuthnRegisterController.php
Normal file
@ -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,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\v1\Requests;
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\v1\Requests;
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
@ -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
app/Http/Requests/WebauthnRenameRequest.php
Normal file
31
app/Http/Requests/WebauthnRenameRequest.php
Normal file
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
@ -9,9 +9,13 @@
|
||||
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 @@ public function setEmailAttribute($value)
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,10 @@
|
||||
|
||||
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 @@ public function boot()
|
||||
{
|
||||
$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())
|
||||
|
@ -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"
|
||||
|
923
composer.lock
generated
923
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -46,6 +46,8 @@
|
||||
'useEncryption' => false,
|
||||
'defaultCaptureMode' => 'livescan',
|
||||
'useDirectCapture' => false,
|
||||
'useWebauthnAsDefault' => false,
|
||||
'useWebauthnOnly' => false,
|
||||
],
|
||||
|
||||
];
|
@ -67,14 +67,9 @@
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'driver' => 'eloquent-2fauth',
|
||||
'model' => App\Models\User::class,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
@ -99,6 +94,14 @@
|
||||
'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
config/larapass.php
Normal file
174
config/larapass.php
Normal file
@ -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
|
||||
];
|
@ -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');
|
||||
}
|
||||
}
|
@ -34,6 +34,10 @@
|
||||
'name' : this.$t('settings.oauth'),
|
||||
'view' : 'settings.oauth'
|
||||
},
|
||||
{
|
||||
'name' : this.$t('settings.webauthn'),
|
||||
'view' : 'settings.webauthn'
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
|
113
resources/js/mixins.js
vendored
113
resources/js/mixins.js
vendored
@ -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);
|
||||
},
|
||||
}
|
||||
|
||||
})
|
4
resources/js/packages/fontawesome.js
vendored
4
resources/js/packages/fontawesome.js
vendored
@ -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,
|
||||
);
|
||||
|
9
resources/js/routes.js
vendored
9
resources/js/routes.js
vendored
@ -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 },
|
||||
|
@ -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>
|
||||
|
@ -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') }} <router-link :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
|
||||
<p>{{ $t('auth.forms.forgot_your_password') }} <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') }} <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') }} <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') }} <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') }} <router-link :to="{ name: 'password.request' }" class="is-link">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
|
||||
<p >{{ $t('auth.sign_in_using') }} <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')
|
||||
|
||||
|
@ -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') }} <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') }} <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') }} <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
resources/js/views/auth/webauthn/Lost.vue
Normal file
53
resources/js/views/auth/webauthn/Lost.vue
Normal file
@ -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
resources/js/views/auth/webauthn/Recover.vue
Normal file
132
resources/js/views/auth/webauthn/Recover.vue
Normal file
@ -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') }} <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
resources/js/views/settings/Credentials/Edit.vue
Normal file
52
resources/js/views/settings/Credentials/Edit.vue
Normal file
@ -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>
|
@ -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
resources/js/views/settings/WebAuthn.vue
Normal file
177
resources/js/views/settings/WebAuthn.vue
Normal file
@ -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>
|
@ -21,11 +21,50 @@
|
||||
// 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 @@
|
||||
'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 @@
|
||||
'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',
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -21,6 +21,8 @@
|
||||
'profile' => 'Profile',
|
||||
'edit' => 'Edit',
|
||||
'delete' => 'Delete',
|
||||
'disable' => 'Disable',
|
||||
'enable' => 'Enable',
|
||||
'create' => 'Create',
|
||||
'save' => 'Save',
|
||||
'close' => 'Close',
|
||||
@ -34,9 +36,11 @@
|
||||
'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',
|
||||
];
|
@ -31,4 +31,8 @@
|
||||
'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',
|
||||
];
|
@ -16,6 +16,7 @@
|
||||
'settings' => 'Settings',
|
||||
'account' => 'Account',
|
||||
'oauth' => 'OAuth',
|
||||
'webauthn' => 'WebAuthn',
|
||||
'tokens' => 'Tokens',
|
||||
'options' => 'Options',
|
||||
'confirm' => [
|
||||
@ -30,6 +31,7 @@
|
||||
'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?',
|
||||
],
|
||||
|
@ -14,12 +14,7 @@
|
||||
*/
|
||||
|
||||
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::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');
|
||||
|
@ -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');
|
Loading…
Reference in New Issue
Block a user