Add passkey support
This commit is contained in:
parent
96f8c86ae7
commit
ce4f485dbc
|
@ -39,3 +39,4 @@ liveSocket.connect()
|
||||||
// >> liveSocket.disableLatencySim()
|
// >> liveSocket.disableLatencySim()
|
||||||
window.liveSocket = liveSocket
|
window.liveSocket = liveSocket
|
||||||
|
|
||||||
|
import "./passkeys";
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
3
mix.exs
3
mix.exs
|
@ -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
|
||||||
|
|
||||||
|
|
1
mix.lock
1
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"},
|
"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"},
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule PhoenixPasskeys.Repo.Migrations.CreateUserCredentials do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:users_credentials, primary_key: false) do
|
||||||
|
add :id, :binary, primary_key: true
|
||||||
|
add :public_key_spki, :binary, null: false
|
||||||
|
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule PhoenixPasskeys.Repo.Migrations.RemoveUserPassowrd do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:users) do
|
||||||
|
remove :hashed_password, :string, null: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue