Merge pull request #1100 from jonnybarnes/985-add-passkey-support-for-admin-login
Add passkey support for admin login
This commit is contained in:
commit
1e28b394b6
36 changed files with 1828 additions and 577 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"rules": {
|
||||
"indentation": 4,
|
||||
"import-notation": "string"
|
||||
}
|
||||
"extends": ["stylelint-config-standard"]
|
||||
}
|
||||
|
|
257
app/Http/Controllers/Admin/PasskeysController.php
Normal file
257
app/Http/Controllers/Admin/PasskeysController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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
25
app/Models/Passkey.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
1139
composer.lock
generated
File diff suppressed because it is too large
Load diff
34
database/factories/PasskeyFactory.php
Normal file
34
database/factories/PasskeyFactory.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
32
database/migrations/2023_08_27_113904_create_passkeys.php
Normal file
32
database/migrations/2023_08_27_113904_create_passkeys.php
Normal 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');
|
||||
}
|
||||
};
|
|
@ -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' },
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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.
2
public/vendor/horizon/app.js
vendored
2
public/vendor/horizon/app.js
vendored
File diff suppressed because one or more lines are too long
2
public/vendor/horizon/mix-manifest.json
vendored
2
public/vendor/horizon/mix-manifest.json
vendored
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
.hljs {
|
||||
border-radius: .5rem;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.p-bridgy-twitter-content {
|
||||
display: none;
|
||||
}
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -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
167
resources/js/auth.js
Normal 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 };
|
18
resources/views/admin/passkeys/index.blade.php
Normal file
18
resources/views/admin/passkeys/index.blade.php
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 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');
|
||||
|
@ -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
|
||||
|
|
|
@ -46,7 +46,7 @@ class AdminTest extends TestCase
|
|||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$response->assertRedirect('/admin');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue