diff --git a/README.md b/README.md
index f00759f..77900f8 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,40 @@
# Demo
-To start your Phoenix server:
+## Spec
- * Install dependencies with `mix deps.get`
- * Create and migrate your database with `mix ecto.setup`
- * Install Node.js dependencies with `npm install --prefix assets`
- * Start Phoenix endpoint with `mix phx.server`
+We will have two database tables: "users" and "users_tokens":
-Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
+* "users" will have the "hashed_password", "email" and "confirmed_at" fields plus timestamps
+* "users_tokens" will have "token", "context", "sent_to", "inserted_at" fields
-Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
+On sign in, we need to renew the session and delete the CSRF token. The password should be limited to 80 characters, the email to 160.
-## Learn more
+Confirmation, resetting passwords, remember me, and session management will be all based on tokens. Tokens are generated randomly using `:crypto.strong_rand_bytes/1` and then hashed using sha2/sha3. The hashing helps protect against timing attacks. The hashed token is the one stored in the database, the original token is not stored.
- * Official website: https://www.phoenixframework.org/
- * Guides: https://hexdocs.pm/phoenix/overview.html
- * Docs: https://hexdocs.pm/phoenix
- * Forum: https://elixirforum.com/c/phoenix-forum
- * Source: https://github.com/phoenixframework/phoenix
+Whenever a token is generated, a context has to be given (such as "session", "reset", etc). The context is used to avoid one token being used against its original purpose. Verifying the token will hash the token and look-up its hashed result in the database. Verification also takes a ttl. The current date is compared against the token `inserted_at + ttl` and the token is deemed as expired if enough time has passed.
+
+For confirming e-mail addresses, we will generate a token, and e-mail it to the user. We will store the hashed token, the context ("confirm"), and store the confirmation e-mail under the `sent_to` column. Once the user clicks the link, we will verify the token, and see if the current user e-mail matches the one in the `sent_to` column. Once the token is used, it is deleted. Note we won't automatically sign the user in after confirmation - this protects us from someone getting access to the account via confirmation tokens. This also allows us to set long expiry dates in the tokens.
+
+For resetting the password, the process is very similar. Once the link is clicked, it will go to the reset password page. The reset password page will ask for the new password and for the password confirmation. Once the user sends the form, we will verify the token and proceed to reset the password. Resetting the password will delete all tokens. Generally speaking, changing the password always deletes all tokens. Resetting the password won't sign the user in - so if anyone intercepts the token, they cannot gain access to the system as they are missing the e-mail/username.
+
+For changing the e-mail, the user will put the new e-mail in a field. This will create a new token with change:CURRENT_EMAIL as context and the new e-mail stored in the `sent_to` column. A hashed token will be sent to the new e-mail. Once the user clicks on the e-mail link, the raw token will be hashed and we will change the user to the new e-mail as long as the hashed token and old email match to the currently signed in user. Once this is done, the token is deleted. This token will have a short expiry date by default.
+
+## Pending for generators
+
+ mix phx.gen.auth Account User users ...extrafields...
+
+The authentication mechanism should be an option. Default to bcrypt for Unix systems, pdkdf2 for Windows systems. The line to config/test.exs must always be added.
+
+## License
+
+Copyright 2020 Dashbit
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/config/test.exs b/config/test.exs
index d4e773a..ddab4c1 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -20,3 +20,6 @@ config :demo, DemoWeb.Endpoint,
# Print only warnings and errors during test
config :logger, level: :warn
+
+# Only in tests, remove the complexity from the password hashing algorithm
+config :bcrypt_elixir, :log_rounds, 1
diff --git a/lib/demo/accounts.ex b/lib/demo/accounts.ex
new file mode 100644
index 0000000..14ca3a9
--- /dev/null
+++ b/lib/demo/accounts.ex
@@ -0,0 +1,348 @@
+defmodule Demo.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ alias Demo.Repo
+ alias Demo.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
+
+ """
+ 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
+
+ """
+ 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)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ ## User registration
+
+ @doc """
+ Registers a user.
+
+ ## Examples
+
+ iex> register_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> register_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def register_user(attrs) do
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user_registration(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs)
+ end
+
+ ## Settings
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user e-mail.
+
+ ## Examples
+
+ iex> change_user_email(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_email(user, attrs \\ %{}) do
+ User.email_changeset(user, attrs)
+ end
+
+ @doc """
+ Emulates that the e-mail will change without actually changing
+ it in the database.
+
+ ## Examples
+
+ iex> apply_user_email(user, "valid password", %{email: ...})
+ {:ok, %User{}}
+
+ iex> apply_user_email(user, "invalid password", %{email: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def apply_user_email(user, password, attrs) do
+ user
+ |> User.email_changeset(attrs)
+ |> User.validate_current_password(password)
+ |> Ecto.Changeset.apply_action(:update)
+ end
+
+ @doc """
+ Updates the user e-mail in 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.
+ """
+ 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 """
+ Delivers the update e-mail instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_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{}}
+
+ """
+ def change_user_password(user, attrs \\ %{}) do
+ User.password_changeset(user, attrs)
+ end
+
+ @doc """
+ Updates the user password.
+
+ ## Examples
+
+ iex> update_user_password(user, "valid password", %{password: ...})
+ {:ok, %User{}}
+
+ iex> update_user_password(user, "invalid password", %{password: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_password(user, password, attrs) do
+ changeset =
+ user
+ |> User.password_changeset(attrs)
+ |> User.validate_current_password(password)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ ## Session
+
+ @doc """
+ Generates a session token.
+ """
+ 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.
+ """
+ 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.
+ """
+ def delete_user_session_token(token) do
+ Repo.delete_all(UserToken.token_and_context_query(token, "session"))
+ :ok
+ end
+
+ ## Confirmation
+
+ @doc """
+ Delivers the confirmation e-mail instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
+ {:error, :already_confirmed}
+
+ """
+ def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
+ when is_function(confirmation_url_fun, 1) do
+ if user.confirmed_at do
+ {:error, :already_confirmed}
+ else
+ {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ end
+ end
+
+ @doc """
+ Confirms a user by the given token.
+
+ If the token matches, the user account is marked as confirmed
+ and the token is deleted.
+ """
+ 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 """
+ Delivers the reset password e-mail to the given user.
+
+ ## Examples
+
+ iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
+ when is_function(reset_password_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
+ 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
+
+ """
+ def get_user_by_reset_password_token(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
+ %User{} = user <- Repo.one(query) do
+ user
+ else
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Resets the user password.
+
+ ## Examples
+
+ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
+ {:ok, %User{}}
+
+ iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def reset_user_password(user, attrs) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+end
diff --git a/lib/demo/accounts/user.ex b/lib/demo/accounts/user.ex
new file mode 100644
index 0000000..3d394c4
--- /dev/null
+++ b/lib/demo/accounts/user.ex
@@ -0,0 +1,116 @@
+defmodule Demo.Accounts.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {Inspect, except: [:password]}
+ schema "users" do
+ field :email, :string
+ field :password, :string, virtual: true
+ field :hashed_password, :string
+ field :confirmed_at, :naive_datetime
+
+ timestamps()
+ end
+
+ @doc """
+ A user changeset for registration.
+
+ It is important to validate the length of both e-mail and password.
+ Otherwise databases may truncate the e-mail without warnings, which
+ could lead to unpredictable or insecure behaviour. Long passwords may
+ also be very expensive to hash for certain algorithms.
+ """
+ def registration_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:email, :password])
+ |> validate_email()
+ |> validate_password()
+ end
+
+ defp validate_email(changeset) do
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
+ |> validate_length(:email, max: 160)
+ |> unsafe_validate_unique(:email, Demo.Repo)
+ |> unique_constraint(:email)
+ end
+
+ defp validate_password(changeset) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12, max: 80)
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+ |> prepare_changes(&hash_password/1)
+ end
+
+ defp hash_password(changeset) do
+ password = get_change(changeset, :password)
+
+ changeset
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
+ end
+
+ @doc """
+ A user changeset for changing the e-mail.
+
+ It requires the e-mail to change otherwise an error is added.
+ """
+ def email_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:email])
+ |> validate_email()
+ |> case do
+ %{changes: %{email: _}} = changeset -> changeset
+ %{} = changeset -> add_error(changeset, :email, "did not change")
+ end
+ end
+
+ @doc """
+ A user changeset for changing the password.
+ """
+ def password_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password()
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ change(user, confirmed_at: now)
+ end
+
+ @doc """
+ Verifies the password.
+
+ If there is no user or the user doesn't have a password, we call
+ `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Demo.Accounts.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
+ end
+
+ @doc """
+ Validates the current password otherwise adds an error to the changeset.
+ """
+ def validate_current_password(changeset, password) do
+ if valid_password?(changeset.data, password) do
+ changeset
+ else
+ add_error(changeset, :current_password, "is not valid")
+ end
+ end
+end
diff --git a/lib/demo/accounts/user_notifier.ex b/lib/demo/accounts/user_notifier.ex
new file mode 100644
index 0000000..a2310c7
--- /dev/null
+++ b/lib/demo/accounts/user_notifier.ex
@@ -0,0 +1,73 @@
+defmodule Demo.Accounts.UserNotifier do
+ # For simplicity, this module simply logs messages to the terminal.
+ # You should replace it by a proper e-mail or notification tool, such as:
+ #
+ # * Swoosh - https://hexdocs.pm/swoosh
+ # * Bamboo - https://hexdocs.pm/bamboo
+ #
+ defp deliver(to, body) do
+ require Logger
+ Logger.debug(body)
+ {:ok, %{to: to, body: body}}
+ end
+
+ @doc """
+ Deliver instructions to confirm account.
+ """
+ def deliver_confirmation_instructions(user, url) do
+ deliver(user.email, """
+
+ ==============================
+
+ 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 password account.
+ """
+ def deliver_reset_password_instructions(user, url) do
+ deliver(user.email, """
+
+ ==============================
+
+ 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 your e-mail.
+ """
+ def deliver_update_email_instructions(user, url) do
+ deliver(user.email, """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can change your e-mail by visiting the url below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+end
diff --git a/lib/demo/accounts/user_token.ex b/lib/demo/accounts/user_token.ex
new file mode 100644
index 0000000..ec4152f
--- /dev/null
+++ b/lib/demo/accounts/user_token.ex
@@ -0,0 +1,139 @@
+defmodule Demo.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+
+ @hash_algorithm :sha256
+ @rand_size 32
+
+ # It is very important to keep the reset password token expiry short,
+ # since someone with access to the e-mail 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, Demo.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.
+ """
+ def build_session_token(user) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ {token, %Demo.Accounts.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.
+ """
+ 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 with a hashed counter part.
+
+ The non-hashed token is sent to the user e-mail while the
+ hashed part is stored in the database, to avoid reconstruction.
+ The token is valid for a week as long as users don't change
+ their email.
+ """
+ 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),
+ %Demo.Accounts.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.
+ """
+ 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 token record.
+ """
+ def verify_change_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)
+
+ 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 given token with the given context.
+ """
+ def token_and_context_query(token, context) do
+ from Demo.Accounts.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 Demo.Accounts.UserToken, where: t.user_id == ^user.id
+ end
+
+ def user_and_contexts_query(user, [_ | _] = contexts) do
+ from t in Demo.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ end
+end
diff --git a/lib/demo_web/controllers/user_auth.ex b/lib/demo_web/controllers/user_auth.ex
new file mode 100644
index 0000000..883337b
--- /dev/null
+++ b/lib/demo_web/controllers/user_auth.ex
@@ -0,0 +1,149 @@
+defmodule DemoWeb.UserAuth do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Demo.Accounts
+ alias DemoWeb.Router.Helpers, as: 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 "user_remember_me"
+ @remember_me_options [sign: true, max_age: @max_age]
+
+ @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 logout. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def login_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_session(:user_token, token)
+ |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(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
+
+ # This function renews the session ID and erases the whole
+ # session to avoid fixation attacks. If there is any data
+ # in the session you may want to preserve after login/logout,
+ # you must explicitly fetch the session data before clearing
+ # and then immediately set it after clearing, for example:
+ #
+ # defp renew_session(conn) do
+ # preferred_locale = get_session(conn, :preferred_locale)
+ #
+ # conn
+ # |> configure_session(renew: true)
+ # |> clear_session()
+ # |> put_session(:preferred_locale, preferred_locale)
+ # 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 logout_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
+ DemoWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+ end
+
+ conn
+ |> renew_session()
+ |> delete_resp_cookie(@remember_me_cookie)
+ |> redirect(to: "/")
+ 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 user_token = get_session(conn, :user_token) do
+ {user_token, conn}
+ else
+ conn = fetch_cookies(conn, signed: [@remember_me_cookie])
+
+ if user_token = conn.cookies[@remember_me_cookie] do
+ {user_token, put_session(conn, :user_token, user_token)}
+ else
+ {nil, conn}
+ 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.
+
+ If you want to enforce the user e-mail is confirmed before
+ they use the application at all, here would be a good place.
+ """
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must login to access this page.")
+ |> maybe_store_return_to()
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ |> halt()
+ end
+ end
+
+ defp maybe_store_return_to(%{method: "GET", request_path: request_path} = conn) do
+ put_session(conn, :user_return_to, request_path)
+ end
+
+ defp maybe_store_return_to(conn), do: conn
+
+ defp signed_in_path(_conn), do: "/"
+end
diff --git a/lib/demo_web/controllers/user_confirmation_controller.ex b/lib/demo_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 0000000..580b616
--- /dev/null
+++ b/lib/demo_web/controllers/user_confirmation_controller.ex
@@ -0,0 +1,43 @@
+defmodule DemoWeb.UserConfirmationController do
+ use DemoWeb, :controller
+
+ alias Demo.Accounts
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :confirm, &1)
+ )
+ end
+
+ # Regardless of the outcome, show an impartial success/error message.
+ conn
+ |> put_flash(
+ :info,
+ "If your e-mail is in our system and it has not been confirmed yet, " <>
+ "you will receive an e-mail with instructions shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ # Do not login the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def confirm(conn, %{"token" => token}) do
+ case Accounts.confirm_user(token) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "Account confirmed successfully.")
+ |> redirect(to: "/")
+
+ :error ->
+ conn
+ |> put_flash(:error, "Confirmation link is invalid or it has expired.")
+ |> redirect(to: "/")
+ end
+ end
+end
diff --git a/lib/demo_web/controllers/user_registration_controller.ex b/lib/demo_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..f52e1c6
--- /dev/null
+++ b/lib/demo_web/controllers/user_registration_controller.ex
@@ -0,0 +1,30 @@
+defmodule DemoWeb.UserRegistrationController do
+ use DemoWeb, :controller
+
+ alias Demo.Accounts
+ alias Demo.Accounts.User
+ alias DemoWeb.UserAuth
+
+ def new(conn, _params) do
+ changeset = Accounts.change_user_registration(%User{})
+ render(conn, "new.html", changeset: changeset)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ case Accounts.register_user(user_params) do
+ {:ok, user} ->
+ {:ok, _} =
+ Accounts.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :confirm, &1)
+ )
+
+ conn
+ |> put_flash(:info, "User created successfully.")
+ |> UserAuth.login_user(user)
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, "new.html", changeset: changeset)
+ end
+ end
+end
diff --git a/lib/demo_web/controllers/user_reset_password_controller.ex b/lib/demo_web/controllers/user_reset_password_controller.ex
new file mode 100644
index 0000000..320f62c
--- /dev/null
+++ b/lib/demo_web/controllers/user_reset_password_controller.ex
@@ -0,0 +1,59 @@
+defmodule DemoWeb.UserResetPasswordController do
+ use DemoWeb, :controller
+
+ alias Demo.Accounts
+
+ plug :get_user_by_reset_password_token when action in [:edit, :update]
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_reset_password_instructions(
+ user,
+ &Routes.user_reset_password_url(conn, :edit, &1)
+ )
+ end
+
+ # Regardless of the outcome, show an impartial success/error message.
+ conn
+ |> put_flash(
+ :info,
+ "If your e-mail is in our system, you will receive instructions to reset your password shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, _params) do
+ render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
+ end
+
+ # Do not login the user after reset password to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"user" => user_params}) do
+ case Accounts.reset_user_password(conn.assigns.user, user_params) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "Password reset successfully.")
+ |> redirect(to: Routes.user_session_path(conn, :new))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", changeset: changeset)
+ end
+ end
+
+ defp get_user_by_reset_password_token(conn, _opts) do
+ %{"token" => token} = conn.params
+
+ if user = Accounts.get_user_by_reset_password_token(token) do
+ conn |> assign(:user, user) |> assign(:token, token)
+ else
+ conn
+ |> put_flash(:error, "Reset password link is invalid or it has expired.")
+ |> redirect(to: "/")
+ |> halt()
+ end
+ end
+end
diff --git a/lib/demo_web/controllers/user_session_controller.ex b/lib/demo_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..4bc97b6
--- /dev/null
+++ b/lib/demo_web/controllers/user_session_controller.ex
@@ -0,0 +1,26 @@
+defmodule DemoWeb.UserSessionController do
+ use DemoWeb, :controller
+
+ alias Demo.Accounts
+ alias DemoWeb.UserAuth
+
+ def new(conn, _params) do
+ render(conn, "new.html", error_message: nil)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ %{"email" => email, "password" => password} = user_params
+
+ if user = Accounts.get_user_by_email_and_password(email, password) do
+ UserAuth.login_user(conn, user, user_params)
+ else
+ render(conn, "new.html", error_message: "Invalid e-mail or password")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.logout_user()
+ end
+end
diff --git a/lib/demo_web/controllers/user_settings_controller.ex b/lib/demo_web/controllers/user_settings_controller.ex
new file mode 100644
index 0000000..2efc9e3
--- /dev/null
+++ b/lib/demo_web/controllers/user_settings_controller.ex
@@ -0,0 +1,72 @@
+defmodule DemoWeb.UserSettingsController do
+ use DemoWeb, :controller
+
+ alias Demo.Accounts
+ alias DemoWeb.UserAuth
+
+ plug :assign_email_and_password_changesets
+
+ def edit(conn, _params) do
+ render(conn, "edit.html")
+ end
+
+ def update_email(conn, %{"current_password" => password, "user" => user_params}) do
+ user = conn.assigns.current_user
+
+ case Accounts.apply_user_email(user, password, user_params) do
+ {:ok, applied_user} ->
+ Accounts.deliver_update_email_instructions(
+ applied_user,
+ user.email,
+ &Routes.user_settings_url(conn, :confirm_email, &1)
+ )
+
+ conn
+ |> put_flash(
+ :info,
+ "A link to confirm your e-mail change has been sent to the new address."
+ )
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", email_changeset: changeset)
+ end
+ end
+
+ def confirm_email(conn, %{"token" => token}) do
+ case Accounts.update_user_email(conn.assigns.current_user, token) do
+ :ok ->
+ conn
+ |> put_flash(:info, "E-mail changed successfully.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ :error ->
+ conn
+ |> put_flash(:error, "Email change link is invalid or it has expired.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+ end
+ end
+
+ def update_password(conn, %{"current_password" => password, "user" => user_params}) do
+ user = conn.assigns.current_user
+
+ case Accounts.update_user_password(user, password, user_params) do
+ {:ok, user} ->
+ conn
+ |> put_flash(:info, "Password updated successfully.")
+ |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
+ |> UserAuth.login_user(user)
+
+ {:error, changeset} ->
+ render(conn, "edit.html", password_changeset: changeset)
+ end
+ end
+
+ defp assign_email_and_password_changesets(conn, _opts) do
+ user = conn.assigns.current_user
+
+ conn
+ |> assign(:email_changeset, Accounts.change_user_email(user))
+ |> assign(:password_changeset, Accounts.change_user_password(user))
+ end
+end
diff --git a/lib/demo_web/router.ex b/lib/demo_web/router.ex
index 4bb74e4..bc6df60 100644
--- a/lib/demo_web/router.ex
+++ b/lib/demo_web/router.ex
@@ -1,12 +1,15 @@
defmodule DemoWeb.Router do
use DemoWeb, :router
+ import DemoWeb.UserAuth
+
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :fetch_current_user
end
pipeline :api do
@@ -23,4 +26,37 @@ defmodule DemoWeb.Router do
# scope "/api", DemoWeb do
# pipe_through :api
# end
+
+ ## Authentication routes
+
+ scope "/", DemoWeb do
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
+
+ get "/users/register", UserRegistrationController, :new
+ post "/users/register", UserRegistrationController, :create
+ get "/users/login", UserSessionController, :new
+ post "/users/login", UserSessionController, :create
+ get "/users/reset_password", UserResetPasswordController, :new
+ post "/users/reset_password", UserResetPasswordController, :create
+ get "/users/reset_password/:token", UserResetPasswordController, :edit
+ put "/users/reset_password/:token", UserResetPasswordController, :update
+ end
+
+ scope "/", DemoWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ get "/users/settings", UserSettingsController, :edit
+ put "/users/settings/update_password", UserSettingsController, :update_password
+ put "/users/settings/update_email", UserSettingsController, :update_email
+ get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
+ end
+
+ scope "/", DemoWeb do
+ pipe_through [:browser]
+
+ delete "/users/logout", UserSessionController, :delete
+ get "/users/confirm", UserConfirmationController, :new
+ post "/users/confirm", UserConfirmationController, :create
+ get "/users/confirm/:token", UserConfirmationController, :confirm
+ end
end
diff --git a/lib/demo_web/templates/layout/_user_menu.html.eex b/lib/demo_web/templates/layout/_user_menu.html.eex
new file mode 100644
index 0000000..a31996a
--- /dev/null
+++ b/lib/demo_web/templates/layout/_user_menu.html.eex
@@ -0,0 +1,10 @@
+
+<%= if @current_user do %>
+ - <%= @current_user.email %>
+ - <%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %>
+ - <%= link "Logout", to: Routes.user_session_path(@conn, :delete), method: :delete %>
+<% else %>
+ - <%= link "Register", to: Routes.user_registration_path(@conn, :new) %>
+ - <%= link "Login", to: Routes.user_session_path(@conn, :new) %>
+<% end %>
+
diff --git a/lib/demo_web/templates/layout/app.html.eex b/lib/demo_web/templates/layout/app.html.eex
index a402871..2ec4141 100644
--- a/lib/demo_web/templates/layout/app.html.eex
+++ b/lib/demo_web/templates/layout/app.html.eex
@@ -12,13 +12,8 @@
diff --git a/lib/demo_web/templates/user_confirmation/new.html.eex b/lib/demo_web/templates/user_confirmation/new.html.eex
new file mode 100644
index 0000000..70983e8
--- /dev/null
+++ b/lib/demo_web/templates/user_confirmation/new.html.eex
@@ -0,0 +1,15 @@
+Resend confirmation instructions
+
+<%= form_for :user, Routes.user_confirmation_path(@conn, :create), fn f -> %>
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+
+
+ <%= submit "Resend confirmation instructions" %>
+
+<% end %>
+
+
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Login", to: Routes.user_session_path(@conn, :new) %>
+
diff --git a/lib/demo_web/templates/user_registration/new.html.eex b/lib/demo_web/templates/user_registration/new.html.eex
new file mode 100644
index 0000000..dfb96a2
--- /dev/null
+++ b/lib/demo_web/templates/user_registration/new.html.eex
@@ -0,0 +1,26 @@
+Register
+
+<%= form_for @changeset, Routes.user_registration_path(@conn, :create), fn f -> %>
+ <%= if @changeset.action do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+ <%= error_tag f, :email %>
+
+ <%= label f, :password %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+
+ <%= submit "Register" %>
+
+<% end %>
+
+
+ <%= link "Login", to: Routes.user_session_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+
diff --git a/lib/demo_web/templates/user_reset_password/edit.html.eex b/lib/demo_web/templates/user_reset_password/edit.html.eex
new file mode 100644
index 0000000..f54cfbe
--- /dev/null
+++ b/lib/demo_web/templates/user_reset_password/edit.html.eex
@@ -0,0 +1,26 @@
+Reset password
+
+<%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %>
+ <%= if @changeset.action do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= label f, :password, "New password" %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <%= label f, :password_confirmation, "Confirm new password" %>
+ <%= password_input f, :password_confirmation, required: true %>
+ <%= error_tag f, :password_confirmation %>
+
+
+ <%= submit "Reset password" %>
+
+<% end %>
+
+
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Login", to: Routes.user_session_path(@conn, :new) %>
+
diff --git a/lib/demo_web/templates/user_reset_password/new.html.eex b/lib/demo_web/templates/user_reset_password/new.html.eex
new file mode 100644
index 0000000..f0de821
--- /dev/null
+++ b/lib/demo_web/templates/user_reset_password/new.html.eex
@@ -0,0 +1,15 @@
+Forgot your password?
+
+<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %>
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+
+
+ <%= submit "Send instructions to reset password" %>
+
+<% end %>
+
+
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Login", to: Routes.user_session_path(@conn, :new) %>
+
diff --git a/lib/demo_web/templates/user_session/new.html.eex b/lib/demo_web/templates/user_session/new.html.eex
new file mode 100644
index 0000000..25acc7b
--- /dev/null
+++ b/lib/demo_web/templates/user_session/new.html.eex
@@ -0,0 +1,27 @@
+Login
+
+<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user], fn f -> %>
+ <%= if @error_message do %>
+
+
<%= @error_message %>
+
+ <% end %>
+
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+
+ <%= label f, :password %>
+ <%= password_input f, :password, required: true %>
+
+ <%= label f, :remember_me, "Keep me logged in for 60 days" %>
+ <%= checkbox f, :remember_me %>
+
+
+ <%= submit "Login" %>
+
+<% end %>
+
+
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+
diff --git a/lib/demo_web/templates/user_settings/edit.html.eex b/lib/demo_web/templates/user_settings/edit.html.eex
new file mode 100644
index 0000000..f4dc065
--- /dev/null
+++ b/lib/demo_web/templates/user_settings/edit.html.eex
@@ -0,0 +1,49 @@
+Settings
+
+Change e-mail
+
+<%= form_for @email_changeset, Routes.user_settings_path(@conn, :update_email), fn f -> %>
+ <%= if @email_changeset.action do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+ <%= error_tag f, :email %>
+
+ <%= label f, :current_password, for: "current_password_for_email" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %>
+ <%= error_tag f, :current_password %>
+
+
+ <%= submit "Change e-mail" %>
+
+<% end %>
+
+Change password
+
+<%= form_for @password_changeset, Routes.user_settings_path(@conn, :update_password), fn f -> %>
+ <%= if @password_changeset.action do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= label f, :password, "New password" %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <%= label f, :password_confirmation, "Confirm new password" %>
+ <%= password_input f, :password_confirmation, required: true %>
+ <%= error_tag f, :password_confirmation %>
+
+ <%= label f, :current_password, for: "current_password_for_password" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %>
+ <%= error_tag f, :current_password %>
+
+
+ <%= submit "Change password" %>
+
+<% end %>
diff --git a/lib/demo_web/views/user_confirmation_view.ex b/lib/demo_web/views/user_confirmation_view.ex
new file mode 100644
index 0000000..0d0bee6
--- /dev/null
+++ b/lib/demo_web/views/user_confirmation_view.ex
@@ -0,0 +1,3 @@
+defmodule DemoWeb.UserConfirmationView do
+ use DemoWeb, :view
+end
diff --git a/lib/demo_web/views/user_registration_view.ex b/lib/demo_web/views/user_registration_view.ex
new file mode 100644
index 0000000..0328988
--- /dev/null
+++ b/lib/demo_web/views/user_registration_view.ex
@@ -0,0 +1,3 @@
+defmodule DemoWeb.UserRegistrationView do
+ use DemoWeb, :view
+end
diff --git a/lib/demo_web/views/user_reset_password_view.ex b/lib/demo_web/views/user_reset_password_view.ex
new file mode 100644
index 0000000..bcd50ba
--- /dev/null
+++ b/lib/demo_web/views/user_reset_password_view.ex
@@ -0,0 +1,3 @@
+defmodule DemoWeb.UserResetPasswordView do
+ use DemoWeb, :view
+end
diff --git a/lib/demo_web/views/user_session_view.ex b/lib/demo_web/views/user_session_view.ex
new file mode 100644
index 0000000..6708b42
--- /dev/null
+++ b/lib/demo_web/views/user_session_view.ex
@@ -0,0 +1,3 @@
+defmodule DemoWeb.UserSessionView do
+ use DemoWeb, :view
+end
diff --git a/lib/demo_web/views/user_settings_view.ex b/lib/demo_web/views/user_settings_view.ex
new file mode 100644
index 0000000..845f9fe
--- /dev/null
+++ b/lib/demo_web/views/user_settings_view.ex
@@ -0,0 +1,3 @@
+defmodule DemoWeb.UserSettingsView do
+ use DemoWeb, :view
+end
diff --git a/mix.exs b/mix.exs
index 4b17d5c..5d6df3e 100644
--- a/mix.exs
+++ b/mix.exs
@@ -33,6 +33,7 @@ defmodule Demo.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
+ {:bcrypt_elixir, "~> 2.0"},
{:phoenix, github: "phoenixframework/phoenix", override: true},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.1"},
diff --git a/mix.lock b/mix.lock
index d8d6745..622cee3 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,21 +1,24 @@
%{
+ "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"},
+ "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
"cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
"cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
"db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
- "ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b96cbb83a94713731461ea48521b178b0e3863d310a39a3948c807266eebd69"},
- "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"},
+ "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"},
+ "ecto_sql": {:hex, :ecto_sql, "3.4.1", "3c9136ba138f9b74d31286c73c61232a92bd19385f7c5607bdeb3a4587ef91f5", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b4be0bffe7b0bdf5393defcae52712f248e70cc2bc0e8ab6ddb03be66371516"},
+ "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"},
"gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"},
- "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"},
+ "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
- "phoenix": {:git, "https://github.com/phoenixframework/phoenix.git", "17fa0596aac4ec1e2ab6542c2c022cdf9a75d852", []},
+ "phoenix": {:git, "https://github.com/phoenixframework/phoenix.git", "7c1bffcdfceb638da832b845aa790005ce5681ed", []},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
"phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [: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", "41b4103a2fa282cfd747d377233baf213c648fdcc7928f432937676532490eee"},
"phoenix_pubsub": {:git, "https://github.com/phoenixframework/phoenix_pubsub.git", "325abd48e0ec164548b84a8bdf2ff357a2ec20a2", []},
- "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"},
+ "plug": {:hex, :plug, "1.10.0", "6508295cbeb4c654860845fb95260737e4a8838d34d115ad76cd487584e2fc4d", [:mix], [{:mime, "~> 1.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", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "422a9727e667be1bf5ab1de03be6fa0ad67b775b2d84ed908f3264415ef29d4a"},
"plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
diff --git a/priv/repo/migrations/20200316103722_create_users_auth_tables.exs b/priv/repo/migrations/20200316103722_create_users_auth_tables.exs
new file mode 100644
index 0000000..d3a10d7
--- /dev/null
+++ b/priv/repo/migrations/20200316103722_create_users_auth_tables.exs
@@ -0,0 +1,27 @@
+defmodule Demo.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
diff --git a/test/demo/accounts_test.exs b/test/demo/accounts_test.exs
new file mode 100644
index 0000000..e694eee
--- /dev/null
+++ b/test/demo/accounts_test.exs
@@ -0,0 +1,480 @@
+defmodule Demo.AccountsTest do
+ use Demo.DataCase, async: true
+
+ import Demo.AccountsFixtures
+ alias Demo.Accounts
+ alias Demo.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/1" 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!(123)
+ 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: ["must have the @ sign and no spaces"],
+ password: ["should be at least 12 character(s)"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for e-mail 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 80 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates e-mail 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 e-mail 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(%{email: email, password: valid_user_password()})
+ 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
+ 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: ["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: ["must have the @ sign and no spaces"]} = errors_on(changeset)
+ end
+
+ test "validates maximum value for e-mail 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 e-mail uniqueness", %{user: user} do
+ %{email: email} = user_fixture()
+
+ {:error, changeset} =
+ Accounts.apply_user_email(user, valid_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: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "applies the e-mail 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_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_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_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{user: user, token: token, email: email}
+ end
+
+ test "updates the e-mail 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 e-mail 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 e-mail if user e-mail 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 e-mail 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
+ 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: ["does not match password"]
+ } = 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 80 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: ["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/2" 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 e-mail 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 e-mail 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/2" 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/3" 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: ["does not match password"]
+ } = 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 80 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" do
+ test "does not include password" do
+ refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
+ end
+ end
+end
diff --git a/test/demo_web/controllers/user_auth_test.exs b/test/demo_web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..97f63fb
--- /dev/null
+++ b/test/demo_web/controllers/user_auth_test.exs
@@ -0,0 +1,163 @@
+defmodule DemoWeb.UserAuthTest do
+ use DemoWeb.ConnCase, async: true
+
+ alias Demo.Accounts
+ alias DemoWeb.UserAuth
+ import Demo.AccountsFixtures
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, DemoWeb.Endpoint.config(:secret_key_base))
+ |> init_test_session(%{})
+
+ %{user: user_fixture(), conn: conn}
+ end
+
+ describe "login_user/3" do
+ test "stores the user token in the session", %{conn: conn, user: user} do
+ conn = UserAuth.login_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) == "/"
+ 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.login_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.login_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.login_user(user, %{"remember_me" => "true"})
+ assert get_session(conn, :user_token) == conn.cookies["user_remember_me"]
+
+ assert %{value: signed_token, max_age: max_age} = conn.resp_cookies["user_remember_me"]
+ 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("user_remember_me", user_token)
+ |> fetch_cookies()
+ |> UserAuth.logout_user()
+
+ refute get_session(conn, :user_token)
+ refute conn.cookies["user_remember_me"]
+ assert %{max_age: 0} = conn.resp_cookies["user_remember_me"]
+ assert redirected_to(conn) == "/"
+ 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"
+ DemoWeb.Endpoint.subscribe(live_socket_id)
+
+ conn
+ |> put_session(:live_socket_id, live_socket_id)
+ |> UserAuth.logout_user()
+
+ assert_receive %Phoenix.Socket.Broadcast{
+ event: "disconnect",
+ topic: "users_sessions:abcdef-token"
+ }
+ end
+
+ test "works even if user is already logged out", %{conn: conn} do
+ conn = conn |> fetch_cookies() |> UserAuth.logout_user()
+ refute get_session(conn, :user_token)
+ assert %{max_age: 0} = conn.resp_cookies["user_remember_me"]
+ assert redirected_to(conn) == "/"
+ 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.login_user(user, %{"remember_me" => "true"})
+
+ user_token = logged_in_conn.cookies["user_remember_me"]
+ %{value: signed_token} = logged_in_conn.resp_cookies["user_remember_me"]
+
+ conn =
+ conn
+ |> put_req_cookie("user_remember_me", signed_token)
+ |> UserAuth.fetch_current_user([])
+
+ assert get_session(conn, :user_token) == user_token
+ assert conn.assigns.current_user.id == user.id
+ 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 "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) == "/"
+ 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) == "/users/login"
+ assert get_flash(conn, :error) == "You must login to access this page."
+ end
+
+ test "stores the path to redirect to on GET", %{conn: conn} do
+ halted_conn =
+ %{conn | request_path: "/foo?bar"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo?bar"
+
+ halted_conn =
+ %{conn | request_path: "/foo?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
diff --git a/test/demo_web/controllers/user_confirmation_controller_test.exs b/test/demo_web/controllers/user_confirmation_controller_test.exs
new file mode 100644
index 0000000..28b3677
--- /dev/null
+++ b/test/demo_web/controllers/user_confirmation_controller_test.exs
@@ -0,0 +1,84 @@
+defmodule DemoWeb.UserConfirmationControllerTest do
+ use DemoWeb.ConnCase, async: true
+
+ alias Demo.Accounts
+ alias Demo.Repo
+ import Demo.AccountsFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/confirm" do
+ test "renders the confirmation page", %{conn: conn} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "Resend confirmation instructions
"
+ end
+ end
+
+ describe "POST /users/confirm" do
+ @tag :capture_log
+ test "sends a new confirmation token", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your e-mail is in our system"
+ assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
+ end
+
+ test "does not send confirmation token if account is confirmed", %{conn: conn, user: user} do
+ Repo.update!(Accounts.User.confirm_changeset(user))
+
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your e-mail 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
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => "unknown@example.com"}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your e-mail is in our system"
+ assert Repo.all(Accounts.UserToken) == []
+ end
+ end
+
+ describe "GET /users/confirm/:token" 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)
+
+ conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "Account confirmed successfully"
+ assert Accounts.get_user!(user.id).confirmed_at
+ refute get_session(conn, :user_token)
+ assert Repo.all(Accounts.UserToken) == []
+
+ conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired"
+ end
+
+ test "does not confirm email with invalid token", %{conn: conn, user: user} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired"
+ refute Accounts.get_user!(user.id).confirmed_at
+ end
+ end
+end
diff --git a/test/demo_web/controllers/user_registration_controller_test.exs b/test/demo_web/controllers/user_registration_controller_test.exs
new file mode 100644
index 0000000..52f6ce7
--- /dev/null
+++ b/test/demo_web/controllers/user_registration_controller_test.exs
@@ -0,0 +1,54 @@
+defmodule DemoWeb.UserRegistrationControllerTest do
+ use DemoWeb.ConnCase, async: true
+
+ import Demo.AccountsFixtures
+
+ describe "GET /users/register" do
+ test "renders registration page", %{conn: conn} do
+ conn = get(conn, Routes.user_registration_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "Register
"
+ assert response =~ "Login"
+ assert response =~ "Register"
+ end
+
+ test "redirects if already logged in", %{conn: conn} do
+ conn = conn |> login_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new))
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "POST /users/register" do
+ @tag :capture_log
+ test "creates account and logs the user in", %{conn: conn} do
+ email = unique_user_email()
+
+ conn =
+ post(conn, Routes.user_registration_path(conn, :create), %{
+ "user" => %{"email" => email, "password" => valid_user_password()}
+ })
+
+ assert get_session(conn, :user_token)
+ assert redirected_to(conn) =~ "/"
+
+ # Now do a logged in request and assert on the menu
+ conn = get(conn, "/")
+ response = html_response(conn, 200)
+ assert response =~ email
+ assert response =~ "Settings"
+ assert response =~ "Logout"
+ end
+
+ test "render errors for invalid data", %{conn: conn} do
+ conn =
+ post(conn, Routes.user_registration_path(conn, :create), %{
+ "user" => %{"email" => "with spaces", "password" => "too short"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "Register
"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "should be at least 12 character"
+ end
+ end
+end
diff --git a/test/demo_web/controllers/user_reset_password_controller_test.exs b/test/demo_web/controllers/user_reset_password_controller_test.exs
new file mode 100644
index 0000000..959f305
--- /dev/null
+++ b/test/demo_web/controllers/user_reset_password_controller_test.exs
@@ -0,0 +1,113 @@
+defmodule DemoWeb.UserResetPasswordControllerTest do
+ use DemoWeb.ConnCase, async: true
+
+ alias Demo.Accounts
+ alias Demo.Repo
+ import Demo.AccountsFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/reset_password" do
+ test "renders the reset password page", %{conn: conn} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "Forgot your password?
"
+ end
+ end
+
+ describe "POST /users/reset_password" do
+ @tag :capture_log
+ test "sends a new reset password token", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_reset_password_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your e-mail 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
+ conn =
+ post(conn, Routes.user_reset_password_path(conn, :create), %{
+ "user" => %{"email" => "unknown@example.com"}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your e-mail is in our system"
+ assert Repo.all(Accounts.UserToken) == []
+ end
+ end
+
+ describe "GET /users/reset_password/:token" do
+ setup %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{token: token}
+ end
+
+ test "renders reset password", %{conn: conn, token: token} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :edit, token))
+ assert html_response(conn, 200) =~ "Reset password
"
+ end
+
+ test "does not render reset password with invalid token", %{conn: conn} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
+ end
+ end
+
+ describe "PUT /users/reset_password/:token" do
+ setup %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{token: token}
+ end
+
+ test "resets password once", %{conn: conn, user: user, token: token} do
+ conn =
+ put(conn, Routes.user_reset_password_path(conn, :update, token), %{
+ "user" => %{
+ "password" => "new valid password",
+ "password_confirmation" => "new valid password"
+ }
+ })
+
+ assert redirected_to(conn) == "/users/login"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :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
+ conn =
+ put(conn, Routes.user_reset_password_path(conn, :update, token), %{
+ "user" => %{
+ "password" => "too short",
+ "password_confirmation" => "does not match"
+ }
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "Reset password
"
+ assert response =~ "should be at least 12 character(s)"
+ assert response =~ "does not match password"
+ end
+
+ test "does not reset password with invalid token", %{conn: conn} do
+ conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
+ end
+ end
+end
diff --git a/test/demo_web/controllers/user_session_controller_test.exs b/test/demo_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..f0b1c56
--- /dev/null
+++ b/test/demo_web/controllers/user_session_controller_test.exs
@@ -0,0 +1,84 @@
+defmodule DemoWeb.UserSessionControllerTest do
+ use DemoWeb.ConnCase, async: true
+
+ import Demo.AccountsFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/login" do
+ test "renders login page", %{conn: conn} do
+ conn = get(conn, Routes.user_session_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "Login
"
+ assert response =~ "Login"
+ assert response =~ "Register"
+ end
+
+ test "redirects if already logged in", %{conn: conn, user: user} do
+ conn = conn |> login_user(user) |> get(Routes.user_session_path(conn, :new))
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "POST /users/login" do
+ test "logs the user in", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{"email" => user.email, "password" => valid_user_password()}
+ })
+
+ assert get_session(conn, :user_token)
+ assert redirected_to(conn) =~ "/"
+
+ # Now do a logged in request and assert on the menu
+ conn = get(conn, "/")
+ response = html_response(conn, 200)
+ assert response =~ user.email
+ assert response =~ "Settings"
+ assert response =~ "Logout"
+ end
+
+ test "logs the user in with remember me", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password(),
+ "remember_me" => "true"
+ }
+ })
+
+ assert conn.resp_cookies["user_remember_me"]
+ assert redirected_to(conn) =~ "/"
+ end
+
+ test "emits error message with invalid credentials", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{"email" => user.email, "password" => "invalid_password"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "Login
"
+ assert response =~ "Invalid e-mail or password"
+ end
+ end
+
+ describe "DELETE /users/logout" do
+ test "logs the user out", %{conn: conn, user: user} do
+ conn = conn |> login_user(user) |> delete(Routes.user_session_path(conn, :delete))
+ assert redirected_to(conn) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Logged out successfully"
+ end
+
+ test "succeeds even if the user is not logged in", %{conn: conn} do
+ conn = delete(conn, Routes.user_session_path(conn, :delete))
+ assert redirected_to(conn) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Logged out successfully"
+ end
+ end
+end
diff --git a/test/demo_web/controllers/user_settings_controller_test.exs b/test/demo_web/controllers/user_settings_controller_test.exs
new file mode 100644
index 0000000..aa8a688
--- /dev/null
+++ b/test/demo_web/controllers/user_settings_controller_test.exs
@@ -0,0 +1,125 @@
+defmodule DemoWeb.UserSettingsControllerTest do
+ use DemoWeb.ConnCase, async: true
+
+ alias Demo.Accounts
+ import Demo.AccountsFixtures
+
+ setup :register_and_login_user
+
+ describe "GET /users/settings" do
+ test "renders settings page", %{conn: conn} do
+ conn = get(conn, Routes.user_settings_path(conn, :edit))
+ response = html_response(conn, 200)
+ assert response =~ "Settings
"
+ end
+
+ test "redirects if user is not logged in" do
+ conn = build_conn()
+ conn = get(conn, Routes.user_settings_path(conn, :edit))
+ assert redirected_to(conn) == "/users/login"
+ end
+ end
+
+ describe "PUT /users/settings/update_password" do
+ test "updates the user password and resets tokens", %{conn: conn, user: user} do
+ new_password_conn =
+ put(conn, Routes.user_settings_path(conn, :update_password), %{
+ "current_password" => valid_user_password(),
+ "user" => %{
+ "password" => "new valid password",
+ "password_confirmation" => "new valid password"
+ }
+ })
+
+ assert redirected_to(new_password_conn) == "/users/settings"
+ assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
+ assert get_flash(new_password_conn, :info) =~ "Password updated successfully"
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "does not update password on invalid data", %{conn: conn} do
+ old_password_conn =
+ put(conn, Routes.user_settings_path(conn, :update_password), %{
+ "current_password" => "invalid",
+ "user" => %{
+ "password" => "too short",
+ "password_confirmation" => "does not match"
+ }
+ })
+
+ response = html_response(old_password_conn, 200)
+ assert response =~ "Settings
"
+ assert response =~ "should be at least 12 character(s)"
+ assert response =~ "does not match password"
+ assert response =~ "is not valid"
+
+ assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
+ end
+ end
+
+ describe "PUT /users/settings/update_email" do
+ @tag :capture_log
+ test "updates the user email", %{conn: conn, user: user} do
+ conn =
+ put(conn, Routes.user_settings_path(conn, :update_email), %{
+ "current_password" => valid_user_password(),
+ "user" => %{"email" => unique_user_email()}
+ })
+
+ assert redirected_to(conn) == "/users/settings"
+ assert get_flash(conn, :info) =~ "A link to confirm your e-mail"
+ assert Accounts.get_user_by_email(user.email)
+ end
+
+ test "does not update email on invalid data", %{conn: conn} do
+ conn =
+ put(conn, Routes.user_settings_path(conn, :update_email), %{
+ "current_password" => "invalid",
+ "user" => %{"email" => "with spaces"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "Settings
"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "is not valid"
+ end
+ end
+
+ describe "GET /users/settings/confirm_email/:token" do
+ setup %{user: user} do
+ email = unique_user_email()
+
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{token: token, email: email}
+ end
+
+ test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == "/users/settings"
+ assert get_flash(conn, :info) =~ "E-mail changed successfully"
+ refute Accounts.get_user_by_email(user.email)
+ assert Accounts.get_user_by_email(email)
+
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == "/users/settings"
+ assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
+ end
+
+ test "does not update email with invalid token", %{conn: conn, user: user} do
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops"))
+ assert redirected_to(conn) == "/users/settings"
+ assert get_flash(conn, :error) =~ "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()
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == "/users/login"
+ end
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 19c5857..ee31b29 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -22,6 +22,8 @@ defmodule DemoWeb.ConnCase do
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
+ import DemoWeb.ConnCase
+
alias DemoWeb.Router.Helpers, as: Routes
# The default endpoint for testing
@@ -38,4 +40,30 @@ defmodule DemoWeb.ConnCase do
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
+
+ @doc """
+ Setup helper that registers and logs in users.
+
+ setup :register_and_login_user
+
+ It stores an updated connection and a registered user in the
+ test context.
+ """
+ def register_and_login_user(%{conn: conn}) do
+ user = Demo.AccountsFixtures.user_fixture()
+ %{conn: login_user(conn, user), user: user}
+ end
+
+ @doc """
+ Logs the given `user` into the `conn`.
+
+ It returns an updated `conn`.
+ """
+ def login_user(conn, user) do
+ token = Demo.Accounts.generate_user_session_token(user)
+
+ conn
+ |> Phoenix.ConnTest.init_test_session(%{})
+ |> Plug.Conn.put_session(:user_token, token)
+ end
end
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
new file mode 100644
index 0000000..ec18348
--- /dev/null
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -0,0 +1,27 @@
+defmodule Demo.AccountsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Demo.Accounts` context.
+ """
+
+ def unique_user_email, do: "user#{System.unique_integer()}@example.com"
+ def valid_user_password, do: "hello world!"
+
+ def user_fixture(attrs \\ %{}) do
+ {:ok, user} =
+ attrs
+ |> Enum.into(%{
+ email: unique_user_email(),
+ password: valid_user_password()
+ })
+ |> Demo.Accounts.register_user()
+
+ user
+ end
+
+ def extract_user_token(fun) do
+ {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]")
+ [_, token, _] = String.split(captured.body, "[TOKEN]")
+ token
+ end
+end