Skip to content

Commit

Permalink
Support Passkeys for authentication (#6)
Browse files Browse the repository at this point in the history
* Add functional Passkeys with Conditional UI.

* Gate logins behind feature flag too.

* Require naming Passkeys, track last used time.

* Limit Passkey name length.

* List and allow removal of Passkeys.
  • Loading branch information
Syfaro authored Feb 29, 2024
1 parent 12ce9c9 commit c6531a1
Show file tree
Hide file tree
Showing 23 changed files with 1,180 additions and 255 deletions.
655 changes: 452 additions & 203 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ tracing-actix-web = { version = "0.7", features = ["opentelemetry_0_21"] }
tracing-opentelemetry = "0.22"
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
webauthn-rs = { version = "0.5.0-dev", features = ["resident-key-support", "danger-allow-state-serialisation", "preview-features"] }
webauthn-rs-proto = "0.5.0-dev"
zip = "0.6"
zxcvbn = "2"

Expand Down
31 changes: 18 additions & 13 deletions frontend/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ const define = {
};

function buildOpts(entryPoints) {
let plugins = [
sassPlugin(),
copy({
assets: {
from: ["./assets/img/*"],
to: ["./img"],
},
watch: true,
}),
];

if (IS_PROD) {
plugins.push(sentryEsbuildPlugin({
telemetry: false,
}));
}

return {
entryPoints,
bundle: true,
Expand All @@ -19,19 +36,7 @@ function buildOpts(entryPoints) {
sourcemap: true,
drop: IS_PROD ? ["console"] : [],
target: ["es2020"],
plugins: [
sassPlugin(),
copy({
assets: {
from: ["./assets/img/*"],
to: ["./img"],
},
watch: true,
}),
sentryEsbuildPlugin({
telemetry: false,
}),
],
plugins,
loader: {
".woff": "file",
".woff2": "file",
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"@creativebulma/bulma-tooltip": "^1.2.0",
"@sentry/browser": "^7.100.1",
"@sentry/esbuild-plugin": "^2.14.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/types": "^9.0.1",
"bootstrap-icons": "^1.11.3",
"bulma": "^0.9.4",
"bulma-toast": "^2.4.3",
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/ts/pages/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
browserSupportsWebAuthnAutofill,
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";

document.getElementById("passkey-add")?.addEventListener(
"click",
async (ev) => {
ev.preventDefault();

try {
await performRegistration();
} catch (error) {
alert(`Could not perform Passkey registration: ${error}`);
return;
}
},
);

if (window.location.pathname === "/auth/login") {
performPasswordlessLogin();
}

async function performPasswordlessLogin() {
if (!(await browserSupportsWebAuthnAutofill())) return;

const resp = await fetch("/auth/webauthn/generate-authentication-options");
const opts = await resp.json();

try {
const auth = await startAuthentication(opts, true);
const data = await verifyAuthentication(auth);

const location = new URL(data.redirect_url, window.location.href);
window.location.replace(location);
} catch (error) {
alert("Could not perform Passkey sign in.");
}
}

async function performRegistration() {
const name = prompt("Please enter a name for this Passkey.");
if (!name) return;

const opts = await generateRegistrationOptions();
let attestation = await startRegistration(opts);

try {
await verifyRegistration(name, attestation);
window.location.reload();
} catch {
alert("Could not register Passkey, please try again later.");
}
}

async function generateRegistrationOptions(): Promise<
PublicKeyCredentialCreationOptionsJSON
> {
const resp = await fetch("/auth/webauthn/generate-registration-options");
return await resp.json();
}

async function verifyRegistration(
name: string,
response: RegistrationResponseJSON,
) {
const resp = await fetch("/auth/webauthn/verify-registration", {
method: "POST",
headers: {
"content-type": "application/json",
"x-passkey-name": name,
},
body: JSON.stringify(response),
});
if (resp.status !== 204) {
throw new Error(
`Got unexpected status code verifying credential registration: ${resp.status}`,
);
}
}

interface LoginData {
redirect_url: string;
}

async function verifyAuthentication(
response: AuthenticationResponseJSON,
): Promise<LoginData> {
const resp = await fetch("/auth/webauthn/verify-authentication", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(response),
});
if (resp.status !== 200) {
throw new Error(
`Got unexpected status code verifying authentication: ${resp.status}`,
);
}
return await resp.json();
}
1 change: 1 addition & 0 deletions frontend/src/ts/pages/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./auth";
import "./account";
import "./bluesky";
import "./media";
9 changes: 0 additions & 9 deletions frontend/src/ts/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,6 @@ async function performChunkedUpload(
return collectionId;
}

document.getElementById("delete-account")?.addEventListener("click", (ev) => {
const wasIntentional = confirm(
"Are you sure you want to delete your account?"
);
if (!wasIntentional) {
ev.preventDefault();
}
});

document.querySelectorAll<HTMLInputElement>(".click-copy").forEach((elem) => {
elem.addEventListener("click", () => {
navigator.clipboard.writeText(elem.value);
Expand Down
12 changes: 12 additions & 0 deletions frontend/yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions migrations/20240229035127_webauthn.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE webauthn_credential;
11 changes: 11 additions & 0 deletions migrations/20240229035127_webauthn.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE webauthn_credential (
id uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
created_at timestamp with time zone NOT NULL DEFAULT current_timestamp,
last_used timestamp with time zone,
credential_id bytea NOT NULL UNIQUE,
name text NOT NULL,
credential jsonb NOT NULL
);

CREATE INDEX webauthn_credential_owner_idx ON webauthn_credential (owner_id, last_used DESC);
8 changes: 8 additions & 0 deletions queries/user/lookup_by_credential.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
SELECT
user_account.id,
webauthn_credential.credential
FROM
user_account
JOIN webauthn_credential ON webauthn_credential.owner_id = user_account.id
WHERE
webauthn_credential.credential_id = $1;
4 changes: 4 additions & 0 deletions queries/webauthn/insert_credential.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
INSERT INTO
webauthn_credential (owner_id, credential_id, name, credential)
VALUES
($1, $2, $3, $4) RETURNING id;
7 changes: 7 additions & 0 deletions queries/webauthn/lookup_by_credential.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SELECT
owner_id,
credential
FROM
webauthn_credential
WHERE
webauthn_credential.credential_id = $1;
6 changes: 6 additions & 0 deletions queries/webauthn/mark_used.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
UPDATE
webauthn_credential
SET
last_used = current_timestamp
WHERE
credential_id = $1;
5 changes: 5 additions & 0 deletions queries/webauthn/remove.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DELETE FROM
webauthn_credential
WHERE
owner_id = $1
AND credential_id = $2;
11 changes: 11 additions & 0 deletions queries/webauthn/user_credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
created_at,
last_used,
name,
credential_id
FROM
webauthn_credential
WHERE
owner_id = $1
ORDER BY
last_used DESC;
Loading

0 comments on commit c6531a1

Please sign in to comment.