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