feat: Add Passkey support
- Added a button for logging in with Passkeys in `login.blade.php` - Refactored the `register` method and added the `login` method in `auth.js` - Made various modifications and additions to the passkey functionality in `PasskeysController.php` - Added event listener for login-passkey element in `app.js` - Modified the passkeys table schema and made modifications to `Passkey.php` - Changed the redirect route in the `login` method of `AuthController.php` - Made modifications and additions to the routes in `web.php` - Added `"web-auth/webauthn-lib": "^4.7"` to the list of required packages in `composer.json` - Changed the redirect URL in `AdminTest.php`
This commit is contained in:
parent
2fb8339d91
commit
03c8f20a8c
18 changed files with 982 additions and 363 deletions
|
@ -2,87 +2,165 @@ class Auth {
|
|||
constructor() {}
|
||||
|
||||
async register() {
|
||||
const { challenge, userId, existing } = await this.getRegisterData();
|
||||
const createOptions = await this.getCreateOptions();
|
||||
|
||||
const publicKeyCredentialCreationOptions = {
|
||||
challenge: new TextEncoder().encode(challenge),
|
||||
challenge: this.base64URLStringToBuffer(createOptions.challenge),
|
||||
rp: {
|
||||
name: 'JB',
|
||||
id: createOptions.rp.id,
|
||||
name: createOptions.rp.name,
|
||||
},
|
||||
user: {
|
||||
id: new TextEncoder().encode(userId),
|
||||
name: 'jonny@jonnybarnes.uk',
|
||||
displayName: 'Jonny',
|
||||
},
|
||||
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',
|
||||
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 publicKeyCredential = await navigator.credentials.create({
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
});
|
||||
if (!publicKeyCredential) {
|
||||
if (!credential) {
|
||||
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);
|
||||
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,
|
||||
}
|
||||
};
|
||||
|
||||
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', {
|
||||
const registerCredential = await window.fetch('/admin/passkeys/register', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
body: JSON.stringify(authenticatorAttestationResponse),
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue