feat: Add Passkey support
- Added a button for logging in with Passkeys in `login.blade.php` - Refactored the `register` method and added the `login` method in `auth.js` - Made various modifications and additions to the passkey functionality in `PasskeysController.php` - Added event listener for login-passkey element in `app.js` - Modified the passkeys table schema and made modifications to `Passkey.php` - Changed the redirect route in the `login` method of `AuthController.php` - Made modifications and additions to the routes in `web.php` - Added `"web-auth/webauthn-lib": "^4.7"` to the list of required packages in `composer.json` - Changed the redirect URL in `AdminTest.php`
This commit is contained in:
parent
2fb8339d91
commit
03c8f20a8c
18 changed files with 982 additions and 363 deletions
|
@ -7,11 +7,37 @@ namespace App\Http\Controllers\Admin;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Passkey;
|
||||
use App\Models\User;
|
||||
use Cose\Algorithm\Manager;
|
||||
use Cose\Algorithm\Signature\ECDSA\ES256;
|
||||
use Cose\Algorithm\Signature\EdDSA\Ed25519;
|
||||
use Cose\Algorithm\Signature\RSA\RS256;
|
||||
use Cose\Algorithms;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\View\View;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use Throwable;
|
||||
use Webauthn\AttestationStatement\AttestationObjectLoader;
|
||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
||||
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
|
||||
use Webauthn\AuthenticatorAssertionResponse;
|
||||
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||
use Webauthn\AuthenticatorAttestationResponse;
|
||||
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\Exception\WebauthnException;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialLoader;
|
||||
use Webauthn\PublicKeyCredentialParameters;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedClass
|
||||
|
@ -20,74 +46,214 @@ class PasskeysController extends Controller
|
|||
{
|
||||
public function index(): View
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
$passkeys = $user->passkey;
|
||||
|
||||
return view('admin.passkeys.index', compact('passkeys'));
|
||||
}
|
||||
|
||||
public function save(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'id' => 'required|string|unique:App\Models\Passkey,passkey_id',
|
||||
'public_key' => 'required|file',
|
||||
'transports' => 'required|json',
|
||||
'challenge' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Passkey could not be saved (validation failed)',
|
||||
]);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
|
||||
if (
|
||||
!session()->has('challenge') ||
|
||||
$validated['challenge'] !== session('challenge')
|
||||
) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Passkey could not be saved (challenge failed)',
|
||||
]);
|
||||
}
|
||||
|
||||
$passkey = new Passkey();
|
||||
$passkey->passkey_id = $validated['id'];
|
||||
$passkey->passkey = $validated['public_key']->get();
|
||||
$passkey->transports = json_decode($validated['transports'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$passkey->user_id = auth()->user()->id;
|
||||
$passkey->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Passkey saved successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
public function init(): JsonResponse
|
||||
public function getCreateOptions(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
$passkeys = $user->passkey()->get();
|
||||
|
||||
$existing = $passkeys->map(function (Passkey $passkey) {
|
||||
return [
|
||||
'id' => $passkey->passkey_id,
|
||||
'transports' => $passkey->transports,
|
||||
'type' => 'public-key',
|
||||
];
|
||||
})->all();
|
||||
// RP Entity i.e. the application
|
||||
$rpEntity = PublicKeyCredentialRpEntity::create(
|
||||
config('app.name'),
|
||||
config('url.longurl'),
|
||||
);
|
||||
|
||||
$challenge = Hash::make(random_bytes(32));
|
||||
session(['challenge' => $challenge]);
|
||||
// User Entity
|
||||
$userEntity = PublicKeyCredentialUserEntity::create(
|
||||
$user->name,
|
||||
(string) $user->id,
|
||||
$user->name,
|
||||
);
|
||||
|
||||
// Challenge
|
||||
$challenge = random_bytes(16);
|
||||
|
||||
// List of supported public key parameters
|
||||
$pubKeyCredParams = collect([
|
||||
Algorithms::COSE_ALGORITHM_EDDSA,
|
||||
Algorithms::COSE_ALGORITHM_ES256,
|
||||
Algorithms::COSE_ALGORITHM_RS256,
|
||||
])->map(
|
||||
fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm)
|
||||
)->toArray();
|
||||
|
||||
$authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create(
|
||||
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
|
||||
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
|
||||
requireResidentKey: true,
|
||||
);
|
||||
|
||||
$options = PublicKeyCredentialCreationOptions::create(
|
||||
$rpEntity,
|
||||
$userEntity,
|
||||
$challenge,
|
||||
$pubKeyCredParams,
|
||||
authenticatorSelection: $authenticatorSelectionCriteria,
|
||||
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE
|
||||
);
|
||||
|
||||
$options = json_encode($options, JSON_THROW_ON_ERROR);
|
||||
|
||||
session(['create_options' => $options]);
|
||||
|
||||
return JsonResponse::fromJsonString($options);
|
||||
}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$publicKeyCredentialCreationOptionsData = session('create_options');
|
||||
if (empty($publicKeyCredentialCreationOptionsData)) {
|
||||
throw new WebAuthnException('No public key credential request options found');
|
||||
}
|
||||
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromString($publicKeyCredentialCreationOptionsData);
|
||||
|
||||
// Unset session data to mitigate replay attacks
|
||||
session()->forget('create_options');
|
||||
|
||||
$attestationSupportManager = AttestationStatementSupportManager::create();
|
||||
$attestationSupportManager->add(NoneAttestationStatementSupport::create());
|
||||
$attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager);
|
||||
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader);
|
||||
|
||||
$publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR));
|
||||
|
||||
if (!$publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
|
||||
throw new WebAuthnException('Invalid response type');
|
||||
}
|
||||
|
||||
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
|
||||
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
|
||||
|
||||
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
|
||||
attestationStatementSupportManager: $attestationStatementSupportManager,
|
||||
publicKeyCredentialSourceRepository: null,
|
||||
tokenBindingHandler: null,
|
||||
extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(),
|
||||
);
|
||||
|
||||
$securedRelyingPartyId = [];
|
||||
if (App::environment('local', 'development')) {
|
||||
$securedRelyingPartyId = [config('url.longurl')];
|
||||
}
|
||||
|
||||
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
|
||||
authenticatorAttestationResponse: $publicKeyCredential->response,
|
||||
publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions,
|
||||
request: config('url.longurl'),
|
||||
securedRelyingPartyId: $securedRelyingPartyId,
|
||||
);
|
||||
|
||||
$user->passkey()->create([
|
||||
'passkey_id' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId),
|
||||
'passkey' => json_encode($publicKeyCredentialSource, JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'challenge' => $challenge,
|
||||
'userId' => $user->name,
|
||||
'existing' => $existing,
|
||||
'success' => true,
|
||||
'message' => 'Passkey created successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRequestOptions(): JsonResponse
|
||||
{
|
||||
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
|
||||
challenge: random_bytes(16),
|
||||
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED
|
||||
);
|
||||
|
||||
$publicKeyCredentialRequestOptions = json_encode($publicKeyCredentialRequestOptions, JSON_THROW_ON_ERROR);
|
||||
|
||||
session(['request_options' => $publicKeyCredentialRequestOptions]);
|
||||
|
||||
return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions);
|
||||
}
|
||||
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$requestOptions = session('request_options');
|
||||
session()->forget('request_options');
|
||||
|
||||
if (empty($requestOptions)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No request options found',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($requestOptions);
|
||||
|
||||
$attestationSupportManager = AttestationStatementSupportManager::create();
|
||||
$attestationSupportManager->add(NoneAttestationStatementSupport::create());
|
||||
$attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager);
|
||||
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader);
|
||||
|
||||
$publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR));
|
||||
|
||||
if (!$publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid response type',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id);
|
||||
if (!$passkey) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Passkey not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$credential = PublicKeyCredentialSource::createFromArray(json_decode($passkey->passkey, true, 512, JSON_THROW_ON_ERROR));
|
||||
|
||||
$algorithmManager = Manager::create();
|
||||
$algorithmManager->add(new Ed25519());
|
||||
$algorithmManager->add(new ES256());
|
||||
$algorithmManager->add(new RS256());
|
||||
|
||||
$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
|
||||
publicKeyCredentialSourceRepository: null,
|
||||
tokenBindingHandler: null,
|
||||
extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(),
|
||||
algorithmManager: $algorithmManager,
|
||||
);
|
||||
|
||||
$securedRelyingPartyId = [];
|
||||
if (App::environment('local', 'development')) {
|
||||
$securedRelyingPartyId = [config('url.longurl')];
|
||||
}
|
||||
|
||||
try {
|
||||
$authenticatorAssertionResponseValidator->check(
|
||||
credentialId: $credential,
|
||||
authenticatorAssertionResponse: $publicKeyCredential->response,
|
||||
publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
|
||||
request: config('url.longurl'),
|
||||
userHandle: null,
|
||||
securedRelyingPartyId: $securedRelyingPartyId,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Passkey could not be verified',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$user = User::find($passkey->user_id);
|
||||
Auth::login($user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Passkey verified successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ class AuthController extends Controller
|
|||
$credentials = $request->only('name', 'password');
|
||||
|
||||
if (Auth::attempt($credentials, true)) {
|
||||
return redirect()->intended('/');
|
||||
return redirect()->intended('/admin');
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
|
|
|
@ -13,27 +13,11 @@ class Passkey extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Save and access the passkey appropriately.
|
||||
*/
|
||||
protected function passkey(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: static fn ($value) => stream_get_contents($value),
|
||||
set: static fn ($value) => pg_escape_bytea($value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and access the transports appropriately.
|
||||
*/
|
||||
protected function transports(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: static fn ($value) => json_decode($value, true, 512, JSON_THROW_ON_ERROR),
|
||||
set: static fn ($value) => json_encode($value, JSON_THROW_ON_ERROR),
|
||||
);
|
||||
}
|
||||
/** @inerhitDoc */
|
||||
protected $fillable = [
|
||||
'passkey_id',
|
||||
'passkey',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
|
|
|
@ -28,7 +28,8 @@
|
|||
"mf2/mf2": "~0.3",
|
||||
"spatie/commonmark-highlighter": "^3.0",
|
||||
"spatie/laravel-ignition": "^2.1",
|
||||
"symfony/html-sanitizer": "^6.1"
|
||||
"symfony/html-sanitizer": "^6.1",
|
||||
"web-auth/webauthn-lib": "^4.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^3.0",
|
||||
|
|
583
composer.lock
generated
583
composer.lock
generated
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a0824739b9d145bf875bf9ae54e89b07",
|
||||
"content-hash": "d870e46c1890e6dc609f0d1b65340ec4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
@ -2363,6 +2363,70 @@
|
|||
},
|
||||
"time": "2023-02-15T16:40:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/clock",
|
||||
"version": "3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lcobucci/clock.git",
|
||||
"reference": "30a854ceb22bd87d83a7a4563b3f6312453945fc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lcobucci/clock/zipball/30a854ceb22bd87d83a7a4563b3f6312453945fc",
|
||||
"reference": "30a854ceb22bd87d83a7a4563b3f6312453945fc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~8.2.0",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/clock-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"infection/infection": "^0.26",
|
||||
"lcobucci/coding-standard": "^10.0.0",
|
||||
"phpstan/extension-installer": "^1.2",
|
||||
"phpstan/phpstan": "^1.10.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3.10",
|
||||
"phpstan/phpstan-strict-rules": "^1.5.0",
|
||||
"phpunit/phpunit": "^10.0.17"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Lcobucci\\Clock\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Luís Cobucci",
|
||||
"email": "lcobucci@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Yet another clock abstraction",
|
||||
"support": {
|
||||
"issues": "https://github.com/lcobucci/clock/issues",
|
||||
"source": "https://github.com/lcobucci/clock/tree/3.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/lcobucci",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/lcobucci",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-03-20T19:12:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.0.0",
|
||||
|
@ -3797,6 +3861,73 @@
|
|||
},
|
||||
"time": "2021-10-12T14:12:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v2.6.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "58c3f47f650c94ec05a151692652a868995d2938"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938",
|
||||
"reference": "58c3f47f650c94ec05a151692652a868995d2938",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7|^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^6|^7|^8|^9",
|
||||
"vimeo/psalm": "^1|^2|^3|^4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParagonIE\\ConstantTime\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com",
|
||||
"role": "Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Steve 'Sc00bz' Thomas",
|
||||
"email": "steve@tobtu.com",
|
||||
"homepage": "https://www.tobtu.com",
|
||||
"role": "Original Developer"
|
||||
}
|
||||
],
|
||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||
"keywords": [
|
||||
"base16",
|
||||
"base32",
|
||||
"base32_decode",
|
||||
"base32_encode",
|
||||
"base64",
|
||||
"base64_decode",
|
||||
"base64_encode",
|
||||
"bin2hex",
|
||||
"encoding",
|
||||
"hex",
|
||||
"hex2bin",
|
||||
"rfc4648"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||
},
|
||||
"time": "2022-06-14T06:56:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.1",
|
||||
|
@ -5024,6 +5155,199 @@
|
|||
],
|
||||
"time": "2023-08-23T06:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/cbor-php",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/cbor-php.git",
|
||||
"reference": "81d5dff7a1101d680729b5789f4359d01b15e6c5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/81d5dff7a1101d680729b5789f4359d01b15e6c5",
|
||||
"reference": "81d5dff7a1101d680729b5789f4359d01b15e6c5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.9|^0.10|^0.11",
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ekino/phpstan-banned-code": "^1.0",
|
||||
"ext-json": "*",
|
||||
"infection/infection": "^0.26",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-beberlei-assert": "^1.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.0",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"qossmic/deptrac-shim": "^1.0",
|
||||
"rector/rector": "^0.15",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/var-dumper": "^6.0",
|
||||
"symplify/easy-coding-standard": "^11.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
|
||||
"ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"CBOR\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CBOR Encoder/Decoder for PHP",
|
||||
"keywords": [
|
||||
"Concise Binary Object Representation",
|
||||
"RFC7049",
|
||||
"cbor"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/cbor-php/issues",
|
||||
"source": "https://github.com/Spomky-Labs/cbor-php/tree/3.0.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-02-28T21:37:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/pki-framework",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/pki-framework.git",
|
||||
"reference": "d3ba688bf40e7c6e0dabf065ee18fc210734e760"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/d3ba688bf40e7c6e0dabf065ee18fc210734e760",
|
||||
"reference": "d3ba688bf40e7c6e0dabf065ee18fc210734e760",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.10 || ^0.11",
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"ekino/phpstan-banned-code": "^1.0",
|
||||
"ext-gmp": "*",
|
||||
"ext-openssl": "*",
|
||||
"infection/infection": "^0.26",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/phpstan": "^1.8",
|
||||
"phpstan/phpstan-beberlei-assert": "^1.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.1",
|
||||
"phpstan/phpstan-strict-rules": "^1.3",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"rector/rector": "^0.15",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/phpunit-bridge": "^6.1",
|
||||
"symfony/var-dumper": "^6.1",
|
||||
"symplify/easy-coding-standard": "^11.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "^1.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "For better performance (or GMP)",
|
||||
"ext-gmp": "For better performance (or BCMath)",
|
||||
"ext-openssl": "For OpenSSL based cyphering"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SpomkyLabs\\Pki\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joni Eskelinen",
|
||||
"email": "jonieske@gmail.com",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"email": "florent.morselli@spomky-labs.com",
|
||||
"role": "Spomky-Labs PKI Framework developer"
|
||||
}
|
||||
],
|
||||
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
|
||||
"homepage": "https://github.com/spomky-labs/pki-framework",
|
||||
"keywords": [
|
||||
"DER",
|
||||
"Private Key",
|
||||
"ac",
|
||||
"algorithm identifier",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"attribute certificate",
|
||||
"certificate",
|
||||
"certification request",
|
||||
"cryptography",
|
||||
"csr",
|
||||
"decrypt",
|
||||
"ec",
|
||||
"encrypt",
|
||||
"pem",
|
||||
"pkcs",
|
||||
"public key",
|
||||
"rsa",
|
||||
"sign",
|
||||
"signature",
|
||||
"verify",
|
||||
"x.509",
|
||||
"x.690",
|
||||
"x509",
|
||||
"x690"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
|
||||
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-02-13T17:21:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v6.3.2",
|
||||
|
@ -7552,6 +7876,258 @@
|
|||
],
|
||||
"time": "2022-03-08T17:03:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-auth/cose-lib",
|
||||
"version": "4.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-auth/cose-lib.git",
|
||||
"reference": "0ecad86d2d034ea22e2205d81c8cdec13d93a991"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/0ecad86d2d034ea22e2205d81c8cdec13d93a991",
|
||||
"reference": "0ecad86d2d034ea22e2205d81c8cdec13d93a991",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.9|^0.10|^0.11",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"php": ">=8.1",
|
||||
"spomky-labs/pki-framework": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ekino/phpstan-banned-code": "^1.0",
|
||||
"infection/infection": "^0.27",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/extension-installer": "^1.3",
|
||||
"phpstan/phpstan": "^1.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.1",
|
||||
"phpstan/phpstan-strict-rules": "^1.2",
|
||||
"phpunit/phpunit": "^10.1",
|
||||
"qossmic/deptrac-shim": "^1.0",
|
||||
"rector/rector": "^0.17",
|
||||
"symfony/phpunit-bridge": "^6.1",
|
||||
"symplify/easy-coding-standard": "^12.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
|
||||
"ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cose\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-auth/cose/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CBOR Object Signing and Encryption (COSE) For PHP",
|
||||
"homepage": "https://github.com/web-auth",
|
||||
"keywords": [
|
||||
"COSE",
|
||||
"RFC8152"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-auth/cose-lib/issues",
|
||||
"source": "https://github.com/web-auth/cose-lib/tree/4.2.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-26T13:32:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-auth/metadata-service",
|
||||
"version": "4.7.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-auth/webauthn-metadata-service.git",
|
||||
"reference": "1da1fc6d8055c75af4e46cde169d7b920b8af90a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-auth/webauthn-metadata-service/zipball/1da1fc6d8055c75af4e46cde169d7b920b8af90a",
|
||||
"reference": "1da1fc6d8055c75af4e46cde169d7b920b8af90a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"lcobucci/clock": "^2.2|^3.0",
|
||||
"paragonie/constant_time_encoding": "^2.6",
|
||||
"php": ">=8.1",
|
||||
"psr/clock": "^1.0",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"spomky-labs/pki-framework": "^1.0",
|
||||
"symfony/deprecation-contracts": "^3.2"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/clock-implementation": "As of 4.5.x, the PSR Clock implementation will replace lcobucci/clock",
|
||||
"psr/log-implementation": "Recommended to receive logs from the library",
|
||||
"web-token/jwt-key-mgmt": "Mandatory for fetching Metadata Statement from distant sources",
|
||||
"web-token/jwt-signature-algorithm-ecdsa": "Mandatory for fetching Metadata Statement from distant sources"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"name": "web-auth/webauthn-framework",
|
||||
"url": "https://github.com/web-auth/webauthn-framework"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webauthn\\MetadataService\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-auth/metadata-service/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Metadata Service for FIDO2/Webauthn",
|
||||
"homepage": "https://github.com/web-auth",
|
||||
"keywords": [
|
||||
"FIDO2",
|
||||
"fido",
|
||||
"webauthn"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/web-auth/webauthn-metadata-service/tree/4.7.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-10-07T13:59:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-auth/webauthn-lib",
|
||||
"version": "4.7.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-auth/webauthn-lib.git",
|
||||
"reference": "d9b0d0563c561eaec5c24c46a551bf8ff23a030b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/d9b0d0563c561eaec5c24c46a551bf8ff23a030b",
|
||||
"reference": "d9b0d0563c561eaec5c24c46a551bf8ff23a030b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"paragonie/constant_time_encoding": "^2.6",
|
||||
"php": ">=8.1",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"spomky-labs/cbor-php": "^3.0",
|
||||
"symfony/uid": "^6.1",
|
||||
"web-auth/cose-lib": "^4.2.3",
|
||||
"web-auth/metadata-service": "self.version"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/event-dispatcher": "^6.1"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log-implementation": "Recommended to receive logs from the library",
|
||||
"symfony/event-dispatcher": "Recommended to use dispatched events",
|
||||
"web-token/jwt-key-mgmt": "Mandatory for the AndroidSafetyNet Attestation Statement support",
|
||||
"web-token/jwt-signature-algorithm-ecdsa": "Recommended for the AndroidSafetyNet Attestation Statement support",
|
||||
"web-token/jwt-signature-algorithm-eddsa": "Recommended for the AndroidSafetyNet Attestation Statement support",
|
||||
"web-token/jwt-signature-algorithm-rsa": "Mandatory for the AndroidSafetyNet Attestation Statement support"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"name": "web-auth/webauthn-framework",
|
||||
"url": "https://github.com/web-auth/webauthn-framework"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webauthn\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-auth/webauthn-library/contributors"
|
||||
}
|
||||
],
|
||||
"description": "FIDO2/Webauthn Support For PHP",
|
||||
"homepage": "https://github.com/web-auth",
|
||||
"keywords": [
|
||||
"FIDO2",
|
||||
"fido",
|
||||
"webauthn"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/web-auth/webauthn-lib/tree/4.7.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-10-15T11:54:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "1.11.0",
|
||||
|
@ -12851,8 +13427,9 @@
|
|||
"php": "^8.2",
|
||||
"ext-dom": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*"
|
||||
"ext-json": "*",
|
||||
"ext-pgsql": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
|
|
@ -15,8 +15,7 @@ return new class extends Migration
|
|||
$table->id();
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->string('passkey_id')->unique();
|
||||
$table->binary('passkey');
|
||||
$table->json('transports');
|
||||
$table->text('passkey');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users');
|
||||
|
|
|
@ -1 +1 @@
|
|||
:root{--font-family-headings:"Archer SSm A","Archer SSm B",serif;--font-family-body:"Verlag A","Verlag B",sans-serif;--font-family-monospace:"Operator Mono SSm A","Operator Mono SSm B",monospace;--font-size-sm:0.75rem;--font-size-base:1rem;--font-size-md:1.25rem;--font-size-lg:1.5rem;--font-size-xl:1.75rem;--font-size-xxl:2rem;--font-size-xxxl:2.25rem;--color-primary:#334700;--color-secondary:#e3ffb7;--color-link:#00649e;--color-link-visited:#bc7aff;--color-primary-shadow:rgba(16,25,0,.4)}@supports (color:color(display-p3 0 0 0)){:root{--color-primary:color(display-p3 0.21567 0.27838 0.03615);--color-secondary:color(display-p3 0.91016 0.99842 0.74082);--color-link:color(display-p3 0.01045 0.38351 0.63618);--color-link-visited:color(display-p3 0.70467 0.47549 0.99958);--color-primary-shadow:color(display-p3 0.06762 0.09646 0.00441/0.4)}}@supports (color:oklch(0% 0 0)){:root{--color-primary:oklch(36.8% 0.1 125.505);--color-secondary:oklch(96.3% 0.1 125.505);--color-link:oklch(48.09% 0.146 241.41);--color-link-visited:oklch(70.44% 0.21 304.41);--color-primary-shadow:oklch(19.56% 0.054 125.505/40%)}}body{background-color:var(--color-secondary);color:var(--color-primary);font-family:var(--font-family-body);font-size:var(--font-size-md)}code{font-family:var(--font-family-monospace)}h1,h2,h3,h4,h5,h6{font-family:var(--font-family-headings)}.grid{display:grid;grid-template-columns:5vw 1fr 5vw;grid-template-rows:-webkit-min-content 1fr -webkit-min-content;grid-template-rows:min-content 1fr min-content;row-gap:1rem}#site-header{grid-column:2/3;grid-row:1/2}main{grid-row:2/3}footer,main{grid-column:2/3}footer{grid-row:3/4}footer .iwc-logo{max-width:85vw}a{color:var(--color-link)}a:visited{color:var(--color-link-visited)}#site-header a:visited{color:var(--color-link)}.hljs{border-radius:.5rem}.p-bridgy-twitter-content{display:none}.h-card .hovercard{-webkit-box-orient:vertical;-webkit-box-direction:normal;background-color:var(--color-secondary);border-radius:1rem;-webkit-box-shadow:0 .5rem .5rem .5rem var(--color-primary-shadow);box-shadow:0 .5rem .5rem .5rem var(--color-primary-shadow);display:none;-ms-flex-direction:column;flex-direction:column;gap:.5rem;opacity:0;padding:1rem;position:absolute;-webkit-transition:opacity .5s ease-in-out;transition:opacity .5s ease-in-out;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content;z-index:100}.h-card .hovercard .u-photo{max-width:6rem}.h-card .hovercard .social-icon{height:1rem;width:1rem}.h-card:hover .hovercard{display:-webkit-box;display:-ms-flexbox;display:flex;opacity:1}.h-entry{-webkit-border-start:1px solid var(--color-primary);-webkit-padding-start:.5rem;border-inline-start:1px solid var(--color-primary);padding-inline-start:.5rem}.h-entry .reply-to{font-style:italic}.h-entry .post-info a{text-decoration:none}.h-entry .note-metadata{-webkit-box-orient:horizontal;-webkit-box-direction:normal;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;gap:1rem}.h-entry .note-metadata .syndication-links{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap}.h-entry .note-metadata .syndication-links a{text-decoration:none}.h-entry .note-metadata .syndication-links a svg{height:1rem;width:1rem}
|
||||
:root{--font-family-headings:"Archer SSm A","Archer SSm B",serif;--font-family-body:"Verlag A","Verlag B",sans-serif;--font-family-monospace:"Operator Mono SSm A","Operator Mono SSm B",monospace;--font-size-sm:0.75rem;--font-size-base:1rem;--font-size-md:1.25rem;--font-size-lg:1.5rem;--font-size-xl:1.75rem;--font-size-xxl:2rem;--font-size-xxxl:2.25rem;--color-primary:#334700;--color-secondary:#e3ffb7;--color-link:#00649e;--color-link-visited:#bc7aff;--color-primary-shadow:rgba(16,25,0,.4)}@supports (color:color(display-p3 0 0 0)){:root{--color-primary:color(display-p3 0.21567 0.27838 0.03615);--color-secondary:color(display-p3 0.91016 0.99842 0.74082);--color-link:color(display-p3 0.01045 0.38351 0.63618);--color-link-visited:color(display-p3 0.70467 0.47549 0.99958);--color-primary-shadow:color(display-p3 0.06762 0.09646 0.00441/0.4)}}@supports (color:oklch(0% 0 0)){:root{--color-primary:oklch(36.8% 0.1 125.505deg);--color-secondary:oklch(96.3% 0.1 125.505deg);--color-link:oklch(48.09% 0.146 241.41deg);--color-link-visited:oklch(70.44% 0.21 304.41deg);--color-primary-shadow:oklch(19.56% 0.054 125.505deg/40%)}}body{background-color:var(--color-secondary);color:var(--color-primary);font-family:var(--font-family-body);font-size:var(--font-size-md)}code{font-family:var(--font-family-monospace)}h1,h2,h3,h4,h5,h6{font-family:var(--font-family-headings)}.grid{display:grid;grid-template-columns:5vw 1fr 5vw;grid-template-rows:-webkit-min-content 1fr -webkit-min-content;grid-template-rows:min-content 1fr min-content;row-gap:1rem}#site-header{grid-column:2/3;grid-row:1/2}main{grid-row:2/3}footer,main{grid-column:2/3}footer{grid-row:3/4}footer .iwc-logo{max-width:85vw}footer .footer-actions{-webkit-box-orient:horizontal;-webkit-box-direction:normal;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;gap:1rem}a{color:var(--color-link)}a:visited{color:var(--color-link-visited)}#site-header a:visited,a.auth:visited{color:var(--color-link)}.hljs{border-radius:.5rem}.h-card .hovercard{-webkit-box-orient:vertical;-webkit-box-direction:normal;background-color:var(--color-secondary);border-radius:1rem;-webkit-box-shadow:0 .5rem .5rem .5rem var(--color-primary-shadow);box-shadow:0 .5rem .5rem .5rem var(--color-primary-shadow);display:none;-ms-flex-direction:column;flex-direction:column;gap:.5rem;opacity:0;padding:1rem;position:absolute;-webkit-transition:opacity .5s ease-in-out;transition:opacity .5s ease-in-out;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content;z-index:100}.h-card .hovercard .u-photo{max-width:6rem}.h-card .hovercard .social-icon{height:1rem;width:1rem}.h-card:hover .hovercard{display:-webkit-box;display:-ms-flexbox;display:flex;opacity:1}.h-entry{-webkit-border-start:1px solid var(--color-primary);-webkit-padding-start:.5rem;border-inline-start:1px solid var(--color-primary);padding-inline-start:.5rem}.h-entry .reply-to{font-style:italic}.h-entry .post-info a{text-decoration:none}.h-entry .note-metadata{-webkit-box-orient:horizontal;-webkit-box-direction:normal;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;gap:1rem}.h-entry .note-metadata .syndication-links{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap}.h-entry .note-metadata .syndication-links a{text-decoration:none}.h-entry .note-metadata .syndication-links a svg{height:1rem;width:1rem}
|
||||
|
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -9,6 +9,10 @@ a {
|
|||
&:visited {
|
||||
color: var(--color-link-visited);
|
||||
}
|
||||
|
||||
&.auth:visited {
|
||||
color: var(--color-link);
|
||||
}
|
||||
}
|
||||
|
||||
#site-header {
|
||||
|
|
|
@ -22,4 +22,10 @@ footer {
|
|||
& .iwc-logo {
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
& .footer-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,3 +9,9 @@ document.querySelectorAll('.add-passkey').forEach((el) => {
|
|||
auth.register();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.login-passkey').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
auth.login();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,87 +2,165 @@ class Auth {
|
|||
constructor() {}
|
||||
|
||||
async register() {
|
||||
const { challenge, userId, existing } = await this.getRegisterData();
|
||||
const createOptions = await this.getCreateOptions();
|
||||
|
||||
const publicKeyCredentialCreationOptions = {
|
||||
challenge: new TextEncoder().encode(challenge),
|
||||
challenge: this.base64URLStringToBuffer(createOptions.challenge),
|
||||
rp: {
|
||||
name: 'JB',
|
||||
id: createOptions.rp.id,
|
||||
name: createOptions.rp.name,
|
||||
},
|
||||
user: {
|
||||
id: new TextEncoder().encode(userId),
|
||||
name: 'jonny@jonnybarnes.uk',
|
||||
displayName: 'Jonny',
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{alg: -8, type: 'public-key'}, // Ed25519
|
||||
{alg: -7, type: 'public-key'}, // ES256
|
||||
{alg: -257, type: 'public-key'}, // RS256
|
||||
],
|
||||
excludeCredentials: existing,
|
||||
authenticatorSelection: {
|
||||
userVerification: 'preferred',
|
||||
residentKey: 'required',
|
||||
id: new TextEncoder().encode(window.atob(createOptions.user.id)),
|
||||
name: createOptions.user.name,
|
||||
displayName: createOptions.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: createOptions.pubKeyCredParams,
|
||||
excludeCredentials: [],
|
||||
authenticatorSelection: createOptions.authenticatorSelection,
|
||||
timeout: 60000,
|
||||
};
|
||||
|
||||
const publicKeyCredential = await navigator.credentials.create({
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
});
|
||||
if (!publicKeyCredential) {
|
||||
if (!credential) {
|
||||
throw new Error('Error generating a passkey');
|
||||
}
|
||||
const {
|
||||
id // the key id a.k.a. kid
|
||||
} = publicKeyCredential;
|
||||
const publicKey = publicKeyCredential.response.getPublicKey();
|
||||
const transports = publicKeyCredential.response.getTransports();
|
||||
const response = publicKeyCredential.response;
|
||||
const clientJSONArrayBuffer = response.clientDataJSON;
|
||||
const clientJSON = JSON.parse(new TextDecoder().decode(clientJSONArrayBuffer));
|
||||
const clientChallenge = clientJSON.challenge;
|
||||
// base64 decode the challenge
|
||||
const clientChallengeDecoded = atob(clientChallenge);
|
||||
|
||||
const saved = await this.savePasskey(id, publicKey, transports, clientChallengeDecoded);
|
||||
const authenticatorAttestationResponse = {
|
||||
id: credential.id ? credential.id : null,
|
||||
type: credential.type ? credential.type : null,
|
||||
rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,
|
||||
response: {
|
||||
attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,
|
||||
clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,
|
||||
}
|
||||
};
|
||||
|
||||
if (saved) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('There was an error saving the passkey');
|
||||
}
|
||||
}
|
||||
|
||||
async getRegisterData() {
|
||||
const response = await fetch('/admin/passkeys/init');
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async savePasskey(id, publicKey, transports, challenge) {
|
||||
const formData = new FormData();
|
||||
formData.append('id', id);
|
||||
formData.append('transports', JSON.stringify(transports));
|
||||
formData.append('challenge', challenge);
|
||||
|
||||
// Convert the ArrayBuffer to a Uint8Array
|
||||
const publicKeyArray = new Uint8Array(publicKey);
|
||||
|
||||
// Create a Blob from the Uint8Array
|
||||
const publicKeyBlob = new Blob([publicKeyArray], { type: 'application/octet-stream' });
|
||||
|
||||
formData.append('public_key', publicKeyBlob);
|
||||
|
||||
const response = await fetch('/admin/passkeys/save', {
|
||||
const registerCredential = await window.fetch('/admin/passkeys/register', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
body: JSON.stringify(authenticatorAttestationResponse),
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!registerCredential.ok) {
|
||||
throw new Error('Error saving the passkey');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async getCreateOptions() {
|
||||
const response = await fetch('/admin/passkeys/register', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async login() {
|
||||
const loginData = await this.getLoginData();
|
||||
|
||||
const publicKeyCredential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: this.base64URLStringToBuffer(loginData.challenge),
|
||||
userVerification: loginData.userVerification,
|
||||
timeout: 60000,
|
||||
}
|
||||
});
|
||||
|
||||
if (!publicKeyCredential) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
const authenticatorAttestationResponse = {
|
||||
id: publicKeyCredential.id ? publicKeyCredential.id : '',
|
||||
type: publicKeyCredential.type ? publicKeyCredential.type : '',
|
||||
rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',
|
||||
response: {
|
||||
authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',
|
||||
clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',
|
||||
signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',
|
||||
userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',
|
||||
},
|
||||
};
|
||||
|
||||
const loginAttempt = await window.fetch('/login/passkey', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(authenticatorAttestationResponse),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!loginAttempt.ok) {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
window.location.assign('/admin');
|
||||
}
|
||||
|
||||
async getLoginData() {
|
||||
const response = await fetch('/login/passkey', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a base64 URL string to a buffer.
|
||||
*
|
||||
* Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8
|
||||
*
|
||||
* @param {string} base64URLString
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
base64URLStringToBuffer(base64URLString) {
|
||||
// Convert from Base64URL to Base64
|
||||
const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');
|
||||
/**
|
||||
* Pad with '=' until it's a multiple of four
|
||||
* (4 - (85 % 4 = 1) = 3) % 4 = 3 padding
|
||||
* (4 - (86 % 4 = 2) = 2) % 4 = 2 padding
|
||||
* (4 - (87 % 4 = 3) = 1) % 4 = 1 padding
|
||||
* (4 - (88 % 4 = 0) = 4) % 4 = 0 padding
|
||||
*/
|
||||
const padLength = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64.padEnd(base64.length + padLength, '=');
|
||||
// Convert to a binary string
|
||||
const binary = window.atob(padded);
|
||||
// Convert binary string to buffer
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a buffer to a base64 URL string.
|
||||
*
|
||||
* Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7
|
||||
*
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
bufferToBase64URLString(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let str = '';
|
||||
for (const charCode of bytes) {
|
||||
str += String.fromCharCode(charCode);
|
||||
}
|
||||
const base64String = btoa(str);
|
||||
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,4 +9,5 @@
|
|||
<input type="password" name="password" placeholder="password">
|
||||
<input type="submit" name="submit" value="Login">
|
||||
</form>
|
||||
<p><button type="button" class="login-passkey">Login with Passkeys</button></p>
|
||||
@stop
|
||||
|
|
|
@ -54,9 +54,16 @@
|
|||
</main>
|
||||
|
||||
<footer>
|
||||
<form action="/search" method="get">
|
||||
<input type="text" name="q" title="Search"><button type="submit">Search</button>
|
||||
</form>
|
||||
<div class="footer-actions">
|
||||
<form action="/search" method="get">
|
||||
<input type="text" name="q" title="Search"><button type="submit">Search</button>
|
||||
</form>
|
||||
@auth()
|
||||
<a href="/logout" class="auth">Logout</a>
|
||||
@else
|
||||
<a href="/login" class="auth">Login</a>
|
||||
@endauth
|
||||
</div>
|
||||
<p>Built with love: <a href="/colophon">Colophon</a></p>
|
||||
<a href="https://indieweb.org"><img src="/assets/img/iwc.svg" alt="Indie Web Camp logo" class="iwc-logo"></a>
|
||||
</footer>
|
||||
|
|
|
@ -50,6 +50,8 @@ Route::group(['domain' => config('url.longurl')], function () {
|
|||
// The login routes to get auth’d for admin
|
||||
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
||||
Route::post('login', [AuthController::class, 'login']);
|
||||
Route::get('login/passkey', [PasskeysController::class, 'getRequestOptions']);
|
||||
Route::post('login/passkey', [PasskeysController::class, 'login']);
|
||||
|
||||
// And the logout routes
|
||||
Route::get('logout', [AuthController::class, 'showLogout'])->name('logout');
|
||||
|
@ -146,8 +148,8 @@ Route::group(['domain' => config('url.longurl')], function () {
|
|||
// Passkeys
|
||||
Route::group(['prefix' => 'passkeys'], static function () {
|
||||
Route::get('/', [PasskeysController::class, 'index']);
|
||||
Route::post('save', [PasskeysController::class, 'save']);
|
||||
Route::get('/init', [PasskeysController::class, 'init']);
|
||||
Route::get('register', [PasskeysController::class, 'getCreateOptions']);
|
||||
Route::post('register', [PasskeysController::class, 'create']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ class AdminTest extends TestCase
|
|||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$response->assertRedirect('/admin');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
Loading…
Add table
Reference in a new issue