diff --git a/assets/js/app.js b/assets/js/app.js index df0cdd9..a4e90c2 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -39,3 +39,4 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +import "./passkeys"; diff --git a/assets/js/passkeys.js b/assets/js/passkeys.js new file mode 100644 index 0000000..cd28eee --- /dev/null +++ b/assets/js/passkeys.js @@ -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; +} diff --git a/lib/phoenix_passkeys/accounts.ex b/lib/phoenix_passkeys/accounts.ex index 4849254..3e4ea0b 100644 --- a/lib/phoenix_passkeys/accounts.ex +++ b/lib/phoenix_passkeys/accounts.ex @@ -6,7 +6,7 @@ defmodule PhoenixPasskeys.Accounts do import Ecto.Query, warn: false alias PhoenixPasskeys.Repo - alias PhoenixPasskeys.Accounts.{User, UserToken, UserNotifier} + alias PhoenixPasskeys.Accounts.{User, UserCredential, UserToken, UserNotifier} ## Database getters @@ -26,24 +26,6 @@ defmodule PhoenixPasskeys.Accounts do Repo.get_by(User, email: email) end - @doc """ - Gets a user by email and password. - - ## Examples - - iex> get_user_by_email_and_password("foo@example.com", "correct_password") - %User{} - - iex> get_user_by_email_and_password("foo@example.com", "invalid_password") - nil - - """ - def get_user_by_email_and_password(email, password) - when is_binary(email) and is_binary(password) do - user = Repo.get_by(User, email: email) - if User.valid_password?(user, password), do: user - end - @doc """ Gets a single user. @@ -64,20 +46,32 @@ defmodule PhoenixPasskeys.Accounts do @doc """ Registers a user. - - ## Examples - - iex> register_user(%{field: value}) - {:ok, %User{}} - - iex> register_user(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - def register_user(attrs) do - %User{} - |> User.registration_changeset(attrs) - |> Repo.insert() + def register_user(email, credential_id, public_key_spki) do + Repo.transaction(fn -> + user = + %User{} + |> User.registration_changeset(%{email: email}) + |> Repo.insert() + |> case do + {:ok, user} -> user + {:error, changeset} -> Repo.rollback(changeset) + end + + %UserCredential{} + |> UserCredential.changeset(%{ + id: credential_id, + public_key_spki: public_key_spki, + user_id: user.id + }) + |> Repo.insert() + |> case do + {:ok, _credential} -> nil + {:error, changeset} -> Repo.rollback(changeset) + end + + user + end) end @doc """ @@ -111,20 +105,10 @@ defmodule PhoenixPasskeys.Accounts do @doc """ Emulates that the email will change without actually changing it in the database. - - ## Examples - - iex> apply_user_email(user, "valid password", %{email: ...}) - {:ok, %User{}} - - iex> apply_user_email(user, "invalid password", %{email: ...}) - {:error, %Ecto.Changeset{}} - """ - def apply_user_email(user, password, attrs) do + def apply_user_email(user, attrs) do user |> User.email_changeset(attrs) - |> User.validate_current_password(password) |> Ecto.Changeset.apply_action(:update) end @@ -174,47 +158,6 @@ defmodule PhoenixPasskeys.Accounts do UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) end - @doc """ - Returns an `%Ecto.Changeset{}` for changing the user password. - - ## Examples - - iex> change_user_password(user) - %Ecto.Changeset{data: %User{}} - - """ - def change_user_password(user, attrs \\ %{}) do - User.password_changeset(user, attrs, hash_password: false) - end - - @doc """ - Updates the user password. - - ## Examples - - iex> update_user_password(user, "valid password", %{password: ...}) - {:ok, %User{}} - - iex> update_user_password(user, "invalid password", %{password: ...}) - {:error, %Ecto.Changeset{}} - - """ - def update_user_password(user, password, attrs) do - changeset = - user - |> User.password_changeset(attrs) - |> User.validate_current_password(password) - - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) - |> Repo.transaction() - |> case do - {:ok, %{user: user}} -> {:ok, user} - {:error, :user, changeset, _} -> {:error, changeset} - end - end - ## Session @doc """ @@ -340,14 +283,38 @@ defmodule PhoenixPasskeys.Accounts do {:error, %Ecto.Changeset{}} """ - def reset_user_password(user, attrs) do + def reset_user_credentials(user, credential_id, public_key_spki) do Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all( + :old_credentials, + from(a in UserCredential, where: a.user_id == ^user.id) + ) + |> Ecto.Multi.insert( + :new_credential, + UserCredential.changeset(%UserCredential{}, %{ + id: credential_id, + public_key_spki: public_key_spki, + user_id: user.id + }) + ) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Repo.transaction() |> case do - {:ok, %{user: user}} -> {:ok, user} - {:error, :user, changeset, _} -> {:error, changeset} + {:ok, _} -> :ok + {:error, _, changeset, _} -> {:error, changeset} + end + end + + def get_credential(id) do + Repo.get(UserCredential, id) + |> Repo.preload(:user) + end + + def get_credentials_by_email(email) do + Repo.one(from u in User, where: u.email == ^email, preload: :credentials) + |> case do + %{credentials: credentials} -> credentials + nil -> [] end end end diff --git a/lib/phoenix_passkeys/accounts/user.ex b/lib/phoenix_passkeys/accounts/user.ex index b0edd84..c6822d1 100644 --- a/lib/phoenix_passkeys/accounts/user.ex +++ b/lib/phoenix_passkeys/accounts/user.ex @@ -4,10 +4,10 @@ defmodule PhoenixPasskeys.Accounts.User do schema "users" do field :email, :string - field :password, :string, virtual: true, redact: true - field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime + has_many :credentials, PhoenixPasskeys.Accounts.UserCredential + timestamps() end @@ -20,12 +20,6 @@ defmodule PhoenixPasskeys.Accounts.User do also be very expensive to hash for certain algorithms. ## Options - - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. Defaults to `true`. * `:validate_email` - Validates the uniqueness of the email, in case @@ -36,9 +30,8 @@ defmodule PhoenixPasskeys.Accounts.User do """ def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :password]) + |> cast(attrs, [:email]) |> validate_email(opts) - |> validate_password(opts) end defp validate_email(changeset, opts) do @@ -49,34 +42,6 @@ defmodule PhoenixPasskeys.Accounts.User do |> maybe_validate_unique_email(opts) end - defp validate_password(changeset, opts) do - changeset - |> validate_required([:password]) - |> validate_length(:password, min: 12, max: 72) - # Examples of additional password validation: - # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") - # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") - # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") - |> maybe_hash_password(opts) - end - - defp maybe_hash_password(changeset, opts) do - hash_password? = Keyword.get(opts, :hash_password, true) - password = get_change(changeset, :password) - - if hash_password? && password && changeset.valid? do - changeset - # If using Bcrypt, then further validate it is at most 72 bytes long - |> validate_length(:password, max: 72, count: :bytes) - # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that - # would keep the database transaction open longer and hurt performance. - |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) - |> delete_change(:password) - else - changeset - end - end - defp maybe_validate_unique_email(changeset, opts) do if Keyword.get(opts, :validate_email, true) do changeset @@ -102,25 +67,6 @@ defmodule PhoenixPasskeys.Accounts.User do end end - @doc """ - A user changeset for changing the password. - - ## Options - - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. - Defaults to `true`. - """ - def password_changeset(user, attrs, opts \\ []) do - user - |> cast(attrs, [:password]) - |> validate_confirmation(:password, message: "does not match password") - |> validate_password(opts) - end - @doc """ Confirms the account by setting `confirmed_at`. """ @@ -128,31 +74,4 @@ defmodule PhoenixPasskeys.Accounts.User do now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) change(user, confirmed_at: now) end - - @doc """ - Verifies the password. - - If there is no user or the user doesn't have a password, we call - `Bcrypt.no_user_verify/0` to avoid timing attacks. - """ - def valid_password?(%PhoenixPasskeys.Accounts.User{hashed_password: hashed_password}, password) - when is_binary(hashed_password) and byte_size(password) > 0 do - Bcrypt.verify_pass(password, hashed_password) - end - - def valid_password?(_, _) do - Bcrypt.no_user_verify() - false - end - - @doc """ - Validates the current password otherwise adds an error to the changeset. - """ - def validate_current_password(changeset, password) do - if valid_password?(changeset.data, password) do - changeset - else - add_error(changeset, :current_password, "is not valid") - end - end end diff --git a/lib/phoenix_passkeys/accounts/user_credential.ex b/lib/phoenix_passkeys/accounts/user_credential.ex new file mode 100644 index 0000000..2e044dc --- /dev/null +++ b/lib/phoenix_passkeys/accounts/user_credential.ex @@ -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 diff --git a/lib/phoenix_passkeys/accounts/user_notifier.ex b/lib/phoenix_passkeys/accounts/user_notifier.ex index c259acf..f0a41dd 100644 --- a/lib/phoenix_passkeys/accounts/user_notifier.ex +++ b/lib/phoenix_passkeys/accounts/user_notifier.ex @@ -41,13 +41,13 @@ defmodule PhoenixPasskeys.Accounts.UserNotifier do Deliver instructions to reset a user password. """ def deliver_reset_password_instructions(user, url) do - deliver(user.email, "Reset password instructions", """ + deliver(user.email, "Reset passkey instructions", """ ============================== Hi #{user.email}, - You can reset your password by visiting the URL below: + You can reset your passkey by visiting the URL below: #{url} diff --git a/lib/phoenix_passkeys_web/controllers/user_registration_controller.ex b/lib/phoenix_passkeys_web/controllers/user_registration_controller.ex index 9b5d59a..4fde963 100644 --- a/lib/phoenix_passkeys_web/controllers/user_registration_controller.ex +++ b/lib/phoenix_passkeys_web/controllers/user_registration_controller.ex @@ -10,8 +10,15 @@ defmodule PhoenixPasskeysWeb.UserRegistrationController do render(conn, :new, changeset: changeset) end - def create(conn, %{"user" => user_params}) do - case Accounts.register_user(user_params) do + def create(conn, %{ + "email" => email, + "credential_id" => credential_id, + "public_key_spki" => public_key_spki + }) do + credential_id = Base.decode64!(credential_id) + public_key_spki = Base.decode64!(public_key_spki) + + case Accounts.register_user(email, credential_id, public_key_spki) do {:ok, user} -> {:ok, _} = Accounts.deliver_user_confirmation_instructions( @@ -20,11 +27,11 @@ defmodule PhoenixPasskeysWeb.UserRegistrationController do ) conn - |> put_flash(:info, "User created successfully.") - |> UserAuth.log_in_user(user) + |> UserAuth.log_in_user_without_redirect(user) + |> json(%{status: :ok}) - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) + {:error, _changeset} -> + json(conn, %{status: :error}) end end end diff --git a/lib/phoenix_passkeys_web/controllers/user_registration_html/new.html.heex b/lib/phoenix_passkeys_web/controllers/user_registration_html/new.html.heex index a06b058..383ba07 100644 --- a/lib/phoenix_passkeys_web/controllers/user_registration_html/new.html.heex +++ b/lib/phoenix_passkeys_web/controllers/user_registration_html/new.html.heex @@ -10,13 +10,12 @@ - <.simple_form :let={f} for={@changeset} action={~p"/users/register"}> + <.simple_form :let={f} for={@changeset} action={~p"/users/register"} id="registration-form"> <.error :if={@changeset.action == :insert}> Oops, something went wrong! Please check the errors below. <.input field={f[:email]} type="email" label="Email" required /> - <.input field={f[:password]} type="password" label="Password" required /> <:actions> <.button phx-disable-with="Creating account..." class="w-full">Create an account diff --git a/lib/phoenix_passkeys_web/controllers/user_reset_password_controller.ex b/lib/phoenix_passkeys_web/controllers/user_reset_password_controller.ex index 9f444a0..dce2b45 100644 --- a/lib/phoenix_passkeys_web/controllers/user_reset_password_controller.ex +++ b/lib/phoenix_passkeys_web/controllers/user_reset_password_controller.ex @@ -1,6 +1,7 @@ defmodule PhoenixPasskeysWeb.UserResetPasswordController do use PhoenixPasskeysWeb, :controller + alias PhoenixPasskeysWeb.UserAuth alias PhoenixPasskeys.Accounts plug :get_user_by_reset_password_token when action in [:edit, :update] @@ -20,26 +21,33 @@ defmodule PhoenixPasskeysWeb.UserResetPasswordController do conn |> put_flash( :info, - "If your email is in our system, you will receive instructions to reset your password shortly." + "If your email is in our system, you will receive instructions to reset your passkey shortly." ) |> redirect(to: ~p"/") end def edit(conn, _params) do - render(conn, :edit, changeset: Accounts.change_user_password(conn.assigns.user)) + render(conn, :edit) end - # Do not log in the user after reset password to avoid a - # leaked token giving the user access to the account. - def update(conn, %{"user" => user_params}) do - case Accounts.reset_user_password(conn.assigns.user, user_params) do - {:ok, _} -> - conn - |> put_flash(:info, "Password reset successfully.") - |> redirect(to: ~p"/users/log_in") + def update(conn, %{ + "credential_id" => credential_id, + "public_key_spki" => public_key_spki + }) do + credential_id = Base.decode64!(credential_id) + public_key_spki = Base.decode64!(public_key_spki) - {:error, changeset} -> - render(conn, :edit, changeset: changeset) + case Accounts.reset_user_credentials(conn.assigns.user, credential_id, public_key_spki) do + :ok -> + conn + |> put_flash(:info, "Passkey reset successfully.") + |> UserAuth.log_in_user_without_redirect(conn.assigns.user) + |> json(%{status: :ok}) + + {:error, _} -> + conn + |> put_flash(:error, "Error resetting passkey") + |> json(%{status: :error}) end end @@ -50,7 +58,7 @@ defmodule PhoenixPasskeysWeb.UserResetPasswordController do conn |> assign(:user, user) |> assign(:token, token) else conn - |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> put_flash(:error, "Reset passkey link is invalid or it has expired.") |> redirect(to: ~p"/") |> halt() end diff --git a/lib/phoenix_passkeys_web/controllers/user_reset_password_html/edit.html.heex b/lib/phoenix_passkeys_web/controllers/user_reset_password_html/edit.html.heex index b8be4ce..c8b713f 100644 --- a/lib/phoenix_passkeys_web/controllers/user_reset_password_html/edit.html.heex +++ b/lib/phoenix_passkeys_web/controllers/user_reset_password_html/edit.html.heex @@ -1,23 +1,14 @@