diff --git a/.eslintrc.yml b/.eslintrc.yml index d3156688..8e72ef3e 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -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 diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php new file mode 100644 index 00000000..61388175 --- /dev/null +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -0,0 +1,93 @@ +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, + ]); + } +} diff --git a/app/Models/Passkey.php b/app/Models/Passkey.php new file mode 100644 index 00000000..64461eca --- /dev/null +++ b/app/Models/Passkey.php @@ -0,0 +1,42 @@ + 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); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index bc57ee54..7bd0472f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/composer.json b/composer.json index f644792f..c41e6eff 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/database/factories/PasskeyFactory.php b/database/factories/PasskeyFactory.php new file mode 100644 index 00000000..8e70e21d --- /dev/null +++ b/database/factories/PasskeyFactory.php @@ -0,0 +1,34 @@ + + */ +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 + */ + public function definition(): array + { + return [ + 'user_id' => $this->faker->numberBetween(1, 1000), + 'passkey_id' => $this->faker->uuid, + 'passkey' => $this->faker->sha256, + 'transports' => ['internal'], + ]; + } +} diff --git a/database/migrations/2023_08_27_113904_create_passkeys.php b/database/migrations/2023_08_27_113904_create_passkeys.php new file mode 100644 index 00000000..a2450d65 --- /dev/null +++ b/database/migrations/2023_08_27_113904_create_passkeys.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/postcss.config.js b/postcss.config.js index b3f36747..f0ba5a6f 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -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' }, + } }; diff --git a/public/assets/app.js b/public/assets/app.js index e8471e65..f30dcde8 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -16,7 +16,17 @@ \*****************************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _css_app_css__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../css/app.css */ \"./resources/css/app.css\");\n\n\n// import { Auth } from './auth.js';\n//\n// let auth = new Auth();\n\n// auth.createCredentials().then((credentials) => {\n// // eslint-disable-next-line no-console\n// console.log(credentials);\n// });//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiLi9yZXNvdXJjZXMvanMvYXBwLmpzIiwibWFwcGluZ3MiOiI7O0FBQXdCOztBQUV4QjtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0EiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9qYnVrLWZyb250ZW5kLy4vcmVzb3VyY2VzL2pzL2FwcC5qcz82ZDQwIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAnLi4vY3NzL2FwcC5jc3MnO1xuXG4vLyBpbXBvcnQgeyBBdXRoIH0gZnJvbSAnLi9hdXRoLmpzJztcbi8vXG4vLyBsZXQgYXV0aCA9IG5ldyBBdXRoKCk7XG5cbi8vIGF1dGguY3JlYXRlQ3JlZGVudGlhbHMoKS50aGVuKChjcmVkZW50aWFscykgPT4ge1xuLy8gICAvLyBlc2xpbnQtZGlzYWJsZS1uZXh0LWxpbmUgbm8tY29uc29sZVxuLy8gICBjb25zb2xlLmxvZyhjcmVkZW50aWFscyk7XG4vLyB9KTtcbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///./resources/js/app.js\n"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _css_app_css__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../css/app.css */ \"./resources/css/app.css\");\n/* harmony import */ var _auth_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./auth.js */ \"./resources/js/auth.js\");\n\n\nlet auth = new _auth_js__WEBPACK_IMPORTED_MODULE_1__.Auth();\ndocument.querySelectorAll('.add-passkey').forEach(el => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiLi9yZXNvdXJjZXMvanMvYXBwLmpzIiwibWFwcGluZ3MiOiI7OztBQUF3QjtBQUVTO0FBRWpDLElBQUlDLElBQUksR0FBRyxJQUFJRCwwQ0FBSSxDQUFDLENBQUM7QUFFckJFLFFBQVEsQ0FBQ0MsZ0JBQWdCLENBQUMsY0FBYyxDQUFDLENBQUNDLE9BQU8sQ0FBRUMsRUFBRSxJQUFLO0VBQ3hEQSxFQUFFLENBQUNDLGdCQUFnQixDQUFDLE9BQU8sRUFBRSxNQUFNO0lBQ2pDTCxJQUFJLENBQUNNLFFBQVEsQ0FBQyxDQUFDO0VBQ2pCLENBQUMsQ0FBQztBQUNKLENBQUMsQ0FBQyIsInNvdXJjZXMiOlsid2VicGFjazovL2pidWstZnJvbnRlbmQvLi9yZXNvdXJjZXMvanMvYXBwLmpzPzZkNDAiXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICcuLi9jc3MvYXBwLmNzcyc7XG5cbmltcG9ydCB7IEF1dGggfSBmcm9tICcuL2F1dGguanMnO1xuXG5sZXQgYXV0aCA9IG5ldyBBdXRoKCk7XG5cbmRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5hZGQtcGFzc2tleScpLmZvckVhY2goKGVsKSA9PiB7XG4gIGVsLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKCkgPT4ge1xuICAgIGF1dGgucmVnaXN0ZXIoKTtcbiAgfSk7XG59KTtcbiJdLCJuYW1lcyI6WyJBdXRoIiwiYXV0aCIsImRvY3VtZW50IiwicXVlcnlTZWxlY3RvckFsbCIsImZvckVhY2giLCJlbCIsImFkZEV2ZW50TGlzdGVuZXIiLCJyZWdpc3RlciJdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///./resources/js/app.js\n"); + +/***/ }), + +/***/ "./resources/js/auth.js": +/*!******************************!*\ + !*** ./resources/js/auth.js ***! + \******************************/ +/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ Auth: function() { return /* binding */ Auth; }\n/* harmony export */ });\nclass Auth {\n constructor() {}\n async register() {\n const {\n challenge,\n userId,\n existing\n } = await this.getRegisterData();\n const publicKeyCredentialCreationOptions = {\n challenge: new TextEncoder().encode(challenge),\n rp: {\n name: 'JB'\n },\n user: {\n id: new TextEncoder().encode(userId),\n name: 'jonny@jonnybarnes.uk',\n displayName: 'Jonny'\n },\n pubKeyCredParams: [{\n alg: -8,\n type: 'public-key'\n },\n // Ed25519\n {\n alg: -7,\n type: 'public-key'\n },\n // ES256\n {\n alg: -257,\n type: 'public-key'\n } // RS256\n ],\n\n excludeCredentials: existing,\n authenticatorSelection: {\n userVerification: 'preferred',\n residentKey: 'required'\n },\n timeout: 60000\n };\n const publicKeyCredential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!publicKeyCredential) {\n throw new Error('Error generating a passkey');\n }\n const {\n id // the key id a.k.a. kid\n } = publicKeyCredential;\n const publicKey = publicKeyCredential.response.getPublicKey();\n const transports = publicKeyCredential.response.getTransports();\n const response = publicKeyCredential.response;\n const clientJSONArrayBuffer = response.clientDataJSON;\n const clientJSON = JSON.parse(new TextDecoder().decode(clientJSONArrayBuffer));\n const clientChallenge = clientJSON.challenge;\n // base64 decode the challenge\n const clientChallengeDecoded = atob(clientChallenge);\n const saved = await this.savePasskey(id, publicKey, transports, clientChallengeDecoded);\n if (saved) {\n window.location.reload();\n } else {\n alert('There was an error saving the passkey');\n }\n }\n async getRegisterData() {\n const response = await fetch('/admin/passkeys/init');\n return await response.json();\n }\n async savePasskey(id, publicKey, transports, challenge) {\n const formData = new FormData();\n formData.append('id', id);\n formData.append('transports', JSON.stringify(transports));\n formData.append('challenge', challenge);\n\n // Convert the ArrayBuffer to a Uint8Array\n const publicKeyArray = new Uint8Array(publicKey);\n\n // Create a Blob from the Uint8Array\n const publicKeyBlob = new Blob([publicKeyArray], {\n type: 'application/octet-stream'\n });\n formData.append('public_key', publicKeyBlob);\n const response = await fetch('/admin/passkeys/save', {\n method: 'POST',\n body: formData,\n headers: {\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n }\n });\n return response.ok;\n }\n}\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,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Q0FBQy9CLEVBQUUsRUFBRWMsU0FBUyxFQUFFSSxVQUFVLEVBQUU1QixTQUFTLEVBQUU7SUFDdEQsTUFBTWdELFFBQVEsR0FBRyxJQUFJQyxRQUFRLENBQUMsQ0FBQztJQUMvQkQsUUFBUSxDQUFDRSxNQUFNLENBQUMsSUFBSSxFQUFFeEMsRUFBRSxDQUFDO0lBQ3pCc0MsUUFBUSxDQUFDRSxNQUFNLENBQUMsWUFBWSxFQUFFakIsSUFBSSxDQUFDa0IsU0FBUyxDQUFDdkIsVUFBVSxDQUFDLENBQUM7SUFDekRvQixRQUFRLENBQUNFLE1BQU0sQ0FBQyxXQUFXLEVBQUVsRCxTQUFTLENBQUM7O0lBRXZDO0lBQ0EsTUFBTW9ELGNBQWMsR0FBRyxJQUFJQyxVQUFVLENBQUM3QixTQUFTLENBQUM7O0lBRWhEO0lBQ0EsTUFBTThCLGFBQWEsR0FBRyxJQUFJQyxJQUFJLENBQUMsQ0FBQ0gsY0FBYyxDQUFDLEVBQUU7TUFBRXRDLElBQUksRUFBRTtJQUEyQixDQUFDLENBQUM7SUFFdEZrQyxRQUFRLENBQUNFLE1BQU0sQ0FBQyxZQUFZLEVBQUVJLGFBQWEsQ0FBQztJQUU1QyxNQUFNNUIsUUFBUSxHQUFHLE1BQU1vQixLQUFLLENBQUMsc0JBQXNCLEVBQUU7TUFDbkRVLE1BQU0sRUFBRSxNQUFNO01BQ2RDLElBQUksRUFBRVQsUUFBUTtNQUNkVSxPQUFPLEVBQUU7UUFDUCxjQUFjLEVBQUVDLFFBQVEsQ0FBQ0MsYUFBYSxDQUFDLHlCQUF5QixDQUFDLENBQUNDLFlBQVksQ0FBQyxTQUFTO01BQzFGO0lBQ0YsQ0FBQyxDQUFDO0lBRUYsT0FBT25DLFFBQVEsQ0FBQ29DLEVBQUU7RUFDcEI7QUFDRiIsInNvdXJjZXMiOlsid2VicGFjazovL2pidWstZnJvbnRlbmQvLi9yZXNvdXJjZXMvanMvYXV0aC5qcz84ZjRmIl0sInNvdXJjZXNDb250ZW50IjpbImNsYXNzIEF1dGgge1xuICBjb25zdHJ1Y3RvcigpIHt9XG5cbiAgYXN5bmMgcmVnaXN0ZXIoKSB7XG4gICAgY29uc3QgeyBjaGFsbGVuZ2UsIHVzZXJJZCwgZXhpc3RpbmcgfSA9IGF3YWl0IHRoaXMuZ2V0UmVnaXN0ZXJEYXRhKCk7XG5cbiAgICBjb25zdCBwdWJsaWNLZXlDcmVkZW50aWFsQ3JlYXRpb25PcHRpb25zID0ge1xuICAgICAgY2hhbGxlbmdlOiBuZXcgVGV4dEVuY29kZXIoKS5lbmNvZGUoY2hhbGxlbmdlKSxcbiAgICAgIHJwOiB7XG4gICAgICAgIG5hbWU6ICdKQicsXG4gICAgICB9LFxuICAgICAgdXNlcjoge1xuICAgICAgICBpZDogbmV3IFRleHRFbmNvZGVyKCkuZW5jb2RlKHVzZXJJZCksXG4gICAgICAgIG5hbWU6ICdqb25ueUBqb25ueWJhcm5lcy51aycsXG4gICAgICAgIGRpc3BsYXlOYW1lOiAnSm9ubnknLFxuICAgICAgfSxcbiAgICAgIHB1YktleUNyZWRQYXJhbXM6IFtcbiAgICAgICAge2FsZzogLTgsIHR5cGU6ICdwdWJsaWMta2V5J30sIC8vIEVkMjU1MTlcbiAgICAgICAge2FsZzogLTcsIHR5cGU6ICdwdWJsaWMta2V5J30sIC8vIEVTMjU2XG4gICAgICAgIHthbGc6IC0yNTcsIHR5cGU6ICdwdWJsaWMta2V5J30sIC8vIFJTMjU2XG4gICAgICBdLFxuICAgICAgZXhjbHVkZUNyZWRlbnRpYWxzOiBleGlzdGluZyxcbiAgICAgIGF1dGhlbnRpY2F0b3JTZWxlY3Rpb246IHtcbiAgICAgICAgdXNlclZlcmlmaWNhdGlvbjogJ3ByZWZlcnJlZCcsXG4gICAgICAgIHJlc2lkZW50S2V5OiAncmVxdWlyZWQnLFxuICAgICAgfSxcbiAgICAgIHRpbWVvdXQ6IDYwMDAwLFxuICAgIH07XG5cbiAgICBjb25zdCBwdWJsaWNLZXlDcmVkZW50aWFsID0gYXdhaXQgbmF2aWdhdG9yLmNyZWRlbnRpYWxzLmNyZWF0ZSh7XG4gICAgICBwdWJsaWNLZXk6IHB1YmxpY0tleUNyZWRlbnRpYWxDcmVhdGlvbk9wdGlvbnNcbiAgICB9KTtcbiAgICBpZiAoIXB1YmxpY0tleUNyZWRlbnRpYWwpIHtcbiAgICAgIHRocm93IG5ldyBFcnJvcignRXJyb3IgZ2VuZXJhdGluZyBhIHBhc3NrZXknKTtcbiAgICB9XG4gICAgY29uc3Qge1xuICAgICAgaWQgLy8gdGhlIGtleSBpZCBhLmsuYS4ga2lkXG4gICAgfSA9IHB1YmxpY0tleUNyZWRlbnRpYWw7XG4gICAgY29uc3QgcHVibGljS2V5ID0gcHVibGljS2V5Q3JlZGVudGlhbC5yZXNwb25zZS5nZXRQdWJsaWNLZXkoKTtcbiAgICBjb25zdCB0cmFuc3BvcnRzID0gcHVibGljS2V5Q3JlZGVudGlhbC5yZXNwb25zZS5nZXRUcmFuc3BvcnRzKCk7XG4gICAgY29uc3QgcmVzcG9uc2UgPSBwdWJsaWNLZXlDcmVkZW50aWFsLnJlc3BvbnNlO1xuICAgIGNvbnN0IGNsaWVudEpTT05BcnJheUJ1ZmZlciA9IHJlc3BvbnNlLmNsaWVudERhdGFKU09OO1xuICAgIGNvbnN0IGNsaWVudEpTT04gPSBKU09OLnBhcnNlKG5ldyBUZXh0RGVjb2RlcigpLmRlY29kZShjbGllbnRKU09OQXJyYXlCdWZmZXIpKTtcbiAgICBjb25zdCBjbGllbnRDaGFsbGVuZ2UgPSBjbGllbnRKU09OLmNoYWxsZW5nZTtcbiAgICAvLyBiYXNlNjQgZGVjb2RlIHRoZSBjaGFsbGVuZ2VcbiAgICBjb25zdCBjbGllbnRDaGFsbGVuZ2VEZWNvZGVkID0gYXRvYihjbGllbnRDaGFsbGVuZ2UpO1xuXG4gICAgY29uc3Qgc2F2ZWQgPSBhd2FpdCB0aGlzLnNhdmVQYXNza2V5KGlkLCBwdWJsaWNLZXksIHRyYW5zcG9ydHMsIGNsaWVudENoYWxsZW5nZURlY29kZWQpO1xuXG4gICAgaWYgKHNhdmVkKSB7XG4gICAgICB3aW5kb3cubG9jYXRpb24ucmVsb2FkKCk7XG4gICAgfSBlbHNlIHtcbiAgICAgIGFsZXJ0KCdUaGVyZSB3YXMgYW4gZXJyb3Igc2F2aW5nIHRoZSBwYXNza2V5Jyk7XG4gICAgfVxuICB9XG5cbiAgYXN5bmMgZ2V0UmVnaXN0ZXJEYXRhKCkge1xuICAgIGNvbnN0IHJlc3BvbnNlID0gYXdhaXQgZmV0Y2goJy9hZG1pbi9wYXNza2V5cy9pbml0Jyk7XG5cbiAgICByZXR1cm4gYXdhaXQgcmVzcG9uc2UuanNvbigpO1xuICB9XG5cbiAgYXN5bmMgc2F2ZVBhc3NrZXkoaWQsIHB1YmxpY0tleSwgdHJhbnNwb3J0cywgY2hhbGxlbmdlKSB7XG4gICAgY29uc3QgZm9ybURhdGEgPSBuZXcgRm9ybURhdGEoKTtcbiAgICBmb3JtRGF0YS5hcHBlbmQoJ2lkJywgaWQpO1xuICAgIGZvcm1EYXRhLmFwcGVuZCgndHJhbnNwb3J0cycsIEpTT04uc3RyaW5naWZ5KHRyYW5zcG9ydHMpKTtcbiAgICBmb3JtRGF0YS5hcHBlbmQoJ2NoYWxsZW5nZScsIGNoYWxsZW5nZSk7XG5cbiAgICAvLyBDb252ZXJ0IHRoZSBBcnJheUJ1ZmZlciB0byBhIFVpbnQ4QXJyYXlcbiAgICBjb25zdCBwdWJsaWNLZXlBcnJheSA9IG5ldyBVaW50OEFycmF5KHB1YmxpY0tleSk7XG5cbiAgICAvLyBDcmVhdGUgYSBCbG9iIGZyb20gdGhlIFVpbnQ4QXJyYXlcbiAgICBjb25zdCBwdWJsaWNLZXlCbG9iID0gbmV3IEJsb2IoW3B1YmxpY0tleUFycmF5XSwgeyB0eXBlOiAnYXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtJyB9KTtcblxuICAgIGZvcm1EYXRhLmFwcGVuZCgncHVibGljX2tleScsIHB1YmxpY0tleUJsb2IpO1xuXG4gICAgY29uc3QgcmVzcG9uc2UgPSBhd2FpdCBmZXRjaCgnL2FkbWluL3Bhc3NrZXlzL3NhdmUnLCB7XG4gICAgICBtZXRob2Q6ICdQT1NUJyxcbiAgICAgIGJvZHk6IGZvcm1EYXRhLFxuICAgICAgaGVhZGVyczoge1xuICAgICAgICAnWC1DU1JGLVRPS0VOJzogZG9jdW1lbnQucXVlcnlTZWxlY3RvcignbWV0YVtuYW1lPVwiY3NyZi10b2tlblwiXScpLmdldEF0dHJpYnV0ZSgnY29udGVudCcpLFxuICAgICAgfSxcbiAgICB9KTtcblxuICAgIHJldHVybiByZXNwb25zZS5vaztcbiAgfVxufVxuXG5leHBvcnQgeyBBdXRoIH07XG4iXSwibmFtZXMiOlsiQXV0aCIsImNvbnN0cnVjdG9yIiwicmVnaXN0ZXIiLCJjaGFsbGVuZ2UiLCJ1c2VySWQiLCJleGlzdGluZyIsImdldFJlZ2lzdGVyRGF0YSIsInB1YmxpY0tleUNyZWRlbnRpYWxDcmVhdGlvbk9wdGlvbnMiLCJUZXh0RW5jb2RlciIsImVuY29kZSIsInJwIiwibmFtZSIsInVzZXIiLCJpZCIsImRpc3BsYXlOYW1lIiwicHViS2V5Q3JlZFBhcmFtcyIsImFsZyIsInR5cGUiLCJleGNsdWRlQ3JlZGVudGlhbHMiLCJhdXRoZW50aWNhdG9yU2VsZWN0aW9uIiwidXNlclZlcmlmaWNhdGlvbiIsInJlc2lkZW50S2V5IiwidGltZW91dCIsInB1YmxpY0tleUNyZWRlbnRpYWwiLCJuYXZpZ2F0b3IiLCJjcmVkZW50aWFscyIsImNyZWF0ZSIsInB1YmxpY0tleSIsIkVycm9yIiwicmVzcG9uc2UiLCJnZXRQdWJsaWNLZXkiLCJ0cmFuc3BvcnRzIiwiZ2V0VHJhbnNwb3J0cyIsImNsaWVudEpTT05BcnJheUJ1ZmZlciIsImNsaWVudERhdGFKU09OIiwiY2xpZW50SlNPTiIsIkpTT04iLCJwYXJzZSIsIlRleHREZWNvZGVyIiwiZGVjb2RlIiwiY2xpZW50Q2hhbGxlbmdlIiwiY2xpZW50Q2hhbGxlbmdlRGVjb2RlZCIsImF0b2IiLCJzYXZlZCIsInNhdmVQYXNza2V5Iiwid2luZG93IiwibG9jYXRpb24iLCJyZWxvYWQiLCJhbGVydCIsImZldGNoIiwianNvbiIsImZvcm1EYXRhIiwiRm9ybURhdGEiLCJhcHBlbmQiLCJzdHJpbmdpZnkiLCJwdWJsaWNLZXlBcnJheSIsIlVpbnQ4QXJyYXkiLCJwdWJsaWNLZXlCbG9iIiwiQmxvYiIsIm1ldGhvZCIsImJvZHkiLCJoZWFkZXJzIiwiZG9jdW1lbnQiLCJxdWVyeVNlbGVjdG9yIiwiZ2V0QXR0cmlidXRlIiwib2siXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///./resources/js/auth.js\n"); /***/ }), diff --git a/public/assets/app.js.br b/public/assets/app.js.br index dc6eb881..ed35c5c4 100644 Binary files a/public/assets/app.js.br and b/public/assets/app.js.br differ diff --git a/resources/js/app.js b/resources/js/app.js index 69946e98..06c635a9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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(); + }); +}); diff --git a/resources/js/auth.js b/resources/js/auth.js index d181f62d..905db945 100644 --- a/resources/js/auth.js +++ b/resources/js/auth.js @@ -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; } } diff --git a/resources/views/admin/passkeys/index.blade.php b/resources/views/admin/passkeys/index.blade.php new file mode 100644 index 00000000..09602162 --- /dev/null +++ b/resources/views/admin/passkeys/index.blade.php @@ -0,0 +1,18 @@ +@extends('master') + +@section('title')Passkeys « Admin CP « @stop + +@section('content') +

Passkeys

+ @if(count($passkeys) > 0) +

You have the following passkeys saved:

+ + @else +

You have no passkey saved.

+ @endif + +@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 3cce67d7..269ccdc5 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -54,6 +54,6 @@

Passkeys

- List passkeys here? + Manager your passkeys.

@stop diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 944627c9..24eb5665 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -2,6 +2,7 @@ + @yield('title'){{ config('app.name') }} @if (!empty(config('app.font_link'))) diff --git a/routes/web.php b/routes/web.php index e5ace4e1..18a17619 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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