8000 fix(auth): return to the current page after sign-in by mwoods79 · Pull Request #681 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions lib/huddlz_web/auth_return_session.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
defmodule HuddlzWeb.AuthReturnSession do
@moduledoc """
Keeps a return destination in this browser while password recovery opens an
email link. It expires after 30 minutes and AuthController consumes it.
Starting a fresh sign-in, registration or recovery journey forgets the old one.
"""
@behaviour Plug

import Plug.Conn
alias HuddlzWeb.AuthReturnTo

@lifetime 30 * 60

@impl true
def init(opts), do: opts

@impl true
def call(%{method: "GET", path_info: [page]} = conn, _opts)
when page in ["sign-in", "register", "reset"] do
remember(
conn,
conn.assigns[:current_user],
AuthReturnTo.validate(conn.query_params["return_to"])
)
end

def call(conn, _opts), do: conn

defp remember(conn, nil = _user, path) when is_binary(path),
do: put_session(conn, :return_to, %{"path" => path, "at" => System.system_time(:second)})

defp remember(conn, _user, _path), do: delete_session(conn, :return_to)

@doc "Returns the unexpired, validated destination remembered in this browser."
def destination(session, now \\ System.system_time(:second)) do
case session["return_to"] do
%{"path" => path, "at" => at} when is_integer(at) and now >= at and now - at <= @lifetime ->
AuthReturnTo.validate(path)

_ ->
nil
end
end
end
60 changes: 57 additions & 3 deletions lib/huddlz_web/auth_return_to.ex
Original file line number Diff line number Diff line change
@@ -1,23 +1,77 @@
defmodule HuddlzWeb.AuthReturnTo do
@moduledoc false
alias Plug.Conn.Query

@doc """
Returns a local path that is safe to use after authentication, or `nil`.
"""
@spec validate(term()) :: String.t() | nil
def validate(path) when is_binary(path) do
case URI.new(path) do
{:ok, uri} -> if local_path?(uri, path), do: path
{:ok, uri} -> if local_path?(uri), do: path
_ -> nil
end
end

def validate(_path), do: nil

defp local_path?(%URI{scheme: nil, host: nil, path: "/" <> _}, path) do
@doc """
Returns the safe local path and query of a requested URI, or `nil`.
"""
def from_uri(uri) do
%URI{path: path, query: query} = URI.parse(uri)
validate(if query, do: "#{path}?#{query}", else: path)
end

@doc """
Rebuilds the page a routed LiveView is mounting, before it loads any data.
A mount sees params, not its URI, so the router's path pattern is filled
back in from them and the remaining params become the query.
"""
def for_live_view(%{router: router, view: view, assigns: %{live_action: action}}, params)
when is_atom(router) and not is_nil(router) and is_map(params) do
patterns =
for %{path: path, metadata: %{phoenix_live_view: {^view, ^action, _, _}}} <-
Phoenix.Router.routes(router),
do: path

with [pattern] <- patterns, false <- String.contains?(pattern, "*") do
{segments, keys} =
pattern
|> String.split("/")
|> Enum.map_reduce([], fn
":" <> key, keys -> {URI.encode(params[key], &URI.char_unreserved?/1), [key | keys]}
segment, keys -> {segment, keys}
end)

path = Enum.join(segments, "/")

query =
params |> Map.drop(keys) |> Query.encode() |> URI.encode(&(&1 not in ~c"[]"))

validate(if query == "", do: path, else: "#{path}?#{query}")
else
_ -> nil
end
end

def for_live_view(_socket, _params), do: nil

@doc """
Adds a safe `return_to` to an authentication page's path. Unsafe or missing
destinations leave the path bare.
"""
def path(path, return_to) do
case validate(return_to) do
nil -> path
return_to -> path <> "?" <> URI.encode_query(return_to: return_to)
end
end

defp local_path?(%URI{scheme: nil, host: nil, path: "/" <> _ = path}) do
not String.starts_with?(path, "//") and
not String.contains?(String.downcase(path), ["\\", "%2f", "%5c"])
end

defp local_path?(_uri, _path), do: false
defp local_path?(_uri), do: false
end
10 changes: 8 additions & 2 deletions lib/huddlz_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule HuddlzWeb.Layouts do

alias Huddlz.Accounts.User
alias Huddlz.Admin.Impersonation
alias HuddlzWeb.AuthReturnTo
alias HuddlzWeb.Avatar

embed_templates "layouts/*"
Expand Down Expand Up @@ -51,6 +52,11 @@ defmodule HuddlzWeb.Layouts do
doc: "groups the current_user organizes — rendered as sb-org-row entries"

attr :query, :string, default: "", doc: "current search query — prefilled in topbar input"

attr :return_to, :string,
default: nil,
doc: "page to come back to after the signed-out Sign in / Sign up links"

slot :inner_block, required: true

def app(assigns) do
Expand Down Expand Up @@ -339,8 +345,8 @@ defmodule HuddlzWeb.Layouts do
</span>
</.link>
<% else %>
<.link class="btn-secondary" navigate={~p"/sign-in"}>Sign in</.link>
<.link class="btn-primary" navigate={~p"/register"}>Sign up</.link>
<.link class="btn-secondary" navigate={AuthReturnTo.path(~p"/sign-in", @return_to)}>Sign in</.link>
<.link class="btn-primary" navigate={AuthReturnTo.path(~p"/register", @return_to)}>Sign up</.link>
<% end %>
</div>
</header>
Expand Down
32 changes: 25 additions & 7 deletions lib/huddlz_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule HuddlzWeb.AuthController do
BD0 alias Huddlz.Accounts.Token
alias Huddlz.Accounts.User
alias Huddlz.Accounts.User.Errors.ConfirmationAddressChanged
alias HuddlzWeb.AuthReturnSession
alias HuddlzWeb.AuthReturnTo
alias HuddlzWeb.BrowserSession

Expand All @@ -22,7 +23,23 @@ defmodule HuddlzWeb.AuthController do
|> redirect(to: ~p"/account-suspended")
end

def success(conn, activity, user, _token) do
def success(conn, {:password, :reset} = activity, user, _token) do
# Password changes revoke every existing token, including the reset action's
# generated token. Create this browser's session after that revocation.
case AshAuthentication.Jwt.token_for_user(user, %{}, domain: Huddlz.Accounts) do
{:ok, token, _claims} ->
complete_success(conn, activity, Ash.Resource.put_metadata(user, :token, token))

:error ->
conn
|> put_flash(:info, "Your password was reset. Please sign in with your new password.")
|> redirect(to: sign_in_path(conn))
end
end

def success(conn, activity, user, _token), do: complete_success(conn, activity, user)

defp complete_success(conn, activity, user) do
return_to =
if activity == {:confirm_new_user, :confirm} do
ConfirmationDestination.validate(user.__metadata__[:confirmation_destination]) || ~p"/"
Expand Down Expand Up @@ -89,7 +106,7 @@ defmodule HuddlzWeb.AuthController do

conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path(conn))
end

defp previous_address?(%ConfirmationAddressChanged{}), do: true
Expand Down Expand Up @@ -120,9 +137,10 @@ defmodule HuddlzWeb.AuthController do
end
end

defp return_to(conn) do
[conn.params["return_to"], get_session(conn, :return_to)]
|> Enum.find_value(&AuthReturnTo.validate/1)
|> Kernel.||(~p"/")
end
defp destination(%{params: %{"return_to" => return_to}}), do: AuthReturnTo.validate(return_to)
defp destination(conn), do: AuthReturnSession.destination(get_session(conn))

defp return_to(conn), do: destination(conn) || ~p"/"

defp sign_in_path(conn), do: AuthReturnTo.path(~p"/sign-in", destination(conn))
end
9 changes: 3 additions & 6 deletions lib/huddlz_web/live/auth_live/register.ex
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ defmodule HuddlzWeb.AuthLive.Register do
<Components.sign_in_token_form token={@sign_in_token} return_to={@return_to} />

<div class="auth-aside">
Already have an account? <.link navigate={sign_in_path(@return_to)}>Sign in</.link>
Already have an account?
<.link navigate={AuthReturnTo.path(~p"/sign-in", @return_to)}>Sign in</.link>
</div>
</Layouts.auth_shell>
"""
Expand Down Expand Up @@ -226,7 +227,7 @@ defmodule HuddlzWeb.AuthLive.Register do
:error,
"Registration succeeded but automatic sign-in failed. Please sign in manually."
)
|> redirect(to: sign_in_path(socket.assigns.return_to))
|> redirect(to: AuthReturnTo.path(~p"/sign-in", socket.assigns.return_to))
end
end

Expand All @@ -245,8 +246,4 @@ defmodule HuddlzWeb.AuthLive.Register do
"Registration failed. Please check your inputs and try again."
end
end

defp sign_in_path(nil), do: ~p"/sign-in"

defp sign_in_path(return_to), do: ~p"/sign-in?#{[return_to: return_to]}"
end
10 changes: 6 additions & 4 deletions lib/huddlz_web/live/auth_live/reset_password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ defmodule HuddlzWeb.AuthLive.ResetPassword do
alias AshPhoenix.Form
alias Huddlz.Accounts.User
alias HuddlzWeb.AuthFormErrors
alias HuddlzWeb.AuthReturnTo
alias HuddlzWeb.FormFocus

@impl true
def mount(_params, _session, socket) do
def mount(params, _session, socket) do
form =
Form.for_action(User, :request_password_reset_token,
post_process_errors: &AuthFormErrors.post_process/3
Expand All @@ -23,7 +24,8 @@ defmodule HuddlzWeb.AuthLive.ResetPassword do
|> assign(:page_title, "Reset password")
|> assign(:body_class, "is-auth")
|> assign(:form, to_form(form))
|> assign(:submitted, false)}
|> assign(:submitted, false)
|> assign(:return_to, AuthReturnTo.validate(params["return_to"]))}
end

@impl true
Expand All @@ -39,7 +41,7 @@ defmodule HuddlzWeb.AuthLive.ResetPassword do
<p>
If an account exists for that email, you will receive password reset instructions shortly.
</p>
<.link navigate={~p"/sign-in"} class="btn-primary">Back to sign in</.link>
<.link navigate={AuthReturnTo.path(~p"/sign-in", @return_to)} class="btn-primary">Back to sign in</.link>
</div>
<% else %>
<h1>Reset your password</h1>
Expand Down Expand Up @@ -70,7 +72,7 @@ defmodule HuddlzWeb.AuthLive.ResetPassword do
</.form>

<div class="auth-aside">
<.link navigate={~p"/sign-in"}>Back to sign in</.link>
<.link navigate={AuthReturnTo.path(~p"/sign-in", @return_to)}>Back to sign in</.link>
</div>
<% end %>
</Layouts.auth_shell>
Expand Down
14 changes: 9 additions & 5 deletions lib/huddlz_web/live/auth_live/reset_password_confirm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ defmodule HuddlzWeb.AuthLive.ResetPasswordConfirm do

alias AshPhoenix.Form
alias Huddlz.Accounts.User
alias HuddlzWeb.AuthReturnSession
alias HuddlzWeb.AuthReturnTo
alias HuddlzWeb.FormFocus

@impl true
def mount(%{"token" => token}, _session, socket) do
def mount(%{"token" => token}, session, socket) do
strategy = AshAuthentication.Info.strategy!(User, :password)
domain = AshAuthentication.Info.authentication_domain!(User)

socket =
socket
|> assign(:page_title, "Set new password")
|> assign(:body_class, "is-auth")
|> assign(:return_to, AuthReturnSession.destination(session))

with {:ok, %{"sub" => subject}, _resource} <- AshAuthentication.Jwt.verify(token, User),
{:ok, user} <- AshAuthentication.subject_to_user(subject, User) do
Expand All @@ -43,11 +46,12 @@ defmodule HuddlzWeb.AuthLive.ResetPasswordConfirm do
end
end

def mount(_params, _session, socket) do
def mount(_params, session, socket) do
{:ok,
socket
|> assign(:page_title, "Set new password")
|> assign(:body_class, "is-auth")
|> assign(:return_to, AuthReturnSession.destination(session))
|> assign(:token_valid, false)
|> assign(:trigger_action, false)}
end
Expand Down Expand Up @@ -77,7 +81,7 @@ defmodule HuddlzWeb.AuthLive.ResetPasswordConfirm do
/>

<%= if @form[:reset_token].errors != [] do %>
<.expired_token_state />
<.expired_token_state return_to={@return_to} />
<% else %>
<div class="form-grid">
<.input
Expand All @@ -102,7 +106,7 @@ defmodule HuddlzWeb.AuthLive.ResetPasswordConfirm do
<% end %>
</.form>
<% else %>
<.expired_token_state />
<.expired_token_state return_to={@return_to} />
<% end %>
</Layouts.auth_shell>
"""
Expand All @@ -116,7 +120,7 @@ defmodule HuddlzWeb.AuthLive.ResetPasswordConfirm do
</div>
<h2>This password reset link is invalid or has expired</h2>
<p>The link may have expired or already been used. Request a fresh one.</p>
<.link navigate={~p"/reset"} class="btn-primary">Request new reset link</.link>
<.link navigate={AuthReturnTo.path(~p"/reset", @return_to)} class="btn-primary">Request new reset link</.link>
</div>
"""
end
Expand Down
17 changes: 4 additions & 13 deletions lib/huddlz_web/live/auth_live/sign_in.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ defmodule HuddlzWeb.AuthLive.SignIn do
phx-submit="sign_in_with_password"
phx-change="validate_password"
phx-trigger-action={@trigger_action}
action={sign_in_path(@return_to)}
action={AuthReturnTo.path("/auth/user/password/sign_in", @return_to)}
method="post"
novalidate
class="auth-card"
Expand Down Expand Up @@ -53,10 +53,11 @@ defmodule HuddlzWeb.AuthLive.SignIn do
<Components.sign_in_token_form token={@sign_in_token} return_to={@return_to} />

<div class="auth-aside">
<.link navigate={~p"/reset"}>Forgot your password?</.link>
<.link navigate={AuthReturnTo.path(~p"/reset", @return_to)}>Forgot your password?</.link>
</div>
<div class="auth-aside">
Don't have an account? <.link navigate={register_path(@return_to)}>Sign up</.link>
Don't have an account?
<.link navigate={AuthReturnTo.path(~p"/register", @return_to)}>Sign up</.link>
</div>
</Layouts.auth_shell>
"""
Expand Down Expand Up @@ -163,14 +164,4 @@ defmodule HuddlzWeb.AuthLive.SignIn do

assign(socket, :password_form, to_form(form))
end

defp sign_in_path(nil), do: "/auth/user/password/sign_in"

defp sign_in_path(return_to) do
"/auth/user/password/sign_in?" <> URI.encode_query(return_to: return_to)
end

defp register_path(nil), do: ~p"/register"

defp register_path(return_to), do: ~p"/register?#{[return_to: return_to]}"
end
1 change: 1 addition & 0 deletions lib/huddlz_web/live/email_change_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defmodule HuddlzWeb.EmailChangeLive do
<Layouts.app
flash={@flash}
current_user={@current_user}
return_to={@auth_return_to}
unread_notification_count={@unread_notification_count}
sidebar_owned_groups={@sidebar_owned_groups}
>
Expand Down
Loading
Loading
0