mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
add mailer, password resets, lockouts
This commit is contained in:
parent
31d637cca0
commit
76886c5329
25 changed files with 564 additions and 59 deletions
|
@ -20,8 +20,9 @@ config :philomena, :pow,
|
||||||
user: Philomena.Users.User,
|
user: Philomena.Users.User,
|
||||||
repo: Philomena.Repo,
|
repo: Philomena.Repo,
|
||||||
web_module: PhilomenaWeb,
|
web_module: PhilomenaWeb,
|
||||||
extensions: [PowResetPassword, PowPersistentSession],
|
extensions: [PowResetPassword, PowLockout, PowPersistentSession],
|
||||||
controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks
|
controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks,
|
||||||
|
mailer_backend: PhilomenaWeb.PowMailer
|
||||||
|
|
||||||
config :bcrypt_elixir,
|
config :bcrypt_elixir,
|
||||||
log_rounds: 12
|
log_rounds: 12
|
||||||
|
@ -54,6 +55,7 @@ config :logger, :console,
|
||||||
|
|
||||||
# Use Jason for JSON parsing in Phoenix
|
# Use Jason for JSON parsing in Phoenix
|
||||||
config :phoenix, :json_library, Jason
|
config :phoenix, :json_library, Jason
|
||||||
|
config :bamboo, :json_library, Jason
|
||||||
|
|
||||||
# Import environment specific config. This must remain at the bottom
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
|
|
|
@ -62,6 +62,13 @@ config :philomena, PhilomenaWeb.Endpoint,
|
||||||
# Do not include metadata nor timestamps in development logs
|
# Do not include metadata nor timestamps in development logs
|
||||||
config :logger, :console, format: "[$level] $message\n"
|
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
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
# in production as building large stacktraces may be expensive.
|
# in production as building large stacktraces may be expensive.
|
||||||
config :phoenix, :stacktrace_depth, 20
|
config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
|
@ -30,6 +30,16 @@ config :philomena, Philomena.Repo,
|
||||||
url: database_url,
|
url: database_url,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
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 =
|
secret_key_base =
|
||||||
System.get_env("SECRET_KEY_BASE") ||
|
System.get_env("SECRET_KEY_BASE") ||
|
||||||
raise """
|
raise """
|
||||||
|
|
|
@ -8,7 +8,7 @@ defmodule Philomena.Users.User do
|
||||||
password_min_length: 6
|
password_min_length: 6
|
||||||
|
|
||||||
use Pow.Extension.Ecto.Schema,
|
use Pow.Extension.Ecto.Schema,
|
||||||
extensions: [PowResetPassword, PowPersistentSession]
|
extensions: [PowResetPassword, PowLockout, PowPersistentSession]
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@ -37,10 +37,10 @@ defmodule Philomena.Users.User do
|
||||||
field :current_sign_in_ip, EctoNetwork.INET
|
field :current_sign_in_ip, EctoNetwork.INET
|
||||||
field :last_sign_in_ip, EctoNetwork.INET
|
field :last_sign_in_ip, EctoNetwork.INET
|
||||||
field :otp_required_for_login, :boolean
|
field :otp_required_for_login, :boolean
|
||||||
field :failed_attempts, :integer
|
|
||||||
field :authentication_token, :string
|
field :authentication_token, :string
|
||||||
field :unlock_token, :string
|
# field :failed_attempts, :integer
|
||||||
field :locked_at, :naive_datetime
|
# field :unlock_token, :string
|
||||||
|
# field :locked_at, :naive_datetime
|
||||||
field :encrypted_otp_secret, :string
|
field :encrypted_otp_secret, :string
|
||||||
field :encrypted_otp_secret_iv, :string
|
field :encrypted_otp_secret_iv, :string
|
||||||
field :encrypted_otp_secret_salt, :string
|
field :encrypted_otp_secret_salt, :string
|
||||||
|
@ -117,6 +117,16 @@ defmodule Philomena.Users.User do
|
||||||
|> validate_required([])
|
|> validate_required([])
|
||||||
end
|
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
|
def create_totp_secret_changeset(user) do
|
||||||
secret = :crypto.strong_rand_bytes(15) |> Base.encode32()
|
secret = :crypto.strong_rand_bytes(15) |> Base.encode32()
|
||||||
data = Philomena.Users.Encryptor.encrypt_model(secret)
|
data = Philomena.Users.Encryptor.encrypt_model(secret)
|
||||||
|
@ -233,7 +243,7 @@ defmodule Philomena.Users.User do
|
||||||
do: ""
|
do: ""
|
||||||
|
|
||||||
defp totp_valid?(user, token),
|
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),
|
defp backup_code_valid?(user, token),
|
||||||
do: Enum.any?(user.otp_backup_codes, &Password.verify_pass(token, &1))
|
do: Enum.any?(user.otp_backup_codes, &Password.verify_pass(token, &1))
|
||||||
|
|
3
lib/philomena_web/mailer.ex
Normal file
3
lib/philomena_web/mailer.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule PhilomenaWeb.Mailer do
|
||||||
|
use Bamboo.Mailer, otp_app: :philomena
|
||||||
|
end
|
|
@ -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
|
|
22
lib/philomena_web/pow_mailer.ex
Normal file
22
lib/philomena_web/pow_mailer.ex
Normal file
|
@ -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
|
|
@ -12,7 +12,6 @@ defmodule PhilomenaWeb.Router do
|
||||||
plug PhilomenaWeb.Plugs.ImageFilter
|
plug PhilomenaWeb.Plugs.ImageFilter
|
||||||
plug PhilomenaWeb.Plugs.Pagination
|
plug PhilomenaWeb.Plugs.Pagination
|
||||||
plug PhilomenaWeb.Plugs.EnsureUserEnabledPlug
|
plug PhilomenaWeb.Plugs.EnsureUserEnabledPlug
|
||||||
plug PhilomenaWeb.Plugs.EnsureUserNotLockedPlug
|
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
@ -28,27 +27,27 @@ defmodule PhilomenaWeb.Router do
|
||||||
error_handler: Pow.Phoenix.PlugErrorHandler
|
error_handler: Pow.Phoenix.PlugErrorHandler
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :not_authenticated do
|
|
||||||
plug Pow.Plug.RequireNotAuthenticated,
|
|
||||||
error_handler: Pow.Phoenix.PlugErrorHandler
|
|
||||||
end
|
|
||||||
|
|
||||||
scope "/" do
|
scope "/" do
|
||||||
pipe_through [:browser, :ensure_totp]
|
pipe_through [:browser, :ensure_totp]
|
||||||
|
|
||||||
pow_routes()
|
pow_session_routes()
|
||||||
pow_extension_routes()
|
pow_extension_routes()
|
||||||
end
|
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
|
scope "/", PhilomenaWeb do
|
||||||
pipe_through [:browser, :protected]
|
pipe_through [:browser, :protected]
|
||||||
|
|
||||||
# Additional routes for TOTP
|
# Additional routes for TOTP
|
||||||
scope "/registration", Registration, as: :registration do
|
scope "/registrations", Registration, as: :registration do
|
||||||
resources "/totp", TotpController, only: [:edit, :update], singleton: true
|
resources "/totp", TotpController, only: [:edit, :update], singleton: true
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/session", Session, as: :session do
|
scope "/sessions", Session, as: :session do
|
||||||
resources "/totp", TotpController, only: [:new, :create], singleton: true
|
resources "/totp", TotpController, only: [:new, :create], singleton: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,6 +28,10 @@ h1 Register
|
||||||
= password_input f, :confirm_password, class: "input", placeholder: "Confirm password", required: true
|
= password_input f, :confirm_password, class: "input", placeholder: "Confirm password", required: true
|
||||||
= error_tag f, :confirm_password
|
= error_tag f, :confirm_password
|
||||||
|
|
||||||
|
.field
|
||||||
|
= checkbox f, :captcha, class: "js-captcha", value: 0
|
||||||
|
= label f, :captcha, "I am not a robot!"
|
||||||
|
|
||||||
br
|
br
|
||||||
|
|
||||||
.block.block--fixed.block--warning
|
.block.block--fixed.block--warning
|
||||||
|
|
|
@ -19,10 +19,6 @@ h1 Sign in
|
||||||
= checkbox f, :persistent_session
|
= checkbox f, :persistent_session
|
||||||
= label f, :persistent_session, "Remember me"
|
= 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"
|
= submit "Sign in", class: "button"
|
||||||
|
|
||||||
p
|
p
|
||||||
|
|
|
@ -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"
|
|
@ -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"
|
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule PhilomenaWeb.PowResetPassword.ResetPasswordView do
|
||||||
|
use PhilomenaWeb, :view
|
||||||
|
end
|
59
lib/pow_lockout/ecto/context.ex
Normal file
59
lib/pow_lockout/ecto/context.ex
Normal file
|
@ -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
|
123
lib/pow_lockout/ecto/schema.ex
Normal file
123
lib/pow_lockout/ecto/schema.ex
Normal file
|
@ -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
|
100
lib/pow_lockout/phoenix/controllers/controller_callbacks.ex
Normal file
100
lib/pow_lockout/phoenix/controllers/controller_callbacks.ex
Normal file
|
@ -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
|
22
lib/pow_lockout/phoenix/controllers/unlock_controller.ex
Normal file
22
lib/pow_lockout/phoenix/controllers/unlock_controller.ex
Normal file
|
@ -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
|
11
lib/pow_lockout/phoenix/mailers/mailer.ex
Normal file
11
lib/pow_lockout/phoenix/mailers/mailer.ex
Normal file
|
@ -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
|
23
lib/pow_lockout/phoenix/mailers/mailer_template.ex
Normal file
23
lib/pow_lockout/phoenix/mailers/mailer_template.ex
Normal file
|
@ -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
|
4
lib/pow_lockout/phoenix/mailers/mailer_view.ex
Normal file
4
lib/pow_lockout/phoenix/mailers/mailer_view.ex
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
defmodule PowLockout.Phoenix.MailerView do
|
||||||
|
@moduledoc false
|
||||||
|
use Pow.Phoenix.Mailer.View
|
||||||
|
end
|
13
lib/pow_lockout/phoenix/messages.ex
Normal file
13
lib/pow_lockout/phoenix/messages.ex
Normal file
|
@ -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
|
12
lib/pow_lockout/phoenix/router.ex
Normal file
12
lib/pow_lockout/phoenix/router.ex
Normal file
|
@ -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
|
85
lib/pow_lockout/plug.ex
Normal file
85
lib/pow_lockout/plug.ex
Normal file
|
@ -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
|
4
mix.exs
4
mix.exs
|
@ -55,7 +55,9 @@ defmodule Philomena.MixProject do
|
||||||
{:scrivener_ecto, "~> 2.0"},
|
{:scrivener_ecto, "~> 2.0"},
|
||||||
{:pbkdf2, "~> 2.0"},
|
{:pbkdf2, "~> 2.0"},
|
||||||
{:qrcode, "~> 0.1.5"},
|
{:qrcode, "~> 0.1.5"},
|
||||||
{:redix, "~> 0.10.2"}
|
{:redix, "~> 0.10.2"},
|
||||||
|
{:bamboo, "~> 1.2"},
|
||||||
|
{:bamboo_smtp, "~> 1.7"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
3
mix.lock
3
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"},
|
"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"},
|
"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"},
|
"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"},
|
"elixir2fa": {:hex, :elixir2fa, "0.1.0", "4585154695ad13a01c17c46e61b0884b0ce1569ebcc667d1d09cd1cbbb4f4ba8", [:mix], [], "hexpm"},
|
||||||
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"},
|
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"},
|
||||||
"file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [: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"},
|
"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"},
|
"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"},
|
"httpoison": {:hex, :httpoison, "1.6.0", "0a148c836e8e5fbec82c3cea37465a603bd42e314b73a8448ad50020757a00bd", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
|
|
Loading…
Reference in a new issue