add mailer, password resets, lockouts

This commit is contained in:
byte[] 2019-11-14 21:40:35 -05:00
parent 31d637cca0
commit 76886c5329
25 changed files with 564 additions and 59 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 """

View file

@ -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))

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.Mailer do
use Bamboo.Mailer, otp_app: :philomena
end

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.PowResetPassword.ResetPasswordView do
use PhilomenaWeb, :view
end

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,4 @@
defmodule PowLockout.Phoenix.MailerView do
@moduledoc false
use Pow.Phoenix.Mailer.View
end

View 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

View 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
View 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

View file

@ -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

View file

@ -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"},