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()
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
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

View File

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

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.
"""
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}

View File

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

View File

@ -10,13 +10,12 @@
</:subtitle>
</.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}>
Oops, something went wrong! Please check the errors below.
</.error>
<.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</.button>

View File

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

View File

@ -1,23 +1,14 @@
<div class="mx-auto max-w-sm">
<.header class="text-center">
Reset Password
Reset Passkey
</.header>
<.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.
</.error>
<.simple_form :let={_} for={@conn} action={~p"/users/reset_password/#{@token}"} id="reset-passkey-form">
<input type="hidden" name="user[email]" value={@user.email} />
<.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
</.button>
</:actions>
</.simple_form>

View File

@ -1,14 +1,14 @@
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
Lost your passkey
<:subtitle>We'll send a passkey reset link to your inbox</:subtitle>
</.header>
<.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
</.button>
</:actions>
</.simple_form>

View File

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

View File

@ -10,11 +10,12 @@
</:subtitle>
</.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>
<.input field={f[:email]} type="email" label="Email" required />
<.input field={f[:password]} type="password" label="Password" required />
<input type="hidden" id="challenge" value={@webauthn_challenge} />
<.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" />

View File

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

View File

@ -1,6 +1,6 @@
<.header class="text-center">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
<:subtitle>Manage your account email address and passkey settings</:subtitle>
</.header>
<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[: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</.button>
</:actions>
</.simple_form>
</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>

View File

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

View File

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

View File

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

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"},
"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"},
}

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