From 76886c5329e16cb8941b56d4bcb8481892199173 Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Thu, 14 Nov 2019 21:40:35 -0500 Subject: [PATCH] add mailer, password resets, lockouts --- config/config.exs | 6 +- config/dev.exs | 7 + config/prod.secret.exs | 10 ++ lib/philomena/users/user.ex | 20 ++- lib/philomena_web/mailer.ex | 3 + .../plugs/ensure_user_not_locked_plug.ex | 38 ------ lib/philomena_web/pow_mailer.ex | 22 ++++ lib/philomena_web/router.ex | 17 ++- .../templates/pow/registration/new.html.slime | 4 + .../templates/pow/session/new.html.slime | 4 - .../reset_password/edit.html.slime | 16 +++ .../reset_password/new.html.slime | 14 ++ .../pow_reset_password/reset_password_view.ex | 3 + lib/pow_lockout/ecto/context.ex | 59 +++++++++ lib/pow_lockout/ecto/schema.ex | 123 ++++++++++++++++++ .../controllers/controller_callbacks.ex | 100 ++++++++++++++ .../phoenix/controllers/unlock_controller.ex | 22 ++++ lib/pow_lockout/phoenix/mailers/mailer.ex | 11 ++ .../phoenix/mailers/mailer_template.ex | 23 ++++ .../phoenix/mailers/mailer_view.ex | 4 + lib/pow_lockout/phoenix/messages.ex | 13 ++ lib/pow_lockout/phoenix/router.ex | 12 ++ lib/pow_lockout/plug.ex | 85 ++++++++++++ mix.exs | 4 +- mix.lock | 3 + 25 files changed, 564 insertions(+), 59 deletions(-) create mode 100644 lib/philomena_web/mailer.ex delete mode 100644 lib/philomena_web/plugs/ensure_user_not_locked_plug.ex create mode 100644 lib/philomena_web/pow_mailer.ex create mode 100644 lib/philomena_web/templates/pow_reset_password/reset_password/edit.html.slime create mode 100644 lib/philomena_web/templates/pow_reset_password/reset_password/new.html.slime create mode 100644 lib/philomena_web/views/pow_reset_password/reset_password_view.ex create mode 100644 lib/pow_lockout/ecto/context.ex create mode 100644 lib/pow_lockout/ecto/schema.ex create mode 100644 lib/pow_lockout/phoenix/controllers/controller_callbacks.ex create mode 100644 lib/pow_lockout/phoenix/controllers/unlock_controller.ex create mode 100644 lib/pow_lockout/phoenix/mailers/mailer.ex create mode 100644 lib/pow_lockout/phoenix/mailers/mailer_template.ex create mode 100644 lib/pow_lockout/phoenix/mailers/mailer_view.ex create mode 100644 lib/pow_lockout/phoenix/messages.ex create mode 100644 lib/pow_lockout/phoenix/router.ex create mode 100644 lib/pow_lockout/plug.ex diff --git a/config/config.exs b/config/config.exs index 67e779da..de2b591b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,8 +20,9 @@ config :philomena, :pow, user: Philomena.Users.User, repo: Philomena.Repo, web_module: PhilomenaWeb, - extensions: [PowResetPassword, PowPersistentSession], - controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks + extensions: [PowResetPassword, PowLockout, PowPersistentSession], + controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks, + mailer_backend: PhilomenaWeb.PowMailer config :bcrypt_elixir, log_rounds: 12 @@ -54,6 +55,7 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :bamboo, :json_library, Jason # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/config/dev.exs b/config/dev.exs index decee984..bf556820 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -62,6 +62,13 @@ config :philomena, PhilomenaWeb.Endpoint, # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" +# Set up mailer +config :philomena, PhilomenaWeb.Mailer, + adapter: Bamboo.LocalAdapter + +config :philomena, :mailer_address, + "noreply@philomena.lc" + # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. config :phoenix, :stacktrace_depth, 20 diff --git a/config/prod.secret.exs b/config/prod.secret.exs index c49da0ae..b2fb81a8 100644 --- a/config/prod.secret.exs +++ b/config/prod.secret.exs @@ -30,6 +30,16 @@ config :philomena, Philomena.Repo, url: database_url, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") +config :philomena, PhilomenaWeb.Mailer, + adapter: Bamboo.SMTPAdapter, + server: System.get_env("SMTP_RELAY"), + hostname: System.get_env("SMTP_DOMAIN"), + port: System.get_env("SMTP_PORT") || 587, + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + tls: :always, + auth: :always + secret_key_base = System.get_env("SECRET_KEY_BASE") || raise """ diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index c604a3de..80b15478 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -8,7 +8,7 @@ defmodule Philomena.Users.User do password_min_length: 6 use Pow.Extension.Ecto.Schema, - extensions: [PowResetPassword, PowPersistentSession] + extensions: [PowResetPassword, PowLockout, PowPersistentSession] import Ecto.Changeset @@ -37,10 +37,10 @@ defmodule Philomena.Users.User do field :current_sign_in_ip, EctoNetwork.INET field :last_sign_in_ip, EctoNetwork.INET field :otp_required_for_login, :boolean - field :failed_attempts, :integer field :authentication_token, :string - field :unlock_token, :string - field :locked_at, :naive_datetime + # field :failed_attempts, :integer + # field :unlock_token, :string + # field :locked_at, :naive_datetime field :encrypted_otp_secret, :string field :encrypted_otp_secret_iv, :string field :encrypted_otp_secret_salt, :string @@ -117,6 +117,16 @@ defmodule Philomena.Users.User do |> validate_required([]) end + def failure_changeset(user) do + changeset = change(user) + user = changeset.data + + user + |> change(%{ + failed_attempts: user.failed_attempts + 1, + }) + end + def create_totp_secret_changeset(user) do secret = :crypto.strong_rand_bytes(15) |> Base.encode32() data = Philomena.Users.Encryptor.encrypt_model(secret) @@ -233,7 +243,7 @@ defmodule Philomena.Users.User do do: "" defp totp_valid?(user, token), - do: :pot.valid_totp(token, totp_secret(user), window: 60) + do: :pot.valid_totp(token, totp_secret(user), window: 1) defp backup_code_valid?(user, token), do: Enum.any?(user.otp_backup_codes, &Password.verify_pass(token, &1)) diff --git a/lib/philomena_web/mailer.ex b/lib/philomena_web/mailer.ex new file mode 100644 index 00000000..2dcf9a24 --- /dev/null +++ b/lib/philomena_web/mailer.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Mailer do + use Bamboo.Mailer, otp_app: :philomena +end \ No newline at end of file diff --git a/lib/philomena_web/plugs/ensure_user_not_locked_plug.ex b/lib/philomena_web/plugs/ensure_user_not_locked_plug.ex deleted file mode 100644 index 5eab2cfe..00000000 --- a/lib/philomena_web/plugs/ensure_user_not_locked_plug.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule PhilomenaWeb.Plugs.EnsureUserNotLockedPlug do - @moduledoc """ - This plug ensures that a user isn't locked. - - ## Example - - plug PhilomenaWeb.Plugs.EnsureUserNotLockedPlug - """ - alias PhilomenaWeb.Router.Helpers, as: Routes - alias Phoenix.Controller - alias Plug.Conn - alias Pow.Plug - - @doc false - @spec init(any()) :: any() - def init(opts), do: opts - - @doc false - @spec call(Conn.t(), any()) :: Conn.t() - def call(conn, _opts) do - conn - |> Plug.current_user() - |> locked?() - |> maybe_halt(conn) - end - - defp locked?(%{locked_at: locked_at}) when not is_nil(locked_at), do: true - defp locked?(_user), do: false - - defp maybe_halt(true, conn) do - {:ok, conn} = Plug.clear_authenticated_user(conn) - - conn - |> Controller.put_flash(:error, "Sorry, your account is locked.") - |> Controller.redirect(to: Routes.pow_session_path(conn, :new)) - end - defp maybe_halt(_any, conn), do: conn -end \ No newline at end of file diff --git a/lib/philomena_web/pow_mailer.ex b/lib/philomena_web/pow_mailer.ex new file mode 100644 index 00000000..73b40551 --- /dev/null +++ b/lib/philomena_web/pow_mailer.ex @@ -0,0 +1,22 @@ +defmodule PhilomenaWeb.PowMailer do + use Pow.Phoenix.Mailer + alias PhilomenaWeb.Mailer + alias Philomena.Users.User + import Bamboo.Email + + def cast(%{user: %User{email: email}, subject: subject, text: text, html: html, assigns: _assigns}) do + # Build email struct to be used in `process/1` + new_email( + to: email, + from: Application.get_env(:philomena, :mailer_address), + subject: subject, + text_body: text, + html_body: html + ) + end + + def process(email) do + email + |> Mailer.deliver_later() + end +end \ No newline at end of file diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index e342f1a3..2f89b022 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -12,7 +12,6 @@ defmodule PhilomenaWeb.Router do plug PhilomenaWeb.Plugs.ImageFilter plug PhilomenaWeb.Plugs.Pagination plug PhilomenaWeb.Plugs.EnsureUserEnabledPlug - plug PhilomenaWeb.Plugs.EnsureUserNotLockedPlug end pipeline :api do @@ -28,27 +27,27 @@ defmodule PhilomenaWeb.Router do error_handler: Pow.Phoenix.PlugErrorHandler end - pipeline :not_authenticated do - plug Pow.Plug.RequireNotAuthenticated, - error_handler: Pow.Phoenix.PlugErrorHandler - end - scope "/" do pipe_through [:browser, :ensure_totp] - pow_routes() + pow_session_routes() pow_extension_routes() end + scope "/", Pow.Phoenix, as: "pow" do + pipe_through [:browser, :protected, :ensure_totp] + resources "/registration", RegistrationController, singleton: true, only: [:edit, :update] + end + scope "/", PhilomenaWeb do pipe_through [:browser, :protected] # Additional routes for TOTP - scope "/registration", Registration, as: :registration do + scope "/registrations", Registration, as: :registration do resources "/totp", TotpController, only: [:edit, :update], singleton: true end - scope "/session", Session, as: :session do + scope "/sessions", Session, as: :session do resources "/totp", TotpController, only: [:new, :create], singleton: true end end diff --git a/lib/philomena_web/templates/pow/registration/new.html.slime b/lib/philomena_web/templates/pow/registration/new.html.slime index 163576ad..69aa4201 100644 --- a/lib/philomena_web/templates/pow/registration/new.html.slime +++ b/lib/philomena_web/templates/pow/registration/new.html.slime @@ -28,6 +28,10 @@ h1 Register = password_input f, :confirm_password, class: "input", placeholder: "Confirm password", required: true = error_tag f, :confirm_password + .field + = checkbox f, :captcha, class: "js-captcha", value: 0 + = label f, :captcha, "I am not a robot!" + br .block.block--fixed.block--warning diff --git a/lib/philomena_web/templates/pow/session/new.html.slime b/lib/philomena_web/templates/pow/session/new.html.slime index 4b65a30d..24b51147 100644 --- a/lib/philomena_web/templates/pow/session/new.html.slime +++ b/lib/philomena_web/templates/pow/session/new.html.slime @@ -19,10 +19,6 @@ h1 Sign in = checkbox f, :persistent_session = label f, :persistent_session, "Remember me" - .field - = checkbox f, :captcha, class: "js-captcha", value: 0 - = label f, :captcha, "I am not a robot!" - = submit "Sign in", class: "button" p diff --git a/lib/philomena_web/templates/pow_reset_password/reset_password/edit.html.slime b/lib/philomena_web/templates/pow_reset_password/reset_password/edit.html.slime new file mode 100644 index 00000000..172c9aec --- /dev/null +++ b/lib/philomena_web/templates/pow_reset_password/reset_password/edit.html.slime @@ -0,0 +1,16 @@ +h1 Reset password + += form_for @changeset, @action, [as: :user], fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + .field + = password_input f, :password, class: "input", placeholder: "New password" + = error_tag f, :password + + .field + = password_input f, :confirm_password, class: "input", placeholder: "Confirm password" + = error_tag f, :confirm_password + + = submit "Submit", class: "button" \ No newline at end of file diff --git a/lib/philomena_web/templates/pow_reset_password/reset_password/new.html.slime b/lib/philomena_web/templates/pow_reset_password/reset_password/new.html.slime new file mode 100644 index 00000000..72ec8a6c --- /dev/null +++ b/lib/philomena_web/templates/pow_reset_password/reset_password/new.html.slime @@ -0,0 +1,14 @@ +h1 Forgot your password? +p + ' Provide the email address you signed up with and we will email you + ' password reset instructions. + += form_for @changeset, @action, [as: :user], fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + .field + = text_input f, :email, class: "input", placeholder: "Email" + + = submit "Submit", class: "button" \ No newline at end of file diff --git a/lib/philomena_web/views/pow_reset_password/reset_password_view.ex b/lib/philomena_web/views/pow_reset_password/reset_password_view.ex new file mode 100644 index 00000000..56320fcd --- /dev/null +++ b/lib/philomena_web/views/pow_reset_password/reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.PowResetPassword.ResetPasswordView do + use PhilomenaWeb, :view +end diff --git a/lib/pow_lockout/ecto/context.ex b/lib/pow_lockout/ecto/context.ex new file mode 100644 index 00000000..8e77e936 --- /dev/null +++ b/lib/pow_lockout/ecto/context.ex @@ -0,0 +1,59 @@ +defmodule PowLockout.Ecto.Context do + @moduledoc """ + Handles lockout context for user. + """ + alias Pow.{Config, Ecto.Context} + alias PowLockout.Ecto.Schema + + @doc """ + Finds a user by the `:unlock_token` column. + """ + @spec get_by_unlock_token(binary(), Config.t()) :: Context.user() | nil + def get_by_unlock_token(token, config), + do: Context.get_by([unlock_token: token], config) + + @doc """ + Checks if the user is current locked out. + """ + @spec locked_out?(Context.user(), Config.t()) :: boolean() + def locked_out?(%{locked_at: time}, _config) when not is_nil(time), + do: true + def locked_out?(_user, _config), + do: false + + @doc """ + Unlocks the account. + + See `PowLockout.Ecto.Schema.unlock_changeset/1`. + """ + @spec unlock_account(Context.user(), Config.t()) :: {:ok, Context.user()} | {:error, Context.changeset()} + def unlock_account(user, config) do + user + |> Schema.unlock_changeset() + |> Context.do_update(config) + end + + @doc """ + Increases the attempts counter and possibly locks the account. + + See `PowLockout.Ecto.Schema.attempt_changeset/1`. + """ + @spec fail_attempt(Context.user(), Config.t()) :: {:ok, Context.user()} | {:error, Context.changeset()} + def fail_attempt(user, config) do + user + |> Schema.attempt_changeset() + |> Context.do_update(config) + end + + @doc """ + Sets the attempts counter to zero. + + See `PowLockout.Ecto.Schema.attempt_reset_changeset/1`. + """ + @spec succeed_attempt(Context.user(), Config.t()) :: {:ok, Context.user()} | {:error, Context.changeset()} + def succeed_attempt(user, config) do + user + |> Schema.attempt_reset_changeset() + |> Context.do_update(config) + end +end diff --git a/lib/pow_lockout/ecto/schema.ex b/lib/pow_lockout/ecto/schema.ex new file mode 100644 index 00000000..45c881c7 --- /dev/null +++ b/lib/pow_lockout/ecto/schema.ex @@ -0,0 +1,123 @@ +defmodule PowLockout.Ecto.Schema do + @moduledoc """ + Handles the lockout schema for user. + + ## Customize PowLockout fields + + If you need to modify any of the fields that `PowLockout` adds to + the user schema, you can override them by defining them before + `pow_user_fields/0`: + + defmodule MyApp.Users.User do + use Ecto.Schema + use Pow.Ecto.Schema + use Pow.Extension.Ecto.Schema, + extensions: [PowLockout] + + schema "users" do + field :unlock_token, :string + field :locked_at, :utc_datetime + field :failed_attempts, :integer + + pow_user_fields() + + timestamps() + end + end + """ + + use Pow.Extension.Ecto.Schema.Base + alias Ecto.Changeset + alias Pow.UUID + + @doc false + @impl true + def attrs(_config) do + [ + {:unlock_token, :string}, + {:locked_at, :utc_datetime}, + {:failed_attempts, :integer} + ] + end + + @doc false + @impl true + def indexes(_config) do + [{:unlock_token, true}] + end + + @doc """ + Sets the account as unlocked. + + This sets `:locked_at` and `:unlock_token` to nil, and sets + `failed_attempts` to 0. + """ + @spec unlock_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t() + def unlock_changeset(user_or_changeset) do + changes = + [ + locked_at: nil, + unlock_token: nil, + failed_attempts: 0 + ] + + user_or_changeset + |> Changeset.change(changes) + end + + @doc """ + Sets the account as locked. + + This sets `:locked_at` to now and sets `:unlock_token` to a random UUID. + """ + @spec lock_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t() + def lock_changeset(user_or_changeset) do + changeset = Changeset.change(user_or_changeset) + locked_at = Pow.Ecto.Schema.__timestamp_for__(changeset.data.__struct__, :locked_at) + changes = + [ + locked_at: locked_at, + unlock_token: UUID.generate() + ] + + changeset + |> Changeset.change(changes) + end + + @doc """ + Updates the failed attempt count. + + This increments `:failed_attempts` by 1, or sets it to 1 if it is nil. + The first time it becomes greater than 10, it also locks the user. + """ + @spec attempt_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t() + def attempt_changeset(%Changeset{data: %{failed_attempts: attempts}} = changeset) when is_integer(attempts) and attempts < 10 do + Changeset.change(changeset, failed_attempts: attempts + 1) + end + def attempt_changeset(%Changeset{data: %{failed_attempts: attempts, locked_at: nil}} = changeset) when is_integer(attempts) do + lock_changeset(changeset) + end + def attempt_changeset(%Changeset{data: %{failed_attempts: attempts, locked_at: _locked_at}} = changeset) when is_integer(attempts) do + changeset + end + def attempt_changeset(%Changeset{} = changeset) do + Changeset.change(changeset, failed_attempts: 1) + end + + def attempt_changeset(user) do + user + |> Changeset.change() + |> attempt_changeset() + end + + @doc """ + Resets the failed attempt count. + + This sets `:failed_attempts` to 0. + """ + @spec attempt_reset_changeset(Ecto.Schema.t() | Changeset.t()) :: Changeset.t() + def attempt_reset_changeset(user_or_changeset) do + user_or_changeset + |> Changeset.change(failed_attempts: 0) + end +end diff --git a/lib/pow_lockout/phoenix/controllers/controller_callbacks.ex b/lib/pow_lockout/phoenix/controllers/controller_callbacks.ex new file mode 100644 index 00000000..1439cb23 --- /dev/null +++ b/lib/pow_lockout/phoenix/controllers/controller_callbacks.ex @@ -0,0 +1,100 @@ +defmodule PowLockout.Phoenix.ControllerCallbacks do + @moduledoc """ + Controller callback logic for e-mail confirmation. + + ### User is locked out + + Triggers on `Pow.Phoenix.SessionController.create/2`. + + When a user is locked out, the credentials will be treated as if they were + invalid and the user will be redirected back to `Pow.Phoenix.Routes.`. + + ### User successfully authenticates + + Triggers on `Pow.Phoenix.SessionController.create/2`. + + When a user successfully signs in, the failed attempts counter will be + reset to zero. + + ### Users unsuccessfully authenticates + + Triggers on `Pow.Phoenix.SessionController.create/2`. + + When a user unsuccessfully signs in, the failed attempts counter will be + incremented, and the user may be locked out. + + See `PowLockout.Ecto.Schema` for more. + """ + use Pow.Extension.Phoenix.ControllerCallbacks.Base + + alias Plug.Conn + alias Pow.Plug + alias Phoenix.Controller + alias PowLockout.Phoenix.{UnlockController, Mailer} + alias PowLockout.Plug, as: PowLockoutPlug + + @doc false + @impl true + def before_respond(Pow.Phoenix.SessionController, :create, {result, conn}, _config) do + PowLockoutPlug.user_for_attempts(conn) + |> maybe_fail_attempt(conn, result) + end + + defp maybe_fail_attempt(nil, conn, result), + do: {result, conn} + + defp maybe_fail_attempt(%{locked_at: nil} = user, conn, :ok) do + case PowLockoutPlug.succeed_attempt(conn, user) do + {:error, _changeset, conn} -> + {:halt, conn} + + {:ok, _user, conn} -> + {:ok, conn} + end + end + + defp maybe_fail_attempt(_locked_user, conn, :ok) do + {:error, invalid_credentials(conn)} + end + + defp maybe_fail_attempt(user, conn, _error) do + PowLockoutPlug.fail_attempt(conn, user) + |> case do + {:error, _changeset, conn} -> + {:halt, conn} + + {:ok, %{locked_at: nil}, conn} -> + {:error, invalid_credentials(conn)} + + {:ok, user, conn} -> + send_unlock_email(user, conn) + + {:error, invalid_credentials(conn)} + end + end + + defp invalid_credentials(conn) do + {:ok, conn} = + Plug.clear_authenticated_user(conn) + + conn + |> Conn.assign(:changeset, Plug.change_user(conn, conn.params["user"])) + |> Controller.put_flash(:error, messages(conn).invalid_credentials(conn)) + |> Controller.render("new.html") + end + + @doc """ + Sends an unlock e-mail to the user. + """ + @spec send_unlock_email(map(), Conn.t()) :: any() + def send_unlock_email(user, conn) do + url = unlock_url(conn, user.unlock_token) + email = Mailer.email_unlock(conn, user, url) + + Pow.Phoenix.Mailer.deliver(conn, email) + end + + defp unlock_url(conn, token) do + routes(conn).url_for(conn, UnlockController, :show, [token]) + end +end diff --git a/lib/pow_lockout/phoenix/controllers/unlock_controller.ex b/lib/pow_lockout/phoenix/controllers/unlock_controller.ex new file mode 100644 index 00000000..daeeeb0b --- /dev/null +++ b/lib/pow_lockout/phoenix/controllers/unlock_controller.ex @@ -0,0 +1,22 @@ +defmodule PowLockout.Phoenix.UnlockController do + @moduledoc false + use Pow.Extension.Phoenix.Controller.Base + + alias Plug.Conn + alias PowLockout.Plug + + @spec process_show(Conn.t(), map()) :: {:ok | :error, map(), Conn.t()} + def process_show(conn, %{"id" => token}), do: Plug.unlock_account(conn, token) + + @spec respond_show({:ok | :error, map(), Conn.t()}) :: Conn.t() + def respond_show({:ok, _user, conn}) do + conn + |> put_flash(:info, extension_messages(conn).account_has_been_unlocked(conn)) + |> redirect(to: routes(conn).session_path(conn, :new)) + end + def respond_show({:error, _changeset, conn}) do + conn + |> put_flash(:error, extension_messages(conn).account_unlock_failed(conn)) + |> redirect(to: routes(conn).session_path(conn, :new)) + end +end diff --git a/lib/pow_lockout/phoenix/mailers/mailer.ex b/lib/pow_lockout/phoenix/mailers/mailer.ex new file mode 100644 index 00000000..68e04dbf --- /dev/null +++ b/lib/pow_lockout/phoenix/mailers/mailer.ex @@ -0,0 +1,11 @@ +defmodule PowLockout.Phoenix.Mailer do + @moduledoc false + alias Plug.Conn + alias Pow.Phoenix.Mailer.Mail + alias PowLockout.Phoenix.MailerView + + @spec email_unlock(Conn.t(), map(), binary()) :: Mail.t() + def email_unlock(conn, user, url) do + Mail.new(conn, user, {MailerView, :email_unlock}, url: url) + end +end diff --git a/lib/pow_lockout/phoenix/mailers/mailer_template.ex b/lib/pow_lockout/phoenix/mailers/mailer_template.ex new file mode 100644 index 00000000..7d886ba7 --- /dev/null +++ b/lib/pow_lockout/phoenix/mailers/mailer_template.ex @@ -0,0 +1,23 @@ +defmodule PowLockout.Phoenix.MailerTemplate do + @moduledoc false + use Pow.Phoenix.Mailer.Template + + template :email_unlock, + "Unlock your account", + """ + Hi, + + Your account has been automatically disabled due to too many unsuccessful + attempts to sign in. + + Please use the following link to unlock your account: + + <%= @url %> + """, + """ + <%= content_tag(:h3, "Hi,") %> + <%= content_tag(:p, "Your account has been automatically disabled due to too many unsuccessful attempts to sign in.") %> + <%= content_tag(:p, "Please use the following link to unlock your account:") %> + <%= content_tag(:p, link(@url, to: @url)) %> + """ +end diff --git a/lib/pow_lockout/phoenix/mailers/mailer_view.ex b/lib/pow_lockout/phoenix/mailers/mailer_view.ex new file mode 100644 index 00000000..08828ae6 --- /dev/null +++ b/lib/pow_lockout/phoenix/mailers/mailer_view.ex @@ -0,0 +1,4 @@ +defmodule PowLockout.Phoenix.MailerView do + @moduledoc false + use Pow.Phoenix.Mailer.View +end diff --git a/lib/pow_lockout/phoenix/messages.ex b/lib/pow_lockout/phoenix/messages.ex new file mode 100644 index 00000000..32d4c9c1 --- /dev/null +++ b/lib/pow_lockout/phoenix/messages.ex @@ -0,0 +1,13 @@ +defmodule PowLockout.Phoenix.Messages do + @moduledoc false + + @doc """ + Flash message to show when account has been unlocked. + """ + def account_has_been_unlocked(_conn), do: "Account successfully unlocked. You may now log in." + + @doc """ + Flash message to show when account couldn't be unlocked. + """ + def account_unlock_failed(_conn), do: "Account unlock failed." +end diff --git a/lib/pow_lockout/phoenix/router.ex b/lib/pow_lockout/phoenix/router.ex new file mode 100644 index 00000000..9a6b092a --- /dev/null +++ b/lib/pow_lockout/phoenix/router.ex @@ -0,0 +1,12 @@ +defmodule PowLockout.Phoenix.Router do + @moduledoc false + use Pow.Extension.Phoenix.Router.Base + + alias Pow.Phoenix.Router + + defmacro routes(_config) do + quote location: :keep do + Router.pow_resources "/unlock", UnlockController, only: [:show] + end + end +end diff --git a/lib/pow_lockout/plug.ex b/lib/pow_lockout/plug.ex new file mode 100644 index 00000000..590e040c --- /dev/null +++ b/lib/pow_lockout/plug.ex @@ -0,0 +1,85 @@ +defmodule PowLockout.Plug do + @moduledoc """ + Plug helper methods. + """ + alias Plug.Conn + alias Pow.Plug + alias PowLockout.Ecto.Context + require IEx + + @doc """ + Check if the current user is locked out. + """ + @spec locked_out?(Conn.t()) :: boolean() + def locked_out?(conn) do + config = Plug.fetch_config(conn) + + conn + |> Plug.current_user() + |> Context.locked_out?(config) + end + + @doc """ + Get the user belonging to the id field. + """ + @spec user_for_attempts(Conn.t()) :: map() | nil + def user_for_attempts(conn) do + config = Plug.fetch_config(conn) + id_field = Pow.Ecto.Schema.user_id_field(config) + id_value = to_string(conn.params["user"][to_string(id_field)]) + + Pow.Ecto.Context.get_by([{id_field, id_value}], config) + end + + @doc """ + Unlocks the user found by the provided unlock token. + """ + @spec unlock_account(Conn.t(), binary()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} + def unlock_account(conn, token) do + config = Plug.fetch_config(conn) + + token + |> Context.get_by_unlock_token(config) + |> maybe_unlock_account(conn, config) + end + + defp maybe_unlock_account(nil, conn, _config) do + {:error, nil, conn} + end + defp maybe_unlock_account(user, conn, config) do + user + |> Context.unlock_account(config) + |> case do + {:error, changeset} -> {:error, changeset, conn} + {:ok, user} -> {:ok, user, conn} + end + end + + @doc """ + Increments the failed attempts counter and possibly locks the user out. + """ + @spec fail_attempt(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} + def fail_attempt(conn, user) do + config = Plug.fetch_config(conn) + + Context.fail_attempt(user, config) + |> case do + {:error, changeset} -> {:error, changeset, conn} + {:ok, user} -> {:ok, user, conn} + end + end + + @doc """ + Resets the failed attempts counter to 0. + """ + @spec succeed_attempt(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} + def succeed_attempt(conn, user) do + config = Plug.fetch_config(conn) + + Context.succeed_attempt(user, config) + |> case do + {:error, changeset} -> {:error, changeset, conn} + {:ok, user} -> {:ok, user, conn} + end + end +end diff --git a/mix.exs b/mix.exs index daff74db..dfb78a54 100644 --- a/mix.exs +++ b/mix.exs @@ -55,7 +55,9 @@ defmodule Philomena.MixProject do {:scrivener_ecto, "~> 2.0"}, {:pbkdf2, "~> 2.0"}, {:qrcode, "~> 0.1.5"}, - {:redix, "~> 0.10.2"} + {:redix, "~> 0.10.2"}, + {:bamboo, "~> 1.2"}, + {:bamboo_smtp, "~> 1.7"} ] end diff --git a/mix.lock b/mix.lock index 8348e501..9c332120 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,6 @@ %{ + "bamboo": {:hex, :bamboo, "1.3.0", "9ab7c054f1c3435464efcba939396c29c5e1b28f73c34e1f169e0881297a3141", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "bamboo_smtp": {:hex, :bamboo_smtp, "1.7.0", "f0d213e18ced1f08b551a72221e9b8cfbf23d592b684e9aa1ef5250f4943ef9b", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.14.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.0.3", "64e0792d5b5064391927bf3b8e436994cafd18ca2d2b76dea5c76e0adcf66b7c", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, "canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm"}, "canary": {:hex, :canary, "1.1.1", "4138d5e05db8497c477e4af73902eb9ae06e49dceaa13c2dd9f0b55525ded48b", [:mix], [{:canada, "~> 1.0.1", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, @@ -16,6 +18,7 @@ "elixir2fa": {:hex, :elixir2fa, "0.1.0", "4585154695ad13a01c17c46e61b0884b0ce1569ebcc667d1d09cd1cbbb4f4ba8", [:mix], [], "hexpm"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"}, "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"}, + "gen_smtp": {:hex, :gen_smtp, "0.14.0", "39846a03522456077c6429b4badfd1d55e5e7d0fdfb65e935b7c5e38549d9202", [:rebar3], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.6.0", "0a148c836e8e5fbec82c3cea37465a603bd42e314b73a8448ad50020757a00bd", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},