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:
Jonny Barnes 2023-10-27 20:22:40 +01:00
parent 2fb8339d91
commit 03c8f20a8c
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
18 changed files with 982 additions and 363 deletions

View file

@ -9,6 +9,10 @@ a {
&:visited {
color: var(--color-link-visited);
}
&.auth:visited {
color: var(--color-link);
}
}
#site-header {

View file

@ -22,4 +22,10 @@ footer {
& .iwc-logo {
max-width: 85vw;
}
& .footer-actions {
display: flex;
flex-direction: row;
gap: 1rem;
}
}

View file

@ -9,3 +9,9 @@ document.querySelectorAll('.add-passkey').forEach((el) => {
auth.register();
});
});
document.querySelectorAll('.login-passkey').forEach((el) => {
el.addEventListener('click', () => {
auth.login();
});
});

View file

@ -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, '');
}
}

View file

@ -9,4 +9,5 @@
<input type="password" name="password" placeholder="password">
<input type="submit" name="submit" value="Login">
</form>
<p><button type="button" class="login-passkey">Login with Passkeys</button></p>
@stop

View file

@ -54,9 +54,16 @@
</main>
<footer>
<form action="/search" method="get">
<input type="text" name="q" title="Search"><button type="submit">Search</button>
</form>
<div class="footer-actions">
<form action="/search" method="get">
<input type="text" name="q" title="Search"><button type="submit">Search</button>
</form>
@auth()
<a href="/logout" class="auth">Logout</a>
@else
<a href="/login" class="auth">Login</a>
@endauth
</div>
<p>Built with love: <a href="/colophon">Colophon</a></p>
<a href="https://indieweb.org"><img src="/assets/img/iwc.svg" alt="Indie Web Camp logo" class="iwc-logo"></a>
</footer>