mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-07 17:04:34 +01:00
Replace darkghosthunter/larapass with laragear/webauthn
This commit is contained in:
parent
0ac04a321d
commit
017bbc6304
11
.env.example
11
.env.example
@ -107,6 +107,7 @@ MAIL_FROM_ADDRESS=null
|
||||
|
||||
AUTHENTICATION_GUARD=web-guard
|
||||
|
||||
|
||||
# Name of the HTTP headers sent by the reverse proxy that identifies the authenticated user at proxy level.
|
||||
# Check your proxy documentation to find out how these headers are named (i.e 'REMOTE_USER', 'REMOTE_EMAIL', etc...)
|
||||
# (only relevant when AUTHENTICATION_GUARD is set to 'reverse-proxy-guard')
|
||||
@ -114,10 +115,12 @@ AUTHENTICATION_GUARD=web-guard
|
||||
AUTH_PROXY_HEADER_FOR_USER=null
|
||||
AUTH_PROXY_HEADER_FOR_EMAIL=null
|
||||
|
||||
|
||||
# Custom logout URL to open when using an auth proxy.
|
||||
|
||||
PROXY_LOGOUT_URL=null
|
||||
|
||||
|
||||
#### WebAuthn settings ####
|
||||
|
||||
# Relying Party name, aka the name of the application.
|
||||
@ -125,15 +128,19 @@ PROXY_LOGOUT_URL=null
|
||||
|
||||
WEBAUTHN_NAME=2FAuth
|
||||
|
||||
|
||||
# Relying Party ID. If null, the device will fill it internally.
|
||||
# See https://webauthn-doc.spomky-labs.com/pre-requisites/the-relying-party#how-to-determine-the-relying-party-id
|
||||
|
||||
WEBAUTHN_ID=null
|
||||
|
||||
# [DEPRECATED]
|
||||
# Optional image data in BASE64 (128 bytes maximum) or an image url
|
||||
# See https://webauthn-doc.spomky-labs.com/pre-requisites/the-relying-party#relying-party-icon
|
||||
|
||||
WEBAUTHN_ICON=null
|
||||
# WEBAUTHN_ICON=null
|
||||
# [/DEPRECATED]
|
||||
|
||||
|
||||
# Use this setting to control how user verification behave during the
|
||||
# WebAuthn authentication flow.
|
||||
@ -150,6 +157,7 @@ WEBAUTHN_ICON=null
|
||||
|
||||
WEBAUTHN_USER_VERIFICATION=preferred
|
||||
|
||||
|
||||
# Use this setting to declare trusted proxied.
|
||||
# Supported:
|
||||
# '*': to trust any proxy
|
||||
@ -157,6 +165,7 @@ WEBAUTHN_USER_VERIFICATION=preferred
|
||||
|
||||
TRUSTED_PROXIES=null
|
||||
|
||||
|
||||
# Leave the following configuration vars as is.
|
||||
# Unless you like to tinker and know what you're doing.
|
||||
|
||||
|
@ -5,7 +5,7 @@ APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
WEBAUTHN_NAME=TestApp
|
||||
WEBAUTHN_ID=localhost
|
||||
WEBAUTHN_ID=null
|
||||
WEBAUTHN_USER_VERIFICATION=discouraged
|
||||
|
||||
AUTHENTICATION_GUARD=web-guard
|
||||
|
@ -66,8 +66,8 @@ protected function flushDB() : void
|
||||
DB::table('oauth_access_tokens')->delete();
|
||||
DB::table('oauth_personal_access_clients')->delete();
|
||||
DB::table('oauth_refresh_tokens')->delete();
|
||||
DB::table('web_authn_credentials')->delete();
|
||||
DB::table('web_authn_recoveries')->delete();
|
||||
DB::table('webauthn_credentials')->delete();
|
||||
DB::table('webauthn_recoveries')->delete();
|
||||
DB::table('twofaccounts')->delete();
|
||||
DB::table('options')->delete();
|
||||
DB::table('groups')->delete();
|
||||
|
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions;
|
||||
|
||||
use DarkGhostHunter\Larapass\Auth\EloquentWebAuthnProvider;
|
||||
use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigContract;
|
||||
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
|
||||
use App\Facades\Settings;
|
||||
|
||||
class EloquentTwoFAuthProvider extends EloquentWebAuthnProvider
|
||||
{
|
||||
/**
|
||||
* Create a new database user provider.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
* @param \DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator $validator
|
||||
* @param \Illuminate\Contracts\Hashing\Hasher $hasher
|
||||
* @param string $model
|
||||
*/
|
||||
public function __construct(
|
||||
ConfigContract $config,
|
||||
WebAuthnAssertValidator $validator,
|
||||
HasherContract $hasher,
|
||||
string $model
|
||||
) {
|
||||
parent::__construct($config, $validator, $hasher, $model);
|
||||
|
||||
$this->fallback = !Settings::get('useWebauthnOnly');
|
||||
}
|
||||
}
|
66
app/Extensions/WebauthnCredentialBroker.php
Normal file
66
app/Extensions/WebauthnCredentialBroker.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions;
|
||||
|
||||
use Closure;
|
||||
use App\Models\WebAuthnAuthenticatable;
|
||||
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
|
||||
class WebauthnCredentialBroker extends PasswordBroker
|
||||
{
|
||||
/**
|
||||
* Send a password reset link to a user.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param \Closure|null $callback
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function sendResetLink(array $credentials, Closure $callback = null): string
|
||||
{
|
||||
$user = $this->getUser($credentials);
|
||||
|
||||
if (!$user instanceof WebAuthnAuthenticatable) {
|
||||
return static::INVALID_USER;
|
||||
}
|
||||
|
||||
if ($this->tokens->recentlyCreatedToken($user)) {
|
||||
return static::RESET_THROTTLED;
|
||||
}
|
||||
|
||||
$token = $this->tokens->create($user);
|
||||
|
||||
if ($callback) {
|
||||
$callback($user, $token);
|
||||
} else {
|
||||
$user->sendWebauthnRecoveryNotification($token);
|
||||
}
|
||||
|
||||
return static::RESET_LINK_SENT;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset the password for the given token.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param \Closure $callback
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\CanResetPassword|string
|
||||
*/
|
||||
public function reset(array $credentials, Closure $callback)
|
||||
{
|
||||
$user = $this->validateReset($credentials);
|
||||
|
||||
if (!$user instanceof CanResetPasswordContract || !$user instanceof WebAuthnAuthenticatable) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$callback($user);
|
||||
|
||||
$this->tokens->delete($user);
|
||||
|
||||
return static::PASSWORD_RESET;
|
||||
}
|
||||
}
|
@ -39,8 +39,6 @@ public function register(UserStoreRequest $request)
|
||||
Log::info('User created');
|
||||
|
||||
$this->guard()->login($user);
|
||||
// $this->guard()->loginUsingId($user->id);
|
||||
// Auth::guard('admin')->attempt($credentials);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'account created',
|
||||
|
@ -62,8 +62,8 @@ public function delete(UserDeleteRequest $request)
|
||||
DB::table('twofaccounts')->delete();
|
||||
DB::table('groups')->delete();
|
||||
DB::table('options')->delete();
|
||||
DB::table('web_authn_credentials')->delete();
|
||||
DB::table('web_authn_recoveries')->delete();
|
||||
DB::table('webauthn_credentials')->delete();
|
||||
DB::table('webauthn_recoveries')->delete();
|
||||
DB::table('oauth_access_tokens')->delete();
|
||||
DB::table('oauth_auth_codes')->delete();
|
||||
DB::table('oauth_clients')->delete();
|
||||
|
@ -1,30 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
// namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn;
|
||||
// use App\Http\Controllers\Controller;
|
||||
// use App\Providers\RouteServiceProvider;
|
||||
// // use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn;
|
||||
|
||||
class WebAuthnConfirmController extends Controller
|
||||
{
|
||||
use ConfirmsWebAuthn;
|
||||
// class WebAuthnConfirmController extends Controller
|
||||
// {
|
||||
// // use ConfirmsWebAuthn;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Confirm Device Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling WebAuthn confirmations and
|
||||
| uses a simple trait to include the behavior. You're free to explore
|
||||
| this trait and override any functions that require customization.
|
||||
|
|
||||
*/
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | Confirm Device Controller
|
||||
// |--------------------------------------------------------------------------
|
||||
// |
|
||||
// | This controller is responsible for handling WebAuthn confirmations and
|
||||
// | uses a simple trait to include the behavior. You're free to explore
|
||||
// | this trait and override any functions that require customization.
|
||||
// |
|
||||
// */
|
||||
|
||||
/**
|
||||
* Where to redirect users when the intended url fails.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = RouteServiceProvider::HOME;
|
||||
}
|
||||
// /**
|
||||
// * Where to redirect users when the intended url fails.
|
||||
// *
|
||||
// * @var string
|
||||
// */
|
||||
// protected $redirectTo = RouteServiceProvider::HOME;
|
||||
// }
|
@ -3,35 +3,57 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Extensions\WebauthnCredentialBroker;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use App\Http\Requests\WebauthnDeviceLostRequest;
|
||||
|
||||
class WebAuthnDeviceLostController extends Controller
|
||||
{
|
||||
use SendsWebAuthnRecoveryEmail;
|
||||
use ResetsPasswords;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* The recovery credentials to retrieve through validation rules.
|
||||
* Send a recovery email to the user.
|
||||
*
|
||||
* @return array|string[]
|
||||
* @param \App\Http\Requests\WebauthnDeviceLostRequest $request
|
||||
* @param \App\Extensions\WebauthnCredentialBroker $broker
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected function recoveryRules(): array
|
||||
public function sendRecoveryEmail(WebauthnDeviceLostRequest $request, WebauthnCredentialBroker $broker)
|
||||
{
|
||||
return [
|
||||
'email' => 'required|exists:users,email',
|
||||
];
|
||||
$credentials = $request->validated();
|
||||
|
||||
$response = $broker->sendResetLink($credentials);
|
||||
|
||||
return $response === Password::RESET_LINK_SENT
|
||||
? $this->sendRecoveryLinkResponse($request, $response)
|
||||
: $this->sendRecoveryLinkFailedResponse($request, $response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
if ($request->wantsJson()) {
|
||||
throw ValidationException::withMessages(['email' => [trans($response)]]);
|
||||
}
|
||||
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => trans($response)]);
|
||||
}
|
||||
|
||||
|
||||
|
@ -3,20 +3,17 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
class WebAuthnLoginController extends Controller
|
||||
{
|
||||
// use AuthenticatesWebAuthn;
|
||||
use AuthenticatesWebAuthn {
|
||||
options as traitOptions;
|
||||
login as traitLogin;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| WebAuthn Login Controller
|
||||
@ -29,66 +26,75 @@ class WebAuthnLoginController extends Controller
|
||||
*/
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Http\JsonResponse|\Webauthn\PublicKeyCredentialRequestOptions
|
||||
* Returns the challenge to assertion.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Http\Requests\AssertionRequest $request
|
||||
* @return \Illuminate\Contracts\Support\Responsable|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function options(Request $request)
|
||||
{
|
||||
// Since 2FAuth is single user designed we fetch the user instance
|
||||
// and merge its email address to the request. This let Larapass validate
|
||||
// the request against a user instance without the need to ask the visitor
|
||||
// for an email address.
|
||||
//
|
||||
// This approach override the Larapass 'userless' config value that seems buggy.
|
||||
public function options(AssertionRequest $request): Responsable|JsonResponse
|
||||
{
|
||||
switch (env('WEBAUTHN_USER_VERIFICATION')) {
|
||||
case WebAuthn::USER_VERIFICATION_DISCOURAGED:
|
||||
$request = $request->fastLogin(); // Makes the authenticator to only check for user presence on registration
|
||||
break;
|
||||
case WebAuthn::USER_VERIFICATION_REQUIRED:
|
||||
$request = $request->secureLogin(); // Makes the authenticator to always verify the user thoroughly on registration
|
||||
break;
|
||||
}
|
||||
|
||||
// Since 2FAuth is single user designed we fetch the user instance.
|
||||
// This lets Larapass validate the request without the need to ask
|
||||
// the visitor for an email address.
|
||||
$user = User::first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json([
|
||||
return $user
|
||||
? $request->toVerify($user)
|
||||
: response()->json([
|
||||
'message' => 'no registered user'
|
||||
], 400);
|
||||
}
|
||||
else $request->merge(['email' => $user->email]);
|
||||
|
||||
return $this->traitOptions($request);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log the user in.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Http\Requests\AssertedRequest $request
|
||||
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function login(Request $request)
|
||||
public function login(AssertedRequest $request)
|
||||
{
|
||||
Log::info('User login via webauthn requested');
|
||||
$request->validate($this->assertionRules());
|
||||
|
||||
if ($request->has('response')) {
|
||||
$response = $request->response;
|
||||
|
||||
// Some authenticators do not send a userHandle so we hack the response to be compliant
|
||||
// with Larapass/webauthn-lib implementation that wait for a userHandle
|
||||
// with Larapass/webauthn-lib implementation that waits for a userHandle
|
||||
if(!$response['userHandle']) {
|
||||
$user = User::getFromCredentialId($request->id);
|
||||
$response['userHandle'] = base64_encode($user->userHandle());
|
||||
$response['userHandle'] = User::getFromCredentialId($request->id)?->userHandle();
|
||||
$request->merge(['response' => $response]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = $request->login();
|
||||
|
||||
return $this->traitLogin($request);
|
||||
if ($user) {
|
||||
$this->authenticated($user);
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
return response()->noContent(422);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The user has been authenticated.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param mixed $user
|
||||
*
|
||||
* @return void|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function authenticated(Request $request, $user)
|
||||
protected function authenticated($user)
|
||||
{
|
||||
$user->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
|
||||
$user->save();
|
||||
|
@ -6,26 +6,10 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Requests\WebauthnRenameRequest;
|
||||
use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WebAuthnManageController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| WebAuthn Manage Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
/**
|
||||
* List all WebAuthn registered credentials
|
||||
@ -34,34 +18,30 @@ public function __construct()
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$allUserCredentials = $user->webAuthnCredentials()
|
||||
->enabled()
|
||||
->get()
|
||||
->all();
|
||||
$allUserCredentials = $request->user()->webAuthnCredentials()->WhereEnabled()->get();
|
||||
|
||||
return response()->json($allUserCredentials, 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Rename a WebAuthn device
|
||||
* Rename a WebAuthn credential
|
||||
*
|
||||
* @param \App\Http\Requests\WebauthnRenameRequest $request
|
||||
* @param string $credential
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function rename(WebauthnRenameRequest $request, string $credential)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$webAuthnCredential = WebAuthnCredential::where('id', $credential)->firstOrFail();
|
||||
$webAuthnCredential->name = $validated['name']; // @phpstan-ignore-line
|
||||
$webAuthnCredential->save();
|
||||
abort_if(! $request->user()->renameCredential($credential, $validated['name']), 404);
|
||||
|
||||
return response()->json([
|
||||
'name' => $webAuthnCredential->name,
|
||||
], 200);
|
||||
'name' => $validated['name'],
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove the specified credential from storage.
|
||||
@ -76,13 +56,15 @@ public function delete(Request $request, $credential)
|
||||
Log::info('Deletion of security device requested');
|
||||
|
||||
$user = $request->user();
|
||||
$user->removeCredential($credential);
|
||||
$user->flushCredential($credential);
|
||||
|
||||
// Webauthn user options should be reset to prevent impossible login
|
||||
// Webauthn user options need to be reset to prevent impossible login when
|
||||
// no more registered device exists.
|
||||
// See #110
|
||||
if (blank($user->allCredentialDescriptors())) {
|
||||
if (blank($user->webAuthnCredentials()->WhereEnabled()->get())) {
|
||||
Settings::delete('useWebauthnAsDefault');
|
||||
Settings::delete('useWebauthnOnly');
|
||||
Log::notice('No Webauthn credential enabled, Webauthn settings reset to default');
|
||||
}
|
||||
|
||||
Log::info('Security device deleted');
|
||||
|
@ -3,55 +3,83 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use DarkGhostHunter\Larapass\Http\RecoversWebAuthn;
|
||||
use DarkGhostHunter\Larapass\Facades\WebAuthn;
|
||||
use App\Http\Requests\WebauthnRecoveryRequest;
|
||||
use App\Extensions\WebauthnCredentialBroker;
|
||||
use App\Facades\Settings;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class WebAuthnRecoveryController extends Controller
|
||||
{
|
||||
use RecoversWebAuthn;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| WebAuthn Recovery Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When an user loses his device he will reach this controller to attach a
|
||||
| new device. The user will attach a new device, and optionally, disable
|
||||
| all others. Then he will be authenticated and redirected to your app.
|
||||
|
|
||||
*/
|
||||
use ResetsPasswords;
|
||||
|
||||
/**
|
||||
* Where to redirect users after resetting their password.
|
||||
* Let the user regain access to his account using email+password by resetting
|
||||
* the "use webauthn only" setting.
|
||||
*
|
||||
* @var string
|
||||
* @param \App\Http\Requests\WebauthnRecoveryRequest $request
|
||||
* @param \App\Extensions\WebauthnCredentialBroker $broker
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected $redirectTo = RouteServiceProvider::HOME;
|
||||
|
||||
|
||||
/**
|
||||
* Returns the credential creation options to the user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function options(Request $request): JsonResponse
|
||||
public function recover(WebauthnRecoveryRequest $request, WebauthnCredentialBroker $broker)
|
||||
{
|
||||
$user = WebAuthn::getUser($request->validate($this->rules()));
|
||||
$credentials = $request->validated();
|
||||
|
||||
// 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'));
|
||||
$response = $broker->reset(
|
||||
$credentials,
|
||||
function ($user) use ($request) {
|
||||
// At this time, the WebAuthnUserProvider is already registered in the Laravel Service Container,
|
||||
// with a password_fallback value set using the useWebauthnOnly user setting (see AuthServiceProvider.php).
|
||||
// To ensure user login with email+pwd credentials, we replace the registered WebAuthnUserProvider instance
|
||||
// with a new instance configured with password_fallback On.
|
||||
$provider = new \Laragear\WebAuthn\Auth\WebAuthnUserProvider(
|
||||
app()->make('hash'),
|
||||
\App\Models\User::class,
|
||||
app()->make(\Laragear\WebAuthn\Assertion\Validator\AssertionValidator::class),
|
||||
true,
|
||||
);
|
||||
|
||||
Auth::guard()->setProvider($provider);
|
||||
|
||||
if (Auth::attempt($request->only('email', 'password'))) {
|
||||
if ($this->shouldRevokeAllCredentials($request)) {
|
||||
$user->flushCredentials();
|
||||
}
|
||||
Settings::delete('useWebauthnOnly');
|
||||
}
|
||||
else throw new AuthenticationException();
|
||||
}
|
||||
);
|
||||
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendRecoveryResponse($request, $response)
|
||||
: $this->sendRecoveryFailedResponse($request, $response);
|
||||
|
||||
return response()->json(WebAuthn::generateAttestation($user));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the user has set to revoke all credentials.
|
||||
*
|
||||
* @param \App\Http\Requests\WebauthnRecoveryRequest $request
|
||||
*
|
||||
* @return bool|mixed
|
||||
*/
|
||||
protected function shouldRevokeAllCredentials(WebauthnRecoveryRequest $request): mixed
|
||||
{
|
||||
return filter_var($request->header('WebAuthn-Unique'), FILTER_VALIDATE_BOOLEAN)
|
||||
?: $request->input('revokeAll', true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the response for a successful account recovery.
|
||||
*
|
||||
@ -60,13 +88,13 @@ public function options(Request $request): JsonResponse
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*
|
||||
* @codeCoverageIgnore - already covered by larapass test
|
||||
*/
|
||||
protected function sendRecoveryResponse(Request $request, string $response): JsonResponse
|
||||
{
|
||||
return response()->json(['message' => __('auth.webauthn.device_successfully_registered')]);
|
||||
return response()->json(['message' => __('auth.webauthn.webauthn_login_disabled')]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the response for a failed account recovery.
|
||||
*
|
||||
@ -76,10 +104,16 @@ protected function sendRecoveryResponse(Request $request, string $response): Jso
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @codeCoverageIgnore - already covered by larapass test
|
||||
*/
|
||||
protected function sendRecoveryFailedResponse(Request $request, string $response): JsonResponse
|
||||
{
|
||||
throw ValidationException::withMessages(['email' => [trans($response)]]);
|
||||
switch ($response) {
|
||||
case Password::INVALID_TOKEN:
|
||||
throw ValidationException::withMessages(['token' => [__('auth.webauthn.invalid_reset_token')]]);
|
||||
|
||||
default:
|
||||
throw ValidationException::withMessages(['email' => [trans($response)]]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,20 +3,48 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use DarkGhostHunter\Larapass\Http\RegistersWebAuthn;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
use Laragear\WebAuthn\WebAuthn;
|
||||
|
||||
class WebAuthnRegisterController extends Controller
|
||||
{
|
||||
use RegistersWebAuthn;
|
||||
/**
|
||||
* Returns a challenge to be verified by the user device.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Http\Requests\AttestationRequest $request
|
||||
* @return \Illuminate\Contracts\Support\Responsable
|
||||
*/
|
||||
public function options(AttestationRequest $request): Responsable
|
||||
{
|
||||
switch (env('WEBAUTHN_USER_VERIFICATION')) {
|
||||
case WebAuthn::USER_VERIFICATION_DISCOURAGED:
|
||||
$request = $request->fastRegistration(); // Makes the authenticator to only check for user presence on registration
|
||||
break;
|
||||
case WebAuthn::USER_VERIFICATION_REQUIRED:
|
||||
$request = $request->secureRegistration(); // Makes the authenticator to always verify the user thoroughly on registration
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| WebAuthn Registration Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller receives an user request to register a device and also
|
||||
| verifies the registration. If everything goes ok, the credential is
|
||||
| persisted into the application, otherwise it will signal failure.
|
||||
|
|
||||
*/
|
||||
return $request
|
||||
// ->allowDuplicates() // Allows the device to create multiple credentials for the same user for this app
|
||||
// ->userless() // Tells the authenticator use this credential to login instantly, instead of asking for one
|
||||
->toCreate();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a device for further WebAuthn authentication.
|
||||
*
|
||||
* @param \Laragear\WebAuthn\Http\Requests\AttestedRequest $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function register(AttestedRequest $request): Response
|
||||
{
|
||||
$request->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
35
app/Http/Requests/WebauthnDeviceLostRequest.php
Normal file
35
app/Http/Requests/WebauthnDeviceLostRequest.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class WebauthnDeviceLostRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
new \App\Rules\CaseInsensitiveEmailExists
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
33
app/Http/Requests/WebauthnRecoveryRequest.php
Normal file
33
app/Http/Requests/WebauthnRecoveryRequest.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class WebauthnRecoveryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
];
|
||||
}
|
||||
}
|
97
app/Models/Traits/WebAuthnManageCredentials.php
Normal file
97
app/Models/Traits/WebAuthnManageCredentials.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use App\Notifications\WebauthnRecoveryNotification;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* @see \App\Models\WebAuthnAuthenticatable
|
||||
* @see \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
trait WebAuthnManageCredentials
|
||||
{
|
||||
/**
|
||||
* Return the handle used to identify his credentials.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function userHandle(): string
|
||||
{
|
||||
// Laragear\WebAuthn uses Ramsey\Uuid\Uuid::fromString()->getHex()->toString()
|
||||
// to obtain a UUID v4 with dashes removed and uses it as user_id (aka userHandle)
|
||||
// see https://github.com/ramsey/uuid/blob/4.x/src/Uuid.php#L379
|
||||
// and Laragear\WebAuthn\Assertion\Validator\Pipes\CheckCredentialIsForUser::validateId()
|
||||
|
||||
return $this->webAuthnCredentials()->value('user_id')
|
||||
?? str_replace('-', '', Str::uuid()->toString());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Saves a new alias for a given WebAuthn credential.
|
||||
*
|
||||
* @param string $id
|
||||
* @param string $alias
|
||||
* @return bool
|
||||
*/
|
||||
public function renameCredential(string $id, string $alias): bool
|
||||
{
|
||||
return boolval($this->webAuthnCredentials()->whereKey($id)->update(['alias' => $alias]));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes one or more credentials previously registered.
|
||||
*
|
||||
* @param string|array $id
|
||||
* @return void
|
||||
*/
|
||||
public function flushCredential($id): void
|
||||
{
|
||||
if (! $this->relationLoaded('webAuthnCredentials')) {
|
||||
$this->webAuthnCredentials()->whereKey($id)->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->webAuthnCredentials instanceof Collection && $this->webAuthnCredentials->isNotEmpty()) {
|
||||
$this->webAuthnCredentials->whereIn('id', $id)->each->delete();
|
||||
|
||||
$this->setRelation('webAuthnCredentials', $this->webAuthnCredentials->whereNotIn('id', $id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a webauthn recovery email to the user.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function sendWebauthnRecoveryNotification(string $token): void
|
||||
{
|
||||
// $accountRecoveryNotification = new WebauthnRecoveryNotification($token);
|
||||
// $accountRecoveryNotification->toMailUsing(null);
|
||||
|
||||
// $accountRecoveryNotification->createUrlUsing(function(mixed $notifiable, string $token) {
|
||||
// $url = url(
|
||||
// route(
|
||||
// 'webauthn.recover',
|
||||
// [
|
||||
// 'token' => $token,
|
||||
// 'email' => $notifiable->getEmailForPasswordReset(),
|
||||
// ],
|
||||
// false
|
||||
// )
|
||||
// );
|
||||
|
||||
// return $url;
|
||||
// });
|
||||
|
||||
$this->notify(new WebauthnRecoveryNotification($token));
|
||||
|
||||
}
|
||||
}
|
@ -4,18 +4,16 @@
|
||||
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable;
|
||||
use DarkGhostHunter\Larapass\WebAuthnAuthentication;
|
||||
use DarkGhostHunter\Larapass\Notifications\AccountRecoveryNotification;
|
||||
use Laragear\WebAuthn\WebAuthnAuthentication;
|
||||
use App\Models\Traits\WebAuthnManageCredentials;
|
||||
|
||||
class User extends Authenticatable implements WebAuthnAuthenticatable
|
||||
{
|
||||
use WebAuthnAuthentication;
|
||||
use WebAuthnAuthentication, WebAuthnManageCredentials;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
@ -30,16 +28,17 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int,string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
* @var array<string,string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
@ -67,33 +66,20 @@ public function setEmailAttribute($value) : void
|
||||
$this->attributes['email'] = strtolower($value);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a credential recovery email to the user.
|
||||
* Returns an WebAuthnAuthenticatable user from a given Credential ID.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
* @param string $id
|
||||
* @return WebAuthnAuthenticatable|null
|
||||
*/
|
||||
public function sendCredentialRecoveryNotification(string $token): void
|
||||
public static function getFromCredentialId(string $id): ?WebAuthnAuthenticatable
|
||||
{
|
||||
$accountRecoveryNotification = new AccountRecoveryNotification($token);
|
||||
$accountRecoveryNotification->toMailUsing(null);
|
||||
|
||||
$accountRecoveryNotification->createUrlUsing(function(mixed $notifiable, string $token) {
|
||||
$url = url(
|
||||
route(
|
||||
'webauthn.recover',
|
||||
[
|
||||
'token' => $token,
|
||||
'email' => $notifiable->getEmailForPasswordReset(),
|
||||
],
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
return $url;
|
||||
});
|
||||
|
||||
$this->notify($accountRecoveryNotification);
|
||||
return static::whereHas(
|
||||
'webauthnCredentials',
|
||||
static function ($query) use ($id) {
|
||||
return $query->whereKey($id);
|
||||
}
|
||||
)->first();
|
||||
}
|
||||
}
|
||||
|
43
app/Models/WebAuthnAuthenticatable.php
Normal file
43
app/Models/WebAuthnAuthenticatable.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable as Authenticatable;
|
||||
|
||||
interface WebAuthnAuthenticatable extends Authenticatable
|
||||
{
|
||||
/**
|
||||
* Return the handle used to identify his credentials.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function userHandle(): string;
|
||||
|
||||
|
||||
/**
|
||||
* Saves a new alias for a given WebAuthn credential.
|
||||
*
|
||||
* @param string $id
|
||||
* @param string $alias
|
||||
* @return bool
|
||||
*/
|
||||
public function renameCredential(string $id, string $alias): bool;
|
||||
|
||||
|
||||
/**
|
||||
* Removes one or more credentials previously registered.
|
||||
*
|
||||
* @param string|array $id
|
||||
* @return void
|
||||
*/
|
||||
public function flushCredential($id): void;
|
||||
|
||||
|
||||
/**
|
||||
* Sends a webauthn recovery email to the user.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendWebauthnRecoveryNotification(string $token): void;
|
||||
}
|
121
app/Notifications/WebauthnRecoveryNotification.php
Normal file
121
app/Notifications/WebauthnRecoveryNotification.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class WebauthnRecoveryNotification extends Notification
|
||||
{
|
||||
/**
|
||||
* Token for account recovery.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $token;
|
||||
|
||||
// /**
|
||||
// * The callback that should be used to create the reset password URL.
|
||||
// *
|
||||
// * @var \Closure|null
|
||||
// */
|
||||
// protected static ?Closure $createUrlCallback;
|
||||
|
||||
// /**
|
||||
// * The callback that should be used to build the mail message.
|
||||
// *
|
||||
// * @var \Closure|null
|
||||
// */
|
||||
// protected static ?Closure $toMailCallback;
|
||||
|
||||
/**
|
||||
* AccountRecoveryNotification constructor.
|
||||
*
|
||||
* @param string $token
|
||||
*/
|
||||
public function __construct(string $token)
|
||||
{
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
// if (static::$toMailCallback) {
|
||||
// return call_user_func(static::$toMailCallback, $notifiable, $this->token);
|
||||
// }
|
||||
|
||||
// if (static::$createUrlCallback) {
|
||||
// $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
|
||||
// } else {
|
||||
$url = url(
|
||||
route(
|
||||
'webauthn.recover',
|
||||
[
|
||||
'token' => $this->token,
|
||||
'email' => $notifiable->getEmailForPasswordReset(),
|
||||
],
|
||||
false
|
||||
)
|
||||
);
|
||||
// }
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(Lang::get('Account Recovery Notification'))
|
||||
->line(
|
||||
Lang::get(
|
||||
'You are receiving this email because we received an account recovery request for your account.'
|
||||
)
|
||||
)
|
||||
->action(Lang::get('Recover Account'), $url)
|
||||
->line(
|
||||
Lang::get(
|
||||
'This recovery link will expire in :count minutes.',
|
||||
['count' => config('auth.passwords.webauthn.expire')]
|
||||
)
|
||||
)
|
||||
->line(Lang::get('If you did not request an account recovery, no further action is required.'));
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Set a callback that should be used when creating the reset password button URL.
|
||||
// *
|
||||
// * @param \Closure|null $callback
|
||||
// *
|
||||
// * @return void
|
||||
// */
|
||||
// public static function createUrlUsing(?Closure $callback): void
|
||||
// {
|
||||
// static::$createUrlCallback = $callback;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Set a callback that should be used when building the notification mail message.
|
||||
// *
|
||||
// * @param \Closure|null $callback
|
||||
// *
|
||||
// * @return void
|
||||
// */
|
||||
// public static function toMailUsing(?Closure $callback): void
|
||||
// {
|
||||
// static::$toMailCallback = $callback;
|
||||
// }
|
||||
}
|
@ -5,20 +5,63 @@
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Services\Auth\ReverseProxyGuard;
|
||||
use App\Extensions\EloquentTwoFAuthProvider;
|
||||
use App\Extensions\RemoteUserProvider;
|
||||
use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
use App\Facades\Settings;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use RuntimeException;
|
||||
use App\Extensions\WebauthnCredentialBroker;
|
||||
use Illuminate\Auth\Passwords\DatabaseTokenRepository;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The policy mappings for the application.
|
||||
* The model to policy mappings for the application.
|
||||
*
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
// protected $policies = [
|
||||
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
|
||||
// ];
|
||||
protected $policies = [
|
||||
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
|
||||
$this->app->singleton(
|
||||
WebauthnCredentialBroker::class,
|
||||
static function ($app) {
|
||||
if (!$config = $app['config']['auth.passwords.webauthn']) {
|
||||
throw new RuntimeException('You must set the [webauthn] key broker in [auth] config.');
|
||||
}
|
||||
|
||||
$key = $app['config']['app.key'];
|
||||
|
||||
if (Str::startsWith($key, 'base64:')) {
|
||||
$key = base64_decode(substr($key, 7));
|
||||
}
|
||||
|
||||
return new WebauthnCredentialBroker(
|
||||
new DatabaseTokenRepository(
|
||||
$app['db']->connection($config['connection'] ?? null),
|
||||
$app['hash'],
|
||||
$config['table'],
|
||||
$key,
|
||||
$config['expire'],
|
||||
$config['throttle'] ?? 0
|
||||
),
|
||||
$app['auth']->createUserProvider($config['provider'] ?? null)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
@ -29,24 +72,6 @@ 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']
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Register a custom provider for reverse-proxy authentication
|
||||
Auth::provider('remote-user', function ($app, array $config) {
|
||||
// Return an instance of Illuminate\Contracts\Auth\UserProvider...
|
||||
@ -62,6 +87,24 @@ static function ($app, $config) {
|
||||
});
|
||||
|
||||
|
||||
// Previously we were using a custom user provider derived from the Larapass user provider
|
||||
// in order to honor the "useWebauthnOnly" user option.
|
||||
// Since Laragear\WebAuthn now replaces DarkGhostHunter\Larapass, the new approach is
|
||||
// simplier: We overload the 'eloquent-webauthn' registration from Laragear\WebAuthn\WebAuthnServiceProvider
|
||||
// with a custom closure that uses the "useWebauthnOnly" user option
|
||||
Auth::provider(
|
||||
'eloquent-webauthn',
|
||||
static function (\Illuminate\Contracts\Foundation\Application $app, array $config): \Laragear\WebAuthn\Auth\WebAuthnUserProvider {
|
||||
return new \Laragear\WebAuthn\Auth\WebAuthnUserProvider(
|
||||
$app->make('hash'),
|
||||
$config['model'],
|
||||
$app->make(\Laragear\WebAuthn\Assertion\Validator\AssertionValidator::class),
|
||||
Settings::get('useWebauthnOnly') ? false : true
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Normally we should set the Passport routes here using Passport::routes().
|
||||
// If so the passport routes would be set for both 'web' and 'api' middlewares without
|
||||
// possibility to exclude the web middleware (we can only pass additional middlewares to Passport::routes())
|
||||
|
@ -21,7 +21,7 @@
|
||||
"ext-tokenizer": "*",
|
||||
"ext-xml": "*",
|
||||
"chillerlan/php-qrcode": "^4.3",
|
||||
"darkghosthunter/larapass": "^3.0.2",
|
||||
"laragear/webauthn": "^1.1.0",
|
||||
"doctormckay/steam-totp": "^1.0",
|
||||
"doctrine/dbal": "^3.4",
|
||||
"fruitcake/laravel-cors": "^2.0",
|
||||
|
3604
composer.lock
generated
3604
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -87,8 +87,9 @@
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent-2fauth',
|
||||
'driver' => 'eloquent-webauthn',
|
||||
'model' => App\Models\User::class,
|
||||
// 'password_fallback' => true,
|
||||
],
|
||||
'remote-user' => [
|
||||
'driver' => 'remote-user',
|
||||
@ -122,7 +123,7 @@
|
||||
// for WebAuthn
|
||||
'webauthn' => [
|
||||
'provider' => 'users', // The user provider using WebAuthn.
|
||||
'table' => 'web_authn_recoveries', // The table to store the recoveries.
|
||||
'table' => 'webauthn_recoveries', // The table to store the recoveries.
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
|
@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relaying Party
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| We will use your application information to inform the device who is the
|
||||
| relaying party. While only the name is enough, you can further set the
|
||||
| a custom domain as ID and even an icon image data encoded as BASE64.
|
||||
|
|
||||
*/
|
||||
|
||||
'relaying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
'icon' => env('WEBAUTHN_ICON'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Challenge configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When making challenges your application needs to push at least 16 bytes
|
||||
| of randomness. Since we need to later check them, we'll also store the
|
||||
| bytes for a sensible amount of seconds inside your default app cache.
|
||||
|
|
||||
*/
|
||||
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'cache' => env('WEBAUTHN_CACHE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Algorithms
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here are default algorithms to use when asking to create sign and encrypt
|
||||
| binary objects like a public key and a challenge. These works almost in
|
||||
| any device, but you can add or change these depending on your devices.
|
||||
|
|
||||
| @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms
|
||||
|
|
||||
*/
|
||||
|
||||
'algorithms' => [
|
||||
\Cose\Algorithm\Signature\ECDSA\ES256::class, // ECDSA with SHA-256
|
||||
\Cose\Algorithm\Signature\EdDSA\Ed25519::class, // EdDSA
|
||||
\Cose\Algorithm\Signature\ECDSA\ES384::class, // ECDSA with SHA-384
|
||||
\Cose\Algorithm\Signature\ECDSA\ES512::class, // ECDSA with SHA-512
|
||||
\Cose\Algorithm\Signature\RSA\RS256::class, // RSASSA-PKCS1-v1_5 with SHA-256
|
||||
],
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Credentials Attachment.
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Authentication can be tied to the current device (like when using Windows
|
||||
| Hello or Touch ID) or a cross-platform device (like USB Key). When this
|
||||
| is "null" the user will decide where to store his authentication info.
|
||||
|
|
||||
| By default, the user decides what to use for registration. If you wish
|
||||
| to exclusively use a cross-platform authentication (like USB Keys, CA
|
||||
| Servers or Certificates) set this to true, or false if you want to
|
||||
| enforce device-only authentication.
|
||||
|
|
||||
| Supported: "null", "cross-platform", "platform".
|
||||
|
|
||||
*/
|
||||
|
||||
'attachment' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Attestation Conveyance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The attestation is the data about the device and the public key used to
|
||||
| sign. Using "none" means the data is meaningless, "indirect" allows to
|
||||
| receive anonymized data, and "direct" means to receive the real data.
|
||||
|
|
||||
| Attestation Conveyance represents if the device key should be verified
|
||||
| by you or not. While most of the time is not needed, you can change this
|
||||
| to indirect (you verify it comes from a trustful source) or direct
|
||||
| (the device includes validation data).
|
||||
|
|
||||
| Supported: "none", "indirect", "direct".
|
||||
|
|
||||
*/
|
||||
|
||||
'conveyance' => 'none',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User presence and verification
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Most authenticators and smartphones will ask the user to actively verify
|
||||
| themselves for log in. For example, through a touch plus pin code,
|
||||
| password entry, or biometric recognition (e.g., presenting a fingerprint).
|
||||
| The intent is to distinguish individual users.
|
||||
|
|
||||
| Supported: "required", "preferred", "discouraged".
|
||||
|
|
||||
| Use "required" to always ask verify, "preferred"
|
||||
| to ask when possible, and "discouraged" to just ask for user presence.
|
||||
|
|
||||
*/
|
||||
|
||||
'login_verify' => env('WEBAUTHN_USER_VERIFICATION', 'preferred'),
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Userless (One touch, Typeless) login
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, users must input their email to receive a list of credentials
|
||||
| ID to use for authentication, but they can also login without specifying
|
||||
| one if the device can remember them, allowing for true one-touch login.
|
||||
|
|
||||
| If required or preferred, login verification will be always required.
|
||||
|
|
||||
| Supported: "null", "required", "preferred", "discouraged".
|
||||
|
|
||||
*/
|
||||
|
||||
'userless' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Credential limit
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Authenticators can have multiple credentials for the same user account.
|
||||
| To limit one device per user account, you can set this to true. This
|
||||
| will force the attest to fail when registering another credential.
|
||||
|
|
||||
*/
|
||||
|
||||
'unique' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Fallback
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the `eloquent-webauthn´ user provider you will be able to use
|
||||
| the same user provider to authenticate users using their password. When
|
||||
| disabling this, users will be strictly authenticated only by WebAuthn.
|
||||
|
|
||||
*/
|
||||
|
||||
'fallback' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Device Confirmation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| If you're using the "webauthn.confirm" middleware in your routes you may
|
||||
| want to adjust the time the confirmation is remembered in the browser.
|
||||
| This is measured in seconds, but it can be overridden in the route.
|
||||
|
|
||||
*/
|
||||
|
||||
'confirm_timeout' => 10800, // 3 hours
|
||||
];
|
37
config/webauthn.php
Normal file
37
config/webauthn.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relaying Party
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| We will use your application information to inform the device who is the
|
||||
| relying party. While only the name is enough, you can further set
|
||||
| a custom domain as ID and even an icon image data encoded as BASE64.
|
||||
|
|
||||
*/
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', config('app.name')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Challenge configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When making challenges your application needs to push at least 16 bytes
|
||||
| of randomness. Since we need to later check them, we'll also store the
|
||||
| bytes for a small amount of time inside this current request session.
|
||||
|
|
||||
*/
|
||||
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
],
|
||||
];
|
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -38,7 +37,7 @@ public function up()
|
||||
$table->uuid('user_handle')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes(WebAuthnCredential::DELETED_AT);
|
||||
$table->softDeletes('disabled_at');
|
||||
|
||||
$table->primary(['id', 'user_id']);
|
||||
});
|
||||
|
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* @see \Laragear\WebAuthn\Models\WebAuthnCredential
|
||||
*/
|
||||
return new class extends Migration {
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// We reset the user option 'useWebauthnOnly' to prevent lockout
|
||||
DB::table('options')->where('key', 'useWebauthnOnly')->delete();
|
||||
|
||||
Schema::create('webauthn_credentials', static function (Blueprint $table): void {
|
||||
static::defaultBlueprint($table);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('web_authn_credentials');
|
||||
Schema::rename('web_authn_recoveries', 'webauthn_recoveries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('webauthn_credentials');
|
||||
Schema::rename('webauthn_recoveries', 'web_authn_recoveries');
|
||||
|
||||
Schema::create('web_authn_credentials', function (Blueprint $table) {
|
||||
// This must be the exact same definition as migration 2021_12_03_220140_create_web_authn_tables.php
|
||||
$table->string('id', 191);
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->string('name')->nullable();
|
||||
$table->string('type', 16);
|
||||
$table->json('transports');
|
||||
$table->string('attestation_type');
|
||||
$table->json('trust_path');
|
||||
$table->uuid('aaguid');
|
||||
$table->binary('public_key');
|
||||
$table->unsignedInteger('counter')->default(0);
|
||||
$table->uuid('user_handle')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes('disabled_at');
|
||||
$table->primary(['id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the default blueprint for the WebAuthn credentials table.
|
||||
*
|
||||
* @param \Illuminate\Database\Schema\Blueprint $table
|
||||
* @return void
|
||||
*/
|
||||
protected static function defaultBlueprint(Blueprint $table): void
|
||||
{
|
||||
$table->string('id')->primary();
|
||||
|
||||
$table->morphs('authenticatable', 'webauthn_user_index');
|
||||
|
||||
// This is the user UUID that is generated automatically when a credential for the
|
||||
// given user is created. If a second credential is created, this UUID is queried
|
||||
// and then copied on top of the new one, this way the real User ID doesn't change.
|
||||
$table->uuid('user_id');
|
||||
|
||||
// The app may allow the user to name or rename a credential to a friendly name,
|
||||
// like "John's iPhone" or "Office Computer".
|
||||
$table->string('alias')->nullable();
|
||||
|
||||
// Allows to detect cloned credentials when the assertion does not have this same counter.
|
||||
$table->unsignedBigInteger('counter')->nullable();
|
||||
// Who created the credential. Should be the same reported by the Authenticator.
|
||||
$table->string('rp_id');
|
||||
// Where the credential was created. Should be the same reported by the Authenticator.
|
||||
$table->string('origin');
|
||||
$table->json('transports')->nullable();
|
||||
$table->uuid('aaguid')->nullable(); // GUID are essentially UUID
|
||||
|
||||
// This is the public key the credential uses to verify the challenges.
|
||||
$table->text('public_key');
|
||||
// The attestation of the public key.
|
||||
$table->string('attestation_format')->default('none');
|
||||
// This would hold the certificate chain for other different attestation formats.
|
||||
$table->json('certificates')->nullable();
|
||||
|
||||
// A way to disable the credential without deleting it.
|
||||
$table->timestamp('disabled_at')->nullable();
|
||||
$table->timestamps();
|
||||
}
|
||||
};
|
161
resources/js/components/WebAuthn.js
vendored
Normal file
161
resources/js/components/WebAuthn.js
vendored
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
* https://github.com/Laragear/WebAuthn
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export default class WebAuthn {
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
*/
|
||||
constructor () {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
parseIncomingServerOptions(publicKey) {
|
||||
publicKey.challenge = WebAuthn.uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return {...data, id: WebAuthn.uint8Array(data.id)};
|
||||
});
|
||||
});
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.arrayToBase64String(credentials.rawId),
|
||||
response: {}
|
||||
};
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
}
|
115
resources/js/mixins.js
vendored
115
resources/js/mixins.js
vendored
@ -45,120 +45,7 @@ Vue.mixin({
|
||||
},
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
parseIncomingServerOptions(publicKey) {
|
||||
publicKey.challenge = this.uint8Array(publicKey.challenge);
|
||||
|
||||
if (publicKey.user !== undefined) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: this.uint8Array(publicKey.user.id, true)
|
||||
};
|
||||
}
|
||||
|
||||
["excludeCredentials", "allowCredentials"]
|
||||
.filter((key) => publicKey[key] !== undefined)
|
||||
.forEach((key) => {
|
||||
publicKey[key] = publicKey[key].map((data) => {
|
||||
return { ...data, id: this.uint8Array(data.id) };
|
||||
});
|
||||
});
|
||||
|
||||
return publicKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: this.arrayToBase64String(credentials.rawId),
|
||||
response: {}
|
||||
};
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter((key) => credentials.response[key] !== undefined)
|
||||
.forEach((key) => {
|
||||
if (credentials.response[key] === null) {
|
||||
parseCredentials.response[key] = null
|
||||
}
|
||||
else {
|
||||
parseCredentials.response[key] = this.arrayToBase64String(
|
||||
credentials.response[key]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return parseCredentials;
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform an string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param atob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
uint8Array(input, atob = false) {
|
||||
return Uint8Array.from(
|
||||
atob ? window.atob(input) : this.base64UrlDecode(input),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error(
|
||||
"InvalidLengthError: Input base64url string is the wrong length to determine padding"
|
||||
);
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return window.atob(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
inputId(fieldType, fieldName) {
|
||||
let prefix
|
||||
|
@ -44,6 +44,7 @@
|
||||
<script>
|
||||
|
||||
import Form from './../../components/Form'
|
||||
import WebAuthn from './../../components/WebAuthn'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
@ -58,6 +59,7 @@
|
||||
isBusy: false,
|
||||
showWebauthn: this.$root.appSettings.useWebauthnAsDefault || this.$root.appSettings.useWebauthnOnly,
|
||||
csrfRefresher: null,
|
||||
webauthn: new WebAuthn()
|
||||
}
|
||||
},
|
||||
|
||||
@ -107,13 +109,13 @@
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (this.webauthn.doesntSupportWebAuthn) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const loginOptions = await this.axios.post('/webauthn/login/options').then(res => res.data)
|
||||
const publicKey = this.parseIncomingServerOptions(loginOptions)
|
||||
const publicKey = this.webauthn.parseIncomingServerOptions(loginOptions)
|
||||
const credentials = await navigator.credentials.get({ publicKey: publicKey })
|
||||
.catch(error => {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('auth.webauthn.unknown_device') })
|
||||
@ -121,7 +123,7 @@
|
||||
|
||||
if (!credentials) return false
|
||||
|
||||
const publicKeyCredential = this.parseOutgoingCredentials(credentials)
|
||||
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(credentials)
|
||||
|
||||
this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
|
||||
this.$router.push({ name: 'accounts', params: { toRefresh: true } })
|
||||
|
@ -40,6 +40,7 @@
|
||||
<script>
|
||||
|
||||
import Form from './../../components/Form'
|
||||
import WebAuthn from './../../components/WebAuthn'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
@ -56,6 +57,7 @@
|
||||
showWebauthnRegistration: false,
|
||||
deviceRegistered: false,
|
||||
deviceId : null,
|
||||
webauthn: new WebAuthn()
|
||||
}
|
||||
},
|
||||
|
||||
@ -95,13 +97,13 @@
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (this.webauthn.doesntSupportWebAuthn) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
|
||||
const publicKey = this.parseIncomingServerOptions(registerOptions)
|
||||
const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
|
||||
let bufferedCredentials
|
||||
|
||||
try {
|
||||
@ -117,7 +119,7 @@
|
||||
return false
|
||||
}
|
||||
|
||||
const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
|
||||
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
|
||||
|
||||
this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
|
||||
this.deviceId = publicKeyCredential.id
|
||||
|
@ -1,28 +1,15 @@
|
||||
<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-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
|
||||
<div>
|
||||
<form @submit.prevent="recover" @keydown="form.onKeydown($event)">
|
||||
<form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
|
||||
<form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
|
||||
<div class="field">
|
||||
<p>{{ $t('auth.forms.forgot_your_password') }} <router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
|
||||
</div>
|
||||
<form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="field">
|
||||
<input id="unique" name="unique" type="checkbox" class="is-checkradio is-info" v-model="unique" >
|
||||
<label tabindex="0" for="unique" class="label" ref="uniqueLabel" v-on:keypress.space.prevent="unique = true">
|
||||
{{ $t('auth.webauthn.disable_all_other_devices') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-link" @click="register()">{{ $t('auth.webauthn.register_a_new_device')}}</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<router-link :to="{ name: 'login' }" class="button is-text">{{ $t('commons.cancel') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
@ -35,20 +22,21 @@
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
email : '',
|
||||
token: '',
|
||||
unique: false,
|
||||
currentPassword: '',
|
||||
deviceRegistered: false,
|
||||
deviceId : null,
|
||||
form: new Form({
|
||||
name : '',
|
||||
email: '',
|
||||
password: '',
|
||||
token: '',
|
||||
revokeAll: false,
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
this.email = this.$route.query.email
|
||||
this.token = this.$route.query.token
|
||||
this.form.email = this.$route.query.email
|
||||
this.form.token = this.$route.query.token
|
||||
},
|
||||
|
||||
methods : {
|
||||
@ -56,73 +44,24 @@
|
||||
/**
|
||||
* Register a new security device
|
||||
*/
|
||||
async register() {
|
||||
// Check https context
|
||||
if (!window.isSecureContext) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
|
||||
return false
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (!window.PublicKeyCredential) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const registerOptions = await this.axios.post('/webauthn/recover/options',
|
||||
{
|
||||
email : this.email,
|
||||
token: this.token
|
||||
},
|
||||
{ returnError: true })
|
||||
.then(res => res.data)
|
||||
.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
|
||||
recover() {
|
||||
this.form.post('/webauthn/recover', {returnError: true})
|
||||
.then(response => {
|
||||
this.$router.push({ name: 'login', params: { forceRefresh: true } })
|
||||
})
|
||||
},
|
||||
.catch(error => {
|
||||
if( error.response.status === 401 ) {
|
||||
|
||||
|
||||
/**
|
||||
* 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 }})
|
||||
}
|
||||
},
|
||||
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
|
||||
}
|
||||
else if (error.response.status === 422) {
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.message })
|
||||
}
|
||||
else {
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
|
43
resources/js/views/settings/Settings.vue
Normal file
43
resources/js/views/settings/Settings.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<aside class="menu">
|
||||
<p class="menu-label">
|
||||
Options
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li><a>General</a></li>
|
||||
<li><a>Groups</a></li>
|
||||
<li><a>Security</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">
|
||||
Administration
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li><a>Team Settings</a></li>
|
||||
<li>
|
||||
<a class="is-active">Manage Your Team</a>
|
||||
<ul>
|
||||
<li><a>Members</a></li>
|
||||
<li><a>Plugins</a></li>
|
||||
<li><a>Add a member</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a>Invitations</a></li>
|
||||
<li><a>Cloud Storage Environment Settings</a></li>
|
||||
<li><a>Authentication</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">
|
||||
Transactions
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li><a>Payments</a></li>
|
||||
<li><a>Transfers</a></li>
|
||||
<li><a>Balance</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
@ -60,6 +60,7 @@
|
||||
<script>
|
||||
|
||||
import Form from './../../components/Form'
|
||||
import WebAuthn from './../../components/WebAuthn'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
@ -71,6 +72,7 @@
|
||||
credentials: [],
|
||||
isFetching: false,
|
||||
isRemoteUser: false,
|
||||
webauthn: new WebAuthn()
|
||||
}
|
||||
},
|
||||
|
||||
@ -140,13 +142,13 @@
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (this.webauthn.doesntSupportWebAuthn) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
|
||||
const publicKey = this.parseIncomingServerOptions(registerOptions)
|
||||
const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
|
||||
let bufferedCredentials
|
||||
|
||||
try {
|
||||
@ -156,13 +158,19 @@
|
||||
if (error.name == 'AbortError') {
|
||||
this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
|
||||
}
|
||||
else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
|
||||
else if (error.name == 'SecurityError') {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.security_error_check_rpid') })
|
||||
}
|
||||
else if (error.name == 'InvalidStateError') {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
|
||||
}
|
||||
else if (error.name == 'NotAllowedError') {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.not_allowed_operation') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
|
||||
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
|
||||
|
||||
this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
|
||||
this.$router.push({ name: 'settings.webauthn.editCredential', params: { id: publicKeyCredential.id, name: this.$t('auth.webauthn.my_device') } })
|
||||
@ -196,7 +204,7 @@
|
||||
* Always display a printable name
|
||||
*/
|
||||
displayName(credential) {
|
||||
return credential.name ? credential.name : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
|
||||
return credential.alias ? credential.alias : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
|
||||
},
|
||||
|
||||
},
|
||||
|
@ -49,24 +49,27 @@
|
||||
'lost_your_device' => 'Lost your device?',
|
||||
'recover_your_account' => 'Recover your account',
|
||||
'account_recovery' => 'Account recovery',
|
||||
'recovery_punchline' => '2FAuth will send you a recovery link to this email address. Click the link in the received email to register a new security device.<br /><br />Ensure you open the email on a device you fully own.',
|
||||
'recovery_punchline' => '2FAuth will send you a recovery link to this email address. Click the link in the received email and follow the instructions.<br /><br />Ensure you open the email on a device you fully own.',
|
||||
'send_recovery_link' => 'Send recovery link',
|
||||
'account_recovery_email_sent' => 'Account recovery email sent!',
|
||||
'disable_all_other_devices' => 'Disable all other devices except this one',
|
||||
'disable_all_security_devices' => 'Disable all security devices',
|
||||
'disable_all_security_devices_help' => 'All your security devices will be revoked. Use this option if you have lost one or its security has been compromised.',
|
||||
'register_a_new_device' => 'Register a new device',
|
||||
'register_a_device' => 'Register a device',
|
||||
'device_successfully_registered' => 'Device successfully registered',
|
||||
'device_revoked' => 'Device successfully revoked',
|
||||
'revoking_a_device_is_permanent' => 'Revoking a device is permanent',
|
||||
'recover_account_instructions' => 'Click the button below to register a new security device to recover your account. Just follow your browser instructions.',
|
||||
'recover_account_instructions' => 'To recover your account, 2FAuth resets some Webauthn settings so you will be able to sign in using your email and password.',
|
||||
'invalid_recovery_token' => 'Invalid recovery token',
|
||||
'webauthn_login_disabled' => 'Webauthn login disabled',
|
||||
'invalid_reset_token' => 'This reset token is invalid.',
|
||||
'rename_device' => 'Rename device',
|
||||
'my_device' => 'My device',
|
||||
'unknown_device' => 'Unknown device',
|
||||
'use_webauthn_only' => [
|
||||
'label' => 'Use WebAuthn only',
|
||||
'help' => 'Make WebAuthn the only available method to sign in 2FAuth. This is the recommended setup to take advantage of the WebAuthn enhanced security.<br />
|
||||
In case of device lost you will always be able to register a new security device to recover your account.'
|
||||
In case of device lost, you will be able to recover your account by resetting this option and signing in using your email and password.'
|
||||
],
|
||||
'need_a_security_device_to_enable_options' => 'Set at least one device to enable these options',
|
||||
'use_webauthn_as_default' => [
|
||||
|
@ -34,7 +34,9 @@
|
||||
'https_required' => 'HTTPS context required',
|
||||
'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser',
|
||||
'aborted_by_user' => 'Aborted by user',
|
||||
'security_device_unsupported' => 'Security device unsupported',
|
||||
'security_device_unsupported' => 'Unsupported or in use device',
|
||||
'not_allowed_operation' => 'Operation not allowed',
|
||||
'security_error_check_rpid' => 'Security error<br/>Check your WEBAUTHN_ID env var',
|
||||
'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
|
||||
'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
|
||||
'auth_proxy_failed' => 'Proxy authentication failed',
|
||||
|
@ -22,7 +22,12 @@
|
||||
Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('password.reset');
|
||||
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
|
||||
Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost');
|
||||
Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options'])->name('webauthn.recover.options');
|
||||
});
|
||||
|
||||
/**
|
||||
* Routes that can be requested max 10 times per minute by the same IP
|
||||
*/
|
||||
Route::group(['middleware' => ['rejectIfDemoMode', 'throttle:10,1']], function () {
|
||||
Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
|
||||
});
|
||||
|
||||
|
@ -36,7 +36,7 @@ public function test_sendRecoveryEmail_sends_notification_on_success()
|
||||
'email' => $this->user->email,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($this->user, \DarkGhostHunter\Larapass\Notifications\AccountRecoveryNotification::class);
|
||||
Notification::assertSentTo($this->user, \App\Notifications\WebauthnRecoveryNotification::class);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
|
Loading…
Reference in New Issue
Block a user