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 @@
<.header class="text-center"> - Reset Password + Reset Passkey - <.simple_form :let={f} for={@changeset} action={~p"/users/reset_password/#{@token}"}> - <.error :if={@changeset.action}> - Oops, something went wrong! Please check the errors below. - + <.simple_form :let={_} for={@conn} action={~p"/users/reset_password/#{@token}"} id="reset-passkey-form"> + - <.input field={f[:password]} type="password" label="New Password" required /> - <.input - field={f[:password_confirmation]} - type="password" - label="Confirm new password" - required - /> <:actions> <.button phx-disable-with="Resetting..." class="w-full"> - Reset password + Reset passkey diff --git a/lib/phoenix_passkeys_web/controllers/user_reset_password_html/new.html.heex b/lib/phoenix_passkeys_web/controllers/user_reset_password_html/new.html.heex index cc36db4..8af4428 100644 --- a/lib/phoenix_passkeys_web/controllers/user_reset_password_html/new.html.heex +++ b/lib/phoenix_passkeys_web/controllers/user_reset_password_html/new.html.heex @@ -1,14 +1,14 @@
<.header class="text-center"> - Forgot your password? - <:subtitle>We'll send a password reset link to your inbox + Lost your passkey + <:subtitle>We'll send a passkey reset link to your inbox <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/reset_password"}> <.input field={f[:email]} type="email" placeholder="Email" required /> <:actions> <.button phx-disable-with="Sending..." class="w-full"> - Send password reset instructions + Send passkey reset instructions diff --git a/lib/phoenix_passkeys_web/controllers/user_session_controller.ex b/lib/phoenix_passkeys_web/controllers/user_session_controller.ex index 36941cb..ba3c70c 100644 --- a/lib/phoenix_passkeys_web/controllers/user_session_controller.ex +++ b/lib/phoenix_passkeys_web/controllers/user_session_controller.ex @@ -1,23 +1,43 @@ defmodule PhoenixPasskeysWeb.UserSessionController do use PhoenixPasskeysWeb, :controller + import Bitwise, only: [&&&: 2] alias PhoenixPasskeys.Accounts alias PhoenixPasskeysWeb.UserAuth def new(conn, _params) do - render(conn, :new, error_message: nil) + conn + |> put_webauthn_challenge() + |> render(:new, error_message: nil) end - def create(conn, %{"user" => user_params}) do - %{"email" => email, "password" => password} = user_params + def create(conn, params) do + id = params |> Map.get("raw_id") |> Base.decode64!() + authenticator_data = params |> Map.get("authenticator_data") |> Base.decode64!() + client_data_json_str = params |> Map.get("client_data_json") + signature = params |> Map.get("signature") |> Base.decode64!() - if user = Accounts.get_user_by_email_and_password(email, password) do + with credential when not is_nil(credential) <- Accounts.get_credential(id), + # Verify that the authenticator data and client data JSON are signed with the user's key. + true <- + verify_signature(credential, client_data_json_str, authenticator_data, signature), + # Decode the client data JSON. + {:ok, client_data_json} <- Jason.decode(client_data_json_str), + # Make sure the values in the client data JSON are what we expect, and extract the challenge. + {:ok, challenge} <- check_client_data_json(client_data_json), + # Make sure the challenge singed by the user's key is what we generated. + true <- challenge == get_session(conn, :webauthn_challenge), + # Make sure the signed origin matches what we expect. + true <- :binary.part(authenticator_data, 0, 32) == :crypto.hash(:sha256, "localhost"), + # Check the user presence bit is set. + true <- (:binary.at(authenticator_data, 32) &&& 1) == 1 do conn - |> put_flash(:info, "Welcome back!") - |> UserAuth.log_in_user(user, user_params) + |> delete_session(:webauthn_challenge) + |> UserAuth.log_in_user_without_redirect(credential.user) + |> json(%{status: :ok}) else - # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - render(conn, :new, error_message: "Invalid email or password") + _ -> + json(conn, %{status: :error}) end end @@ -26,4 +46,49 @@ defmodule PhoenixPasskeysWeb.UserSessionController do |> put_flash(:info, "Logged out successfully.") |> UserAuth.log_out_user() end + + def credentials(conn, %{"email" => email}) do + ids = + Accounts.get_credentials_by_email(email) + |> Enum.map(fn cred -> Base.encode64(cred.id) end) + + json(conn, ids) + end + + # Generate a value that will be signed by WebAuthn on the client. + defp put_webauthn_challenge(conn) do + # The challenge is returned by the browser in base 64 URL encoding, so match that. + challenge = :crypto.strong_rand_bytes(64) |> Base.url_encode64(padding: false) + + conn + |> put_session(:webauthn_challenge, challenge) + |> assign(:webauthn_challenge, challenge) + end + + defp verify_signature(credential, client_data_json_str, authenticator_data, signature) do + with {:ok, pubkey} <- X509.PublicKey.from_der(credential.public_key_spki), + client_data_json_hash <- :crypto.hash(:sha256, client_data_json_str), + signed_message <- authenticator_data <> client_data_json_hash, + true <- :public_key.verify(signed_message, :sha256, signature, pubkey) do + true + else + _ -> + false + end + end + + # If crossOrigin is present and is not false, we reject the login attempt. + defp check_client_data_json(%{"crossOrigin" => crossOrigin}) when crossOrigin != false do + false + end + + defp check_client_data_json(%{ + "type" => "webauthn.get", + "challenge" => challenge, + "origin" => "http://localhost:4000" + }) do + {:ok, challenge} + end + + defp check_client_data_json(_), do: false end diff --git a/lib/phoenix_passkeys_web/controllers/user_session_html/new.html.heex b/lib/phoenix_passkeys_web/controllers/user_session_html/new.html.heex index 28c2467..52d04bd 100644 --- a/lib/phoenix_passkeys_web/controllers/user_session_html/new.html.heex +++ b/lib/phoenix_passkeys_web/controllers/user_session_html/new.html.heex @@ -10,11 +10,12 @@ - <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/log_in"}> + <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/log_in"} id="login-form"> <.error :if={@error_message}><%= @error_message %> - <.input field={f[:email]} type="email" label="Email" required /> - <.input field={f[:password]} type="password" label="Password" required /> + + + <.input field={f[:email]} type="email" label="Email" required autocomplete="email webauthn" /> <:actions :let={f}> <.input field={f[:remember_me]} type="checkbox" label="Keep me logged in" /> diff --git a/lib/phoenix_passkeys_web/controllers/user_settings_controller.ex b/lib/phoenix_passkeys_web/controllers/user_settings_controller.ex index dade387..66ecd9a 100644 --- a/lib/phoenix_passkeys_web/controllers/user_settings_controller.ex +++ b/lib/phoenix_passkeys_web/controllers/user_settings_controller.ex @@ -2,19 +2,18 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do use PhoenixPasskeysWeb, :controller alias PhoenixPasskeys.Accounts - alias PhoenixPasskeysWeb.UserAuth - plug :assign_email_and_password_changesets + plug :assign_email_changesets def edit(conn, _params) do render(conn, :edit) end def update(conn, %{"action" => "update_email"} = params) do - %{"current_password" => password, "user" => user_params} = params + %{"user" => user_params} = params user = conn.assigns.current_user - case Accounts.apply_user_email(user, password, user_params) do + case Accounts.apply_user_email(user, user_params) do {:ok, applied_user} -> Accounts.deliver_user_update_email_instructions( applied_user, @@ -34,22 +33,6 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do end end - def update(conn, %{"action" => "update_password"} = params) do - %{"current_password" => password, "user" => user_params} = params - user = conn.assigns.current_user - - case Accounts.update_user_password(user, password, user_params) do - {:ok, user} -> - conn - |> put_flash(:info, "Password updated successfully.") - |> put_session(:user_return_to, ~p"/users/settings") - |> UserAuth.log_in_user(user) - - {:error, changeset} -> - render(conn, :edit, password_changeset: changeset) - end - end - def confirm_email(conn, %{"token" => token}) do case Accounts.update_user_email(conn.assigns.current_user, token) do :ok -> @@ -64,11 +47,10 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do end end - defp assign_email_and_password_changesets(conn, _opts) do + defp assign_email_changesets(conn, _opts) do user = conn.assigns.current_user conn |> assign(:email_changeset, Accounts.change_user_email(user)) - |> assign(:password_changeset, Accounts.change_user_password(user)) end end diff --git a/lib/phoenix_passkeys_web/controllers/user_settings_html/edit.html.heex b/lib/phoenix_passkeys_web/controllers/user_settings_html/edit.html.heex index 915c3fd..c07d92e 100644 --- a/lib/phoenix_passkeys_web/controllers/user_settings_html/edit.html.heex +++ b/lib/phoenix_passkeys_web/controllers/user_settings_html/edit.html.heex @@ -1,6 +1,6 @@ <.header class="text-center"> Account Settings - <:subtitle>Manage your account email address and password settings + <:subtitle>Manage your account email address and passkey settings
@@ -13,51 +13,9 @@ <.input field={f[:action]} type="hidden" name="action" value="update_email" /> <.input field={f[:email]} type="email" label="Email" required /> - <.input - field={f[:current_password]} - name="current_password" - type="password" - label="Current Password" - required - id="current_password_for_email" - /> <:actions> <.button phx-disable-with="Changing...">Change Email
-
- <.simple_form - :let={f} - for={@password_changeset} - action={~p"/users/settings"} - id="update_password" - > - <.error :if={@password_changeset.action}> - Oops, something went wrong! Please check the errors below. - - - <.input field={f[:action]} type="hidden" name="action" value="update_password" /> - - <.input field={f[:password]} type="password" label="New password" required /> - <.input - field={f[:password_confirmation]} - type="password" - label="Confirm new password" - required - /> - - <.input - field={f[:current_password]} - name="current_password" - type="password" - label="Current password" - id="current_password_for_password" - required - /> - <:actions> - <.button phx-disable-with="Changing...">Change Password - - -
diff --git a/lib/phoenix_passkeys_web/router.ex b/lib/phoenix_passkeys_web/router.ex index 2039163..1c703bb 100644 --- a/lib/phoenix_passkeys_web/router.ex +++ b/lib/phoenix_passkeys_web/router.ex @@ -54,10 +54,11 @@ defmodule PhoenixPasskeysWeb.Router do post "/users/register", UserRegistrationController, :create get "/users/log_in", UserSessionController, :new post "/users/log_in", UserSessionController, :create + get "/users/log_in/credentials", UserSessionController, :credentials get "/users/reset_password", UserResetPasswordController, :new post "/users/reset_password", UserResetPasswordController, :create get "/users/reset_password/:token", UserResetPasswordController, :edit - put "/users/reset_password/:token", UserResetPasswordController, :update + post "/users/reset_password/:token", UserResetPasswordController, :update end scope "/", PhoenixPasskeysWeb do diff --git a/lib/phoenix_passkeys_web/user_auth.ex b/lib/phoenix_passkeys_web/user_auth.ex index c768051..a646d0b 100644 --- a/lib/phoenix_passkeys_web/user_auth.ex +++ b/lib/phoenix_passkeys_web/user_auth.ex @@ -26,14 +26,20 @@ defmodule PhoenixPasskeysWeb.UserAuth do if you are not using LiveView. """ def log_in_user(conn, user, params \\ %{}) do - token = Accounts.generate_user_session_token(user) user_return_to = get_session(conn, :user_return_to) + conn + |> log_in_user_without_redirect(user, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + def log_in_user_without_redirect(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + conn |> renew_session() |> put_token_in_session(token) |> maybe_write_remember_me_cookie(token, params) - |> redirect(to: user_return_to || signed_in_path(conn)) end defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do diff --git a/mix.exs b/mix.exs index b65060e..7fad039 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,8 @@ defmodule PhoenixPasskeys.MixProject do {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"} + {:plug_cowboy, "~> 2.5"}, + {:x509, "~> 0.8"} ] end diff --git a/mix.lock b/mix.lock index 5d34a0c..5c4a68c 100644 --- a/mix.lock +++ b/mix.lock @@ -41,4 +41,5 @@ "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, + "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, } diff --git a/priv/repo/migrations/20231012031013_create_user_credentials.exs b/priv/repo/migrations/20231012031013_create_user_credentials.exs new file mode 100644 index 0000000..5455680 --- /dev/null +++ b/priv/repo/migrations/20231012031013_create_user_credentials.exs @@ -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 diff --git a/priv/repo/migrations/20231013213545_remove_user_password.exs b/priv/repo/migrations/20231013213545_remove_user_password.exs new file mode 100644 index 0000000..d784601 --- /dev/null +++ b/priv/repo/migrations/20231013213545_remove_user_password.exs @@ -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