Admin API: Add ability to force user's password reset
authorMaxim Filippov <colixer@gmail.com>
Sun, 22 Sep 2019 13:08:07 +0000 (16:08 +0300)
committerMaxim Filippov <colixer@gmail.com>
Sun, 22 Sep 2019 13:08:07 +0000 (16:08 +0300)
12 files changed:
CHANGELOG.md
docs/api/admin_api.md
lib/pleroma/user.ex
lib/pleroma/user/info.ex
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/router.ex
lib/pleroma/workers/background_worker.ex
test/user_test.exs
test/web/admin_api/admin_api_controller_test.exs
test/web/oauth/oauth_controller_test.exs
test/web/twitter_api/password_controller_test.exs

index 84b64e2b9adfa37d165a4b742f21119e40266ba2..e5a84f5aefe6e2bcfc536cfd6ee47c00a6a44875 100644 (file)
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 ## [Unreleased]
 ### Added
 - Refreshing poll results for remote polls
+- Admin API: Add ability to force user's password reset
+
 ### Changed
 - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
 - Replaced [pleroma_job_queue](https://git.pleroma.social/pleroma/pleroma_job_queue) and `Pleroma.Web.Federator.RetryQueue` with [Oban](https://github.com/sorentwo/oban) (see [`docs/config.md`](docs/config.md) on migrating customized worker / retry settings)
index 7637fa0d408728be4aa26ae5ce82549edc84c853..c6b9dd2b68bd481ea51840558c8e0f97e9bbdf0d 100644 (file)
@@ -310,6 +310,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 - Params: none
 - Response: password reset token (base64 string)
 
+## `/api/pleroma/admin/users/:nickname/force_password_reset`
+
+### Force passord reset for a user with a given nickname
+
+- Methods: `PATCH`
+- Params: none
+- Response: none (code `204`)
+
 ## `/api/pleroma/admin/reports`
 ### Get a list of reports
 - Method `GET`
index fb1f2425438c0de4b5e7c38b652dca8e5187f2e4..ab253a2749402d37f5fae2d9feadcd334a47a76c 100644 (file)
@@ -269,6 +269,7 @@ defmodule Pleroma.User do
     |> validate_required([:password, :password_confirmation])
     |> validate_confirmation(:password)
     |> put_password_hash
+    |> put_embed(:info, User.Info.set_password_reset_pending(struct.info, false))
   end
 
   @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@@ -285,6 +286,20 @@ defmodule Pleroma.User do
     end
   end
 
+  def force_password_reset_async(user) do
+    BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id})
+  end
+
+  @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def force_password_reset(user) do
+    info_cng = User.Info.set_password_reset_pending(user.info, true)
+
+    user
+    |> change()
+    |> put_embed(:info, info_cng)
+    |> update_and_set_cache()
+  end
+
   def register_changeset(struct, params \\ %{}, opts \\ []) do
     bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
     name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
@@ -1115,6 +1130,8 @@ defmodule Pleroma.User do
     BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id})
   end
 
+  def perform(:force_password_reset, user), do: force_password_reset(user)
+
   @spec perform(atom(), User.t()) :: {:ok, User.t()}
   def perform(:delete, %User{} = user) do
     {:ok, _user} = ActivityPub.delete(user)
index b150a57cd80e5b440d96f8f8c4fad684b84b9103..67abc3ecdc4052d0790b6fc67b1d50975db90151 100644 (file)
@@ -20,6 +20,7 @@ defmodule Pleroma.User.Info do
     field(:following_count, :integer, default: nil)
     field(:locked, :boolean, default: false)
     field(:confirmation_pending, :boolean, default: false)
+    field(:password_reset_pending, :boolean, default: false)
     field(:confirmation_token, :string, default: nil)
     field(:default_scope, :string, default: "public")
     field(:blocks, {:array, :string}, default: [])
@@ -82,6 +83,14 @@ defmodule Pleroma.User.Info do
     |> validate_required([:deactivated])
   end
 
+  def set_password_reset_pending(info, pending) do
+    params = %{password_reset_pending: pending}
+
+    info
+    |> cast(params, [:password_reset_pending])
+    |> validate_required([:password_reset_pending])
+  end
+
   def update_notification_settings(info, settings) do
     settings =
       settings
@@ -333,9 +342,7 @@ defmodule Pleroma.User.Info do
     name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255)
     value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255)
 
-    is_binary(name) &&
-      is_binary(value) &&
-      String.length(name) <= name_limit &&
+    is_binary(name) && is_binary(value) && String.length(name) <= name_limit &&
       String.length(value) <= value_limit
   end
 
index 8a8091daa2e39cf3375e13294febff14bdabaaed..711e4dfc2ee71d8701cacab8d7344d847c89b26f 100644 (file)
@@ -447,6 +447,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     |> json(token.token)
   end
 
+  @doc "Force password reset for a given user"
+  def force_password_reset(conn, %{"nickname" => nickname}) do
+    (%User{local: true} = user) = User.get_cached_by_nickname(nickname)
+
+    User.force_password_reset_async(user)
+
+    json_response(conn, :no_content, "")
+  end
+
   def list_reports(conn, params) do
     params =
       params
index 81eae2c8be526a888f15abd58b517d099d2e166d..a57670e025f734aa6d6db3ec2cb62cd14a773472 100644 (file)
@@ -202,6 +202,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:ok, app} <- Token.Utils.fetch_app(conn),
          {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
          {:user_active, true} <- {:user_active, !user.info.deactivated},
+         {:password_reset_pending, false} <-
+           {:password_reset_pending, user.info.password_reset_pending},
          {:ok, scopes} <- validate_scopes(app, params),
          {:ok, auth} <- Authorization.create_authorization(app, user, scopes),
          {:ok, token} <- Token.exchange_token(app, auth) do
@@ -215,6 +217,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do
       {:user_active, false} ->
         render_error(conn, :forbidden, "Your account is currently disabled")
 
+      {:password_reset_pending, true} ->
+        render_error(conn, :forbidden, "Password reset is required")
+
       _error ->
         render_invalid_credentials_error(conn)
     end
index b9b85fd6776ca31ffdf2c4970034c51cae0173f7..a306c1b8007da0ee5d14ee8d64908aae8b925b44 100644 (file)
@@ -186,6 +186,7 @@ defmodule Pleroma.Web.Router do
     post("/users/email_invite", AdminAPIController, :email_invite)
 
     get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset)
+    patch("/users/:nickname/force_password_reset", AdminAPIController, :force_password_reset)
 
     get("/users", AdminAPIController, :list_users)
     get("/users/:nickname", AdminAPIController, :user_show)
index 082f20ab7882443ebeaed821c9a0a77d15690ff8..7ffc8eabec217ae19db9ec72c5465353526b4eeb 100644 (file)
@@ -26,6 +26,11 @@ defmodule Pleroma.Workers.BackgroundWorker do
     User.perform(:delete, user)
   end
 
+  def perform(%{"op" => "force_password_reset", "user_id" => user_id}, _job) do
+    user = User.get_cached_by_id(user_id)
+    User.perform(:force_password_reset, user)
+  end
+
   def perform(
         %{
           "op" => "blocks_import",
index 39ba69668ad6b3e36277c025f06d58aec18957b3..1641724057614408c2d950db6dbef09c16c1b7df 100644 (file)
@@ -1690,4 +1690,21 @@ defmodule Pleroma.UserTest do
       assert {:ok, %User{email: "cofe@cofe.party"}} = User.change_email(user, "cofe@cofe.party")
     end
   end
+
+  describe "set_password_reset_pending/2" do
+    setup do
+      [user: insert(:user)]
+    end
+
+    test "sets password_reset_pending to true", %{user: user} do
+      %{password_reset_pending: password_reset_pending} = user.info
+
+      refute password_reset_pending
+
+      {:ok, %{info: %{password_reset_pending: password_reset_pending}}} =
+        User.force_password_reset(user)
+
+      assert password_reset_pending
+    end
+  end
 end
index 108143f6a94a79e3bf8765280ed9229ef6185219..f00e02a7a045d46a83c7a7240651230b25851073 100644 (file)
@@ -4,11 +4,13 @@
 
 defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   use Pleroma.Web.ConnCase
+  use Oban.Testing, repo: Pleroma.Repo
 
   alias Pleroma.Activity
   alias Pleroma.HTML
   alias Pleroma.ModerationLog
   alias Pleroma.Repo
+  alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.CommonAPI
@@ -2351,6 +2353,30 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
                "@#{admin.nickname} followed relay: https://example.org/relay"
     end
   end
+
+  describe "PATCH /users/:nickname/force_password_reset" do
+    setup %{conn: conn} do
+      admin = insert(:user, info: %{is_admin: true})
+      user = insert(:user)
+
+      %{conn: assign(conn, :user, admin), admin: admin, user: user}
+    end
+
+    test "sets password_reset_pending to true", %{admin: admin, user: user} do
+      assert user.info.password_reset_pending == false
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> patch("/api/pleroma/admin/users/#{user.nickname}/force_password_reset")
+
+      assert json_response(conn, 204) == ""
+
+      ObanHelpers.perform_all()
+
+      assert User.get_by_id(user.id).info.password_reset_pending == true
+    end
+  end
 end
 
 # Needed for testing
index 2780e1746bebf3c4481546196ebd0ce2622efdf7..8b88fd7846b213f9417ee13f7bd554717558461c 100644 (file)
@@ -831,6 +831,33 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       refute Map.has_key?(resp, "access_token")
     end
 
+    test "rejects token exchange for user with password_reset_pending set to true" do
+      password = "testpassword"
+
+      user =
+        insert(:user,
+          password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
+          info: %{password_reset_pending: true}
+        )
+
+      app = insert(:oauth_app, scopes: ["read", "write"])
+
+      conn =
+        build_conn()
+        |> post("/oauth/token", %{
+          "grant_type" => "password",
+          "username" => user.nickname,
+          "password" => password,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+
+      assert resp = json_response(conn, 403)
+
+      assert resp["error"] == "Password reset is required"
+      refute Map.has_key?(resp, "access_token")
+    end
+
     test "rejects an invalid authorization code" do
       app = insert(:oauth_app)
 
index 3a7246ea8321ac99440fe7c252ffec10269cce4c..dc6d4e3e32b43cf8a143717a5c27c424fd85af49 100644 (file)
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do
   use Pleroma.Web.ConnCase
 
   alias Pleroma.PasswordResetToken
+  alias Pleroma.User
   alias Pleroma.Web.OAuth.Token
   import Pleroma.Factory
 
@@ -56,5 +57,25 @@ defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do
       assert Comeonin.Pbkdf2.checkpw("test", user.password_hash)
       assert length(Token.get_user_tokens(user)) == 0
     end
+
+    test "it sets password_reset_pending to false", %{conn: conn} do
+      user = insert(:user, info: %{password_reset_pending: true})
+
+      {:ok, token} = PasswordResetToken.create_token(user)
+      {:ok, _access_token} = Token.create_token(insert(:oauth_app), user, %{})
+
+      params = %{
+        "password" => "test",
+        password_confirmation: "test",
+        token: token.token
+      }
+
+      conn
+      |> assign(:user, user)
+      |> post("/api/pleroma/password_reset", %{data: params})
+      |> html_response(:ok)
+
+      assert User.get_by_id(user.id).info.password_reset_pending == false
+    end
   end
 end