diff --git a/config/config.exs b/config/config.exs index b8e89a2c..74cf2dc2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -56,6 +56,19 @@ config :microwaveprop, Oban, ]} ] +config :microwaveprop, :scopes, + user: [ + default: true, + module: Microwaveprop.Accounts.Scope, + assign_key: :current_scope, + access_path: [:user, :id], + schema_key: :user_id, + schema_type: :binary_id, + schema_table: :users, + test_data_fixture: Microwaveprop.AccountsFixtures, + test_setup_helper: :register_and_log_in_user + ] + config :microwaveprop, ecto_repos: [Microwaveprop.Repo], generators: [timestamp_type: :utc_datetime, binary_id: true] diff --git a/config/test.exs b/config/test.exs index 9c6adb7f..cb49c0c2 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 + # Print only warnings and errors during test config :logger, level: :warning diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex new file mode 100644 index 00000000..346ebeed --- /dev/null +++ b/lib/microwaveprop/accounts.ex @@ -0,0 +1,272 @@ +defmodule Microwaveprop.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Accounts.UserNotifier + alias Microwaveprop.Accounts.UserToken + alias Microwaveprop.Repo + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user registration changes. + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_unique: false) + 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.add(DateTime.utc_now(), minutes, :minute)) + end + + def sudo_mode?(_user, _minutes), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + See `Microwaveprop.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 `Microwaveprop.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 + + ## Confirmation + + @doc ~S""" + Delivers the account confirmation instructions to the given user. + + Returns `{:error, :already_confirmed}` if the user is already confirmed. + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + On success, the confirmation token (and all other tokens tied to this user) + are deleted to prevent reuse. + """ + def confirm_user_by_token(token) do + with {:ok, query} <- UserToken.verify_confirm_token_query(token), + {%User{} = user, _token} <- Repo.one(query) do + user + |> User.confirm_changeset() + |> update_user_and_delete_all_tokens() + else + _ -> {: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 """ + 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/lib/microwaveprop/accounts/scope.ex b/lib/microwaveprop/accounts/scope.ex new file mode 100644 index 00000000..f73da130 --- /dev/null +++ b/lib/microwaveprop/accounts/scope.ex @@ -0,0 +1,33 @@ +defmodule Microwaveprop.Accounts.Scope do + @moduledoc """ + Defines the scope of the caller to be used throughout the app. + + The `Microwaveprop.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 Microwaveprop.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/lib/microwaveprop/accounts/user.ex b/lib/microwaveprop/accounts/user.ex new file mode 100644 index 00000000..e8fa9573 --- /dev/null +++ b/lib/microwaveprop/accounts/user.ex @@ -0,0 +1,182 @@ +defmodule Microwaveprop.Accounts.User do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "users" do + field :callsign, :string + field :name, :string + 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 a new user. + + Requires callsign, name, email, and password with confirmation. + Sets `confirmed_at` so the account is usable immediately after + password-based registration (there is no magic link confirmation flow). + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely. + Defaults to `true`. Set to `false` for form validations in LiveView. + * `:validate_unique` - Set to false to skip uniqueness validation. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:callsign, :name, :email, :password]) + |> validate_callsign(opts) + |> validate_name() + |> validate_email(opts) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + defp validate_callsign(changeset, opts) do + changeset = + changeset + |> update_change(:callsign, &String.upcase(&1 || "")) + |> validate_required([:callsign]) + |> validate_format(:callsign, ~r/^[A-Z0-9]{3,10}$/, message: "must be 3-10 letters and digits") + + if Keyword.get(opts, :validate_unique, true) do + changeset + |> unsafe_validate_unique(:callsign, Microwaveprop.Repo) + |> unique_constraint(:callsign) + else + changeset + end + end + + defp validate_name(changeset) do + changeset + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 100) + end + + @doc """ + A user changeset for 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) + |> validate_email_changed() + 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, Microwaveprop.Repo) + |> unique_constraint(:email) + 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?(%Microwaveprop.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/lib/microwaveprop/accounts/user_notifier.ex b/lib/microwaveprop/accounts/user_notifier.ex new file mode 100644 index 00000000..06ae9476 --- /dev/null +++ b/lib/microwaveprop/accounts/user_notifier.ex @@ -0,0 +1,72 @@ +defmodule Microwaveprop.Accounts.UserNotifier do + @moduledoc false + import Swoosh.Email + + alias Microwaveprop.Mailer + + @default_from {"NTMS Propagation", "noreply@mcintire.me"} + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from(from_address()) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + defp from_address do + case System.get_env("EMAIL_FROM") do + nil -> @default_from + "" -> @default_from + addr -> {"NTMS Propagation", addr} + end + end + + @doc """ + Deliver instructions to confirm a newly registered account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirm your NTMS Propagation account", """ + + ============================== + + Hi #{user.callsign}, + + Welcome to the NTMS Propagation tool. To finish creating your + account, please confirm your email by visiting the URL below: + + #{url} + + This link expires in 24 hours. If you didn't create this account, + please ignore this message. + + ============================== + """) + 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/microwaveprop/accounts/user_token.ex b/lib/microwaveprop/accounts/user_token.ex new file mode 100644 index 00000000..b7c1de93 --- /dev/null +++ b/lib/microwaveprop/accounts/user_token.ex @@ -0,0 +1,187 @@ +defmodule Microwaveprop.Accounts.UserToken do + @moduledoc false + use Ecto.Schema + + import Ecto.Query + + alias Microwaveprop.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 + @confirm_validity_in_days 1 + @session_validity_in_days 14 + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + field :authenticated_at, :utc_datetime + belongs_to :user, Microwaveprop.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. + + Used to confirm a newly registered account. The context is always + "confirm" and the token is valid for @confirm_validity_in_days days. + """ + def verify_confirm_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, "confirm"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^@confirm_validity_in_days, "day"), + 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/lib/microwaveprop_web/components/layouts/root.html.heex b/lib/microwaveprop_web/components/layouts/root.html.heex index 9e582bdb..df5b44c4 100644 --- a/lib/microwaveprop_web/components/layouts/root.html.heex +++ b/lib/microwaveprop_web/components/layouts/root.html.heex @@ -37,6 +37,26 @@
+ {@inner_content}