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 1828 additions and 577 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;
}

View file

@ -1,18 +1,22 @@
body {
background-color: var(--color-secondary);
color: var(--color-primary);
background-color: var(--color-secondary);
color: var(--color-primary);
}
a {
color: var(--color-link);
color: var(--color-link);
&:visited {
color: var(--color-link-visited);
}
&:visited {
color: var(--color-link-visited);
}
&.auth:visited {
color: var(--color-link);
}
}
#site-header {
& a:visited {
color: var(--color-link);
}
& a:visited {
color: var(--color-link);
}
}

View file

@ -1,36 +1,35 @@
@import "posse.css";
@import "h-card.css";
@import url('h-card.css');
.h-entry {
border-inline-start: 1px solid var(--color-primary);
padding-inline-start: .5rem;
border-inline-start: 1px solid var(--color-primary);
padding-inline-start: .5rem;
& .reply-to {
font-style: italic;
& .reply-to {
font-style: italic;
}
& .post-info {
& a {
text-decoration: none;
}
}
& .post-info {
& a {
text-decoration: none;
}
}
& .note-metadata {
display: flex;
flex-direction: row;
gap: 1rem;
& .syndication-links {
flex-flow: row wrap;
& a {
text-decoration: none;
& svg {
width: 1rem;
height: 1rem;
}
}
& .note-metadata {
display: flex;
flex-direction: row;
gap: 1rem;
& .syndication-links {
flex-flow: row wrap;
& a {
text-decoration: none;
& svg {
width: 1rem;
height: 1rem;
}
}
}
}
}

View file

@ -1,10 +1,10 @@
body {
font-family: var(--font-family-body);
font-size: var(--font-size-md);
font-family: var(--font-family-body);
font-size: var(--font-size-md);
}
code {
font-family: var(--font-family-monospace);
font-family: var(--font-family-monospace);
}
h1,
@ -13,5 +13,5 @@ h3,
h4,
h5,
h6 {
font-family: var(--font-family-headings);
font-family: var(--font-family-headings);
}

View file

@ -1,32 +1,32 @@
.h-card {
& .hovercard {
display: none;
position: absolute;
z-index: 100;
padding: 1rem;
border-radius: 1rem;
box-shadow: 0 .5rem .5rem .5rem var(--color-primary-shadow);
background-color: var(--color-secondary);
width: fit-content;
transition: opacity 0.5s ease-in-out;
opacity: 0;
flex-direction: column;
gap: .5rem;
& .u-photo {
max-width: 6rem;
}
& .social-icon {
width: 1rem;
height: 1rem;
}
}
&:hover {
& .hovercard {
display: none;
position: absolute;
z-index: 100;
padding: 1rem;
border-radius: 1rem;
box-shadow: 0 .5rem .5rem .5rem var(--color-primary-shadow);
background-color: var(--color-secondary);
width: fit-content;
transition: opacity 0.5s ease-in-out;
opacity: 0;
flex-direction: column;
gap: .5rem;
& .u-photo {
max-width: 6rem;
}
& .social-icon {
width: 1rem;
height: 1rem;
}
}
&:hover {
& .hovercard {
display: flex;
opacity: 1;
}
display: flex;
opacity: 1;
}
}
}

View file

@ -1,25 +1,31 @@
.grid {
display: grid;
grid-template-columns: 5vw 1fr 5vw;
grid-template-rows: min-content 1fr min-content;
row-gap: 1rem;
display: grid;
grid-template-columns: 5vw 1fr 5vw;
grid-template-rows: min-content 1fr min-content;
row-gap: 1rem;
}
#site-header {
grid-column: 2 / 3;
grid-row: 1 / 2;
grid-column: 2 / 3;
grid-row: 1 / 2;
}
main {
grid-column: 2 / 3;
grid-row: 2 / 3;
grid-column: 2 / 3;
grid-row: 2 / 3;
}
footer {
grid-column: 2 / 3;
grid-row: 3 / 4;
grid-column: 2 / 3;
grid-row: 3 / 4;
& .iwc-logo {
max-width: 85vw;
}
& .iwc-logo {
max-width: 85vw;
}
& .footer-actions {
display: flex;
flex-direction: row;
gap: 1rem;
}
}

View file

@ -1,3 +0,0 @@
.p-bridgy-twitter-content {
display: none;
}

View file

@ -1,22 +1,22 @@
:root {
/* Font Family */
--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 Family */
--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 */
--font-size-sm: 0.75rem; /* 12px */
--font-size-base: 1rem; /* 16px, base */
--font-size-md: 1.25rem; /* 20px */
--font-size-lg: 1.5rem; /* 24px */
--font-size-xl: 1.75rem; /* 28px */
--font-size-xxl: 2rem; /* 32px */
--font-size-xxxl: 2.25rem; /* 36px */
/* Font Size */
--font-size-sm: 0.75rem; /* 12px */
--font-size-base: 1rem; /* 16px, base */
--font-size-md: 1.25rem; /* 20px */
--font-size-lg: 1.5rem; /* 24px */
--font-size-xl: 1.75rem; /* 28px */
--font-size-xxl: 2rem; /* 32px */
--font-size-xxxl: 2.25rem; /* 36px */
/* Colours */
--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%);
/* Colours */
--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%);
}

View file

@ -1 +1,17 @@
import '../css/app.css';
import { Auth } from './auth.js';
let auth = new Auth();
document.querySelectorAll('.add-passkey').forEach((el) => {
el.addEventListener('click', () => {
auth.register();
});
});
document.querySelectorAll('.login-passkey').forEach((el) => {
el.addEventListener('click', () => {
auth.login();
});
});

167
resources/js/auth.js Normal file
View file

@ -0,0 +1,167 @@
class Auth {
constructor() {}
async register() {
const createOptions = await this.getCreateOptions();
const publicKeyCredentialCreationOptions = {
challenge: this.base64URLStringToBuffer(createOptions.challenge),
rp: {
id: createOptions.rp.id,
name: createOptions.rp.name,
},
user: {
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 credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
if (!credential) {
throw new Error('Error generating a passkey');
}
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,
}
};
const registerCredential = await window.fetch('/admin/passkeys/register', {
method: 'POST',
body: JSON.stringify(authenticatorAttestationResponse),
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
},
});
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, '');
}
}
export { Auth };

View file

@ -0,0 +1,18 @@
@extends('master')
@section('title')Passkeys « Admin CP « @stop
@section('content')
<h1>Passkeys</h1>
@if(count($passkeys) > 0)
<p>You have the following passkeys saved:</p>
<ul>
@foreach($passkeys as $passkey)
<li>{{ $passkey->passkey_id }}</li>
@endforeach
</ul>
@else
<p>You have no passkey saved.</p>
@endif
<button type="button" class="add-passkey">Add Passkey</button>
@stop

View file

@ -51,4 +51,9 @@
<p>
Edit your <a href="/admin/bio">bio</a>.
</p>
<h2>Passkeys</h2>
<p>
Manager <a href="/admin/passkeys">your passkeys</a>.
</p>
@stop

View file

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

View file

@ -2,6 +2,7 @@
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title'){{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width">
@if (!empty(config('app.font_link')))
@ -53,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>

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\ContactsController as AdminContactsController;
use App\Http\Controllers\Admin\HomeController;
use App\Http\Controllers\Admin\LikesController as AdminLikesController;
use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
use App\Http\Controllers\Admin\SyndicationTargetsController;
use App\Http\Controllers\ArticlesController;
@ -49,6 +50,8 @@ Route::group(['domain' => config('url.longurl')], function () {
// The login routes to get authd 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');
@ -141,6 +144,13 @@ Route::group(['domain' => config('url.longurl')], function () {
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show');
Route::put('/', [BioController::class, 'update']);
});
// Passkeys
Route::group(['prefix' => 'passkeys'], static function () {
Route::get('/', [PasskeysController::class, 'index']);
Route::get('register', [PasskeysController::class, 'getCreateOptions']);
Route::post('register', [PasskeysController::class, 'create']);
});
});
// Blog pages using ArticlesController

View file

@ -46,7 +46,7 @@ class AdminTest extends TestCase
'password' => 'password',
]);
$response->assertRedirect('/');
$response->assertRedirect('/admin');
}
/** @test */

View file

@ -6,103 +6,103 @@ const EslintPlugin = require('eslint-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const config = {
entry: ['./resources/js/app.js'],
output: {
path: path.resolve('./public/assets'),
filename: 'app.js',
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: "defaults" }]
]
}
}
}]
},
plugins: [
new StyleLintPlugin({
configFile: path.resolve(__dirname + '/.stylelintrc'),
context: path.resolve(__dirname + '/resources/css'),
files: '**/*.css',
}),
new EslintPlugin({
context: path.resolve(__dirname + '/resources/js'),
files: '**/*.js',
}),
new CompressionPlugin({
filename: "[path][base].br",
algorithm: "brotliCompress",
test: /\.js$|\.css$/,
exclude: /.map$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
}),
]
entry: ['./resources/js/app.js'],
output: {
path: path.resolve('./public/assets'),
filename: 'app.js',
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: "defaults" }]
]
}
}
}]
},
plugins: [
new StyleLintPlugin({
configFile: path.resolve(__dirname + '/.stylelintrc'),
context: path.resolve(__dirname + '/resources/css'),
files: '**/*.css',
}),
new EslintPlugin({
context: path.resolve(__dirname + '/resources/js'),
files: '**/*.js',
}),
new CompressionPlugin({
filename: "[path][base].br",
algorithm: "brotliCompress",
test: /\.js$|\.css$/,
exclude: /.map$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
}),
]
};
module.exports = (env, argv) => {
if (argv.mode === 'development') {
config.devtool = 'eval-source-map';
if (argv.mode === 'development') {
config.devtool = 'eval-source-map';
config.module.rules.push({
test: /\.css$/,
exclude: /node_modules/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
sourceMap: true
}
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
config: path.resolve(__dirname, 'postcss.config.js'),
},
sourceMap: true
}
}
]
});
}
config.module.rules.push({
test: /\.css$/,
exclude: /node_modules/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
sourceMap: true
}
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
config: path.resolve(__dirname, 'postcss.config.js'),
},
sourceMap: true
}
}
]
});
}
if (argv.mode === 'production') {
config.module.rules.push({
test: /\.css$/,
exclude: /node_modules/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
config: path.resolve(__dirname, 'postcss.config.js'),
},
}
}
]
});
if (argv.mode === 'production') {
config.module.rules.push({
test: /\.css$/,
exclude: /node_modules/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
config: path.resolve(__dirname, 'postcss.config.js'),
},
}
}
]
});
config.plugins.push(new MiniCssExtractPlugin({filename: 'app.css'}));
}
config.plugins.push(new MiniCssExtractPlugin({filename: 'app.css'}));
}
return config;
return config;
};