Hi everyone,
I always jump around between languages and frameworks to see what is new and to keep my general knowledge up to date if I need to use a different language or framework.
Lately I’ve been spending some time with Rails 7.1 and got a small crush on the authentication work they’ve done.
Since I have a couple of Phoenix side projects that I’ve built my own auth for, the empty columns for confirmation_token and reset_password_token has been a bit of a nuisance and I don’t like the phx.gen.auth setup with a separate table to handle tokens either.
Anyway to the actual point. In Rails they now have generate_token_for which avoids the entire need for additional columns just to handle password reset, updating email or confirming an account.
I’ve written a small PoC in Elixir that essentially solves the same issue and would like to hear some feedback, thoughts or suggestions on this.
First the TokenFor module:
defmodule MyApp.TokenFor do
@moduledoc """
Token management for various purposes.
"""
alias Phoenix.Token
alias MyAppWeb.Endpoint
@type token_definition :: %{
expires_in: integer(),
payload_func: function()
}
@spec generate_token_for(Ecto.Schema.t(), token_definition) :: String.t()
def generate_token_for(model, %{expires_in: expires_in, payload_func: payload_func}) do
payload = payload_func.(model)
Token.sign(Endpoint, Endpoint.config(:secret_key_base), {model.id, payload},
max_age: expires_in
)
end
@spec find_by_token_for(String.t(), token_definition, (integer() -> Ecto.Schema.t() | nil)) :: {:ok, Ecto.Schema.t()} | {:error, atom()}
def find_by_token_for(token, %{payload_func: payload_func}, fetch_model_func) do
case Token.verify(Endpoint, Endpoint.config(:secret_key_base), token) do
{:ok, {id, payload}} ->
model = fetch_model_func.(id)
cond do
model && payload == payload_func.(model) -> {:ok, model}
model -> {:error, :invalid}
true -> {:error, :not_found}
end
{:error, :expired} ->
{:error, :not_found}
{:error, _} ->
{:error, :invalid}
end
end
end
This gives us everything we need to create and find users for tokens generated.
On the User model we just add a function for the definition we need, for example:
def password_reset_definition do
%{
expires_in: 15 * 60, # 15 minutes in seconds
payload_func: fn model -> model.email end
}
end
We can then generate a token with:
token = TokenFor.generate_token_for(user, User.password_reset_definition())
and retrieve the user with:
TokenFor.find_by_token_for(token, User.password_reset_definition(), fn id -> Repo.get(User, id) end)
What do you think?