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:
|
||||
sourceType: 'module'
|
||||
ecmaVersion: 8
|
||||
ecmaVersion: 'latest'
|
||||
extends: 'eslint:recommended'
|
||||
env:
|
||||
browser: true
|
||||
|
@ -25,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
|
||||
|
|
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;
|
||||
|
||||
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",
|
||||
|
|
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');
|
||||
}
|
||||
};
|
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -1,10 +1,11 @@
|
|||
import '../css/app.css';
|
||||
|
||||
// import { Auth } from './auth.js';
|
||||
//
|
||||
// let auth = new Auth();
|
||||
import { Auth } from './auth.js';
|
||||
|
||||
// auth.createCredentials().then((credentials) => {
|
||||
// // eslint-disable-next-line no-console
|
||||
// console.log(credentials);
|
||||
// });
|
||||
let auth = new Auth();
|
||||
|
||||
document.querySelectorAll('.add-passkey').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
auth.register();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,35 +1,88 @@
|
|||
class Auth {
|
||||
constructor() {}
|
||||
|
||||
async createCredentials() {
|
||||
async register() {
|
||||
const { challenge, userId, existing } = await this.getRegisterData();
|
||||
|
||||
const publicKeyCredentialCreationOptions = {
|
||||
challenge: Uint8Array.from(
|
||||
'randomStringFromServer',
|
||||
c => c.charCodeAt(0)
|
||||
),
|
||||
challenge: new TextEncoder().encode(challenge),
|
||||
rp: {
|
||||
id: 'jonnybarnes.localhost',
|
||||
name: 'JB',
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(
|
||||
'UZSL85T9AFC',
|
||||
c => c.charCodeAt(0)
|
||||
),
|
||||
id: new TextEncoder().encode(userId),
|
||||
name: 'jonny@jonnybarnes.uk',
|
||||
displayName: 'Jonny',
|
||||
},
|
||||
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
|
||||
// authenticatorSelection: {
|
||||
// authenticatorAttachment: 'cross-platform',
|
||||
// },
|
||||
pubKeyCredParams: [
|
||||
{alg: -8, type: 'public-key'}, // Ed25519
|
||||
{alg: -7, type: 'public-key'}, // ES256
|
||||
{alg: -257, type: 'public-key'}, // RS256
|
||||
],
|
||||
excludeCredentials: existing,
|
||||
authenticatorSelection: {
|
||||
userVerification: 'preferred',
|
||||
residentKey: 'required',
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: 'direct'
|
||||
};
|
||||
|
||||
return await navigator.credentials.create({
|
||||
const publicKeyCredential = await navigator.credentials.create({
|
||||
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>
|
||||
<p>
|
||||
List passkeys here?
|
||||
Manager <a href="/admin/passkeys">your passkeys</a>.
|
||||
</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')))
|
||||
|
|
|
@ -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;
|
||||
|
@ -141,6 +142,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::post('save', [PasskeysController::class, 'save']);
|
||||
Route::get('/init', [PasskeysController::class, 'init']);
|
||||
});
|
||||
});
|
||||
|
||||
// Blog pages using ArticlesController
|
||||
|
|
Loading…
Add table
Reference in a new issue