diff --git a/changelog.d/admin-api-passwordless-accounts.add b/changelog.d/admin-api-passwordless-accounts.add new file mode 100644 index 000000000..f5ea2626f --- /dev/null +++ b/changelog.d/admin-api-passwordless-accounts.add @@ -0,0 +1 @@ +Allow creating accounts without a password in the AdminAPI diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index ec58e8c19..5f8a886be 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -80,10 +80,24 @@ The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/ad { `nickname`, `email`, - `password` + `password` (optional; when provided, must not be empty) } ] -- Response: Array of user objects +- Response: Array of user objects. When a user is created without a `password`, `data` + object contains a `password_reset_link` the user can follow to set their own password. + +```json +[ + { + "data": { + "nickname": string, + "email": string, + "password_reset_link": string (optional) + }, + "type": "success" + } +] +``` ## `POST /api/v1/pleroma/admin/users/follow` diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 9ac275396..ab8bf73ee 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -8,13 +8,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 3] + alias Ecto.Multi alias Pleroma.ModerationLog + alias Pleroma.PasswordResetToken alias Pleroma.User alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Search + alias Pleroma.Web.Endpoint alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Router @users_page_size 50 @@ -148,9 +152,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do } = conn, _ ) do - changesets = + conn = + if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do + put_private(conn, :skip_idempotency_cache, true) + else + conn + end + + multi = users - |> Enum.map(fn %{nickname: nickname, email: email, password: password} -> + |> Enum.map(fn %{nickname: nickname, email: email} = attrs -> + {password, passwordless?} = + case Map.fetch(attrs, :password) do + {:ok, password} -> + {password, false} + + :error -> + {:crypto.strong_rand_bytes(16) |> Base.encode64(), true} + end + user_data = %{ nickname: nickname, name: nickname, @@ -160,44 +180,80 @@ defmodule Pleroma.Web.AdminAPI.UserController do bio: "." } - User.register_changeset(%User{}, user_data, need_confirmation: false) + {User.register_changeset(%User{}, user_data, confirmed: true), passwordless?} end) - |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> - Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + |> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi -> + user_operation = {:user, Ecto.UUID.generate()} + multi = Multi.insert(multi, user_operation, changeset) + + if passwordless? do + Multi.run( + multi, + {:password_reset_token, Ecto.UUID.generate()}, + fn _repo, changes -> + changes + |> Map.fetch!(user_operation) + |> PasswordResetToken.create_token() + end + ) + else + multi + end end) - case Pleroma.Repo.transaction(changesets) do - {:ok, users_map} -> - users = - users_map + case Pleroma.Repo.transaction(multi) do + {:ok, results} -> + {users, password_reset_tokens} = + results |> Map.values() + |> Enum.split_with(&match?(%User{}, &1)) + + users = + users |> Enum.map(fn user -> {:ok, user} = User.post_register_action(user) user end) + password_reset_links = + Map.new(password_reset_tokens, fn token -> + {token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)} + end) + ModerationLog.insert_log(%{ actor: admin, subjects: users, action: "create" }) - render(conn, "created_many.json", users: users) + render(conn, "created_many.json", + users: users, + password_reset_links: password_reset_links + ) - {:error, id, changeset, _} -> + {:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} -> changesets = - Enum.map(changesets.operations, fn + Enum.flat_map(multi.operations, fn {^id, {:changeset, _current_changeset, _}} -> - changeset + [changeset] - {_, {:changeset, current_changeset, _}} -> - current_changeset + {{:user, _}, {:changeset, current_changeset, _}} -> + [current_changeset] + + _ -> + [] end) conn |> put_status(:conflict) |> render("create_errors.json", changesets: changesets) + + {:error, {:password_reset_token, _}, _reason, _} -> + Pleroma.Web.AdminAPI.FallbackController.call( + conn, + {:password_reset_token_error, :creation_failed} + ) end end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 280152241..cc715c2a5 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end - def render("created_many.json", %{users: users}) do - render_many(users, AccountView, "created.json", as: :user) + def render("created_many.json", %{users: users} = assigns) do + password_reset_links = Map.get(assigns, :password_reset_links, %{}) + + render_many(users, AccountView, "created.json", + as: :user, + password_reset_links: password_reset_links + ) end - def render("created.json", %{user: user}) do - %{ - type: "success", - code: 200, - data: %{ + def render("created.json", %{user: user} = assigns) do + password_reset_links = Map.get(assigns, :password_reset_links, %{}) + + data = + %{ nickname: user.nickname, email: user.email } + |> maybe_put_password_reset_link(Map.get(password_reset_links, user.id)) + + %{ + type: "success", + code: 200, + data: data } end @@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end + defp maybe_put_password_reset_link(data, nil), do: data + + defp maybe_put_password_reset_link(data, link), + do: Map.put(data, :password_reset_link, link) + def merge_account_views(%User{} = user) do MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex index a5179ac39..7c4725f56 100644 --- a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -82,8 +82,15 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do properties: %{ nickname: %Schema{type: :string}, email: %Schema{type: :string}, - password: %Schema{type: :string} - } + password: %Schema{ + type: :string, + description: + "Optional. When omitted, a `password_reset_link` is returned, " <> + "allowing the user to set their own password. When provided, " <> + "it must not be empty." + } + }, + required: [:nickname, :email] } } } @@ -102,7 +109,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do type: :object, properties: %{ email: %Schema{type: :string, format: :email}, - nickname: %Schema{type: :string} + nickname: %Schema{type: :string}, + password_reset_link: %Schema{type: :string} } } } diff --git a/lib/pleroma/web/plugs/idempotency_plug.ex b/lib/pleroma/web/plugs/idempotency_plug.ex index a3b7af869..81983efba 100644 --- a/lib/pleroma/web/plugs/idempotency_plug.ex +++ b/lib/pleroma/web/plugs/idempotency_plug.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do import Phoenix.Controller, only: [json: 2] import Plug.Conn + alias Pleroma.Config + @behaviour Plug @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do def call(conn, _), do: conn def process_request(conn, key) do - case @cachex.get(:idempotency_cache, key) do + cache_key = {conn.method, conn.request_path, actor_key(conn), key} + + case @cachex.get(:idempotency_cache, cache_key) do {:ok, nil} -> - cache_resposnse(conn, key) + cache_resposnse(conn, key, cache_key) {:ok, record} -> send_cached(conn, key, record) @@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do end end - defp cache_resposnse(conn, key) do - register_before_send(conn, fn conn -> - [request_id] = get_resp_header(conn, "x-request-id") - content_type = get_content_type(conn) + defp cache_resposnse(conn, key, cache_key) do + register_before_send(conn, &maybe_cache_response(&1, key, cache_key)) + end - record = {request_id, content_type, conn.status, conn.resp_body} - {:ok, _} = @cachex.put(:idempotency_cache, key, record) + defp maybe_cache_response( + %{private: %{skip_idempotency_cache: true}} = conn, + _key, + _cache_key + ), + do: conn - conn - |> put_resp_header("idempotency-key", key) - |> put_resp_header("x-original-request-id", request_id) - end) + defp maybe_cache_response(conn, key, cache_key) do + [request_id] = get_resp_header(conn, "x-request-id") + content_type = get_content_type(conn) + + record = {request_id, content_type, conn.status, conn.resp_body} + {:ok, _} = @cachex.put(:idempotency_cache, cache_key, record) + + conn + |> put_resp_header("idempotency-key", key) + |> put_resp_header("x-original-request-id", request_id) + end + + defp actor_key(%{assigns: assigns}) do + {principal_key(assigns), token_key(assigns[:token]), staff_privileges()} + end + + defp principal_key(%{user: %{id: id} = user}) do + {:user, id, Map.get(user, :is_admin), Map.get(user, :is_moderator)} + end + + defp principal_key(%{app: %{id: id}}), do: {:app, id} + defp principal_key(_assigns), do: nil + + defp token_key(%{id: id} = token), do: {id, token |> Map.get(:scopes, []) |> Enum.sort()} + defp token_key(_token), do: nil + + defp staff_privileges do + { + Config.get([:instance, :admin_privileges], []) |> Enum.sort(), + Config.get([:instance, :moderator_privileges], []) |> Enum.sort() + } end defp send_cached(conn, key, record) do diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 0e5650285..764f5e01f 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -176,6 +176,183 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] end + test "Admin-created users bypass account confirmation", %{conn: conn} do + clear_config([:instance, :account_activation_required], true) + + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => "test"} + ] + }) + |> json_response_and_validate_schema(200) + + assert User.get_by_nickname("lain").is_confirmed + end + + test "Creating without a password returns a password reset link", %{conn: conn} do + accounts = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => "lain@example.org" + }, + %{ + "nickname" => "lain2", + "email" => "lain2@example.org", + "password" => "dupa.8" + } + ] + }) + |> json_response_and_validate_schema(200) + |> Map.new(fn account -> {account["data"]["nickname"], account} end) + + account1 = accounts["lain"] + account2 = accounts["lain2"] + + assert account1["type"] == "success" + assert %{"password_reset_link" => link} = account1["data"] + assert link =~ "/api/v1/pleroma/password_reset/" + + assert account2["type"] == "success" + refute Map.has_key?(account2["data"], "password_reset_link") + end + + test "Explicit empty passwords return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => ""} + ] + }) + + assert [%{"error" => "password can't be blank"}] = json_response(conn, 409) + refute User.get_by_nickname("lain") + end + + test "Password reset token failures roll back the user batch", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org"}, + %{"nickname" => "lain2", "email" => "lain2@example.org"} + ] + }) + end + + with_mock Pleroma.PasswordResetToken, [:passthrough], + create_token: fn user -> + if user.nickname == "lain2" do + {:error, :forced} + else + result = passthrough([user]) + send(self(), {:token_created, user.nickname}) + result + end + end do + conn = request.(conn) + + assert json_response(conn, 500) == %{"error" => "Something went wrong"} + end + + assert_received {:token_created, "lain"} + refute User.get_by_nickname("lain") + refute User.get_by_nickname("lain2") + assert Repo.aggregate(Pleroma.PasswordResetToken, :count) == 0 + + assert [_account1, _account2] = conn |> request.() |> json_response(200) + end + + test "Password reset links are not cached by idempotency key", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [%{"nickname" => "lain", "email" => "lain@example.org"}] + }) + end + + assert [%{"data" => %{"password_reset_link" => _link}}] = + conn |> request.() |> json_response(200) + + second_conn = request.(conn) + + assert [_error] = json_response(second_conn, 409) + refute get_resp_header(second_conn, "idempotent-replayed") == ["true"] + end + + test "Explicit-password responses remain idempotent", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => "test"} + ] + }) + end + + assert [_account] = conn |> request.() |> json_response(200) + + second_conn = request.(conn) + + assert [_account] = json_response(second_conn, 200) + assert get_resp_header(second_conn, "idempotent-replayed") == ["true"] + end + + test "Duplicate nicknames in a batch return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain1@example.org", "password" => "test"}, + %{"nickname" => "lain", "email" => "lain2@example.org", "password" => "test"} + ] + }) + + assert [_first, _second] = json_response(conn, 409) + refute User.get_by_nickname("lain") + end + + test "Blank nicknames return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "", "email" => "lain@example.org", "password" => "test"} + ] + }) + + assert [%{"error" => "nickname can't be blank"}] = json_response(conn, 409) + end + test "Cannot create user with existing email", %{conn: conn} do user = insert(:user) diff --git a/test/pleroma/web/plugs/idempotency_plug_test.exs b/test/pleroma/web/plugs/idempotency_plug_test.exs index 3b0131596..cc7580ca2 100644 --- a/test/pleroma/web/plugs/idempotency_plug_test.exs +++ b/test/pleroma/web/plugs/idempotency_plug_test.exs @@ -70,6 +70,139 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlugTest do assert [^key] = Conn.get_resp_header(conn, "idempotency-key") end + test "does not cache responses marked as sensitive" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "sensitive-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.put_private(:skip_idempotency_cache, true) + |> Conn.send_resp(200, "secret") + + conn = + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "second-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + + test "does not replay legacy unscoped cache entries" do + key = Ecto.UUID.generate() + record = {"legacy-request", "application/json", 200, "secret"} + assert {:ok, true} = Cachex.put(:idempotency_cache, key, record) + + conn = + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "new-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + + test "scopes cached responses to the route and authenticated actor" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "user-one", is_admin: false, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + for {path, user} <- [ + {"/tea", %{id: "user-one", is_admin: false, is_moderator: false}}, + {"/cofe", %{id: "user-two", is_admin: false, is_moderator: false}}, + {"/cofe", %{id: "user-one", is_admin: true, is_moderator: false}} + ] do + conn = + :post + |> conn(path) + |> Conn.assign(:user, user) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", Ecto.UUID.generate()) + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + end + + test "scopes cached responses to the OAuth application and token" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> Conn.assign(:app, %{id: "app-one"}) + |> Conn.assign(:token, %{id: "token-one", scopes: ["write"]}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + for {app, token} <- [ + {%{id: "app-two"}, %{id: "token-two", scopes: ["write"]}}, + {%{id: "app-one"}, %{id: "token-two", scopes: ["write"]}}, + {%{id: "app-one"}, %{id: "token-one", scopes: ["read"]}} + ] do + conn = + :post + |> conn("/cofe") + |> Conn.assign(:app, app) + |> Conn.assign(:token, token) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", Ecto.UUID.generate()) + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + end + + test "scopes cached responses to configured staff privileges" do + key = Ecto.UUID.generate() + clear_config([:instance, :admin_privileges], [:users_delete]) + + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + clear_config([:instance, :admin_privileges], []) + + conn = + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "second-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + test "passes conn downstream if idempotency is not present in headers" do orig_request_id = "test4" body = "testing"