Add passkey support

This commit is contained in:
Shadowfacts 2023-10-14 22:46:30 -04:00
parent 96f8c86ae7
commit ce4f485dbc
21 changed files with 478 additions and 291 deletions

View File

@ -39,3 +39,4 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim() // >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket window.liveSocket = liveSocket
import "./passkeys";

234
assets/js/passkeys.js Normal file
View File

@ -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;
}

View File

@ -6,7 +6,7 @@ defmodule PhoenixPasskeys.Accounts do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias PhoenixPasskeys.Repo alias PhoenixPasskeys.Repo
alias PhoenixPasskeys.Accounts.{User, UserToken, UserNotifier} alias PhoenixPasskeys.Accounts.{User, UserCredential, UserToken, UserNotifier}
## Database getters ## Database getters
@ -26,24 +26,6 @@ defmodule PhoenixPasskeys.Accounts do
Repo.get_by(User, email: email) Repo.get_by(User, email: email)
end 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 """ @doc """
Gets a single user. Gets a single user.
@ -64,20 +46,32 @@ defmodule PhoenixPasskeys.Accounts do
@doc """ @doc """
Registers a user. 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 def register_user(email, credential_id, public_key_spki) do
Repo.transaction(fn ->
user =
%User{} %User{}
|> User.registration_changeset(attrs) |> User.registration_changeset(%{email: email})
|> Repo.insert() |> 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 end
@doc """ @doc """
@ -111,20 +105,10 @@ defmodule PhoenixPasskeys.Accounts do
@doc """ @doc """
Emulates that the email will change without actually changing Emulates that the email will change without actually changing
it in the database. 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
|> User.email_changeset(attrs) |> User.email_changeset(attrs)
|> User.validate_current_password(password)
|> Ecto.Changeset.apply_action(:update) |> Ecto.Changeset.apply_action(:update)
end end
@ -174,47 +158,6 @@ defmodule PhoenixPasskeys.Accounts do
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end 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 ## Session
@doc """ @doc """
@ -340,14 +283,38 @@ defmodule PhoenixPasskeys.Accounts do
{:error, %Ecto.Changeset{}} {: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.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)) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{user: user}} -> {:ok, user} {:ok, _} -> :ok
{:error, :user, changeset, _} -> {:error, changeset} {: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 end
end end

View File

@ -4,10 +4,10 @@ defmodule PhoenixPasskeys.Accounts.User do
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime field :confirmed_at, :naive_datetime
has_many :credentials, PhoenixPasskeys.Accounts.UserCredential
timestamps() timestamps()
end end
@ -20,12 +20,6 @@ defmodule PhoenixPasskeys.Accounts.User do
also be very expensive to hash for certain algorithms. also be very expensive to hash for certain algorithms.
## Options ## 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`. Defaults to `true`.
* `:validate_email` - Validates the uniqueness of the email, in case * `: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 def registration_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:email, :password]) |> cast(attrs, [:email])
|> validate_email(opts) |> validate_email(opts)
|> validate_password(opts)
end end
defp validate_email(changeset, opts) do defp validate_email(changeset, opts) do
@ -49,34 +42,6 @@ defmodule PhoenixPasskeys.Accounts.User do
|> maybe_validate_unique_email(opts) |> maybe_validate_unique_email(opts)
end 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 defp maybe_validate_unique_email(changeset, opts) do
if Keyword.get(opts, :validate_email, true) do if Keyword.get(opts, :validate_email, true) do
changeset changeset
@ -102,25 +67,6 @@ defmodule PhoenixPasskeys.Accounts.User do
end end
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 """ @doc """
Confirms the account by setting `confirmed_at`. Confirms the account by setting `confirmed_at`.
""" """
@ -128,31 +74,4 @@ defmodule PhoenixPasskeys.Accounts.User do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
change(user, confirmed_at: now) change(user, confirmed_at: now)
end 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 end

View File

@ -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

View File

@ -41,13 +41,13 @@ defmodule PhoenixPasskeys.Accounts.UserNotifier do
Deliver instructions to reset a user password. Deliver instructions to reset a user password.
""" """
def deliver_reset_password_instructions(user, url) do def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Reset password instructions", """ deliver(user.email, "Reset passkey instructions", """
============================== ==============================
Hi #{user.email}, Hi #{user.email},
You can reset your password by visiting the URL below: You can reset your passkey by visiting the URL below:
#{url} #{url}

View File

@ -10,8 +10,15 @@ defmodule PhoenixPasskeysWeb.UserRegistrationController do
render(conn, :new, changeset: changeset) render(conn, :new, changeset: changeset)
end end
def create(conn, %{"user" => user_params}) do def create(conn, %{
case Accounts.register_user(user_params) do "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, user} ->
{:ok, _} = {:ok, _} =
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
@ -20,11 +27,11 @@ defmodule PhoenixPasskeysWeb.UserRegistrationController do
) )
conn conn
|> put_flash(:info, "User created successfully.") |> UserAuth.log_in_user_without_redirect(user)
|> UserAuth.log_in_user(user) |> json(%{status: :ok})
{:error, %Ecto.Changeset{} = changeset} -> {:error, _changeset} ->
render(conn, :new, changeset: changeset) json(conn, %{status: :error})
end end
end end
end end

View File

@ -10,13 +10,12 @@
</:subtitle> </:subtitle>
</.header> </.header>
<.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}> <.error :if={@changeset.action == :insert}>
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<.input field={f[:email]} type="email" label="Email" required /> <.input field={f[:email]} type="email" label="Email" required />
<.input field={f[:password]} type="password" label="Password" required />
<:actions> <:actions>
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button> <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>

View File

@ -1,6 +1,7 @@
defmodule PhoenixPasskeysWeb.UserResetPasswordController do defmodule PhoenixPasskeysWeb.UserResetPasswordController do
use PhoenixPasskeysWeb, :controller use PhoenixPasskeysWeb, :controller
alias PhoenixPasskeysWeb.UserAuth
alias PhoenixPasskeys.Accounts alias PhoenixPasskeys.Accounts
plug :get_user_by_reset_password_token when action in [:edit, :update] plug :get_user_by_reset_password_token when action in [:edit, :update]
@ -20,26 +21,33 @@ defmodule PhoenixPasskeysWeb.UserResetPasswordController do
conn conn
|> put_flash( |> put_flash(
:info, :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"/") |> redirect(to: ~p"/")
end end
def edit(conn, _params) do def edit(conn, _params) do
render(conn, :edit, changeset: Accounts.change_user_password(conn.assigns.user)) render(conn, :edit)
end end
# Do not log in the user after reset password to avoid a def update(conn, %{
# leaked token giving the user access to the account. "credential_id" => credential_id,
def update(conn, %{"user" => user_params}) do "public_key_spki" => public_key_spki
case Accounts.reset_user_password(conn.assigns.user, user_params) do }) do
{:ok, _} -> credential_id = Base.decode64!(credential_id)
conn public_key_spki = Base.decode64!(public_key_spki)
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log_in")
{:error, changeset} -> case Accounts.reset_user_credentials(conn.assigns.user, credential_id, public_key_spki) do
render(conn, :edit, changeset: changeset) :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
end end
@ -50,7 +58,7 @@ defmodule PhoenixPasskeysWeb.UserResetPasswordController do
conn |> assign(:user, user) |> assign(:token, token) conn |> assign(:user, user) |> assign(:token, token)
else else
conn 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"/") |> redirect(to: ~p"/")
|> halt() |> halt()
end end

View File

@ -1,23 +1,14 @@
<div class="mx-auto max-w-sm"> <div class="mx-auto max-w-sm">
<.header class="text-center"> <.header class="text-center">
Reset Password Reset Passkey
</.header> </.header>
<.simple_form :let={f} for={@changeset} action={~p"/users/reset_password/#{@token}"}> <.simple_form :let={_} for={@conn} action={~p"/users/reset_password/#{@token}"} id="reset-passkey-form">
<.error :if={@changeset.action}> <input type="hidden" name="user[email]" value={@user.email} />
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={f[:password]} type="password" label="New Password" required />
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
required
/>
<:actions> <:actions>
<.button phx-disable-with="Resetting..." class="w-full"> <.button phx-disable-with="Resetting..." class="w-full">
Reset password Reset passkey
</.button> </.button>
</:actions> </:actions>
</.simple_form> </.simple_form>

View File

@ -1,14 +1,14 @@
<div class="mx-auto max-w-sm"> <div class="mx-auto max-w-sm">
<.header class="text-center"> <.header class="text-center">
Forgot your password? Lost your passkey
<:subtitle>We'll send a password reset link to your inbox</:subtitle> <:subtitle>We'll send a passkey reset link to your inbox</:subtitle>
</.header> </.header>
<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/reset_password"}> <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/reset_password"}>
<.input field={f[:email]} type="email" placeholder="Email" required /> <.input field={f[:email]} type="email" placeholder="Email" required />
<:actions> <:actions>
<.button phx-disable-with="Sending..." class="w-full"> <.button phx-disable-with="Sending..." class="w-full">
Send password reset instructions Send passkey reset instructions
</.button> </.button>
</:actions> </:actions>
</.simple_form> </.simple_form>

View File

@ -1,23 +1,43 @@
defmodule PhoenixPasskeysWeb.UserSessionController do defmodule PhoenixPasskeysWeb.UserSessionController do
use PhoenixPasskeysWeb, :controller use PhoenixPasskeysWeb, :controller
import Bitwise, only: [&&&: 2]
alias PhoenixPasskeys.Accounts alias PhoenixPasskeys.Accounts
alias PhoenixPasskeysWeb.UserAuth alias PhoenixPasskeysWeb.UserAuth
def new(conn, _params) do def new(conn, _params) do
render(conn, :new, error_message: nil) conn
|> put_webauthn_challenge()
|> render(:new, error_message: nil)
end end
def create(conn, %{"user" => user_params}) do def create(conn, params) do
%{"email" => email, "password" => password} = user_params 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 conn
|> put_flash(:info, "Welcome back!") |> delete_session(:webauthn_challenge)
|> UserAuth.log_in_user(user, user_params) |> UserAuth.log_in_user_without_redirect(credential.user)
|> json(%{status: :ok})
else 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
end end
@ -26,4 +46,49 @@ defmodule PhoenixPasskeysWeb.UserSessionController do
|> put_flash(:info, "Logged out successfully.") |> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user() |> UserAuth.log_out_user()
end 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 end

View File

@ -10,11 +10,12 @@
</:subtitle> </:subtitle>
</.header> </.header>
<.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 %></.error> <.error :if={@error_message}><%= @error_message %></.error>
<.input field={f[:email]} type="email" label="Email" required /> <input type="hidden" id="challenge" value={@webauthn_challenge} />
<.input field={f[:password]} type="password" label="Password" required />
<.input field={f[:email]} type="email" label="Email" required autocomplete="email webauthn" />
<:actions :let={f}> <:actions :let={f}>
<.input field={f[:remember_me]} type="checkbox" label="Keep me logged in" /> <.input field={f[:remember_me]} type="checkbox" label="Keep me logged in" />

View File

@ -2,19 +2,18 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do
use PhoenixPasskeysWeb, :controller use PhoenixPasskeysWeb, :controller
alias PhoenixPasskeys.Accounts alias PhoenixPasskeys.Accounts
alias PhoenixPasskeysWeb.UserAuth
plug :assign_email_and_password_changesets plug :assign_email_changesets
def edit(conn, _params) do def edit(conn, _params) do
render(conn, :edit) render(conn, :edit)
end end
def update(conn, %{"action" => "update_email"} = params) do def update(conn, %{"action" => "update_email"} = params) do
%{"current_password" => password, "user" => user_params} = params %{"user" => user_params} = params
user = conn.assigns.current_user 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} -> {:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions( Accounts.deliver_user_update_email_instructions(
applied_user, applied_user,
@ -34,22 +33,6 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do
end end
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 def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_user, token) do case Accounts.update_user_email(conn.assigns.current_user, token) do
:ok -> :ok ->
@ -64,11 +47,10 @@ defmodule PhoenixPasskeysWeb.UserSettingsController do
end end
end end
defp assign_email_and_password_changesets(conn, _opts) do defp assign_email_changesets(conn, _opts) do
user = conn.assigns.current_user user = conn.assigns.current_user
conn conn
|> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end end
end end

View File

@ -1,6 +1,6 @@
<.header class="text-center"> <.header class="text-center">
Account Settings Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle> <:subtitle>Manage your account email address and passkey settings</:subtitle>
</.header> </.header>
<div class="space-y-12 divide-y"> <div class="space-y-12 divide-y">
@ -13,51 +13,9 @@
<.input field={f[:action]} type="hidden" name="action" value="update_email" /> <.input field={f[:action]} type="hidden" name="action" value="update_email" />
<.input field={f[:email]} type="email" label="Email" required /> <.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> <:actions>
<.button phx-disable-with="Changing...">Change Email</.button> <.button phx-disable-with="Changing...">Change Email</.button>
</:actions> </:actions>
</.simple_form> </.simple_form>
</div> </div>
<div>
<.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.
</.error>
<.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</.button>
</:actions>
</.simple_form>
</div>
</div> </div>

View File

@ -54,10 +54,11 @@ defmodule PhoenixPasskeysWeb.Router do
post "/users/register", UserRegistrationController, :create post "/users/register", UserRegistrationController, :create
get "/users/log_in", UserSessionController, :new get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create post "/users/log_in", UserSessionController, :create
get "/users/log_in/credentials", UserSessionController, :credentials
get "/users/reset_password", UserResetPasswordController, :new get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update post "/users/reset_password/:token", UserResetPasswordController, :update
end end
scope "/", PhoenixPasskeysWeb do scope "/", PhoenixPasskeysWeb do

View File

@ -26,14 +26,20 @@ defmodule PhoenixPasskeysWeb.UserAuth do
if you are not using LiveView. if you are not using LiveView.
""" """
def log_in_user(conn, user, params \\ %{}) do def log_in_user(conn, user, params \\ %{}) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to) 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 conn
|> renew_session() |> renew_session()
|> put_token_in_session(token) |> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params) |> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do

View File

@ -49,7 +49,8 @@ defmodule PhoenixPasskeys.MixProject do
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.20"}, {:gettext, "~> 0.20"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"} {:plug_cowboy, "~> 2.5"},
{:x509, "~> 0.8"}
] ]
end end

View File

@ -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"}, "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": {: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"}, "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"},
} }

View File

@ -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

View File

@ -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