Skip to content

Commit

Permalink
How to add Magic Link Authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
srcrip committed Dec 18, 2023
1 parent d4198c6 commit dd53d79
Show file tree
Hide file tree
Showing 30 changed files with 365 additions and 1,759 deletions.
205 changes: 38 additions & 167 deletions lib/example/users.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ defmodule Example.Users do

## Database getters

def get_user_by_email_token(token, context) do
with {:ok, query} <- UserToken.verify_email_token_query(token, context),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end

@doc """
Gets a user by email.
Expand All @@ -26,24 +35,6 @@ defmodule Example.Users 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.
Expand Down Expand Up @@ -80,19 +71,6 @@ defmodule Example.Users do
|> Repo.insert()
end

@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
end

## Settings

@doc """
Expand Down Expand Up @@ -121,10 +99,9 @@ defmodule Example.Users do
{: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

Expand Down Expand Up @@ -174,47 +151,6 @@ defmodule Example.Users 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.by_user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end

## Session

@doc """
Expand Down Expand Up @@ -244,110 +180,45 @@ defmodule Example.Users do

## Confirmation

@doc ~S"""
Delivers the confirmation email instructions to the given user.
## Examples
iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
{:ok, %{to: ..., body: ...}}
iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
{:error, :already_confirmed}
"""
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do
{:error, :already_confirmed}
else
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
end
end

@doc """
Confirms a user by the given token.
If the token matches, the user account is marked as confirmed
and the token is deleted.
Confirms a user. Does nothing if they're already confirmed.
"""
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
{:ok, user}
else
_ -> :error
end
# NOTE: You could add a last_seen_at timestamp update here.
def confirm_user(%User{confirmed_at: confirmed_at} = user) when is_nil(confirmed_at) do
user
|> User.confirm_changeset()
|> Repo.update()
end

defp confirm_user_multi(user) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"]))
def confirm_user(%User{confirmed_at: confirmed_at} = user) when not is_nil(confirmed_at) do
{:ok, user}
end

## Reset password
## Authentication

@doc ~S"""
Delivers the reset password email to the given user.
def login_or_register_user(email) do
case get_user_by_email(email) do
# Found existing user.
%User{} = user ->
{email_token, token} = UserToken.build_email_token(user, "magic_link")
Repo.insert!(token)

## Examples
UserNotifier.deliver_login_link(
user,
"#{ExampleWeb.Endpoint.url()}/login/email/token/#{email_token}"
)

iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
{:ok, %{to: ..., body: ...}}
# New user, create a new account.
_ ->
{:ok, user} = register_user(%{email: email})

"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
Repo.insert!(user_token)
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
end
{email_token, token} = UserToken.build_email_token(user, "magic_link")
Repo.insert!(token)

@doc """
Gets the user by reset password token.
## Examples
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end

@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
UserNotifier.deliver_register_link(
user,
"#{ExampleWeb.Endpoint.url()}/login/email/token/#{email_token}"
)
end
end
end
91 changes: 3 additions & 88 deletions lib/example/users/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ defmodule Example.Users.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

timestamps(type: :utc_datetime)
Expand All @@ -14,20 +12,12 @@ defmodule Example.Users.User do
@doc """
A user changeset for registration.
It is important to validate the length of both email and password.
It is important to validate the length of email addresses.
Otherwise databases may truncate the email without warnings, which
could lead to unpredictable or insecure behaviour. Long passwords may
also be very expensive to hash for certain algorithms.
could lead to unpredictable or insecure behaviour.
## 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
you don't want to validate the uniqueness of the email (like when
using this changeset for validations on a LiveView form before
Expand All @@ -36,9 +26,8 @@ defmodule Example.Users.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
Expand All @@ -49,34 +38,6 @@ defmodule Example.Users.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
Expand All @@ -102,57 +63,11 @@ defmodule Example.Users.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`.
"""
def confirm_changeset(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?(%Example.Users.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
Loading

0 comments on commit dd53d79

Please sign in to comment.