diff --git a/config/test.exs b/config/test.exs
index 58472c9..0703c35 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,5 +1,8 @@
import Config
+# Only in tests, remove the complexity from the password hashing algorithm
+config :bcrypt_elixir, :log_rounds, 1
+
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
diff --git a/lib/kmxgit/user_manager.ex b/lib/kmxgit/user_manager.ex
index 44b596d..d86d22a 100644
--- a/lib/kmxgit/user_manager.ex
+++ b/lib/kmxgit/user_manager.ex
@@ -7,24 +7,12 @@ defmodule Kmxgit.UserManager do
alias Kmxgit.Repo
alias Kmxgit.SlugManager.Slug
- alias Kmxgit.UserManager.User
-
- alias Bcrypt
+ alias Kmxgit.UserManager.{User, UserToken, UserNotifier}
def list_users do
Repo.all from user in User, preload: :slug
end
- def get_user!(id) do
- user = Repo.one(from user in User,
- where: [id: ^id],
- preload: [:slug,
- organisations: :slug,
- owned_repositories: [organisation: :slug,
- user: :slug]])
- user || raise Ecto.NoResultsError
- end
-
def get_user(id) do
Repo.one from user in User,
where: [id: ^id],
@@ -33,6 +21,10 @@ defmodule Kmxgit.UserManager do
preload: :slug
end
+ def get_user!(id) do
+ get_user(id) || raise Ecto.NoResultsError
+ end
+
def get_user_by_slug(slug) do
Repo.one from u in User,
join: s in Slug,
@@ -45,12 +37,161 @@ defmodule Kmxgit.UserManager do
user: :slug]]
end
- def create_user(attrs \\ %{}) do
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.one from u in User,
+ where: u.email == ^email,
+ limit: 1,
+ preload: [:slug,
+ organisations: :slug,
+ owned_repositories: [organisation: :slug,
+ user: :slug]]
+ end
+
+ def get_user_by_email_and_password(email, password)
+ when is_binary(email) and is_binary(password) do
+ user = get_user_by_email(email) || get_user_by_slug(email)
+ if User.valid_password?(user, password), do: user
+ end
+
+ def register_user(attrs) do
%User{}
- |> User.changeset(attrs)
+ |> User.registration_changeset(attrs)
|> Repo.insert()
end
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs, hash_password: false)
+ end
+
+ def change_user_email(user, attrs \\ %{}) do
+ User.email_changeset(user, attrs)
+ end
+
+ def apply_user_email(user, password, attrs) do
+ user
+ |> User.email_changeset(attrs)
+ |> User.validate_current_password(password)
+ |> Ecto.Changeset.apply_action(:update)
+ end
+
+ 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
+
+ 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
+
+ def change_user_password(user, attrs \\ %{}) do
+ User.password_changeset(user, attrs, hash_password: false)
+ end
+
+ 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
+
+ def generate_user_session_token(user) do
+ {token, user_token} = UserToken.build_session_token(user)
+ Repo.insert!(user_token)
+ token
+ end
+
+ def get_user_by_session_token(token) do
+ {:ok, query} = UserToken.verify_session_token_query(token)
+ Repo.one(query)
+ end
+
+ def delete_session_token(token) do
+ Repo.delete_all(UserToken.token_and_context_query(token, "session"))
+ :ok
+ end
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
def admin_create_user(attrs \\ %{}) do
%User{}
|> User.admin_changeset(attrs)
diff --git a/lib/kmxgit/user_manager/user.ex b/lib/kmxgit/user_manager/user.ex
index c7d0ebf..b03d6e8 100644
--- a/lib/kmxgit/user_manager/user.ex
+++ b/lib/kmxgit/user_manager/user.ex
@@ -8,10 +8,11 @@ defmodule Kmxgit.UserManager.User do
alias BCrypt
schema "users" do
+ field :confirmed_at, :utc_datetime
field :deploy_only, :boolean, null: false, default: false
field :description, :string, null: true
- field :email, :string, unique: true
- field :encrypted_password, :string
+ field :email, :string
+ field :hashed_password, :string, redact: true
field :is_admin, :boolean, null: false, default: false
field :name, :string
has_many :owned_repositories, Repository
@@ -24,66 +25,154 @@ defmodule Kmxgit.UserManager.User do
timestamps()
end
- defp common_changeset(changeset) do
- changeset
- |> check_password_confirmation()
- |> put_password_hash()
- |> cast_assoc(:slug)
- |> validate_required([:deploy_only, :email, :encrypted_password, :is_admin, :slug])
- |> validate_format(:email, ~r/^[-_+.0-9A-Za-z]+@([-_0-9A-Za-z]+[.])+[A-Za-z]+$/)
- |> Markdown.validate_markdown(:description)
- |> unique_constraint(:email, name: "users__lower_email_index")
- |> foreign_key_constraint(:owned_repositories, name: :repositories_user_id_fkey)
- end
+ @doc """
+ A user changeset for registration.
- def changeset(user, attrs \\ %{}) do
+ It is important to validate the length of both email and password.
+ Otherwise databases may truncate the email without warnings, which
+ could lead to unpredictable or insecure behaviour. Long passwords may
+ also be very expensive to hash for certain algorithms.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def registration_changeset(user, attrs, opts \\ []) do
user
- |> cast(attrs, [:deploy_only, :description, :email, :name, :password, :password_confirmation, :ssh_keys])
- |> common_changeset()
+ |> cast(attrs, [:email, :password])
+ |> validate_email()
+ |> validate_password(opts)
end
- def admin_changeset(user, attrs \\ %{}) do
- user
- |> cast(attrs, [:deploy_only, :description, :email, :is_admin, :name, :password, :password_confirmation, :ssh_keys])
- |> common_changeset()
+ 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, Kmxgit.Repo)
+ |> unique_constraint(:email)
+ end
+
+ defp validate_password(changeset, opts) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12, max: 72)
+ |> 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")
+ |> validate_format(:password, ~r([-_+*/\\.:;,=!?@#$%^'"&\(\)\[\]<>°§]), message: "at least one special character")
+ |> maybe_hash_password(opts)
end
- defp check_password_confirmation(%Ecto.Changeset{changes: %{password: password,
- password_confirmation: password_confirmation}} = changeset) do
- if password != password_confirmation do
- passwords_do_not_match(changeset)
+ defp maybe_hash_password(changeset, opts) do
+ hash_password? = Keyword.get(opts, :hash_password, true)
+ password = get_change(changeset, :password)
+
+ if hash_password? && password && changeset.valid? do
+ changeset
+ # If using Bcrypt, then further validate it is at most 72 bytes long
+ |> validate_length(:password, max: 72, count: :bytes)
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
else
changeset
end
end
- defp check_password_confirmation(%Ecto.Changeset{changes: %{password: _}} = changeset) do
- passwords_do_not_match(changeset)
+ @doc """
+ A user changeset for changing the email.
+
+ It requires the email 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.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def password_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password(opts)
end
- defp check_password_confirmation(%Ecto.Changeset{changes: %{password_confirmation: _}} = changeset) do
- passwords_do_not_match(changeset)
+ @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
- defp check_password_confirmation(changeset) do
- changeset
+ @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?(%__MODULE__{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
end
- defp passwords_do_not_match(changeset) do
- Ecto.Changeset.add_error(changeset,
- :password_confirmation,
- "Passwords do not match.")
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
end
- defp put_password_hash(%Ecto.Changeset{valid?: true,
- changes: %{password: password,
- password_confirmation: password}}
- = changeset) do
- change(changeset, encrypted_password: Bcrypt.hash_pwd_salt(password))
+ @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
- defp put_password_hash(changeset) do
+ defp common_changeset(changeset) do
changeset
+ |> cast_assoc(:slug)
+ |> validate_required([:deploy_only, :email, :hashed_password, :is_admin, :slug])
+ |> validate_format(:email, ~r/^[-_+.0-9A-Za-z]+@([-_0-9A-Za-z]+[.])+[A-Za-z]+$/)
+ |> Markdown.validate_markdown(:description)
+ |> unique_constraint(:email)
+ |> foreign_key_constraint(:owned_repositories, name: :repositories_user_id_fkey)
+ end
+
+ def changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [:deploy_only, :description, :name, :ssh_keys])
+ |> common_changeset()
+ end
+
+ def admin_changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [:deploy_only, :description, :email, :is_admin, :name, :password, :password_confirmation, :ssh_keys])
+ |> common_changeset()
end
def display_name(user) do
diff --git a/lib/kmxgit/user_manager/user_notifier.ex b/lib/kmxgit/user_manager/user_notifier.ex
new file mode 100644
index 0000000..7722178
--- /dev/null
+++ b/lib/kmxgit/user_manager/user_notifier.ex
@@ -0,0 +1,79 @@
+defmodule Kmxgit.UserManager.UserNotifier do
+ import Swoosh.Email
+
+ alias Kmxgit.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"kmxgit", "contact@git.kmx.io"})
+ |> subject(subject)
+ |> text_body(body)
+
+ with {:ok, _metadata} <- Mailer.deliver(email) do
+ {:ok, email}
+ end
+ end
+
+ @doc """
+ Deliver instructions to confirm account.
+ """
+ def deliver_confirmation_instructions(user, url) do
+ deliver(user.email, "Confirmation instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can confirm your account by visiting the URL below:
+
+ #{url}
+
+ If you didn't create an account with us, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to reset a user password.
+ """
+ def deliver_reset_password_instructions(user, url) do
+ deliver(user.email, "Reset password instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can reset your password by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to update a user email.
+ """
+ def deliver_update_email_instructions(user, url) do
+ deliver(user.email, "Update email instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can change your email by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+end
diff --git a/lib/kmxgit/user_manager/user_token.ex b/lib/kmxgit/user_manager/user_token.ex
new file mode 100644
index 0000000..78644f0
--- /dev/null
+++ b/lib/kmxgit/user_manager/user_token.ex
@@ -0,0 +1,178 @@
+defmodule Kmxgit.UserManager.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 email may take over the account.
+ @reset_password_validity_in_days 1
+ @confirm_validity_in_days 7
+ @change_email_validity_in_days 7
+ @session_validity_in_days 60
+
+ schema "users_tokens" do
+ field :token, :binary
+ field :context, :string
+ field :sent_to, :string
+ belongs_to :user, Kmxgit.UserManager.User
+
+ timestamps(updated_at: false)
+ end
+
+ @doc """
+ Generates a token that will be stored in a signed place,
+ such as session or cookie. As they are signed, those
+ tokens do not need to be hashed.
+
+ The reason why we store session tokens in the database, even
+ though Phoenix already provides a session cookie, is because
+ Phoenix' default session cookies are not persisted, they are
+ simply signed and potentially encrypted. This means they are
+ valid indefinitely, unless you change the signing/encryption
+ salt.
+
+ Therefore, storing them allows individual user
+ sessions to be expired. The token system can also be extended
+ to store additional data, such as the device used for logging in.
+ You could then use this information to display all valid sessions
+ and devices in the UI and allow users to explicitly expire any
+ session they deem invalid.
+ """
+ def build_session_token(user) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ {token, %Kmxgit.UserManager.UserToken{token: token, context: "session", user_id: user.id}}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The token is valid if it matches the value in the database and it has
+ not expired (after @session_validity_in_days).
+ """
+ def verify_session_token_query(token) do
+ query =
+ from token in token_and_context_query(token, "session"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ select: user
+
+ {:ok, query}
+ end
+
+ @doc """
+ Builds a token and its hash to be delivered to the user's email.
+
+ The non-hashed token is sent to the user email while the
+ hashed part is stored in the database. The original token cannot be reconstructed,
+ which means anyone with read-only access to the database cannot directly use
+ the token in the application to gain access. Furthermore, if the user changes
+ their email in the system, the tokens sent to the previous email are no longer
+ valid.
+
+ Users can easily adapt the existing code to provide other types of delivery methods,
+ for example, by phone numbers.
+ """
+ def build_email_token(user, context) do
+ build_hashed_token(user, context, user.email)
+ end
+
+ defp build_hashed_token(user, context, sent_to) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ hashed_token = :crypto.hash(@hash_algorithm, token)
+
+ {Base.url_encode64(token, padding: false),
+ %Kmxgit.UserManager.UserToken{
+ token: hashed_token,
+ context: context,
+ sent_to: sent_to,
+ user_id: user.id
+ }}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and the user email has not changed. This function also checks
+ if the token is being used within a certain period, depending on the
+ context. The default contexts supported by this function are either
+ "confirm", for account confirmation emails, and "reset_password",
+ for resetting the password. For verifying requests to change the email,
+ see `verify_change_email_token_query/2`.
+ """
+ def verify_email_token_query(token, context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+ days = days_for_context(context)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
+ select: user
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp days_for_context("confirm"), do: @confirm_validity_in_days
+ defp days_for_context("reset_password"), do: @reset_password_validity_in_days
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ This is used to validate requests to change the user
+ email. It is different from `verify_email_token_query/2` precisely because
+ `verify_email_token_query/2` validates the email has not changed, which is
+ the starting point by this function.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and if it has not expired (after @change_email_validity_in_days).
+ The context must always start with "change:".
+ """
+ def verify_change_email_token_query(token, "change:" <> _ = context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ @doc """
+ Returns the token struct for the given token value and context.
+ """
+ def token_and_context_query(token, context) do
+ from Kmxgit.UserManager.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 Kmxgit.UserManager.UserToken, where: t.user_id == ^user.id
+ end
+
+ def user_and_contexts_query(user, [_ | _] = contexts) do
+ from t in Kmxgit.UserManager.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ end
+end
diff --git a/lib/kmxgit_web.ex b/lib/kmxgit_web.ex
index 4511e06..eb72bf6 100644
--- a/lib/kmxgit_web.ex
+++ b/lib/kmxgit_web.ex
@@ -47,6 +47,10 @@ defmodule KmxgitWeb do
# Include shared imports and aliases for views
unquote(view_helpers())
+
+ def recaptcha_site_key do
+ Application.get_env :kmxgit, :recaptcha_site_key
+ end
end
end
diff --git a/lib/kmxgit_web/controllers/registration_controller.ex b/lib/kmxgit_web/controllers/registration_controller.ex
deleted file mode 100644
index f14d918..0000000
--- a/lib/kmxgit_web/controllers/registration_controller.ex
+++ /dev/null
@@ -1,28 +0,0 @@
-defmodule KmxgitWeb.RegistrationController do
- use KmxgitWeb, :controller
-
- alias Kmxgit.{UserManager, UserManager.User, UserManager.Guardian}
-
- def new(conn, _) do
- changeset = UserManager.change_user(%User{})
- conn
- |> assign(:action, Routes.registration_path(conn, :register))
- |> assign(:changeset, changeset)
- |> render("new.html")
- end
-
- def register(conn, params) do
- case UserManager.create_user(params["user"]) do
- {:ok, user} ->
- conn
- |> Guardian.Plug.sign_in(user)
- |> redirect(to: Routes.slug_path(conn, :show, user.slug.slug))
- {:error, changeset} ->
- IO.inspect(changeset)
- conn
- |> assign(:action, Routes.registration_path(conn, :register))
- |> assign(:changeset, changeset)
- |> render("new.html")
- end
- end
-end
diff --git a/lib/kmxgit_web/controllers/session_controller.ex b/lib/kmxgit_web/controllers/session_controller.ex
deleted file mode 100644
index 4651c06..0000000
--- a/lib/kmxgit_web/controllers/session_controller.ex
+++ /dev/null
@@ -1,78 +0,0 @@
-defmodule KmxgitWeb.SessionController do
- use KmxgitWeb, :controller
-
- alias Kmxgit.{UserManager, UserManager.User}
-
- def new(conn, params) do
- changeset = UserManager.change_user(%User{})
- user = UserManager.Guardian.Plug.current_resource(conn)
- if user do
- redirect(conn, to: params["redirect"] || Routes.user_path(KmxgitWeb.Endpoint, :show, user.slug.slug))
- else
- redirect = params["redirect"]
- action = if redirect do
- Routes.session_path(conn, :login, redirect: redirect)
- else
- Routes.session_path(conn, :login)
- end
- conn
- |> assign(:action, action)
- |> assign(:changeset, changeset)
- |> assign(:page_title, gettext "Login")
- |> render("new.html")
- end
- end
-
- def login(conn, params = %{"user" => %{"login" => login,
- "password" => password}}) do
- redirect = params["redirect"] || "/"
- conn
- |> login_reply(UserManager.authenticate_user(login, password), redirect)
- end
-
- def logout(conn, _params) do
- conn
- |> UserManager.Guardian.Plug.sign_out()
- |> redirect(to: Routes.session_path(conn, :new))
- end
-
- defp login_reply(conn, {:ok, user}, redirect) do
- conn
- |> UserManager.Guardian.Plug.sign_in(user)
- |> redirect(to: redirect || Routes.user_path(KmxgitWeb.Endpoint, :show, user.slug.slug))
- end
-
- defp login_reply(conn, {:error, reason}, _redirect) do
- conn
- |> put_flash(:error, to_string(reason))
- |> new(%{})
- end
-
- @behaviour Guardian.Plug.ErrorHandler
-
- @impl Guardian.Plug.ErrorHandler
- def auth_error(conn, {type, _reason}, _opts) do
- body = to_string(type)
- case conn.request_path do
- "/sessions/new" ->
- conn
- |> put_view(KmxgitWeb.SessionView)
- |> put_layout({KmxgitWeb.LayoutView, "app.html"})
- |> put_flash(:error, body)
- |> new(conn.params)
- |> halt()
- "/sessions/login" ->
- conn
- |> put_view(KmxgitWeb.SessionView)
- |> put_layout({KmxgitWeb.LayoutView, "app.html"})
- |> put_flash(:error, body)
- |> login(conn.params)
- |> halt()
- _ ->
- conn
- |> put_flash(:error, body)
- |> redirect(to: Routes.session_path(conn, :new, redirect: current_path(conn) |> halt()
-))
- end
- end
-end
diff --git a/lib/kmxgit_web/controllers/user_auth.ex b/lib/kmxgit_web/controllers/user_auth.ex
new file mode 100644
index 0000000..c990e87
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_auth.ex
@@ -0,0 +1,149 @@
+defmodule KmxgitWeb.UserAuth do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Kmxgit.UserManager
+ alias KmxgitWeb.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 "_kmxgit_web_user_remember_me"
+ @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+
+ @doc """
+ Logs the user in.
+
+ It renews the session ID and clears the whole session
+ to avoid fixation attacks. See the renew_session
+ function to customize this behaviour.
+
+ It also sets a `:live_socket_id` key in the session,
+ so LiveView sessions are identified and automatically
+ disconnected on log out. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def log_in_user(conn, user, params \\ %{}) do
+ token = UserManager.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 log in/log out,
+ # 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 log_out_user(conn) do
+ user_token = get_session(conn, :user_token)
+ user_token && UserManager.delete_session_token(user_token)
+
+ if live_socket_id = get_session(conn, :live_socket_id) do
+ KmxgitWeb.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 && UserManager.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 email 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 log in 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"} = conn) do
+ put_session(conn, :user_return_to, current_path(conn))
+ end
+
+ defp maybe_store_return_to(conn), do: conn
+
+ defp signed_in_path(_conn), do: "/"
+end
diff --git a/lib/kmxgit_web/controllers/user_confirmation_controller.ex b/lib/kmxgit_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 0000000..437c59b
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_confirmation_controller.ex
@@ -0,0 +1,57 @@
+defmodule KmxgitWeb.UserConfirmationController do
+ use KmxgitWeb, :controller
+
+ alias Kmxgit.UserManager
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = UserManager.get_user_by_email(email) do
+ UserManager.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+ end
+
+ # In order to prevent user enumeration attacks, regardless of the outcome, show an impartial success/error message.
+ conn
+ |> put_flash(
+ :info,
+ "If your email is in our system and it has not been confirmed yet, " <>
+ "you will receive an email with instructions shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, %{"token" => token}) do
+ render(conn, "edit.html", token: token)
+ end
+
+ # Do not log in the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"token" => token}) do
+ case UserManager.confirm_user(token) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "User confirmed successfully.")
+ |> redirect(to: "/")
+
+ :error ->
+ # If there is a current user and the account was already confirmed,
+ # then odds are that the confirmation link was already visited, either
+ # by some automation or by the user themselves, so we redirect without
+ # a warning message.
+ case conn.assigns do
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
+ redirect(conn, to: "/")
+
+ %{} ->
+ conn
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
+ |> redirect(to: "/")
+ end
+ end
+ end
+end
diff --git a/lib/kmxgit_web/controllers/user_registration_controller.ex b/lib/kmxgit_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..0e2713e
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_registration_controller.ex
@@ -0,0 +1,30 @@
+defmodule KmxgitWeb.UserRegistrationController do
+ use KmxgitWeb, :controller
+
+ alias Kmxgit.UserManager
+ alias Kmxgit.UserManager.User
+ alias KmxgitWeb.UserAuth
+
+ def new(conn, _params) do
+ changeset = UserManager.change_user_registration(%User{})
+ render(conn, "new.html", changeset: changeset)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ case UserManager.register_user(user_params) do
+ {:ok, user} ->
+ {:ok, _} =
+ UserManager.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+
+ conn
+ |> put_flash(:info, "User created successfully.")
+ |> UserAuth.log_in_user(user)
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, "new.html", changeset: changeset)
+ end
+ end
+end
diff --git a/lib/kmxgit_web/controllers/user_reset_password_controller.ex b/lib/kmxgit_web/controllers/user_reset_password_controller.ex
new file mode 100644
index 0000000..c083e11
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_reset_password_controller.ex
@@ -0,0 +1,59 @@
+defmodule KmxgitWeb.UserResetPasswordController do
+ use KmxgitWeb, :controller
+
+ alias Kmxgit.UserManager
+
+ 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 = UserManager.get_user_by_email(email) do
+ UserManager.deliver_user_reset_password_instructions(
+ user,
+ &Routes.user_reset_password_url(conn, :edit, &1)
+ )
+ end
+
+ # In order to prevent user enumeration attacks, regardless of the outcome, show an impartial success/error message.
+ conn
+ |> put_flash(
+ :info,
+ "If your email 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: UserManager.change_user_password(conn.assigns.user))
+ end
+
+ # Do not log in 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 UserManager.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 = UserManager.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/kmxgit_web/controllers/user_session_controller.ex b/lib/kmxgit_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..f22052a
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_session_controller.ex
@@ -0,0 +1,27 @@
+defmodule KmxgitWeb.UserSessionController do
+ use KmxgitWeb, :controller
+
+ alias Kmxgit.UserManager
+ alias KmxgitWeb.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 = UserManager.get_user_by_email_and_password(email, password) do
+ UserAuth.log_in_user(conn, user, user_params)
+ else
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
+ render(conn, "new.html", error_message: "Invalid email or password")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> UserAuth.log_out_user()
+ |> put_flash(:info, "Logged out successfully.")
+ end
+end
diff --git a/lib/kmxgit_web/controllers/user_settings_controller.ex b/lib/kmxgit_web/controllers/user_settings_controller.ex
new file mode 100644
index 0000000..c85f1a2
--- /dev/null
+++ b/lib/kmxgit_web/controllers/user_settings_controller.ex
@@ -0,0 +1,74 @@
+defmodule KmxgitWeb.UserSettingsController do
+ use KmxgitWeb, :controller
+
+ alias Kmxgit.UserManager
+ alias KmxgitWeb.UserAuth
+
+ plug :assign_email_and_password_changesets
+
+ def edit(conn, _params) do
+ render(conn, "edit.html")
+ end
+
+ def update(conn, %{"action" => "update_email"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case UserManager.apply_user_email(user, password, user_params) do
+ {:ok, applied_user} ->
+ UserManager.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 email 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 update(conn, %{"action" => "update_password"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case UserManager.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.log_in_user(user)
+
+ {:error, changeset} ->
+ render(conn, "edit.html", password_changeset: changeset)
+ end
+ end
+
+ def confirm_email(conn, %{"token" => token}) do
+ case UserManager.update_user_email(conn.assigns.current_user, token) do
+ :ok ->
+ conn
+ |> put_flash(:info, "Email 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
+
+ defp assign_email_and_password_changesets(conn, _opts) do
+ user = conn.assigns.current_user
+
+ conn
+ |> assign(:email_changeset, UserManager.change_user_email(user))
+ |> assign(:password_changeset, UserManager.change_user_password(user))
+ end
+end
diff --git a/lib/kmxgit_web/router.ex b/lib/kmxgit_web/router.ex
index 0e8c6ce..275bf4b 100644
--- a/lib/kmxgit_web/router.ex
+++ b/lib/kmxgit_web/router.ex
@@ -1,6 +1,8 @@
defmodule KmxgitWeb.Router do
use KmxgitWeb, :router
+ import KmxgitWeb.UserAuth
+
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -8,6 +10,7 @@ defmodule KmxgitWeb.Router do
plug :put_root_layout, {KmxgitWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :fetch_current_user
end
pipeline :api do
@@ -35,7 +38,7 @@ defmodule KmxgitWeb.Router do
# maybe logged in
scope "/", KmxgitWeb do
- pipe_through [:browser, :auth]
+ pipe_through [:browser]
get "/", PageController, :index
get "/_etc/git/auth.conf", PageController, :auth
@@ -47,7 +50,6 @@ defmodule KmxgitWeb.Router do
get "/new", SessionController, :new
get "/logout", SessionController, :logout
- pipe_through :recaptcha
post "/new", SessionController, :login
end
@@ -57,9 +59,12 @@ defmodule KmxgitWeb.Router do
post "/_register", RegistrationController, :register
end
- # definitely logged in, will redirect to login page
scope "/", KmxgitWeb do
- pipe_through [:browser, :auth, :ensure_auth]
+ pipe_through [:browser, :require_authenticated_user]
+
+ get "/users/settings", UserSettingsController, :edit
+ put "/users/settings", UserSettingsController, :update
+ get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
scope "/_new" do
get "/organisation", OrganisationController, :new
@@ -143,4 +148,31 @@ defmodule KmxgitWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
+
+ ## Authentication routes
+
+ scope "/", KmxgitWeb do
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
+
+ get "/users/register", UserRegistrationController, :new
+ get "/users/log_in", UserSessionController, :new
+ 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
+
+ pipe_through :recaptcha
+ post "/users/register", UserRegistrationController, :create
+ post "/users/log_in", UserSessionController, :create
+ end
+
+ scope "/", KmxgitWeb do
+ pipe_through [:browser]
+
+ delete "/users/log_out", UserSessionController, :delete
+ get "/users/confirm", UserConfirmationController, :new
+ post "/users/confirm", UserConfirmationController, :create
+ get "/users/confirm/:token", UserConfirmationController, :edit
+ post "/users/confirm/:token", UserConfirmationController, :update
+ end
end
diff --git a/lib/kmxgit_web/templates/layout/nav_connected.html.heex b/lib/kmxgit_web/templates/layout/nav_connected.html.heex
index fa077c0..1e688fe 100644
--- a/lib/kmxgit_web/templates/layout/nav_connected.html.heex
+++ b/lib/kmxgit_web/templates/layout/nav_connected.html.heex
@@ -21,5 +21,5 @@
</li>
<% end %>
<li class="nav-item">
- <%= link gettext("Logout"), to: Routes.session_path(@conn, :logout), class: "nav-link" %>
+ <%= link gettext("Logout"), to: Routes.user_session_path(@conn, :delete), class: "nav-link" %>
</li>
diff --git a/lib/kmxgit_web/templates/layout/nav_disconnected.html.heex b/lib/kmxgit_web/templates/layout/nav_disconnected.html.heex
index 72d7420..f00632a 100644
--- a/lib/kmxgit_web/templates/layout/nav_disconnected.html.heex
+++ b/lib/kmxgit_web/templates/layout/nav_disconnected.html.heex
@@ -1,6 +1,6 @@
<li class="nav-item">
- <%= link "login", to: Routes.session_path(@conn, :new), class: "nav-link" %>
+ <%= link "login", to: Routes.user_session_path(@conn, :new), class: "nav-link" %>
</li>
<li class="nav-item">
- <%= link "register", to: Routes.registration_path(@conn, :new), class: "nav-link" %>
+ <%= link "register", to: Routes.user_registration_path(@conn, :new), class: "nav-link" %>
</li>
diff --git a/lib/kmxgit_web/templates/layout/root.html.heex b/lib/kmxgit_web/templates/layout/root.html.heex
index 9a94640..4f640a3 100644
--- a/lib/kmxgit_web/templates/layout/root.html.heex
+++ b/lib/kmxgit_web/templates/layout/root.html.heex
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= if assigns[:page_title] do %>
- <%= live_title_tag assigns[:page_title], suffix: " · kmx git" %>
+ <%= live_title_tag assigns[:page_title], suffix: " - kmx git" %>
<% else %>
<%= live_title_tag "kmx git" %>
<% end %>
diff --git a/lib/kmxgit_web/templates/registration/new.html.heex b/lib/kmxgit_web/templates/registration/new.html.heex
deleted file mode 100644
index 8228fb8..0000000
--- a/lib/kmxgit_web/templates/registration/new.html.heex
+++ /dev/null
@@ -1,45 +0,0 @@
-<div class="container-fluid">
- <h1><%= gettext "Register" %></h1>
-
- <%= form_for @changeset, @action, [as: "user"], fn f -> %>
-
- <div class="mb-3">
- <%= label f, :name, class: "form-label" %>
- <%= text_input f, :name, class: "form-control" %>
- <%= error_tag f, :name %>
- </div>
-
- <div class="mb-3">
- <%= label f, :email, class: "form-label" %>
- <%= text_input f, :email, class: "form-control" %>
- <%= error_tag f, :email %>
- </div>
-
- <%= inputs_for f, :slug, fn ff -> %>
- <div class="mb-3">
- <%= label ff, :slug, gettext("Login"), class: "form-label" %>
- <%= text_input ff, :slug, class: "form-control" %>
- <%= error_tag ff, :slug %>
- </div>
- <% end %>
-
- <div class="mb-3">
- <%= label f, :password, class: "form-label" %>
- <%= password_input f, :password, class: "form-control" %>
- <%= error_tag f, :password %>
- </div>
-
- <div class="mb-3">
- <%= label f, :password_confirmation, class: "form-label" %>
- <%= password_input f, :password_confirmation, class: "form-control" %>
- <%= error_tag f, :password_confirmation %>
- </div>
-
- <%= render "recaptcha.html", assigns %>
-
- <div class="mb-3">
- <%= submit "Submit", class: "btn btn-primary" %>
- </div>
-
- <% end %>
-</div>
diff --git a/lib/kmxgit_web/templates/registration/recaptcha.html.heex b/lib/kmxgit_web/templates/registration/recaptcha.html.heex
deleted file mode 100644
index 1bb0939..0000000
--- a/lib/kmxgit_web/templates/registration/recaptcha.html.heex
+++ /dev/null
@@ -1,9 +0,0 @@
-<%= tag :input, type: 'hidden', name: 'recaptcha' %>
-<script src={"https://www.google.com/recaptcha/api.js?render=#{recaptcha_site_key()}"}></script>
-<script>
- grecaptcha.ready(function() {
- grecaptcha.execute('<%= recaptcha_site_key() %>', {action: 'register'}).then(function(token) {
- $('input[name="recaptcha"]').val(token);
- });
- });
-</script>
diff --git a/lib/kmxgit_web/templates/session/recaptcha.html.heex b/lib/kmxgit_web/templates/session/recaptcha.html.heex
deleted file mode 100644
index fdd3565..0000000
--- a/lib/kmxgit_web/templates/session/recaptcha.html.heex
+++ /dev/null
@@ -1,9 +0,0 @@
-<%= tag :input, type: "hidden", name: "recaptcha" %>
-<script src={"https://www.google.com/recaptcha/api.js?render=#{recaptcha_site_key()}"}></script>
-<script>
- grecaptcha.ready(function() {
- grecaptcha.execute('<%= recaptcha_site_key() %>', {action: 'login'}).then(function(token) {
- $('input[name="recaptcha"]').val(token);
- });
- });
-</script>
diff --git a/lib/kmxgit_web/templates/user/edit.html.heex b/lib/kmxgit_web/templates/user/edit.html.heex
index e4d31ab..a13070c 100644
--- a/lib/kmxgit_web/templates/user/edit.html.heex
+++ b/lib/kmxgit_web/templates/user/edit.html.heex
@@ -1,5 +1,6 @@
<div class="container-fluid center">
<h1>Edit user <%= @current_user.slug.slug %></h1>
+
<%= form_for @changeset, Routes.user_path(@conn, :update, @current_user.slug.slug), fn f -> %>
<div class="mb-3">
@@ -8,12 +9,6 @@
<%= error_tag f, :name %>
</div>
- <div class="mb-3">
- <%= label f, :email, class: "form-label" %>
- <%= text_input f, :email, class: "form-control" %>
- <%= error_tag f, :email %>
- </div>
-
<%= inputs_for f, :slug, fn ff -> %>
<div class="mb-3">
<%= label ff, :slug, gettext("Login"), class: "form-label" %>
@@ -34,18 +29,6 @@
<%= error_tag f, :ssh_keys %>
</div>
- <div class="mb-3">
- <%= label f, :password, class: "form-label" %>
- <%= password_input f, :password, class: "form-control" %>
- <%= error_tag f, :password %>
- </div>
-
- <div class="mb-3">
- <%= label f, :password_confirmation, class: "form-label" %>
- <%= password_input f, :password_confirmation, class: "form-control" %>
- <%= error_tag f, :password_confirmation %>
- </div>
-
<div class="mb-3 form-check">
<%= checkbox f, :deploy_only, class: "form-check-input" %>
<%= label f, :deploy_only, class: "form-check-label" %>
@@ -67,6 +50,61 @@
data: [confirm: gettext("Are you sure you want to delete the user %{user} ?", user: @current_user.slug.slug)] %>
<%= submit gettext("Submit"), class: "btn btn-primary" %>
</div>
-
<% end %>
+
+ <h3>Change email</h3>
+
+ <.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
+ <%= if @email_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_email" %>
+
+ <div class="mb-3">
+ <%= label f, :email, class: "form-label" %>
+ <%= email_input f, :email, class: "form-control", required: true %>
+ <%= error_tag f, :email %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :current_password, for: "current_password_for_email", class: "form-label" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "form-control" %>
+ <%= error_tag f, :current_password %>
+ </div>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <h3>Change password</h3>
+
+ <.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
+ <%= if @password_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_password" %>
+
+ <%= 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 %>
+
+ <div>
+ <%= submit "Change password" %>
+ </div>
+ </.form>
</div>
diff --git a/lib/kmxgit_web/templates/user_confirmation/edit.html.heex b/lib/kmxgit_web/templates/user_confirmation/edit.html.heex
new file mode 100644
index 0000000..e9bf443
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_confirmation/edit.html.heex
@@ -0,0 +1,12 @@
+<h1>Confirm account</h1>
+
+<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}>
+ <div>
+ <%= submit "Confirm my account" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxgit_web/templates/user_confirmation/new.html.heex b/lib/kmxgit_web/templates/user_confirmation/new.html.heex
new file mode 100644
index 0000000..4d9bee3
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_confirmation/new.html.heex
@@ -0,0 +1,15 @@
+<h1>Resend confirmation instructions</h1>
+
+<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}>
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <div>
+ <%= submit "Resend confirmation instructions" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxgit_web/templates/user_registration/new.html.heex b/lib/kmxgit_web/templates/user_registration/new.html.heex
new file mode 100644
index 0000000..e6e76d7
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_registration/new.html.heex
@@ -0,0 +1,54 @@
+<div class="container-fluid">
+ <h1><%= gettext "Register" %></h1>
+
+ <.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <div class="mb-3">
+ <%= label f, :name, class: "form-label" %>
+ <%= text_input f, :name, class: "form-control", required: true %>
+ <%= error_tag f, :name %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, class: "form-control", required: true %>
+ <%= error_tag f, :email %>
+ </div>
+
+ <%= inputs_for f, :slug, fn ff -> %>
+ <div class="mb-3">
+ <%= label ff, :slug, gettext("Login"), class: "form-label" %>
+ <%= text_input ff, :slug, class: "form-control", required: true %>
+ <%= error_tag ff, :slug %>
+ </div>
+ <% end %>
+
+ <div class="mb-3">
+ <%= label f, :password, class: "form-label" %>
+ <%= password_input f, :password, class: "form-control", required: true %>
+ <%= error_tag f, :password %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :password_confirmation, class: "form-label" %>
+ <%= password_input f, :password_confirmation, class: "form-control", required: true %>
+ <%= error_tag f, :password_confirmation %>
+ </div>
+
+ <%= render "recaptcha.html", assigns %>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <p>
+ <%= link gettext("Log in"), to: Routes.user_session_path(@conn, :new) %> |
+ <%= link gettext("Forgot your password?"), to: Routes.user_reset_password_path(@conn, :new) %>
+ </p>
+</div>
diff --git a/lib/kmxgit_web/templates/user_registration/recaptcha.html.heex b/lib/kmxgit_web/templates/user_registration/recaptcha.html.heex
new file mode 100644
index 0000000..1bb0939
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_registration/recaptcha.html.heex
@@ -0,0 +1,9 @@
+<%= tag :input, type: 'hidden', name: 'recaptcha' %>
+<script src={"https://www.google.com/recaptcha/api.js?render=#{recaptcha_site_key()}"}></script>
+<script>
+ grecaptcha.ready(function() {
+ grecaptcha.execute('<%= recaptcha_site_key() %>', {action: 'register'}).then(function(token) {
+ $('input[name="recaptcha"]').val(token);
+ });
+ });
+</script>
diff --git a/lib/kmxgit_web/templates/user_reset_password/edit.html.heex b/lib/kmxgit_web/templates/user_reset_password/edit.html.heex
new file mode 100644
index 0000000..2c79a93
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_reset_password/edit.html.heex
@@ -0,0 +1,32 @@
+<div class="container-fluid">
+ <h1><%= gettext "Reset password" %></h1>
+
+ <.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <div class="mb-3">
+ <%= label f, :password, "New password", class: "form-label" %>
+ <%= password_input f, :password, class: "form-control", required: true %>
+ <%= error_tag f, :password %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
+ <%= password_input f, :password_confirmation, class: "form-control", required: true %>
+ <%= error_tag f, :password_confirmation %>
+ </div>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <p>
+ <%= link gettext("Register"), to: Routes.user_registration_path(@conn, :new) %>
+ <%= link gettext("Log in"), to: Routes.user_session_path(@conn, :new) %>
+ </p>
+</div>
diff --git a/lib/kmxgit_web/templates/user_reset_password/new.html.heex b/lib/kmxgit_web/templates/user_reset_password/new.html.heex
new file mode 100644
index 0000000..4ad481d
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_reset_password/new.html.heex
@@ -0,0 +1,20 @@
+<div class="container-fluid">
+ <h1><%= gettext "Forgot your password ?" %></h1>
+
+ <.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}>
+
+ <div class="mb-3">
+ <%= label f, :email, class: "form-label" %>
+ <%= email_input f, :email, class: "form-control", required: true %>
+ </div>
+
+ <div>
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <p>
+ <%= link gettext("Register"), to: Routes.user_registration_path(@conn, :new) %>
+ <%= link gettext("Log in"), to: Routes.user_session_path(@conn, :new) %>
+ </p>
+</div>
diff --git a/lib/kmxgit_web/templates/user_session/new.html.heex b/lib/kmxgit_web/templates/user_session/new.html.heex
new file mode 100644
index 0000000..6781588
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_session/new.html.heex
@@ -0,0 +1,39 @@
+<div class="container-fluid">
+ <h1><%= gettext "Log in" %></h1>
+
+ <.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}>
+ <%= if @error_message do %>
+ <div class="alert alert-danger">
+ <p><%= @error_message %></p>
+ </div>
+ <% end %>
+
+ <div class="mb-3">
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, class: "form-control", required: true %>
+ <%= error_tag f, :email %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :password, class: "form-label" %>
+ <%= password_input f, :password, class: "form-control", required: true %>
+ <%= error_tag f, :password %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :remember_me, "Keep me logged in for 60 days", class: "form-label" %>
+ <%= checkbox f, :remember_me, class: "form-control" %>
+ </div>
+
+ <%= render "recaptcha.html", assigns %>
+
+ <div>
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <p>
+ <%= link gettext("Register"), to: Routes.user_registration_path(@conn, :new) %>
+ <%= link gettext("Forgot your password ?"), to: Routes.user_reset_password_path(@conn, :new) %>
+ </p>
+</div>
diff --git a/lib/kmxgit_web/templates/user_session/recaptcha.html.heex b/lib/kmxgit_web/templates/user_session/recaptcha.html.heex
new file mode 100644
index 0000000..fdd3565
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_session/recaptcha.html.heex
@@ -0,0 +1,9 @@
+<%= tag :input, type: "hidden", name: "recaptcha" %>
+<script src={"https://www.google.com/recaptcha/api.js?render=#{recaptcha_site_key()}"}></script>
+<script>
+ grecaptcha.ready(function() {
+ grecaptcha.execute('<%= recaptcha_site_key() %>', {action: 'login'}).then(function(token) {
+ $('input[name="recaptcha"]').val(token);
+ });
+ });
+</script>
diff --git a/lib/kmxgit_web/templates/user_settings/edit.html.heex b/lib/kmxgit_web/templates/user_settings/edit.html.heex
new file mode 100644
index 0000000..9863bc5
--- /dev/null
+++ b/lib/kmxgit_web/templates/user_settings/edit.html.heex
@@ -0,0 +1,53 @@
+<h1>Settings</h1>
+
+<h3>Change email</h3>
+
+<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
+ <%= if @email_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_email" %>
+
+ <%= label f, :email %>
+ <%= email_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 %>
+
+ <div>
+ <%= submit "Change email" %>
+ </div>
+</.form>
+
+<h3>Change password</h3>
+
+<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
+ <%= if @password_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_password" %>
+
+ <%= 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 %>
+
+ <div>
+ <%= submit "Change password" %>
+ </div>
+</.form>
diff --git a/lib/kmxgit_web/views/registration_view.ex b/lib/kmxgit_web/views/registration_view.ex
deleted file mode 100644
index f743ad7..0000000
--- a/lib/kmxgit_web/views/registration_view.ex
+++ /dev/null
@@ -1,7 +0,0 @@
-defmodule KmxgitWeb.RegistrationView do
- use KmxgitWeb, :view
-
- def recaptcha_site_key do
- Application.get_env :kmxgit, :recaptcha_site_key
- end
-end
diff --git a/lib/kmxgit_web/views/session_view.ex b/lib/kmxgit_web/views/session_view.ex
deleted file mode 100644
index 7b11688..0000000
--- a/lib/kmxgit_web/views/session_view.ex
+++ /dev/null
@@ -1,7 +0,0 @@
-defmodule KmxgitWeb.SessionView do
- use KmxgitWeb, :view
-
- def recaptcha_site_key do
- Application.get_env :kmxgit, :recaptcha_site_key
- end
-end
diff --git a/lib/kmxgit_web/views/user_confirmation_view.ex b/lib/kmxgit_web/views/user_confirmation_view.ex
new file mode 100644
index 0000000..0a65519
--- /dev/null
+++ b/lib/kmxgit_web/views/user_confirmation_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxgitWeb.UserConfirmationView do
+ use KmxgitWeb, :view
+end
diff --git a/lib/kmxgit_web/views/user_registration_view.ex b/lib/kmxgit_web/views/user_registration_view.ex
new file mode 100644
index 0000000..036e152
--- /dev/null
+++ b/lib/kmxgit_web/views/user_registration_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxgitWeb.UserRegistrationView do
+ use KmxgitWeb, :view
+end
diff --git a/lib/kmxgit_web/views/user_reset_password_view.ex b/lib/kmxgit_web/views/user_reset_password_view.ex
new file mode 100644
index 0000000..7ae1df0
--- /dev/null
+++ b/lib/kmxgit_web/views/user_reset_password_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxgitWeb.UserResetPasswordView do
+ use KmxgitWeb, :view
+end
diff --git a/lib/kmxgit_web/views/user_session_view.ex b/lib/kmxgit_web/views/user_session_view.ex
new file mode 100644
index 0000000..8f0e95f
--- /dev/null
+++ b/lib/kmxgit_web/views/user_session_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxgitWeb.UserSessionView do
+ use KmxgitWeb, :view
+end
diff --git a/lib/kmxgit_web/views/user_settings_view.ex b/lib/kmxgit_web/views/user_settings_view.ex
new file mode 100644
index 0000000..01837ae
--- /dev/null
+++ b/lib/kmxgit_web/views/user_settings_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxgitWeb.UserSettingsView do
+ use KmxgitWeb, :view
+end
diff --git a/priv/repo/migrations/20211208130339_create_users_auth_tables.exs b/priv/repo/migrations/20211208130339_create_users_auth_tables.exs
new file mode 100644
index 0000000..369ea7a
--- /dev/null
+++ b/priv/repo/migrations/20211208130339_create_users_auth_tables.exs
@@ -0,0 +1,29 @@
+defmodule Kmxgit.Repo.Migrations.CreateUsersAuthTables do
+ use Ecto.Migration
+
+ def change do
+ execute "CREATE EXTENSION IF NOT EXISTS citext", ""
+
+ drop index(:users, ["(lower(email))"], unique: true)
+
+ alter table(:users) do
+ modify :email, :citext, null: false
+ remove :encrypted_password
+ add :hashed_password, :string, null: false, default: "*"
+ add :confirmed_at, :utc_datetime
+ 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/kmxgit/user_manager_test.exs b/test/kmxgit/user_manager_test.exs
new file mode 100644
index 0000000..450aaaf
--- /dev/null
+++ b/test/kmxgit/user_manager_test.exs
@@ -0,0 +1,508 @@
+defmodule Kmxgit.UserManagerTest do
+ use Kmxgit.DataCase
+
+ alias Kmxgit.UserManager
+
+ import Kmxgit.UserManagerFixtures
+ alias Kmxgit.UserManager.{User, UserToken}
+
+ describe "get_user_by_email/1" do
+ test "does not return the user if the email does not exist" do
+ refute UserManager.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} = UserManager.get_user_by_email(user.email)
+ end
+ end
+
+ describe "get_user_by_email_and_password/2" do
+ test "does not return the user if the email does not exist" do
+ refute UserManager.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 UserManager.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} =
+ UserManager.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 ->
+ UserManager.get_user!(-1)
+ end
+ end
+
+ test "returns the user with the given id" do
+ %{id: id} = user = user_fixture()
+ assert %User{id: ^id} = UserManager.get_user!(user.id)
+ end
+ end
+
+ describe "register_user/1" do
+ test "requires email and password to be set" do
+ {:error, changeset} = UserManager.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} = UserManager.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 email and password for security" do
+ too_long = String.duplicate("db", 100)
+ {:error, changeset} = UserManager.register_user(%{email: too_long, password: too_long})
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates email uniqueness" do
+ %{email: email} = user_fixture()
+ {:error, changeset} = UserManager.register_user(%{email: email})
+ assert "has already been taken" in errors_on(changeset).email
+
+ # Now try with the upper cased email too, to check that email case is ignored.
+ {:error, changeset} = UserManager.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} = UserManager.register_user(valid_user_attributes(email: email))
+ assert user.email == email
+ assert is_binary(user.hashed_password)
+ assert is_nil(user.confirmed_at)
+ assert is_nil(user.password)
+ end
+ end
+
+ describe "change_user_registration/2" do
+ test "returns a changeset" do
+ assert %Ecto.Changeset{} = changeset = UserManager.change_user_registration(%User{})
+ assert changeset.required == [:password, :email]
+ end
+
+ test "allows fields to be set" do
+ email = unique_user_email()
+ password = valid_user_password()
+
+ changeset =
+ UserManager.change_user_registration(
+ %User{},
+ valid_user_attributes(email: email, password: password)
+ )
+
+ assert changeset.valid?
+ assert get_change(changeset, :email) == email
+ assert get_change(changeset, :password) == password
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "change_user_email/2" do
+ test "returns a user changeset" do
+ assert %Ecto.Changeset{} = changeset = UserManager.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} = UserManager.apply_user_email(user, valid_user_password(), %{})
+ assert %{email: ["did not change"]} = errors_on(changeset)
+ end
+
+ test "validates email", %{user: user} do
+ {:error, changeset} =
+ UserManager.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 email for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+
+ {:error, changeset} =
+ UserManager.apply_user_email(user, valid_user_password(), %{email: too_long})
+
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ end
+
+ test "validates email uniqueness", %{user: user} do
+ %{email: email} = user_fixture()
+
+ {:error, changeset} =
+ UserManager.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} =
+ UserManager.apply_user_email(user, "invalid", %{email: unique_user_email()})
+
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "applies the email without persisting it", %{user: user} do
+ email = unique_user_email()
+ {:ok, user} = UserManager.apply_user_email(user, valid_user_password(), %{email: email})
+ assert user.email == email
+ assert UserManager.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 ->
+ UserManager.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 ->
+ UserManager.deliver_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{user: user, token: token, email: email}
+ end
+
+ test "updates the email with a valid token", %{user: user, token: token, email: email} do
+ assert UserManager.update_user_email(user, token) == :ok
+ changed_user = Repo.get!(User, user.id)
+ assert changed_user.email != user.email
+ assert changed_user.email == email
+ assert changed_user.confirmed_at
+ assert changed_user.confirmed_at != user.confirmed_at
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email with invalid token", %{user: user} do
+ assert UserManager.update_user_email(user, "oops") == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if user email changed", %{user: user, token: token} do
+ assert UserManager.update_user_email(%{user | email: "current@example.com"}, token) == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert UserManager.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 = UserManager.change_user_password(%User{})
+ assert changeset.required == [:password]
+ end
+
+ test "allows fields to be set" do
+ changeset =
+ UserManager.change_user_password(%User{}, %{
+ "password" => "new valid password"
+ })
+
+ assert changeset.valid?
+ assert get_change(changeset, :password) == "new valid password"
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "update_user_password/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ UserManager.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} =
+ UserManager.update_user_password(user, valid_user_password(), %{password: too_long})
+
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates current password", %{user: user} do
+ {:error, changeset} =
+ UserManager.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} =
+ UserManager.update_user_password(user, valid_user_password(), %{
+ password: "new valid password"
+ })
+
+ assert is_nil(user.password)
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = UserManager.generate_user_session_token(user)
+
+ {:ok, _} =
+ UserManager.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 = UserManager.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 = UserManager.generate_user_session_token(user)
+ %{user: user, token: token}
+ end
+
+ test "returns user by token", %{user: user, token: token} do
+ assert session_user = UserManager.get_user_by_session_token(token)
+ assert session_user.id == user.id
+ end
+
+ test "does not return user for invalid token" do
+ refute UserManager.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 UserManager.get_user_by_session_token(token)
+ end
+ end
+
+ describe "delete_session_token/1" do
+ test "deletes the token" do
+ user = user_fixture()
+ token = UserManager.generate_user_session_token(user)
+ assert UserManager.delete_session_token(token) == :ok
+ refute UserManager.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 ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "confirm"
+ end
+ end
+
+ describe "confirm_user/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ %{user: user, token: token}
+ end
+
+ test "confirms the email with a valid token", %{user: user, token: token} do
+ assert {:ok, confirmed_user} = UserManager.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 UserManager.confirm_user("oops") == :error
+ refute Repo.get!(User, user.id).confirmed_at
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not confirm email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert UserManager.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 ->
+ UserManager.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "reset_password"
+ end
+ end
+
+ describe "get_user_by_reset_password_token/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.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} = UserManager.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 UserManager.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 UserManager.get_user_by_reset_password_token(token)
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "reset_user_password/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ UserManager.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} = UserManager.reset_user_password(user, %{password: too_long})
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "updates the password", %{user: user} do
+ {:ok, updated_user} = UserManager.reset_user_password(user, %{password: "new valid password"})
+ assert is_nil(updated_user.password)
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = UserManager.generate_user_session_token(user)
+ {:ok, _} = UserManager.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/kmxgit_web/controllers/user_auth_test.exs b/test/kmxgit_web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..e4a8ced
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_auth_test.exs
@@ -0,0 +1,170 @@
+defmodule KmxgitWeb.UserAuthTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ alias Kmxgit.UserManager
+ alias KmxgitWeb.UserAuth
+ import Kmxgit.UserManager1Fixtures
+
+ @remember_me_cookie "_kmxgit_web_user_remember_me"
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, KmxgitWeb.Endpoint.config(:secret_key_base))
+ |> init_test_session(%{})
+
+ %{user: user_fixture(), conn: conn}
+ end
+
+ describe "log_in_user/3" do
+ test "stores the user token in the session", %{conn: conn, user: user} do
+ conn = UserAuth.log_in_user(conn, user)
+ assert token = get_session(conn, :user_token)
+ assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
+ assert redirected_to(conn) == "/"
+ assert UserManager.get_user_by_session_token(token)
+ end
+
+ test "clears everything previously stored in the session", %{conn: conn, user: user} do
+ conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
+ refute get_session(conn, :to_be_removed)
+ end
+
+ test "redirects to the configured path", %{conn: conn, user: user} do
+ conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
+ assert redirected_to(conn) == "/hello"
+ end
+
+ test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
+ conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+ assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
+
+ assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
+ assert signed_token != get_session(conn, :user_token)
+ assert max_age == 5_184_000
+ end
+ end
+
+ describe "logout_user/1" do
+ test "erases session and cookies", %{conn: conn, user: user} do
+ user_token = UserManager.generate_user_session_token(user)
+
+ conn =
+ conn
+ |> put_session(:user_token, user_token)
+ |> put_req_cookie(@remember_me_cookie, user_token)
+ |> fetch_cookies()
+ |> UserAuth.log_out_user()
+
+ refute get_session(conn, :user_token)
+ refute conn.cookies[@remember_me_cookie]
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+ assert redirected_to(conn) == "/"
+ refute UserManager.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"
+ KmxgitWeb.Endpoint.subscribe(live_socket_id)
+
+ conn
+ |> put_session(:live_socket_id, live_socket_id)
+ |> UserAuth.log_out_user()
+
+ assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
+ end
+
+ test "works even if user is already logged out", %{conn: conn} do
+ conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
+ refute get_session(conn, :user_token)
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "fetch_current_user/2" do
+ test "authenticates user from session", %{conn: conn, user: user} do
+ user_token = UserManager.generate_user_session_token(user)
+ conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
+ assert conn.assigns.current_user.id == user.id
+ end
+
+ test "authenticates user from cookies", %{conn: conn, user: user} do
+ logged_in_conn =
+ conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+
+ user_token = logged_in_conn.cookies[@remember_me_cookie]
+ %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
+
+ conn =
+ conn
+ |> put_req_cookie(@remember_me_cookie, signed_token)
+ |> UserAuth.fetch_current_user([])
+
+ assert 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
+ _ = UserManager.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) == Routes.user_session_path(conn, :new)
+ assert get_flash(conn, :error) == "You must log in to access this page."
+ end
+
+ test "stores the path to redirect to on GET", %{conn: conn} do
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: ""}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar=baz"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ refute get_session(halted_conn, :user_return_to)
+ end
+
+ test "does not redirect if user is authenticated", %{conn: conn, user: user} do
+ conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
+ refute conn.halted
+ refute conn.status
+ end
+ end
+end
diff --git a/test/kmxgit_web/controllers/user_confirmation_controller_test.exs b/test/kmxgit_web/controllers/user_confirmation_controller_test.exs
new file mode 100644
index 0000000..b2f88ab
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_confirmation_controller_test.exs
@@ -0,0 +1,105 @@
+defmodule KmxgitWeb.UserConfirmationControllerTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ alias Kmxgit.UserManager
+ alias Kmxgit.Repo
+ import Kmxgit.UserManagerFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/confirm" do
+ test "renders the resend confirmation page", %{conn: conn} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Resend confirmation instructions</h1>"
+ 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 email is in our system"
+ assert Repo.get_by!(UserManager.UserToken, user_id: user.id).context == "confirm"
+ end
+
+ test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do
+ Repo.update!(UserManager.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 email is in our system"
+ refute Repo.get_by(UserManager.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 email is in our system"
+ assert Repo.all(UserManager.UserToken) == []
+ end
+ end
+
+ describe "GET /users/confirm/:token" do
+ test "renders the confirmation page", %{conn: conn} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :edit, "some-token"))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Confirm account</h1>"
+
+ form_action = Routes.user_confirmation_path(conn, :update, "some-token")
+ assert response =~ "action=\"#{form_action}\""
+ end
+ end
+
+ describe "POST /users/confirm/:token" do
+ test "confirms the given token once", %{conn: conn, user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "User confirmed successfully"
+ assert UserManager.get_user!(user.id).confirmed_at
+ refute get_session(conn, :user_token)
+ assert Repo.all(UserManager.UserToken) == []
+
+ # When not logged in
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired"
+
+ # When logged in
+ conn =
+ build_conn()
+ |> log_in_user(user)
+ |> post(Routes.user_confirmation_path(conn, :update, token))
+
+ assert redirected_to(conn) == "/"
+ refute get_flash(conn, :error)
+ end
+
+ test "does not confirm email with invalid token", %{conn: conn, user: user} do
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired"
+ refute UserManager.get_user!(user.id).confirmed_at
+ end
+ end
+end
diff --git a/test/kmxgit_web/controllers/user_registration_controller_test.exs b/test/kmxgit_web/controllers/user_registration_controller_test.exs
new file mode 100644
index 0000000..3a84700
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_registration_controller_test.exs
@@ -0,0 +1,54 @@
+defmodule KmxgitWeb.UserRegistrationControllerTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ import Kmxgit.UserManagerFixtures
+
+ 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 =~ "<h1>Register</h1>"
+ assert response =~ "Log in</a>"
+ assert response =~ "Register</a>"
+ end
+
+ test "redirects if already logged in", %{conn: conn} do
+ conn = conn |> log_in_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" => valid_user_attributes(email: email)
+ })
+
+ 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</a>"
+ assert response =~ "Log out</a>"
+ 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 =~ "<h1>Register</h1>"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "should be at least 12 character"
+ end
+ end
+end
diff --git a/test/kmxgit_web/controllers/user_reset_password_controller_test.exs b/test/kmxgit_web/controllers/user_reset_password_controller_test.exs
new file mode 100644
index 0000000..3e1164a
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_reset_password_controller_test.exs
@@ -0,0 +1,113 @@
+defmodule KmxgitWeb.UserResetPasswordControllerTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ alias Kmxgit.UserManager
+ alias Kmxgit.Repo
+ import Kmxgit.UserManagerFixtures
+
+ 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 =~ "<h1>Forgot your password?</h1>"
+ 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 email is in our system"
+ assert Repo.get_by!(UserManager.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 email is in our system"
+ assert Repo.all(UserManager.UserToken) == []
+ end
+ end
+
+ describe "GET /users/reset_password/:token" do
+ setup %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.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) =~ "<h1>Reset password</h1>"
+ 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 ->
+ UserManager.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) == Routes.user_session_path(conn, :new)
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Password reset successfully"
+ assert UserManager.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 =~ "<h1>Reset password</h1>"
+ 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/kmxgit_web/controllers/user_session_controller_test.exs b/test/kmxgit_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..8028380
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_session_controller_test.exs
@@ -0,0 +1,98 @@
+defmodule KmxgitWeb.UserSessionControllerTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ import Kmxgit.UserManagerFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/log_in" do
+ test "renders log in page", %{conn: conn} do
+ conn = get(conn, Routes.user_session_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Log in</h1>"
+ assert response =~ "Register</a>"
+ assert response =~ "Forgot your password?</a>"
+ end
+
+ test "redirects if already logged in", %{conn: conn, user: user} do
+ conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new))
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "POST /users/log_in" 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</a>"
+ assert response =~ "Log out</a>"
+ 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["_kmxgit_web_user_remember_me"]
+ assert redirected_to(conn) == "/"
+ end
+
+ test "logs the user in with return to", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> init_test_session(user_return_to: "/foo/bar")
+ |> post(Routes.user_session_path(conn, :create), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password()
+ }
+ })
+
+ assert redirected_to(conn) == "/foo/bar"
+ 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 =~ "<h1>Log in</h1>"
+ assert response =~ "Invalid email or password"
+ end
+ end
+
+ describe "DELETE /users/log_out" do
+ test "logs the user out", %{conn: conn, user: user} do
+ conn = conn |> log_in_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/kmxgit_web/controllers/user_settings_controller_test.exs b/test/kmxgit_web/controllers/user_settings_controller_test.exs
new file mode 100644
index 0000000..dbad64f
--- /dev/null
+++ b/test/kmxgit_web/controllers/user_settings_controller_test.exs
@@ -0,0 +1,129 @@
+defmodule KmxgitWeb.UserSettingsControllerTest do
+ use KmxgitWeb.ConnCase, async: true
+
+ alias Kmxgit.UserManager
+ import Kmxgit.UserManagerFixtures
+
+ setup :register_and_log_in_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 =~ "<h1>Settings</h1>"
+ 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) == Routes.user_session_path(conn, :new)
+ end
+ end
+
+ describe "PUT /users/settings (change password form)" 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), %{
+ "action" => "update_password",
+ "current_password" => valid_user_password(),
+ "user" => %{
+ "password" => "new valid password",
+ "password_confirmation" => "new valid password"
+ }
+ })
+
+ assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit)
+ assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
+ assert get_flash(new_password_conn, :info) =~ "Password updated successfully"
+ assert UserManager.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), %{
+ "action" => "update_password",
+ "current_password" => "invalid",
+ "user" => %{
+ "password" => "too short",
+ "password_confirmation" => "does not match"
+ }
+ })
+
+ response = html_response(old_password_conn, 200)
+ assert response =~ "<h1>Settings</h1>"
+ 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 (change email form)" do
+ @tag :capture_log
+ test "updates the user email", %{conn: conn, user: user} do
+ conn =
+ put(conn, Routes.user_settings_path(conn, :update), %{
+ "action" => "update_email",
+ "current_password" => valid_user_password(),
+ "user" => %{"email" => unique_user_email()}
+ })
+
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :info) =~ "A link to confirm your email"
+ assert UserManager.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), %{
+ "action" => "update_email",
+ "current_password" => "invalid",
+ "user" => %{"email" => "with spaces"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Settings</h1>"
+ 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 ->
+ UserManager.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) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :info) =~ "Email changed successfully"
+ refute UserManager.get_user_by_email(user.email)
+ assert UserManager.get_user_by_email(email)
+
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ 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) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
+ assert UserManager.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) == Routes.user_session_path(conn, :new)
+ end
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 864b45f..60af357 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -36,4 +36,30 @@ defmodule KmxgitWeb.ConnCase do
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
+
+ @doc """
+ Setup helper that registers and logs in users.
+
+ setup :register_and_log_in_user
+
+ It stores an updated connection and a registered user in the
+ test context.
+ """
+ def register_and_log_in_user(%{conn: conn}) do
+ user = Kmxgit.UserManagerFixtures.user_fixture()
+ %{conn: log_in_user(conn, user), user: user}
+ end
+
+ @doc """
+ Logs the given `user` into the `conn`.
+
+ It returns an updated `conn`.
+ """
+ def log_in_user(conn, user) do
+ token = Kmxgit.UserManager.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/user_manager_fixtures.ex b/test/support/fixtures/user_manager_fixtures.ex
new file mode 100644
index 0000000..68867e0
--- /dev/null
+++ b/test/support/fixtures/user_manager_fixtures.ex
@@ -0,0 +1,31 @@
+defmodule Kmxgit.UserManagerFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Kmxgit.UserManager` context.
+ """
+
+ def unique_user_email, do: "user#{System.unique_integer()}@example.com"
+ def valid_user_password, do: "hello world!"
+
+ def valid_user_attributes(attrs \\ %{}) do
+ Enum.into(attrs, %{
+ email: unique_user_email(),
+ password: valid_user_password()
+ })
+ end
+
+ def user_fixture(attrs \\ %{}) do
+ {:ok, user} =
+ attrs
+ |> valid_user_attributes()
+ |> Kmxgit.UserManager.register_user()
+
+ user
+ end
+
+ def extract_user_token(fun) do
+ {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
+ [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
+ token
+ end
+end