add bulk of totp logic

This commit is contained in:
byte[] 2019-11-12 22:12:46 -05:00
parent 0f6e94d1a9
commit f1726e3d52
14 changed files with 250 additions and 138 deletions

View file

@ -51,7 +51,7 @@ defmodule Philomena.Images.Query do
must_not must_not
end end
%{bool: %{should: should, must_not: must_not}} {:ok, %{bool: %{should: should, must_not: must_not}}}
end end
def user_my_transform(_ctx, _value), def user_my_transform(_ctx, _value),

View file

@ -1,13 +1,13 @@
defmodule Philomena.Users.Password do defmodule Philomena.Users.Password do
def hash_pwd_salt(password, opts \\ []) do def hash_pwd_salt(password, opts \\ []) do
pepper = Application.get_env(:philomena, :password_pepper) Bcrypt.hash_pwd_salt(<<password::binary, password_pepper()::binary>>, opts)
Bcrypt.hash_pwd_salt(<<password::binary, pepper::binary>>, opts)
end end
def verify_pass(password, stored_hash) do def verify_pass(password, stored_hash) do
pepper = Application.get_env(:philomena, :password_pepper) Bcrypt.verify_pass(<<password::binary, password_pepper()::binary>>, stored_hash)
end
Bcrypt.verify_pass(<<password::binary, pepper::binary>>, stored_hash) defp password_pepper do
Application.get_env(:philomena, :password_pepper)
end end
end end

View file

@ -7,7 +7,7 @@ defmodule Philomena.Users.User do
password_hash_methods: {&Password.hash_pwd_salt/1, &Password.verify_pass/2} password_hash_methods: {&Password.hash_pwd_salt/1, &Password.verify_pass/2}
use Pow.Extension.Ecto.Schema, use Pow.Extension.Ecto.Schema,
extensions: [PowResetPassword] extensions: [PowResetPassword, PowPersistentSession]
import Ecto.Changeset import Ecto.Changeset
@ -17,7 +17,7 @@ defmodule Philomena.Users.User do
has_many :links, Philomena.Users.Link has_many :links, Philomena.Users.Link
has_many :verified_links, Philomena.Users.Link, where: [aasm_state: "verified"] has_many :verified_links, Philomena.Users.Link, where: [aasm_state: "verified"]
has_many :public_links, Philomena.Users.Link, where: [public: true, aasm_state: "verified"] has_many :public_links, Philomena.Users.Link, where: [public: true, aasm_state: "verified"]
has_many :galleries, Philomena.Galleries.Gallery has_many :galleries, Philomena.Galleries.Gallery, foreign_key: :creator_id
has_many :awards, Philomena.Badges.Award has_many :awards, Philomena.Badges.Award
belongs_to :current_filter, Philomena.Filters.Filter belongs_to :current_filter, Philomena.Filters.Filter
@ -116,24 +116,97 @@ defmodule Philomena.Users.User do
|> validate_required([]) |> validate_required([])
end end
def otp_secret(%{encrypted_otp_secret: x} = user) when x not in [nil, ""] do def create_totp_secret_changeset(user) do
Philomena.Users.Encryptor.decrypt_model( secret = :crypto.strong_rand_bytes(15) |> Base.encode32()
user.encrypted_otp_secret,
user.encrypted_otp_secret_iv,
user.encrypted_otp_secret_salt
)
end
def otp_secret(_user), do: nil
def put_otp_secret(user_or_changeset, secret) do
data = Philomena.Users.Encryptor.encrypt_model(secret) data = Philomena.Users.Encryptor.encrypt_model(secret)
user_or_changeset user
|> change(%{ |> change(%{
encrypted_otp_secret: data.secret, encrypted_otp_secret: data.secret,
encrypted_otp_secret_iv: data.iv, encrypted_otp_secret_iv: data.iv,
encrypted_otp_secret_salt: data.salt encrypted_otp_secret_salt: data.salt
}) })
end end
def consume_totp_token_changeset(user, token) do
cond do
totp_valid?(user, token) ->
user
|> change(%{consumed_timestep: token})
backup_code_valid?(user, token) ->
user
|> change(%{otp_backup_codes: remove_backup_code(user, token)})
true ->
user
|> add_error(:consumed_timestep, "invalid token")
end
end
def totp_changeset(user, params, backup_codes) do
token = to_string(params["twofactor_token"])
case user.otp_required_for_login do
true ->
# User wants to disable TOTP
user
|> pow_current_password_changeset(params)
|> consume_totp_token_changeset(token)
|> disable_totp_changeset()
false ->
# User wants to enable TOTP
user
|> pow_current_password_changeset(params)
|> consume_totp_token_changeset(token)
|> enable_totp_changeset(backup_codes)
end
end
def random_backup_codes do
(1..10)
|> Enum.map(fn _i ->
:crypto.strong_rand_bytes(6) |> Base.encode16(case: :lower)
end)
end
defp enable_totp_changeset(user, backup_codes) do
hashed_codes =
backup_codes
|> Enum.map(&Password.hash_pwd_salt/1)
user
|> change(%{
otp_required_for_login: true,
otp_backup_codes: hashed_codes
})
end
defp disable_totp_changeset(user) do
user
|> change(%{
otp_required_for_login: false,
otp_backup_codes: []
})
end
defp totp_valid?(user, token),
do: :pot.valid_totp(token, otp_secret(user), window: 60)
defp backup_code_valid?(user, token),
do: Enum.any?(user.otp_backup_codes, &Password.verify_pass(token, &1))
defp remove_backup_code(user, token),
do: user.otp_backup_codes |> Enum.reject(&Password.verify_pass(token, &1))
defp otp_secret(user) do
Philomena.Users.Encryptor.decrypt_model(
user.encrypted_otp_secret,
user.encrypted_otp_secret_iv,
user.encrypted_otp_secret_salt
)
end
end end

View file

@ -1,8 +1,7 @@
defmodule PhilomenaWeb.CommentController do defmodule PhilomenaWeb.CommentController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias Philomena.{Images.Image, Comments.Comment, Textile.Renderer} alias Philomena.{Comments.Comment, Textile.Renderer}
alias Philomena.Repo
import Ecto.Query import Ecto.Query
def index(conn, _params) do def index(conn, _params) do

View file

@ -1,7 +1,7 @@
defmodule PhilomenaWeb.ImageController do defmodule PhilomenaWeb.ImageController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias Philomena.{Images.Image, Comments.Comment, Tags.Tag, Textile.Renderer} alias Philomena.{Images.Image, Comments.Comment, Textile.Renderer}
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query

View file

@ -1,8 +1,7 @@
defmodule PhilomenaWeb.ProfileController do defmodule PhilomenaWeb.ProfileController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias Philomena.{Images, Images.Image, Comments.Comment, Posts.Post, Users.User, Users.Link} alias Philomena.{Images, Images.Image, Users.User}
alias Philomena.Repo
import Ecto.Query import Ecto.Query
plug :load_and_authorize_resource, model: User, only: :show, id_field: "slug", preload: [awards: :badge, public_links: :tag] plug :load_and_authorize_resource, model: User, only: :show, id_field: "slug", preload: [awards: :badge, public_links: :tag]

View file

@ -0,0 +1,43 @@
defmodule PhilomenaWeb.Registration.TotpController do
use PhilomenaWeb, :controller
alias Philomena.Users.User
alias Philomena.Repo
def edit(conn, _params) do
user = conn.assigns.current_user
case user.encrypted_otp_secret do
nil ->
user
|> User.create_totp_secret_changeset()
|> Repo.update()
# Redirect to have Pow pick up the changes
redirect(conn, to: Routes.registration_totp_path(conn, :edit))
_ ->
changeset = Pow.Plug.change_user(conn)
render(conn, "edit.html", changeset: changeset)
end
end
def update(conn, params) do
backup_codes = User.random_backup_codes()
conn
|> Pow.Plug.current_user()
|> User.totp_changeset(params, backup_codes)
|> Repo.update()
|> case do
{:error, changeset} ->
render(conn, "edit.html", changeset: changeset)
{:ok, user} ->
conn
|> PhilomenaWeb.Plugs.TotpPlug.update_valid_totp_at_for_session(user)
|> put_flash(:totp_backup_codes, backup_codes)
|> redirect(to: Routes.registration_totp_path(conn, :edit))
end
end
end

View file

@ -0,0 +1,31 @@
defmodule PhilomenaWeb.Session.TotpController do
use PhilomenaWeb, :controller
alias Philomena.Users.User
alias Philomena.Repo
def new(conn, _params) do
changeset = Pow.Plug.change_user(conn)
render(conn, "new.html", changeset: changeset)
end
def create(conn, params) do
conn
|> Pow.Plug.current_user()
|> User.consume_totp_token_changeset(params)
|> Repo.update()
|> case do
{:error, _changeset} ->
conn
|> Pow.Plug.clear_authenticated_user()
|> put_flash(:error, "Sorry, invalid TOTP token entered. Please sign in again.")
|> redirect(to: Routes.pow_session_path(conn, :new))
{:ok, user} ->
conn
|> PhilomenaWeb.Plugs.TotpPlug.update_valid_totp_at_for_session(user)
|> redirect(to: "/")
end
end
end

View file

@ -0,0 +1,58 @@
defmodule PhilomenaWeb.Plugs.TotpPlug do
@moduledoc """
This plug ensures that a user session has a valid TOTP.
## Example
plug PhilomenaWeb.TotpPlug
"""
alias PhilomenaWeb.Router.Helpers, as: Routes
@doc false
@spec init(any()) :: any()
def init(opts), do: opts
@doc false
@spec call(Plug.Conn.t(), any()) :: Plug.Conn.t()
def call(conn, _opts) do
conn
|> Pow.Plug.current_user()
|> case do
nil -> conn
user -> maybe_require_totp_phase(user, conn)
end
end
defp maybe_require_totp_phase(%{otp_required_for_login: nil}, conn), do: conn
defp maybe_require_totp_phase(%{otp_required_for_login: false}, conn), do: conn
defp maybe_require_totp_phase(_user, conn) do
conn.private
|> Map.get(:pow_session_metadata, [])
|> Keyword.get(:valid_totp_at)
|> case do
nil ->
conn
|> Phoenix.Controller.redirect(to: Routes.session_totp_path(conn, :new))
|> Plug.Conn.halt()
_valid_at ->
conn
end
end
@doc false
@spec update_valid_totp_at_for_session(Plug.Conn.t(), map()) :: Plug.Conn.t()
def update_valid_totp_at_for_session(conn, user) do
metadata =
conn.private
|> Map.get(:pow_session_metadata, [])
|> Keyword.put(:valid_totp_at, DateTime.utc_now())
config = Pow.Plug.fetch_config(conn)
plug = Pow.Plug.get_plug(config)
conn = Plug.Conn.put_private(conn, :pow_session_metadata, metadata)
plug.do_create(conn, user, config)
end
end

View file

@ -1,6 +1,7 @@
defmodule PhilomenaWeb.Router do defmodule PhilomenaWeb.Router do
use PhilomenaWeb, :router use PhilomenaWeb, :router
use Pow.Phoenix.Router use Pow.Phoenix.Router
use Pow.Extension.Phoenix.Router, otp_app: :philomena
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
@ -16,14 +17,28 @@ defmodule PhilomenaWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
scope "/" do pipeline :ensure_totp do
pipe_through :browser plug PhilomenaWeb.Plugs.TotpPlug
end
#pow_routes() scope "/" do
pipe_through [:browser, :ensure_totp]
pow_routes()
pow_extension_routes()
end end
scope "/", PhilomenaWeb do scope "/", PhilomenaWeb do
pipe_through :browser pipe_through [:browser, :ensure_totp]
# Additional routes for TOTP
scope "/registration", Registration, as: :registration do
resources "/totp", TotpController, only: [:edit, :update], singleton: true
end
scope "/session", Session, as: :session do
resources "/totp", TotpController, only: [:new, :create], singleton: true
end
get "/", ActivityController, :index get "/", ActivityController, :index
@ -39,7 +54,7 @@ defmodule PhilomenaWeb.Router do
resources "/comments", CommentController, only: [:index] resources "/comments", CommentController, only: [:index]
scope "/filters", Filter, as: :filter do scope "/filters", Filter, as: :filter do
resources "/current", CurrentController, only: [:update], singular: true resources "/current", CurrentController, only: [:update], singleton: true
end end
resources "/filters", FilterController resources "/filters", FilterController
resources "/profiles", ProfileController, only: [:show] resources "/profiles", ProfileController, only: [:show]

View file

@ -1,69 +0,0 @@
defmodule PowMultiFactor.Phoenix.ControllerCallbacks do
@moduledoc """
Controller callback logic for multi-factor authentication.
### 2FA code not submitted
Triggers on `Pow.Phoenix.SessionController.create/2`.
When a user with 2FA enabled attempts to sign in without submitting their
TOTP token, the session will be cleared, and the user redirected back to
`Pow.Phoenix.Routes.session_path/1`.
### User updates account
Triggers on `Pow.Phoenix.RegistrationController.update/2`
When a user changes their account settings, they are required to confirm a
current 2FA token.
See `PowMultiFactor.Ecto.Schema` for more.
"""
use Pow.Extension.Phoenix.ControllerCallbacks.Base
alias Pow.Plug
alias PowMultiFactor.Plug, as: PowMultiFactorPlug
def before_respond(Pow.Phoenix.SessionController, :create, {:ok, conn}, config) do
return_path = routes(conn).session_path(conn, :new)
clear_unauthorized(conn, config, {:ok, conn}, return_path)
end
def before_respond(Pow.Phoenix.RegistrationController, :update, {:ok, user, conn}, config) do
return_path = routes(conn).registration_path(conn, :edit)
halt_unauthorized(conn, config, {:ok, user, conn}, return_path)
end
defp clear_unauthorized(conn, config, success_response, return_path) do
case PowMultiFactorPlug.mfa_authorized?(conn, config) do
false -> clear_auth(conn) |> go_back(return_path)
true -> success_response
end
end
defp halt_unauthorized(conn, config, success_response, return_path) do
case PowMultiFactorPlug.mfa_authorized?(conn, config) do
false -> go_back(conn, return_path)
true -> success_response
end
end
def clear_auth(conn) do
{:ok, conn} = Plug.clear_authenticated_user(conn)
conn
end
defp go_back(conn, return_path) do
error = extension_messages(conn).invalid_multi_factor(conn)
conn =
conn
|> Phoenix.Controller.put_flash(:error, error)
|> Phoenix.Controller.redirect(to: return_path)
{:halt, conn}
end
end

View file

@ -1,37 +0,0 @@
defmodule PowMultiFactor.Plug do
@moduledoc """
Plug helper methods.
"""
alias Plug.Crypto
alias Pow.Plug
alias Pow.Config
def mfa_authorized?(conn, config) do
user = Plug.current_user(conn)
if user.otp_required_for_login do
secret = user.__struct__.otp_secret(user)
totp = Elixir2fa.generate_totp(secret)
Crypto.secure_compare(totp, conn.params)
else
true
end
end
def assign_mfa(conn, config) do
user = Plug.current_user(conn)
repo = Config.repo!(config)
if user.encrypted_otp_secret in [nil, ""] do
{:ok, user} =
user.__struct__.put_otp_secret(Elixir2fa.random_secret())
|> repo.update()
user
else
user
end
end
end

View file

@ -66,8 +66,9 @@ defmodule Search.Parser do
{:error, msg} -> {:error, msg} ->
{:error, msg} {:error, msg}
_ -> err ->
{:error, "unknown parsing error"} err
#{:error, "unknown parsing error"}
end end
end end

View file

@ -53,7 +53,6 @@ defmodule Philomena.MixProject do
{:nimble_parsec, "~> 0.5.1"}, {:nimble_parsec, "~> 0.5.1"},
{:canary, "~> 1.1.1"}, {:canary, "~> 1.1.1"},
{:scrivener_ecto, "~> 2.0"}, {:scrivener_ecto, "~> 2.0"},
{:elixir2fa, "~> 0.1.0"},
{:pbkdf2, "~> 2.0"} {:pbkdf2, "~> 2.0"}
] ]
end end