Current section

Files

Jump to
caravela priv templates auth_context.eex
Raw

priv/templates/auth_context.eex

defmodule <%= inspect @module %> do
@moduledoc """
Authentication context generated by Caravela from <%= inspect @domain_module %>.
Regenerate with `mix caravela.gen.auth <%= inspect @domain_module %>`.
Custom code placed below the `# --- CUSTOM ---` marker is preserved.
## Dependencies
`:argon2_elixir` must be in your deps for password hashing:
{:argon2_elixir, "~> 4.0"}
"""
import Ecto.Query, only: [from: 2]
alias <%= inspect @repo_module %>, as: Repo
alias <%= inspect @user_module %>
alias <%= inspect @session_module %>
@session_default_ttl_days <%= @session_ttl_days %>
@session_remember_me_ttl_days <%= @remember_me_ttl_days %>
@max_sessions <%= @max_sessions %>
<%= if @confirm? do %> @confirm_token_ttl_hours <%= @confirm_ttl_hours %>
<% end %><%= if @reset? do %> @reset_token_ttl_hours <%= @reset_ttl_hours %>
<% end %><%= if @api_token? do %> @api_token_ttl_days <%= @api_token_ttl_days %>
@max_api_tokens <%= @max_api_tokens %>
@api_token_scopes <%= inspect @api_token_scopes %>
<% end %>
## Registration
def register(attrs, context \\ %{}) do
%<%= @user_short %>{}
|> <%= @user_short %>.registration_changeset(attrs)
|> apply_on_register(context)
|> inject_tenant_id(context)
|> Repo.insert()
end
# --- CUSTOM :register ---
# --- END :register ---
## Login
def login(email, password, context \\ %{}) do
with {:ok, user} <- fetch_by_email(email, context),
:ok <- verify_password(user, password),
:ok <- apply_on_login(user, context),
:ok <- check_confirmed(user) do
{:ok, user, create_session(user, context)}
end
end
# --- CUSTOM :login ---
# --- END :login ---
## Sessions
def create_session(user, _context, opts \\ []) do
token = :crypto.strong_rand_bytes(32)
days =
if Keyword.get(opts, :remember_me, false),
do: @session_remember_me_ttl_days,
else: @session_default_ttl_days
expires_at =
DateTime.utc_now()
|> DateTime.add(days * 86_400, :second)
|> DateTime.truncate(:second)
{:ok, _} =
%<%= @session_short %>{}
|> <%= @session_short %>.changeset(%{
user_id: user.id,
token: token,
context: "session",
expires_at: expires_at
})
|> Repo.insert()
enforce_session_limit(user, @max_sessions)
token
end
def get_user_by_session(nil), do: nil
def get_user_by_session(token) do
now = DateTime.utc_now()
query =
from s in <%= @session_short %>,
where: s.token == ^token and s.expires_at > ^now,
join: u in <%= @user_short %>,
on: u.id == s.user_id,
select: u
Repo.one(query)
end
def logout(token) do
Repo.delete_all(from s in <%= @session_short %>, where: s.token == ^token)
:ok
end
# --- CUSTOM :logout ---
# --- END :logout ---
def logout_all(user) do
Repo.delete_all(from s in <%= @session_short %>, where: s.user_id == ^user.id)
:ok
end
@doc """
Return active sessions for a user. The `current_token` is flagged so
the UI can distinguish "this session" from the others.
"""
def list_sessions(user, current_token \\ nil) do
now = DateTime.utc_now()
from(s in <%= @session_short %>,
where: s.user_id == ^user.id and s.expires_at > ^now and s.context == "session",
order_by: [desc: s.inserted_at]
)
|> Repo.all()
|> Enum.map(fn session ->
Map.put(session, :is_current, not is_nil(current_token) and session.token == current_token)
end)
end
@doc "Revoke one of `user`'s sessions by its database id."
def revoke_session(user, session_id) do
case Repo.get_by(<%= @session_short %>, id: session_id, user_id: user.id) do
nil -> {:error, :not_found}
session -> {:ok, Repo.delete!(session)}
end
end
defp enforce_session_limit(user, max) when is_integer(max) and max > 0 do
ids =
from(s in <%= @session_short %>,
where: s.user_id == ^user.id,
order_by: [desc: s.inserted_at],
offset: ^max,
select: s.id
)
|> Repo.all()
if ids != [] do
from(s in <%= @session_short %>, where: s.id in ^ids) |> Repo.delete_all()
end
:ok
end
defp enforce_session_limit(_user, _), do: :ok
<%= if @api_token? do %>
## API Tokens
def create_api_token(user, scope, opts \\ []) when scope in @api_token_scopes do
token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
ttl_days = Keyword.get(opts, :ttl_days, @api_token_ttl_days)
expires_at =
DateTime.utc_now()
|> DateTime.add(ttl_days * 86_400, :second)
|> DateTime.truncate(:second)
entry = %{
"token" => token,
"scope" => Atom.to_string(scope),
"expires_at" => DateTime.to_iso8601(expires_at),
"inserted_at" => DateTime.to_iso8601(DateTime.utc_now())
}
tokens =
user.api_tokens
|> Map.put(token, entry)
|> prune_api_tokens()
{:ok, user} =
user
|> <%= @user_short %>.api_tokens_changeset(%{api_tokens: tokens})
|> Repo.update()
{:ok, token, user}
end
def create_api_token(_user, scope, _opts) do
{:error, {:invalid_scope, scope}}
end
def verify_api_token(token) do
now = DateTime.utc_now()
from(u in <%= @user_short %>, where: fragment("? \\? ?", u.api_tokens, ^token))
|> Repo.one()
|> case do
nil ->
{:error, :invalid_token}
user ->
case Map.get(user.api_tokens || %{}, token) do
nil ->
{:error, :invalid_token}
%{"expires_at" => expires_at, "scope" => scope} ->
case DateTime.from_iso8601(expires_at) do
{:ok, dt, _} ->
if DateTime.compare(dt, now) == :gt do
{:ok, %{user: user, scope: String.to_existing_atom(scope)}}
else
{:error, :expired_token}
end
_ ->
{:error, :invalid_token}
end
end
end
end
defp prune_api_tokens(tokens) when map_size(tokens) <= @max_api_tokens, do: tokens
defp prune_api_tokens(tokens) do
tokens
|> Enum.sort_by(fn {_, %{"inserted_at" => at}} -> at end, :desc)
|> Enum.take(@max_api_tokens)
|> Map.new()
end
@doc """
Return a client-safe view of `user`'s API tokens — the raw token value
is never returned (only its id = token hash prefix), so this can be
sent to the browser without leaking credentials.
"""
def list_api_tokens(user) do
(user.api_tokens || %{})
|> Enum.map(fn {token, entry} ->
%{
id: public_token_id(token),
scope: Map.get(entry, "scope"),
inserted_at: Map.get(entry, "inserted_at"),
expires_at: Map.get(entry, "expires_at")
}
end)
|> Enum.sort_by(& &1.inserted_at, :desc)
end
@doc "Revoke one of `user`'s API tokens by its public id."
def revoke_api_token(user, token_id) do
tokens = user.api_tokens || %{}
case Enum.find(tokens, fn {token, _} -> public_token_id(token) == token_id end) do
nil ->
{:error, :not_found}
{full_token, _} ->
new_tokens = Map.delete(tokens, full_token)
user
|> <%= @user_short %>.api_tokens_changeset(%{api_tokens: new_tokens})
|> Repo.update()
end
end
# Stable, opaque id derived from the token bytes. Does not leak the
# credential but lets the UI reference "which token" to revoke.
defp public_token_id(token) when is_binary(token) do
:crypto.hash(:sha256, token) |> Base.url_encode64(padding: false) |> binary_part(0, 16)
end
<% end %>
<%= if @reset? do %>
## Password reset
def request_password_reset(email, context \\ %{}) do
case fetch_by_email(email, context) do
{:ok, user} ->
token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
expires_at =
DateTime.utc_now()
|> DateTime.add(@reset_token_ttl_hours * 3_600, :second)
|> DateTime.truncate(:second)
{:ok, _} =
%<%= @session_short %>{}
|> <%= @session_short %>.changeset(%{
user_id: user.id,
token: token,
context: "reset",
expires_at: expires_at
})
|> Repo.insert()
{:ok, token}
error ->
error
end
end
def reset_password(token, new_password) do
with {:ok, user} <- consume_contextual_token(token, "reset"),
{:ok, user} <-
user
|> <%= @user_short %>.password_changeset(%{password: new_password})
|> Repo.update() do
logout_all(user)
{:ok, user}
end
end
# --- CUSTOM :reset_password ---
# --- END :reset_password ---
<% end %>
<%= if @confirm? do %>
## Email confirmation
def deliver_confirmation_token(user) do
token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
expires_at =
DateTime.utc_now()
|> DateTime.add(@confirm_token_ttl_hours * 3_600, :second)
|> DateTime.truncate(:second)
{:ok, _} =
%<%= @session_short %>{}
|> <%= @session_short %>.changeset(%{
user_id: user.id,
token: token,
context: "confirm",
expires_at: expires_at
})
|> Repo.insert()
{:ok, token}
end
def confirm_email(token) do
with {:ok, user} <- consume_contextual_token(token, "confirm") do
now = DateTime.utc_now() |> DateTime.truncate(:second)
user
|> <%= @user_short %>.confirm_changeset(%{confirmed_at: now})
|> Repo.update()
end
end
# --- CUSTOM :confirm_email ---
# --- END :confirm_email ---
<% end %>
## Internal helpers
defp fetch_by_email(email, context) do
query = from u in <%= @user_short %>, where: u.email == ^email<%= if @multi_tenant do %> and u.tenant_id == ^context.tenant_id<% end %>
case Repo.one(query) do
nil -> {:error, :invalid_credentials}
user -> {:ok, user}
end
end
defp verify_password(%{hashed_password: hash}, password) when is_binary(hash) do
if Code.ensure_loaded?(Argon2) and function_exported?(Argon2, :verify_pass, 2) do
if apply(Argon2, :verify_pass, [password, hash]) do
:ok
else
{:error, :invalid_credentials}
end
else
{:error, :argon2_not_available}
end
end
defp verify_password(_, _), do: {:error, :invalid_credentials}
<%= if @confirm? do %> defp check_confirmed(%{confirmed_at: nil}), do: {:error, :unconfirmed_email}
defp check_confirmed(_), do: :ok
<% else %> defp check_confirmed(_), do: :ok
<% end %>
defp apply_on_register(changeset, context) do
<%= inspect @domain_short %>.__caravela_auth_hook__(:on_register, changeset, context)
end
defp apply_on_login(user, context) do
<%= inspect @domain_short %>.__caravela_auth_hook__(:on_login, user, context)
end
<%= if @multi_tenant do %> defp inject_tenant_id(changeset, context) do
Ecto.Changeset.put_change(changeset, :tenant_id, context.tenant_id)
end
<% else %> defp inject_tenant_id(changeset, _context), do: changeset
<% end %>
defp consume_contextual_token(token, ctx) do
now = DateTime.utc_now()
query =
from s in <%= @session_short %>,
where: s.token == ^token and s.context == ^ctx and s.expires_at > ^now,
join: u in <%= @user_short %>,
on: u.id == s.user_id,
select: {s, u}
case Repo.one(query) do
nil ->
{:error, :invalid_token}
{session, user} ->
Repo.delete!(session)
{:ok, user}
end
end
<%= @custom_marker %>