From ecc379085a589bc7385e571997fc45ce1dc23608 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 11 Feb 2025 19:00:02 +0100 Subject: [PATCH 1/9] Update phx.gen.auth with sudo mode and magic links Closes #6041. Still todo: documentation. --- lib/mix/tasks/phx.gen.auth.ex | 82 +---- priv/templates/phx.gen.auth/auth.ex | 84 ++++- priv/templates/phx.gen.auth/auth_test.exs | 134 ++++++-- .../phx.gen.auth/confirmation_controller.ex | 56 ---- .../confirmation_controller_test.exs | 122 ------- .../phx.gen.auth/confirmation_edit.html.heex | 14 - .../phx.gen.auth/confirmation_html.ex | 5 - .../confirmation_instructions_live.ex | 57 ---- .../confirmation_instructions_live_test.exs | 67 ---- .../phx.gen.auth/confirmation_live.ex | 85 +++-- .../phx.gen.auth/confirmation_live_test.exs | 114 ++++--- .../phx.gen.auth/confirmation_new.html.heex | 20 -- priv/templates/phx.gen.auth/conn_case.exs | 20 +- .../context_fixtures_functions.ex | 46 ++- .../phx.gen.auth/context_functions.ex | 263 ++++++--------- .../phx.gen.auth/forgot_password_live.ex | 56 ---- .../forgot_password_live_test.exs | 63 ---- priv/templates/phx.gen.auth/login_live.ex | 124 +++++-- .../phx.gen.auth/login_live_test.exs | 83 +++-- priv/templates/phx.gen.auth/migration.ex | 2 +- priv/templates/phx.gen.auth/notifier.ex | 39 ++- .../phx.gen.auth/registration_controller.ex | 14 +- .../registration_controller_test.exs | 17 +- .../phx.gen.auth/registration_live.ex | 41 +-- .../phx.gen.auth/registration_live_test.exs | 21 +- .../phx.gen.auth/registration_new.html.heex | 7 - .../phx.gen.auth/reset_password_controller.ex | 58 ---- .../reset_password_controller_test.exs | 123 ------- .../reset_password_edit.html.heex | 36 -- .../phx.gen.auth/reset_password_html.ex | 5 - .../phx.gen.auth/reset_password_live.ex | 96 ------ .../phx.gen.auth/reset_password_live_test.exs | 118 ------- .../phx.gen.auth/reset_password_new.html.heex | 20 -- priv/templates/phx.gen.auth/routes.ex | 47 +-- priv/templates/phx.gen.auth/schema.ex | 130 +++----- priv/templates/phx.gen.auth/schema_token.ex | 45 ++- .../phx.gen.auth/session_confirm.html.heex | 38 +++ .../phx.gen.auth/session_controller.ex | 109 +++++- .../phx.gen.auth/session_controller_test.exs | 161 ++++++--- priv/templates/phx.gen.auth/session_html.ex | 4 + .../phx.gen.auth/session_new.html.heex | 84 +++-- .../phx.gen.auth/settings_controller.ex | 21 +- .../phx.gen.auth/settings_controller_test.exs | 15 +- .../phx.gen.auth/settings_edit.html.heex | 23 +- priv/templates/phx.gen.auth/settings_live.ex | 79 ++--- .../phx.gen.auth/settings_live_test.exs | 40 +-- priv/templates/phx.gen.auth/test_cases.exs | 310 +++++------------- 47 files changed, 1247 insertions(+), 1951 deletions(-) delete mode 100644 priv/templates/phx.gen.auth/confirmation_controller.ex delete mode 100644 priv/templates/phx.gen.auth/confirmation_controller_test.exs delete mode 100644 priv/templates/phx.gen.auth/confirmation_edit.html.heex delete mode 100644 priv/templates/phx.gen.auth/confirmation_html.ex delete mode 100644 priv/templates/phx.gen.auth/confirmation_instructions_live.ex delete mode 100644 priv/templates/phx.gen.auth/confirmation_instructions_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/confirmation_new.html.heex delete mode 100644 priv/templates/phx.gen.auth/forgot_password_live.ex delete mode 100644 priv/templates/phx.gen.auth/forgot_password_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/reset_password_controller.ex delete mode 100644 priv/templates/phx.gen.auth/reset_password_controller_test.exs delete mode 100644 priv/templates/phx.gen.auth/reset_password_edit.html.heex delete mode 100644 priv/templates/phx.gen.auth/reset_password_html.ex delete mode 100644 priv/templates/phx.gen.auth/reset_password_live.ex delete mode 100644 priv/templates/phx.gen.auth/reset_password_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/reset_password_new.html.heex create mode 100644 priv/templates/phx.gen.auth/session_confirm.html.heex diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 5dcec16f61..da83a1aa45 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -298,34 +298,6 @@ defmodule Mix.Tasks.Phx.Gen.Auth do "#{singular}_live", "login_test.exs" ], - "reset_password_live.ex": [ - web_pre, - "live", - web_path, - "#{singular}_live", - "reset_password.ex" - ], - "reset_password_live_test.exs": [ - web_test_pre, - "live", - web_path, - "#{singular}_live", - "reset_password_test.exs" - ], - "forgot_password_live.ex": [ - web_pre, - "live", - web_path, - "#{singular}_live", - "forgot_password.ex" - ], - "forgot_password_live_test.exs": [ - web_test_pre, - "live", - web_path, - "#{singular}_live", - "forgot_password_test.exs" - ], "settings_live.ex": [web_pre, "live", web_path, "#{singular}_live", "settings.ex"], "settings_live_test.exs": [ web_test_pre, @@ -347,20 +319,6 @@ defmodule Mix.Tasks.Phx.Gen.Auth do web_path, "#{singular}_live", "confirmation_test.exs" - ], - "confirmation_instructions_live.ex": [ - web_pre, - "live", - web_path, - "#{singular}_live", - "confirmation_instructions.ex" - ], - "confirmation_instructions_live_test.exs": [ - web_test_pre, - "live", - web_path, - "#{singular}_live", - "confirmation_instructions_test.exs" ] ] @@ -368,24 +326,6 @@ defmodule Mix.Tasks.Phx.Gen.Auth do _ -> non_live_files = [ - "confirmation_html.ex": [controller_pre, "#{singular}_confirmation_html.ex"], - "confirmation_new.html.heex": [ - controller_pre, - "#{singular}_confirmation_html", - "new.html.heex" - ], - "confirmation_edit.html.heex": [ - controller_pre, - "#{singular}_confirmation_html", - "edit.html.heex" - ], - "confirmation_controller.ex": [controller_pre, "#{singular}_confirmation_controller.ex"], - "confirmation_controller_test.exs": [ - web_test_pre, - "controllers", - web_path, - "#{singular}_confirmation_controller_test.exs" - ], "registration_new.html.heex": [ controller_pre, "#{singular}_registration_html", @@ -399,29 +339,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth do "#{singular}_registration_controller_test.exs" ], "registration_html.ex": [controller_pre, "#{singular}_registration_html.ex"], - "reset_password_html.ex": [controller_pre, "#{singular}_reset_password_html.ex"], - "reset_password_controller.ex": [ - controller_pre, - "#{singular}_reset_password_controller.ex" - ], - "reset_password_controller_test.exs": [ - web_test_pre, - "controllers", - web_path, - "#{singular}_reset_password_controller_test.exs" - ], - "reset_password_edit.html.heex": [ - controller_pre, - "#{singular}_reset_password_html", - "edit.html.heex" - ], - "reset_password_new.html.heex": [ - controller_pre, - "#{singular}_reset_password_html", - "new.html.heex" - ], "session_html.ex": [controller_pre, "#{singular}_session_html.ex"], "session_new.html.heex": [controller_pre, "#{singular}_session_html", "new.html.heex"], + "session_confirm.html.heex": [controller_pre, "#{singular}_session_html", "confirm.html.heex"], "settings_html.ex": [web_pre, "controllers", web_path, "#{singular}_settings_html.ex"], "settings_controller.ex": [controller_pre, "#{singular}_settings_controller.ex"], "settings_edit.html.heex": [ diff --git a/priv/templates/phx.gen.auth/auth.ex b/priv/templates/phx.gen.auth/auth.ex index a65c01fe66..30d6274f76 100644 --- a/priv/templates/phx.gen.auth/auth.ex +++ b/priv/templates/phx.gen.auth/auth.ex @@ -24,24 +24,34 @@ defmodule <%= inspect auth_module %> do so LiveView sessions are identified and automatically disconnected on log out. The line can be safely removed if you are not using LiveView. + + In case the <%= schema.singular %> re-authenticates for sudo mode, + the existing remember_me setting is kept, writing a new remember_me cookie. """ def log_in_<%= schema.singular %>(conn, <%= schema.singular %>, params \\ %{}) do token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) <%= schema.singular %>_return_to = get_session(conn, :<%= schema.singular %>_return_to) + remember_me = get_session(conn, :<%= schema.singular %>_remember_me) conn |> renew_session() |> put_token_in_session(token) - |> maybe_write_remember_me_cookie(token, params) + |> maybe_write_remember_me_cookie(token, params, remember_me) |> redirect(to: <%= schema.singular %>_return_to || signed_in_path(conn)) end - defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do - put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) - end + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, token, _params, true), + do: write_remember_me_cookie(conn, token) - defp maybe_write_remember_me_cookie(conn, _token, _params) do + defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn + + defp write_remember_me_cookie(conn, token) do conn + |> put_session(:<%= schema.singular %>_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) end # This function renews the session ID and erases the whole @@ -124,9 +134,6 @@ defmodule <%= inspect auth_module %> do on <%= schema.singular %>_token. Redirects to login page if there's no logged <%= schema.singular %>. - * `:redirect_if_<%= schema.singular %>_is_authenticated` - Authenticates the <%= schema.singular %> from the session. - Redirects to signed_in_path if there's a logged <%= schema.singular %>. - ## Examples Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate @@ -164,13 +171,18 @@ defmodule <%= inspect auth_module %> do end end - def on_mount(:redirect_if_<%= schema.singular %>_is_authenticated, _params, session, socket) do + def on_mount(:ensure_sudo_mode, _params, session, socket) do socket = mount_current_<%= schema.singular %>(socket, session) - if socket.assigns.current_<%= schema.singular %> do - {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} - else + if <%= inspect context.alias %>.sudo_mode?(socket.assigns.current_<%= schema.singular %>, -10) do {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"<%= schema.route_prefix %>/log-in") + + {:halt, socket} end end @@ -182,7 +194,22 @@ defmodule <%= inspect auth_module %> do end) end - <% end %>@doc """ + <% else %>@doc """ + Used for routes that require sudo mode. + """ + def require_sudo_mode(conn, _opts) do + if <%= inspect context.alias %>.sudo_mode?(conn.assigns.current_<%= schema.singular %>, -10) do + conn + else + conn + |> put_flash(:error, "You must re-authenticate to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") + |> halt() + end + end + + @doc """ Used for routes that require the <%= schema.singular %> to not be authenticated. """ def redirect_if_<%= schema.singular %>_is_authenticated(conn, _opts) do @@ -195,7 +222,7 @@ defmodule <%= inspect auth_module %> do end end - @doc """ + <% end %>@doc """ Used for routes that require the <%= schema.singular %> to be authenticated. If you want to enforce the <%= schema.singular %> email is confirmed before @@ -213,17 +240,38 @@ defmodule <%= inspect auth_module %> do end end - defp put_token_in_session(conn, token) do + <%= if live? do %>defp put_token_in_session(conn, token) do conn |> put_session(:<%= schema.singular %>_token, token) - |> put_session(:live_socket_id, "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}") + |> put_session(:live_socket_id, <%= schema.singular %>_session_topic(token)) + end + + @doc """ + Disconnects existing sockets for the given tokens. + """ + def disconnect_sessions(tokens) do + Enum.each(tokens, fn %{token: token} -> + <%= inspect endpoint_module %>.broadcast(<%= schema.singular %>_session_topic(token), "disconnect", %{}) + end) end - defp maybe_store_return_to(%{method: "GET"} = conn) do + defp <%= schema.singular %>_session_topic(token), do: "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}" + + <% else %>defp put_token_in_session(conn, token) do + put_session(conn, :<%= schema.singular %>_token, token) + end + + <% end %>defp maybe_store_return_to(%{method: "GET"} = conn) do put_session(conn, :<%= schema.singular %>_return_to, current_path(conn)) end defp maybe_store_return_to(conn), do: conn - defp signed_in_path(_conn), do: ~p"/" + <%= if live? do %>@doc "Returns the path to redirect to after log in." + # the <%= schema.singular %> was already logged in, redirect to settings + def signed_in_path(%Plug.Conn{assigns: %{current_<%= schema.singular %>: %<%= inspect context.alias %>.<%= inspect schema.alias %>{}}}) do + ~p"<%= schema.route_prefix %>/settings" + end + + def signed_in_path(_), do: ~p"/"<% else %>defp signed_in_path(_conn), do: ~p"/"<% end %> end diff --git a/priv/templates/phx.gen.auth/auth_test.exs b/priv/templates/phx.gen.auth/auth_test.exs index b88173a9ca..89082ed184 100644 --- a/priv/templates/phx.gen.auth/auth_test.exs +++ b/priv/templates/phx.gen.auth/auth_test.exs @@ -14,14 +14,14 @@ defmodule <%= inspect auth_module %>Test do |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base)) |> init_test_session(%{}) - %{<%= schema.singular %>: <%= schema.singular %>_fixture(), conn: conn} + %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: DateTime.utc_now()}, conn: conn} end describe "log_in_<%= schema.singular %>/3" do test "stores the <%= schema.singular %> token in the session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(conn, <%= schema.singular %>) - assert token = get_session(conn, :<%= schema.singular %>_token) - assert get_session(conn, :live_socket_id) == "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}" + assert token = get_session(conn, :<%= schema.singular %>_token)<%= if live? do %> + assert get_session(conn, :live_socket_id) == "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}"<% end %> assert redirected_to(conn) == ~p"/" assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) end @@ -39,10 +39,38 @@ defmodule <%= inspect auth_module %>Test do test "writes a cookie if remember_me is configured", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) assert get_session(conn, :<%= schema.singular %>_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :<%= schema.singular %>_remember_me) == true assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] assert signed_token != get_session(conn, :<%= schema.singular %>_token) assert max_age == 5_184_000 + end<%= if live? do %> + + test "redirects to settings when <%= schema.singular %> is already logged in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + conn = conn |> assign(:current_<%= schema.singular %>, <%= schema.singular %>) |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) + assert redirected_to(conn) == "<%= schema.route_prefix %>/settings" + end<% end %> + + test "writes a cookie if remember_me was set in previous session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) + assert get_session(conn, :<%= schema.singular %>_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :<%= schema.singular %>_remember_me) == true + + conn = + conn + |> recycle() + |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base)) + |> fetch_cookies() + |> init_test_session(%{<%= schema.singular %>_remember_me: true}) + + # the conn is already logged in and has the remeber_me cookie set, + # now we log in again and even without explicitly setting remember_me, + # the cookie should be set again + conn = conn |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{}) + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :<%= schema.singular %>_token) + assert max_age == 5_184_000 + assert get_session(conn, :<%= schema.singular %>_remember_me) == true end end @@ -64,7 +92,7 @@ defmodule <%= inspect auth_module %>Test do refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token) end - test "broadcasts to the given live_socket_id", %{conn: conn} do + <%= if live? do %>test "broadcasts to the given live_socket_id", %{conn: conn} do live_socket_id = "<%= schema.plural %>_sessions:abcdef-token" <%= inspect(endpoint_module) %>.subscribe(live_socket_id) @@ -75,7 +103,7 @@ defmodule <%= inspect auth_module %>Test do assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} end - test "works even if <%= schema.singular %> is already logged out", %{conn: conn} do + <% end %>test "works even if <%= schema.singular %> is already logged out", %{conn: conn} do conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_out_<%= schema.singular %>() refute get_session(conn, :<%= schema.singular %>_token) assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] @@ -103,10 +131,10 @@ defmodule <%= inspect auth_module %>Test do |> <%= inspect schema.alias %>Auth.fetch_current_<%= schema.singular %>([]) assert conn.assigns.current_<%= schema.singular %>.id == <%= schema.singular %>.id - assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token + assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token<%= if live? do %> assert get_session(conn, :live_socket_id) == - "<%= schema.plural %>_sessions:#{Base.url_encode64(<%= schema.singular %>_token)}" + "<%= schema.plural %>_sessions:#{Base.url_encode64(<%= schema.singular %>_token)}"<% end %> end test "does not authenticate if data is missing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do @@ -185,34 +213,64 @@ defmodule <%= inspect auth_module %>Test do end end - describe "on_mount :redirect_if_<%= schema.singular %>_is_authenticated" do - test "redirects if there is an authenticated <%= schema.singular %> ", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + describe "on_mount :ensure_sudo_mode" do + test "allows <%= schema.plural %> that have authenticated in the last 10 minutes", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() + socket = %LiveView.Socket{ + endpoint: <%= inspect(endpoint_module) %>, + assigns: %{__changed__: %{}, flash: %{}} + } + + assert {:cont, _updated_socket} = + <%= inspect schema.alias %>Auth.on_mount(:ensure_sudo_mode, %{}, session, socket) + end + + test "redirects when authentication is too old", %{<%= schema.singular %>: <%= schema.singular %>} do + eleven_minutes_ago = DateTime.utc_now() |> DateTime.add(-11, :minute) + + socket = %LiveView.Socket{ + endpoint: AuthAppWeb.Endpoint, + assigns: %{ + __changed__: %{}, + flash: %{}, + current_<%= schema.singular %>: %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago} + } + } + assert {:halt, _updated_socket} = - <%= inspect schema.alias %>Auth.on_mount( - :redirect_if_<%= schema.singular %>_is_authenticated, - %{}, - session, - %LiveView.Socket{} - ) + <%= inspect schema.alias %>Auth.on_mount(:ensure_sudo_mode, %{}, %{}, socket) end + end<% else %>describe "require_sudo_mode/2" do + test "allows <%= schema.plural %> that have authenticated in the last 10 minutes", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + conn = + conn + |> fetch_flash() + |> assign(:current_<%= schema.singular %>, <%= schema.singular %>) + |> <%= inspect schema.alias %>Auth.require_sudo_mode([]) - test "doesn't redirect if there is no authenticated <%= schema.singular %>", %{conn: conn} do - session = conn |> get_session() + refute conn.halted + refute conn.status + end - assert {:cont, _updated_socket} = - <%= inspect schema.alias %>Auth.on_mount( - :redirect_if_<%= schema.singular %>_is_authenticated, - %{}, - session, - %LiveView.Socket{} - ) + test "redirects when authentication is too old", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + eleven_minutes_ago = DateTime.utc_now() |> DateTime.add(-11, :minute) + + conn = + conn + |> fetch_flash() + |> assign(:current_<%= schema.singular %>, %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago}) + |> <%= inspect schema.alias %>Auth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." end end - <% end %>describe "redirect_if_<%= schema.singular %>_is_authenticated/2" do + describe "redirect_if_<%= schema.singular %>_is_authenticated/2" do test "redirects if <%= schema.singular %> is authenticated", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = conn |> assign(:current_<%= schema.singular %>, <%= schema.singular %>) |> <%= inspect schema.alias %>Auth.redirect_if_<%= schema.singular %>_is_authenticated([]) assert conn.halted @@ -224,7 +282,7 @@ defmodule <%= inspect auth_module %>Test do refute conn.halted refute conn.status end - end + end<% end %> describe "require_authenticated_<%= schema.singular %>/2" do test "redirects if <%= schema.singular %> is not authenticated", %{conn: conn} do @@ -268,5 +326,27 @@ defmodule <%= inspect auth_module %>Test do refute conn.halted refute conn.status end - end + end<%= if live? do %> + + describe "disconnect_sessions/1" do + test "broadcasts disconnect messages for each token" do + tokens = [%{token: "token1"}, %{token: "token2"}] + + for %{token: token} <- tokens do + <%= inspect context.web_module %>.Endpoint.subscribe("<%= schema.plural %>_sessions:#{Base.url_encode64(token)}") + end + + <%= inspect schema.alias %>Auth.disconnect_sessions(tokens) + + assert_receive %Phoenix.Socket.Broadcast{ + event: "disconnect", + topic: "<%= schema.plural %>_sessions:dG9rZW4x" + } + + assert_receive %Phoenix.Socket.Broadcast{ + event: "disconnect", + topic: "<%= schema.plural %>_sessions:dG9rZW4y" + } + end + end<% end %> end diff --git a/priv/templates/phx.gen.auth/confirmation_controller.ex b/priv/templates/phx.gen.auth/confirmation_controller.ex deleted file mode 100644 index 5a70d6e062..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_controller.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ConfirmationController do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - - def new(conn, _params) do - render(conn, :new) - end - - def create(conn, %{"<%= schema.singular %>" => %{"email" => email}}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/confirm/#{&1}") - ) - end - - conn - |> put_flash( - :info, - "If your email is in our system and it has not been confirmed yet, " <> - "you will receive an email with instructions shortly." - ) - |> redirect(to: ~p"/") - end - - def edit(conn, %{"token" => token}) do - render(conn, :edit, token: token) - end - - # Do not log in the <%= schema.singular %> after confirmation to avoid a - # leaked token giving the <%= schema.singular %> access to the account. - def update(conn, %{"token" => token}) do - case <%= inspect context.alias %>.confirm_<%= schema.singular %>(token) do - {:ok, _} -> - conn - |> put_flash(:info, "<%= schema.human_singular %> confirmed successfully.") - |> redirect(to: ~p"/") - - :error -> - # If there is a current <%= schema.singular %> and the account was already confirmed, - # then odds are that the confirmation link was already visited, either - # by some automation or by the <%= schema.singular %> themselves, so we redirect without - # a warning message. - case conn.assigns do - %{current_<%= schema.singular %>: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> - redirect(conn, to: ~p"/") - - %{} -> - conn - |> put_flash(:error, "<%= schema.human_singular %> confirmation link is invalid or it has expired.") - |> redirect(to: ~p"/") - end - end - end -end diff --git a/priv/templates/phx.gen.auth/confirmation_controller_test.exs b/priv/templates/phx.gen.auth/confirmation_controller_test.exs deleted file mode 100644 index d38e11f889..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_controller_test.exs +++ /dev/null @@ -1,122 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ConfirmationControllerTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - alias <%= inspect context.module %> - alias <%= inspect schema.repo %><%= schema.repo_alias %> - import <%= inspect context.module %>Fixtures - - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - describe "GET <%= schema.route_prefix %>/confirm" do - test "renders the resend confirmation page", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/confirm") - response = html_response(conn, 200) - assert response =~ "Resend confirmation instructions" - end - end - - describe "POST <%= schema.route_prefix %>/confirm" do - @tag :capture_log - test "sends a new confirmation token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/confirm", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} - }) - - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "confirm" - end - - test "does not send confirmation token if <%= schema.human_singular %> is confirmed", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - Repo.update!(<%= inspect context.alias %>.<%= inspect schema.alias %>.confirm_changeset(<%= schema.singular %>)) - - conn = - post(conn, ~p"<%= schema.route_prefix %>/confirm", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} - }) - - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - refute Repo.get_by(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not send confirmation token if email is invalid", %{conn: conn} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/confirm", %{ - "<%= schema.singular %>" => %{"email" => "unknown@example.com"} - }) - - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] - end - end - - describe "GET <%= schema.route_prefix %>/confirm/:token" do - test "renders the confirmation page", %{conn: conn} do - token_path = ~p"<%= schema.route_prefix %>/confirm/some-token" - conn = get(conn, token_path) - response = html_response(conn, 200) - assert response =~ "Confirm account" - - assert response =~ "action=\"#{token_path}\"" - end - end - - describe "POST <%= schema.route_prefix %>/confirm/:token" do - test "confirms the given token once", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions(<%= schema.singular %>, url) - end) - - conn = post(conn, ~p"<%= schema.route_prefix %>/confirm/#{token}") - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "<%= schema.human_singular %> confirmed successfully" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at - refute get_session(conn, :<%= schema.singular %>_token) - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] - - # When not logged in - conn = post(conn, ~p"<%= schema.route_prefix %>/confirm/#{token}") - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "<%= schema.human_singular %> confirmation link is invalid or it has expired" - - # When logged in - conn = - build_conn() - |> log_in_<%= schema.singular %>(<%= schema.singular %>) - |> post(~p"<%= schema.route_prefix %>/confirm/#{token}") - - assert redirected_to(conn) == ~p"/" - refute Phoenix.Flash.get(conn.assigns.flash, :error) - end - - test "does not confirm email with invalid token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = post(conn, ~p"<%= schema.route_prefix %>/confirm/oops") - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "<%= schema.human_singular %> confirmation link is invalid or it has expired" - - refute <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at - end - end -end diff --git a/priv/templates/phx.gen.auth/confirmation_edit.html.heex b/priv/templates/phx.gen.auth/confirmation_edit.html.heex deleted file mode 100644 index 0f6d96d416..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_edit.html.heex +++ /dev/null @@ -1,14 +0,0 @@ -
- <.header class="text-center">Confirm account - - <.simple_form for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm/#{@token}"}> - <:actions> - <.button class="w-full">Confirm my account - - - -

} class="text-center mt-4"> - <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
diff --git a/priv/templates/phx.gen.auth/confirmation_html.ex b/priv/templates/phx.gen.auth/confirmation_html.ex deleted file mode 100644 index 539b8dc1e2..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ConfirmationHTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_confirmation_html/*" -end diff --git a/priv/templates/phx.gen.auth/confirmation_instructions_live.ex b/priv/templates/phx.gen.auth/confirmation_instructions_live.ex deleted file mode 100644 index bf54a99333..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_instructions_live.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ConfirmationInstructions do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - def render(assigns) do - ~H""" -
- <.header class="text-center"> - No confirmation instructions received? - <:subtitle>We'll send a new confirmation link to your inbox - - - <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> - <.input - field={@form[:email]} - type="email" - placeholder="Email" - autocomplete="username" - required - /> - <:actions> - <.button phx-disable-with="Sending..." class="w-full"> - Resend confirmation instructions - - - - -

} class="text-center mt-4"> - <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
- """ - end - - def mount(_params, _session, socket) do - {:ok, assign(socket, form: to_form(%{}, as: "<%= schema.singular %>"))} - end - - def handle_event("send_instructions", %{"<%= schema.singular %>" => %{"email" => email}}, socket) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/confirm/#{&1}") - ) - end - - info = - "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." - - {:noreply, - socket - |> put_flash(:info, info) - |> redirect(to: ~p"/")} - end -end diff --git a/priv/templates/phx.gen.auth/confirmation_instructions_live_test.exs b/priv/templates/phx.gen.auth/confirmation_instructions_live_test.exs deleted file mode 100644 index d8d1d23763..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_instructions_live_test.exs +++ /dev/null @@ -1,67 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ConfirmationInstructionsTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - alias <%= inspect context.module %> - alias <%= inspect schema.repo %><%= schema.repo_alias %> - - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - describe "Resend confirmation" do - test "renders the resend confirmation page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/confirm") - assert html =~ "Resend confirmation instructions" - end - - test "sends a new confirmation token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", <%= schema.singular %>: %{email: <%= schema.singular %>.email}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "confirm" - end - - test "does not send confirmation token if <%= schema.singular %> is confirmed", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - Repo.update!(<%= inspect context.alias %>.<%= inspect schema.alias %>.confirm_changeset(<%= schema.singular %>)) - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", <%= schema.singular %>: %{email: <%= schema.singular %>.email}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - refute Repo.get_by(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not send confirmation token if email is invalid", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", <%= schema.singular %>: %{email: "unknown@example.com"}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] - end - end -end diff --git a/priv/templates/phx.gen.auth/confirmation_live.ex b/priv/templates/phx.gen.auth/confirmation_live.ex index 251afacec0..c0fece4f59 100644 --- a/priv/templates/phx.gen.auth/confirmation_live.ex +++ b/priv/templates/phx.gen.auth/confirmation_live.ex @@ -3,56 +3,73 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web alias <%= inspect context.module %> - def render(%{live_action: :edit} = assigns) do + def render(assigns) do ~H"""
- <.header class="text-center">Confirm Account + <.header class="text-center">Welcome {@<%= schema.singular %>.email} - <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + <.simple_form + :if={!@<%= schema.singular %>.confirmed_at} + for={@form} + id="confirmation_form" + phx-submit="submit" + action={~p"<%= schema.route_prefix %>/log-in?_action=confirmed"} + phx-trigger-action={@trigger_submit} + > + <.input + :if={!@current_<%= schema.singular %>} + field={@form[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> <:actions> <.button phx-disable-with="Confirming..." class="w-full">Confirm my account -

} class="text-center mt-4"> - <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in + <.simple_form + :if={@<%= schema.singular %>.confirmed_at} + for={@form} + id="login_form" + phx-submit="submit" + action={~p"<%= schema.route_prefix %>/log-in"} + phx-trigger-action={@trigger_submit} + > + + <.input + :if={!@current_<%= schema.singular %>} + field={@form[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> + <:actions> + <.button phx-disable-with="Logging in..." class="w-full">Log in + + + +

.confirmed_at} class="mt-8 p-4 border text-center"> + Tip: If you prefer passwords, you can enable them in the <%= schema.singular %> settings.

""" end def mount(%{"token" => token}, _session, socket) do - form = to_form(%{"token" => token}, as: "<%= schema.singular %>") - {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} - end + if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) do + form = to_form(%{"token" => token}, as: "<%= schema.singular %>") - # Do not log in the <%= schema.singular %> after confirmation to avoid a - # leaked token giving the <%= schema.singular %> access to the account. - def handle_event("confirm_account", %{"<%= schema.singular %>" => %{"token" => token}}, socket) do - case <%= inspect context.alias %>.confirm_<%= schema.singular %>(token) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "<%= inspect schema.alias %> confirmed successfully.") - |> redirect(to: ~p"/")} - - :error -> - # If there is a current <%= schema.singular %> and the account was already confirmed, - # then odds are that the confirmation link was already visited, either - # by some automation or by the <%= schema.singular %> themselves, so we redirect without - # a warning message. - case socket.assigns do - %{current_<%= schema.singular %>: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> - {:noreply, redirect(socket, to: ~p"/")} - - %{} -> - {:noreply, - socket - |> put_flash(:error, "<%= inspect schema.alias %> confirmation link is invalid or it has expired.") - |> redirect(to: ~p"/")} - end + {:ok, assign(socket, <%= schema.singular %>: <%= schema.singular %>, form: form, trigger_submit: false), + temporary_assigns: [form: nil]} + else + {:ok, + socket + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} end end + + def handle_event("submit", %{"<%= schema.singular %>" => params}, socket) do + {:noreply, assign(socket, form: to_form(params, as: "<%= schema.singular %>"), trigger_submit: true)} + end end diff --git a/priv/templates/phx.gen.auth/confirmation_live_test.exs b/priv/templates/phx.gen.auth/confirmation_live_test.exs index 0eac2e43c0..8a25a044ee 100644 --- a/priv/templates/phx.gen.auth/confirmation_live_test.exs +++ b/priv/templates/phx.gen.auth/confirmation_live_test.exs @@ -5,85 +5,101 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web import <%= inspect context.module %>Fixtures alias <%= inspect context.module %> - alias <%= inspect schema.repo %><%= schema.repo_alias %> setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} + %{unconfirmed_<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture(), confirmed_<%= schema.singular %>: <%= schema.singular %>_fixture()} end describe "Confirm <%= schema.singular %>" do - test "renders confirmation page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/confirm/some-token") - assert html =~ "Confirm Account" + test "renders confirmation page for unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) + + {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + assert html =~ "Confirm my account" + end + + test "renders log in page for confirmed <%= schema.singular %>", %{conn: conn, confirmed_<%= schema.singular %>: <%= schema.singular %>} do + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) + + {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + refute html =~ "Confirm my account" + assert html =~ "Log in" end - test "confirms the given token once", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + test "confirms the given token once", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do token = extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions(<%= schema.singular %>, url) + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) end) - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm/#{token}") + {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, ~p"/") + form = form(lv, "#confirmation_form", %{"<%= schema.singular %>" => %{"token" => token}}) + render_submit(form) - assert {:ok, conn} = result + conn = follow_trigger_action(form, conn) assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "<%= inspect schema.alias %> confirmed successfully" assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at - refute get_session(conn, :<%= schema.singular %>_token) - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] + # we are logged in now + assert get_session(conn, :<%= schema.singular %>_token) + assert redirected_to(conn) == ~p"/" - # when not logged in - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm/#{token}") + # log out, new conn + conn = build_conn() - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, ~p"/") + {:ok, _lv, html} = + live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - assert {:ok, conn} = result + assert html =~ "Magic link is invalid or it has expired" + end - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "<%= inspect schema.alias %> confirmation link is invalid or it has expired" + test "logs confirmed <%= schema.singular %> in without changing confirmed_at", %{ + conn: conn, + confirmed_<%= schema.singular %>: <%= schema.singular %> + } do + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) - # when logged in - conn = - build_conn() - |> log_in_<%= schema.singular %>(<%= schema.singular %>) + {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm/#{token}") + form = form(lv, "#login_form", %{"<%= schema.singular %>" => %{"token" => token}}) + render_submit(form) - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, ~p"/") + conn = follow_trigger_action(form, conn) - assert {:ok, conn} = result - refute Phoenix.Flash.get(conn.assigns.flash, :error) - end + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Welcome back!" + + assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at == <%= schema.singular %>.confirmed_at - test "does not confirm email with invalid token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/confirm/invalid-token") + # log out, new conn + conn = build_conn() - {:ok, conn} = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, ~p"/") + {:ok, _lv, html} = + live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") + + assert html =~ "Magic link is invalid or it has expired" + end - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "<%= inspect schema.alias %> confirmation link is invalid or it has expired" + test "raises error for invalid token", %{conn: conn} do + {:ok, _lv, html} = + live(conn, ~p"<%= schema.route_prefix %>/log-in/invalid-token") + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - refute <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at + assert html =~ "Magic link is invalid or it has expired" end end end diff --git a/priv/templates/phx.gen.auth/confirmation_new.html.heex b/priv/templates/phx.gen.auth/confirmation_new.html.heex deleted file mode 100644 index 7edb1088c4..0000000000 --- a/priv/templates/phx.gen.auth/confirmation_new.html.heex +++ /dev/null @@ -1,20 +0,0 @@ -
- <.header class="text-center"> - No confirmation instructions received? - <:subtitle>We'll send a new confirmation link to your inbox - - - <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm"}> - <.input field={f[:email]} type="email" placeholder="Email" autocomplete="username" required /> - <:actions> - <.button phx-disable-with="Sending..." class="w-full"> - Resend confirmation instructions - - - - -

} class="text-center mt-4"> - <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
diff --git a/priv/templates/phx.gen.auth/conn_case.exs b/priv/templates/phx.gen.auth/conn_case.exs index c3939fd3a9..dafec5d16a 100644 --- a/priv/templates/phx.gen.auth/conn_case.exs +++ b/priv/templates/phx.gen.auth/conn_case.exs @@ -7,9 +7,15 @@ It stores an updated connection and a registered <%= schema.singular %> in the test context. """ - def register_and_log_in_<%= schema.singular %>(%{conn: conn}) do + def register_and_log_in_<%= schema.singular %>(%{conn: conn} = context) do <%= schema.singular %> = <%= inspect context.module %>Fixtures.<%= schema.singular %>_fixture() - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>} + + opts = + context + |> Map.take([:token_inserted_at]) + |> Enum.into([]) + + %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts), <%= schema.singular %>: <%= schema.singular %>} end @doc """ @@ -17,10 +23,18 @@ It returns an updated `conn`. """ - def log_in_<%= schema.singular %>(conn, <%= schema.singular %>) do + def log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts \\ []) do token = <%= inspect context.module %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) + maybe_set_token_inserted_at(token, opts[:token_inserted_at]) + conn |> Phoenix.ConnTest.init_test_session(%{}) |> Plug.Conn.put_session(:<%= schema.singular %>_token, token) end + + defp maybe_set_token_inserted_at(_token, nil), do: nil + + defp maybe_set_token_inserted_at(token, inserted_at) do + <%= inspect context.module %>Fixtures.override_token_inserted_at(token, inserted_at) + end diff --git a/priv/templates/phx.gen.auth/context_fixtures_functions.ex b/priv/templates/phx.gen.auth/context_fixtures_functions.ex index 5a8b1474e0..64a2f0c13f 100644 --- a/priv/templates/phx.gen.auth/context_fixtures_functions.ex +++ b/priv/templates/phx.gen.auth/context_fixtures_functions.ex @@ -1,18 +1,41 @@ + import Ecto.Query + + alias <%= inspect context.module %> + def unique_<%= schema.singular %>_email, do: "<%= schema.singular %>#{System.unique_integer()}@example.com" def valid_<%= schema.singular %>_password, do: "hello world!" def valid_<%= schema.singular %>_attributes(attrs \\ %{}) do Enum.into(attrs, %{ - email: unique_<%= schema.singular %>_email(), - password: valid_<%= schema.singular %>_password() + email: unique_<%= schema.singular %>_email() }) end - def <%= schema.singular %>_fixture(attrs \\ %{}) do + def unconfirmed_<%= schema.singular %>_fixture(attrs \\ %{}) do {:ok, <%= schema.singular %>} = attrs |> valid_<%= schema.singular %>_attributes() - |> <%= inspect context.module %>.register_<%= schema.singular %>() + |> <%= inspect context.alias %>.register_<%= schema.singular %>() + + <%= schema.singular %> + end + + def <%= schema.singular %>_fixture(attrs \\ %{}) do + <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture(attrs) + + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) + + {:ok, <%= schema.singular %>, _expired_tokens} = <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) + + <%= schema.singular %> + end + + def set_password(<%= schema.singular %>) do + {:ok, <%= schema.singular %>, _expired_tokens} = + <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: valid_<%= schema.singular %>_password()}) <%= schema.singular %> end @@ -22,3 +45,18 @@ [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") token end + + def override_token_inserted_at(token, inserted_at) when is_binary(token) do + <%= inspect schema.repo %>.update_all( + from(t in <%= inspect context.alias %>.<%= inspect schema.alias %>Token, + where: t.token == ^token + ), + set: [inserted_at: inserted_at] + ) + end + + def generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) do + {encoded_token, <%= schema.singular %>_token} = <%= inspect context.alias %>.<%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "login") + <%= inspect schema.repo %>.insert!(<%= schema.singular %>_token) + {encoded_token, <%= schema.singular %>_token.token} + end diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex index e3a2c090f1..a6b007c469 100644 --- a/priv/templates/phx.gen.auth/context_functions.ex +++ b/priv/templates/phx.gen.auth/context_functions.ex @@ -68,63 +68,45 @@ """ def register_<%= schema.singular %>(attrs) do %<%= inspect schema.alias %>{} - |> <%= inspect schema.alias %>.registration_changeset(attrs) + |> <%= inspect schema.alias %>.email_changeset(attrs) |> Repo.insert() end - @doc """ - Returns an `%Ecto.Changeset{}` for tracking <%= schema.singular %> changes. - - ## Examples + ## Settings - iex> change_<%= schema.singular %>_registration(<%= schema.singular %>) - %Ecto.Changeset{data: %<%= inspect schema.alias %>{}} + @doc """ + Checks whether the <%= schema.singular %> is in sudo mode. + The <%= schema.singular %> is in sudo mode when the last authentication was done no further + than 20 minutes ago. The limit can be given as second argument in minutes. """ - def change_<%= schema.singular %>_registration(%<%= inspect schema.alias %>{} = <%= schema.singular %>, attrs \\ %{}) do - <%= inspect schema.alias %>.registration_changeset(<%= schema.singular %>, attrs, hash_password: false, validate_email: false) + def sudo_mode?(<%= schema.singular %>, minutes \\ -20) + + def sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do + DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) end - ## Settings + def sudo_mode?(_<%= schema.singular %>, _minutes), do: false @doc """ Returns an `%Ecto.Changeset{}` for changing the <%= schema.singular %> email. + See `<%= inspect context.module %>.<%= inspect schema.alias %>.email_changeset/3` for a list of supported options. + ## Examples iex> change_<%= schema.singular %>_email(<%= schema.singular %>) %Ecto.Changeset{data: %<%= inspect schema.alias %>{}} """ - def change_<%= schema.singular %>_email(<%= schema.singular %>, attrs \\ %{}) do - <%= inspect schema.alias %>.email_changeset(<%= schema.singular %>, attrs, validate_email: false) - end - - @doc """ - Emulates that the email will change without actually changing - it in the database. - - ## Examples - - iex> apply_<%= schema.singular %>_email(<%= schema.singular %>, "valid password", %{email: ...}) - {:ok, %<%= inspect schema.alias %>{}} - - iex> apply_<%= schema.singular %>_email(<%= schema.singular %>, "invalid password", %{email: ...}) - {:error, %Ecto.Changeset{}} - - """ - def apply_<%= schema.singular %>_email(<%= schema.singular %>, password, attrs) do - <%= schema.singular %> - |> <%= inspect schema.alias %>.email_changeset(attrs) - |> <%= inspect schema.alias %>.validate_current_password(password) - |> Ecto.Changeset.apply_action(:update) + def change_<%= schema.singular %>_email(<%= schema.singular %>, attrs \\ %{}, opts \\ []) do + <%= inspect schema.alias %>.email_changeset(<%= schema.singular %>, attrs, opts) end @doc """ Updates the <%= schema.singular %> email using the given token. If the token matches, the <%= schema.singular %> email is updated and the token is deleted. - The confirmed_at date is also updated to the current time. """ def update_<%= schema.singular %>_email(<%= schema.singular %>, token) do context = "change:#{<%= schema.singular %>.email}" @@ -139,70 +121,48 @@ end defp <%= schema.singular %>_email_multi(<%= schema.singular %>, email, context) do - changeset = - <%= schema.singular %> - |> <%= inspect schema.alias %>.email_changeset(%{email: email}) - |> <%= inspect schema.alias %>.confirm_changeset() + changeset = <%= inspect schema.alias %>.email_changeset(<%= schema.singular %>, %{email: email}) Ecto.Multi.new() |> Ecto.Multi.update(:<%= schema.singular %>, changeset) |> Ecto.Multi.delete_all(:tokens, <%= inspect schema.alias %>Token.by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, [context])) end - @doc ~S""" - Delivers the update email instructions to the given <%= schema.singular %>. - - ## Examples - - iex> deliver_<%= schema.singular %>_update_email_instructions(<%= schema.singular %>, current_email, &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}")) - {:ok, %{to: ..., body: ...}} - - """ - def deliver_<%= schema.singular %>_update_email_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, current_email, update_email_url_fun) - when is_function(update_email_url_fun, 1) do - {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "change:#{current_email}") - - Repo.insert!(<%= schema.singular %>_token) - <%= inspect schema.alias %>Notifier.deliver_update_email_instructions(<%= schema.singular %>, update_email_url_fun.(encoded_token)) - end - @doc """ Returns an `%Ecto.Changeset{}` for changing the <%= schema.singular %> password. + See `<%= inspect context.module %>.<%= inspect schema.alias %>.password_changeset/3` for a list of supported options. + ## Examples iex> change_<%= schema.singular %>_password(<%= schema.singular %>) %Ecto.Changeset{data: %<%= inspect schema.alias %>{}} """ - def change_<%= schema.singular %>_password(<%= schema.singular %>, attrs \\ %{}) do - <%= inspect schema.alias %>.password_changeset(<%= schema.singular %>, attrs, hash_password: false) + def change_<%= schema.singular %>_password(<%= schema.singular %>, attrs \\ %{}, opts \\ []) do + <%= inspect schema.alias %>.password_changeset(<%= schema.singular %>, attrs, opts) end @doc """ Updates the <%= schema.singular %> password. + Returns the updated <%= schema.singular %>, as well as a list of expired tokens. + ## Examples - iex> update_<%= schema.singular %>_password(<%= schema.singular %>, "valid password", %{password: ...}) - {:ok, %<%= inspect schema.alias %>{}} + iex> update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: ...}) + {:ok, %<%= inspect schema.alias %>{}, [...]} - iex> update_<%= schema.singular %>_password(<%= schema.singular %>, "invalid password", %{password: ...}) + iex> update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "too short"}) {:error, %Ecto.Changeset{}} """ - def update_<%= schema.singular %>_password(<%= schema.singular %>, password, attrs) do - changeset = - <%= schema.singular %> - |> <%= inspect schema.alias %>.password_changeset(attrs) - |> <%= inspect schema.alias %>.validate_current_password(password) - - Ecto.Multi.new() - |> Ecto.Multi.update(:<%= schema.singular %>, changeset) - |> Ecto.Multi.delete_all(:tokens, <%= inspect schema.alias %>Token.by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, :all)) - |> Repo.transaction() + def update_<%= schema.singular %>_password(<%= schema.singular %>, attrs) do + <%= schema.singular %> + |> <%= inspect schema.alias %>.password_changeset(attrs) + |> update_<%= schema.singular %>_and_delete_all_tokens() |> case do - {:ok, %{<%= schema.singular %>: <%= schema.singular %>}} -> {:ok, <%= schema.singular %>} + {:ok, <%= schema.singular %>, expired_tokens} -> {:ok, <%= schema.singular %>, expired_tokens} {:error, :<%= schema.singular %>, changeset, _} -> {:error, changeset} end end @@ -227,118 +187,111 @@ end @doc """ - Deletes the signed token with the given context. - """ - def delete_<%= schema.singular %>_session_token(token) do - Repo.delete_all(<%= inspect schema.alias %>Token.by_token_and_context_query(token, "session")) - :ok - end - - ## Confirmation - - @doc ~S""" - Delivers the confirmation email instructions to the given <%= schema.singular %>. - - ## Examples - - iex> deliver_<%= schema.singular %>_confirmation_instructions(<%= schema.singular %>, &url(~p"<%= schema.route_prefix %>/confirm/#{&1}")) - {:ok, %{to: ..., body: ...}} - - iex> deliver_<%= schema.singular %>_confirmation_instructions(confirmed_<%= schema.singular %>, &url(~p"<%= schema.route_prefix %>/confirm/#{&1}")) - {:error, :already_confirmed} - + Gets the <%= schema.singular %> with the given magic link token. """ - def deliver_<%= schema.singular %>_confirmation_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, confirmation_url_fun) - when is_function(confirmation_url_fun, 1) do - if <%= schema.singular %>.confirmed_at do - {:error, :already_confirmed} + def get_<%= schema.singular %>_by_magic_link_token(token) do + with {:ok, query} <- <%= inspect schema.alias %>Token.verify_magic_link_token_query(token), + {<%= schema.singular %>, _token} <- Repo.one(query) do + <%= schema.singular %> else - {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "confirm") - Repo.insert!(<%= schema.singular %>_token) - <%= inspect schema.alias %>Notifier.deliver_confirmation_instructions(<%= schema.singular %>, confirmation_url_fun.(encoded_token)) + _ -> nil end end @doc """ - Confirms a <%= schema.singular %> by the given token. + Logs the <%= schema.singular %> in by magic link. + + There are three cases to consider: + + 1. The <%= schema.singular %> has already confirmed their email. They are logged in + and the magic link is expired. + + 2. The <%= schema.singular %> has not confirmed their email and no password is set. + In this case, the <%= schema.singular %> gets confirmed, logged in, and all tokens - + including session ones - are expired. In theory, no other tokens + exist but we delete all of them for best security practices. - If the token matches, the <%= schema.singular %> account is marked as confirmed - and the token is deleted. + 3. The <%= schema.singular %> has not confirmed their email but a password is set. + This cannot happen in the default implementation but may be the + source of security pitfalls. See the "Mixing magic link and password + registration" section of `mix help phx.gen.auth`. """ - def confirm_<%= schema.singular %>(token) do - with {:ok, query} <- <%= inspect schema.alias %>Token.verify_email_token_query(token, "confirm"), - %<%= inspect schema.alias %>{} = <%= schema.singular %> <- Repo.one(query), - {:ok, %{<%= schema.singular %>: <%= schema.singular %>}} <- Repo.transaction(confirm_<%= schema.singular %>_multi(<%= schema.singular %>)) do - {:ok, <%= schema.singular %>} - else - _ -> :error + def login_<%= schema.singular %>_by_magic_link(token) do + {:ok, query} = <%= inspect schema.alias %>Token.verify_magic_link_token_query(token) + + case Repo.one(query) do + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password + {%<%= inspect schema.alias %>{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> + raise """ + magic link log in is not allowed for unconfirmed users with a password set! + + This cannot happen with the default implementation, which indicates that you + might have adapted the code to a different use case. Please make sure to read the + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + """ + + {%<%= inspect schema.alias %>{confirmed_at: nil} = <%= schema.singular %>, _token} -> + <%= schema.singular %> + |> <%= inspect schema.alias %>.confirm_changeset() + |> update_<%= schema.singular %>_and_delete_all_tokens() + + {<%= schema.singular %>, token} -> + Repo.delete!(token) + {:ok, <%= schema.singular %>, []} + + nil -> + {:error, :not_found} end end - defp confirm_<%= schema.singular %>_multi(<%= schema.singular %>) do - Ecto.Multi.new() - |> Ecto.Multi.update(:<%= schema.singular %>, <%= inspect schema.alias %>.confirm_changeset(<%= schema.singular %>)) - |> Ecto.Multi.delete_all(:tokens, <%= inspect schema.alias %>Token.by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, ["confirm"])) - end - - ## Reset password - @doc ~S""" - Delivers the reset password email to the given <%= schema.singular %>. + Delivers the update email instructions to the given <%= schema.singular %>. ## Examples - iex> deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, &url(~p"<%= schema.route_prefix %>/reset-password/#{&1}")) + iex> deliver_<%= schema.singular %>_update_email_instructions(<%= schema.singular %>, current_email, &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}")) {:ok, %{to: ..., body: ...}} """ - def deliver_<%= schema.singular %>_reset_password_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, reset_password_url_fun) - when is_function(reset_password_url_fun, 1) do - {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "reset_password") + def deliver_<%= schema.singular %>_update_email_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "change:#{current_email}") + Repo.insert!(<%= schema.singular %>_token) - <%= inspect schema.alias %>Notifier.deliver_reset_password_instructions(<%= schema.singular %>, reset_password_url_fun.(encoded_token)) + <%= inspect schema.alias %>Notifier.deliver_update_email_instructions(<%= schema.singular %>, update_email_url_fun.(encoded_token)) end - @doc """ - Gets the <%= schema.singular %> by reset password token. - - ## Examples - - iex> get_<%= schema.singular %>_by_reset_password_token("validtoken") - %<%= inspect schema.alias %>{} - - iex> get_<%= schema.singular %>_by_reset_password_token("invalidtoken") - nil - + @doc ~S""" + Delivers the magic link login instructions to the given <%= schema.singular %>. """ - def get_<%= schema.singular %>_by_reset_password_token(token) do - with {:ok, query} <- <%= inspect schema.alias %>Token.verify_email_token_query(token, "reset_password"), - %<%= inspect schema.alias %>{} = <%= schema.singular %> <- Repo.one(query) do - <%= schema.singular %> - else - _ -> nil - end + def deliver_login_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, magic_link_url_fun) + when is_function(magic_link_url_fun, 1) do + {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "login") + Repo.insert!(<%= schema.singular %>_token) + <%= inspect schema.alias %>Notifier.deliver_login_instructions(<%= schema.singular %>, magic_link_url_fun.(encoded_token)) end @doc """ - Resets the <%= schema.singular %> password. - - ## Examples + Deletes the signed token with the given context. + """ + def delete_<%= schema.singular %>_session_token(token) do + Repo.delete_all(<%= inspect schema.alias %>Token.by_token_and_context_query(token, "session")) + :ok + end - iex> reset_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "new long password", password_confirmation: "new long password"}) - {:ok, %<%= inspect schema.alias %>{}} + ## Token helper - iex> reset_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "valid", password_confirmation: "not the same"}) - {:error, %Ecto.Changeset{}} + defp update_<%= schema.singular %>_and_delete_all_tokens(changeset) do + %{data: %<%= inspect schema.alias %>{} = <%= schema.singular %>} = changeset - """ - def reset_<%= schema.singular %>_password(<%= schema.singular %>, attrs) do - Ecto.Multi.new() - |> Ecto.Multi.update(:<%= schema.singular %>, <%= inspect schema.alias %>.password_changeset(<%= schema.singular %>, attrs)) - |> Ecto.Multi.delete_all(:tokens, <%= inspect schema.alias %>Token.by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, :all)) - |> Repo.transaction() - |> case do - {:ok, %{<%= schema.singular %>: <%= schema.singular %>}} -> {:ok, <%= schema.singular %>} - {:error, :<%= schema.singular %>, changeset, _} -> {:error, changeset} + with {:ok, %{<%= schema.singular %>: <%= schema.singular %>, tokens_to_expire: expired_tokens}} <- + Ecto.Multi.new() + |> Ecto.Multi.update(:<%= schema.singular %>, changeset) + |> Ecto.Multi.all(:tokens_to_expire, <%= inspect schema.alias %>Token.by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, :all)) + |> Ecto.Multi.delete_all(:tokens, fn %{tokens_to_expire: tokens_to_expire} -> + <%= inspect schema.alias %>Token.delete_all_query(tokens_to_expire) + end) + |> Repo.transaction() do + {:ok, <%= schema.singular %>, expired_tokens} end end diff --git a/priv/templates/phx.gen.auth/forgot_password_live.ex b/priv/templates/phx.gen.auth/forgot_password_live.ex deleted file mode 100644 index 9c30c3c90e..0000000000 --- a/priv/templates/phx.gen.auth/forgot_password_live.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ForgotPassword do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - def render(assigns) do - ~H""" -
- <.header class="text-center"> - Forgot your password? - <:subtitle>We'll send a password reset link to your inbox - - - <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> - <.input - field={@form[:email]} - type="email" - placeholder="Email" - autocomplete="username" - required - /> - <:actions> - <.button phx-disable-with="Sending..." class="w-full"> - Send password reset instructions - - - -

- <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
- """ - end - - def mount(_params, _session, socket) do - {:ok, assign(socket, form: to_form(%{}, as: "<%= schema.singular %>"))} - end - - def handle_event("send_email", %{"<%= schema.singular %>" => %{"email" => email}}, socket) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/reset-password/#{&1}") - ) - end - - info = - "If your email is in our system, you will receive instructions to reset your password shortly." - - {:noreply, - socket - |> put_flash(:info, info) - |> redirect(to: ~p"/")} - end -end diff --git a/priv/templates/phx.gen.auth/forgot_password_live_test.exs b/priv/templates/phx.gen.auth/forgot_password_live_test.exs deleted file mode 100644 index 2a7f5b3620..0000000000 --- a/priv/templates/phx.gen.auth/forgot_password_live_test.exs +++ /dev/null @@ -1,63 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ForgotPasswordTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - alias <%= inspect context.module %> - alias <%= inspect schema.repo %><%= schema.repo_alias %> - - describe "Forgot password page" do - test "renders email page", %{conn: conn} do - {:ok, lv, html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password") - - assert html =~ "Forgot your password?" - assert has_element?(lv, ~s|a[href="#{~p"<%= schema.route_prefix %>/register"}"]|, "Register") - assert has_element?(lv, ~s|a[href="#{~p"<%= schema.route_prefix %>/log-in"}"]|, "Log in") - end - - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture()) - |> live(~p"<%= schema.route_prefix %>/reset-password") - |> follow_redirect(conn, ~p"/") - - assert {:ok, _conn} = result - end - end - - describe "Reset link" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "sends a new reset password token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password") - - {:ok, conn} = - lv - |> form("#reset_password_form", <%= schema.singular %>: %{"email" => <%= schema.singular %>.email}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" - - assert Repo.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == - "reset_password" - end - - test "does not send reset password token if email is invalid", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password") - - {:ok, conn} = - lv - |> form("#reset_password_form", <%= schema.singular %>: %{"email" => "unknown@example.com"}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] - end - end -end diff --git a/priv/templates/phx.gen.auth/login_live.ex b/priv/templates/phx.gen.auth/login_live.ex index 77bb16d791..8924831e96 100644 --- a/priv/templates/phx.gen.auth/login_live.ex +++ b/priv/templates/phx.gen.auth/login_live.ex @@ -1,49 +1,127 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Login do use <%= inspect context.web_module %>, :live_view + alias <%= inspect context.module %> + def render(assigns) do ~H"""
<.header class="text-center"> - Log in to account +

Log in

<:subtitle> - Don't have an account? - <.link navigate={~p"<%= schema.route_prefix %>/register"} class="font-semibold text-brand hover:underline"> - Sign up - - for an account now. + <%%= if @current_<%= schema.singular %> do %> + You need to reauthenticate to perform sensitive actions on your account. + <%% else %> + Don't have an account? <.link + navigate={~p"<%= schema.route_prefix %>/register"} + class="font-semibold text-brand hover:underline" + phx-no-format + >Sign up for an account now. + <%% end %> - <.simple_form for={@form} id="login_form" action={~p"<%= schema.route_prefix %>/log-in"} phx-update="ignore"> - <.input field={@form[:email]} type="email" label="Email" autocomplete="username" required /> + <.simple_form + :let={f} + for={@form} + id="login_form_magic" + action={~p"<%= schema.route_prefix %>/log-in"} + phx-submit="submit_magic" + > + <.input + readonly={!!@current_<%= schema.singular %>} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + required + /> + <.button class="w-full"> + Log in with email + + + +
+
+ or +
+
+ + <.simple_form + :let={f} + for={@form} + id="login_form_password" + action={~p"<%= schema.route_prefix %>/log-in"} + phx-submit="submit_password" + phx-trigger-action={@trigger_submit} + > + <.input + readonly={!!@current_<%= schema.singular %>} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + required + /> <.input field={@form[:password]} type="password" label="Password" autocomplete="current-password" - required /> - - <:actions> - <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> - <.link href={~p"<%= schema.route_prefix %>/reset-password"} class="text-sm font-semibold"> - Forgot your password? - - - <:actions> - <.button class="w-full"> - Log in - - + <.input + :if={!@current_<%= schema.singular %>} + field={f[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> + <.button class="w-full"> + Log in + + +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
""" end def mount(_params, _session, socket) do - email = Phoenix.Flash.get(socket.assigns.flash, :email) + email = + Phoenix.Flash.get(socket.assigns.flash, :email) || + get_in(socket.assigns, [:current_<%= schema.singular %>, Access.key(:email)]) + form = to_form(%{"email" => email}, as: "<%= schema.singular %>") - {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + + {:ok, assign(socket, form: form, trigger_submit: false)} + end + + def handle_event("submit_password", _params, socket) do + {:noreply, assign(socket, :trigger_submit, true)} + end + + def handle_event("submit_magic", %{"<%= schema.singular %>" => %{"email" => email}}, socket) do + if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do + <%= inspect context.alias %>.deliver_login_instructions( + <%= schema.singular %>, + &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} + end + + defp local_mail_adapter? do + Application.get_env(:<%= Mix.Phoenix.otp_app() %>, <%= inspect context.base_module %>.Mailer)[:adapter] == Swoosh.Adapters.Local end end diff --git a/priv/templates/phx.gen.auth/login_live_test.exs b/priv/templates/phx.gen.auth/login_live_test.exs index 11516b78ac..e5631ed73f 100644 --- a/priv/templates/phx.gen.auth/login_live_test.exs +++ b/priv/templates/phx.gen.auth/login_live_test.exs @@ -4,56 +4,75 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web import Phoenix.LiveViewTest import <%= inspect context.module %>Fixtures - describe "Log in page" do - test "renders log in page", %{conn: conn} do + describe "login page" do + test "renders login page", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") assert html =~ "Log in" assert html =~ "Register" - assert html =~ "Forgot your password?" + assert html =~ "Log in with email" end + end + + describe "<%= schema.singular %> login - magic link" do + test "sends magic link email when <%= schema.singular %> exists", %{conn: conn} do + <%= schema.singular %> = <%= schema.singular %>_fixture() + + {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") + + {:ok, _lv, html} = + form(lv, "#login_form_magic", <%= schema.singular %>: %{email: <%= schema.singular %>.email}) + |> render_submit() + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture()) - |> live(~p"<%= schema.route_prefix %>/log-in") - |> follow_redirect(conn, ~p"/") + assert html =~ "If your email is in our system" - assert {:ok, _conn} = result + assert <%= inspect schema.repo %>.get_by!(<%= inspect context.module %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "login" + end + + test "does not disclose if <%= schema.singular %> is registered", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") + + {:ok, _lv, html} = + form(lv, "#login_form_magic", <%= schema.singular %>: %{email: "idonotexist@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") + + assert html =~ "If your email is in our system" end end - describe "<%= schema.singular %> login" do - test "redirects if <%= schema.singular %> login with valid credentials", %{conn: conn} do - password = "123456789abcd" - <%= schema.singular %> = <%= schema.singular %>_fixture(%{password: password}) + describe "<%= schema.singular %> login - password" do + test "redirects if <%= schema.singular %> logs in with valid credentials", %{conn: conn} do + <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") form = - form(lv, "#login_form", <%= schema.singular %>: %{email: <%= schema.singular %>.email, password: password, remember_me: true}) + form(lv, "#login_form_password", + <%= schema.singular %>: %{email: <%= schema.singular %>.email, password: valid_<%= schema.singular %>_password(), remember_me: true} + ) conn = submit_form(form, conn) assert redirected_to(conn) == ~p"/" end - test "redirects to login page with a flash error if there are no valid credentials", %{ + test "redirects to login page with a flash error if credentials are invalid", %{ conn: conn } do {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") form = - form(lv, "#login_form", + form(lv, "#login_form_password", <%= schema.singular %>: %{email: "test@email.com", password: "123456", remember_me: true} ) - conn = submit_form(form, conn) + render_submit(form) + conn = follow_trigger_action(form, conn) assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - - assert redirected_to(conn) == "<%= schema.route_prefix %>/log-in" + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" end end @@ -69,19 +88,23 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert login_html =~ "Register" end + end - test "redirects to forgot password page when the Forgot Password button is clicked", %{ - conn: conn - } do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") + describe "re-authentication (sudo mode)" do + setup %{conn: conn} do + <%= schema.singular %> = <%= schema.singular %>_fixture() + %{<%= schema.singular %>: <%= schema.singular %>, conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>)} + end - {:ok, conn} = - lv - |> element(~s|main a:fl-contains("Forgot your password?")|) - |> render_click() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/reset-password") + test "shows login page with email filled in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" - assert conn.resp_body =~ "Forgot your password?" + assert html =~ + ~s(.Migrations.Create<%= Macro.camelize(schema. create table(:<%= schema.table %><%= if schema.binary_id do %>, primary_key: false<% end %>) do <%= if schema.binary_id do %> add :id, :binary_id, primary_key: true <% end %> <%= migration.column_definitions[:email] %> - add :hashed_password, :string, null: false + add :hashed_password, :string add :confirmed_at, <%= inspect schema.timestamp_type %> timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) diff --git a/priv/templates/phx.gen.auth/notifier.ex b/priv/templates/phx.gen.auth/notifier.ex index a8a0a58a41..586d7b9caa 100644 --- a/priv/templates/phx.gen.auth/notifier.ex +++ b/priv/templates/phx.gen.auth/notifier.ex @@ -2,6 +2,7 @@ defmodule <%= inspect context.module %>.<%= inspect schema.alias %>Notifier do import Swoosh.Email alias <%= inspect context.base_module %>.Mailer + alias <%= inspect context.module %>.<%= inspect schema.alias %> # Delivers the email using the application mailer. defp deliver(recipient, subject, body) do @@ -18,60 +19,64 @@ defmodule <%= inspect context.module %>.<%= inspect schema.alias %>Notifier do end @doc """ - Deliver instructions to confirm account. + Deliver instructions to update a <%= schema.singular %> email. """ - def deliver_confirmation_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Confirmation instructions", """ + def deliver_update_email_instructions(<%= schema.singular %>, url) do + deliver(<%= schema.singular %>.email, "Update email instructions", """ ============================== Hi #{<%= schema.singular %>.email}, - You can confirm your account by visiting the URL below: + You can change your email by visiting the URL below: #{url} - If you didn't create an account with us, please ignore this. + If you didn't request this change, please ignore this. ============================== """) end @doc """ - Deliver instructions to reset a <%= schema.singular %> password. + Deliver instructions to log in with a magic link. """ - def deliver_reset_password_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Reset password instructions", """ + def deliver_login_instructions(<%= schema.singular %>, url) do + case <%= schema.singular %> do + %<%= inspect schema.alias %>{confirmed_at: nil} -> deliver_confirmation_instructions(<%= schema.singular %>, url) + _ -> deliver_magic_link_instructions(<%= schema.singular %>, url) + end + end + + defp deliver_magic_link_instructions(<%= schema.singular %>, url) do + deliver(<%= schema.singular %>.email, "Log in", """ ============================== Hi #{<%= schema.singular %>.email}, - You can reset your password by visiting the URL below: + You can log into your account by visiting the URL below: #{url} - If you didn't request this change, please ignore this. + If you didn't request this email, please ignore this. ============================== """) end - @doc """ - Deliver instructions to update a <%= schema.singular %> email. - """ - def deliver_update_email_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Update email instructions", """ + defp deliver_confirmation_instructions(<%= schema.singular %>, url) do + deliver(<%= schema.singular %>.email, "Confirmation instructions", """ ============================== Hi #{<%= schema.singular %>.email}, - You can change your email by visiting the URL below: + You can confirm your account by visiting the URL below: #{url} - If you didn't request this change, please ignore this. + If you didn't create an account with us, please ignore this. ============================== """) diff --git a/priv/templates/phx.gen.auth/registration_controller.ex b/priv/templates/phx.gen.auth/registration_controller.ex index 46752454d4..eb361e40d3 100644 --- a/priv/templates/phx.gen.auth/registration_controller.ex +++ b/priv/templates/phx.gen.auth/registration_controller.ex @@ -3,10 +3,9 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web alias <%= inspect context.module %> alias <%= inspect schema.module %> - alias <%= inspect auth_module %> def new(conn, _params) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_registration(%<%= inspect schema.alias %>{}) + changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}) render(conn, :new, changeset: changeset) end @@ -14,14 +13,17 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web case <%= inspect context.alias %>.register_<%= schema.singular %>(<%= schema.singular %>_params) do {:ok, <%= schema.singular %>} -> {:ok, _} = - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions( + <%= inspect context.alias %>.deliver_login_instructions( <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/confirm/#{&1}") + &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") ) conn - |> put_flash(:info, "<%= schema.human_singular %> created successfully.") - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) + |> put_flash( + :info, + "An email was sent to #{<%= schema.singular %>.email}, please access it to confirm your account." + ) + |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") {:error, %Ecto.Changeset{} = changeset} -> render(conn, :new, changeset: changeset) diff --git a/priv/templates/phx.gen.auth/registration_controller_test.exs b/priv/templates/phx.gen.auth/registration_controller_test.exs index 53dfbb2e66..62db2c628e 100644 --- a/priv/templates/phx.gen.auth/registration_controller_test.exs +++ b/priv/templates/phx.gen.auth/registration_controller_test.exs @@ -21,7 +21,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web describe "POST <%= schema.route_prefix %>/register" do @tag :capture_log - test "creates account and logs the <%= schema.singular %> in", %{conn: conn} do + test "creates account but does not log in", %{conn: conn} do email = unique_<%= schema.singular %>_email() conn = @@ -29,27 +29,22 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web "<%= schema.singular %>" => valid_<%= schema.singular %>_attributes(email: email) }) - assert get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"/" + refute get_session(conn, :<%= schema.singular %>_token) + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ email - assert response =~ ~p"<%= schema.route_prefix %>/settings" - assert response =~ ~p"<%= schema.route_prefix %>/log-out" + assert conn.assigns.flash["info"] =~ + ~r/An email was sent to .*, please access it to confirm your account/ end test "render errors for invalid data", %{conn: conn} do conn = post(conn, ~p"<%= schema.route_prefix %>/register", %{ - "<%= schema.singular %>" => %{"email" => "with spaces", "password" => "too short"} + "<%= schema.singular %>" => %{"email" => "with spaces"} }) response = html_response(conn, 200) assert response =~ "Register" assert response =~ "must have the @ sign and no spaces" - assert response =~ "should be at least 12 character" end end end diff --git a/priv/templates/phx.gen.auth/registration_live.ex b/priv/templates/phx.gen.auth/registration_live.ex index 31cb8ad8e8..6675ec625a 100644 --- a/priv/templates/phx.gen.auth/registration_live.ex +++ b/priv/templates/phx.gen.auth/registration_live.ex @@ -18,27 +18,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web - <.simple_form - for={@form} - id="registration_form" - phx-submit="save" - phx-change="validate" - phx-trigger-action={@trigger_submit} - action={~p"<%= schema.route_prefix %>/log-in?_action=registered"} - method="post" - > + <.simple_form for={@form} id="registration_form" phx-submit="save" phx-change="validate"> <.error :if={@check_errors}> Oops, something went wrong! Please check the errors below. <.input field={@form[:email]} type="email" label="Email" autocomplete="username" required /> - <.input - field={@form[:password]} - type="password" - label="Password" - autocomplete="new-password" - required - /> <:actions> <.button phx-disable-with="Creating account..." class="w-full">Create an account @@ -48,12 +33,17 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web """ end + def mount(_params, _session, %{assigns: %{current_<%= schema.singular %>: <%= schema.singular %>}} = socket) + when not is_nil(<%= schema.singular %>) do + {:ok, redirect(socket, to: <%= inspect auth_module %>.signed_in_path(socket))} + end + def mount(_params, _session, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_registration(%<%= inspect schema.alias %>{}) + changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}) socket = socket - |> assign(trigger_submit: false, check_errors: false) + |> assign(check_errors: false) |> assign_form(changeset) {:ok, socket, temporary_assigns: [form: nil]} @@ -63,13 +53,18 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web case <%= inspect context.alias %>.register_<%= schema.singular %>(<%= schema.singular %>_params) do {:ok, <%= schema.singular %>} -> {:ok, _} = - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions( + <%= inspect context.alias %>.deliver_login_instructions( <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/confirm/#{&1}") + &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") ) - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_registration(<%= schema.singular %>) - {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + {:noreply, + socket + |> put_flash( + :info, + "An email was sent to #{<%= schema.singular %>.email}, please access it to confirm your account." + ) + |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} @@ -77,7 +72,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_registration(%<%= inspect schema.alias %>{}, <%= schema.singular %>_params) + changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}, <%= schema.singular %>_params) {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} end diff --git a/priv/templates/phx.gen.auth/registration_live_test.exs b/priv/templates/phx.gen.auth/registration_live_test.exs index 8c878d3015..0c98f20336 100644 --- a/priv/templates/phx.gen.auth/registration_live_test.exs +++ b/priv/templates/phx.gen.auth/registration_live_test.exs @@ -28,31 +28,26 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web result = lv |> element("#registration_form") - |> render_change(<%= schema.singular %>: %{"email" => "with spaces", "password" => "too short"}) + |> render_change(<%= schema.singular %>: %{"email" => "with spaces"}) assert result =~ "Register" assert result =~ "must have the @ sign and no spaces" - assert result =~ "should be at least 12 character" end end describe "register <%= schema.singular %>" do - test "creates account and logs the <%= schema.singular %> in", %{conn: conn} do + test "creates account but does not log in", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/register") email = unique_<%= schema.singular %>_email() form = form(lv, "#registration_form", <%= schema.singular %>: valid_<%= schema.singular %>_attributes(email: email)) - render_submit(form) - conn = follow_trigger_action(form, conn) - assert redirected_to(conn) == ~p"/" + {:ok, _lv, html} = + render_submit(form) + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ email - assert response =~ "Settings" - assert response =~ "Log out" + assert html =~ + ~r/An email was sent to .*, please access it to confirm your account/ end test "renders errors for duplicated email", %{conn: conn} do @@ -63,7 +58,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web result = lv |> form("#registration_form", - <%= schema.singular %>: %{"email" => <%= schema.singular %>.email, "password" => "valid_password"} + <%= schema.singular %>: %{"email" => <%= schema.singular %>.email} ) |> render_submit() diff --git a/priv/templates/phx.gen.auth/registration_new.html.heex b/priv/templates/phx.gen.auth/registration_new.html.heex index cff2b38196..640e67ef23 100644 --- a/priv/templates/phx.gen.auth/registration_new.html.heex +++ b/priv/templates/phx.gen.auth/registration_new.html.heex @@ -16,13 +16,6 @@ <.input field={f[:email]} type="email" label="Email" autocomplete="username" required /> - <.input - field={f[:password]} - type="password" - label="Password" - autocomplete="new-password" - required - /> <:actions> <.button phx-disable-with="Creating account..." class="w-full">Create an account diff --git a/priv/templates/phx.gen.auth/reset_password_controller.ex b/priv/templates/phx.gen.auth/reset_password_controller.ex deleted file mode 100644 index e572e4d5f1..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_controller.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ResetPasswordController do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - - plug :get_<%= schema.singular %>_by_reset_password_token when action in [:edit, :update] - - def new(conn, _params) do - render(conn, :new) - end - - def create(conn, %{"<%= schema.singular %>" => %{"email" => email}}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/reset-password/#{&1}") - ) - end - - conn - |> put_flash( - :info, - "If your email is in our system, you will receive instructions to reset your password shortly." - ) - |> redirect(to: ~p"/") - end - - def edit(conn, _params) do - render(conn, :edit, changeset: <%= inspect context.alias %>.change_<%= schema.singular %>_password(conn.assigns.<%= schema.singular %>)) - end - - # Do not log in the <%= schema.singular %> after reset password to avoid a - # leaked token giving the <%= schema.singular %> access to the account. - def update(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params}) do - case <%= inspect context.alias %>.reset_<%= schema.singular %>_password(conn.assigns.<%= schema.singular %>, <%= schema.singular %>_params) do - {:ok, _} -> - conn - |> put_flash(:info, "Password reset successfully.") - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - - {:error, changeset} -> - render(conn, :edit, changeset: changeset) - end - end - - defp get_<%= schema.singular %>_by_reset_password_token(conn, _opts) do - %{"token" => token} = conn.params - - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_reset_password_token(token) do - conn |> assign(:<%= schema.singular %>, <%= schema.singular %>) |> assign(:token, token) - else - conn - |> put_flash(:error, "Reset password link is invalid or it has expired.") - |> redirect(to: ~p"/") - |> halt() - end - end -end diff --git a/priv/templates/phx.gen.auth/reset_password_controller_test.exs b/priv/templates/phx.gen.auth/reset_password_controller_test.exs deleted file mode 100644 index dcf0dc5c96..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_controller_test.exs +++ /dev/null @@ -1,123 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ResetPasswordControllerTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - alias <%= inspect context.module %> - alias <%= inspect schema.repo %><%= schema.repo_alias %> - import <%= inspect context.module %>Fixtures - - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - describe "GET <%= schema.route_prefix %>/reset-password" do - test "renders the reset password page", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/reset-password") - response = html_response(conn, 200) - assert response =~ "Forgot your password?" - end - end - - describe "POST <%= schema.route_prefix %>/reset-password" do - @tag :capture_log - test "sends a new reset password token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/reset-password", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} - }) - - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "reset_password" - end - - test "does not send reset password token if email is invalid", %{conn: conn} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/reset-password", %{ - "<%= schema.singular %>" => %{"email" => "unknown@example.com"} - }) - - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.all(<%= inspect context.alias %>.<%= inspect schema.alias %>Token) == [] - end - end - - describe "GET <%= schema.route_prefix %>/reset-password/:token" do - setup %{<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, url) - end) - - %{token: token} - end - - test "renders reset password", %{conn: conn, token: token} do - conn = get(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - assert html_response(conn, 200) =~ "Reset password" - end - - test "does not render reset password with invalid token", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/reset-password/oops") - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "Reset password link is invalid or it has expired" - end - end - - describe "PUT <%= schema.route_prefix %>/reset-password/:token" do - setup %{<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, url) - end) - - %{token: token} - end - - test "resets password once", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>, token: token} do - conn = - put(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}", %{ - "<%= schema.singular %>" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - refute get_session(conn, :<%= schema.singular %>_token) - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "Password reset successfully" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") - end - - test "does not reset password on invalid data", %{conn: conn, token: token} do - conn = - put(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}", %{ - "<%= schema.singular %>" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - assert html_response(conn, 200) =~ "something went wrong" - end - - test "does not reset password with invalid token", %{conn: conn} do - conn = put(conn, ~p"<%= schema.route_prefix %>/reset-password/oops") - assert redirected_to(conn) == ~p"/" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "Reset password link is invalid or it has expired" - end - end -end diff --git a/priv/templates/phx.gen.auth/reset_password_edit.html.heex b/priv/templates/phx.gen.auth/reset_password_edit.html.heex deleted file mode 100644 index 729b497c42..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_edit.html.heex +++ /dev/null @@ -1,36 +0,0 @@ -
- <.header class="text-center"> - Reset Password - - - <.simple_form :let={f} for={@changeset} action={~p"<%= schema.route_prefix %>/reset-password/#{@token}"}> - <.error :if={@changeset.action}> - Oops, something went wrong! Please check the errors below. - - - <.input - field={f[:password]} - type="password" - label="New Password" - autocomplete="new-password" - required - /> - <.input - field={f[:password_confirmation]} - type="password" - label="Confirm new password" - autocomplete="new-password" - required - /> - <:actions> - <.button phx-disable-with="Resetting..." class="w-full"> - Reset password - - - - -

- <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
diff --git a/priv/templates/phx.gen.auth/reset_password_html.ex b/priv/templates/phx.gen.auth/reset_password_html.ex deleted file mode 100644 index 332b9356d2..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ResetPasswordHTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_reset_password_html/*" -end diff --git a/priv/templates/phx.gen.auth/reset_password_live.ex b/priv/templates/phx.gen.auth/reset_password_live.ex deleted file mode 100644 index 02ae26850f..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_live.ex +++ /dev/null @@ -1,96 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ResetPassword do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - def render(assigns) do - ~H""" -
- <.header class="text-center">Reset Password - - <.simple_form - for={@form} - id="reset_password_form" - phx-submit="reset_password" - phx-change="validate" - > - <.error :if={@form.errors != []}> - Oops, something went wrong! Please check the errors below. - - - <.input - field={@form[:password]} - type="password" - label="New password" - autocomplete="new-password" - required - /> - <.input - field={@form[:password_confirmation]} - type="password" - label="Confirm new password" - autocomplete="new-password" - required - /> - <:actions> - <.button phx-disable-with="Resetting..." class="w-full">Reset Password - - - -

- <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
- """ - end - - def mount(params, _session, socket) do - socket = assign_<%= schema.singular %>_and_token(socket, params) - - form_source = - case socket.assigns do - %{<%= schema.singular %>: <%= schema.singular %>} -> - <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>) - - _ -> - %{} - end - - {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} - end - - # Do not log in the <%= schema.singular %> after reset password to avoid a - # leaked token giving the <%= schema.singular %> access to the account. - def handle_event("reset_password", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - case <%= inspect context.alias %>.reset_<%= schema.singular %>_password(socket.assigns.<%= schema.singular %>, <%= schema.singular %>_params) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "Password reset successfully.") - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in")} - - {:error, changeset} -> - {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} - end - end - - def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(socket.assigns.<%= schema.singular %>, <%= schema.singular %>_params) - {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} - end - - defp assign_<%= schema.singular %>_and_token(socket, %{"token" => token}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_reset_password_token(token) do - assign(socket, <%= schema.singular %>: <%= schema.singular %>, token: token) - else - socket - |> put_flash(:error, "Reset password link is invalid or it has expired.") - |> redirect(to: ~p"/") - end - end - - defp assign_form(socket, %{} = source) do - assign(socket, :form, to_form(source, as: "<%= schema.singular %>")) - end -end diff --git a/priv/templates/phx.gen.auth/reset_password_live_test.exs b/priv/templates/phx.gen.auth/reset_password_live_test.exs deleted file mode 100644 index 6e73bfe8e9..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_live_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ResetPasswordTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - alias <%= inspect context.module %> - - setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, url) - end) - - %{token: token, <%= schema.singular %>: <%= schema.singular %>} - end - - describe "Reset password page" do - test "renders reset password with valid token", %{conn: conn, token: token} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - assert html =~ "Reset Password" - end - - test "does not render reset password with invalid token", %{conn: conn} do - {:error, {:redirect, to}} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/invalid") - - assert to == %{ - flash: %{"error" => "Reset password link is invalid or it has expired."}, - to: ~p"/" - } - end - - test "renders errors for invalid data", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - result = - lv - |> element("#reset_password_form") - |> render_change( - <%= schema.singular %>: %{"password" => "secret12", "password_confirmation" => "secret123456"} - ) - - assert result =~ "should be at least 12 character" - assert result =~ "does not match password" - end - end - - describe "Reset Password" do - test "resets password once", %{conn: conn, token: token, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - {:ok, conn} = - lv - |> form("#reset_password_form", - <%= schema.singular %>: %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - ) - |> render_submit() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - refute get_session(conn, :<%= schema.singular %>_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") - end - - test "does not reset password on invalid data", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - result = - lv - |> form("#reset_password_form", - <%= schema.singular %>: %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - ) - |> render_submit() - - assert result =~ "Reset Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - end - end - - describe "Reset password navigation" do - test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - {:ok, conn} = - lv - |> element(~s|main a:fl-contains("Log in")|) - |> render_click() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert conn.resp_body =~ "Log in" - end - - test "redirects to registration page when the Register button is clicked", %{ - conn: conn, - token: token - } do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/reset-password/#{token}") - - {:ok, conn} = - lv - |> element(~s|main a:fl-contains("Register")|) - |> render_click() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/register") - - assert conn.resp_body =~ "Register" - end - end -end diff --git a/priv/templates/phx.gen.auth/reset_password_new.html.heex b/priv/templates/phx.gen.auth/reset_password_new.html.heex deleted file mode 100644 index 6f254d1e8c..0000000000 --- a/priv/templates/phx.gen.auth/reset_password_new.html.heex +++ /dev/null @@ -1,20 +0,0 @@ -
- <.header class="text-center"> - Forgot your password? - <:subtitle>We'll send a password reset link to your inbox - - - <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/reset-password"}> - <.input field={f[:email]} type="email" placeholder="Email" autocomplete="username" required /> - <:actions> - <.button phx-disable-with="Sending..." class="w-full"> - Send password reset instructions - - - - -

- <.link href={~p"<%= schema.route_prefix %>/register"}>Register - | <.link href={~p"<%= schema.route_prefix %>/log-in"}>Log in -

-
diff --git a/priv/templates/phx.gen.auth/routes.ex b/priv/templates/phx.gen.auth/routes.ex index 928ea70c43..b68abf4c5c 100644 --- a/priv/templates/phx.gen.auth/routes.ex +++ b/priv/templates/phx.gen.auth/routes.ex @@ -1,37 +1,23 @@ ## Authentication routes - scope <%= router_scope %> do - pipe_through [:browser, :redirect_if_<%= schema.singular %>_is_authenticated]<%= if live? do %> - - live_session :redirect_if_<%= schema.singular %>_is_authenticated, - on_mount: [{<%= inspect auth_module %>, :redirect_if_<%= schema.singular %>_is_authenticated}] do - live "/<%= schema.plural %>/register", <%= inspect schema.alias %>Live.Registration, :new - live "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>Live.Login, :new - live "/<%= schema.plural %>/reset-password", <%= inspect schema.alias %>Live.ForgotPassword, :new - live "/<%= schema.plural %>/reset-password/:token", <%= inspect schema.alias %>Live.ResetPassword, :edit - end - - post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create<% else %> + <%= if not live? do %>scope <%= router_scope %> do + pipe_through [:browser, :redirect_if_<%= schema.singular %>_is_authenticated] get "/<%= schema.plural %>/register", <%= inspect schema.alias %>RegistrationController, :new post "/<%= schema.plural %>/register", <%= inspect schema.alias %>RegistrationController, :create - get "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :new - post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create - get "/<%= schema.plural %>/reset-password", <%= inspect schema.alias %>ResetPasswordController, :new - post "/<%= schema.plural %>/reset-password", <%= inspect schema.alias %>ResetPasswordController, :create - get "/<%= schema.plural %>/reset-password/:token", <%= inspect schema.alias %>ResetPasswordController, :edit - put "/<%= schema.plural %>/reset-password/:token", <%= inspect schema.alias %>ResetPasswordController, :update<% end %> end - scope <%= router_scope %> do + <% end %>scope <%= router_scope %> do pipe_through [:browser, :require_authenticated_<%= schema.singular %>]<%= if live? do %> live_session :require_authenticated_<%= schema.singular %>, on_mount: [{<%= inspect auth_module %>, :ensure_authenticated}] do live "/<%= schema.plural %>/settings", <%= inspect schema.alias %>Live.Settings, :edit live "/<%= schema.plural %>/settings/confirm-email/:token", <%= inspect schema.alias %>Live.Settings, :confirm_email - end<% else %> + end + + post "/<%= schema.plural %>/update-password", <%= inspect schema.alias %>SessionController, :update_password<% else %> get "/<%= schema.plural %>/settings", <%= inspect schema.alias %>SettingsController, :edit put "/<%= schema.plural %>/settings", <%= inspect schema.alias %>SettingsController, :update @@ -41,15 +27,16 @@ scope <%= router_scope %> do pipe_through [:browser] - delete "/<%= schema.plural %>/log-out", <%= inspect schema.alias %>SessionController, :delete<%= if live? do %> - - live_session :current_<%= schema.singular %>, + <%= if live? do %>live_session :current_<%= schema.singular %>, on_mount: [{<%= inspect auth_module %>, :mount_current_<%= schema.singular %>}] do - live "/<%= schema.plural %>/confirm/:token", <%= inspect schema.alias %>Live.Confirmation, :edit - live "/<%= schema.plural %>/confirm", <%= inspect schema.alias %>Live.ConfirmationInstructions, :new - end<% else %> - get "/<%= schema.plural %>/confirm", <%= inspect schema.alias %>ConfirmationController, :new - post "/<%= schema.plural %>/confirm", <%= inspect schema.alias %>ConfirmationController, :create - get "/<%= schema.plural %>/confirm/:token", <%= inspect schema.alias %>ConfirmationController, :edit - post "/<%= schema.plural %>/confirm/:token", <%= inspect schema.alias %>ConfirmationController, :update<% end %> + live "/<%= schema.plural %>/register", <%= inspect schema.alias %>Live.Registration, :new + live "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>Live.Login, :new + live "/<%= schema.plural %>/log-in/:token", <%= inspect schema.alias %>Live.Confirmation, :new + end + + post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create + delete "/<%= schema.plural %>/log-out", <%= inspect schema.alias %>SessionController, :delete<% else %>get "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :new + get "/<%= schema.plural %>/log-in/:token", <%= inspect schema.alias %>SessionController, :confirm + post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create + delete "/<%= schema.plural %>/log-out", <%= inspect schema.alias %>SessionController, :delete<% end %> end diff --git a/priv/templates/phx.gen.auth/schema.ex b/priv/templates/phx.gen.auth/schema.ex index b289b3e0b8..dae8f1cbee 100644 --- a/priv/templates/phx.gen.auth/schema.ex +++ b/priv/templates/phx.gen.auth/schema.ex @@ -9,106 +9,61 @@ defmodule <%= inspect schema.module %> do field :hashed_password, :string, redact: true field :current_password, :string, virtual: true, redact: true field :confirmed_at, <%= inspect schema.timestamp_type %> + field :authenticated_at, <%= inspect schema.timestamp_type %>, virtual: true timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) end @doc """ - A <%= schema.singular %> changeset for registration. + A <%= schema.singular %> changeset for registering or changing the email. - It is important to validate the length of both email and password. - Otherwise databases may truncate the email without warnings, which - could lead to unpredictable or insecure behaviour. Long passwords may - also be very expensive to hash for certain algorithms. + It requires the email to change otherwise an error is added. ## Options - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. - Defaults to `true`. - - * `:validate_email` - Validates the uniqueness of the email, in case - you don't want to validate the uniqueness of the email (like when - using this changeset for validations on a LiveView form before - submitting the form), this option can be set to `false`. + * `:validate_email` - Set to false if you don't want to validate the + uniqueness of the email, useful when displaying live validations. Defaults to `true`. """ - def registration_changeset(<%= schema.singular %>, attrs, opts \\ []) do + def email_changeset(<%= schema.singular %>, attrs, opts \\ []) do <%= schema.singular %> - |> cast(attrs, [:email, :password]) + |> cast(attrs, [:email]) |> validate_email(opts) - |> validate_password(opts) end defp validate_email(changeset, opts) do - changeset - |> validate_required([:email]) - |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, - message: "must have the @ sign and no spaces" - ) - |> validate_length(:email, max: 160) - |> maybe_validate_unique_email(opts) - end - - defp validate_password(changeset, opts) do - changeset - |> validate_required([:password]) - |> validate_length(:password, min: 12, max: 72) - # Examples of additional password validation: - # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") - # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") - # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") - |> maybe_hash_password(opts) - end - - defp maybe_hash_password(changeset, opts) do - hash_password? = Keyword.get(opts, :hash_password, true) - password = get_change(changeset, :password) - - if hash_password? && password && changeset.valid? do - changeset<%= if hashing_library.name == :bcrypt do %> - # If using Bcrypt, then further validate it is at most 72 bytes long - |> validate_length(:password, max: 72, count: :bytes)<% end %> - # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that - # would keep the database transaction open longer and hurt performance. - |> put_change(:hashed_password, <%= inspect hashing_library.module %>.hash_pwd_salt(password)) - |> delete_change(:password) - else + changeset = changeset - end - end + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) - defp maybe_validate_unique_email(changeset, opts) do if Keyword.get(opts, :validate_email, true) do changeset |> unsafe_validate_unique(:email, <%= inspect schema.repo %>) |> unique_constraint(:email) + |> validate_email_changed() else changeset end end - @doc """ - A <%= schema.singular %> changeset for changing the email. - - It requires the email to change otherwise an error is added. - """ - def email_changeset(<%= schema.singular %>, attrs, opts \\ []) do - <%= schema.singular %> - |> cast(attrs, [:email]) - |> validate_email(opts) - |> case do - %{changes: %{email: _}} = changeset -> changeset - %{} = changeset -> add_error(changeset, :email, "did not change") + defp validate_email_changed(changeset) do + if get_field(changeset, :email) && get_change(changeset, :email) == nil do + add_error(changeset, :email, "did not change") + else + changeset end end @doc """ A <%= schema.singular %> changeset for changing the password. + It is important to validate the length of the password, as long passwords may + be very expensive to hash for certain algorithms. + ## Options * `:hash_password` - Hashes the password so it can be stored securely @@ -125,6 +80,34 @@ defmodule <%= inspect schema.module %> do |> validate_password(opts) end + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset<%= if hashing_library.name == :bcrypt do %> + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes)<% end %> + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, <%= inspect hashing_library.module %>.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + @doc """ Confirms the account by setting `confirmed_at`. """ @@ -151,17 +134,4 @@ defmodule <%= inspect schema.module %> do <%= inspect hashing_library.module %>.no_user_verify() false end - - @doc """ - Validates the current password otherwise adds an error to the changeset. - """ - def validate_current_password(changeset, password) do - changeset = cast(changeset, %{current_password: password}, [:current_password]) - - if valid_password?(changeset.data, password) do - changeset - else - add_error(changeset, :current_password, "is not valid") - end - end end diff --git a/priv/templates/phx.gen.auth/schema_token.ex b/priv/templates/phx.gen.auth/schema_token.ex index d862d1d288..3dcce14f25 100644 --- a/priv/templates/phx.gen.auth/schema_token.ex +++ b/priv/templates/phx.gen.auth/schema_token.ex @@ -6,10 +6,9 @@ defmodule <%= inspect schema.module %>Token do @hash_algorithm :sha256 @rand_size 32 - # It is very important to keep the reset password token expiry short, + # It is very important to keep the magic link token expiry short, # since someone with access to the email may take over the account. - @reset_password_validity_in_days 1 - @confirm_validity_in_days 7 + @magic_link_validity_in_minutes 15 @change_email_validity_in_days 7 @session_validity_in_days 60 <%= if schema.binary_id do %> @@ -61,7 +60,8 @@ defmodule <%= inspect schema.module %>Token do from token in by_token_and_context_query(token, "session"), join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), where: token.inserted_at > ago(@session_validity_in_days, "day"), - select: <%= schema.singular %> + select: <%= schema.singular %>, + select_merge: %{authenticated_at: token.inserted_at} {:ok, query} end @@ -72,7 +72,7 @@ defmodule <%= inspect schema.module %>Token do The non-hashed token is sent to the <%= schema.singular %> email while the hashed part is stored in the database. The original token cannot be reconstructed, which means anyone with read-only access to the database cannot directly use - the token in the application to gain access. Furthermore, if the user changes + the token in the application to gain access. Furthermore, if the <%= schema.singular %> changes their email in the system, the tokens sent to the previous email are no longer valid. @@ -99,27 +99,23 @@ defmodule <%= inspect schema.module %>Token do @doc """ Checks if the token is valid and returns its underlying lookup query. - The query returns the <%= schema.singular %> found by the token, if any. + If found, the query returns a tuple of the form `{<%= schema.singular %>, token}`. The given token is valid if it matches its hashed counterpart in the - database and the user email has not changed. This function also checks - if the token is being used within a certain period, depending on the - context. The default contexts supported by this function are either - "confirm", for account confirmation emails, and "reset_password", - for resetting the password. For verifying requests to change the email, - see `verify_change_email_token_query/2`. + database. This function also checks if the token is being used within + 15 minutes. The context of a magic link token is always "login". """ - def verify_email_token_query(token, context) do + def verify_magic_link_token_query(token) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) - days = days_for_context(context) query = - from token in by_token_and_context_query(hashed_token, context), + from token in by_token_and_context_query(hashed_token, "login"), join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), - where: token.inserted_at > ago(^days, "day") and token.sent_to == <%= schema.singular %>.email, - select: <%= schema.singular %> + where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), + where: token.sent_to == <%= schema.singular %>.email, + select: {<%= schema.singular %>, token} {:ok, query} @@ -128,19 +124,13 @@ defmodule <%= inspect schema.module %>Token do end end - defp days_for_context("confirm"), do: @confirm_validity_in_days - defp days_for_context("reset_password"), do: @reset_password_validity_in_days - @doc """ Checks if the token is valid and returns its underlying lookup query. The query returns the <%= schema.singular %>_token found by the token, if any. This is used to validate requests to change the <%= schema.singular %> - email. It is different from `verify_email_token_query/2` precisely because - `verify_email_token_query/2` validates the email has not changed, which is - the starting point by this function. - + email. The given token is valid if it matches its hashed counterpart in the database and if it has not expired (after @change_email_validity_in_days). The context must always start with "change:". @@ -178,4 +168,11 @@ defmodule <%= inspect schema.module %>Token do def by_<%= schema.singular %>_and_contexts_query(<%= schema.singular %>, [_ | _] = contexts) do from t in <%= inspect schema.alias %>Token, where: t.<%= schema.singular %>_id == ^<%= schema.singular %>.id and t.context in ^contexts end + + @doc """ + Deletes a list of tokens. + """ + def delete_all_query(tokens) do + from t in <%= inspect schema.alias %>Token, where: t.id in ^Enum.map(tokens, & &1.id) + end end diff --git a/priv/templates/phx.gen.auth/session_confirm.html.heex b/priv/templates/phx.gen.auth/session_confirm.html.heex new file mode 100644 index 0000000000..505adbd207 --- /dev/null +++ b/priv/templates/phx.gen.auth/session_confirm.html.heex @@ -0,0 +1,38 @@ +
+ <.header class="text-center">Welcome {@<%= schema.singular %>.email} + + <.simple_form + :if={!@<%= schema.singular %>.confirmed_at} + for={@form} + id="confirmation_form" + action={~p"<%= schema.route_prefix %>/log-in?_action=confirmed"} + > + + <.input + :if={!@current_<%= schema.singular %>} + field={@form[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + + <.simple_form :if={@<%= schema.singular %>.confirmed_at} for={@form} id="login_form" action={~p"<%= schema.route_prefix %>/log-in"}> + + <.input + :if={!@current_<%= schema.singular %>} + field={@form[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> + <:actions> + <.button phx-disable-with="Logging in..." class="w-full">Log in + + + +

.confirmed_at} class="mt-8 p-4 border text-center"> + Tip: If you prefer passwords, you can enable them in the <%= schema.singular %> settings. +

+
diff --git a/priv/templates/phx.gen.auth/session_controller.ex b/priv/templates/phx.gen.auth/session_controller.ex index bab413a3c2..f9b4d1460a 100644 --- a/priv/templates/phx.gen.auth/session_controller.ex +++ b/priv/templates/phx.gen.auth/session_controller.ex @@ -4,20 +4,32 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web alias <%= inspect context.module %> alias <%= inspect auth_module %><%= if live? do %> - def create(conn, %{"_action" => "registered"} = params) do - create(conn, params, "Account created successfully!") - end - - def create(conn, %{"_action" => "password-updated"} = params) do - conn - |> put_session(:<%= schema.singular %>_return_to, ~p"<%= schema.route_prefix %>/settings") - |> create(params, "Password updated successfully!") + def create(conn, %{"_action" => "confirmed"} = params) do + create(conn, params, "<%= schema.human_singular %> confirmed successfully.") end def create(conn, params) do create(conn, params, "Welcome back!") end + # magic link login + defp create(conn, %{"<%= schema.singular %>" => %{"token" => token} = <%= schema.singular %>_params}, info) do + case <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) do + {:ok, <%= schema.singular %>, tokens_to_disconnect} -> + <%= inspect schema.alias %>Auth.disconnect_sessions(tokens_to_disconnect) + + conn + |> put_flash(:info, info) + |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) + + _ -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") + end + end + + # email + password login defp create(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params}, info) do %{"email" => email, "password" => password} = <%= schema.singular %>_params @@ -32,22 +44,95 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web |> put_flash(:email, String.slice(email, 0, 160)) |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") end + end + + def update_password(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params) do + <%= schema.singular %> = conn.assigns.current_<%= schema.singular %> + true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) + {:ok, _<%= schema.singular %>, expired_tokens} = <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) + + # disconnect all existing LiveViews with old sessions + <%= inspect schema.alias %>Auth.disconnect_sessions(expired_tokens) + + conn + |> put_session(:<%= schema.singular %>_return_to, ~p"<%= schema.route_prefix %>/settings") + |> create(params, "Password updated successfully!") end<% else %> def new(conn, _params) do - render(conn, :new, error_message: nil) + email = get_in(conn.assigns, [:current_<%= schema.singular %>, Access.key(:email)]) + form = Phoenix.Component.to_form(%{"email" => email}, as: "<%= schema.singular %>") + + render(conn, :new, form: form, error_message: nil) end - def create(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params}) do - %{"email" => email, "password" => password} = <%= schema.singular %>_params + # magic link login + def create(conn, %{"<%= schema.singular %>" => %{"token" => token} = <%= schema.singular %>_params} = params) do + info = + case params do + %{"_action" => "confirmed"} -> "<%= schema.human_singular %> confirmed successfully." + _ -> "Welcome back!" + end + case <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) do + {:ok, <%= schema.singular %>, _expired_tokens} -> + conn + |> put_flash(:info, info) + |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) + + {:error, :not_found} -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> render(:new, + form: Phoenix.Component.to_form(%{}, as: "<%= schema.singular %>"), + error_message: nil + ) + end + end + + # email + password login + def create(conn, %{"<%= schema.singular %>" => %{"email" => email, "password" => password} = <%= schema.singular %>_params}) do if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(email, password) do conn |> put_flash(:info, "Welcome back!") |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) else + form = Phoenix.Component.to_form(<%= schema.singular %>_params, as: "<%= schema.singular %>") + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - render(conn, :new, error_message: "Invalid email or password") + render(conn, :new, form: form, error_message: "Invalid email or password") + end + end + + # magic link request + def create(conn, %{"<%= schema.singular %>" => %{"email" => email}}) do + if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do + <%= inspect context.alias %>.deliver_login_instructions( + <%= schema.singular %>, + &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + conn + |> put_flash(:info, info) + |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") + end + + def confirm(conn, %{"token" => token}) do + if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) do + form = Phoenix.Component.to_form(%{"token" => token}, as: "<%= schema.singular %>") + + conn + |> assign(:<%= schema.singular %>, <%= schema.singular %>) + |> assign(:form, form) + |> render(:confirm) + else + conn + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") end end<% end %> diff --git a/priv/templates/phx.gen.auth/session_controller_test.exs b/priv/templates/phx.gen.auth/session_controller_test.exs index 8004208b15..6c2175de2a 100644 --- a/priv/templates/phx.gen.auth/session_controller_test.exs +++ b/priv/templates/phx.gen.auth/session_controller_test.exs @@ -2,28 +2,81 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> import <%= inspect context.module %>Fixtures + alias <%= inspect context.module %> setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} + %{unconfirmed_<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture(), <%= schema.singular %>: <%= schema.singular %>_fixture()} end<%= if not live? do %> describe "GET <%= schema.route_prefix %>/log-in" do - test "renders log in page", %{conn: conn} do + test "renders login page", %{conn: conn} do conn = get(conn, ~p"<%= schema.route_prefix %>/log-in") response = html_response(conn, 200) assert response =~ "Log in" assert response =~ ~p"<%= schema.route_prefix %>/register" - assert response =~ "Forgot your password?" + assert response =~ "Log in with email" end - test "redirects if already logged in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> log_in_<%= schema.singular %>(<%= schema.singular %>) |> get(~p"<%= schema.route_prefix %>/log-in") - assert redirected_to(conn) == ~p"/" + test "renders login page with email filled in (sudo mode)", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + html = + conn + |> log_in_<%= schema.singular %>(<%= schema.singular %>) + |> get(~p"<%= schema.route_prefix %>/log-in") + |> html_response(200) + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" + + assert html =~ + ~s(/log-in?mode=password") + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ ~p"<%= schema.route_prefix %>/register" + assert response =~ "Log in with email" + end + end + + describe "GET <%= schema.route_prefix %>/log-in/:token" do + test "renders confirmation page for unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) + + conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + assert html_response(conn, 200) =~ "Confirm my account" + end + + test "renders login page for confirmed <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + token = + extract_<%= schema.singular %>_token(fn url -> + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) + end) + + conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") + html = html_response(conn, 200) + refute html =~ "Confirm my account" + assert html =~ "Log in" + end + + test "raises error for invalid token", %{conn: conn} do + conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/invalid-token") + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Magic link is invalid or it has expired." end end<% end %> - describe "POST <%= schema.route_prefix %>/log-in" do + describe "POST <%= schema.route_prefix %>/log-in - email and password" do test "logs the <%= schema.singular %> in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + <%= schema.singular %> = set_password(<%= schema.singular %>) + conn = post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email, "password" => valid_<%= schema.singular %>_password()} @@ -41,6 +94,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end test "logs the <%= schema.singular %> in with remember me", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + <%= schema.singular %> = set_password(<%= schema.singular %>) + conn = post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ "<%= schema.singular %>" => %{ @@ -55,6 +110,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end test "logs the <%= schema.singular %> in with return to", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + <%= schema.singular %> = set_password(<%= schema.singular %>) + conn = conn |> init_test_session(<%= schema.singular %>_return_to: "/foo/bar") @@ -67,58 +124,86 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert redirected_to(conn) == "/foo/bar" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" - end<%= if live? do %> + end - test "login following registration", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + test "<%= if live?, do: "redirects to login page", else: "emits error message" %> with invalid credentials", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = - conn - |> post(~p"<%= schema.route_prefix %>/log-in", %{ - "_action" => "registered", - "<%= schema.singular %>" => %{ - "email" => <%= schema.singular %>.email, - "password" => valid_<%= schema.singular %>_password() - } + post(conn, ~p"<%= schema.route_prefix %>/log-in?mode=password", %{ + "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email, "password" => "invalid_password"} }) - assert redirected_to(conn) == ~p"/" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + <%= if live? do %>assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"<% else %>response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ "Invalid email or password"<% end %> end + end - test "login following password update", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + describe "POST <%= schema.route_prefix %>/log-in - magic link" do + <%= if not live? do %>test "sends magic link email when <%= schema.singular %> exists", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = - conn - |> post(~p"<%= schema.route_prefix %>/log-in", %{ - "_action" => "password-updated", - "<%= schema.singular %>" => %{ - "email" => <%= schema.singular %>.email, - "password" => valid_<%= schema.singular %>_password() - } + post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ + "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} }) - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert <%= inspect schema.repo %>.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "login" end - test "redirects to login page with invalid credentials", %{conn: conn} do + <% end %>test "logs the <%= schema.singular %> in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do + {token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) + conn = post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + "<%= schema.singular %>" => %{"token" => token} }) - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - end<% else %> + assert get_session(conn, :<%= schema.singular %>_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ <%= schema.singular %>.email + assert response =~ ~p"<%= schema.route_prefix %>/settings" + assert response =~ ~p"<%= schema.route_prefix %>/log-out" + end + + test "confirms unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do + {token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) + refute <%= schema.singular %>.confirmed_at - test "emits error message with invalid credentials", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do conn = post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email, "password" => "invalid_password"} + "<%= schema.singular %>" => %{"token" => token}, + "_action" => "confirmed" }) + assert get_session(conn, :<%= schema.singular %>_token) + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "<%= schema.human_singular %> confirmed successfully." + + assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") response = html_response(conn, 200) - assert response =~ "Log in" - assert response =~ "Invalid email or password" - end<% end %> + assert response =~ <%= schema.singular %>.email + assert response =~ ~p"<%= schema.route_prefix %>/settings" + assert response =~ ~p"<%= schema.route_prefix %>/log-out" + end + + test "<%= if live?, do: "redirects to login page", else: "emits error message" %> when magic link is invalid", %{conn: conn} do + conn = + post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ + "<%= schema.singular %>" => %{"token" => "invalid"} + }) + + <%= if live? do %>assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "The link is invalid or it has expired." + + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"<% else %>assert html_response(conn, 200) =~ "The link is invalid or it has expired."<% end %> + end end describe "DELETE <%= schema.route_prefix %>/log-out" do diff --git a/priv/templates/phx.gen.auth/session_html.ex b/priv/templates/phx.gen.auth/session_html.ex index d042992427..0a02f38243 100644 --- a/priv/templates/phx.gen.auth/session_html.ex +++ b/priv/templates/phx.gen.auth/session_html.ex @@ -2,4 +2,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web use <%= inspect context.web_module %>, :html embed_templates "<%= schema.singular %>_session_html/*" + + defp local_mail_adapter? do + Application.get_env(:<%= Mix.Phoenix.otp_app() %>, <%= inspect context.base_module %>.Mailer)[:adapter] == Swoosh.Adapters.Local + end end diff --git a/priv/templates/phx.gen.auth/session_new.html.heex b/priv/templates/phx.gen.auth/session_new.html.heex index 8e19c74011..57af1db0f8 100644 --- a/priv/templates/phx.gen.auth/session_new.html.heex +++ b/priv/templates/phx.gen.auth/session_new.html.heex @@ -1,37 +1,71 @@
<.header class="text-center"> - Log in to account +

Log in

<:subtitle> - Don't have an account? - <.link navigate={~p"<%= schema.route_prefix %>/register"} class="font-semibold text-brand hover:underline"> - Sign up - - for an account now. + <%%= if @current_<%= schema.singular %> do %> + You need to reauthenticate to perform sensitive actions on your account. + <%% else %> + Don't have an account? <.link + navigate={~p"<%= schema.route_prefix %>/register"} + class="font-semibold text-brand hover:underline" + phx-no-format + >Sign up for an account now. + <%% end %> - <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/log-in"}> - <.error :if={@error_message}>{@error_message} - - <.input field={f[:email]} type="email" label="Email" autocomplete="username" required /> + <.simple_form :let={f} for={@form} as={:<%= schema.singular %>} id="login_form_magic" action={~p"<%= schema.route_prefix %>/log-in"}> <.input - field={f[:password]} - type="password" - label="Password" - autocomplete="current-password" + readonly={!!@current_<%= schema.singular %>} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" required /> + <.button class="w-full"> + Log in with email + + - <:actions :let={f}> - <.input field={f[:remember_me]} type="checkbox" label="Keep me logged in" /> - <.link href={~p"<%= schema.route_prefix %>/reset-password"} class="text-sm font-semibold"> - Forgot your password? - - - <:actions> - <.button phx-disable-with="Logging in..." class="w-full"> - Log in - - +
+
+ or +
+
+ + <.simple_form + :let={f} + for={@form} + as={:<%= schema.singular %>} + id="login_form_password" + action={~p"<%= schema.route_prefix %>/log-in"} + > + <.error :if={@error_message}>{@error_message} + <.input + readonly={!!@current_<%= schema.singular %>} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + required + /> + <.input field={f[:password]} type="password" label="Password" autocomplete="current-password" /> + <.input + :if={!@current_<%= schema.singular %>} + field={f[:remember_me]} + type="checkbox" + label="Keep me logged in" + /> + <.button class="w-full"> + Log in + + +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
diff --git a/priv/templates/phx.gen.auth/settings_controller.ex b/priv/templates/phx.gen.auth/settings_controller.ex index c046cf7796..221a253b30 100644 --- a/priv/templates/phx.gen.auth/settings_controller.ex +++ b/priv/templates/phx.gen.auth/settings_controller.ex @@ -4,6 +4,9 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web alias <%= inspect context.module %> alias <%= inspect auth_module %> + import <%= inspect auth_module %>, only: [require_sudo_mode: 2] + + plug :require_sudo_mode plug :assign_email_and_password_changesets def edit(conn, _params) do @@ -11,13 +14,13 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end def update(conn, %{"action" => "update_email"} = params) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params <%= schema.singular %> = conn.assigns.current_<%= schema.singular %> - case <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, password, <%= schema.singular %>_params) do - {:ok, applied_<%= schema.singular %>} -> + case <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, <%= schema.singular %>_params) do + %{valid?: true} = changeset -> <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions( - applied_<%= schema.singular %>, + Ecto.Changeset.apply_action!(changeset, :insert), <%= schema.singular %>.email, &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}") ) @@ -29,17 +32,17 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web ) |> redirect(to: ~p"<%= schema.route_prefix %>/settings") - {:error, changeset} -> - render(conn, :edit, email_changeset: changeset) + changeset -> + render(conn, :edit, email_changeset: %{changeset | action: :insert}) end end def update(conn, %{"action" => "update_password"} = params) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params <%= schema.singular %> = conn.assigns.current_<%= schema.singular %> - case <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, password, <%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> + case <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) do + {:ok, <%= schema.singular %>, _} -> conn |> put_flash(:info, "Password updated successfully.") |> put_session(:<%= schema.singular %>_return_to, ~p"<%= schema.route_prefix %>/settings") diff --git a/priv/templates/phx.gen.auth/settings_controller_test.exs b/priv/templates/phx.gen.auth/settings_controller_test.exs index 6f5ce220d0..9edeadf3bf 100644 --- a/priv/templates/phx.gen.auth/settings_controller_test.exs +++ b/priv/templates/phx.gen.auth/settings_controller_test.exs @@ -18,6 +18,15 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web conn = get(conn, ~p"<%= schema.route_prefix %>/settings") assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" end + + @tag token_inserted_at: DateTime.add(DateTime.utc_now(), -11, :minute) + test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do + conn = get(conn, ~p"<%= schema.route_prefix %>/settings") + assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end end describe "PUT <%= schema.route_prefix %>/settings (change password form)" do @@ -25,7 +34,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web new_password_conn = put(conn, ~p"<%= schema.route_prefix %>/settings", %{ "action" => "update_password", - "current_password" => valid_<%= schema.singular %>_password(), "<%= schema.singular %>" => %{ "password" => "new valid password", "password_confirmation" => "new valid password" @@ -46,7 +54,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web old_password_conn = put(conn, ~p"<%= schema.route_prefix %>/settings", %{ "action" => "update_password", - "current_password" => "invalid", "<%= schema.singular %>" => %{ "password" => "too short", "password_confirmation" => "does not match" @@ -57,7 +64,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert response =~ "Settings" assert response =~ "should be at least 12 character(s)" assert response =~ "does not match password" - assert response =~ "is not valid" assert get_session(old_password_conn, :<%= schema.singular %>_token) == get_session(conn, :<%= schema.singular %>_token) end @@ -69,7 +75,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web conn = put(conn, ~p"<%= schema.route_prefix %>/settings", %{ "action" => "update_email", - "current_password" => valid_<%= schema.singular %>_password(), "<%= schema.singular %>" => %{"email" => unique_<%= schema.singular %>_email()} }) @@ -85,14 +90,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web conn = put(conn, ~p"<%= schema.route_prefix %>/settings", %{ "action" => "update_email", - "current_password" => "invalid", "<%= schema.singular %>" => %{"email" => "with spaces"} }) response = html_response(conn, 200) assert response =~ "Settings" assert response =~ "must have the @ sign and no spaces" - assert response =~ "is not valid" end end diff --git a/priv/templates/phx.gen.auth/settings_edit.html.heex b/priv/templates/phx.gen.auth/settings_edit.html.heex index e741536a90..78c49da9a3 100644 --- a/priv/templates/phx.gen.auth/settings_edit.html.heex +++ b/priv/templates/phx.gen.auth/settings_edit.html.heex @@ -13,15 +13,6 @@ <.input field={f[:email]} type="email" label="Email" autocomplete="username" required /> - <.input - field={f[:current_password]} - name="current_password" - type="password" - label="Current Password" - autocomplete="current-password" - required - id="current_password_for_email" - /> <:actions> <.button phx-disable-with="Changing...">Change Email @@ -54,18 +45,10 @@ autocomplete="new-password" required /> - - <.input - field={f[:current_password]} - name="current_password" - type="password" - label="Current password" - id="current_password_for_password" - autocomplete="current-password" - required - /> <:actions> - <.button phx-disable-with="Changing...">Change Password + <.button phx-disable-with="Changing..."> + Save Password + diff --git a/priv/templates/phx.gen.auth/settings_live.ex b/priv/templates/phx.gen.auth/settings_live.ex index 7dc7c999bf..ed81c6e6bc 100644 --- a/priv/templates/phx.gen.auth/settings_live.ex +++ b/priv/templates/phx.gen.auth/settings_live.ex @@ -1,6 +1,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Settings do use <%= inspect context.web_module %>, :live_view + on_mount {<%= inspect auth_module %>, :ensure_sudo_mode} + alias <%= inspect context.module %> def render(assigns) do @@ -25,16 +27,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web autocomplete="username" required /> - <.input - field={@email_form[:current_password]} - name="current_password" - id="current_password_for_email" - type="password" - label="Current password" - value={@email_form_current_password} - autocomplete="current-password" - required - /> <:actions> <.button phx-disable-with="Changing...">Change Email @@ -44,7 +36,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web <.simple_form for={@password_form} id="password_form" - action={~p"<%= schema.route_prefix %>/log-in?_action=password-updated"} + action={~p"<%= schema.route_prefix %>/update-password"} method="post" phx-change="validate_password" phx-submit="update_password" @@ -70,18 +62,10 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web label="Confirm new password" autocomplete="new-password" /> - <.input - field={@password_form[:current_password]} - name="current_password" - type="password" - label="Current password" - id="current_password_for_password" - value={@current_password} - autocomplete="current-password" - required - /> <:actions> - <.button phx-disable-with="Changing...">Change Password + <.button phx-disable-with="Saving..."> + Save Password + @@ -104,13 +88,11 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web def mount(_params, _session, socket) do <%= schema.singular %> = socket.assigns.current_<%= schema.singular %> - email_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>) - password_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>) + email_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, %{}, validate_email: false) + password_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>, %{}, hash_password: false) socket = socket - |> assign(:current_password, nil) - |> assign(:email_form_current_password, nil) |> assign(:current_email, <%= schema.singular %>.email) |> assign(:email_form, to_form(email_changeset)) |> assign(:password_form, to_form(password_changeset)) @@ -120,64 +102,61 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end def handle_event("validate_email", params, socket) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params email_form = socket.assigns.current_<%= schema.singular %> - |> <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>_params) + |> <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>_params, validate_email: false) |> Map.put(:action, :validate) |> to_form() - {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + {:noreply, assign(socket, email_form: email_form)} end def handle_event("update_email", params, socket) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params <%= schema.singular %> = socket.assigns.current_<%= schema.singular %> + true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) - case <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, password, <%= schema.singular %>_params) do - {:ok, applied_<%= schema.singular %>} -> + case <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, <%= schema.singular %>_params) do + %{valid?: true} = changeset -> <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions( - applied_<%= schema.singular %>, + Ecto.Changeset.apply_action!(changeset, :insert), <%= schema.singular %>.email, &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}") ) info = "A link to confirm your email change has been sent to the new address." - {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + {:noreply, socket |> put_flash(:info, info)} - {:error, changeset} -> - {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + changeset -> + {:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))} end end def handle_event("validate_password", params, socket) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params password_form = socket.assigns.current_<%= schema.singular %> - |> <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>_params) + |> <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>_params, hash_password: false) |> Map.put(:action, :validate) |> to_form() - {:noreply, assign(socket, password_form: password_form, current_password: password)} + {:noreply, assign(socket, password_form: password_form)} end def handle_event("update_password", params, socket) do - %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params + %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params <%= schema.singular %> = socket.assigns.current_<%= schema.singular %> + true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) - case <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, password, <%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - password_form = - <%= schema.singular %> - |> <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>_params) - |> to_form() - - {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + case <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) do + %{valid?: true} = changeset -> + {:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))} - {:error, changeset} -> - {:noreply, assign(socket, password_form: to_form(changeset))} + changeset -> + {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} end end end diff --git a/priv/templates/phx.gen.auth/settings_live_test.exs b/priv/templates/phx.gen.auth/settings_live_test.exs index 32f4ebd929..afb9bf93a2 100644 --- a/priv/templates/phx.gen.auth/settings_live_test.exs +++ b/priv/templates/phx.gen.auth/settings_live_test.exs @@ -13,7 +13,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web |> live(~p"<%= schema.route_prefix %>/settings") assert html =~ "Change Email" - assert html =~ "Change Password" + assert html =~ "Save Password" end test "redirects if <%= schema.singular %> is not logged in", %{conn: conn} do @@ -23,16 +23,27 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert path == ~p"<%= schema.route_prefix %>/log-in" assert %{"error" => "You must log in to access this page."} = flash end + + test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do + {:ok, conn} = + conn + |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture(), + token_inserted_at: DateTime.add(DateTime.utc_now(), -11, :minute) + ) + |> live(~p"<%= schema.route_prefix %>/settings") + |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") + + assert conn.resp_body =~ "You must re-authenticate to access this page." + end end describe "update email form" do setup %{conn: conn} do - password = valid_<%= schema.singular %>_password() - <%= schema.singular %> = <%= schema.singular %>_fixture(%{password: password}) - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>, password: password} + <%= schema.singular %> = <%= schema.singular %>_fixture() + %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>} end - test "updates the <%= schema.singular %> email", %{conn: conn, password: password, <%= schema.singular %>: <%= schema.singular %>} do + test "updates the <%= schema.singular %> email", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do new_email = unique_<%= schema.singular %>_email() {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") @@ -40,7 +51,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web result = lv |> form("#email_form", %{ - "current_password" => password, "<%= schema.singular %>" => %{"email" => new_email} }) |> render_submit() @@ -57,7 +67,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web |> element("#email_form") |> render_change(%{ "action" => "update_email", - "current_password" => "invalid", "<%= schema.singular %>" => %{"email" => "with spaces"} }) @@ -71,32 +80,28 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web result = lv |> form("#email_form", %{ - "current_password" => "invalid", "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} }) |> render_submit() assert result =~ "Change Email" assert result =~ "did not change" - assert result =~ "is not valid" end end describe "update password form" do setup %{conn: conn} do - password = valid_<%= schema.singular %>_password() - <%= schema.singular %> = <%= schema.singular %>_fixture(%{password: password}) - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>, password: password} + <%= schema.singular %> = <%= schema.singular %>_fixture() + %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>} end - test "updates the <%= schema.singular %> password", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>, password: password} do + test "updates the <%= schema.singular %> password", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do new_password = valid_<%= schema.singular %>_password() {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") form = form(lv, "#password_form", %{ - "current_password" => password, "<%= schema.singular %>" => %{ "email" => <%= schema.singular %>.email, "password" => new_password, @@ -125,14 +130,13 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web lv |> element("#password_form") |> render_change(%{ - "current_password" => "invalid", "<%= schema.singular %>" => %{ "password" => "too short", "password_confirmation" => "does not match" } }) - assert result =~ "Change Password" + assert result =~ "Save Password" assert result =~ "should be at least 12 character(s)" assert result =~ "does not match password" end @@ -143,7 +147,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web result = lv |> form("#password_form", %{ - "current_password" => "invalid", "<%= schema.singular %>" => %{ "password" => "too short", "password_confirmation" => "does not match" @@ -151,10 +154,9 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web }) |> render_submit() - assert result =~ "Change Password" + assert result =~ "Save Password" assert result =~ "should be at least 12 character(s)" assert result =~ "does not match password" - assert result =~ "is not valid" end end diff --git a/priv/templates/phx.gen.auth/test_cases.exs b/priv/templates/phx.gen.auth/test_cases.exs index 910f46629f..a4ce367f7d 100644 --- a/priv/templates/phx.gen.auth/test_cases.exs +++ b/priv/templates/phx.gen.auth/test_cases.exs @@ -18,12 +18,12 @@ end test "does not return the <%= schema.singular %> if the password is not valid" do - <%= schema.singular %> = <%= schema.singular %>_fixture() + <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "invalid") end test "returns the <%= schema.singular %> if the email and password are valid" do - %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() + %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, valid_<%= schema.singular %>_password()) @@ -39,34 +39,27 @@ test "returns the <%= schema.singular %> with the given id" do %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() - assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id) + assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%=schema.singular %>.id) end end describe "register_<%= schema.singular %>/1" do - test "requires email and password to be set" do + test "requires email to be set" do {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{}) - assert %{ - password: ["can't be blank"], - email: ["can't be blank"] - } = errors_on(changeset) + assert %{email: ["can't be blank"]} = errors_on(changeset) end - test "validates email and password when given" do - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: "not valid", password: "not valid"}) + test "validates email when given" do + {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: "not valid"}) - assert %{ - email: ["must have the @ sign and no spaces"], - password: ["should be at least 12 character(s)"] - } = errors_on(changeset) + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) end - test "validates maximum values for email and password for security" do + test "validates maximum values for email for security" do too_long = String.duplicate("db", 100) - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: too_long, password: too_long}) + {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: too_long}) assert "should be at most 160 character(s)" in errors_on(changeset).email - assert "should be at most 72 character(s)" in errors_on(changeset).password end test "validates email uniqueness" do @@ -79,96 +72,42 @@ assert "has already been taken" in errors_on(changeset).email end - test "registers <%= schema.plural %> with a hashed password" do + test "registers <%= schema.plural %> without password" do email = unique_<%= schema.singular %>_email() {:ok, <%= schema.singular %>} = <%= inspect context.alias %>.register_<%= schema.singular %>(valid_<%= schema.singular %>_attributes(email: email)) assert <%= schema.singular %>.email == email - assert is_binary(<%= schema.singular %>.hashed_password) + assert is_nil(<%= schema.singular %>.hashed_password) assert is_nil(<%= schema.singular %>.confirmed_at) assert is_nil(<%= schema.singular %>.password) end end - describe "change_<%= schema.singular %>_registration/2" do - test "returns a changeset" do - assert %Ecto.Changeset{} = changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_registration(%<%= inspect schema.alias %>{}) - assert changeset.required == [:password, :email] - end + describe "sudo_mode?/2" do + test "validates the authenticated_at time" do + now = DateTime.utc_now() - test "allows fields to be set" do - email = unique_<%= schema.singular %>_email() - password = valid_<%= schema.singular %>_password() + assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.utc_now()}) + assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -19, :minute)}) + refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -21, :minute)}) - changeset = - <%= inspect context.alias %>.change_<%= schema.singular %>_registration( - %<%= inspect schema.alias %>{}, - valid_<%= schema.singular %>_attributes(email: email, password: password) - ) + # minute override + refute <%= inspect context.alias %>.sudo_mode?( + %<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -11, :minute)}, + -10 + ) - assert changeset.valid? - assert get_change(changeset, :email) == email - assert get_change(changeset, :password) == password - assert is_nil(get_change(changeset, :hashed_password)) + # not authenticated + refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{}) end end - describe "change_<%= schema.singular %>_email/2" do + describe "change_<%= schema.singular %>_email/3" do test "returns a <%= schema.singular %> changeset" do assert %Ecto.Changeset{} = changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}) assert changeset.required == [:email] end end - describe "apply_<%= schema.singular %>_email/3" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "requires email to change", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{}) - assert %{email: ["did not change"]} = errors_on(changeset) - end - - test "validates email", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = - <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{email: "not valid"}) - - assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) - end - - test "validates maximum value for email for security", %{<%= schema.singular %>: <%= schema.singular %>} do - too_long = String.duplicate("db", 100) - - {:error, changeset} = - <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{email: too_long}) - - assert "should be at most 160 character(s)" in errors_on(changeset).email - end - - test "validates email uniqueness", %{<%= schema.singular %>: <%= schema.singular %>} do - %{email: email} = <%= schema.singular %>_fixture() - password = valid_<%= schema.singular %>_password() - - {:error, changeset} = <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, password, %{email: email}) - - assert "has already been taken" in errors_on(changeset).email - end - - test "validates current password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = - <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, "invalid", %{email: unique_<%= schema.singular %>_email()}) - - assert %{current_password: ["is not valid"]} = errors_on(changeset) - end - - test "applies the email without persisting it", %{<%= schema.singular %>: <%= schema.singular %>} do - email = unique_<%= schema.singular %>_email() - {:ok, <%= schema.singular %>} = <%= inspect context.alias %>.apply_<%= schema.singular %>_email(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{email: email}) - assert <%= schema.singular %>.email == email - assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).email != email - end - end - describe "deliver_<%= schema.singular %>_update_email_instructions/3" do setup do %{<%= schema.singular %>: <%= schema.singular %>_fixture()} @@ -190,7 +129,7 @@ describe "update_<%= schema.singular %>_email/2" do setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() + <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() email = unique_<%= schema.singular %>_email() token = @@ -206,8 +145,6 @@ changed_<%= schema.singular %> = Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id) assert changed_<%= schema.singular %>.email != <%= schema.singular %>.email assert changed_<%= schema.singular %>.email == email - assert changed_<%= schema.singular %>.confirmed_at - assert changed_<%= schema.singular %>.confirmed_at != <%= schema.singular %>.confirmed_at refute Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) end @@ -231,7 +168,7 @@ end end - describe "change_<%= schema.singular %>_password/2" do + describe "change_<%= schema.singular %>_password/3" do test "returns a <%= schema.singular %> changeset" do assert %Ecto.Changeset{} = changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(%<%= inspect schema.alias %>{}) assert changeset.required == [:password] @@ -239,9 +176,13 @@ test "allows fields to be set" do changeset = - <%= inspect context.alias %>.change_<%= schema.singular %>_password(%<%= inspect schema.alias %>{}, %{ - "password" => "new valid password" - }) + <%= inspect context.alias %>.change_<%= schema.singular %>_password( + %<%= inspect schema.alias %>{}, + %{ + "password" => "new valid password" + }, + hash_password: false + ) assert changeset.valid? assert get_change(changeset, :password) == "new valid password" @@ -249,14 +190,14 @@ end end - describe "update_<%= schema.singular %>_password/3" do + describe "update_<%= schema.singular %>_password/2" do setup do %{<%= schema.singular %>: <%= schema.singular %>_fixture()} end test "validates password", %{<%= schema.singular %>: <%= schema.singular %>} do {:error, changeset} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{ + <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ password: "not valid", password_confirmation: "another" }) @@ -271,24 +212,18 @@ too_long = String.duplicate("db", 100) {:error, changeset} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{password: too_long}) + <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: too_long}) assert "should be at most 72 character(s)" in errors_on(changeset).password end - test "validates current password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, "invalid", %{password: valid_<%= schema.singular %>_password()}) - - assert %{current_password: ["is not valid"]} = errors_on(changeset) - end - test "updates the password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:ok, <%= schema.singular %>} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{ + {:ok, <%= schema.singular %>, expired_tokens} = + <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ password: "new valid password" }) + assert expired_tokens == [] assert is_nil(<%= schema.singular %>.password) assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") end @@ -296,8 +231,8 @@ test "deletes all tokens for the given <%= schema.singular %>", %{<%= schema.singular %>: <%= schema.singular %>} do _ = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - {:ok, _} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, valid_<%= schema.singular %>_password(), %{ + {:ok, _, _} = + <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ password: "new valid password" }) @@ -348,150 +283,85 @@ end end - describe "delete_<%= schema.singular %>_session_token/1" do - test "deletes the token" do + describe "get_<%= schema.singular %>_by_magic_link_token/1" do + setup do <%= schema.singular %> = <%= schema.singular %>_fixture() - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - assert <%= inspect context.alias %>.delete_<%= schema.singular %>_session_token(token) == :ok - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) + {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) + %{<%= schema.singular %>: <%= schema.singular %>, token: encoded_token} end - end - describe "deliver_<%= schema.singular %>_confirmation_instructions/2" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} + test "returns <%= schema.singular %> by token", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do + assert session_<%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) + assert session_<%= schema.singular %>.id == <%= schema.singular %>.id end - test "sends token through notification", %{<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions(<%= schema.singular %>, url) - end) + test "does not return <%= schema.singular %> for invalid token" do + refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token("oops") + end - {:ok, token} = Base.url_decode64(token, padding: false) - assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: :crypto.hash(:sha256, token)) - assert <%= schema.singular %>_token.<%= schema.singular %>_id == <%= schema.singular %>.id - assert <%= schema.singular %>_token.sent_to == <%= schema.singular %>.email - assert <%= schema.singular %>_token.context == "confirm" + test "does not return <%= schema.singular %> for expired token", %{token: token} do + {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) end end - describe "confirm_<%= schema.singular %>/1" do - setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() + describe "login_<%= schema.singular %>_by_magic_link/1" do + test "confirms <%= schema.singular %> and expires tokens" do + <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() + refute <%= schema.singular %>.confirmed_at + {encoded_token, hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_confirmation_instructions(<%= schema.singular %>, url) - end) + assert {:ok, <%= schema.singular %>, [%{token: ^hashed_token}]} = + <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) - %{<%= schema.singular %>: <%= schema.singular %>, token: token} + assert <%= schema.singular %>.confirmed_at end - test "confirms the email with a valid token", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - assert {:ok, confirmed_<%= schema.singular %>} = <%= inspect context.alias %>.confirm_<%= schema.singular %>(token) - assert confirmed_<%= schema.singular %>.confirmed_at - assert confirmed_<%= schema.singular %>.confirmed_at != <%= schema.singular %>.confirmed_at - assert Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).confirmed_at - refute Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) + test "returns <%= schema.singular %> and (deleted) token for confirmed <%= schema.singular %>" do + <%= schema.singular %> = <%= schema.singular %>_fixture() + assert <%= schema.singular %>.confirmed_at + {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) + assert {:ok, ^<%= schema.singular %>, []} = <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) + # one time use only + assert {:error, :not_found} = <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) end - test "does not confirm with invalid token", %{<%= schema.singular %>: <%= schema.singular %>} do - assert <%= inspect context.alias %>.confirm_<%= schema.singular %>("oops") == :error - refute Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).confirmed_at - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) + test "raises when unconfirmed <%= schema.singular %> has password set" do + <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() + {1, nil} = Repo.update_all(<%= inspect schema.alias %>, set: [hashed_password: "hashed"]) + {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) + + assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> + <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) + end end + end - test "does not confirm email if token expired", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - assert <%= inspect context.alias %>.confirm_<%= schema.singular %>(token) == :error - refute Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).confirmed_at - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) + describe "delete_<%= schema.singular %>_session_token/1" do + test "deletes the token" do + <%= schema.singular %> = <%= schema.singular %>_fixture() + token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) + assert <%= inspect context.alias %>.delete_<%= schema.singular %>_session_token(token) == :ok + refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) end end - describe "deliver_<%= schema.singular %>_reset_password_instructions/2" do + describe "deliver_login_instructions/2" do setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} + %{<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture()} end test "sends token through notification", %{<%= schema.singular %>: <%= schema.singular %>} do token = extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, url) + <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) end) {:ok, token} = Base.url_decode64(token, padding: false) assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: :crypto.hash(:sha256, token)) assert <%= schema.singular %>_token.<%= schema.singular %>_id == <%= schema.singular %>.id assert <%= schema.singular %>_token.sent_to == <%= schema.singular %>.email - assert <%= schema.singular %>_token.context == "reset_password" - end - end - - describe "get_<%= schema.singular %>_by_reset_password_token/1" do - setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_reset_password_instructions(<%= schema.singular %>, url) - end) - - %{<%= schema.singular %>: <%= schema.singular %>, token: token} - end - - test "returns the <%= schema.singular %> with valid token", %{<%= schema.singular %>: %{id: id}, token: token} do - assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_reset_password_token(token) - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: id) - end - - test "does not return the <%= schema.singular %> with invalid token", %{<%= schema.singular %>: <%= schema.singular %>} do - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_reset_password_token("oops") - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not return the <%= schema.singular %> if token expired", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_reset_password_token(token) - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - end - - describe "reset_<%= schema.singular %>_password/2" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "validates password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = - <%= inspect context.alias %>.reset_<%= schema.singular %>_password(<%= schema.singular %>, %{ - password: "not valid", - password_confirmation: "another" - }) - - assert %{ - password: ["should be at least 12 character(s)"], - password_confirmation: ["does not match password"] - } = errors_on(changeset) - end - - test "validates maximum values for password for security", %{<%= schema.singular %>: <%= schema.singular %>} do - too_long = String.duplicate("db", 100) - {:error, changeset} = <%= inspect context.alias %>.reset_<%= schema.singular %>_password(<%= schema.singular %>, %{password: too_long}) - assert "should be at most 72 character(s)" in errors_on(changeset).password - end - - test "updates the password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:ok, updated_<%= schema.singular %>} = <%= inspect context.alias %>.reset_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "new valid password"}) - assert is_nil(updated_<%= schema.singular %>.password) - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") - end - - test "deletes all tokens for the given <%= schema.singular %>", %{<%= schema.singular %>: <%= schema.singular %>} do - _ = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - {:ok, _} = <%= inspect context.alias %>.reset_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "new valid password"}) - refute Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) + assert <%= schema.singular %>_token.context == "login" end end From 7c9ef5e7d33ed635a845044a82dd526393c7bae3 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 12 Feb 2025 14:03:14 +0100 Subject: [PATCH 2/9] remove failing test assertions --- lib/mix/tasks/phx.gen.auth.ex | 7 +- .../phx.gen.auth/context_functions.ex | 4 +- .../phx.gen.auth/login_live_test.exs | 3 +- priv/templates/phx.gen.auth/notifier.ex | 2 +- priv/templates/phx.gen.auth/test_cases.exs | 10 +- test/mix/tasks/phx.gen.auth_test.exs | 291 +++--------------- 6 files changed, 60 insertions(+), 257 deletions(-) diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index da83a1aa45..90284d8215 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -167,7 +167,8 @@ defmodule Mix.Tasks.Phx.Gen.Auth do router_scope: router_scope(context), web_path_prefix: web_path_prefix(schema), test_case_options: test_case_options(ecto_adapter), - live?: Keyword.fetch!(context.opts, :live) + live?: Keyword.fetch!(context.opts, :live), + datetime_module: datetime_module(schema) ] paths = Mix.Phoenix.generator_paths() @@ -791,6 +792,10 @@ defmodule Mix.Tasks.Phx.Gen.Auth do defp test_case_options(Ecto.Adapters.Postgres), do: ", async: true" defp test_case_options(adapter) when is_atom(adapter), do: "" + defp datetime_module(%{timestamp_type: :naive_datetime}), do: NaiveDateTime + defp datetime_module(%{timestamp_type: :utc_datetime}), do: DateTime + defp datetime_module(%{timestamp_type: :utc_datetime_usec}), do: DateTime + defp put_live_option(schema) do opts = case Keyword.fetch(schema.opts, :live) do diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex index a6b007c469..eb38be15ea 100644 --- a/priv/templates/phx.gen.auth/context_functions.ex +++ b/priv/templates/phx.gen.auth/context_functions.ex @@ -82,8 +82,8 @@ """ def sudo_mode?(<%= schema.singular %>, minutes \\ -20) - def sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do - DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) + def sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: ts}, minutes) when is_struct(ts, <%= inspect datetime_module %>) do + <%= inspect datetime_module %>.after?(ts, <%= inspect datetime_module %>.utc_now() |> <%= inspect datetime_module %>.add(minutes, :minute)) end def sudo_mode?(_<%= schema.singular %>, _minutes), do: false diff --git a/priv/templates/phx.gen.auth/login_live_test.exs b/priv/templates/phx.gen.auth/login_live_test.exs index e5631ed73f..5b6d90e69d 100644 --- a/priv/templates/phx.gen.auth/login_live_test.exs +++ b/priv/templates/phx.gen.auth/login_live_test.exs @@ -27,7 +27,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert html =~ "If your email is in our system" - assert <%= inspect schema.repo %>.get_by!(<%= inspect context.module %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "login" + assert <%= inspect schema.repo %>.get_by!(<%= inspect context.module %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == + "login" end test "does not disclose if <%= schema.singular %> is registered", %{conn: conn} do diff --git a/priv/templates/phx.gen.auth/notifier.ex b/priv/templates/phx.gen.auth/notifier.ex index 586d7b9caa..a74a96f26c 100644 --- a/priv/templates/phx.gen.auth/notifier.ex +++ b/priv/templates/phx.gen.auth/notifier.ex @@ -49,7 +49,7 @@ defmodule <%= inspect context.module %>.<%= inspect schema.alias %>Notifier do end defp deliver_magic_link_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Log in", """ + deliver(<%= schema.singular %>.email, "Log in instructions", """ ============================== diff --git a/priv/templates/phx.gen.auth/test_cases.exs b/priv/templates/phx.gen.auth/test_cases.exs index a4ce367f7d..98632c46a1 100644 --- a/priv/templates/phx.gen.auth/test_cases.exs +++ b/priv/templates/phx.gen.auth/test_cases.exs @@ -84,15 +84,15 @@ describe "sudo_mode?/2" do test "validates the authenticated_at time" do - now = DateTime.utc_now() + now = <%= inspect datetime_module %>.utc_now() - assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.utc_now()}) - assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -19, :minute)}) - refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -21, :minute)}) + assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.utc_now()}) + assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -19, :minute)}) + refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -21, :minute)}) # minute override refute <%= inspect context.alias %>.sudo_mode?( - %<%= inspect schema.alias %>{authenticated_at: DateTime.add(now, -11, :minute)}, + %<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -11, :minute)}, -10 ) diff --git a/test/mix/tasks/phx.gen.auth_test.exs b/test/mix/tasks/phx.gen.auth_test.exs index fb90b40fbf..e5c78eaf4c 100644 --- a/test/mix/tasks/phx.gen.auth_test.exs +++ b/test/mix/tasks/phx.gen.auth_test.exs @@ -109,7 +109,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ "Mailer.deliver(email)" assert file =~ ~s|from({"MyApp", "contact@example.com"})| assert file =~ ~s|deliver(user.email, "Confirmation instructions",| - assert file =~ ~s|deliver(user.email, "Reset password instructions",| + assert file =~ ~s|deliver(user.email, "Log in instructions",| assert file =~ ~s|deliver(user.email, "Update email instructions",| end) @@ -117,18 +117,9 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert_file("test/support/fixtures/accounts_fixtures.ex") assert_file("lib/my_app_web/user_auth.ex") assert_file("test/my_app_web/user_auth_test.exs") - assert_file("lib/my_app_web/controllers/user_confirmation_html.ex") - assert_file("lib/my_app_web/controllers/user_confirmation_html/new.html.heex") - assert_file("lib/my_app_web/controllers/user_confirmation_controller.ex") - assert_file("test/my_app_web/controllers/user_confirmation_controller_test.exs") assert_file("lib/my_app_web/controllers/user_registration_controller.ex") assert_file("lib/my_app_web/controllers/user_registration_html.ex") assert_file("test/my_app_web/controllers/user_registration_controller_test.exs") - assert_file("lib/my_app_web/controllers/user_reset_password_controller.ex") - assert_file("lib/my_app_web/controllers/user_reset_password_html/edit.html.heex") - assert_file("lib/my_app_web/controllers/user_reset_password_html/new.html.heex") - assert_file("lib/my_app_web/controllers/user_reset_password_html.ex") - assert_file("test/my_app_web/controllers/user_reset_password_controller_test.exs") assert_file("lib/my_app_web/controllers/user_session_controller.ex") assert_file("lib/my_app_web/controllers/user_session_html/new.html.heex") assert_file("test/my_app_web/controllers/user_session_controller_test.exs") @@ -161,12 +152,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do get "/users/register", UserRegistrationController, :new post "/users/register", UserRegistrationController, :create - get "/users/log-in", UserSessionController, :new - post "/users/log-in", UserSessionController, :create - get "/users/reset-password", UserResetPasswordController, :new - post "/users/reset-password", UserResetPasswordController, :create - get "/users/reset-password/:token", UserResetPasswordController, :edit - put "/users/reset-password/:token", UserResetPasswordController, :update end scope "/", MyAppWeb do @@ -180,11 +165,10 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do scope "/", MyAppWeb do pipe_through [:browser] + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create delete "/users/log-out", UserSessionController, :delete - get "/users/confirm", UserConfirmationController, :new - post "/users/confirm", UserConfirmationController, :create - get "/users/confirm/:token", UserConfirmationController, :edit - post "/users/confirm/:token", UserConfirmationController, :update end """ end) @@ -204,8 +188,8 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end) assert_file("test/support/conn_case.ex", fn file -> - assert file =~ "def register_and_log_in_user(%{conn: conn})" - assert file =~ "def log_in_user(conn, user)" + assert file =~ "def register_and_log_in_user(%{conn: conn} = context)" + assert file =~ "def log_in_user(conn, user, opts \\\\ [])" end) assert_received {:mix_shell, :info, @@ -245,7 +229,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ "Mailer.deliver(email)" assert file =~ ~s|from({"MyApp", "contact@example.com"})| assert file =~ ~s|deliver(user.email, "Confirmation instructions",| - assert file =~ ~s|deliver(user.email, "Reset password instructions",| + assert file =~ ~s|deliver(user.email, "Log in instructions",| assert file =~ ~s|deliver(user.email, "Update email instructions",| end) @@ -253,16 +237,10 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert_file("test/my_app_web/live/user_live/registration_test.exs") assert_file("lib/my_app_web/live/user_live/login.ex") assert_file("test/my_app_web/live/user_live/login_test.exs") - assert_file("lib/my_app_web/live/user_live/reset_password.ex") - assert_file("test/my_app_web/live/user_live/reset_password_test.exs") - assert_file("lib/my_app_web/live/user_live/forgot_password.ex") - assert_file("test/my_app_web/live/user_live/forgot_password_test.exs") assert_file("lib/my_app_web/live/user_live/settings.ex") assert_file("test/my_app_web/live/user_live/settings_test.exs") assert_file("lib/my_app_web/live/user_live/confirmation.ex") assert_file("test/my_app_web/live/user_live/confirmation_test.exs") - assert_file("lib/my_app_web/live/user_live/confirmation_instructions.ex") - assert_file("test/my_app_web/live/user_live/confirmation_instructions_test.exs") assert_file("lib/my_app_web/user_auth.ex") assert_file("test/my_app_web/user_auth_test.exs") @@ -285,20 +263,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ """ ## Authentication routes - scope "/", MyAppWeb do - pipe_through [:browser, :redirect_if_user_is_authenticated] - - live_session :redirect_if_user_is_authenticated, - on_mount: [{MyAppWeb.UserAuth, :redirect_if_user_is_authenticated}] do - live "/users/register", UserLive.Registration, :new - live "/users/log-in", UserLive.Login, :new - live "/users/reset-password", UserLive.ForgotPassword, :new - live "/users/reset-password/:token", UserLive.ResetPassword, :edit - end - - post "/users/log-in", UserSessionController, :create - end - scope "/", MyAppWeb do pipe_through [:browser, :require_authenticated_user] @@ -307,18 +271,22 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do live "/users/settings", UserLive.Settings, :edit live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email end + + post "/users/update-password", UserSessionController, :update_password end scope "/", MyAppWeb do pipe_through [:browser] - delete "/users/log-out", UserSessionController, :delete - live_session :current_user, on_mount: [{MyAppWeb.UserAuth, :mount_current_user}] do - live "/users/confirm/:token", UserLive.Confirmation, :edit - live "/users/confirm", UserLive.ConfirmationInstructions, :new + live "/users/register", UserLive.Registration, :new + live "/users/log-in", UserLive.Login, :new + live "/users/log-in/:token", UserLive.Confirmation, :new end + + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete end """ end) @@ -338,8 +306,8 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end) assert_file("test/support/conn_case.ex", fn file -> - assert file =~ "def register_and_log_in_user(%{conn: conn})" - assert file =~ "def log_in_user(conn, user)" + assert file =~ "def register_and_log_in_user(%{conn: conn} = context)" + assert file =~ "def log_in_user(conn, user, opts \\\\ [])" end) assert_received {:mix_shell, :info, @@ -382,20 +350,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ """ ## Authentication routes - scope "/", MyAppWeb do - pipe_through [:browser, :redirect_if_user_is_authenticated] - - live_session :redirect_if_user_is_authenticated, - on_mount: [{MyAppWeb.UserAuth, :redirect_if_user_is_authenticated}] do - live "/users/register", UserLive.Registration, :new - live "/users/log-in", UserLive.Login, :new - live "/users/reset-password", UserLive.ForgotPassword, :new - live "/users/reset-password/:token", UserLive.ResetPassword, :edit - end - - post "/users/log-in", UserSessionController, :create - end - scope "/", MyAppWeb do pipe_through [:browser, :require_authenticated_user] @@ -404,18 +358,22 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do live "/users/settings", UserLive.Settings, :edit live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email end + + post "/users/update-password", UserSessionController, :update_password end scope "/", MyAppWeb do pipe_through [:browser] - delete "/users/log-out", UserSessionController, :delete - live_session :current_user, on_mount: [{MyAppWeb.UserAuth, :mount_current_user}] do - live "/users/confirm/:token", UserLive.Confirmation, :edit - live "/users/confirm", UserLive.ConfirmationInstructions, :new + live "/users/register", UserLive.Registration, :new + live "/users/log-in", UserLive.Login, :new + live "/users/log-in/:token", UserLive.Confirmation, :new end + + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete end """ end) @@ -455,12 +413,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do get "/users/register", UserRegistrationController, :new post "/users/register", UserRegistrationController, :create - get "/users/log-in", UserSessionController, :new - post "/users/log-in", UserSessionController, :create - get "/users/reset-password", UserResetPasswordController, :new - post "/users/reset-password", UserResetPasswordController, :create - get "/users/reset-password/:token", UserResetPasswordController, :edit - put "/users/reset-password/:token", UserResetPasswordController, :update end scope "/", MyAppWeb do @@ -474,11 +426,10 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do scope "/", MyAppWeb do pipe_through [:browser] + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create delete "/users/log-out", UserSessionController, :delete - get "/users/confirm", UserConfirmationController, :new - post "/users/confirm", UserConfirmationController, :create - get "/users/confirm/:token", UserConfirmationController, :edit - post "/users/confirm/:token", UserConfirmationController, :update end """ end) @@ -514,35 +465,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ "defmodule MyAppWeb.Warehouse.UserAuthTest do" end) - assert_file("lib/my_app_web/controllers/warehouse/user_confirmation_html.ex", fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserConfirmationHTML do" - end) - - assert_file("lib/my_app_web/controllers/warehouse/user_confirmation_html/new.html.heex", fn file -> - assert file =~ - ~S(<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/confirm"}>) - - assert file =~ - ~r|<\.link.*href={~p"/warehouse/users/register"}.*>|s - - assert file =~ - ~r|<\.link.*href={~p"/warehouse/users/log-in"}.*>|s - end) - - assert_file( - "lib/my_app_web/controllers/warehouse/user_confirmation_controller.ex", - fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserConfirmationController do" - end - ) - - assert_file( - "test/my_app_web/controllers/warehouse/user_confirmation_controller_test.exs", - fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserConfirmationControllerTest do" - end - ) - assert_file("lib/my_app_web/components/layouts/root.html.heex", fn file -> assert file =~ ~r|<\.link.*href={~p"/warehouse/users/settings"}.*>|s @@ -575,53 +497,26 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end ) - assert_file( - "lib/my_app_web/controllers/warehouse/user_reset_password_controller.ex", - fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserResetPasswordController do" - end - ) - - assert_file( - "lib/my_app_web/controllers/warehouse/user_reset_password_html/edit.html.heex", - fn file -> - assert file =~ - ~S|<.simple_form :let={f} for={@changeset} action={~p"/warehouse/users/reset-password/#{@token}"}>| - end - ) - - assert_file( - "lib/my_app_web/controllers/warehouse/user_reset_password_html/new.html.heex", - fn file -> - assert file =~ - ~S(<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/reset-password"}>) - end - ) - - assert_file("lib/my_app_web/controllers/warehouse/user_reset_password_html.ex", fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserResetPasswordHTML do" - end) - - assert_file( - "test/my_app_web/controllers/warehouse/user_reset_password_controller_test.exs", - fn file -> - assert file =~ "defmodule MyAppWeb.Warehouse.UserResetPasswordControllerTest do" - end - ) - assert_file("lib/my_app_web/controllers/warehouse/user_session_controller.ex", fn file -> assert file =~ "defmodule MyAppWeb.Warehouse.UserSessionController do" end) assert_file("lib/my_app_web/controllers/warehouse/user_session_html/new.html.heex", fn file -> assert file =~ - ~S|<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/log-in"}>| + ~S|<.simple_form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/warehouse/users/log-in"}>| - assert file =~ - ~S|<.link navigate={~p"/warehouse/users/register"}| + assert file =~ """ + <.simple_form + :let={f} + for={@form} + as={:user} + id="login_form_password" + action={~p"/warehouse/users/log-in"} + > + """ assert file =~ - ~S|<.link href={~p"/warehouse/users/reset-password"}| + ~S|navigate={~p"/warehouse/users/register"}| end) assert_file( @@ -677,12 +572,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do get "/users/register", UserRegistrationController, :new post "/users/register", UserRegistrationController, :create - get "/users/log-in", UserSessionController, :new - post "/users/log-in", UserSessionController, :create - get "/users/reset-password", UserResetPasswordController, :new - post "/users/reset-password", UserResetPasswordController, :create - get "/users/reset-password/:token", UserResetPasswordController, :edit - put "/users/reset-password/:token", UserResetPasswordController, :update end scope "/warehouse", MyAppWeb.Warehouse, as: :warehouse do @@ -696,18 +585,17 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do scope "/warehouse", MyAppWeb.Warehouse, as: :warehouse do pipe_through [:browser] + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create delete "/users/log-out", UserSessionController, :delete - get "/users/confirm", UserConfirmationController, :new - post "/users/confirm", UserConfirmationController, :create - get "/users/confirm/:token", UserConfirmationController, :edit - post "/users/confirm/:token", UserConfirmationController, :update end """ end) assert_file("test/support/conn_case.ex", fn file -> - assert file =~ "def register_and_log_in_user(%{conn: conn})" - assert file =~ "def log_in_user(conn, user)" + assert file =~ "def register_and_log_in_user(%{conn: conn} = context)" + assert file =~ "def log_in_user(conn, user, opts \\\\ [])" end) end) end @@ -735,13 +623,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ ~r/use MyAppWeb\.ConnCase, async: true$/m end) - assert_file( - "test/my_app_web/controllers/user_confirmation_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase, async: true$/m - end - ) - assert_file( "test/my_app_web/controllers/user_registration_controller_test.exs", fn file -> @@ -749,13 +630,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end ) - assert_file( - "test/my_app_web/controllers/user_reset_password_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase, async: true$/m - end - ) - assert_file("test/my_app_web/controllers/user_session_controller_test.exs", fn file -> assert file =~ ~r/use MyAppWeb\.ConnCase, async: true$/m end) @@ -788,13 +662,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) - assert_file( - "test/my_app_web/controllers/user_confirmation_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file( "test/my_app_web/controllers/user_registration_controller_test.exs", fn file -> @@ -802,13 +669,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end ) - assert_file( - "test/my_app_web/controllers/user_reset_password_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file("test/my_app_web/controllers/user_session_controller_test.exs", fn file -> assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) @@ -841,13 +701,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) - assert_file( - "test/my_app_web/controllers/user_confirmation_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file( "test/my_app_web/controllers/user_registration_controller_test.exs", fn file -> @@ -855,13 +708,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end ) - assert_file( - "test/my_app_web/controllers/user_reset_password_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file("test/my_app_web/controllers/user_session_controller_test.exs", fn file -> assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) @@ -894,13 +740,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) - assert_file( - "test/my_app_web/controllers/user_confirmation_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file( "test/my_app_web/controllers/user_registration_controller_test.exs", fn file -> @@ -908,13 +747,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do end ) - assert_file( - "test/my_app_web/controllers/user_reset_password_controller_test.exs", - fn file -> - assert file =~ ~r/use MyAppWeb\.ConnCase$/m - end - ) - assert_file("test/my_app_web/controllers/user_session_controller_test.exs", fn file -> assert file =~ ~r/use MyAppWeb\.ConnCase$/m end) @@ -1118,13 +950,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert_file("apps/my_app/test/support/fixtures/accounts_fixtures.ex") assert_file("apps/my_app/lib/my_app_web/user_auth.ex") assert_file("apps/my_app/test/my_app_web/user_auth_test.exs") - assert_file("apps/my_app/lib/my_app_web/controllers/user_confirmation_html.ex") - assert_file("apps/my_app/lib/my_app_web/controllers/user_confirmation_html/new.html.heex") - assert_file("apps/my_app/lib/my_app_web/controllers/user_confirmation_controller.ex") - - assert_file( - "apps/my_app/test/my_app_web/controllers/user_confirmation_controller_test.exs" - ) assert_file("apps/my_app/lib/my_app_web/controllers/user_registration_controller.ex") assert_file("apps/my_app/lib/my_app_web/controllers/user_registration_html.ex") @@ -1133,15 +958,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do "apps/my_app/test/my_app_web/controllers/user_registration_controller_test.exs" ) - assert_file("apps/my_app/lib/my_app_web/controllers/user_reset_password_controller.ex") - assert_file("apps/my_app/lib/my_app_web/controllers/user_reset_password_html/edit.html.heex") - assert_file("apps/my_app/lib/my_app_web/controllers/user_reset_password_html/new.html.heex") - assert_file("apps/my_app/lib/my_app_web/controllers/user_reset_password_html.ex") - - assert_file( - "apps/my_app/test/my_app_web/controllers/user_reset_password_controller_test.exs" - ) - assert_file("apps/my_app/lib/my_app_web/controllers/user_session_controller.ex") assert_file("apps/my_app/lib/my_app_web/controllers/user_session_html/new.html.heex") assert_file("apps/my_app/test/my_app_web/controllers/user_session_controller_test.exs") @@ -1176,13 +992,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert_file("apps/my_app/test/support/fixtures/accounts_fixtures.ex") assert_file("apps/my_app_web/lib/my_app_web/user_auth.ex") assert_file("apps/my_app_web/test/my_app_web/user_auth_test.exs") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_confirmation_html.ex") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_confirmation_html/new.html.heex") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_confirmation_controller.ex") - - assert_file( - "apps/my_app_web/test/my_app_web/controllers/user_confirmation_controller_test.exs" - ) assert_file("apps/my_app_web/lib/my_app_web/controllers/user_registration_controller.ex") assert_file("apps/my_app_web/lib/my_app_web/controllers/user_registration_html.ex") @@ -1191,18 +1000,6 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do "apps/my_app_web/test/my_app_web/controllers/user_registration_controller_test.exs" ) - assert_file( - "apps/my_app_web/lib/my_app_web/controllers/user_reset_password_controller.ex" - ) - - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_reset_password_html/edit.html.heex") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_reset_password_html/new.html.heex") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_reset_password_html.ex") - - assert_file( - "apps/my_app_web/test/my_app_web/controllers/user_reset_password_controller_test.exs" - ) - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_session_controller.ex") assert_file("apps/my_app_web/lib/my_app_web/controllers/user_session_html/new.html.heex") From 06edadc45ab30f813a4b076807be2c0a918402dd Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 12 Feb 2025 14:35:45 +0100 Subject: [PATCH 3/9] datetime_module --- priv/templates/phx.gen.auth/auth_test.exs | 2 +- priv/templates/phx.gen.auth/settings_controller_test.exs | 2 +- priv/templates/phx.gen.auth/settings_live_test.exs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/priv/templates/phx.gen.auth/auth_test.exs b/priv/templates/phx.gen.auth/auth_test.exs index 89082ed184..94815f33ca 100644 --- a/priv/templates/phx.gen.auth/auth_test.exs +++ b/priv/templates/phx.gen.auth/auth_test.exs @@ -14,7 +14,7 @@ defmodule <%= inspect auth_module %>Test do |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base)) |> init_test_session(%{}) - %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: DateTime.utc_now()}, conn: conn} + %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: <%= inspect datetime_module %>.utc_now()}, conn: conn} end describe "log_in_<%= schema.singular %>/3" do diff --git a/priv/templates/phx.gen.auth/settings_controller_test.exs b/priv/templates/phx.gen.auth/settings_controller_test.exs index 9edeadf3bf..c2705f1447 100644 --- a/priv/templates/phx.gen.auth/settings_controller_test.exs +++ b/priv/templates/phx.gen.auth/settings_controller_test.exs @@ -19,7 +19,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" end - @tag token_inserted_at: DateTime.add(DateTime.utc_now(), -11, :minute) + @tag token_inserted_at: <%= inspect datetime_module %>.add(<%= inspect datetime_module %>.utc_now(), -11, :minute) test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do conn = get(conn, ~p"<%= schema.route_prefix %>/settings") assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" diff --git a/priv/templates/phx.gen.auth/settings_live_test.exs b/priv/templates/phx.gen.auth/settings_live_test.exs index afb9bf93a2..6642b2c94a 100644 --- a/priv/templates/phx.gen.auth/settings_live_test.exs +++ b/priv/templates/phx.gen.auth/settings_live_test.exs @@ -28,7 +28,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web {:ok, conn} = conn |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture(), - token_inserted_at: DateTime.add(DateTime.utc_now(), -11, :minute) + token_inserted_at: <%= inspect datetime_module %>.add(<%= inspect datetime_module %>.utc_now(), -11, :minute) ) |> live(~p"<%= schema.route_prefix %>/settings") |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") From 1627f4767b0a24491166cac6aa50abc749a111ee Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 12 Feb 2025 15:30:49 +0100 Subject: [PATCH 4/9] log in page -> login page --- priv/templates/phx.gen.auth/confirmation_live_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/templates/phx.gen.auth/confirmation_live_test.exs b/priv/templates/phx.gen.auth/confirmation_live_test.exs index 8a25a044ee..ff4da7f1ad 100644 --- a/priv/templates/phx.gen.auth/confirmation_live_test.exs +++ b/priv/templates/phx.gen.auth/confirmation_live_test.exs @@ -21,7 +21,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert html =~ "Confirm my account" end - test "renders log in page for confirmed <%= schema.singular %>", %{conn: conn, confirmed_<%= schema.singular %>: <%= schema.singular %>} do + test "renders login page for confirmed <%= schema.singular %>", %{conn: conn, confirmed_<%= schema.singular %>: <%= schema.singular %>} do token = extract_<%= schema.singular %>_token(fn url -> <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) From 0dbb9a966dc25703b7975f89504c81271e6cee9c Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 13 Feb 2025 17:25:04 +0100 Subject: [PATCH 5/9] format phx.gen.auth_test.exs --- test/mix/tasks/phx.gen.auth_test.exs | 227 ++++++++++++++------------- 1 file changed, 121 insertions(+), 106 deletions(-) diff --git a/test/mix/tasks/phx.gen.auth_test.exs b/test/mix/tasks/phx.gen.auth_test.exs index e5c78eaf4c..ef3a39c401 100644 --- a/test/mix/tasks/phx.gen.auth_test.exs +++ b/test/mix/tasks/phx.gen.auth_test.exs @@ -86,7 +86,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "generates with defaults (Prompt: --no-live)", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -206,7 +206,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "generates with defaults (Prompt: --live)", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, true} + send(self(), {:mix_shell_input, :yes?, true}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -438,7 +438,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "generates with --web option", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --web warehouse --no-compile), @@ -501,23 +501,26 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ "defmodule MyAppWeb.Warehouse.UserSessionController do" end) - assert_file("lib/my_app_web/controllers/warehouse/user_session_html/new.html.heex", fn file -> - assert file =~ - ~S|<.simple_form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/warehouse/users/log-in"}>| + assert_file( + "lib/my_app_web/controllers/warehouse/user_session_html/new.html.heex", + fn file -> + assert file =~ + ~S|<.simple_form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/warehouse/users/log-in"}>| - assert file =~ """ - <.simple_form - :let={f} - for={@form} - as={:user} - id="login_form_password" - action={~p"/warehouse/users/log-in"} - > - """ + assert file =~ """ + <.simple_form + :let={f} + for={@form} + as={:user} + id="login_form_password" + action={~p"/warehouse/users/log-in"} + > + """ - assert file =~ - ~S|navigate={~p"/warehouse/users/register"}| - end) + assert file =~ + ~S|navigate={~p"/warehouse/users/register"}| + end + ) assert_file( "test/my_app_web/controllers/warehouse/user_session_controller_test.exs", @@ -534,13 +537,16 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert file =~ "defmodule MyAppWeb.Warehouse.UserSettingsController do" end) - assert_file("lib/my_app_web/controllers/warehouse/user_settings_html/edit.html.heex", fn file -> - assert file =~ - ~S|<.simple_form :let={f} for={@email_changeset} action={~p"/warehouse/users/settings"} id="update_email">| + assert_file( + "lib/my_app_web/controllers/warehouse/user_settings_html/edit.html.heex", + fn file -> + assert file =~ + ~S|<.simple_form :let={f} for={@email_changeset} action={~p"/warehouse/users/settings"} id="update_email">| - assert file =~ - ~s|<.simple_form\n :let={f}\n for={@password_changeset}\n action={~p"/warehouse/users/settings"}\n id="update_password"\n >| - end) + assert file =~ + ~s|<.simple_form\n :let={f}\n for={@password_changeset}\n action={~p"/warehouse/users/settings"}\n id="update_password"\n >| + end + ) assert_file("lib/my_app_web/controllers/warehouse/user_settings_html.ex", fn file -> assert file =~ "defmodule MyAppWeb.Warehouse.UserSettingsHTML do" @@ -603,7 +609,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do describe "--database option" do test "when the database is postgres", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -642,7 +648,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "when the database is mysql", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -681,7 +687,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "when the database is sqlite3", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -720,7 +726,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "when the database is mssql", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -760,37 +766,42 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "allows utc_datetime", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} - with_generator_env(:my_app, [timestamp_type: :utc_datetime], fn -> + send(self(), {:mix_shell_input, :yes?, false}) + with_generator_env(:my_app, [timestamp_type: :utc_datetime], fn -> Gen.Auth.run( - ~w(Accounts User users --no-compile), - ecto_adapter: Ecto.Adapters.Postgres + ~w(Accounts User users --no-compile), + ecto_adapter: Ecto.Adapters.Postgres ) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_users_auth_tables.exs") - assert_file migration, fn file -> + assert_file(migration, fn file -> assert file =~ "timestamps(type: :utc_datetime)" assert file =~ "timestamps(type: :utc_datetime, updated_at: false)" - end + end) - assert_file "lib/my_app/accounts/user.ex", fn file -> + assert_file("lib/my_app/accounts/user.ex", fn file -> assert file =~ "field :confirmed_at, :utc_datetime" assert file =~ "timestamps(type: :utc_datetime)" assert file =~ "now = DateTime.utc_now() |> DateTime.truncate(:second)" - end + end) - assert_file "lib/my_app/accounts/user_token.ex", fn file -> + assert_file("lib/my_app/accounts/user_token.ex", fn file -> assert file =~ "timestamps(type: :utc_datetime, updated_at: false)" - end + end) + + assert_file("lib/my_app/accounts.ex", fn file -> + assert file =~ + "sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime)" + end) end) end) end test "supports --binary-id option", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --binary-id --no-compile), @@ -822,7 +833,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do describe "--hashing-lib option" do test "when bcrypt", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --hashing-lib bcrypt --no-compile), @@ -847,7 +858,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "when pbkdf2", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --hashing-lib pbkdf2 --no-compile), @@ -872,7 +883,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "when argon2", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --hashing-lib argon2 --no-compile), @@ -900,7 +911,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do test "with --table option", config do in_tmp_phx_project(config.test, fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --table my_users --no-compile), @@ -931,7 +942,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do in_tmp_phx_umbrella_project(config.test, fn -> in_project(:my_app, "apps/my_app", fn _module -> with_generator_env(:my_app_web, [context_app: nil], fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -973,7 +984,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do in_tmp_phx_umbrella_project(config.test, fn -> in_project(:my_app_web, "apps/my_app_web", fn _module -> with_generator_env(:my_app_web, [context_app: :my_app], fn -> - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1009,7 +1020,11 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert_file("apps/my_app_web/lib/my_app_web/controllers/user_session_html.ex") assert_file("apps/my_app_web/lib/my_app_web/controllers/user_settings_controller.ex") - assert_file("apps/my_app_web/lib/my_app_web/controllers/user_settings_html/edit.html.heex") + + assert_file( + "apps/my_app_web/lib/my_app_web/controllers/user_settings_html/edit.html.heex" + ) + assert_file("apps/my_app_web/lib/my_app_web/controllers/user_settings_html.ex") assert_file( @@ -1039,7 +1054,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do in_tmp_phx_project(config.test, fn -> File.write!("mix.exs", "") - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1071,7 +1086,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do String.replace(file, "use MyAppWeb, :router", "") end) - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1106,7 +1121,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do String.replace(file, "plug :put_secure_browser_headers\n", "") end) - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1137,7 +1152,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do File.rm!("lib/my_app_web/components/layouts/root.html.heex") File.rm!("lib/my_app_web/components/layouts/app.html.heex") - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1150,60 +1165,60 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do assert error == """ - Unable to find an application layout file to inject user menu items. - - Missing files: - - * lib/my_app_web/components/layouts/root.html.heex - * lib/my_app_web/components/layouts/app.html.heex - - Please ensure this phoenix app was not generated with - --no-html. If you have changed the name of your application - layout file, please add the following code to it where you'd - like the user menu items to be rendered. - -
    - <%= if @current_user do %> -
  • - {@current_user.email} -
  • -
  • - <.link - href={~p"/users/settings"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Settings - -
  • -
  • - <.link - href={~p"/users/log-out"} - method="delete" - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Log out - -
  • - <% else %> -
  • - <.link - href={~p"/users/register"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Register - -
  • -
  • - <.link - href={~p"/users/log-in"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Log in - -
  • - <% end %> -
- """ + Unable to find an application layout file to inject user menu items. + + Missing files: + + * lib/my_app_web/components/layouts/root.html.heex + * lib/my_app_web/components/layouts/app.html.heex + + Please ensure this phoenix app was not generated with + --no-html. If you have changed the name of your application + layout file, please add the following code to it where you'd + like the user menu items to be rendered. + +
    + <%= if @current_user do %> +
  • + {@current_user.email} +
  • +
  • + <.link + href={~p"/users/settings"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Settings + +
  • +
  • + <.link + href={~p"/users/log-out"} + method="delete" + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log out + +
  • + <% else %> +
  • + <.link + href={~p"/users/register"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Register + +
  • +
  • + <.link + href={~p"/users/log-in"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log in + +
  • + <% end %> +
+ """ end) end @@ -1213,7 +1228,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do "" end) - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts User users --no-compile), @@ -1280,7 +1295,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do File.mkdir_p!("priv/templates/phx.gen.auth") File.write!("priv/templates/phx.gen.auth/auth.ex", "#it works!") - send self(), {:mix_shell_input, :yes?, false} + send(self(), {:mix_shell_input, :yes?, false}) Gen.Auth.run( ~w(Accounts Admin admins --no-compile), From 4b2ed5b5d11d0b24fe2192730b0a3e7ac3cc9056 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 13 Feb 2025 17:25:24 +0100 Subject: [PATCH 6/9] format phx.gen.auth.ex --- lib/mix/tasks/phx.gen.auth.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 90284d8215..4472bbe2fd 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -342,7 +342,11 @@ defmodule Mix.Tasks.Phx.Gen.Auth do "registration_html.ex": [controller_pre, "#{singular}_registration_html.ex"], "session_html.ex": [controller_pre, "#{singular}_session_html.ex"], "session_new.html.heex": [controller_pre, "#{singular}_session_html", "new.html.heex"], - "session_confirm.html.heex": [controller_pre, "#{singular}_session_html", "confirm.html.heex"], + "session_confirm.html.heex": [ + controller_pre, + "#{singular}_session_html", + "confirm.html.heex" + ], "settings_html.ex": [web_pre, "controllers", web_path, "#{singular}_settings_html.ex"], "settings_controller.ex": [controller_pre, "#{singular}_settings_controller.ex"], "settings_edit.html.heex": [ From 1f44fafdd151f6eb43ac5d5c7097b8c0dc7a8caf Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 13 Feb 2025 17:58:31 +0100 Subject: [PATCH 7/9] security considerations section for docs --- lib/mix/tasks/phx.gen.auth.ex | 26 +++++++++++++++++++ .../phx.gen.auth/context_functions.ex | 6 ++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 4472bbe2fd..6094119131 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -26,6 +26,32 @@ defmodule Mix.Tasks.Phx.Gen.Auth do to authentication views without necessarily triggering a new HTTP request each time (which would result in a full page load). + ## Security considerations + + By default, `mix phx.gen.auth` generates an authentication solution that allows registration + using email and magic links only. Users must verify their email address after registration + by clicking a magic link, which both logs them in and confirms their email. + This email confirmation is crucial for preventing session fixation attacks. + + If you allow users to immediately log in after registering, either by registering with a password, + or by directly logging them in after only providing an email address, the following attack is possible: + + 1. An attacker registers a new account with the email address of their target, anticipating + that the target creates an account at a later point in time. + 2. The attacker sets a password (either when registering, or in the settings). + 3. The target registers an account and sees that their email address is already in use. + 4. The target logs in by magic link, but does not change the existing password. + 5. The attacker maintains access using the password they previously set. + + This is why the default implementation raises whenever a user tries to log in by magic link + when there is already a password set. You can safely remove this check if you do not allow + unconfirmed users to log in. In that case, registering with email and password is still secure. + + If you want to allow unconfirmed users to log in, you need to ensure that they need to reset + their password when they first sign in by magic link. To do this, you can add a required password + field to the confirmation LiveView / template and re-use the password change functionality from + the settings page. + ## Password hashing The password hashing mechanism defaults to `bcrypt` for diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex index eb38be15ea..f0d930b5e0 100644 --- a/priv/templates/phx.gen.auth/context_functions.ex +++ b/priv/templates/phx.gen.auth/context_functions.ex @@ -213,8 +213,8 @@ 3. The <%= schema.singular %> has not confirmed their email but a password is set. This cannot happen in the default implementation but may be the - source of security pitfalls. See the "Mixing magic link and password - registration" section of `mix help phx.gen.auth`. + source of security pitfalls. See the "Security considerations" section of + `mix help phx.gen.auth`. """ def login_<%= schema.singular %>_by_magic_link(token) do {:ok, query} = <%= inspect schema.alias %>Token.verify_magic_link_token_query(token) @@ -227,7 +227,7 @@ This cannot happen with the default implementation, which indicates that you might have adapted the code to a different use case. Please make sure to read the - "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + "Security considerations" section of `mix help phx.gen.auth`. """ {%<%= inspect schema.alias %>{confirmed_at: nil} = <%= schema.singular %>, _token} -> From 76682deba58ced12279e871d779da582c18007ba Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 14 Feb 2025 10:07:40 +0100 Subject: [PATCH 8/9] update security considerations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: José Valim --- lib/mix/tasks/phx.gen.auth.ex | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 6094119131..60e5fadbf9 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -28,29 +28,27 @@ defmodule Mix.Tasks.Phx.Gen.Auth do ## Security considerations - By default, `mix phx.gen.auth` generates an authentication solution that allows registration - using email and magic links only. Users must verify their email address after registration - by clicking a magic link, which both logs them in and confirms their email. - This email confirmation is crucial for preventing session fixation attacks. + `mix phx.gen.auth` generates email based authentication, which assumes the user who + owns the email address has control over the account. Therefore, it is extremely + important to void all access tokens once the user confirms their account for the first + time, and we do so by revoking all tokens upon confirmation. - If you allow users to immediately log in after registering, either by registering with a password, - or by directly logging them in after only providing an email address, the following attack is possible: + However, if you allow users to create an account with password, you must also + require them to be logged in by the time of confirmation, otherwise you may be + vulnerable to credential pre-stuffing, as the following attack is possible: 1. An attacker registers a new account with the email address of their target, anticipating that the target creates an account at a later point in time. - 2. The attacker sets a password (either when registering, or in the settings). + 2. The attacker sets a password when registering. 3. The target registers an account and sees that their email address is already in use. 4. The target logs in by magic link, but does not change the existing password. 5. The attacker maintains access using the password they previously set. - This is why the default implementation raises whenever a user tries to log in by magic link - when there is already a password set. You can safely remove this check if you do not allow - unconfirmed users to log in. In that case, registering with email and password is still secure. - - If you want to allow unconfirmed users to log in, you need to ensure that they need to reset - their password when they first sign in by magic link. To do this, you can add a required password - field to the confirmation LiveView / template and re-use the password change functionality from - the settings page. + This is why the default implementation raises whenever a user tries to log in for the first + time by magic link and there is a password set. If you add registration with email and + password, then you must require the user to be logged in to confirm their account. + If they don't have a password (because it was set by the attacker), then they can set one + via a "Forgot your password?"-like workflow. ## Password hashing From a18c47cd3edf4621c7cc53c39b2bedc562b71c22 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 14 Feb 2025 11:04:27 +0100 Subject: [PATCH 9/9] small adjustments to phx.gen.auth guide --- guides/authentication/mix_phx_gen_auth.md | 15 ++++++++++----- lib/mix/tasks/phx.gen.auth.ex | 2 +- priv/templates/phx.gen.auth/context_functions.ex | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/guides/authentication/mix_phx_gen_auth.md b/guides/authentication/mix_phx_gen_auth.md index 05b7cd533a..86e40255d1 100644 --- a/guides/authentication/mix_phx_gen_auth.md +++ b/guides/authentication/mix_phx_gen_auth.md @@ -2,7 +2,7 @@ > This guide assumes that you have gone through the [introductory guides](overview.html) and have a Phoenix application [up and running](up_and_running.html). -The `mix phx.gen.auth` command generates a flexible, pre-built authentication system into your Phoenix app. This generator allows you to quickly move past the task of adding authentication to your codebase and stay focused on the real-world problem your application is trying to solve. +The `mix phx.gen.auth` command generates a flexible, pre-built authentication system based on magic links into your Phoenix app. This generator allows you to quickly move past the task of adding authentication to your codebase and stay focused on the real-world problem your application is trying to solve. ## Getting started @@ -76,11 +76,10 @@ The generated code ships with an authentication module with a handful of plugs t * `fetch_current_user` - fetches the current user information if available * `require_authenticated_user` - must be invoked after `fetch_current_user` and requires that a current user exists and is authenticated - * `redirect_if_user_is_authenticated` - used for the few pages that must not be available to authenticated users + * `redirect_if_user_is_authenticated` - used for the few pages that must not be available to authenticated users (only generated for controller based authentication) + * `require_sudo_mode` - used for pages that contain sensitive operations and enforces recent authentication -### Confirmation - -The generated functionality ships with an account confirmation mechanism, where users have to confirm their account, typically by email. However, the generated code does not forbid users from using the application if their accounts have not yet been confirmed. You can add this functionality by customizing the `require_authenticated_user` in the `Auth` module to check for the `confirmed_at` field (and any other property you desire). +There are similar `:on_mount` hooks for LiveView based authentication. ### Notifiers @@ -102,6 +101,10 @@ If your application is sensitive to enumeration attacks, you need to implement y Furthermore, if you are concerned about enumeration attacks, beware of timing attacks too. For example, registering a new account typically involves additional work (such as writing to the database, sending emails, etc) compared to when an account already exists. Someone could measure the time taken to execute those additional tasks to enumerate emails. This applies to all endpoints (registration, confirmation, password recovery, etc.) that may send email, in-app notifications, etc. +### Confirmation and credential pre-stuffing attacks + +The generated functionality ships with an account confirmation mechanism, where users have to confirm their account, typically by email. Furthermore, to prevent security issues, the generated code does forbid users from using the application if their accounts have not yet been confirmed. If you want to change this behavior, please refer to the ["Mixing magic link and password registration" section](Mix.Tasks.Phx.Gen.Auth.html#module-mixing-magic-link-and-password-registration) of `mix phx.gen.auth`. + ### Case sensitiveness The email lookup is made to be case-insensitive. Case-insensitive lookups are the default in MySQL and MSSQL. In SQLite3 we use [`COLLATE NOCASE`](https://www.sqlite.org/datatype3.html#collating_sequences) in the column definition to support it. In PostgreSQL, we use the [`citext` extension](https://www.postgresql.org/docs/current/citext.html). @@ -125,6 +128,8 @@ The following links have more information regarding the motivation and design of * The [original `phx_gen_auth` repo][phx_gen_auth repo] (for Phoenix 1.5 applications) - This is a great resource to see discussions around decisions that have been made in earlier versions of the project. * [Original pull request on bare Phoenix app][auth PR] * [Original design spec](https://github.com/dashbitco/mix_phx_gen_auth_demo/blob/auth/README.md) + * [Pull request for migrating LiveView based Phoenix 1.7 `phx.gen.auth` to magic links](https://github.com/SteffenDE/phoenix_gen_auth_magic_link/pull/1) + * [Pull request for migrating controller based Phoenix 1.7 `phx.gen.auth` to magic links](https://github.com/SteffenDE/phoenix_gen_auth_magic_link/pull/2) [phx_gen_auth repo]: https://github.com/aaronrenner/phx_gen_auth [auth PR]: https://github.com/dashbitco/mix_phx_gen_auth_demo/pull/1 diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 60e5fadbf9..737d82d15d 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -26,7 +26,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do to authentication views without necessarily triggering a new HTTP request each time (which would result in a full page load). - ## Security considerations + ## Mixing magic link and password registration `mix phx.gen.auth` generates email based authentication, which assumes the user who owns the email address has control over the account. Therefore, it is extremely diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex index f0d930b5e0..51988d129d 100644 --- a/priv/templates/phx.gen.auth/context_functions.ex +++ b/priv/templates/phx.gen.auth/context_functions.ex @@ -213,7 +213,7 @@ 3. The <%= schema.singular %> has not confirmed their email but a password is set. This cannot happen in the default implementation but may be the - source of security pitfalls. See the "Security considerations" section of + source of security pitfalls. See the "Mixing magic link and password registration" section of `mix help phx.gen.auth`. """ def login_<%= schema.singular %>_by_magic_link(token) do @@ -227,7 +227,7 @@ This cannot happen with the default implementation, which indicates that you might have adapted the code to a different use case. Please make sure to read the - "Security considerations" section of `mix help phx.gen.auth`. + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. """ {%<%= inspect schema.alias %>{confirmed_at: nil} = <%= schema.singular %>, _token} ->