Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

user settings: recovery codes page, text/voice confirmation #145

Merged
merged 25 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions assets/scripts/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '../../deps/phoenix_html/priv/static/phoenix_html.js'
import './util.js'

import '../styles/main.scss'

Expand All @@ -23,4 +24,13 @@ documentReady(function () {
toggleDisplay('div.company_name')
})
})

document
.querySelectorAll('#copy-text')
.forEach((field) => {
field.addEventListener('click', (event) => {
const text = event.target.dataset.recoveryBlock
copyClipboard(text)
})
})
})
6 changes: 6 additions & 0 deletions assets/scripts/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

function copyClipboard(text) {
navigator.clipboard.writeText(text)
}

window.copyClipboard = copyClipboard
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,6 @@ config :spandex_ecto, SpandexEcto.EctoLogger,
config :spandex_phoenix, tracer: Recognizer.Tracer
config :spandex, :decorators, tracer: Recognizer.Tracer

config :recognizer, Recognizer.Accounts, cache_expiry: 60 * 15

import_config "#{Mix.env()}.exs"
2 changes: 2 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ config :recognizer, Recognizer.BigCommerce,
logout_uri: "http://localhost/logout",
http_client: HTTPoison,
enabled?: false

config :recognizer, Recognizer.Accounts, cache_expiry: 60 * 60 * 24 * 7
2 changes: 2 additions & 0 deletions config/releases.exs
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,5 @@ config :recognizer, Recognizer.BigCommerce,
logout_uri: recognizer_config["BIGCOMMERCE_LOGOUT_URI"],
http_client: HTTPoison,
enabled?: false

config :recognizer, Recognizer.Accounts, cache_expiry: recognizer_config["ACCOUNT_CACHE_EXPIRY_SECONDS"]
18 changes: 16 additions & 2 deletions lib/recognizer/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,14 @@ defmodule Recognizer.Accounts do
two_factor_enabled: true
}

Redix.noreply_command(:redix, ["SET", "two_factor_settings:#{user.id}", Jason.encode!(attrs)])
:ok =
Redix.noreply_command(:redix, [
"SET",
"two_factor_settings:#{user.id}",
Jason.encode!(attrs),
"EX",
config(:cache_expiry)
])

attrs
end
Expand Down Expand Up @@ -639,7 +646,7 @@ defmodule Recognizer.Accounts do
end

@doc """
Retreives the new user's two factor settings from our cache. These settings
Retrieves the new user's two factor settings from our cache. These settings
are not yet active, but are in the process of being verified.
"""
def get_new_two_factor_settings(user) do
Expand All @@ -665,6 +672,11 @@ defmodule Recognizer.Accounts do
end
end

@doc """
Deletes cached settings.
"""
def clear_two_factor_settings(user), do: {:ok, _} = Redix.command(:redix, ["DEL", "two_factor_settings:#{user.id}"])

def load_notification_preferences(user) do
Repo.preload(user, :notification_preference)
end
Expand Down Expand Up @@ -738,4 +750,6 @@ defmodule Recognizer.Accounts do

Repo.delete_all(user_codes)
end

defp config(key), do: Application.get_env(:recognizer, __MODULE__)[key]
end
2 changes: 1 addition & 1 deletion lib/recognizer/accounts/notification_preference.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule Recognizer.Accounts.NotificationPreference do
alias __MODULE__, as: NotificationPreference

schema "notification_preferences" do
field :two_factor, Recognizer.TwoFactorPreference, default: :text
field :two_factor, Recognizer.TwoFactorPreference, default: :app

belongs_to :user, User

Expand Down
114 changes: 100 additions & 14 deletions lib/recognizer_web/controllers/accounts/user_settings_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,97 @@ defmodule RecognizerWeb.Accounts.UserSettingsController do
alias Recognizer.Accounts
alias RecognizerWeb.Authentication

@one_minute 60_000

plug :assign_email_and_password_changesets

plug Hammer.Plug,
[
rate_limit: {"user_settings:two_factor", @one_minute, 2},
by: {:conn, &__MODULE__.two_factor_rate_key/1},
when_nil: :pass,
on_deny: &__MODULE__.two_factor_rate_limited/2
]
when action in [:two_factor_init]

def edit(conn, _params) do
if Application.get_env(:recognizer, :redirect_url) do
if Application.get_env(:recognizer, :redirect_url) && !get_session(conn, :bc) do
redirect(conn, external: Application.get_env(:recognizer, :redirect_url))
else
render(conn, "edit.html")
end
end

def two_factor(conn, _params) do
@doc """
Generate codes for a new two factor setup
"""
def two_factor_init(conn, _params) do
user = Authentication.fetch_current_user(conn)
{:ok, %{two_factor_seed: seed}} = Accounts.get_new_two_factor_settings(user)

render(conn, "confirm_two_factor.html",
barcode: Authentication.generate_totp_barcode(user, seed),
totp_app_url: Authentication.get_totp_app_url(user, seed)
)
{:ok, %{two_factor_seed: seed, notification_preference: %{two_factor: method}} = settings} =
Accounts.get_new_two_factor_settings(user)

if method == "text" || method == "voice" do
:ok = Accounts.send_new_two_factor_notification(user, settings)
render(conn, "confirm_two_factor_external.html")
else
render(conn, "confirm_two_factor.html",
barcode: Authentication.generate_totp_barcode(user, seed),
totp_app_url: Authentication.get_totp_app_url(user, seed)
)
end
end

@doc """
Rate limit 2fa setup only for text & voice, bypass for app.
"""
def two_factor_rate_key(conn) do
user = Authentication.fetch_current_user(conn)

case Accounts.get_new_two_factor_settings(user) do
{:ok, %{notification_preference: %{two_factor: "app"}}} ->
nil

_ ->
get_user_id_from_request(conn)
end
end

@doc """
Graceful error for 2fa retry rate limits
"""
def two_factor_rate_limited(conn, _params) do
conn
|> put_flash(:error, "Too many requests, please wait and try again")
|> render("confirm_two_factor_external.html")
|> halt()
end

@doc """
Confirming and saving a new two factor setup with user-provided code
"""
def two_factor_confirm(conn, params) do
two_factor_code = Map.get(params, "two_factor_code", "")
user = Authentication.fetch_current_user(conn)

case Accounts.confirm_and_save_two_factor_settings(two_factor_code, user) do
{:ok, _updated_user} ->
Accounts.clear_two_factor_settings(user)

conn
|> put_flash(:info, "Two factor code verified.")
|> put_flash(:info, "Two factor code verified")
|> redirect(to: Routes.user_settings_path(conn, :edit))

_ ->
conn
|> put_flash(:error, "Two factor code is invalid.")
|> redirect(to: Routes.user_settings_path(conn, :confirm_two_factor))
|> put_flash(:error, "Two factor code is invalid")
|> redirect(to: Routes.user_settings_path(conn, :two_factor_confirm))
end
end

@doc """
Form submission for settings applied
"""
def update(conn, %{"action" => "update", "user" => user_params}) do
user = Authentication.fetch_current_user(conn)

Expand Down Expand Up @@ -73,6 +127,7 @@ defmodule RecognizerWeb.Accounts.UserSettingsController do
end
end

# disable 2fa
def update(conn, %{"action" => "update_two_factor", "user" => %{"two_factor_enabled" => "0"}}) do
user = Authentication.fetch_current_user(conn)

Expand All @@ -83,13 +138,44 @@ defmodule RecognizerWeb.Accounts.UserSettingsController do
end
end

def update(conn, %{"action" => "update_two_factor", "user" => user_params}) do
# enable 2fa
def update(conn, %{
"action" => "update_two_factor",
"user" => %{"notification_preference" => %{"two_factor" => preference}}
}) do
%{phone_number: phone_number} = user = Authentication.fetch_current_user(conn)

# phone number required for text/voice
if (preference == "text" || preference == "voice") && phone_number == nil do
conn
|> put_flash(:error, "Phone number required for text and voice two-factor methods")
|> redirect(to: Routes.user_settings_path(conn, :edit))
else
Accounts.generate_and_cache_new_two_factor_settings(user, preference)
redirect(conn, to: Routes.user_settings_path(conn, :review))
end
end

@doc """
Review recovery codes for copying.
"""
def review(conn, _params) do
user = Authentication.fetch_current_user(conn)
preference = get_in(user_params, ["notification_preference", "two_factor"])

Accounts.generate_and_cache_new_two_factor_settings(user, preference)
case Accounts.get_new_two_factor_settings(user) do
{:ok, %{recovery_codes: recovery_codes}} ->
recovery_block =
recovery_codes
|> Enum.map_join("\n", & &1.code)

conn
|> render("recovery_codes.html", recovery_block: recovery_block)

redirect(conn, to: Routes.user_settings_path(conn, :two_factor))
_ ->
conn
|> put_flash(:error, "Two factor setup expired or not yet initiated")
|> redirect(to: Routes.user_settings_path(conn, :edit))
end
end

defp assign_email_and_password_changesets(conn, _opts) do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ defmodule RecognizerWeb.Accounts.UserTwoFactorController do
]
when action in [:resend]

@doc """
Prompt the user for a two factor code on login
"""
def new(conn, _params) do
current_user_id = get_session(conn, :two_factor_user_id)
current_user = Accounts.get_user!(current_user_id)
Expand All @@ -37,7 +40,7 @@ defmodule RecognizerWeb.Accounts.UserTwoFactorController do
end

@doc """
Handle a user creating a session with a two factor code
Verify a user creating a session with a two factor code
"""
def create(conn, %{"user" => %{"two_factor_code" => two_factor_code}}) do
current_user_id = get_session(conn, :two_factor_user_id)
Expand Down
3 changes: 2 additions & 1 deletion lib/recognizer_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ defmodule RecognizerWeb.Router do

get "/settings", UserSettingsController, :edit
put "/settings", UserSettingsController, :update
get "/settings/two-factor", UserSettingsController, :two_factor
get "/settings/two-factor/review", UserSettingsController, :review
get "/settings/two-factor", UserSettingsController, :two_factor_init
post "/settings/two-factor", UserSettingsController, :two_factor_confirm
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="box">
<h2 class="title is-2 mb-5 has-text-centered-mobile">Confirm Two Factor Authentication</h2>

<div class="content mt-5">
<p>Enter the provided 6-digit code:</p>
</div>

<%= form_for @conn, Routes.user_settings_path(@conn, :two_factor_confirm), fn f -> %>
<div class="field">
<div class="control">
<%= text_input f, :two_factor_code, inputmode: "numeric", pattern: "[0-9]*", autocomplete: "one-time-code", required: true, class: "is-medium #{input_classes(f, :two_factor_code)}" %>
</div>

<%= error_tag f, :two_factor_code %>
</div>

<div class="buttons is-right mt-6">
<%= submit "Verify Code", class: "button is-secondary" %>
<a href="/settings" class="button">Cancel</a>
</div>

<div class="content">
<p>If you did not receive a verification code, or it has expired:</p>
<div class="buttons is-right">
<%= link "Send another", to: Routes.user_settings_path(@conn, :two_factor_init), class: "button is-warning is-right"%>
</div>
</div>

<% end %>
</div>
26 changes: 13 additions & 13 deletions lib/recognizer_web/templates/accounts/user_settings/edit.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</div>

<div class="box">
<h2 class="title is-2 mb-5 has-text-centered-mobile">Change Profile</h2>
<h2 class="title is-2 mb-5 has-text-centered-mobile">Update Profile</h2>

<%= form_for @changeset, Routes.user_settings_path(@conn, :update), fn f -> %>
<%= hidden_input f, :action, name: "action", value: "update" %>
Expand Down Expand Up @@ -128,51 +128,51 @@

<div class="buttons is-right mt-5">
<div class="control">
<%= submit "Change Profile", class: "button is-secondary" %>
<%= submit "Update Profile", class: "button is-secondary" %>
</div>
</div>
<% end %>
</div>

<div class="box">
<h2 class="title is-2 mb-5 has-text-centered-mobile">Change Password</h2>
<h2 class="title is-2 mb-5 has-text-centered-mobile">Update Password</h2>

<%= form_for @password_changeset, Routes.user_settings_path(@conn, :update), fn f -> %>
<%= hidden_input f, :action, name: "action", value: "update_password" %>

<div class="field">
<%= label f, :password, "New Password", class: "label" %>
<%= label f, :current_password, class: "label" %>

<div class="control">
<%= password_input f, :password, class: input_classes(f, :password), required: true %>
<%= password_input f, :current_password, class: input_classes(f, :password), required: true, name: "current_password" %>
</div>

<%= error_tag f, :password %>
<%= error_tag f, :current_password %>
</div>

<div class="field">
<%= label f, :password_confirmation, "Confirm new password", class: "label" %>
<%= label f, :password, "New Password", class: "label" %>

<div class="control">
<%= password_input f, :password_confirmation, class: input_classes(f, :password_confirmation), required: true %>
<%= password_input f, :password, class: input_classes(f, :password), required: true %>
</div>

<%= error_tag f, :password_confirmation %>
<%= error_tag f, :password %>
</div>

<div class="field">
<%= label f, :current_password, class: "label" %>
<%= label f, :password_confirmation, "Confirm new password", class: "label" %>

<div class="control">
<%= password_input f, :current_password, class: input_classes(f, :password), required: true, name: "current_password" %>
<%= password_input f, :password_confirmation, class: input_classes(f, :password_confirmation), required: true %>
</div>

<%= error_tag f, :current_password %>
<%= error_tag f, :password_confirmation %>
</div>

<div class="buttons is-right mt-5">
<div class="control">
<%= submit "Change Password", class: "button is-secondary" %>
<%= submit "Update Password", class: "button is-secondary" %>
</div>
</div>
<% end %>
Expand Down
Loading
Loading