daily-tracker

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

user_auth.ex (9247B)


      1 defmodule DailyTrackerWeb.UserAuth do
      2   use DailyTrackerWeb, :verified_routes
      3 
      4   import Plug.Conn
      5   import Phoenix.Controller
      6 
      7   alias DailyTracker.Accounts
      8   alias DailyTracker.Accounts.Scope
      9 
     10   # Make the remember me cookie valid for 14 days. This should match
     11   # the session validity setting in UserToken.
     12   @max_cookie_age_in_days 14
     13   @remember_me_cookie "_daily_tracker_web_user_remember_me"
     14   @remember_me_options [
     15     sign: true,
     16     max_age: @max_cookie_age_in_days * 24 * 60 * 60,
     17     same_site: "Lax"
     18   ]
     19 
     20   # How old the session token should be before a new one is issued. When a request is made
     21   # with a session token older than this value, then a new session token will be created
     22   # and the session and remember-me cookies (if set) will be updated with the new token.
     23   # Lowering this value will result in more tokens being created by active users. Increasing
     24   # it will result in less time before a session token expires for a user to get issued a new
     25   # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
     26   # the reissuing of tokens completely.
     27   @session_reissue_age_in_days 7
     28 
     29   @doc """
     30   Logs the user in.
     31 
     32   Redirects to the session's `:user_return_to` path
     33   or falls back to the `signed_in_path/1`.
     34   """
     35   def log_in_user(conn, user, params \\ %{}) do
     36     user_return_to = get_session(conn, :user_return_to)
     37 
     38     conn
     39     |> create_or_extend_session(user, params)
     40     |> redirect(to: user_return_to || signed_in_path(conn))
     41   end
     42 
     43   @doc """
     44   Logs the user out.
     45 
     46   It clears all session data for safety. See renew_session.
     47   """
     48   def log_out_user(conn) do
     49     user_token = get_session(conn, :user_token)
     50     user_token && Accounts.delete_user_session_token(user_token)
     51 
     52     if live_socket_id = get_session(conn, :live_socket_id) do
     53       DailyTrackerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
     54     end
     55 
     56     conn
     57     |> renew_session(nil)
     58     |> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
     59     |> redirect(to: ~p"/")
     60   end
     61 
     62   @doc """
     63   Authenticates the user by looking into the session and remember me token.
     64 
     65   Will reissue the session token if it is older than the configured age.
     66   """
     67   def fetch_current_scope_for_user(conn, _opts) do
     68     with {token, conn} <- ensure_user_token(conn),
     69          {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
     70       conn
     71       |> assign(:current_scope, Scope.for_user(user))
     72       |> maybe_reissue_user_session_token(user, token_inserted_at)
     73     else
     74       nil -> assign(conn, :current_scope, Scope.for_user(nil))
     75     end
     76   end
     77 
     78   defp ensure_user_token(conn) do
     79     if token = get_session(conn, :user_token) do
     80       {token, conn}
     81     else
     82       conn = fetch_cookies(conn, signed: [@remember_me_cookie])
     83 
     84       if token = conn.cookies[@remember_me_cookie] do
     85         {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
     86       else
     87         nil
     88       end
     89     end
     90   end
     91 
     92   # Reissue the session token if it is older than the configured reissue age.
     93   defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
     94     token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
     95 
     96     if token_age >= @session_reissue_age_in_days do
     97       create_or_extend_session(conn, user, %{})
     98     else
     99       conn
    100     end
    101   end
    102 
    103   # This function is the one responsible for creating session tokens
    104   # and storing them safely in the session and cookies. It may be called
    105   # either when logging in, during sudo mode, or to renew a session which
    106   # will soon expire.
    107   #
    108   # When the session is created, rather than extended, the renew_session
    109   # function will clear the session to avoid fixation attacks. See the
    110   # renew_session function to customize this behaviour.
    111   defp create_or_extend_session(conn, user, params) do
    112     token = Accounts.generate_user_session_token(user)
    113     remember_me = get_session(conn, :user_remember_me)
    114 
    115     conn
    116     |> renew_session(user)
    117     |> put_token_in_session(token)
    118     |> maybe_write_remember_me_cookie(token, params, remember_me)
    119   end
    120 
    121   # Do not renew session if the user is already logged in
    122   # to prevent CSRF errors or data being lost in tabs that are still open
    123   defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
    124     conn
    125   end
    126 
    127   # This function renews the session ID and erases the whole
    128   # session to avoid fixation attacks. If there is any data
    129   # in the session you may want to preserve after log in/log out,
    130   # you must explicitly fetch the session data before clearing
    131   # and then immediately set it after clearing, for example:
    132   #
    133   #     defp renew_session(conn, _user) do
    134   #       delete_csrf_token()
    135   #       preferred_locale = get_session(conn, :preferred_locale)
    136   #
    137   #       conn
    138   #       |> configure_session(renew: true)
    139   #       |> clear_session()
    140   #       |> put_session(:preferred_locale, preferred_locale)
    141   #     end
    142   #
    143   defp renew_session(conn, _user) do
    144     delete_csrf_token()
    145 
    146     conn
    147     |> configure_session(renew: true)
    148     |> clear_session()
    149   end
    150 
    151   defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
    152     do: write_remember_me_cookie(conn, token)
    153 
    154   defp maybe_write_remember_me_cookie(conn, token, _params, true),
    155     do: write_remember_me_cookie(conn, token)
    156 
    157   defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
    158 
    159   defp write_remember_me_cookie(conn, token) do
    160     conn
    161     |> put_session(:user_remember_me, true)
    162     |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
    163   end
    164 
    165   defp put_token_in_session(conn, token) do
    166     conn
    167     |> put_session(:user_token, token)
    168     |> put_session(:live_socket_id, user_session_topic(token))
    169   end
    170 
    171   @doc """
    172   Disconnects existing sockets for the given tokens.
    173   """
    174   def disconnect_sessions(tokens) do
    175     Enum.each(tokens, fn %{token: token} ->
    176       DailyTrackerWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{})
    177     end)
    178   end
    179 
    180   defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
    181 
    182   @doc """
    183   Handles mounting and authenticating the current_scope in LiveViews.
    184 
    185   ## `on_mount` arguments
    186 
    187     * `:mount_current_scope` - Assigns current_scope
    188       to socket assigns based on user_token, or nil if
    189       there's no user_token or no matching user.
    190 
    191     * `:require_authenticated` - Authenticates the user from the session,
    192       and assigns the current_scope to socket assigns based
    193       on user_token.
    194       Redirects to login page if there's no logged user.
    195 
    196   ## Examples
    197 
    198   Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
    199   the `current_scope`:
    200 
    201       defmodule DailyTrackerWeb.PageLive do
    202         use DailyTrackerWeb, :live_view
    203 
    204         on_mount {DailyTrackerWeb.UserAuth, :mount_current_scope}
    205         ...
    206       end
    207 
    208   Or use the `live_session` of your router to invoke the on_mount callback:
    209 
    210       live_session :authenticated, on_mount: [{DailyTrackerWeb.UserAuth, :require_authenticated}] do
    211         live "/profile", ProfileLive, :index
    212       end
    213   """
    214   def on_mount(:mount_current_scope, _params, session, socket) do
    215     {:cont, mount_current_scope(socket, session)}
    216   end
    217 
    218   def on_mount(:require_authenticated, _params, session, socket) do
    219     socket = mount_current_scope(socket, session)
    220 
    221     if socket.assigns.current_scope && socket.assigns.current_scope.user do
    222       {:cont, socket}
    223     else
    224       socket =
    225         socket
    226         |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
    227         |> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
    228 
    229       {:halt, socket}
    230     end
    231   end
    232 
    233   def on_mount(:require_sudo_mode, _params, session, socket) do
    234     socket = mount_current_scope(socket, session)
    235 
    236     if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do
    237       {:cont, socket}
    238     else
    239       socket =
    240         socket
    241         |> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.")
    242         |> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
    243 
    244       {:halt, socket}
    245     end
    246   end
    247 
    248   defp mount_current_scope(socket, session) do
    249     Phoenix.Component.assign_new(socket, :current_scope, fn ->
    250       {user, _} =
    251         if user_token = session["user_token"] do
    252           Accounts.get_user_by_session_token(user_token)
    253         end || {nil, nil}
    254 
    255       Scope.for_user(user)
    256     end)
    257   end
    258 
    259   @doc "Returns the path to redirect to after log in."
    260   # the user was already logged in, redirect to settings
    261   def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do
    262     ~p"/users/settings"
    263   end
    264 
    265   def signed_in_path(_), do: ~p"/"
    266 
    267   @doc """
    268   Plug for routes that require the user to be authenticated.
    269   """
    270   def require_authenticated_user(conn, _opts) do
    271     if conn.assigns.current_scope && conn.assigns.current_scope.user do
    272       conn
    273     else
    274       conn
    275       |> put_flash(:error, "You must log in to access this page.")
    276       |> maybe_store_return_to()
    277       |> redirect(to: ~p"/users/log-in")
    278       |> halt()
    279     end
    280   end
    281 
    282   defp maybe_store_return_to(%{method: "GET"} = conn) do
    283     put_session(conn, :user_return_to, current_path(conn))
    284   end
    285 
    286   defp maybe_store_return_to(conn), do: conn
    287 end