Replace darkghosthunter/larapass with laragear/webauthn

This commit is contained in:
Bubka 2022-11-14 17:13:24 +01:00
parent 0ac04a321d
commit 017bbc6304
38 changed files with 2400 additions and 3010 deletions

View File

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

View File

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

View File

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

View File

@ -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');
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

@ -3,35 +3,57 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; 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 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 [ $credentials = $request->validated();
'email' => 'required|exists:users,email',
]; $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)]);
} }

View File

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

View File

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

View File

@ -3,55 +3,83 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider; use App\Http\Requests\WebauthnRecoveryRequest;
use DarkGhostHunter\Larapass\Http\RecoversWebAuthn; use App\Extensions\WebauthnCredentialBroker;
use DarkGhostHunter\Larapass\Facades\WebAuthn; use App\Facades\Settings;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; 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 class WebAuthnRecoveryController extends Controller
{ {
use RecoversWebAuthn; use ResetsPasswords;
/*
|--------------------------------------------------------------------------
| 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. * 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; public function recover(WebauthnRecoveryRequest $request, WebauthnCredentialBroker $broker)
/**
* 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())); $credentials = $request->validated();
// We will proceed only if the broker can find the user and the token is valid. $response = $broker->reset(
// If the user doesn't exists or the token is invalid, we will bail out with a $credentials,
// HTTP 401 code because the user doing the request is not authorized for it. function ($user) use ($request) {
abort_unless(WebAuthn::tokenExists($user, $request->input('token')), 401, __('auth.webauthn.invalid_recovery_token')); // 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. * Get the response for a successful account recovery.
* *
@ -60,13 +88,13 @@ class WebAuthnRecoveryController extends Controller
* *
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
* @codeCoverageIgnore - already covered by larapass test
*/ */
protected function sendRecoveryResponse(Request $request, string $response): JsonResponse 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. * Get the response for a failed account recovery.
* *
@ -76,10 +104,16 @@ class WebAuthnRecoveryController extends Controller
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
* *
* @codeCoverageIgnore - already covered by larapass test
*/ */
protected function sendRecoveryFailedResponse(Request $request, string $response): JsonResponse 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)]]);
}
} }
} }

View File

@ -3,20 +3,48 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; 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 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;
}
/* return $request
|-------------------------------------------------------------------------- // ->allowDuplicates() // Allows the device to create multiple credentials for the same user for this app
| WebAuthn Registration Controller // ->userless() // Tells the authenticator use this credential to login instantly, instead of asking for one
|-------------------------------------------------------------------------- ->toCreate();
| }
| 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. /**
| * 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();
}
} }

View 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
],
];
}
}

View 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',
];
}
}

View 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));
}
}

View File

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

View 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;
}

View 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;
// }
}

View File

@ -5,20 +5,63 @@ namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Services\Auth\ReverseProxyGuard; use App\Services\Auth\ReverseProxyGuard;
use App\Extensions\EloquentTwoFAuthProvider;
use App\Extensions\RemoteUserProvider; use App\Extensions\RemoteUserProvider;
use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator; use App\Facades\Settings;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Support\Facades\Config;
use RuntimeException;
use App\Extensions\WebauthnCredentialBroker;
use Illuminate\Auth\Passwords\DatabaseTokenRepository;
use Illuminate\Support\Str;
class AuthServiceProvider extends ServiceProvider 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 = [ protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy', // '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. * Register any authentication / authorization services.
@ -29,24 +72,6 @@ class AuthServiceProvider extends ServiceProvider
{ {
$this->registerPolicies(); $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 // Register a custom provider for reverse-proxy authentication
Auth::provider('remote-user', function ($app, array $config) { Auth::provider('remote-user', function ($app, array $config) {
// Return an instance of Illuminate\Contracts\Auth\UserProvider... // Return an instance of Illuminate\Contracts\Auth\UserProvider...
@ -62,6 +87,24 @@ class AuthServiceProvider extends ServiceProvider
}); });
// Previously we were using a custom user provider derived from the Larapass user provider
// in order to honor the "useWebauthnOnly" user option.
// Since Laragear\WebAuthn now replaces DarkGhostHunter\Larapass, the new approach is
// simplier: We overload the 'eloquent-webauthn' registration from Laragear\WebAuthn\WebAuthnServiceProvider
// with a custom closure that uses the "useWebauthnOnly" user option
Auth::provider(
'eloquent-webauthn',
static function (\Illuminate\Contracts\Foundation\Application $app, array $config): \Laragear\WebAuthn\Auth\WebAuthnUserProvider {
return new \Laragear\WebAuthn\Auth\WebAuthnUserProvider(
$app->make('hash'),
$config['model'],
$app->make(\Laragear\WebAuthn\Assertion\Validator\AssertionValidator::class),
Settings::get('useWebauthnOnly') ? false : true
);
}
);
// Normally we should set the Passport routes here using Passport::routes(). // 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 // 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()) // possibility to exclude the web middleware (we can only pass additional middlewares to Passport::routes())

View File

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

3604
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -87,8 +87,9 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent-2fauth', 'driver' => 'eloquent-webauthn',
'model' => App\Models\User::class, 'model' => App\Models\User::class,
// 'password_fallback' => true,
], ],
'remote-user' => [ 'remote-user' => [
'driver' => 'remote-user', 'driver' => 'remote-user',
@ -122,7 +123,7 @@ return [
// for WebAuthn // for WebAuthn
'webauthn' => [ 'webauthn' => [
'provider' => 'users', // The user provider using 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, 'expire' => 60,
'throttle' => 60, 'throttle' => 60,
], ],

View File

@ -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
View 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',
],
];

View File

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

View File

@ -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
View 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();
}
}

113
resources/js/mixins.js vendored
View File

@ -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) { inputId(fieldType, fieldName) {
let prefix let prefix

View File

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

View File

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

View File

@ -1,28 +1,15 @@
<template> <template>
<form-wrapper :title="$t('auth.webauthn.register_a_new_device')" :punchline="$t('auth.webauthn.recover_account_instructions')" > <form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
<div v-if="deviceRegistered" class="field"> <div>
<label class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label> <form @submit.prevent="recover" @keydown="form.onKeydown($event)">
<form @submit.prevent="handleSubmit" @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-field :form="form" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" /> <form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<form-buttons :isBusy="form.isBusy" :isDisabled="form.isDisabled" :caption="$t('commons.continue')" /> <div class="field">
<p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
</div>
<form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
</form> </form>
</div> </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 --> <!-- footer -->
<vue-footer></vue-footer> <vue-footer></vue-footer>
</form-wrapper> </form-wrapper>
@ -35,20 +22,21 @@
export default { export default {
data(){ data(){
return { return {
email : '', currentPassword: '',
token: '',
unique: false,
deviceRegistered: false, deviceRegistered: false,
deviceId : null, deviceId : null,
form: new Form({ form: new Form({
name : '', email: '',
password: '',
token: '',
revokeAll: false,
}), }),
} }
}, },
created () { created () {
this.email = this.$route.query.email this.form.email = this.$route.query.email
this.token = this.$route.query.token this.form.token = this.$route.query.token
}, },
methods : { methods : {
@ -56,73 +44,24 @@
/** /**
* Register a new security device * Register a new security device
*/ */
async register() { recover() {
// Check https context this.form.post('/webauthn/recover', {returnError: true})
if (!window.isSecureContext) { .then(response => {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') }) this.$router.push({ name: 'login', params: { forceRefresh: true } })
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
}) })
}, .catch(error => {
if( error.response.status === 401 ) {
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
/** }
* Rename the registered device else if (error.response.status === 422) {
*/ this.$notify({ type: 'is-danger', text: error.response.data.message })
async handleSubmit(e) { }
else {
await this.form.patch('/webauthn/credentials/' + this.deviceId + '/name') this.$router.push({ name: 'genericError', params: { err: error.response } });
}
if( this.form.errors.any() === false ) { });
this.$router.push({name: 'accounts', params: { toRefresh: true }}) }
}
},
}, },
beforeRouteLeave (to, from, next) { beforeRouteLeave (to, from, next) {

View 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>

View File

@ -60,6 +60,7 @@
<script> <script>
import Form from './../../components/Form' import Form from './../../components/Form'
import WebAuthn from './../../components/WebAuthn'
export default { export default {
data(){ data(){
@ -71,6 +72,7 @@
credentials: [], credentials: [],
isFetching: false, isFetching: false,
isRemoteUser: false, isRemoteUser: false,
webauthn: new WebAuthn()
} }
}, },
@ -140,13 +142,13 @@
} }
// Check browser support // Check browser support
if (!window.PublicKeyCredential) { if (this.webauthn.doesntSupportWebAuthn) {
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') }) this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
return false return false
} }
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data) 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 let bufferedCredentials
try { try {
@ -156,13 +158,19 @@
if (error.name == 'AbortError') { if (error.name == 'AbortError') {
this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') }) 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') }) 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 return false
} }
const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials); const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
this.axios.post('/webauthn/register', publicKeyCredential).then(response => { 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') } }) 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 * Always display a printable name
*/ */
displayName(credential) { 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) + ')'
}, },
}, },

View File

@ -49,24 +49,27 @@ return [
'lost_your_device' => 'Lost your device?', 'lost_your_device' => 'Lost your device?',
'recover_your_account' => 'Recover your account', 'recover_your_account' => 'Recover your account',
'account_recovery' => 'Account recovery', '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', 'send_recovery_link' => 'Send recovery link',
'account_recovery_email_sent' => 'Account recovery email sent!', '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_new_device' => 'Register a new device',
'register_a_device' => 'Register a device', 'register_a_device' => 'Register a device',
'device_successfully_registered' => 'Device successfully registered', 'device_successfully_registered' => 'Device successfully registered',
'device_revoked' => 'Device successfully revoked', 'device_revoked' => 'Device successfully revoked',
'revoking_a_device_is_permanent' => 'Revoking a device is permanent', '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', 'invalid_recovery_token' => 'Invalid recovery token',
'webauthn_login_disabled' => 'Webauthn login disabled',
'invalid_reset_token' => 'This reset token is invalid.',
'rename_device' => 'Rename device', 'rename_device' => 'Rename device',
'my_device' => 'My device', 'my_device' => 'My device',
'unknown_device' => 'Unknown device', 'unknown_device' => 'Unknown device',
'use_webauthn_only' => [ 'use_webauthn_only' => [
'label' => '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 /> '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', 'need_a_security_device_to_enable_options' => 'Set at least one device to enable these options',
'use_webauthn_as_default' => [ 'use_webauthn_as_default' => [

View File

@ -34,7 +34,9 @@ return [
'https_required' => 'HTTPS context required', 'https_required' => 'HTTPS context required',
'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser', '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', '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', 'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
'user_deletion_failed' => 'User account deletion failed, no data have been deleted', 'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
'auth_proxy_failed' => 'Proxy authentication failed', 'auth_proxy_failed' => 'Proxy authentication failed',

View File

@ -22,7 +22,12 @@ Route::group(['middleware' => ['guest', 'rejectIfDemoMode']], function () {
Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('password.reset'); 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/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost'); 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'); Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
}); });

View File

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