diff --git a/app/AGENTS.md b/app/AGENTS.md
index 6f52c21..de4d711 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -44,6 +44,37 @@ custom classes must fully style the input
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
+
+## Authentication
+
+- **Always** handle authentication flow at the router level with proper redirects
+- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs:
+ - A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
+ - A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
+ - In both cases, a `@current_scope` is assigned to the Plug connection
+ - A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
+- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY**
+- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
+- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
+- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates
+- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below**
+
+### Routes that require authentication
+
+Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
+
+ scope "/", AppWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ get "/", MyControllerThatRequiresAuth, :index
+ end
+
+### Routes that work with or without authentication
+
+Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
+
+
+
diff --git a/app/config/config.exs b/app/config/config.exs
index 93010aa..a32c72b 100644
--- a/app/config/config.exs
+++ b/app/config/config.exs
@@ -7,6 +7,19 @@
# General application configuration
import Config
+config :firehose, :scopes,
+ user: [
+ default: true,
+ module: Firehose.Accounts.Scope,
+ assign_key: :current_scope,
+ access_path: [:user, :id],
+ schema_key: :user_id,
+ schema_type: :id,
+ schema_table: :users,
+ test_data_fixture: Firehose.AccountsFixtures,
+ test_setup_helper: :register_and_log_in_user
+ ]
+
config :firehose,
ecto_repos: [Firehose.Repo],
generators: [timestamp_type: :utc_datetime]
diff --git a/app/config/test.exs b/app/config/test.exs
index ee30aa3..148c651 100644
--- a/app/config/test.exs
+++ b/app/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/app/lib/firehose/accounts.ex b/app/lib/firehose/accounts.ex
new file mode 100644
index 0000000..2850e5f
--- /dev/null
+++ b/app/lib/firehose/accounts.ex
@@ -0,0 +1,297 @@
+defmodule Firehose.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Firehose.Repo
+
+ alias Firehose.Accounts.{User, UserToken, UserNotifier}
+
+ ## Database getters
+
+ @doc """
+ Gets a user by email.
+
+ ## Examples
+
+ iex> get_user_by_email("foo@example.com")
+ %User{}
+
+ iex> get_user_by_email("unknown@example.com")
+ nil
+
+ """
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.get_by(User, email: email)
+ end
+
+ @doc """
+ Gets a user by email and password.
+
+ ## Examples
+
+ iex> get_user_by_email_and_password("foo@example.com", "correct_password")
+ %User{}
+
+ iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
+ nil
+
+ """
+ def get_user_by_email_and_password(email, password)
+ when is_binary(email) and is_binary(password) do
+ user = Repo.get_by(User, email: email)
+ if User.valid_password?(user, password), do: user
+ end
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ ## User registration
+
+ @doc """
+ Registers a user.
+
+ ## Examples
+
+ iex> register_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> register_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def register_user(attrs) do
+ %User{}
+ |> User.email_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ ## Settings
+
+ @doc """
+ Checks whether the user is in sudo mode.
+
+ The user is in sudo mode when the last authentication was done no further
+ than 20 minutes ago. The limit can be given as second argument in minutes.
+ """
+ def sudo_mode?(user, minutes \\ -20)
+
+ def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
+ DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
+ end
+
+ def sudo_mode?(_user, _minutes), do: false
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user email.
+
+ See `Firehose.Accounts.User.email_changeset/3` for a list of supported options.
+
+ ## Examples
+
+ iex> change_user_email(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_email(user, attrs \\ %{}, opts \\ []) do
+ User.email_changeset(user, attrs, opts)
+ end
+
+ @doc """
+ Updates the user email using the given token.
+
+ If the token matches, the user email is updated and the token is deleted.
+ """
+ def update_user_email(user, token) do
+ context = "change:#{user.email}"
+
+ Repo.transact(fn ->
+ with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
+ %UserToken{sent_to: email} <- Repo.one(query),
+ {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
+ {_count, _result} <-
+ Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
+ {:ok, user}
+ else
+ _ -> {:error, :transaction_aborted}
+ end
+ end)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user password.
+
+ See `Firehose.Accounts.User.password_changeset/3` for a list of supported options.
+
+ ## Examples
+
+ iex> change_user_password(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_password(user, attrs \\ %{}, opts \\ []) do
+ User.password_changeset(user, attrs, opts)
+ end
+
+ @doc """
+ Updates the user password.
+
+ Returns a tuple with the updated user, as well as a list of expired tokens.
+
+ ## Examples
+
+ iex> update_user_password(user, %{password: ...})
+ {:ok, {%User{}, [...]}}
+
+ iex> update_user_password(user, %{password: "too short"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_password(user, attrs) do
+ user
+ |> User.password_changeset(attrs)
+ |> update_user_and_delete_all_tokens()
+ end
+
+ ## Session
+
+ @doc """
+ Generates a session token.
+ """
+ def generate_user_session_token(user) do
+ {token, user_token} = UserToken.build_session_token(user)
+ Repo.insert!(user_token)
+ token
+ end
+
+ @doc """
+ Gets the user with the given signed token.
+
+ If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
+ """
+ def get_user_by_session_token(token) do
+ {:ok, query} = UserToken.verify_session_token_query(token)
+ Repo.one(query)
+ end
+
+ @doc """
+ Gets the user with the given magic link token.
+ """
+ def get_user_by_magic_link_token(token) do
+ with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
+ {user, _token} <- Repo.one(query) do
+ user
+ else
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Logs the user in by magic link.
+
+ There are three cases to consider:
+
+ 1. The user has already confirmed their email. They are logged in
+ and the magic link is expired.
+
+ 2. The user has not confirmed their email and no password is set.
+ In this case, the user gets confirmed, logged in, and all tokens -
+ including session ones - are expired. In theory, no other tokens
+ exist but we delete all of them for best security practices.
+
+ 3. The user has not confirmed their email but a password is set.
+ This cannot happen in the default implementation but may be the
+ source of security pitfalls. See the "Mixing magic link and password registration" section of
+ `mix help phx.gen.auth`.
+ """
+ def login_user_by_magic_link(token) do
+ {:ok, query} = UserToken.verify_magic_link_token_query(token)
+
+ case Repo.one(query) do
+ # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
+ {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
+ raise """
+ magic link log in is not allowed for unconfirmed users with a password set!
+
+ This cannot happen with the default implementation, which indicates that you
+ might have adapted the code to a different use case. Please make sure to read the
+ "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
+ """
+
+ {%User{confirmed_at: nil} = user, _token} ->
+ user
+ |> User.confirm_changeset()
+ |> update_user_and_delete_all_tokens()
+
+ {user, token} ->
+ Repo.delete!(token)
+ {:ok, {user, []}}
+
+ nil ->
+ {:error, :not_found}
+ end
+ end
+
+ @doc ~S"""
+ Delivers the update email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
+ when is_function(update_email_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
+
+ Repo.insert!(user_token)
+ UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Delivers the magic link login instructions to the given user.
+ """
+ def deliver_login_instructions(%User{} = user, magic_link_url_fun)
+ when is_function(magic_link_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "login")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Deletes the signed token with the given context.
+ """
+ def delete_user_session_token(token) do
+ Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
+ :ok
+ end
+
+ ## Token helper
+
+ defp update_user_and_delete_all_tokens(changeset) do
+ Repo.transact(fn ->
+ with {:ok, user} <- Repo.update(changeset) do
+ tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
+
+ Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
+
+ {:ok, {user, tokens_to_expire}}
+ end
+ end)
+ end
+end
diff --git a/app/lib/firehose/accounts/scope.ex b/app/lib/firehose/accounts/scope.ex
new file mode 100644
index 0000000..7a560f0
--- /dev/null
+++ b/app/lib/firehose/accounts/scope.ex
@@ -0,0 +1,33 @@
+defmodule Firehose.Accounts.Scope do
+ @moduledoc """
+ Defines the scope of the caller to be used throughout the app.
+
+ The `Firehose.Accounts.Scope` allows public interfaces to receive
+ information about the caller, such as if the call is initiated from an
+ end-user, and if so, which user. Additionally, such a scope can carry fields
+ such as "super user" or other privileges for use as authorization, or to
+ ensure specific code paths can only be access for a given scope.
+
+ It is useful for logging as well as for scoping pubsub subscriptions and
+ broadcasts when a caller subscribes to an interface or performs a particular
+ action.
+
+ Feel free to extend the fields on this struct to fit the needs of
+ growing application requirements.
+ """
+
+ alias Firehose.Accounts.User
+
+ defstruct user: nil
+
+ @doc """
+ Creates a scope for the given user.
+
+ Returns nil if no user is given.
+ """
+ def for_user(%User{} = user) do
+ %__MODULE__{user: user}
+ end
+
+ def for_user(nil), do: nil
+end
diff --git a/app/lib/firehose/accounts/user.ex b/app/lib/firehose/accounts/user.ex
new file mode 100644
index 0000000..458f208
--- /dev/null
+++ b/app/lib/firehose/accounts/user.ex
@@ -0,0 +1,132 @@
+defmodule Firehose.Accounts.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "users" do
+ field :email, :string
+ field :password, :string, virtual: true, redact: true
+ field :hashed_password, :string, redact: true
+ field :confirmed_at, :utc_datetime
+ field :authenticated_at, :utc_datetime, virtual: true
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc """
+ A user changeset for registering or changing the email.
+
+ It requires the email to change otherwise an error is added.
+
+ ## Options
+
+ * `:validate_unique` - Set to false if you don't want to validate the
+ uniqueness of the email, useful when displaying live validations.
+ Defaults to `true`.
+ """
+ def email_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:email])
+ |> validate_email(opts)
+ end
+
+ defp validate_email(changeset, opts) do
+ changeset =
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
+ message: "must have the @ sign and no spaces"
+ )
+ |> validate_length(:email, max: 160)
+
+ if Keyword.get(opts, :validate_unique, true) do
+ changeset
+ |> unsafe_validate_unique(:email, Firehose.Repo)
+ |> unique_constraint(:email)
+ |> validate_email_changed()
+ else
+ changeset
+ end
+ end
+
+ defp validate_email_changed(changeset) do
+ if get_field(changeset, :email) && get_change(changeset, :email) == nil do
+ add_error(changeset, :email, "did not change")
+ else
+ changeset
+ end
+ end
+
+ @doc """
+ A user changeset for changing the password.
+
+ It is important to validate the length of the password, as long passwords may
+ 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 password_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password(opts)
+ end
+
+ defp validate_password(changeset, opts) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12, max: 72)
+ # Examples of additional password validation:
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+ |> maybe_hash_password(opts)
+ end
+
+ defp maybe_hash_password(changeset, opts) do
+ hash_password? = Keyword.get(opts, :hash_password, true)
+ password = get_change(changeset, :password)
+
+ if hash_password? && password && changeset.valid? do
+ changeset
+ # If using Bcrypt, then further validate it is at most 72 bytes long
+ |> validate_length(:password, max: 72, count: :bytes)
+ # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
+ # would keep the database transaction open longer and hurt performance.
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
+ else
+ changeset
+ end
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = DateTime.utc_now(:second)
+ change(user, confirmed_at: now)
+ end
+
+ @doc """
+ Verifies the password.
+
+ If there is no user or the user doesn't have a password, we call
+ `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Firehose.Accounts.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
+ end
+end
diff --git a/app/lib/firehose/accounts/user_notifier.ex b/app/lib/firehose/accounts/user_notifier.ex
new file mode 100644
index 0000000..c6d2c59
--- /dev/null
+++ b/app/lib/firehose/accounts/user_notifier.ex
@@ -0,0 +1,84 @@
+defmodule Firehose.Accounts.UserNotifier do
+ import Swoosh.Email
+
+ alias Firehose.Mailer
+ alias Firehose.Accounts.User
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"Firehose", "contact@example.com"})
+ |> subject(subject)
+ |> text_body(body)
+
+ with {:ok, _metadata} <- Mailer.deliver(email) do
+ {:ok, email}
+ end
+ 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
+
+ @doc """
+ Deliver instructions to log in with a magic link.
+ """
+ def deliver_login_instructions(user, url) do
+ case user do
+ %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
+ _ -> deliver_magic_link_instructions(user, url)
+ end
+ end
+
+ defp deliver_magic_link_instructions(user, url) do
+ deliver(user.email, "Log in instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can log into your account by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this email, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ defp 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
+end
diff --git a/app/lib/firehose/accounts/user_token.ex b/app/lib/firehose/accounts/user_token.ex
new file mode 100644
index 0000000..e95f5a5
--- /dev/null
+++ b/app/lib/firehose/accounts/user_token.ex
@@ -0,0 +1,156 @@
+defmodule Firehose.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+ alias Firehose.Accounts.UserToken
+
+ @hash_algorithm :sha256
+ @rand_size 32
+
+ # It is very important to keep the magic link token expiry short,
+ # since someone with access to the email may take over the account.
+ @magic_link_validity_in_minutes 15
+ @change_email_validity_in_days 7
+ @session_validity_in_days 14
+
+ schema "users_tokens" do
+ field :token, :binary
+ field :context, :string
+ field :sent_to, :string
+ field :authenticated_at, :utc_datetime
+ belongs_to :user, Firehose.Accounts.User
+
+ timestamps(type: :utc_datetime, 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's 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)
+ dt = user.authenticated_at || DateTime.utc_now(:second)
+ {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
+ 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, along with the token's creation time.
+
+ 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 by_token_and_context_query(token, "session"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
+
+ {:ok, query}
+ end
+
+ @doc """
+ Builds a token and its hash to be delivered to the user's email.
+
+ The non-hashed token is sent to the user email while the
+ hashed part is stored in the database. The original token cannot be reconstructed,
+ which means anyone with read-only access to the database cannot directly use
+ the token in the application to gain access. Furthermore, if the user changes
+ their email in the system, the tokens sent to the previous email are no longer
+ valid.
+
+ Users can easily adapt the existing code to provide other types of delivery methods,
+ for example, by phone numbers.
+ """
+ def build_email_token(user, context) do
+ build_hashed_token(user, context, user.email)
+ end
+
+ defp build_hashed_token(user, context, sent_to) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ hashed_token = :crypto.hash(@hash_algorithm, token)
+
+ {Base.url_encode64(token, padding: false),
+ %UserToken{
+ token: hashed_token,
+ context: context,
+ sent_to: sent_to,
+ user_id: user.id
+ }}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ If found, the query returns a tuple of the form `{user, token}`.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database. This function also checks whether the token has expired. The context
+ of a magic link token is always "login".
+ """
+ def verify_magic_link_token_query(token) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+
+ query =
+ from token in by_token_and_context_query(hashed_token, "login"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
+ where: token.sent_to == user.email,
+ select: {user, token}
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user_token found by the token, if any.
+
+ This is used to validate requests to change the user
+ email.
+ 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 by_token_and_context_query(hashed_token, context),
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp by_token_and_context_query(token, context) do
+ from UserToken, where: [token: ^token, context: ^context]
+ end
+end
diff --git a/app/lib/firehose_web/components/layouts.ex b/app/lib/firehose_web/components/layouts.ex
index 727f358..6e33e2c 100644
--- a/app/lib/firehose_web/components/layouts.ex
+++ b/app/lib/firehose_web/components/layouts.ex
@@ -9,6 +9,7 @@ defmodule FirehoseWeb.Layouts do
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
+
embed_templates "layouts/*"
@doc """
diff --git a/app/lib/firehose_web/controllers/user_registration_controller.ex b/app/lib/firehose_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..3c1ed89
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_registration_controller.ex
@@ -0,0 +1,32 @@
+defmodule FirehoseWeb.UserRegistrationController do
+ use FirehoseWeb, :controller
+
+ alias Firehose.Accounts
+ alias Firehose.Accounts.User
+
+ def new(conn, _params) do
+ changeset = Accounts.change_user_email(%User{})
+ render(conn, :new, changeset: changeset)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ case Accounts.register_user(user_params) do
+ {:ok, user} ->
+ {:ok, _} =
+ Accounts.deliver_login_instructions(
+ user,
+ &url(~p"/users/log-in/#{&1}")
+ )
+
+ conn
+ |> put_flash(
+ :info,
+ "An email was sent to #{user.email}, please access it to confirm your account."
+ )
+ |> redirect(to: ~p"/users/log-in")
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, :new, changeset: changeset)
+ end
+ end
+end
diff --git a/app/lib/firehose_web/controllers/user_registration_html.ex b/app/lib/firehose_web/controllers/user_registration_html.ex
new file mode 100644
index 0000000..4835923
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_registration_html.ex
@@ -0,0 +1,5 @@
+defmodule FirehoseWeb.UserRegistrationHTML do
+ use FirehoseWeb, :html
+
+ embed_templates "user_registration_html/*"
+end
diff --git a/app/lib/firehose_web/controllers/user_registration_html/new.html.heex b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex
new file mode 100644
index 0000000..d6444a1
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex
@@ -0,0 +1,30 @@
+
+
+ <.header>
+ Register for an account
+ <:subtitle>
+ Already registered?
+ <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
+ Log in
+
+ to your account now.
+
+
+
diff --git a/app/lib/firehose_web/controllers/user_session_controller.ex b/app/lib/firehose_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..acb48e3
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_session_controller.ex
@@ -0,0 +1,88 @@
+defmodule FirehoseWeb.UserSessionController do
+ use FirehoseWeb, :controller
+
+ alias Firehose.Accounts
+ alias FirehoseWeb.UserAuth
+
+ def new(conn, _params) do
+ email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
+ form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
+
+ render(conn, :new, form: form)
+ end
+
+ # magic link login
+ def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
+ info =
+ case params do
+ %{"_action" => "confirmed"} -> "User confirmed successfully."
+ _ -> "Welcome back!"
+ end
+
+ case Accounts.login_user_by_magic_link(token) do
+ {:ok, {user, _expired_tokens}} ->
+ conn
+ |> put_flash(:info, info)
+ |> UserAuth.log_in_user(user, user_params)
+
+ {:error, :not_found} ->
+ conn
+ |> put_flash(:error, "The link is invalid or it has expired.")
+ |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
+ end
+ end
+
+ # email + password login
+ def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
+ if user = Accounts.get_user_by_email_and_password(email, password) do
+ conn
+ |> put_flash(:info, "Welcome back!")
+ |> UserAuth.log_in_user(user, user_params)
+ else
+ form = Phoenix.Component.to_form(user_params, as: "user")
+
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
+ conn
+ |> put_flash(:error, "Invalid email or password")
+ |> render(:new, form: form)
+ end
+ end
+
+ # magic link request
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_login_instructions(
+ user,
+ &url(~p"/users/log-in/#{&1}")
+ )
+ end
+
+ info =
+ "If your email is in our system, you will receive instructions for logging in shortly."
+
+ conn
+ |> put_flash(:info, info)
+ |> redirect(to: ~p"/users/log-in")
+ end
+
+ def confirm(conn, %{"token" => token}) do
+ if user = Accounts.get_user_by_magic_link_token(token) do
+ form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
+
+ conn
+ |> assign(:user, user)
+ |> assign(:form, form)
+ |> render(:confirm)
+ else
+ conn
+ |> put_flash(:error, "Magic link is invalid or it has expired.")
+ |> redirect(to: ~p"/users/log-in")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.log_out_user()
+ end
+end
diff --git a/app/lib/firehose_web/controllers/user_session_html.ex b/app/lib/firehose_web/controllers/user_session_html.ex
new file mode 100644
index 0000000..9668513
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_session_html.ex
@@ -0,0 +1,9 @@
+defmodule FirehoseWeb.UserSessionHTML do
+ use FirehoseWeb, :html
+
+ embed_templates "user_session_html/*"
+
+ defp local_mail_adapter? do
+ Application.get_env(:firehose, Firehose.Mailer)[:adapter] == Swoosh.Adapters.Local
+ end
+end
diff --git a/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex
new file mode 100644
index 0000000..7597e79
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex
@@ -0,0 +1,57 @@
+
+
+ <.header>Welcome {@user.email}
+
+
+ <.form
+ :if={!@user.confirmed_at}
+ for={@form}
+ id="confirmation_form"
+ action={~p"/users/log-in?_action=confirmed"}
+ phx-mounted={JS.focus_first()}
+ >
+
+ <.button
+ name={@form[:remember_me].name}
+ value="true"
+ phx-disable-with="Confirming..."
+ class="btn btn-primary w-full"
+ >
+ Confirm and stay logged in
+
+ <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
+ Confirm and log in only this time
+
+
+
+ <.form
+ :if={@user.confirmed_at}
+ for={@form}
+ id="login_form"
+ action={~p"/users/log-in"}
+ phx-mounted={JS.focus_first()}
+ >
+
+ <%= if @current_scope do %>
+ <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
+ Log in
+
+ <% else %>
+ <.button
+ name={@form[:remember_me].name}
+ value="true"
+ phx-disable-with="Logging in..."
+ class="btn btn-primary w-full"
+ >
+ Keep me logged in on this device
+
+ <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
+ Log me in only this time
+
+ <% end %>
+
+
+
+ Tip: If you prefer passwords, you can enable them in the user settings.
+
+
diff --git a/app/lib/firehose_web/controllers/user_session_html/new.html.heex b/app/lib/firehose_web/controllers/user_session_html/new.html.heex
new file mode 100644
index 0000000..92ce5cd
--- /dev/null
+++ b/app/lib/firehose_web/controllers/user_session_html/new.html.heex
@@ -0,0 +1,71 @@
+
+
+ <.header>
+
Log in
+ <:subtitle>
+ <%= if @current_scope do %>
+ You need to reauthenticate to perform sensitive actions on your account.
+ <% else %>
+ Don't have an account? <.link
+ navigate={~p"/users/register"}
+ class="font-semibold text-brand hover:underline"
+ phx-no-format
+ >Sign up for an account now.
+ <% end %>
+
+
+