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
# 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.

View File

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

View File

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

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 @@ 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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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 @@
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())

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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,
],

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

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

115
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) {
let prefix

View File

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

View File

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

View File

@ -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') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label>
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
<form-buttons :isBusy="form.isBusy" :isDisabled="form.isDisabled" :caption="$t('commons.continue')" />
<form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
<div>
<form @submit.prevent="recover" @keydown="form.onKeydown($event)">
<form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
<form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<div class="field">
<p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
</div>
<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) {

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>
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) + ')'
},
},

View File

@ -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' => [

View File

@ -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',

View File

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

View File

@ -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([