Admin can now hopefully add a passkey to their account

This commit is contained in:
Jonny Barnes 2023-09-25 18:31:38 +01:00
parent cadd58187a
commit 2fb8339d91
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
16 changed files with 351 additions and 40 deletions

View file

@ -1,6 +1,6 @@
parserOptions: parserOptions:
sourceType: 'module' sourceType: 'module'
ecmaVersion: 8 ecmaVersion: 'latest'
extends: 'eslint:recommended' extends: 'eslint:recommended'
env: env:
browser: true browser: true
@ -25,3 +25,14 @@ rules:
- allow: - allow:
- warn - warn
- error - 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

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Passkey;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class PasskeysController extends Controller
{
public function index(): View
{
$user = auth()->user();
$passkeys = $user->passkey;
return view('admin.passkeys.index', compact('passkeys'));
}
public function save(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'id' => 'required|string|unique:App\Models\Passkey,passkey_id',
'public_key' => 'required|file',
'transports' => 'required|json',
'challenge' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Passkey could not be saved (validation failed)',
]);
}
$validated = $validator->validated();
if (
!session()->has('challenge') ||
$validated['challenge'] !== session('challenge')
) {
return response()->json([
'success' => false,
'message' => 'Passkey could not be saved (challenge failed)',
]);
}
$passkey = new Passkey();
$passkey->passkey_id = $validated['id'];
$passkey->passkey = $validated['public_key']->get();
$passkey->transports = json_decode($validated['transports'], true, 512, JSON_THROW_ON_ERROR);
$passkey->user_id = auth()->user()->id;
$passkey->save();
return response()->json([
'success' => true,
'message' => 'Passkey saved successfully',
]);
}
public function init(): JsonResponse
{
/** @var User $user */
$user = auth()->user();
$passkeys = $user->passkey()->get();
$existing = $passkeys->map(function (Passkey $passkey) {
return [
'id' => $passkey->passkey_id,
'transports' => $passkey->transports,
'type' => 'public-key',
];
})->all();
$challenge = Hash::make(random_bytes(32));
session(['challenge' => $challenge]);
return response()->json([
'challenge' => $challenge,
'userId' => $user->name,
'existing' => $existing,
]);
}
}

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

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Passkey extends Model
{
use HasFactory;
/**
* Save and access the passkey appropriately.
*/
protected function passkey(): Attribute
{
return Attribute::make(
get: static fn ($value) => stream_get_contents($value),
set: static fn ($value) => pg_escape_bytea($value),
);
}
/**
* Save and access the transports appropriately.
*/
protected function transports(): Attribute
{
return Attribute::make(
get: static fn ($value) => json_decode($value, true, 512, JSON_THROW_ON_ERROR),
set: static fn ($value) => json_encode($value, JSON_THROW_ON_ERROR),
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

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

View file

@ -9,6 +9,7 @@
"ext-dom": "*", "ext-dom": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-json": "*", "ext-json": "*",
"ext-pgsql": "*",
"cviebrock/eloquent-sluggable": "^10.0", "cviebrock/eloquent-sluggable": "^10.0",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1", "indieauth/client": "^1.1",

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,33 @@
<?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->binary('passkey');
$table->json('transports');
$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 = { module.exports = {
plugins: { plugins: {
'postcss-import': {}, 'postcss-import': {},
'autoprefixer': {}, 'autoprefixer': {},
'@csstools/postcss-oklab-function': { '@csstools/postcss-oklab-function': {
preserve: true preserve: true
}, },
'postcss-nesting': {}, 'postcss-nesting': {},
'postcss-combine-media-query': {}, 'postcss-combine-media-query': {},
'postcss-combine-duplicated-selectors': { 'postcss-combine-duplicated-selectors': {
removeDuplicatedProperties: true, removeDuplicatedProperties: true,
removeDuplicatedValues: true removeDuplicatedValues: true
}, },
'cssnano': { preset: 'default' }, 'cssnano': { preset: 'default' },
} }
}; };

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,10 +1,11 @@
import '../css/app.css'; import '../css/app.css';
// import { Auth } from './auth.js'; import { Auth } from './auth.js';
//
// let auth = new Auth();
// auth.createCredentials().then((credentials) => { let auth = new Auth();
// // eslint-disable-next-line no-console
// console.log(credentials); document.querySelectorAll('.add-passkey').forEach((el) => {
// }); el.addEventListener('click', () => {
auth.register();
});
});

View file

@ -1,35 +1,88 @@
class Auth { class Auth {
constructor() {} constructor() {}
async createCredentials() { async register() {
const { challenge, userId, existing } = await this.getRegisterData();
const publicKeyCredentialCreationOptions = { const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from( challenge: new TextEncoder().encode(challenge),
'randomStringFromServer',
c => c.charCodeAt(0)
),
rp: { rp: {
id: 'jonnybarnes.localhost',
name: 'JB', name: 'JB',
}, },
user: { user: {
id: Uint8Array.from( id: new TextEncoder().encode(userId),
'UZSL85T9AFC',
c => c.charCodeAt(0)
),
name: 'jonny@jonnybarnes.uk', name: 'jonny@jonnybarnes.uk',
displayName: 'Jonny', displayName: 'Jonny',
}, },
pubKeyCredParams: [{alg: -7, type: 'public-key'}], pubKeyCredParams: [
// authenticatorSelection: { {alg: -8, type: 'public-key'}, // Ed25519
// authenticatorAttachment: 'cross-platform', {alg: -7, type: 'public-key'}, // ES256
// }, {alg: -257, type: 'public-key'}, // RS256
],
excludeCredentials: existing,
authenticatorSelection: {
userVerification: 'preferred',
residentKey: 'required',
},
timeout: 60000, timeout: 60000,
attestation: 'direct'
}; };
return await navigator.credentials.create({ const publicKeyCredential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions publicKey: publicKeyCredentialCreationOptions
}); });
if (!publicKeyCredential) {
throw new Error('Error generating a passkey');
}
const {
id // the key id a.k.a. kid
} = publicKeyCredential;
const publicKey = publicKeyCredential.response.getPublicKey();
const transports = publicKeyCredential.response.getTransports();
const response = publicKeyCredential.response;
const clientJSONArrayBuffer = response.clientDataJSON;
const clientJSON = JSON.parse(new TextDecoder().decode(clientJSONArrayBuffer));
const clientChallenge = clientJSON.challenge;
// base64 decode the challenge
const clientChallengeDecoded = atob(clientChallenge);
const saved = await this.savePasskey(id, publicKey, transports, clientChallengeDecoded);
if (saved) {
window.location.reload();
} else {
alert('There was an error saving the passkey');
}
}
async getRegisterData() {
const response = await fetch('/admin/passkeys/init');
return await response.json();
}
async savePasskey(id, publicKey, transports, challenge) {
const formData = new FormData();
formData.append('id', id);
formData.append('transports', JSON.stringify(transports));
formData.append('challenge', challenge);
// Convert the ArrayBuffer to a Uint8Array
const publicKeyArray = new Uint8Array(publicKey);
// Create a Blob from the Uint8Array
const publicKeyBlob = new Blob([publicKeyArray], { type: 'application/octet-stream' });
formData.append('public_key', publicKeyBlob);
const response = await fetch('/admin/passkeys/save', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
},
});
return response.ok;
} }
} }

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

@ -54,6 +54,6 @@
<h2>Passkeys</h2> <h2>Passkeys</h2>
<p> <p>
List passkeys here? Manager <a href="/admin/passkeys">your passkeys</a>.
</p> </p>
@stop @stop

View file

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

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