One of the pet-peeves that I have with the official phx.gen.auth
generator is that it merges all the user tokens into a single table.
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(type: :utc_datetime, updated_at: false)
end
I'm not a big fan of this, because I consider it to be a leaky abstraction.
Why would my session tokens need sent_to
? This is for the email-change flow.
What if I want to add extra metadata, like the user-agent, or the IP address? I shouldn't do that in this table. My password reset token flow doesn't care about that.
I believe it's best to make this change early, before we get to unicorn status, and not have to deal with the migration when it's painful. In this post I'll focus on the session tokens, but the same principle applies to all others.
Step 1: Generate the migration
We keep the same structure, but without context
& sent_to
. Let's also add a unique index on the token.
There are no surprises here.
defmodule MyApp.Repo.Migrations.CreateSessionTokensTable do
use Ecto.Migration
def change do
create table(:session_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:session_tokens, [:user_id])
create unique_index(:session_tokens, [:token])
end
end
Optional Step: Centralize the token generation
Well, here's a second pet-peeve. I don't enjoy having to call :crypto.strong_rand_bytes/1
everywhere in my code.
It's a bit of a code smell. The hash_algorithm
and rand_size
are also things that are not going to change module to module.
So I like to centralize this in a helper module.
defmodule MyApp.Helpers.TokenService do
alias MyApp.Constants
@hash_algorithm Constants.hash_algorithm()
@rand_size Constants.rand_size()
@spec generate_token() :: binary()
def generate_token do
:crypto.strong_rand_bytes(@rand_size)
end
@spec hash(binary()) :: binary()
def hash(token) do
:crypto.hash(@hash_algorithm, token)
end
@spec generate() :: {binary(), binary()}
def generate do
raw_token = generate_token()
{raw_token, hash(raw_token)}
end
@spec encode(binary()) :: binary()
def encode(token) do
Base.url_encode64(token, padding: false)
end
@spec decode(binary()) :: {:ok, binary()} | :error
def decode(encoded_token) do
Base.url_decode64(encoded_token, padding: false)
end
end
Step 2: Create the schema
OK, back to the juicy stuff. Let's create the SessionToken
schema. There's only a single changeset
method that generates the token, and prepares the changeset.
defmodule MyApp.Accounts.SessionToken do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Accounts.User
alias MyApp.Helpers.TokenService
schema "session_tokens" do
field :token, :binary
belongs_to :user, User
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(user) do
token = TokenService.generate_token()
attrs =
%{
token: token,
user_id: user.id
}
changeset =
%__MODULE__{}
|> cast(attrs, [:token, :user_id])
|> validate_required([:token, :user_id])
|> foreign_key_constraint(:user_id)
{token, changeset}
end
end
Step 3: Update the Accounts
context
Finally, let's make small adjustments to the Accounts
context. I'm also going to add a few new ones, revoke_other_sessions/2
and list_sessions/2
.
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, session_token_changeset} = SessionToken.changeset(user)
Repo.insert!(session_token_changeset)
token
end
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
days = Constants.session_validity_in_days()
SessionToken
|> where([st], st.token == ^token)
|> join(:inner, [st], u in assoc(st, :user))
|> where([st], st.inserted_at > ago(^days, "day"))
|> select([st, u], u)
|> Repo.one()
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from st in SessionToken, where: st.token == ^token)
:ok
end
@doc """
Revokes all session tokens for a user except the current one.
## Parameters
- user: User struct
- current_token: Token to keep active
## Returns
- {number_of_revoked_sessions, nil}
"""
def revoke_other_sessions(user, current_token) do
Repo.delete_all(
from st in SessionToken,
where: st.user_id == ^user.id and st.token != ^current_token
)
end
@doc """
Lists all active sessions for a user.
## Parameters
- user: User struct
- current_token: Current active session token
## Returns
- List of session details
"""
def list_sessions(user, current_token) do
SessionToken
|> where(user_id: ^user.id)
|> select([st], %{
id: st.id,
is_current: st.token == ^current_token
})
|> Repo.all()
end
Let's also update the reset_user_password/2
method to delete all the session tokens for the user.
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|> Ecto.Multi.delete_all(
:session_tokens,
from(t in SessionToken, where: t.user_id == ^user.id)
)
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
I find Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)
to be sub-optimal as well, but I'll leave it as is for now.
Step 4: Update the Accounts
context tests
Last but not least, let's update the tests. There's not much to change here (intentionally), we just have to ensure we're referencing the correct schema.
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(SessionToken, token: token)
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%SessionToken{
token: user_token.token,
user_id: user_fixture().id
})
end
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
# other tests omitted
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(SessionToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_session_token(token)
end
end
describe "reset_user_password/2" do
setup do
%{user: user_fixture()}
end
# other tests omitted
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
refute Repo.get_by(UserToken, user_id: user.id)
refute Repo.get_by(SessionToken, user_id: user.id)
end
end
Step 5: Remove old session tokens
One last thing, be sure to remove the leftover session tokens in the users_tokens
table, any way you see fit.
I'm not going to provide an example here, as it's a one-time operation. You can do it manually, or write a migration for it.
Optional Step: Add some metadata
Let's make this change useful. We will expand the session token table, to include some metadata about the device that the user is using. The point here is to allow them inspect their active sessions, and see if there's anything suspicious.
We start by generating a migration to add the fields.
defmodule MyApp.Repo.Migrations.AddDeviceMetadataToSessionTokens do
use Ecto.Migration
def change do
alter table(:session_tokens) do
add :device, :string, default: "Unknown"
add :os, :string, default: "Unknown"
add :browser, :string, default: "Unknown"
add :browser_version, :string, default: ""
end
end
end
Next, we have to update the SessionToken
schema to include the device metadata.
schema "session_tokens" do
field :token, :binary
field :device, :string
field :os, :string
field :browser, :string
field :browser_version, :string
belongs_to :user, User
timestamps(type: :utc_datetime, updated_at: false)
end
Then, update the changeset/1
method (now changeset/2
) to accept these fields.
def changeset(user, device_info) do
token = TokenService.generate_token()
attrs =
%{
token: token,
user_id: user.id
}
|> Map.merge(device_info)
changeset =
%__MODULE__{}
|> cast(attrs, [:token, :user_id, :device, :os, :browser, :browser_version])
|> validate_required([:token, :user_id, :device, :os, :browser, :browser_version])
|> foreign_key_constraint(:user_id)
{token, changeset}
end
Finally, we update generate_user_session_token/1
method (now generate_user_session_token/2
), and the list_sessions/2
.
def generate_user_session_token(user, device_info) do
{token, session_token_changeset} = SessionToken.changeset(user, device_info)
Repo.insert!(session_token_changeset)
token
end
def list_sessions(user, current_token) do
SessionToken
|> where(user_id: ^user.id)
|> select([st], %{
id: st.id,
is_current: st.token == ^current_token,
device: st.device,
os: st.os,
browser: st.browser,
browser_version: st.browser_version
})
|> Repo.all()
After that, we just have to parse our headers in our session controller, and extract the device info. Even if you're using LiveView for everything, you can't get away without having a session controller. So the same step applies.
In order to collect the device info, we can use various User-Agent parsers (like UAParser
). That said, it's not a foolproof method. For example Brave sometimes gets detected as Chrome. You need client-hints to get the real info there.
MacOS has also stopped updating the User-Agent string, so it's not reliable. I believe it's stuck on 10_15_7
, and it's not going to change.
So, I won't go into the details of how to parse the User-Agent string, it depends on how serious you want to get with this.
But essentially, you need something along these lines:
defmodule MyApp.UserSessionController do
# other methods and imports omitted
def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
device_info = extract_user_agent_info(conn)
conn
# Which passes it to `Accounts.generate_user_session_token/2`
|> UserAuth.log_in_user(user, user_params, device_info)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "You have entered an invalid email or password.")
|> redirect(to: ~p"/users/log_in")
end
end
def extract_user_agent_info(conn) do
conn
|> Plug.Conn.get_req_header("user-agent")
|> List.first()
|> parse_user_agent()
rescue
_ -> default_user_agent_info()
end
defp parse_user_agent(nil), do: default_user_agent_info()
defp parse_user_agent(user_agent) do
# parse the user agent
end
end
Conclusion
That's it, we now have a separate table for session tokens, that can be easily extended with relevant metadata.
Now, let's recap why we did it.
The generator, creates what's effectively a "God Table", it kind of knows too much, and handles different concerns.
When you get a session token, you notice a sent_to
. If you are not aware of the generator structure, and you see this, you have every right to be concerned. Session management,
has nothing to do with email delivery.
This is a classic example of a leaky abstraction, where implementation details of one concern are leaking into another.
So we separate them.
I hope you found this post useful. If I have messed something up, feel free to reach out to me on Twitter or BlueSky.