Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
254 lines
7.8 KiB
Elixir
254 lines
7.8 KiB
Elixir
defmodule AprsmeWeb.UserAuth do
|
|
@moduledoc false
|
|
use AprsmeWeb, :verified_routes
|
|
|
|
import Phoenix.Controller
|
|
import Plug.Conn
|
|
|
|
alias Aprsme.Accounts
|
|
|
|
# Make the remember me cookie valid for 60 days.
|
|
# If you want bump or reduce this value, also change
|
|
# the token expiry itself in UserToken.
|
|
alias Phoenix.LiveView.Socket
|
|
|
|
@max_age 60 * 60 * 24 * 60
|
|
@remember_me_cookie "_aprs_web_user_remember_me"
|
|
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
|
|
|
@doc """
|
|
Logs the user in.
|
|
|
|
It renews the session ID and clears the whole session
|
|
to avoid fixation attacks. See the renew_session
|
|
function to customize this behaviour.
|
|
|
|
It also sets a `:live_socket_id` key in the session,
|
|
so LiveView sessions are identified and automatically
|
|
disconnected on log out. The line can be safely removed
|
|
if you are not using LiveView.
|
|
"""
|
|
@spec log_in_user(Plug.Conn.t(), Aprsme.Accounts.User.t(), map()) :: Plug.Conn.t()
|
|
def log_in_user(conn, user, params \\ %{}) do
|
|
token = Accounts.generate_user_session_token(user)
|
|
user_return_to = get_session(conn, :user_return_to)
|
|
|
|
conn
|
|
|> renew_session()
|
|
|> put_token_in_session(token)
|
|
|> maybe_write_remember_me_cookie(token, params)
|
|
|> redirect(to: user_return_to || signed_in_path(conn))
|
|
end
|
|
|
|
@spec maybe_write_remember_me_cookie(Plug.Conn.t(), String.t(), map()) :: Plug.Conn.t()
|
|
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, _params), do: conn
|
|
|
|
# This function renews the session ID and erases the whole
|
|
# session to avoid fixation attacks. If there is any data
|
|
# in the session you may want to preserve after log in/log out,
|
|
# you must explicitly fetch the session data before clearing
|
|
# and then immediately set it after clearing, for example:
|
|
#
|
|
# defp renew_session(conn) do
|
|
# preferred_locale = get_session(conn, :preferred_locale)
|
|
#
|
|
# conn
|
|
# |> configure_session(renew: true)
|
|
# |> clear_session()
|
|
# |> put_session(:preferred_locale, preferred_locale)
|
|
# end
|
|
#
|
|
@spec renew_session(Plug.Conn.t()) :: Plug.Conn.t()
|
|
defp renew_session(conn) do
|
|
conn
|
|
|> configure_session(renew: true)
|
|
|> clear_session()
|
|
end
|
|
|
|
@doc """
|
|
Logs the user out.
|
|
|
|
It clears all session data for safety. See renew_session.
|
|
"""
|
|
@spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t()
|
|
def log_out_user(conn) do
|
|
user_token = get_session(conn, :user_token)
|
|
user_token && Accounts.delete_user_session_token(user_token)
|
|
|
|
_ =
|
|
if live_socket_id = get_session(conn, :live_socket_id) do
|
|
AprsmeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
|
end
|
|
|
|
conn
|
|
|> renew_session()
|
|
|> delete_resp_cookie(@remember_me_cookie)
|
|
|> redirect(to: "/")
|
|
end
|
|
|
|
@doc """
|
|
Authenticates the user by looking into the session
|
|
and remember me token.
|
|
"""
|
|
@spec fetch_current_user(Plug.Conn.t(), any()) :: Plug.Conn.t()
|
|
def fetch_current_user(conn, _opts) do
|
|
{user_token, conn} = ensure_user_token(conn)
|
|
user = user_token && Accounts.get_user_by_session_token(user_token)
|
|
assign(conn, :current_user, user)
|
|
end
|
|
|
|
@spec ensure_user_token(Plug.Conn.t()) :: {String.t() | nil, Plug.Conn.t()}
|
|
defp ensure_user_token(conn) do
|
|
case get_session(conn, :user_token) do
|
|
nil -> ensure_user_token_from_cookie(conn)
|
|
token -> {token, conn}
|
|
end
|
|
end
|
|
|
|
@spec ensure_user_token_from_cookie(Plug.Conn.t()) :: {String.t() | nil, Plug.Conn.t()}
|
|
defp ensure_user_token_from_cookie(conn) do
|
|
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
|
|
|
case conn.cookies[@remember_me_cookie] do
|
|
nil -> {nil, conn}
|
|
token -> {token, put_token_in_session(conn, token)}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Handles mounting and authenticating the current_user in LiveViews.
|
|
|
|
## `on_mount` arguments
|
|
|
|
* `:mount_current_user` - Assigns current_user
|
|
to socket assigns based on user_token, or nil if
|
|
there's no user_token or no matching user.
|
|
|
|
* `:ensure_authenticated` - Authenticates the user from the session,
|
|
and assigns the current_user to socket assigns based
|
|
on user_token.
|
|
Redirects to login page if there's no logged user.
|
|
|
|
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
|
|
Redirects to signed_in_path if there's a logged user.
|
|
|
|
## Examples
|
|
|
|
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
|
the current_user:
|
|
|
|
defmodule AprsmeWeb.PageLive do
|
|
use AprsmeWeb, :live_view
|
|
|
|
on_mount {AprsmeWeb.UserAuth, :mount_current_user}
|
|
...
|
|
end
|
|
|
|
Or use the `live_session` of your router to invoke the on_mount callback:
|
|
|
|
live_session :authenticated, on_mount: [{AprsmeWeb.UserAuth, :ensure_authenticated}] do
|
|
live "/profile", ProfileLive, :index
|
|
end
|
|
"""
|
|
@spec on_mount(atom(), map(), map(), Socket.t()) :: {:cont | :halt, Socket.t()}
|
|
def on_mount(:mount_current_user, _params, session, socket) do
|
|
{:cont, mount_current_user(session, socket)}
|
|
end
|
|
|
|
def on_mount(:ensure_authenticated, _params, session, socket) do
|
|
socket = mount_current_user(session, socket)
|
|
|
|
case socket.assigns.current_user do
|
|
nil ->
|
|
socket =
|
|
socket
|
|
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
|
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
|
|
|
|
{:halt, socket}
|
|
|
|
_user ->
|
|
{:cont, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
|
socket = mount_current_user(session, socket)
|
|
|
|
case socket.assigns.current_user do
|
|
nil -> {:cont, socket}
|
|
_user -> {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
|
|
end
|
|
end
|
|
|
|
@spec mount_current_user(map(), Socket.t()) :: Socket.t()
|
|
defp mount_current_user(session, socket) do
|
|
case session do
|
|
%{"user_token" => user_token} ->
|
|
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
|
Accounts.get_user_by_session_token(user_token)
|
|
end)
|
|
|
|
%{} ->
|
|
Phoenix.Component.assign_new(socket, :current_user, fn -> nil end)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Used for routes that require the user to not be authenticated.
|
|
"""
|
|
@spec redirect_if_user_is_authenticated(Plug.Conn.t(), any()) :: Plug.Conn.t()
|
|
def redirect_if_user_is_authenticated(conn, _opts) do
|
|
case conn.assigns[:current_user] do
|
|
nil ->
|
|
conn
|
|
|
|
_user ->
|
|
conn
|
|
|> redirect(to: signed_in_path(conn))
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Used for routes that require the user to be authenticated.
|
|
|
|
If you want to enforce the user email is confirmed before
|
|
they use the application at all, here would be a good place.
|
|
"""
|
|
@spec require_authenticated_user(Plug.Conn.t(), any()) :: Plug.Conn.t()
|
|
def require_authenticated_user(conn, _opts) do
|
|
case conn.assigns[:current_user] do
|
|
nil ->
|
|
conn
|
|
|> put_flash(:error, "You must log in to access this page.")
|
|
|> maybe_store_return_to()
|
|
|> redirect(to: ~p"/users/log_in")
|
|
|> halt()
|
|
|
|
_user ->
|
|
conn
|
|
end
|
|
end
|
|
|
|
@spec put_token_in_session(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
|
|
defp put_token_in_session(conn, token) do
|
|
conn
|
|
|> put_session(:user_token, token)
|
|
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
|
end
|
|
|
|
@spec maybe_store_return_to(Plug.Conn.t()) :: Plug.Conn.t()
|
|
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
|
put_session(conn, :user_return_to, current_path(conn))
|
|
end
|
|
|
|
defp maybe_store_return_to(conn), do: conn
|
|
|
|
@spec signed_in_path(Plug.Conn.t() | Socket.t()) :: String.t()
|
|
defp signed_in_path(_conn), do: ~p"/"
|
|
end
|