Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

magic link authentication for phx.gen.live #1

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion live/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "../../../../"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"

Expand Down
263 changes: 109 additions & 154 deletions live/lib/auth_app/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,56 +76,39 @@ defmodule AuthApp.Accounts do
"""
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> User.email_changeset(attrs)
|> Repo.insert()
end

@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.

## Examples
## Settings

iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
@doc """
Checks whether the user is in sudo mode.

The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
def sudo_mode?(user, minutes \\ -20)

def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end

## Settings
def sudo_mode?(_user, _minutes), do: false

@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.

See `AuthApp.Accounts.User.email_changeset/3` for a list of supported options.

## Examples

iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}

"""
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs, validate_email: false)
end

@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
user
|> User.email_changeset(attrs)
|> User.validate_current_password(password)
|> Ecto.Changeset.apply_action(:update)
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end

@doc """
Expand All @@ -147,70 +130,48 @@ defmodule AuthApp.Accounts do
end

defp user_email_multi(user, email, context) do
changeset =
user
|> User.email_changeset(%{email: email})
|> User.confirm_changeset()
changeset = User.email_changeset(user, %{email: email})

Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context]))
end

@doc ~S"""
Delivers the update email instructions to the given user.

## Examples

iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}

"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end

@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.

See `AuthApp.Accounts.User.password_changeset/3` for a list of supported options.

## 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)
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end

@doc """
Updates the user password.

Returns the updated user, as well as a list of expired tokens.

## Examples

iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, %{password: ...})
{:ok, %User{}, [...]}

iex> update_user_password(user, "invalid password", %{password: ...})
iex> update_user_password(user, %{password: "too short"})
{: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()
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:ok, user, expired_tokens} -> {:ok, user, expired_tokens}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
Expand All @@ -235,119 +196,113 @@ defmodule AuthApp.Accounts do
end

@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
:ok
end

## 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}

Gets the user with the given magic link token.
"""
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}
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
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))
_ -> nil
end
end

@doc """
Confirms a user by the given token.
Logs the user in by magic link.

There are three cases to consider:

1. The user has already confirmed their email. They are logged in
and the magic link is expired.

2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.

If the token matches, the user account is marked as confirmed
and the token is deleted.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password
registration" section of `mix help phx.gen.auth`.
"""
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
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)

case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!

This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the phx.gen.auth
documentation (mix help phx.gen.auth) about the security implications when allowing passwords
for unconfirmed users.
SteffenDE marked this conversation as resolved.
Show resolved Hide resolved
"""

{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()

{user, token} ->
Repo.delete!(token)
{:ok, user, []}

nil ->
{:error, :not_found}
end
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"]))
end

## Reset password

@doc ~S"""
Delivers the reset password email to the given user.
Delivers the update email instructions to the given user.

## Examples

iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset-password/#{&1}"))
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}

"""
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")
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

Repo.insert!(user_token)
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end

@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

@doc ~S"""
Delivers the magic link login instructions to the given user.
"""
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
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end

@doc """
Resets the user password.

## Examples
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
:ok
end

iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
## Token helper

iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
defp update_user_and_delete_all_tokens(changeset) do
%{data: %User{} = user} = 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}
with {:ok, %{user: user, tokens_to_expire: expired_tokens}} <-
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.all(:tokens_to_expire, UserToken.by_user_and_contexts_query(user, :all))
|> Ecto.Multi.delete_all(:tokens, fn %{tokens_to_expire: tokens_to_expire} ->
UserToken.delete_all_query(tokens_to_expire)
end)
|> Repo.transaction() do
{:ok, user, expired_tokens}
end
end
end
Loading