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 %>