Users can access their items via API. Authentication via API token. No public access to items.

This commit is contained in:
2026-04-22 10:32:42 +02:00
parent 6076654aa4
commit b077a1c81c
7 changed files with 143 additions and 1 deletions

View File

@ -281,6 +281,32 @@ defmodule GenericRestServer.Accounts do
:ok
end
## API
@doc """
Creates a new api token for a user.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_user_api_token(user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
Repo.insert!(user_token)
encoded_token
end
@doc """
Fetches the user by API token.
"""
def fetch_user_by_api_token(token) do
with {:ok, query} <- UserToken.verify_api_token_query(token),
%User{} = user <- Repo.one(query) do
{:ok, user}
else
_ -> :error
end
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do

View File

@ -11,6 +11,7 @@ defmodule GenericRestServer.Accounts.UserToken do
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
@api_token_validity_in_days 30
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -155,4 +156,35 @@ defmodule GenericRestServer.Accounts.UserToken do
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
## API
@doc """
Checks if the API token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within 365 days.
"""
def verify_api_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "api-token"),
join: user in assoc(token, :user),
where:
token.inserted_at > ago(^@api_token_validity_in_days, "day") and
token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
end

View File

@ -0,0 +1,28 @@
defmodule GenericRestServerWeb.UserTokenController do
use GenericRestServerWeb, :controller
alias GenericRestServer.Accounts
alias GenericRestServer.Accounts.User
action_fallback GenericRestServerWeb.FallbackController
def log_in(conn, %{"user" => user_params}) do
case Accounts.get_user_by_email_and_password(user_params["email"], user_params["password"]) do
%User{} = user ->
create_token(conn, user)
_ ->
conn
|> put_status(:forbidden)
|> render(:error, %{error: "No access for you!"})
end
end
defp create_token(conn, user) do
encoded_token = Accounts.create_user_api_token(user)
updated_user = Map.put(user, :token, encoded_token)
render(conn, :token, user: updated_user)
end
end

View File

@ -0,0 +1,17 @@
defmodule GenericRestServerWeb.UserTokenJSON do
def token(%{user: user}) do
%{
data: %{
id: user.id,
email: user.email,
token: user.token
}
}
end
def error(%{error: error}) do
%{
error: error
}
end
end

View File

@ -17,16 +17,28 @@ defmodule GenericRestServerWeb.Router do
plug :accepts, ["json"]
end
pipeline :protected_api do
plug :accepts, ["json"]
plug :fetch_current_scope_for_api_user
end
scope "/", GenericRestServerWeb do
pipe_through :browser
get "/", PageController, :home
end
# Other scopes may use custom stacks.
# public API
scope "/api", GenericRestServerWeb do
pipe_through :api
post "/log_in", UserTokenController, :log_in
end
# protected API
scope "/api", GenericRestServerWeb do
pipe_through :protected_api
resources "/items", ItemController, except: [:new, :edit]
end

View File

@ -284,4 +284,20 @@ defmodule GenericRestServerWeb.UserAuth do
end
defp maybe_store_return_to(conn), do: conn
## API
def fetch_current_scope_for_api_user(conn, _opts) do
with [<<bearer::binary-size(6), " ", token::binary>>] <-
get_req_header(conn, "authorization"),
true <- String.downcase(bearer) == "bearer",
{:ok, user} <- Accounts.fetch_user_by_api_token(token) do
assign(conn, :current_scope, Scope.for_user(user))
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you")
|> halt()
end
end
end

View File

@ -394,4 +394,15 @@ defmodule GenericRestServer.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
## API
describe "create_user_api_token/1 and fetch_user_by_api_token/1" do
test "creates and fetches by token" do
user = user_fixture()
token = Accounts.create_user_api_token(user)
assert Accounts.fetch_user_by_api_token(token) == {:ok, user}
assert Accounts.fetch_user_by_api_token("invalid") == :error
end
end
end