Elixir / Phoenix: How to implement session timeout / expiration
This is our production solution (view in a Gist):
sliding_session_timeout.ex
defmodule Auth.SlidingSessionTimeout do
import Plug.Conn
def init(opts \\ []) do
Keyword.merge([timeout_after_seconds: 3600], opts)
end
def call(conn, opts) do
timeout_at = get_session(conn, :session_timeout_at)
if timeout_at && now() > timeout_at do
logout_user(conn)
else
put_session(conn, :session_timeout_at, new_session_timeout_at(opts[:timeout_after_seconds]))
end
end
defp logout_user(conn) do
conn
|> clear_session()
|> configure_session([:renew])
|> assign(:session_timeout, true)
end
defp now do
DateTime.utc_now() |> DateTime.to_unix
end
defp new_session_timeout_at(timeout_after_seconds) do
now() + timeout_after_seconds
end
end
How to use it
Plug it at the end of your :browser
pipeline in your Phoenix app’s router.ex
.
Please note that authentication (getting user_id
from the session, loading the user from the DB) and authorization are concerns for other plugs, further in the pipeline. So make sure it is plugged before your session-based authentication and authorization Plugs.
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :put_secure_browser_headers
plug Auth.SlidingSessionTimeout, timeout_after_seconds: 3600 # <=
end
I first looked for cookie expiration options in the Plug library, then realized that an easier (and more secure) approach is to simply set an expiration datetime in the session along with the user_id. The session is tamper-proof, so when I receive each request, I can compare the datetime to now; if the session hasn't yet expired, I set current_user
as normal. Otherwise I call logout!
to delete the expired session.
An implementation would look something like this (requires the Timex library):
# Assign current_user to the conn, if a user is logged in
def load_current_user(conn, _opts) do
cond do
no_login_session?(conn) ->
conn # No user_id was found; make no changes
current_user_already_set?(conn) ->
conn
session_expired?(conn) ->
logout!(conn)
user = load_user_from_session(conn) ->
conn
|> put_session(:expires_at, new_expiration_datetime_string)
|> assign(:current_user, user)
end
end
defp session_expired?(conn) do
expires_at = get_session(conn, :expires_at) |> Timex.parse!("{ISO:Extended}")
Timex.after?(Timex.now, expires_at)
end
# ... more ...
# Start a logged-in session for an (already authenticated) user
def login!(conn, user) do
conn
|> assign(:current_user, user)
|> put_session(:user_id, user.id)
|> put_session(:expires_at, new_expiration_datetime_string)
|> configure_session(renew: true)
end
defp new_expiration_datetime_string do
Timex.now |> Timex.shift(hours: +2) |> Timex.format("{ISO:Extended}")
end
# ... more ...
The Plug.Sessions module has a built-in option to set the expiration of a cookie using the max_age
key. For example, extending your endpoint.ex
snippet would look like:
plug Plug.Session,
store: :cookie,
key: "_zb_key",
signing_salt: "RANDOM HEX",
max_age: 24*60*60*37 # 37 days
Credit: https://teamgaslight.com/blog/til-how-to-explicitly-set-session-expiration-in-phoenix
Documentation: https://hexdocs.pm/plug/Plug.Session.html#module-options