Complete the following steps to configure your mobile app:
-1. Install an authenticator app on your mobile device.
-2. In the app choose to add by QR code.
-3. Scan the barcode below.
-diff --git a/assets/package-lock.json b/assets/package-lock.json index 426b2f6..455eb99 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -1439,6 +1439,12 @@ "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", "dev": true }, + "alpinejs": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.0.tgz", + "integrity": "sha512-UHvE71BBYHJa6+RxjMwM0g4eokq+5yG1QO5C6VieUu0MVPlsUSlwvrh19VVZQApNIlQsgcvQUhIalzAIgoAPoA==", + "dev": true + }, "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -5036,6 +5042,9 @@ "phoenix_html": { "version": "file:../deps/phoenix_html" }, + "phoenix_live_view": { + "version": "file:../deps/phoenix_live_view" + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", @@ -7651,6 +7660,12 @@ "is-number": "^7.0.0" } }, + "topbar": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/topbar/-/topbar-1.0.1.tgz", + "integrity": "sha512-HZqQSMBiG29vcjOrqKCM9iGY/h69G5gQH7ae83ZCPz5uPmbQKwK0sMEqzVDBiu64tWHJ+kk9NApECrF+FAAvRA==", + "dev": true + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", diff --git a/assets/package.json b/assets/package.json index 6292b61..af674a3 100644 --- a/assets/package.json +++ b/assets/package.json @@ -9,12 +9,14 @@ }, "dependencies": { "phoenix": "file:../deps/phoenix", - "phoenix_html": "file:../deps/phoenix_html" + "phoenix_html": "file:../deps/phoenix_html", + "phoenix_live_view": "file:../deps/phoenix_live_view" }, "devDependencies": { "@babel/core": "^7.0.0", "@babel/preset-env": "^7.0.0", "@fullhuman/postcss-purgecss": "^3.0.0", + "alpinejs": "^2.8.0", "babel-loader": "^8.0.0", "bulma": "^0.9.1", "copy-webpack-plugin": "^6.3.2", @@ -29,6 +31,7 @@ "sass-loader": "^10.1.0", "standard": "^16.0.3", "terser-webpack-plugin": "^5.0.3", + "topbar": "^1.0.1", "webpack": "5.10.0", "webpack-cli": "^4.2.0" } diff --git a/assets/scripts/main.js b/assets/scripts/main.js index 31f3a82..c35bba5 100644 --- a/assets/scripts/main.js +++ b/assets/scripts/main.js @@ -1,26 +1,27 @@ -import '../../deps/phoenix_html/priv/static/phoenix_html.js' +import { Socket } from 'phoenix' +import LiveSocket from 'phoenix_live_view' +import topbar from 'topbar' -import '../styles/main.scss' +import 'alpinejs' +import 'phoenix_html/priv/static/phoenix_html.js' -function documentReady (fn) { - if (document.readyState === 'complete' || document.readyState === 'interactive') { - setTimeout(fn, 1) - } else { - document.addEventListener('DOMContentLoaded', fn) +const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content') +const liveSocket = new LiveSocket('/live', Socket, { + params: { + _csrf_token: csrfToken } -} +}) -function toggleDisplay (selector, value) { - const input = document.querySelector(selector) - input.style.display = (value) ? 'block' : 'none' -} +topbar.config({barColors: {0: '#63b1bc'}, shadowBlur: 0}) +window.addEventListener('phx:page-loading-start', info => topbar.show()) +window.addEventListener('phx:page-loading-stop', info => topbar.hide()) -documentReady(function () { - document - .querySelectorAll('input[name="user[type]"]') - .forEach((field) => { - field.addEventListener('change', (e) => { - toggleDisplay('div.company_name', (e.target.value === 'business')) - }) - }) -}) +liveSocket.connect() + +// Expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) +// The latency simulator is enabled for the duration of the browser session. +// Call disableLatencySim() to disable: +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket diff --git a/lib/recognizer/accounts.ex b/lib/recognizer/accounts.ex index c491a98..b5ede4b 100644 --- a/lib/recognizer/accounts.ex +++ b/lib/recognizer/accounts.ex @@ -555,12 +555,18 @@ defmodule Recognizer.Accounts do Redix.noreply_command(:redix, ["SET", "two_factor_settings:#{user.id}", Jason.encode!(attrs)]) + send_new_two_factor_notification(user, new_seed, preference) + end + + @doc """ + Sends a two factor confirmation code for new settings. This is a no-op if the + user is trying to setup an app. + """ + def send_new_two_factor_notification(user, seed, preference) do if preference != "app" do - token = Authentication.generate_token(new_seed) + token = Authentication.generate_token(seed) Notification.deliver_two_factor_token(user, token, String.to_existing_atom(preference)) end - - attrs end @doc """ diff --git a/lib/recognizer_web.ex b/lib/recognizer_web.ex index f194c8a..9c9dfe3 100644 --- a/lib/recognizer_web.ex +++ b/lib/recognizer_web.ex @@ -21,6 +21,7 @@ defmodule RecognizerWeb do quote do use Phoenix.Controller, namespace: RecognizerWeb + import Phoenix.LiveView.Controller import Plug.Conn import RecognizerWeb.Gettext @@ -47,12 +48,30 @@ defmodule RecognizerWeb do end end + def live_view do + quote do + use Phoenix.LiveView, + layout: {RecognizerWeb.LayoutView, "live.html"} + + unquote(view_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller + import Phoenix.LiveView.Router end end @@ -70,6 +89,7 @@ defmodule RecognizerWeb do # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View + import Phoenix.LiveView.Helpers import RecognizerWeb.ErrorHelpers import RecognizerWeb.FormHelpers diff --git a/lib/recognizer_web/controllers/accounts/user_settings_controller.ex b/lib/recognizer_web/controllers/accounts/user_settings_controller.ex index 98f51c7..81096e5 100644 --- a/lib/recognizer_web/controllers/accounts/user_settings_controller.ex +++ b/lib/recognizer_web/controllers/accounts/user_settings_controller.ex @@ -14,33 +14,6 @@ defmodule RecognizerWeb.Accounts.UserSettingsController do end end - def two_factor(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) - ) - end - - 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} -> - conn - |> 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)) - end - end - def update(conn, %{"action" => "update", "user" => user_params}) do user = Authentication.fetch_current_user(conn) @@ -83,13 +56,12 @@ defmodule RecognizerWeb.Accounts.UserSettingsController do end end - def update(conn, %{"action" => "update_two_factor", "user" => user_params}) do + def update(conn, %{"action" => "update_two_factor", "user" => %{"two_factor_enabled" => "1"}}) 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) - - redirect(conn, to: Routes.user_settings_path(conn, :two_factor)) + conn + |> put_session(:two_factor_user_id, user.id) + |> redirect(to: Routes.live_path(conn, RecognizerWeb.Accounts.TwoFactorSettingsLive)) end defp assign_email_and_password_changesets(conn, _opts) do diff --git a/lib/recognizer_web/endpoint.ex b/lib/recognizer_web/endpoint.ex index 464085b..6f35618 100644 --- a/lib/recognizer_web/endpoint.ex +++ b/lib/recognizer_web/endpoint.ex @@ -8,6 +8,8 @@ defmodule RecognizerWeb.Endpoint do signing_salt: "juvsYHmf" ] + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + plug RecognizerWeb.HealthcheckPlug plug Plug.Static, diff --git a/lib/recognizer_web/live/accounts/two-factor-settings.ex b/lib/recognizer_web/live/accounts/two-factor-settings.ex new file mode 100644 index 0000000..452b343 --- /dev/null +++ b/lib/recognizer_web/live/accounts/two-factor-settings.ex @@ -0,0 +1,108 @@ +defmodule RecognizerWeb.Accounts.TwoFactorSettingsLive do + use RecognizerWeb, :live_view + + alias Recognizer.Accounts + + def mount(_params, %{"two_factor_user_id" => user_id}, socket) do + user = Accounts.get_user!(user_id) + changeset = Accounts.change_user_two_factor(user, %{}) + + {:ok, + socket + |> assign(:user, user) + |> assign(:changeset, changeset) + |> assign(:params, %{}) + |> assign(:settings, %{}) + |> assign(:step, :choice)} + end + + def handle_event("choice", %{"user" => user_params}, socket) do + user = socket.assigns.user + + changeset = Accounts.change_user_two_factor(user, user_params) + {:ok, settings} = Accounts.get_new_two_factor_settings(user) + + {:noreply, + socket + |> assign(:changeset, changeset) + |> assign(:params, user_params) + |> assign(:settings, settings) + |> assign(:step, :backup)} + end + + def handle_event("backup", _params, socket) do + notification_preferences = Ecto.Changeset.get_field(socket.assigns.changeset, :notification_preference) + + case notification_preferences.two_factor do + :app -> + {:noreply, assign(socket, :step, :app_validate)} + + _ -> + {:noreply, assign(socket, :step, :phone_number)} + end + end + + def handle_event("app_validate", %{"user" => %{"two_factor_code" => code}}, socket) do + user = socket.assigns.user + + with {:ok, semi_updated_user} <- Accounts.confirm_and_save_two_factor_settings(code, user), + {:ok, updated_user} <- Accounts.update_user(semi_updated_user, socket.assigns.params) do + {:noreply, + socket + |> put_flash(:info, "Two factor authentication enabled.") + |> push_redirect(to: Routes.user_settings_path(socket, :edit))} + else + _ -> {:noreply, put_flash(socket, :error, "Two factor code is invalid.")} + end + end + + def handle_event("phone_number", %{"user" => user_attrs}, socket) do + user = socket.assigns.user + params = Map.merge(socket.assigns.params, user_attrs) + changeset = Accounts.change_user_two_factor(user, params) + + if changeset.valid? do + {:noreply, + socket + |> assign(:changeset, changeset) + |> assign(:params, params) + |> assign(:step, :phone_number_validate)} + else + {:noreply, + socket + |> assign(:changeset, changeset) + |> assign(:params, params)} + end + end + + def handle_event("phone_number_validate", %{"user" => %{"two_factor_code" => code}}, socket) do + user = socket.assigns.user + + with {:ok, semi_updated_user} <- Accounts.confirm_and_save_two_factor_settings(code, user), + {:ok, updated_user} <- Accounts.update_user(semi_updated_user, socket.assigns.params) do + {:noreply, + socket + |> put_flash(:info, "Two factor authentication enabled.") + |> push_redirect(to: Routes.user_settings_path(socket, :edit))} + else + _ -> {:noreply, put_flash(socket, :error, "Two factor code is invalid.")} + end + end + + def handle_event("resend", _params, socket) do + %{user: user, settings: settings} = socket.assigns + + Accounts.send_new_two_factor_notification( + user, + settings["two_factor_seed"], + settings["notification_preference"]["two_factor"] + ) + + {:noreply, put_flash(socket, :info, "Two factor code sent.")} + end + + def render(assigns) do + template = to_string(assigns.step) <> ".html" + RecognizerWeb.Accounts.TwoFactorSettingsView.render(template, assigns) + end +end diff --git a/lib/recognizer_web/router.ex b/lib/recognizer_web/router.ex index bb88620..7ca63e1 100644 --- a/lib/recognizer_web/router.ex +++ b/lib/recognizer_web/router.ex @@ -4,9 +4,10 @@ defmodule RecognizerWeb.Router do pipeline :browser do plug :accepts, ["html"] plug :fetch_session - plug :fetch_flash + plug :fetch_live_flash plug :protect_from_forgery plug :put_secure_browser_headers + plug :put_root_layout, {RecognizerWeb.LayoutView, :root} end pipeline :api do @@ -96,7 +97,6 @@ defmodule RecognizerWeb.Router do get "/settings", UserSettingsController, :edit put "/settings", UserSettingsController, :update - get "/settings/two-factor", UserSettingsController, :two_factor - post "/settings/two-factor", UserSettingsController, :two_factor_confirm + live "/settings/two-factor", TwoFactorSettingsLive end end diff --git a/lib/recognizer_web/templates/accounts/two_factor_settings/app_validate.html.leex b/lib/recognizer_web/templates/accounts/two_factor_settings/app_validate.html.leex new file mode 100644 index 0000000..4c4b13b --- /dev/null +++ b/lib/recognizer_web/templates/accounts/two_factor_settings/app_validate.html.leex @@ -0,0 +1,47 @@ +
+ Scan the image below with the two-factor authentication app on your phone. + If you can’t use a barcode, use the URL instead. +
+<%= two_factor_url(@user, @settings) %>
+ + After scanning the barcode image, the app will display a six-digit code + that you can enter above. +
++ Recovery codes are used to access your account if you have lost the + access to your phone. +
+ ++ + Download, print or copy your recovery codes before continuing + two-factor authentication setup. + +
+<%= @settings["recovery_codes"] |> Enum.map(&Map.get(&1, "code")) |> Enum.join("\n") %>
+
+ + + Treat your recovery codes with the same level of attention as you would + your password! + +
++ Two-factor authentication adds an additional layer of security to your + account by requiring more than just a password to log in. We + strongly urge you to enable 2FA for the safety of your + account. +
++ Message and data rates may apply for text message and phone call + methods. +
+ + + <% end %> ++ Please confirm your phone number to use text or voice two-factor + authentication. +
+ <% else %> ++ Please add a valid phone number to use text or voice two-factor + authentication. +
+ <% end %> ++ Didn't get the code? <%= link "Resend", to: "#", phx_click: "resend" %> +
+Complete the following steps to configure your mobile app:
-1. Install an authenticator app on your mobile device.
-2. In the app choose to add by QR code.
-3. Scan the barcode below.
-If you are unable to scan the QR code you can enter the URL manually:
- -
<%= @totp_app_url %>
-
- 4. Enter the displayed 6-digit code:
-- Message and data rates may apply for text message and phone call - methods. -
-