Merge pull request 'Add user authentication' (#1) from add-auth into main

Reviewed-on: https://git.nikos.gg/prymn/prymn/pulls/1
This commit is contained in:
nikos 2023-07-29 11:49:46 +00:00
commit d8f4baff8b
40 changed files with 3255 additions and 92 deletions

View file

@ -41,7 +41,7 @@ config :esbuild,
# Configure tailwind (the version is required)
config :tailwind,
version: "3.2.7",
version: "3.3.2",
default: [
args: ~w(
--config=tailwind.config.js

View file

@ -12,6 +12,9 @@ config :prymn, PrymnWeb.Endpoint,
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Prymn.Finch
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info

View file

@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :argon2_elixir, t_cost: 1, m_cost: 8
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used

377
app/lib/prymn/accounts.ex Normal file
View file

@ -0,0 +1,377 @@
defmodule Prymn.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Prymn.Repo
alias Prymn.Accounts.{User, UserToken, UserNotifier}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) 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
"""
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | 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.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
@spec get_user!(integer()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
## User registration, used by the registration flow
@doc """
Registers a user.
## Examples
iex> register_user(%{email: "email@example.com", password: "examplepassword"})
{:ok, %User{}}
iex> register_user(%{field: "value"})
{:error, %Ecto.Changeset{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
end
## Settings, used by the account settings flows
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_email(User.t(), map()) :: Ecto.Changeset.t()
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{}}
"""
@spec apply_user_email(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()}
def apply_user_email(user, password, attrs) do
user
|> User.email_changeset(attrs)
|> User.validate_current_password(password)
|> Ecto.Changeset.apply_action(:update)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
The `confirmed_at` date is also updated to the current time.
"""
@spec update_user_email(User.t(), binary()) :: :ok | :error
def update_user_email(user, token) do
context = "change:#{user.email}"
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok
else
_ -> :error
end
end
defp user_email_multi(user, email, context) do
changeset =
user
|> User.email_changeset(%{email: email})
|> User.confirm_changeset()
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.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: ...}}
"""
@spec deliver_user_update_email_instructions(User.t(), String.t(), (... -> String.t())) ::
{:ok, Swoosh.Email.t()}
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.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_password(User.t(), map()) :: Ecto.Changeset.t()
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{}}
"""
@spec update_user_password(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()}
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 """
Generates a session token.
"""
@spec generate_user_session_token(User.t()) :: binary()
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
"""
@spec get_user_by_session_token(binary()) :: User.t()
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Deletes the signed token with the given context.
"""
@spec delete_user_session_token(binary()) :: :ok
def delete_user_session_token(token) do
Repo.delete_all(UserToken.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}
"""
@spec deliver_user_confirmation_instructions(User.t(), (... -> String.t())) ::
{:ok, Swoosh.Email.t()} | {: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.
"""
@spec confirm_user(binary()) :: {:ok, User.t()} | :error
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
end
defp confirm_user_multi(user) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
end
## Reset password
@doc ~S"""
Delivers the reset password email to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
@spec deliver_user_reset_password_instructions(User.t(), (... -> String.t())) ::
{:ok, Swoosh.Email.t()}
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
@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
"""
@spec get_user_by_reset_password_token(binary()) :: User.t() | 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{}}
"""
@spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
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.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
end

View file

@ -0,0 +1,163 @@
defmodule Prymn.Accounts.User do
import Ecto.Changeset
use Ecto.Schema
@type t :: %__MODULE__{
id: term,
email: String.t(),
password: String.t(),
hashed_password: String.t(),
confirmed_at: NaiveDateTime.t()
}
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()
end
@doc """
A user changeset for registration.
It is important to validate the length of both email and password.
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.
## 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
submitting the form), this option can be set to `false`.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> validate_email(opts)
|> validate_password(opts)
end
defp validate_email(changeset, opts) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/,
message: "Email must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
|> maybe_validate_unique_email(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
|> 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
# 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, Argon2.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
|> unsafe_validate_unique(:email, Prymn.Repo)
|> unique_constraint(:email)
else
changeset
end
end
@doc """
A user changeset for changing the email.
It requires the email to change otherwise an error is added.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
|> case do
%{changes: %{email: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, "Email did not change")
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: "Passwords do not match")
|> 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
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Prymn.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Argon2.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, "Password is not valid")
end
end
end

View file

@ -0,0 +1,81 @@
defmodule Prymn.Accounts.UserNotifier do
import Swoosh.Email
alias Prymn.Mailer
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"Prymn", "contact@example.com"})
|> subject(subject)
|> text_body(body)
{:ok, _metadata} = Mailer.deliver(email)
{:ok, email}
end
@doc """
Deliver instructions to confirm account.
"""
@spec deliver_confirmation_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()}
def deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to reset a user password.
"""
@spec deliver_reset_password_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()}
def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Reset password instructions", """
==============================
Hi #{user.email},
You can reset your password by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to update a user email.
"""
@spec deliver_update_email_instructions(User.t(), String.t()) :: {:ok, Swoosh.Email.t()}
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
end

View file

@ -0,0 +1,179 @@
defmodule Prymn.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Prymn.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the reset password token expiry short,
# since someone with access to the email may take over the account.
@reset_password_validity_in_days 1
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Prymn.Accounts.User
timestamps(updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix' default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %UserToken{token: token, context: "session", user_id: user.id}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: user
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within a certain period, depending on the
context. The default contexts supported by this function are either
"confirm", for account confirmation emails, and "reset_password",
for resetting the password. For verifying requests to change the email,
see `verify_change_email_token_query/2`.
"""
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days = days_for_context(context)
query =
from token in token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
defp days_for_context("confirm"), do: @confirm_validity_in_days
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
This is used to validate requests to change the user
email. It is different from `verify_email_token_query/2` precisely because
`verify_email_token_query/2` validates the email has not changed, which is
the starting point by this function.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Returns the token struct for the given token value and context.
"""
def token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
@doc """
Gets all tokens for the given user for the given contexts.
"""
def user_and_contexts_query(user, :all) do
from t in UserToken, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View file

@ -21,6 +21,12 @@
>
Get Started <span aria-hidden="true">&rarr;</span>
</a>
<.link href={~p"/users/settings"} class="hover:text-zinc-700">
<%= @current_user.email %>
</.link>
<.link href={~p"/auth/log_out"} method="delete" class="hover:text-zinc-700">
Log out
</.link>
</div>
</div>
</header>

View file

@ -0,0 +1,6 @@
<main class="grid h-screen items-center">
<.flash_group flash={@flash} />
<div class="mb-32">
<%= @inner_content %>
</div>
</main>

View file

@ -2,9 +2,7 @@ defmodule PrymnWeb.PageController do
use PrymnWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
render(conn, :home)
end
def install(conn, _params) do

View file

@ -1,43 +1,3 @@
<.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">

View file

@ -0,0 +1,43 @@
defmodule PrymnWeb.UserSessionController do
alias Prymn.Accounts
alias PrymnWeb.UserAuth
use PrymnWeb, :controller
@spec create(Plug.Conn.t(), Phoenix.Param.t()) :: Plug.Conn.t()
@spec delete(Plug.Conn.t(), Phoenix.Param.t()) :: Plug.Conn.t()
def create(conn, %{"_action" => "registered"} = params) do
create(conn, params, "Account created successfully!")
end
def create(conn, %{"_action" => "password_updated"} = params) do
conn
|> put_session(:user_return_to, ~p"/users/settings")
|> create(params, "Password updated successfully!")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
conn
|> put_flash("login_error", "Invalid email or password")
|> redirect(to: ~p"/auth/log_in?#{[email: String.slice(email, 0, 160)]}")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -15,7 +15,13 @@ defmodule PrymnWeb.ServerLive.NewServer do
<.error :if={assigns[:error]}>There were some errors.</.error>
<.simple_form for={@form} phx-change="validate" phx-submit="connect" phx-target={@myself}>
<.simple_form
id="server-form"
for={@form}
phx-change="validate"
phx-submit="connect"
phx-target={@myself}
>
<.input field={@form[:name]} label="Server Name" phx-debounce={1000} />
<.input
field={@form[:provider]}

View file

@ -0,0 +1,97 @@
defmodule PrymnWeb.UserConfirmationLive do
use PrymnWeb, :live_view
alias Prymn.Accounts
@impl true
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Email Confirm</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
<.input field={@form[:token]} type="hidden" />
<:actions>
<.button phx-disable-with="Confirming..." class="w-full">Confirm my email</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def render(%{live_action: :new} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
No confirmation instructions received?
<:subtitle>We'll send a new email confirmation link to your inbox.</:subtitle>
</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="send_instructions">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Resend confirmation instructions
</.button>
</:actions>
</.simple_form>
<div class="mt-4 flex justify-center divide-x text-sm">
<.link href={~p"/auth/register"} class="px-3">Register</.link>
<.link href={~p"/auth/log_in"} class="px-3">Log in</.link>
</div>
</div>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok, assign(socket, form: to_form(params, as: "user")), temporary_assigns: [form: nil]}
end
@impl true
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
case Accounts.confirm_user(token) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Email confirmed successfully.")
|> redirect(to: ~p"/")}
:error ->
# If there is a current user and the account was already confirmed,
# then odds are that the confirmation link was already visited, either
# by some automation or by the user themselves, so we redirect without
# a warning message.
case socket.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
{:noreply, redirect(socket, to: ~p"/")}
%{} ->
{:noreply,
socket
|> put_flash(:error, "Email confirmation link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
end
@impl true
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/auth/confirm/#{&1}")
)
end
info =
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View file

@ -0,0 +1,57 @@
defmodule PrymnWeb.UserForgotPasswordLive do
use PrymnWeb, :live_view
alias Prymn.Accounts
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>
Enter your email that your account was registered with and we will
send a password reset link to your inbox.
</:subtitle>
</.header>
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Send password reset instructions
</.button>
</:actions>
</.simple_form>
<div class="mt-4 flex justify-center divide-x text-sm">
<.link href={~p"/auth/register"} class="px-3">Register</.link>
<.link href={~p"/auth/log_in"} class="px-3">Log in</.link>
</div>
</div>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
@impl true
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/auth/reset_password/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions to reset your password shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View file

@ -0,0 +1,54 @@
defmodule PrymnWeb.UserLoginLive do
use PrymnWeb, :live_view
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Sign in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/auth/register"} class="text-brand font-semibold hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/auth/log_in"} phx-update="ignore">
<.error :if={@error}>
Could not sign in: <%= @error %>
</.error>
<.input
field={@form[:email]}
type="email"
label="Email"
placeholder="hello@example.com"
required
/>
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
<.link href={~p"/auth/reset_password"} class="text-sm font-semibold">
Forgot your password?
</.link>
</:actions>
<:actions>
<.button phx-disable-with="Signing in..." class="w-full">Sign in</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def mount(params, _session, socket) do
form = to_form(%{"email" => params["email"] || nil}, as: "user")
error = Phoenix.Flash.get(socket.assigns.flash, "login_error")
{:ok, assign(socket, form: form, error: error), temporary_assigns: [form: form]}
end
end

View file

@ -0,0 +1,85 @@
defmodule PrymnWeb.UserRegistrationLive do
use PrymnWeb, :live_view
alias Prymn.Accounts
alias Prymn.Accounts.User
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/auth/log_in"} class="text-brand font-semibold hover:underline">
Sign in
</.link>
to your account now.
</:subtitle>
</.header>
<.simple_form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
phx-trigger-action={@trigger_submit}
action={~p"/auth/log_in?_action=registered"}
method="post"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:email]} type="email" label="Email" phx-debounce required />
<.input field={@form[:password]} type="password" label="Password" phx-debounce required />
<:actions>
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
socket =
socket
|> assign(trigger_submit: false, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/auth/confirm/#{&1}")
)
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign_form(changeset) |> assign(check_errors: true)}
end
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, form: to_form(changeset, as: "user"))
end
end

View file

@ -0,0 +1,85 @@
defmodule PrymnWeb.UserResetPasswordLive do
use PrymnWeb, :live_view
alias Prymn.Accounts
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.simple_form
for={@form}
id="reset_password_form"
phx-submit="reset_password"
phx-change="validate"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:password]} type="password" label="New password" phx-debounce required />
<.input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
phx-debounce
required
/>
<:actions>
<.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
</:actions>
</.simple_form>
<div class="mt-4 flex justify-center divide-x text-sm">
<.link href={~p"/auth/register"} class="px-3">Register</.link>
<.link href={~p"/auth/log_in"} class="px-3">Log in</.link>
</div>
</div>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
if user = Accounts.get_user_by_reset_password_token(token) do
changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(user: user, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
else
{:ok,
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
@impl true
def handle_event("reset_password", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully. Please sign in.")
|> redirect(to: ~p"/auth/log_in")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign_form(changeset) |> assign(check_errors: true)}
end
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, form: to_form(changeset, as: "user"))
end
end

View file

@ -0,0 +1,174 @@
defmodule PrymnWeb.UserSettingsLive do
alias Prymn.Accounts
use PrymnWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header class="text-center">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
<div class="space-y-12 divide-y">
<div>
<.simple_form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input field={@email_form[:email]} type="email" label="Email" required />
<.input
field={@email_form[:current_password]}
name="current_password"
id="current_password_for_email"
type="password"
label="Current password"
value={@email_form_current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Email</.button>
</:actions>
</.simple_form>
</div>
<div>
<.simple_form
for={@password_form}
id="password_form"
action={~p"/auth/log_in?_action=password_updated"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<.input
field={@password_form[:email]}
type="hidden"
id="hidden_user_email"
value={@current_email}
/>
<.input field={@password_form[:password]} type="password" label="New password" required />
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
/>
<.input
field={@password_form[:current_password]}
name="current_password"
type="password"
label="Current password"
id="current_password_for_password"
value={@current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Password</.button>
</:actions>
</.simple_form>
</div>
</div>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_user
email_changeset = Accounts.change_user_email(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:email_form_current_password, nil)
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
@impl true
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_form =
socket.assigns.current_user
|> Accounts.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
end
@impl true
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
user.email,
&url(~p"/users/settings/confirm_email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
{:error, changeset} ->
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
@impl true
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
@impl true
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

View file

@ -1,4 +1,6 @@
defmodule PrymnWeb.Router do
import PrymnWeb.UserAuth
use PrymnWeb, :router
pipeline :browser do
@ -8,6 +10,7 @@ defmodule PrymnWeb.Router do
plug :put_root_layout, html: {PrymnWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
@ -15,15 +18,53 @@ defmodule PrymnWeb.Router do
end
scope "/", PrymnWeb do
pipe_through :browser
pipe_through [:browser, :require_authenticated_user]
get "/", PageController, :home
live_session :require_authenticated_user,
on_mount: [{PrymnWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive
live "/users/settings/confirm_email/:token", UserSettingsLive
live "/servers", ServerLive.Index, :index
live "/servers/new", ServerLive.Index, :new
live "/servers/:id", ServerLive.Show
end
end
scope "/", PrymnWeb do
pipe_through [:browser]
get "/install", PageController, :install
end
live "/servers", ServerLive.Index, :index
live "/servers/new", ServerLive.Index, :new
scope "/auth", PrymnWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
live "/servers/:id", ServerLive.Show
live_session :redirect_if_user_is_authenticated,
on_mount: [{PrymnWeb.UserAuth, :redirect_if_user_is_authenticated}],
layout: {PrymnWeb.Layouts, :auth} do
live "/register", UserRegistrationLive
live "/log_in", UserLoginLive
live "/reset_password", UserForgotPasswordLive
live "/reset_password/:token", UserResetPasswordLive
end
post "/log_in", UserSessionController, :create
end
scope "/auth", PrymnWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{PrymnWeb.UserAuth, :mount_current_user}],
layout: {PrymnWeb.Layouts, :auth} do
live "/confirm", UserConfirmationLive, :new
live "/confirm/:token", UserConfirmationLive, :edit
end
delete "/log_out", UserSessionController, :delete
end
scope "/api/v1", PrymnWeb do

View file

@ -43,7 +43,7 @@ defmodule PrymnWeb.Telemetry do
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_join.duration",
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",

View file

@ -0,0 +1,203 @@
defmodule PrymnWeb.UserAuth do
import Plug.Conn
import Phoenix.Controller
alias Prymn.Accounts
use PrymnWeb, :verified_routes
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
@max_age 60 * 60 * 24 * 60
@remember_me_cookie "_prymn_web_user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
@doc """
Logs the user in.
It renews the session ID and clears the whole session
to avoid fixation attacks. See the renew_session
function to customize this behaviour.
It also sets a `:live_socket_id` key in the session,
so LiveView sessions are identified and automatically
disconnected on log out. The line can be safely removed
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
|> 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
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
defp maybe_write_remember_me_cookie(conn, _token, _params) do
conn
end
defp renew_session(conn) do
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
PrymnWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session
and remember me token.
"""
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule PrymnWeb.PageLive do
use PrymnWeb, :live_view
on_mount {PrymnWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{PrymnWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/auth/log_in")}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
@doc """
Used for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> maybe_store_return_to()
|> redirect(to: ~p"/auth/log_in")
|> halt()
end
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/"
end

View file

@ -33,7 +33,8 @@ defmodule Prymn.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.7.6"},
{:argon2_elixir, "~> 3.0"},
{:phoenix, "~> 1.7.7"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"},

View file

@ -1,5 +1,7 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "3.1.0", "4135e0a1b4ff800d42c85aa663e068efa3cb356297189b5b65caa992db8ec8cf", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "c08feae0ee0292165d1b945003363c7cd8523d002e0483c627dfca930291dd73"},
"castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
@ -8,6 +10,7 @@
"dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"},
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"},
"elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"},
"expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"},
@ -29,16 +32,16 @@
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.0", "0b3158b5b198aa444473c91d23d79f52fb077e807ffad80dacf88ce078fa8df2", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "87785a54474fed91a67a1227a741097eb1a42c2e49d3c0d098b588af65cd410d"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.19.4", "dd9ffe3ca0683bdef4f340bcdd2c35a6ee0d581a2696033fc25f52e742618bdc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fd2c666d227476d63af7b8c20e6e61d16f07eb49f924cf4198fca7668156f15b"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.2", "a3dd349493d7c0b8f58da8175f805963a5b809ffc7d8c1b8dd46ba5b199ef58f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "ab78ebc964685b9eeba102344049eb32d69e582c497d5a0ae6f25909db00c67b"},
"phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"},
"plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"},
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
"postgrex": {:hex, :postgrex, "0.17.2", "a3ec9e3239d9b33f1e5841565c4eb200055c52cc0757a22b63ca2d529bbe764c", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "80a918a9e9531d39f7bd70621422f3ebc93c01618c645f2d91306f50041ed90c"},
"protobuf": {:hex, :protobuf, "0.12.0", "58c0dfea5f929b96b5aa54ec02b7130688f09d2de5ddc521d696eec2a015b223", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "75fa6cbf262062073dd51be44dd0ab940500e18386a6c4e87d5819a58964dc45"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"swoosh": {:hex, :swoosh, "1.11.3", "49caa2653205bfa0a567b5404afb5c39e932a9678d2e43cc78271670721397c8", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e6f3381f9e95d88effa31446177d9cb7c3f6d617c355ab806ddddceda35208d7"},
"swoosh": {:hex, :swoosh, "1.11.4", "9b353f998cba3c5e101a0669559c2fb2757b5d9eb7db058bf08687d82e93e416", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d3390914022a456ae1604bfcb3431bd12509b2afe8c70296bae6c9dca4903d0f"},
"tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"},
"tailwind_formatter": {:hex, :tailwind_formatter, "0.3.6", "f3b02687a79a99106f2cee604d36561091ab5b9c9d16a97ae5901d91b3357047", [:mix], [{:phoenix_live_view, ">= 0.17.6", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "3a0d75dad1700f9fa9394185c4ce0eb0eff2b1a0eb9aef66b4b382eae657bded"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},

View file

@ -0,0 +1,27 @@
defmodule Prymn.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View file

@ -0,0 +1,508 @@
defmodule Prymn.AccountsTest do
use Prymn.DataCase
alias Prymn.Accounts
import Prymn.AccountsFixtures
alias Prymn.Accounts.{User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!(-1)
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires email and password to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{
password: ["can't be blank"],
email: ["can't be blank"]
} = errors_on(changeset)
end
test "validates email and password when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
assert %{
email: ["Email must have the @ sign and no spaces"],
password: ["should be at least 12 character(s)"]
} = errors_on(changeset)
end
test "validates maximum values for email and password for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Accounts.register_user(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the upper cased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users with a hashed password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_binary(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
end
end
describe "change_user_registration/2" do
test "returns a changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
assert changeset.required == [:password, :email]
end
test "allows fields to be set" do
email = unique_user_email()
password = valid_user_password()
changeset =
Accounts.change_user_registration(
%User{},
valid_user_attributes(email: email, password: password)
)
assert changeset.valid?
assert get_change(changeset, :email) == email
assert get_change(changeset, :password) == password
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "change_user_email/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
assert changeset.required == [:email]
end
end
describe "apply_user_email/3" do
setup do
%{user: user_fixture()}
end
test "requires email to change", %{user: user} do
{:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{})
assert %{email: ["Email did not change"]} = errors_on(changeset)
end
test "validates email", %{user: user} do
{:error, changeset} =
Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"})
assert %{email: ["Email must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum value for email for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.apply_user_email(user, valid_user_password(), %{email: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness", %{user: user} do
%{email: email} = user_fixture()
password = valid_user_password()
{:error, changeset} = Accounts.apply_user_email(user, password, %{email: email})
assert "has already been taken" in errors_on(changeset).email
end
test "validates current password", %{user: user} do
{:error, changeset} =
Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()})
assert %{current_password: ["Password is not valid"]} = errors_on(changeset)
end
test "applies the email without persisting it", %{user: user} do
email = unique_user_email()
{:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email})
assert user.email == email
assert Accounts.get_user!(user.id).email != email
end
end
describe "deliver_user_update_email_instructions/3" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "change:current@example.com"
end
end
describe "update_user_email/2" do
setup do
user = user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{user: user, token: token, email: email}
end
test "updates the email with a valid token", %{user: user, token: token, email: email} do
assert Accounts.update_user_email(user, token) == :ok
changed_user = Repo.get!(User, user.id)
assert changed_user.email != user.email
assert changed_user.email == email
assert changed_user.confirmed_at
assert changed_user.confirmed_at != user.confirmed_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email with invalid token", %{user: user} do
assert Accounts.update_user_email(user, "oops") == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if user email changed", %{user: user, token: token} do
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.update_user_email(user, token) == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "change_user_password/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(%User{}, %{
"password" => "new valid password"
})
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/3" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["Passwords do not match"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, valid_user_password(), %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "validates current password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, "invalid", %{password: valid_user_password()})
assert %{current_password: ["Password is not valid"]} = errors_on(changeset)
end
test "updates the password", %{user: user} do
{:ok, user} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password"
})
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, _} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_session_token(token)
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "deliver_user_confirmation_instructions/2" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "confirm"
end
end
describe "confirm_user/1" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
%{user: user, token: token}
end
test "confirms the email with a valid token", %{user: user, token: token} do
assert {:ok, confirmed_user} = Accounts.confirm_user(token)
assert confirmed_user.confirmed_at
assert confirmed_user.confirmed_at != user.confirmed_at
assert Repo.get!(User, user.id).confirmed_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm with invalid token", %{user: user} do
assert Accounts.confirm_user("oops") == :error
refute Repo.get!(User, user.id).confirmed_at
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.confirm_user(token) == :error
refute Repo.get!(User, user.id).confirmed_at
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "deliver_user_reset_password_instructions/2" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "reset_password"
end
end
describe "get_user_by_reset_password_token/1" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
%{user: user, token: token}
end
test "returns the user with valid token", %{user: %{id: id}, token: token} do
assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
assert Repo.get_by(UserToken, user_id: id)
end
test "does not return the user with invalid token", %{user: user} do
refute Accounts.get_user_by_reset_password_token("oops")
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not return the user if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_reset_password_token(token)
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "reset_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.reset_user_password(user, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["Passwords do not match"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.reset_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"})
assert is_nil(updated_user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end

View file

@ -21,10 +21,14 @@ defmodule Prymn.ServersTest do
end
test "create_server/1 with valid data creates a server" do
valid_attrs = %{name: "some name"}
valid_attrs = %{name: "some name", public_ip: "127.0.0.1", provider: :Custom}
assert {:ok, %Server{} = server} = Servers.create_server(valid_attrs)
assert server.name == "some name"
assert server.public_ip == "127.0.0.1"
assert server.status == :unregistered
assert server.provider == :Custom
assert server.registration_token != nil
end
test "create_server/1 with invalid data returns error changeset" do

View file

@ -1,8 +1,20 @@
defmodule PrymnWeb.PageControllerTest do
alias Prymn.Accounts
use PrymnWeb.ConnCase
test "GET /", %{conn: conn} do
import Prymn.AccountsFixtures
test "redirect unauthenticated user", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Welcome"
assert html_response(conn, 302) =~ "redirected"
end
describe "authenticated" do
setup :register_and_log_in_user
test "shows page", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200)
end
end
end

View file

@ -0,0 +1,114 @@
defmodule PrymnWeb.UserSessionControllerTest do
use PrymnWeb.ConnCase, async: true
import Prymn.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "POST /auth/log_in" do
test "logs the user in", %{conn: conn, user: user} do
conn =
post(conn, ~p"/auth/log_in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/auth/log_out"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
conn =
post(conn, ~p"/auth/log_in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_prymn_web_user_remember_me"]
assert redirected_to(conn) == ~p"/"
end
test "logs the user in with return to", %{conn: conn, user: user} do
conn =
conn
|> init_test_session(user_return_to: "/foo/bar")
|> post(~p"/auth/log_in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == "/foo/bar"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
end
test "login following registration", %{conn: conn, user: user} do
conn =
conn
|> post(~p"/auth/log_in", %{
"_action" => "registered",
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == ~p"/"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully"
end
test "login following password update", %{conn: conn, user: user} do
conn =
conn
|> post(~p"/auth/log_in", %{
"_action" => "password_updated",
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully"
end
test "redirects to login page with invalid credentials", %{conn: conn} do
email = "invalid@email.com"
conn =
post(conn, ~p"/auth/log_in", %{
"user" => %{"email" => email, "password" => "invalid_password"}
})
assert redirected_to(conn) == ~p"/auth/log_in?#{[email: email]}"
end
end
describe "DELETE /auth/log_out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> delete(~p"/auth/log_out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/auth/log_out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
end
end

View file

@ -4,8 +4,7 @@ defmodule PrymnWeb.ServerLiveTest do
import Phoenix.LiveViewTest
import Prymn.ServersFixtures
@create_attrs %{name: "some name"}
@update_attrs %{name: "some updated name"}
@create_attrs %{name: "some name", provider: :Custom}
@invalid_attrs %{name: nil}
defp create_server(_) do
@ -14,12 +13,12 @@ defmodule PrymnWeb.ServerLiveTest do
end
describe "Index" do
setup [:create_server]
setup [:create_server, :register_and_log_in_user]
test "lists all servers", %{conn: conn, server: server} do
{:ok, _index_live, html} = live(conn, ~p"/servers")
assert html =~ "Listing Servers"
assert html =~ "Your servers"
assert html =~ server.name
end
@ -42,20 +41,13 @@ defmodule PrymnWeb.ServerLiveTest do
assert_patch(index_live, ~p"/servers")
html = render(index_live)
assert html =~ "Server created successfully"
assert html =~ "Success!"
assert html =~ "some name"
end
test "deletes server in listing", %{conn: conn, server: server} do
{:ok, index_live, _html} = live(conn, ~p"/servers")
assert index_live |> element("#servers-#{server.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#servers-#{server.id}")
end
end
describe "Show" do
setup [:create_server]
setup [:create_server, :register_and_log_in_user]
test "displays server", %{conn: conn, server: server} do
{:ok, _show_live, html} = live(conn, ~p"/servers/#{server}")
@ -63,24 +55,4 @@ defmodule PrymnWeb.ServerLiveTest do
assert html =~ "Server some name"
end
end
describe "Edit" do
setup [:create_server]
test "updates server", %{conn: conn, server: server} do
{:ok, edit_live, _html} = live(conn, ~p"/servers/#{server}/edit")
assert edit_live
|> form("#server-form", server: @invalid_attrs)
|> render_change() =~
"can&#39;t be blank"
assert edit_live
|> form("#server-form", server: @update_attrs)
|> render_submit()
html = render(edit_live)
assert html =~ "some updated name"
end
end
end

View file

@ -0,0 +1,137 @@
defmodule PrymnWeb.UserConfirmationLiveTest do
use PrymnWeb.ConnCase
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
alias Prymn.Accounts
alias Prymn.Repo
setup do
%{user: user_fixture()}
end
describe "Confirm user" do
test "confirms the given token once", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, lv, _html} = live(conn, ~p"/auth/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"Email confirmed successfully"
assert Accounts.get_user!(user.id).confirmed_at
refute get_session(conn, :user_token)
assert Repo.all(Accounts.UserToken) == []
# when not logged in
{:ok, lv, _html} = live(conn, ~p"/auth/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Email confirmation link is invalid or it has expired"
# when logged in
{:ok, lv, _html} =
build_conn()
|> log_in_user(user)
|> live(~p"/auth/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
refute Phoenix.Flash.get(conn.assigns.flash, :error)
end
test "does not confirm email with invalid token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/auth/confirm/invalid-token")
{:ok, conn} =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Email confirmation link is invalid or it has expired"
refute Accounts.get_user!(user.id).confirmed_at
end
end
describe "Resend confirmation" do
test "renders the resend confirmation page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/auth/confirm")
assert html =~ "Resend confirmation instructions"
end
test "sends a new confirmation token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/auth/confirm")
{:ok, conn} =
lv
|> form("#confirmation_form", user: %{email: user.email})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
end
test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do
Repo.update!(Accounts.User.confirm_changeset(user))
{:ok, lv, _html} = live(conn, ~p"/auth/confirm")
{:ok, conn} =
lv
|> form("#confirmation_form", user: %{email: user.email})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
refute Repo.get_by(Accounts.UserToken, user_id: user.id)
end
test "does not send confirmation token if email is invalid", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/confirm")
{:ok, conn} =
lv
|> form("#confirmation_form", user: %{email: "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View file

@ -0,0 +1,55 @@
defmodule PrymnWeb.UserForgotPasswordLiveTest do
use PrymnWeb.ConnCase
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
alias Prymn.Accounts
alias Prymn.Repo
describe "Forgot password page" do
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/auth/reset_password")
|> follow_redirect(conn, ~p"/")
assert {:ok, _conn} = result
end
end
describe "Reset link" do
setup do
%{user: user_fixture()}
end
test "sends a new reset password token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/auth/reset_password")
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => user.email})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context ==
"reset_password"
end
test "does not send reset password token if email is invalid", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/reset_password")
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View file

@ -0,0 +1,47 @@
defmodule PrymnWeb.UserLoginLiveTest do
use PrymnWeb.ConnCase
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
describe "Log in page" do
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/auth/log_in")
|> follow_redirect(conn, "/")
assert {:ok, _conn} = result
end
end
describe "user login" do
test "redirects if user login with valid credentials", %{conn: conn} do
password = "123456789abcd"
user = user_fixture(%{password: password})
{:ok, lv, _html} = live(conn, ~p"/auth/log_in")
form =
form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true})
conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/"
end
test "redirects to login page if there are no valid credentials", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/log_in")
email = "test@email.com"
form =
form(lv, "#login_form", user: %{email: email, password: "123456", remember_me: true})
conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/auth/log_in?#{[email: email]}"
end
end
end

View file

@ -0,0 +1,65 @@
defmodule PrymnWeb.UserRegistrationLiveTest do
use PrymnWeb.ConnCase
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
describe "Registration page" do
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/auth/register")
|> follow_redirect(conn, "/")
assert {:ok, _conn} = result
end
test "renders errors for invalid data", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/register")
result =
lv
|> element("#registration_form")
|> render_change(user: %{"email" => "with spaces", "password" => "too short"})
assert result =~ "Register"
assert result =~ "must have the @ sign and no spaces"
assert result =~ "should be at least 12 character"
end
end
describe "register user" do
test "creates account and logs the user in", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/register")
email = unique_user_email()
form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
render_submit(form)
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
assert response =~ email
assert response =~ "Welcome"
end
test "renders errors for duplicated email", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/auth/register")
user = user_fixture(%{email: "test@email.com"})
result =
lv
|> form("#registration_form",
user: %{"email" => user.email, "password" => "valid_password"}
)
|> render_submit()
assert result =~ "has already been taken"
end
end
end

View file

@ -0,0 +1,83 @@
defmodule PrymnWeb.UserResetPasswordLiveTest do
use PrymnWeb.ConnCase
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
alias Prymn.Accounts
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
%{token: token, user: user}
end
describe "Reset password page" do
test "does not render reset password with invalid token", %{conn: conn} do
{:error, {:redirect, to}} = live(conn, ~p"/auth/reset_password/invalid")
assert to == %{
flash: %{"error" => "Reset password link is invalid or it has expired."},
to: ~p"/"
}
end
test "renders errors for invalid data", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/auth/reset_password/#{token}")
result =
lv
|> element("#reset_password_form")
|> render_change(
user: %{"password" => "secret12", "confirmation_password" => "secret123456"}
)
assert result =~ "should be at least 12 character"
assert result =~ "Passwords do not match"
end
end
describe "Reset Password" do
test "resets password once", %{conn: conn, token: token, user: user} do
{:ok, lv, _html} = live(conn, ~p"/auth/reset_password/#{token}")
{:ok, conn} =
lv
|> form("#reset_password_form",
user: %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/auth/log_in")
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "does not reset password on invalid data", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/auth/reset_password/#{token}")
result =
lv
|> form("#reset_password_form",
user: %{
"password" => "too short",
"password_confirmation" => "does not match"
}
)
|> render_submit()
assert result =~ "Reset Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "Passwords do not match"
end
end
end

View file

@ -0,0 +1,188 @@
defmodule PrymnWeb.UserSettingsLiveTest do
use PrymnWeb.ConnCase
alias Prymn.Accounts
import Phoenix.LiveViewTest
import Prymn.AccountsFixtures
describe "update email form" do
setup %{conn: conn} do
password = valid_user_password()
user = user_fixture(%{password: password})
%{conn: log_in_user(conn, user), user: user, password: password}
end
test "updates the user email", %{conn: conn, password: password, user: user} do
new_email = unique_user_email()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"current_password" => password,
"user" => %{"email" => new_email}
})
|> render_submit()
assert result =~ "A link to confirm your email"
assert Accounts.get_user_by_email(user.email)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#email_form")
|> render_change(%{
"action" => "update_email",
"current_password" => "invalid",
"user" => %{"email" => "with spaces"}
})
assert result =~ "Change Email"
assert result =~ "must have the @ sign and no spaces"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"current_password" => "invalid",
"user" => %{"email" => user.email}
})
|> render_submit()
assert result =~ "Change Email"
assert result =~ "did not change"
assert result =~ "is not valid"
end
end
describe "update password form" do
setup %{conn: conn} do
password = valid_user_password()
user = user_fixture(%{password: password})
%{conn: log_in_user(conn, user), user: user, password: password}
end
test "updates the user password", %{conn: conn, user: user, password: password} do
new_password = valid_user_password()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
form =
form(lv, "#password_form", %{
"current_password" => password,
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/users/settings"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#password_form")
|> render_change(%{
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
assert result =~ "Change Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "Passwords do not match"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#password_form", %{
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
|> render_submit()
assert result =~ "Change Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "Passwords do not match"
assert result =~ "is not valid"
end
end
describe "confirm email" do
setup %{conn: conn} do
user = user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{conn: log_in_user(conn, user), token: token, email: email, user: user}
end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"info" => message} = flash
assert message == "Email changed successfully."
refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(email)
# use confirm token again
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"error" => message} = flash
assert message == "Email change link is invalid or it has expired."
end
test "does not update email with invalid token", %{conn: conn, user: user} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"error" => message} = flash
assert message == "Email change link is invalid or it has expired."
assert Accounts.get_user_by_email(user.email)
end
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/auth/log_in"
end
end
end

View file

@ -0,0 +1,269 @@
defmodule PrymnWeb.UserAuthTest do
use PrymnWeb.ConnCase, async: true
alias Phoenix.LiveView
alias Prymn.Accounts
alias PrymnWeb.UserAuth
import Prymn.AccountsFixtures
@remember_me_cookie "_prymn_web_user_remember_me"
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, PrymnWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
%{user: user_fixture(), conn: conn}
end
describe "log_in_user/3" do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/"
assert Accounts.get_user_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, user: user} do
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == 5_184_000
end
end
describe "logout_user/1" do
test "erases session and cookies", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn
|> put_session(:user_token, user_token)
|> put_req_cookie(@remember_me_cookie, user_token)
|> fetch_cookies()
|> UserAuth.log_out_user()
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Accounts.get_user_by_session_token(user_token)
end
test "broadcasts to the given live_socket_id", %{conn: conn} do
live_socket_id = "users_sessions:abcdef-token"
PrymnWeb.Endpoint.subscribe(live_socket_id)
conn
|> put_session(:live_socket_id, live_socket_id)
|> UserAuth.log_out_user()
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
end
test "authenticates user from cookies", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
user_token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
conn =
conn
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
assert get_session(conn, :user_token) == user_token
assert get_session(conn, :live_socket_id) ==
"users_sessions:#{Base.url_encode64(user_token)}"
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ = Accounts.generate_user_session_token(user)
conn = UserAuth.fetch_current_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_user
end
end
describe "on_mount: mount_current_user" do
test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
session = conn |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount: ensure_authenticated" do
test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: PrymnWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
test "redirects to login page if there isn't a user_token", %{conn: conn} do
session = conn |> get_session()
socket = %LiveView.Socket{
endpoint: PrymnWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount: :redirect_if_user_is_authenticated" do
test "redirects if there is an authenticated user ", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
assert {:halt, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
test "doesn't redirect if there is no authenticated user", %{conn: conn} do
session = conn |> get_session()
assert {:cont, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
end
describe "redirect_if_user_is_authenticated/2" do
test "redirects if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
end
test "does not redirect if user is not authenticated", %{conn: conn} do
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
refute conn.halted
refute conn.status
end
end
describe "require_authenticated_user/2" do
test "redirects if user is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
assert conn.halted
assert redirected_to(conn) == ~p"/auth/log_in"
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
end

View file

@ -35,4 +35,30 @@ defmodule PrymnWeb.ConnCase do
Prymn.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
@doc """
Setup helper that registers and logs in users.
setup :register_and_log_in_user
It stores an updated connection and a registered user in the
test context.
"""
def register_and_log_in_user(%{conn: conn}) do
user = Prymn.AccountsFixtures.user_fixture()
%{conn: log_in_user(conn, user), user: user}
end
@doc """
Logs the given `user` into the `conn`.
It returns an updated `conn`.
"""
def log_in_user(conn, user) do
token = Prymn.Accounts.generate_user_session_token(user)
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
end

View file

@ -0,0 +1,31 @@
defmodule Prymn.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Prymn.Accounts` context.
"""
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email(),
password: valid_user_password()
})
end
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> valid_user_attributes()
|> Prymn.Accounts.register_user()
user
end
def extract_user_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
token
end
end

View file

@ -12,8 +12,8 @@ defmodule Prymn.ServersFixtures do
attrs
|> Enum.into(%{
name: "some name",
ipv4: "192.168.1.1",
ipv6: "[1234:5678::1]"
public_ip: "127.0.0.1",
provider: "Custom"
})
|> Prymn.Servers.create_server()