parent
96f8c86ae7
commit
ce4f485dbc
@ -0,0 +1,234 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const registrationForm = document.getElementById("registration-form");
|
||||
if (registrationForm) {
|
||||
registrationForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
registerWebAuthnAccount(registrationForm);
|
||||
});
|
||||
}
|
||||
|
||||
const resetForm = document.getElementById("reset-passkey-form");
|
||||
if (resetForm) {
|
||||
resetForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
registerWebAuthnAccount(resetForm);
|
||||
});
|
||||
}
|
||||
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) {
|
||||
loginWebAuthnAccount(loginForm, true);
|
||||
loginForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
loginWebAuthnAccount(loginForm, false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function registerWebAuthnAccount(form, resetting) {
|
||||
if (!(await supportsPasskeys())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createOptions = {
|
||||
publicKey: {
|
||||
rp: {
|
||||
// In reality, this would be the actual app domain.
|
||||
id: "localhost",
|
||||
// User-facing name of the service.
|
||||
name: "Phoenix Passkeys",
|
||||
},
|
||||
user: {
|
||||
// Generate a random value to use as the user ID, since we don't have a user yet.
|
||||
// This is used to to overwrite existing credentials with the same RP & user ID.
|
||||
// Since the user is registering, we're assuming they don't want to do that.
|
||||
// This is also returned as response.userHandle from navigator.credentials.get, which
|
||||
// could be used to identify the logging-in user. We don't use it for that (and don't
|
||||
// need to store it) because a credential only belongs to 1 account and so we can
|
||||
// directly use the credential to identify the account.
|
||||
id: generateUserID(),
|
||||
// The email the user entered.
|
||||
name: form["user[email]"].value,
|
||||
// Required, but we don't have anything better to display than the email.
|
||||
displayName: "",
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{
|
||||
type: "public-key",
|
||||
// Ed25519
|
||||
alg: -8,
|
||||
},
|
||||
{
|
||||
type: "public-key",
|
||||
// ES256
|
||||
alg: -7,
|
||||
},
|
||||
{
|
||||
type: "public-key",
|
||||
// RS256
|
||||
alg: -257,
|
||||
},
|
||||
],
|
||||
// Unused during registration.
|
||||
challenge: new Uint8Array(),
|
||||
authenticatorSelection: {
|
||||
// We only want the platform (device) authenticator, not a roaming authenticator (such as a physical security key).
|
||||
authenticatorAttachment: "platform",
|
||||
// We want a discoverable credential, so that it's available when we request credentials for login.
|
||||
// Being discoverable is what means we don't require a separate username entry step.
|
||||
requireResidentKey: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.create(createOptions);
|
||||
if (!credential) {
|
||||
alert("Could not create credential");
|
||||
return
|
||||
}
|
||||
|
||||
const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);
|
||||
const clientData = JSON.parse(clientDataJSON);
|
||||
if (clientData.type !== "webauthn.create" || (("crossOrigin" in clientData) && clientData.crossOrigin) || clientData.origin !== "http://localhost:4000") {
|
||||
alert("Invalid credential");
|
||||
return;
|
||||
}
|
||||
|
||||
const authenticatorData = new Uint8Array(credential.response.getAuthenticatorData());
|
||||
const backedUp = (authenticatorData[32] >> 4) & 1;
|
||||
const idLength = (authenticatorData[53] << 8) | authenticatorData[54];
|
||||
const id = authenticatorData.slice(55, 55 + idLength);
|
||||
const publicKey = credential.response.getPublicKey();
|
||||
|
||||
if (backedUp !== 1) {
|
||||
alert("Can't register with non backed-up credential");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append("_csrf_token", form._csrf_token.value);
|
||||
body.append("email", form["user[email]"].value);
|
||||
body.append("credential_id", arrayBufferToBase64(id));
|
||||
body.append("public_key_spki", arrayBufferToBase64(publicKey));
|
||||
|
||||
const resp = await fetch(form.action, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const respJSON = await resp.json();
|
||||
if (respJSON.status === "ok") {
|
||||
// Registration successful, redirect to homepage
|
||||
window.location = "/";
|
||||
} else {
|
||||
alert("Registration failed");
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
alert(`WebAuthn enrollment failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWebAuthnAccount(loginForm, conditional) {
|
||||
if (!(await supportsPasskeys())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the challenge generated by the server.
|
||||
const challenge = loginForm.challenge.value;
|
||||
|
||||
let allowCredentials = [];
|
||||
if (!conditional) {
|
||||
const email = loginForm["user[email]"].value;
|
||||
const resp = await fetch(`/users/log_in/credentials?email=${email}`);
|
||||
const respJSON = await resp.json();
|
||||
allowCredentials = respJSON.map((id) => {
|
||||
return {
|
||||
type: "public-key",
|
||||
id: base64ToArrayBuffer(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const getOptions = {
|
||||
mediation: conditional ? "conditional" : "optional",
|
||||
publicKey: {
|
||||
challenge: base64URLToArrayBuffer(challenge),
|
||||
rpId: "localhost",
|
||||
allowCredentials,
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.get(getOptions);
|
||||
if (!credential) {
|
||||
alert("Could not get credential");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);
|
||||
|
||||
const body = new FormData();
|
||||
body.append("_csrf_token", loginForm._csrf_token.value);
|
||||
body.append("raw_id", arrayBufferToBase64(credential.rawId));
|
||||
body.append("client_data_json", clientDataJSON);
|
||||
body.append("authenticator_data", arrayBufferToBase64(credential.response.authenticatorData));
|
||||
body.append("signature", arrayBufferToBase64(credential.response.signature));
|
||||
const resp = await fetch("/users/log_in", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const respJSON = await resp.json();
|
||||
if (respJSON?.status === "ok") {
|
||||
// Login successful, redirect to homepage.
|
||||
window.location = "/";
|
||||
} else {
|
||||
alert("Login failed");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`WebAuthn login failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function supportsPasskeys() {
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
return false;
|
||||
}
|
||||
const [conditional, userVerifiying] = await Promise.all([
|
||||
PublicKeyCredential.isConditionalMediationAvailable(),
|
||||
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
|
||||
]);
|
||||
return conditional && userVerifiying;
|
||||
}
|
||||
|
||||
function generateUserID() {
|
||||
const userID = new Uint8Array(64);
|
||||
crypto.getRandomValues(userID);
|
||||
return userID;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
for (let i = 0; i < buf.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// Note that this function deals in Base64 URL, not regular Base64.
|
||||
function base64URLToArrayBuffer(b64) {
|
||||
const converted = b64.replace(/[-_]/g, (c) => c === "-" ? "+" : "/");
|
||||
return base64ToArrayBuffer(converted);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(b64) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
defmodule PhoenixPasskeys.Accounts.UserCredential do
|
||||
@moduledoc """
|
||||
A WebAuthn credential belonging to a particular user.
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
# Use a binary as the ID, since that's what we get from WebAuthn for the credential.
|
||||
@primary_key {:id, :binary, []}
|
||||
|
||||
schema "users_credentials" do
|
||||
# DER Subject Public Key Info: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7
|
||||
field :public_key_spki, :binary
|
||||
|
||||
belongs_to :user, PhoenixPasskeys.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(credential, attrs) do
|
||||
credential
|
||||
|> cast(attrs, [:id, :public_key_spki, :user_id])
|
||||
end
|
||||
end
|
@ -0,0 +1,12 @@
|
||||
defmodule PhoenixPasskeys.Repo.Migrations.CreateUserCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:users_credentials, primary_key: false) do
|
||||
add :id, :binary, primary_key: true
|
||||
add :public_key_spki, :binary, null: false
|
||||
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule PhoenixPasskeys.Repo.Migrations.RemoveUserPassowrd do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:users) do
|
||||
remove :hashed_password, :string, null: true
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in new issue