From b077a1c81c604c292e3ca2473111c1e0dd9e6b7a Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Wed, 22 Apr 2026 10:32:42 +0200 Subject: [PATCH] Users can access their items via API. Authentication via API token. No public access to items. --- lib/generic_rest_server/accounts.ex | 26 +++++++++++++++ .../accounts/user_token.ex | 32 +++++++++++++++++++ .../controllers/user_token_controller.ex | 28 ++++++++++++++++ .../controllers/user_token_json.ex | 17 ++++++++++ lib/generic_rest_server_web/router.ex | 14 +++++++- lib/generic_rest_server_web/user_auth.ex | 16 ++++++++++ test/generic_rest_server/accounts_test.exs | 11 +++++++ 7 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 lib/generic_rest_server_web/controllers/user_token_controller.ex create mode 100644 lib/generic_rest_server_web/controllers/user_token_json.ex diff --git a/lib/generic_rest_server/accounts.ex b/lib/generic_rest_server/accounts.ex index 3ee3c60..40ced23 100644 --- a/lib/generic_rest_server/accounts.ex +++ b/lib/generic_rest_server/accounts.ex @@ -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 diff --git a/lib/generic_rest_server/accounts/user_token.ex b/lib/generic_rest_server/accounts/user_token.ex index e4b6661..9920abb 100644 --- a/lib/generic_rest_server/accounts/user_token.ex +++ b/lib/generic_rest_server/accounts/user_token.ex @@ -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 diff --git a/lib/generic_rest_server_web/controllers/user_token_controller.ex b/lib/generic_rest_server_web/controllers/user_token_controller.ex new file mode 100644 index 0000000..589a788 --- /dev/null +++ b/lib/generic_rest_server_web/controllers/user_token_controller.ex @@ -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 diff --git a/lib/generic_rest_server_web/controllers/user_token_json.ex b/lib/generic_rest_server_web/controllers/user_token_json.ex new file mode 100644 index 0000000..8739182 --- /dev/null +++ b/lib/generic_rest_server_web/controllers/user_token_json.ex @@ -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 diff --git a/lib/generic_rest_server_web/router.ex b/lib/generic_rest_server_web/router.ex index 0e42077..06e03e3 100644 --- a/lib/generic_rest_server_web/router.ex +++ b/lib/generic_rest_server_web/router.ex @@ -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 diff --git a/lib/generic_rest_server_web/user_auth.ex b/lib/generic_rest_server_web/user_auth.ex index 316c0ec..d08ff23 100644 --- a/lib/generic_rest_server_web/user_auth.ex +++ b/lib/generic_rest_server_web/user_auth.ex @@ -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 [<>] <- + 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 diff --git a/test/generic_rest_server/accounts_test.exs b/test/generic_rest_server/accounts_test.exs index 5f4ebae..288da8d 100644 --- a/test/generic_rest_server/accounts_test.exs +++ b/test/generic_rest_server/accounts_test.exs @@ -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