Merge pull request #1100 from jonnybarnes/985-add-passkey-support-for-admin-login

Add passkey support for admin login
This commit is contained in:
Jonny Barnes 2023-10-27 19:37:02 +00:00 • committed by GitHub
commit 1e28b394b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1832 additions and 581 deletions

View file

@ -8,6 +8,9 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,css}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -1,5 +1,6 @@
parserOptions:
sourceType: 'module'
ecmaVersion: 'latest'
extends: 'eslint:recommended'
env:
browser: true
@ -9,7 +10,7 @@ ignorePatterns:
rules:
indent:
- error
- 4
- 2
linebreak-style:
- error
- unix
@ -24,3 +25,14 @@ rules:
- allow:
- warn
- error
no-await-in-loop:
- error
no-promise-executor-return:
- error
require-atomic-updates:
- error
max-nested-callbacks:
- error
- 3
prefer-promise-reject-errors:
- error

View file

@ -1,7 +1,3 @@
{
"extends": ["stylelint-config-standard"],
"rules": {
"indentation": 4,
"import-notation": "string"
}
"extends": ["stylelint-config-standard"]
}

View file

@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
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\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
*/
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 getCreateOptions(): JsonResponse
{
/** @var User $user */
$user = auth()->user();
// RP Entity i.e. the application
$rpEntity = PublicKeyCredentialRpEntity::create(
config('app.name'),
config('url.longurl'),
);
// 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([
'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',
]);
}
}

View file

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

25
app/Models/Passkey.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Passkey extends Model
{
use HasFactory;
/** @inerhitDoc */
protected $fillable = [
'passkey_id',
'passkey',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -24,4 +25,9 @@ class User extends Authenticatable
'password',
'remember_token',
];
public function passkey(): HasMany
{
return $this->hasMany(Passkey::class);
}
}

View file

@ -9,6 +9,7 @@
"ext-dom": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-pgsql": "*",
"cviebrock/eloquent-sluggable": "^10.0",
"guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1",
@ -27,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",

1139
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\Passkey;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Passkey>
*/
class PasskeyFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Passkey::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => $this->faker->numberBetween(1, 1000),
'passkey_id' => $this->faker->uuid,
'passkey' => $this->faker->sha256,
'transports' => ['internal'],
];
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('passkey_id')->unique();
$table->text('passkey');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('passkeys');
}
};

View file

@ -1,16 +1,16 @@
module.exports = {
plugins: {
'postcss-import': {},
'autoprefixer': {},
'@csstools/postcss-oklab-function': {
preserve: true
},
'postcss-nesting': {},
'postcss-combine-media-query': {},
'postcss-combine-duplicated-selectors': {
removeDuplicatedProperties: true,
removeDuplicatedValues: true
},
'cssnano': { preset: 'default' },
}
plugins: {
'postcss-import': {},
'autoprefixer': {},
'@csstools/postcss-oklab-function': {
preserve: true
},
'postcss-nesting': {},
'postcss-combine-media-query': {},
'postcss-combine-duplicated-selectors': {
removeDuplicatedProperties: true,
removeDuplicatedValues: true
},
'cssnano': { preset: 'default' },
}
};

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
{
"/app.js": "/app.js?id=7e1968acfd75b8dc843675097962e3ce",
"/app.js": "/app.js?id=ff1533ec4a7afad65c5bd7bcc2cc7d7b",
"/app-dark.css": "/app-dark.css?id=15c72df05e2b1147fa3e4b0670cfb435",
"/app.css": "/app.css?id=4d6a1a7fe095eedc2cb2a4ce822ea8a5",
"/img/favicon.png": "/img/favicon.png?id=1542bfe8a0010dcbee710da13cce367f",

View file

@ -1,6 +1,6 @@
@import "variables.css";
@import "fonts.css";
@import "layout.css";
@import "colours.css";
@import "code.css";
@import "content.css";
@import url('variables.css');
@import url('fonts.css');
@import url('layout.css');
@import url('colours.css');
@import url('code.css');
@import url('content.css');

View file

@ -1,3 +1,3 @@
.hljs {
border-radius: .5rem;
border-radius: .5rem;
}

Some files were not shown because too many files have changed in this diff Show more