Admin can now hopefully add a passkey to their account
This commit is contained in:
parent
cadd58187a
commit
2fb8339d91
16 changed files with 351 additions and 40 deletions
|
@ -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
|
||||||
|
|
93
app/Http/Controllers/Admin/PasskeysController.php
Normal file
93
app/Http/Controllers/Admin/PasskeysController.php
Normal 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
42
app/Models/Passkey.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
33
database/migrations/2023_08_27_113904_create_passkeys.php
Normal file
33
database/migrations/2023_08_27_113904_create_passkeys.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
|
@ -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.
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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
|
|
@ -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
|
||||||
|
|
|
@ -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')))
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue