Skip to content

Commit

Permalink
user settings: recovery codes page, text/voice confirmation (#145)
Browse files Browse the repository at this point in the history
  • Loading branch information
ah-s76 authored Mar 26, 2024
1 parent 7df1d2a commit c7ba9ec
Show file tree
Hide file tree
Showing 15 changed files with 334 additions and 36 deletions.
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

0 comments on commit c7ba9ec

Please sign in to comment.