Merge branch 'fix/privacy-notification' into 'develop'
authorlain <lain@soykaf.club>
Fri, 8 May 2020 09:04:24 +0000 (09:04 +0000)
committerlain <lain@soykaf.club>
Fri, 8 May 2020 09:04:24 +0000 (09:04 +0000)
Privacy option affects all push notifications, not just Direct Messages

Closes #1745

See merge request pleroma/pleroma!2485

98 files changed:
config/config.exs
config/description.exs
config/test.exs
docs/API/admin_api.md
docs/API/pleroma_api.md
docs/configuration/cheatsheet.md
lib/pleroma/application.ex
lib/pleroma/mfa.ex [new file with mode: 0644]
lib/pleroma/mfa/backup_codes.ex [new file with mode: 0644]
lib/pleroma/mfa/changeset.ex [new file with mode: 0644]
lib/pleroma/mfa/settings.ex [new file with mode: 0644]
lib/pleroma/mfa/token.ex [new file with mode: 0644]
lib/pleroma/mfa/totp.ex [new file with mode: 0644]
lib/pleroma/plugs/ensure_authenticated_plug.ex
lib/pleroma/user.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/activity_pub_controller.ex
lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/object_validator.ex
lib/pleroma/web/activity_pub/object_validators/common_validations.ex
lib/pleroma/web/activity_pub/object_validators/undo_validator.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/activity_pub/utils.ex
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/api_spec/operations/account_operation.ex
lib/pleroma/web/api_spec/operations/poll_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/search_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/schemas/poll.ex
lib/pleroma/web/api_spec/schemas/status.ex
lib/pleroma/web/api_spec/schemas/tag.ex [new file with mode: 0644]
lib/pleroma/web/auth/pleroma_authenticator.ex
lib/pleroma/web/auth/totp_authenticator.ex [new file with mode: 0644]
lib/pleroma/web/common_api/common_api.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/mastodon_api/controllers/account_controller.ex
lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
lib/pleroma/web/mastodon_api/controllers/search_controller.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/mastodon_api/websocket_handler.ex
lib/pleroma/web/oauth/mfa_controller.ex [new file with mode: 0644]
lib/pleroma/web/oauth/mfa_view.ex [new file with mode: 0644]
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/oauth/token/clean_worker.ex [new file with mode: 0644]
lib/pleroma/web/oauth/token/response.ex
lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex
lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
lib/pleroma/web/streamer/ping.ex [deleted file]
lib/pleroma/web/streamer/state.ex [deleted file]
lib/pleroma/web/streamer/streamer.ex
lib/pleroma/web/streamer/streamer_socket.ex [deleted file]
lib/pleroma/web/streamer/supervisor.ex [deleted file]
lib/pleroma/web/streamer/worker.ex [deleted file]
lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/o_auth/mfa/totp.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex [new file with mode: 0644]
lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex
lib/pleroma/web/views/streamer_view.ex
mix.exs
mix.lock
priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs [new file with mode: 0644]
priv/repo/migrations/20190508193213_create_mfa_tokens.exs [new file with mode: 0644]
priv/static/adminfe/static/fonts/element-icons.535877f.woff [deleted file]
priv/static/adminfe/static/fonts/element-icons.732389d.ttf [deleted file]
test/integration/mastodon_websocket_test.exs
test/mfa/backup_codes_test.exs [new file with mode: 0644]
test/mfa/totp_test.exs [new file with mode: 0644]
test/mfa_test.exs [new file with mode: 0644]
test/notification_test.exs
test/plugs/ensure_authenticated_plug_test.exs
test/support/builders/activity_builder.ex
test/support/builders/user_builder.ex
test/support/conn_case.ex
test/support/data_case.ex
test/support/factory.ex
test/user_search_test.exs
test/web/activity_pub/activity_pub_controller_test.exs
test/web/activity_pub/activity_pub_test.exs
test/web/activity_pub/object_validator_test.exs
test/web/activity_pub/side_effects_test.exs
test/web/activity_pub/transmogrifier/undo_handling_test.exs [new file with mode: 0644]
test/web/activity_pub/transmogrifier_test.exs
test/web/activity_pub/utils_test.exs
test/web/admin_api/admin_api_controller_test.exs
test/web/auth/pleroma_authenticator_test.exs [new file with mode: 0644]
test/web/auth/totp_authenticator_test.exs [new file with mode: 0644]
test/web/common_api/common_api_test.exs
test/web/mastodon_api/controllers/poll_controller_test.exs
test/web/mastodon_api/controllers/search_controller_test.exs
test/web/oauth/mfa_controller_test.exs [new file with mode: 0644]
test/web/oauth/oauth_controller_test.exs
test/web/pleroma_api/controllers/pleroma_api_controller_test.exs
test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs [new file with mode: 0644]
test/web/streamer/ping_test.exs [deleted file]
test/web/streamer/state_test.exs [deleted file]
test/web/streamer/streamer_test.exs
test/web/twitter_api/remote_follow_controller_test.exs

index ca9bbab6480c43ccebaecaca106ad22c8059bd6c..e703c1632fbfe2974c790605a0e7c55fcff0f862 100644 (file)
@@ -238,7 +238,18 @@ config :pleroma, :instance,
   account_field_value_length: 2048,
   external_user_synchronization: true,
   extended_nickname_format: true,
-  cleanup_attachments: false
+  cleanup_attachments: false,
+  multi_factor_authentication: [
+    totp: [
+      # digits 6 or 8
+      digits: 6,
+      period: 30
+    ],
+    backup_codes: [
+      number: 5,
+      length: 16
+    ]
+  ]
 
 config :pleroma, :extensions, output_relationships_in_statuses_by_default: true
 
index 1b2afebef0cc923a4eedd61ab52ffe05d0fb4e0d..39e0940824ce7bfe654dedb21317eaff0ca0bfb7 100644 (file)
@@ -919,6 +919,62 @@ config :pleroma, :config_description, [
         key: :external_user_synchronization,
         type: :boolean,
         description: "Enabling following/followers counters synchronization for external users"
+      },
+      %{
+        key: :multi_factor_authentication,
+        type: :keyword,
+        description: "Multi-factor authentication settings",
+        suggestions: [
+          [
+            totp: [digits: 6, period: 30],
+            backup_codes: [number: 5, length: 16]
+          ]
+        ],
+        children: [
+          %{
+            key: :totp,
+            type: :keyword,
+            description: "TOTP settings",
+            suggestions: [digits: 6, period: 30],
+            children: [
+              %{
+                key: :digits,
+                type: :integer,
+                suggestions: [6],
+                description:
+                  "Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
+              },
+              %{
+                key: :period,
+                type: :integer,
+                suggestions: [30],
+                description:
+                  "a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
+              }
+            ]
+          },
+          %{
+            key: :backup_codes,
+            type: :keyword,
+            description: "MFA backup codes settings",
+            suggestions: [number: 5, length: 16],
+            children: [
+              %{
+                key: :number,
+                type: :integer,
+                suggestions: [5],
+                description: "number of backup codes to generate."
+              },
+              %{
+                key: :length,
+                type: :integer,
+                suggestions: [16],
+                description:
+                  "Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
+              }
+            ]
+          }
+        ]
       }
     ]
   },
index cbf775109de48f2438e72fe4c3c1c48c91747eb6..e38b9967d67a387031ad58d0b50b22458d45fd54 100644 (file)
@@ -56,6 +56,19 @@ config :pleroma, :rich_media,
   ignore_hosts: [],
   ignore_tld: ["local", "localdomain", "lan"]
 
+config :pleroma, :instance,
+  multi_factor_authentication: [
+    totp: [
+      # digits 6 or 8
+      digits: 6,
+      period: 30
+    ],
+    backup_codes: [
+      number: 2,
+      length: 6
+    ]
+  ]
+
 config :web_push_encryption, :vapid_details,
   subject: "mailto:administrator@example.com",
   public_key:
index 23af08961b11c002c59a58f030b49e81a44e4662..c455047cc1fa529996021571ec8e68a17f1a11c1 100644 (file)
@@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 
 ### Get a password reset token for a given nickname
 
+
 - Params: none
 - Response:
 
@@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
   - `nicknames`
 - Response: none (code `204`)
 
+## PUT `/api/pleroma/admin/users/disable_mfa`
+
+### Disable mfa for user's account.
+
+- Params:
+  - `nickname`
+- Response: User’s nickname
+
 ## `GET /api/pleroma/admin/users/:nickname/credentials`
 
 ### Get the user's email, password, display and settings-related fields
index b927be026bb480c88bceab7f1cebfcbe04e3560c..5895613a3d9d24317d4083f86ffdf0b8dca08796 100644 (file)
@@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
 * Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise
 * Example response: `{"error": "Invalid password."}`
 
-## `/api/pleroma/admin/`…
+## `/api/pleroma/accounts/mfa`
+#### Gets current MFA settings
+* method: `GET`
+* Authentication: required
+* OAuth scope: `read:security`
+* Response: JSON. Returns `{"enabled": "false", "totp": false }`
+
+## `/api/pleroma/accounts/mfa/setup/totp`
+#### Pre-setup the MFA/TOTP method
+* method: `GET`
+* Authentication: required
+* OAuth scope: `write:security`
+* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]"  }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}`
+
+## `/api/pleroma/accounts/mfa/confirm/totp`
+#### Confirms & enables MFA/TOTP support for user account.
+* method: `POST`
+* Authentication: required
+* OAuth scope: `write:security`
+* Params:
+    * `password`: user's password
+    * `code`: token from TOTP App
+* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
+
+
+## `/api/pleroma/accounts/mfa/totp`
+####  Disables MFA/TOTP method for user account.
+* method: `DELETE`
+* Authentication: required
+* OAuth scope: `write:security`
+* Params:
+    * `password`: user's password
+* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
+* Example response: `{"error": "Invalid password."}`
+
+## `/api/pleroma/accounts/mfa/backup_codes`
+####  Generstes backup codes MFA for user account.
+* method: `GET`
+* Authentication: required
+* OAuth scope: `write:security`
+* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}`
+
+## `/api/pleroma/admin/`
 See [Admin-API](admin_api.md)
 
 ## `/api/v1/pleroma/notifications/read`
index 2524918d4d9268f8243102142bf2c1ae11db0a95..707d7fdbd58228b61988666afb107fb7e5e10903 100644 (file)
@@ -907,12 +907,18 @@ config :auto_linker,
 
 * `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
 
-
 ## :configurable_from_database
 
 Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information.
 
 
+### Multi-factor authentication -  :two_factor_authentication
+* `totp` - a list containing TOTP configuration
+  - `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
+  - `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
+* `backup_codes` - a list containing backup codes configuration
+  - `number` - number of backup codes to generate.
+  - `length` - backup code length. Defaults to 16 characters.
 
 ## Restrict entities access for unauthenticated users
 
@@ -930,6 +936,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us
   * `local`
   * `remote`
 
+
 ## Pleroma.Web.ApiSpec.CastAndValidate
 
 * `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.
index 308d8cffa661ad3cf1ab8cf3dfb3845fcda1a8d4..a00bc06247e3125cc5157320b10361c616d1ce4a 100644 (file)
@@ -173,7 +173,14 @@ defmodule Pleroma.Application do
   defp streamer_child(env) when env in [:test, :benchmark], do: []
 
   defp streamer_child(_) do
-    [Pleroma.Web.Streamer.supervisor()]
+    [
+      {Registry,
+       [
+         name: Pleroma.Web.Streamer.registry(),
+         keys: :duplicate,
+         partitions: System.schedulers_online()
+       ]}
+    ]
   end
 
   defp chat_child(_env, true) do
diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex
new file mode 100644 (file)
index 0000000..d353a4d
--- /dev/null
@@ -0,0 +1,156 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA do
+  @moduledoc """
+  The MFA context.
+  """
+
+  alias Comeonin.Pbkdf2
+  alias Pleroma.User
+
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.Changeset
+  alias Pleroma.MFA.Settings
+  alias Pleroma.MFA.TOTP
+
+  @doc """
+  Returns MFA methods the user has enabled.
+
+  ## Examples
+
+    iex> Pleroma.MFA.supported_method(User)
+    "totp, u2f"
+  """
+  @spec supported_methods(User.t()) :: String.t()
+  def supported_methods(user) do
+    settings = fetch_settings(user)
+
+    Settings.mfa_methods()
+    |> Enum.reduce([], fn m, acc ->
+      if method_enabled?(m, settings) do
+        acc ++ [m]
+      else
+        acc
+      end
+    end)
+    |> Enum.join(",")
+  end
+
+  @doc "Checks that user enabled MFA"
+  def require?(user) do
+    fetch_settings(user).enabled
+  end
+
+  @doc """
+  Display MFA settings of user
+  """
+  def mfa_settings(user) do
+    settings = fetch_settings(user)
+
+    Settings.mfa_methods()
+    |> Enum.map(fn m -> [m, method_enabled?(m, settings)] end)
+    |> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end)
+  end
+
+  @doc false
+  def fetch_settings(%User{} = user) do
+    user.multi_factor_authentication_settings || %Settings{}
+  end
+
+  @doc "clears backup codes"
+  def invalidate_backup_code(%User{} = user, hash_code) do
+    %{backup_codes: codes} = fetch_settings(user)
+
+    user
+    |> Changeset.cast_backup_codes(codes -- [hash_code])
+    |> User.update_and_set_cache()
+  end
+
+  @doc "generates backup codes"
+  @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()}
+  def generate_backup_codes(%User{} = user) do
+    with codes <- BackupCodes.generate(),
+         hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1),
+         changeset <- Changeset.cast_backup_codes(user, hashed_codes),
+         {:ok, _} <- User.update_and_set_cache(changeset) do
+      {:ok, codes}
+    else
+      {:error, msg} ->
+        %{error: msg}
+    end
+  end
+
+  @doc """
+  Generates secret key and set delivery_type to 'app' for TOTP method.
+  """
+  @spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def setup_totp(user) do
+    user
+    |> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"})
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Confirms the TOTP method for user.
+
+  `attrs`:
+    `password` - current user password
+    `code` - TOTP token
+  """
+  @spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
+  def confirm_totp(%User{} = user, attrs) do
+    with settings <- user.multi_factor_authentication_settings.totp,
+         {:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do
+      user
+      |> Changeset.confirm_totp()
+      |> User.update_and_set_cache()
+    end
+  end
+
+  @doc """
+  Disables the TOTP method for user.
+
+  `attrs`:
+    `password` - current user password
+  """
+  @spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def disable_totp(%User{} = user) do
+    user
+    |> Changeset.disable_totp()
+    |> Changeset.disable()
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Force disables all MFA methods for user.
+  """
+  @spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def disable(%User{} = user) do
+    user
+    |> Changeset.disable_totp()
+    |> Changeset.disable(true)
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Checks if the user has MFA method enabled.
+  """
+  def method_enabled?(method, settings) do
+    with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do
+      true
+    else
+      _ -> false
+    end
+  end
+
+  @doc """
+  Checks if the user has enabled at least one MFA method.
+  """
+  def enabled?(settings) do
+    Settings.mfa_methods()
+    |> Enum.map(fn m -> method_enabled?(m, settings) end)
+    |> Enum.any?()
+  end
+end
diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex
new file mode 100644 (file)
index 0000000..2b5ec34
--- /dev/null
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.BackupCodes do
+  @moduledoc """
+  This module contains functions for generating backup codes.
+  """
+  alias Pleroma.Config
+
+  @config_ns [:instance, :multi_factor_authentication, :backup_codes]
+
+  @doc """
+  Generates backup codes.
+  """
+  @spec generate(Keyword.t()) :: list(String.t())
+  def generate(opts \\ []) do
+    number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number())
+    code_length = Keyword.get(opts, :length, default_backup_codes_code_length())
+
+    Enum.map(1..number_of_codes, fn _ ->
+      :crypto.strong_rand_bytes(div(code_length, 2))
+      |> Base.encode16(case: :lower)
+    end)
+  end
+
+  defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5)
+
+  defp default_backup_codes_code_length,
+    do: Config.get(@config_ns ++ [:length], 16)
+end
diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex
new file mode 100644 (file)
index 0000000..9b020aa
--- /dev/null
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Changeset do
+  alias Pleroma.MFA
+  alias Pleroma.MFA.Settings
+  alias Pleroma.User
+
+  def disable(%Ecto.Changeset{} = changeset, force \\ false) do
+    settings =
+      changeset
+      |> Ecto.Changeset.apply_changes()
+      |> MFA.fetch_settings()
+
+    if force || not MFA.enabled?(settings) do
+      put_change(changeset, %Settings{settings | enabled: false})
+    else
+      changeset
+    end
+  end
+
+  def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
+    user
+    |> put_change(%Settings{settings | totp: %Settings.TOTP{}})
+  end
+
+  def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
+    totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
+
+    user
+    |> put_change(%Settings{settings | totp: totp_settings, enabled: true})
+  end
+
+  def setup_totp(%User{} = user, attrs) do
+    mfa_settings = MFA.fetch_settings(user)
+
+    totp_settings =
+      %Settings.TOTP{}
+      |> Ecto.Changeset.cast(attrs, [:secret, :delivery_type])
+
+    user
+    |> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)})
+  end
+
+  def cast_backup_codes(%User{} = user, codes) do
+    user
+    |> put_change(%Settings{
+      user.multi_factor_authentication_settings
+      | backup_codes: codes
+    })
+  end
+
+  defp put_change(%User{} = user, settings) do
+    user
+    |> Ecto.Changeset.change()
+    |> put_change(settings)
+  end
+
+  defp put_change(%Ecto.Changeset{} = changeset, settings) do
+    changeset
+    |> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings)
+  end
+end
diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex
new file mode 100644 (file)
index 0000000..2764b88
--- /dev/null
@@ -0,0 +1,24 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Settings do
+  use Ecto.Schema
+
+  @primary_key false
+
+  @mfa_methods [:totp]
+  embedded_schema do
+    field(:enabled, :boolean, default: false)
+    field(:backup_codes, {:array, :string}, default: [])
+
+    embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do
+      field(:secret, :string)
+      # app | sms
+      field(:delivery_type, :string, default: "app")
+      field(:confirmed, :boolean, default: false)
+    end
+  end
+
+  def mfa_methods, do: @mfa_methods
+end
diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex
new file mode 100644 (file)
index 0000000..25ff7fb
--- /dev/null
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Token do
+  use Ecto.Schema
+  import Ecto.Query
+  import Ecto.Changeset
+
+  alias Pleroma.Repo
+  alias Pleroma.User
+  alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.Token, as: OAuthToken
+
+  @expires 300
+
+  schema "mfa_tokens" do
+    field(:token, :string)
+    field(:valid_until, :naive_datetime_usec)
+
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+    belongs_to(:authorization, Authorization)
+
+    timestamps()
+  end
+
+  def get_by_token(token) do
+    from(
+      t in __MODULE__,
+      where: t.token == ^token,
+      preload: [:user, :authorization]
+    )
+    |> Repo.find_resource()
+  end
+
+  def validate(token) do
+    with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)},
+         {:expired, false} <- {:expired, is_expired?(token)} do
+      {:ok, token}
+    else
+      {:expired, _} -> {:error, :expired_token}
+      {:fetch_token, _} -> {:error, :not_found}
+      error -> {:error, error}
+    end
+  end
+
+  def create_token(%User{} = user) do
+    %__MODULE__{}
+    |> change
+    |> assign_user(user)
+    |> put_token
+    |> put_valid_until
+    |> Repo.insert()
+  end
+
+  def create_token(user, authorization) do
+    %__MODULE__{}
+    |> change
+    |> assign_user(user)
+    |> assign_authorization(authorization)
+    |> put_token
+    |> put_valid_until
+    |> Repo.insert()
+  end
+
+  defp assign_user(changeset, user) do
+    changeset
+    |> put_assoc(:user, user)
+    |> validate_required([:user])
+  end
+
+  defp assign_authorization(changeset, authorization) do
+    changeset
+    |> put_assoc(:authorization, authorization)
+    |> validate_required([:authorization])
+  end
+
+  defp put_token(changeset) do
+    changeset
+    |> change(%{token: OAuthToken.Utils.generate_token()})
+    |> validate_required([:token])
+    |> unique_constraint(:token)
+  end
+
+  defp put_valid_until(changeset) do
+    expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires)
+
+    changeset
+    |> change(%{valid_until: expires_in})
+    |> validate_required([:valid_until])
+  end
+
+  def is_expired?(%__MODULE__{valid_until: valid_until}) do
+    NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
+  end
+
+  def is_expired?(_), do: false
+
+  def delete_expired_tokens do
+    from(
+      q in __MODULE__,
+      where: fragment("?", q.valid_until) < ^Timex.now()
+    )
+    |> Repo.delete_all()
+  end
+end
diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex
new file mode 100644 (file)
index 0000000..1407afc
--- /dev/null
@@ -0,0 +1,86 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.TOTP do
+  @moduledoc """
+  This module represents functions to create secrets for
+  TOTP Application as well as validate them with a time based token.
+  """
+  alias Pleroma.Config
+
+  @config_ns [:instance, :multi_factor_authentication, :totp]
+
+  @doc """
+  https://github.com/google/google-authenticator/wiki/Key-Uri-Format
+  """
+  def provisioning_uri(secret, label, opts \\ []) do
+    query =
+      %{
+        secret: secret,
+        issuer: Keyword.get(opts, :issuer, default_issuer()),
+        digits: Keyword.get(opts, :digits, default_digits()),
+        period: Keyword.get(opts, :period, default_period())
+      }
+      |> Enum.filter(fn {_, v} -> not is_nil(v) end)
+      |> Enum.into(%{})
+      |> URI.encode_query()
+
+    %URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query}
+    |> URI.to_string()
+  end
+
+  defp default_period, do: Config.get(@config_ns ++ [:period])
+  defp default_digits, do: Config.get(@config_ns ++ [:digits])
+
+  defp default_issuer,
+    do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name]))
+
+  @doc "Creates a random Base 32 encoded string"
+  def generate_secret do
+    Base.encode32(:crypto.strong_rand_bytes(10))
+  end
+
+  @doc "Generates a valid token based on a secret"
+  def generate_token(secret) do
+    :pot.totp(secret)
+  end
+
+  @doc """
+  Validates a given token based on a secret.
+
+  optional parameters:
+  `token_length` default `6`
+  `interval_length` default `30`
+  `window` default 0
+
+  Returns {:ok, :pass} if the token is valid and
+  {:error, :invalid_token} if it is not.
+  """
+  @spec validate_token(String.t(), String.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def validate_token(secret, token)
+      when is_binary(secret) and is_binary(token) do
+    opts = [
+      token_length: default_digits(),
+      interval_length: default_period()
+    ]
+
+    validate_token(secret, token, opts)
+  end
+
+  def validate_token(_, _), do: {:error, :invalid_secret_and_token}
+
+  @doc "See `validate_token/2`"
+  @spec validate_token(String.t(), String.t(), Keyword.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def validate_token(secret, token, options)
+      when is_binary(secret) and is_binary(token) do
+    case :pot.valid_totp(token, secret, options) do
+      true -> {:ok, :pass}
+      false -> {:error, :invalid_token}
+    end
+  end
+
+  def validate_token(_, _, _), do: {:error, :invalid_secret_and_token}
+end
index 9d5176e2b66953a3fd50bb77fbc87b5d8cfd957a..3fe5508060730e6d40f96860ad774e42896af65d 100644 (file)
@@ -15,6 +15,20 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do
   end
 
   @impl true
+  def perform(
+        %{
+          assigns: %{
+            auth_credentials: %{password: _},
+            user: %User{multi_factor_authentication_settings: %{enabled: true}}
+          }
+        } = conn,
+        _
+      ) do
+    conn
+    |> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.")
+    |> halt()
+  end
+
   def perform(%{assigns: %{user: %User{}}} = conn, _) do
     conn
   end
index 323eb2a4198edd737eb83e4b4caf70971033c8df..2a6a23fecb69c8c1938137b508266a93cb051824 100644 (file)
@@ -20,6 +20,7 @@ defmodule Pleroma.User do
   alias Pleroma.Formatter
   alias Pleroma.HTML
   alias Pleroma.Keys
+  alias Pleroma.MFA
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Registration
@@ -190,6 +191,12 @@ defmodule Pleroma.User do
     # `:subscribers` is deprecated (replaced with `subscriber_users` relation)
     field(:subscribers, {:array, :string}, default: [])
 
+    embeds_one(
+      :multi_factor_authentication_settings,
+      MFA.Settings,
+      on_replace: :delete
+    )
+
     timestamps()
   end
 
@@ -927,6 +934,7 @@ defmodule Pleroma.User do
     end
   end
 
+  @spec get_by_nickname(String.t()) :: User.t() | nil
   def get_by_nickname(nickname) do
     Repo.get_by(User, nickname: nickname) ||
       if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
@@ -1549,23 +1557,13 @@ defmodule Pleroma.User do
   defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do
     {:ok, delete_data, _} = Builder.delete(user, object)
 
-    Pipeline.common_pipeline(delete_data, local: true)
+    Pipeline.common_pipeline(delete_data, local: user.local)
   end
 
-  defp delete_activity(%{data: %{"type" => "Like"}} = activity, _user) do
-    object = Object.normalize(activity)
-
-    activity.actor
-    |> get_cached_by_ap_id()
-    |> ActivityPub.unlike(object)
-  end
-
-  defp delete_activity(%{data: %{"type" => "Announce"}} = activity, _user) do
-    object = Object.normalize(activity)
-
-    activity.actor
-    |> get_cached_by_ap_id()
-    |> ActivityPub.unannounce(object)
+  defp delete_activity(%{data: %{"type" => type}} = activity, user)
+       when type in ["Like", "Announce"] do
+    {:ok, undo, _} = Builder.undo(user, activity)
+    Pipeline.common_pipeline(undo, local: user.local)
   end
 
   defp delete_activity(_activity, _user), do: "Doing nothing"
index 099df58792562fc3766868760249fc8a762e087b..5abeb94c601e9f8785be7b073e00509d2a736c0e 100644 (file)
@@ -170,12 +170,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
       BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id})
 
-      Notification.create_notifications(activity)
-
-      conversation = create_or_bump_conversation(activity, map["actor"])
-      participations = get_participations(conversation)
-      stream_out(activity)
-      stream_out_participations(participations)
       {:ok, activity}
     else
       %Activity{} = activity ->
@@ -198,6 +192,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
+  def notify_and_stream(activity) do
+    Notification.create_notifications(activity)
+
+    conversation = create_or_bump_conversation(activity, activity.actor)
+    participations = get_participations(conversation)
+    stream_out(activity)
+    stream_out_participations(participations)
+  end
+
   defp create_or_bump_conversation(activity, actor) do
     with {:ok, conversation} <- Conversation.create_or_bump_for(activity),
          %User{} = user <- User.get_cached_by_ap_id(actor),
@@ -274,6 +277,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          _ <- increase_poll_votes_if_vote(create_data),
          {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
          {:ok, _actor} <- increase_note_count_if_public(actor, activity),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -301,6 +305,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
              additional
            ),
          {:ok, activity} <- insert(listen_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -325,6 +330,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
            %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object}
            |> Utils.maybe_put("id", activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -344,6 +350,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          },
          data <- Utils.maybe_put(data, "id", activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -365,6 +372,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
          {:ok, activity} <- insert(reaction_data, local),
          {:ok, object} <- add_emoji_reaction_to_object(activity, object),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity, object}
     else
@@ -373,54 +381,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
-  @spec unreact_with_emoji(User.t(), String.t(), keyword()) ::
-          {:ok, Activity.t(), Object.t()} | {:error, any()}
-  def unreact_with_emoji(user, reaction_id, options \\ []) do
-    with {:ok, result} <-
-           Repo.transaction(fn -> do_unreact_with_emoji(user, reaction_id, options) end) do
-      result
-    end
-  end
-
-  defp do_unreact_with_emoji(user, reaction_id, options) do
-    with local <- Keyword.get(options, :local, true),
-         activity_id <- Keyword.get(options, :activity_id, nil),
-         user_ap_id <- user.ap_id,
-         %Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id),
-         object <- Object.normalize(reaction_activity),
-         unreact_data <- make_undo_data(user, reaction_activity, activity_id),
-         {:ok, activity} <- insert(unreact_data, local),
-         {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
-         :ok <- maybe_federate(activity) do
-      {:ok, activity, object}
-    else
-      {:error, error} -> Repo.rollback(error)
-    end
-  end
-
-  @spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) ::
-          {:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
-  def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do
-    with {:ok, result} <-
-           Repo.transaction(fn -> do_unlike(actor, object, activity_id, local) end) do
-      result
-    end
-  end
-
-  defp do_unlike(actor, object, activity_id, local) do
-    with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object),
-         unlike_data <- make_unlike_data(actor, like_activity, activity_id),
-         {:ok, unlike_activity} <- insert(unlike_data, local),
-         {:ok, _activity} <- Repo.delete(like_activity),
-         {:ok, object} <- remove_like_from_object(like_activity, object),
-         :ok <- maybe_federate(unlike_activity) do
-      {:ok, unlike_activity, like_activity, object}
-    else
-      nil -> {:ok, object}
-      {:error, error} -> Repo.rollback(error)
-    end
-  end
-
   @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) ::
           {:ok, Activity.t(), Object.t()} | {:error, any()}
   def announce(
@@ -442,6 +402,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          announce_data <- make_announce_data(user, object, activity_id, public),
          {:ok, activity} <- insert(announce_data, local),
          {:ok, object} <- add_announce_to_object(activity, object),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity, object}
     else
@@ -450,34 +411,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
-  @spec unannounce(User.t(), Object.t(), String.t() | nil, boolean()) ::
-          {:ok, Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
-  def unannounce(
-        %User{} = actor,
-        %Object{} = object,
-        activity_id \\ nil,
-        local \\ true
-      ) do
-    with {:ok, result} <-
-           Repo.transaction(fn -> do_unannounce(actor, object, activity_id, local) end) do
-      result
-    end
-  end
-
-  defp do_unannounce(actor, object, activity_id, local) do
-    with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
-         unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
-         {:ok, unannounce_activity} <- insert(unannounce_data, local),
-         :ok <- maybe_federate(unannounce_activity),
-         {:ok, _activity} <- Repo.delete(announce_activity),
-         {:ok, object} <- remove_announce_from_object(announce_activity, object) do
-      {:ok, unannounce_activity, object}
-    else
-      nil -> {:ok, object}
-      {:error, error} -> Repo.rollback(error)
-    end
-  end
-
   @spec follow(User.t(), User.t(), String.t() | nil, boolean()) ::
           {:ok, Activity.t()} | {:error, any()}
   def follow(follower, followed, activity_id \\ nil, local \\ true) do
@@ -490,6 +423,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   defp do_follow(follower, followed, activity_id, local) do
     with data <- make_follow_data(follower, followed, activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -511,6 +445,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
          unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
          {:ok, activity} <- insert(unfollow_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -540,6 +475,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with true <- outgoing_blocks,
          block_data <- make_block_data(blocker, blocked, activity_id),
          {:ok, activity} <- insert(block_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -547,27 +483,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
-  @spec unblock(User.t(), User.t(), String.t() | nil, boolean()) ::
-          {:ok, Activity.t()} | {:error, any()} | nil
-  def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do
-    with {:ok, result} <-
-           Repo.transaction(fn -> do_unblock(blocker, blocked, activity_id, local) end) do
-      result
-    end
-  end
-
-  defp do_unblock(blocker, blocked, activity_id, local) do
-    with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
-         unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
-         {:ok, activity} <- insert(unblock_data, local),
-         :ok <- maybe_federate(activity) do
-      {:ok, activity}
-    else
-      nil -> nil
-      {:error, error} -> Repo.rollback(error)
-    end
-  end
-
   @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()}
   def flag(
         %{
@@ -594,6 +509,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with flag_data <- make_flag_data(params, additional),
          {:ok, activity} <- insert(flag_data, local),
          {:ok, stripped_activity} <- strip_report_status_data(activity),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(stripped_activity) do
       User.all_superusers()
       |> Enum.filter(fn user -> not is_nil(user.email) end)
@@ -617,7 +533,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     }
 
     with true <- origin.ap_id in target.also_known_as,
-         {:ok, activity} <- insert(params, local) do
+         {:ok, activity} <- insert(params, local),
+         _ <- notify_and_stream(activity) do
       maybe_federate(activity)
 
       BackgroundWorker.enqueue("move_following", %{
index 976ff243ea0b6558009e772ed2acd07aed1538f9..62ad15d85ba929f6cf51522085ceeb4f9483f41b 100644 (file)
@@ -396,7 +396,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
     |> json(err)
   end
 
-  defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do
+  defp handle_user_activity(
+         %User{} = user,
+         %{"type" => "Create", "object" => %{"type" => "Note"}} = params
+       ) do
     object =
       params["object"]
       |> Map.merge(Map.take(params, ["to", "cc"]))
index 1345a3a3e2cf2430e42990f0d8b758f1d7a20266..b0f447e28417a9deb34190084932af70bc8ae9d5 100644 (file)
@@ -10,6 +10,19 @@ defmodule Pleroma.Web.ActivityPub.Builder do
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.ActivityPub.Visibility
 
+  @spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()}
+  def undo(actor, object) do
+    {:ok,
+     %{
+       "id" => Utils.generate_activity_id(),
+       "actor" => actor.ap_id,
+       "type" => "Undo",
+       "object" => object.data["id"],
+       "to" => object.data["to"] || [],
+       "cc" => object.data["cc"] || []
+     }, []}
+  end
+
   @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()}
   def delete(actor, object_id) do
     object = Object.normalize(object_id, false)
index 479f922f51296a5a55e8bf0fb4c8c2937f9320d3..4782cd8f3e3c37b196219c00e948f501e38698a8 100644 (file)
@@ -14,10 +14,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
   alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.Types
+  alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator
 
   @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
   def validate(object, meta)
 
+  def validate(%{"type" => "Undo"} = object, meta) do
+    with {:ok, object} <-
+           object
+           |> UndoValidator.cast_and_validate()
+           |> Ecto.Changeset.apply_action(:insert) do
+      object = stringify_keys(object)
+      {:ok, object, meta}
+    end
+  end
+
   def validate(%{"type" => "Delete"} = object, meta) do
     with cng <- DeleteValidator.cast_and_validate(object),
          do_not_federate <- DeleteValidator.do_not_federate?(cng),
index 4e6ee2034168eb359e455bb1a4438d0d283dea4f..aeef31945dab440ec462952a35cac3be8126bbe6 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
   import Ecto.Changeset
 
+  alias Pleroma.Activity
   alias Pleroma.Object
   alias Pleroma.User
 
@@ -47,7 +48,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
 
     cng
     |> validate_change(field_name, fn field_name, object_id ->
-      object = Object.get_cached_by_ap_id(object_id)
+      object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id)
 
       cond do
         !object ->
diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex
new file mode 100644 (file)
index 0000000..d0ba418
--- /dev/null
@@ -0,0 +1,62 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do
+  use Ecto.Schema
+
+  alias Pleroma.Activity
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types
+
+  import Ecto.Changeset
+  import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
+
+  @primary_key false
+
+  embedded_schema do
+    field(:id, Types.ObjectID, primary_key: true)
+    field(:type, :string)
+    field(:object, Types.ObjectID)
+    field(:actor, Types.ObjectID)
+    field(:to, {:array, :string}, default: [])
+    field(:cc, {:array, :string}, default: [])
+  end
+
+  def cast_and_validate(data) do
+    data
+    |> cast_data()
+    |> validate_data()
+  end
+
+  def cast_data(data) do
+    %__MODULE__{}
+    |> changeset(data)
+  end
+
+  def changeset(struct, data) do
+    struct
+    |> cast(data, __schema__(:fields))
+  end
+
+  def validate_data(data_cng) do
+    data_cng
+    |> validate_inclusion(:type, ["Undo"])
+    |> validate_required([:id, :type, :object, :actor, :to, :cc])
+    |> validate_actor_presence()
+    |> validate_object_presence()
+    |> validate_undo_rights()
+  end
+
+  def validate_undo_rights(cng) do
+    actor = get_field(cng, :actor)
+    object = get_field(cng, :object)
+
+    with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object),
+         true <- object_actor != actor do
+      cng
+      |> add_error(:actor, "not the same as object actor")
+    else
+      _ -> cng
+    end
+  end
+end
index 7b53abeafc61de0f69bfef89b81c14f8265e8aaf..5049cb54ea2804ce21fd11854c366c3f2965f7d5 100644 (file)
@@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   liked object, a `Follow` activity will add the user to the follower
   collection, and so on.
   """
+  alias Pleroma.Activity
   alias Pleroma.Notification
   alias Pleroma.Object
+  alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Utils
@@ -25,6 +27,13 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     {:ok, object, meta}
   end
 
+  def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do
+    with undone_object <- Activity.get_by_ap_id(undone_object),
+         :ok <- handle_undoing(undone_object) do
+      {:ok, object, meta}
+    end
+  end
+
   # Tasks this handles:
   # - Delete and unpins the create activity
   # - Replace object with Tombstone
@@ -72,4 +81,41 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   def handle(object, meta) do
     {:ok, object, meta}
   end
+
+  def handle_undoing(%{data: %{"type" => "Like"}} = object) do
+    with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
+         {:ok, _} <- Utils.remove_like_from_object(object, liked_object),
+         {:ok, _} <- Repo.delete(object) do
+      :ok
+    end
+  end
+
+  def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do
+    with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]),
+         {:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object),
+         {:ok, _} <- Repo.delete(object) do
+      :ok
+    end
+  end
+
+  def handle_undoing(%{data: %{"type" => "Announce"}} = object) do
+    with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
+         {:ok, _} <- Utils.remove_announce_from_object(object, liked_object),
+         {:ok, _} <- Repo.delete(object) do
+      :ok
+    end
+  end
+
+  def handle_undoing(
+        %{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object
+      ) do
+    with %User{} = blocker <- User.get_cached_by_ap_id(blocker),
+         %User{} = blocked <- User.get_cached_by_ap_id(blocked),
+         {:ok, _} <- User.unblock(blocker, blocked),
+         {:ok, _} <- Repo.delete(object) do
+      :ok
+    end
+  end
+
+  def handle_undoing(object), do: {:error, ["don't know how to handle", object]}
 end
index 0e4e7261b1177caebb5b2dc630fbe02ea8a1b8f3..26b5bda0e026f0e4fd71bbec3c911bf2627f2c5d 100644 (file)
@@ -744,25 +744,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
-  def handle_incoming(
-        %{
-          "type" => "Undo",
-          "object" => %{"type" => "Announce", "object" => object_id},
-          "actor" => _actor,
-          "id" => id
-        } = data,
-        _options
-      ) do
-    with actor <- Containment.get_actor(data),
-         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
-         {:ok, object} <- get_obj_helper(object_id),
-         {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
-      {:ok, activity}
-    else
-      _e -> :error
-    end
-  end
-
   def handle_incoming(
         %{
           "type" => "Undo",
@@ -785,39 +766,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
   def handle_incoming(
         %{
           "type" => "Undo",
-          "object" => %{"type" => "EmojiReact", "id" => reaction_activity_id},
-          "actor" => _actor,
-          "id" => id
+          "object" => %{"type" => type}
         } = data,
         _options
-      ) do
-    with actor <- Containment.get_actor(data),
-         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
-         {:ok, activity, _} <-
-           ActivityPub.unreact_with_emoji(actor, reaction_activity_id,
-             activity_id: id,
-             local: false
-           ) do
+      )
+      when type in ["Like", "EmojiReact", "Announce", "Block"] do
+    with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
       {:ok, activity}
-    else
-      _e -> :error
     end
   end
 
+  # For Undos that don't have the complete object attached, try to find it in our database.
   def handle_incoming(
         %{
           "type" => "Undo",
-          "object" => %{"type" => "Block", "object" => blocked},
-          "actor" => blocker,
-          "id" => id
-        } = _data,
-        _options
-      ) do
-    with %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
-         {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
-         {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
-      User.unblock(blocker, blocked)
-      {:ok, activity}
+          "object" => object
+        } = activity,
+        options
+      )
+      when is_binary(object) do
+    with %Activity{data: data} <- Activity.get_by_ap_id(object) do
+      activity
+      |> Map.put("object", data)
+      |> handle_incoming(options)
     else
       _e -> :error
     end
@@ -838,43 +809,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
-  def handle_incoming(
-        %{
-          "type" => "Undo",
-          "object" => %{"type" => "Like", "object" => object_id},
-          "actor" => _actor,
-          "id" => id
-        } = data,
-        _options
-      ) do
-    with actor <- Containment.get_actor(data),
-         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
-         {:ok, object} <- get_obj_helper(object_id),
-         {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
-      {:ok, activity}
-    else
-      _e -> :error
-    end
-  end
-
-  # For Undos that don't have the complete object attached, try to find it in our database.
-  def handle_incoming(
-        %{
-          "type" => "Undo",
-          "object" => object
-        } = activity,
-        options
-      )
-      when is_binary(object) do
-    with %Activity{data: data} <- Activity.get_by_ap_id(object) do
-      activity
-      |> Map.put("object", data)
-      |> handle_incoming(options)
-    else
-      _e -> :error
-    end
-  end
-
   def handle_incoming(
         %{
           "type" => "Move",
index 1a3b0b3c12d03bf52e2afc86521b35830d444971..09b80fa576520823ed75cd84618675622a2cced0 100644 (file)
@@ -562,45 +562,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> maybe_put("id", activity_id)
   end
 
-  @doc """
-  Make unannounce activity data for the given actor and object
-  """
-  def make_unannounce_data(
-        %User{ap_id: ap_id} = user,
-        %Activity{data: %{"context" => context, "object" => object}} = activity,
-        activity_id
-      ) do
-    object = Object.normalize(object)
-
-    %{
-      "type" => "Undo",
-      "actor" => ap_id,
-      "object" => activity.data,
-      "to" => [user.follower_address, object.data["actor"]],
-      "cc" => [Pleroma.Constants.as_public()],
-      "context" => context
-    }
-    |> maybe_put("id", activity_id)
-  end
-
-  def make_unlike_data(
-        %User{ap_id: ap_id} = user,
-        %Activity{data: %{"context" => context, "object" => object}} = activity,
-        activity_id
-      ) do
-    object = Object.normalize(object)
-
-    %{
-      "type" => "Undo",
-      "actor" => ap_id,
-      "object" => activity.data,
-      "to" => [user.follower_address, object.data["actor"]],
-      "cc" => [Pleroma.Constants.as_public()],
-      "context" => context
-    }
-    |> maybe_put("id", activity_id)
-  end
-
   def make_undo_data(
         %User{ap_id: actor, follower_address: follower_address},
         %Activity{
@@ -688,16 +649,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> maybe_put("id", activity_id)
   end
 
-  def make_unblock_data(blocker, blocked, block_activity, activity_id) do
-    %{
-      "type" => "Undo",
-      "actor" => blocker.ap_id,
-      "to" => [blocked.ap_id],
-      "object" => block_activity.data
-    }
-    |> maybe_put("id", activity_id)
-  end
-
   #### Create-related helpers
 
   def make_create_data(params, additional) do
index 80a4ebaac2f4e2d2888e4cfd92774473b57015a1..9f1fd3aeb334f273577a189f460cb55229937096 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   alias Pleroma.Activity
   alias Pleroma.Config
   alias Pleroma.ConfigDB
+  alias Pleroma.MFA
   alias Pleroma.ModerationLog
   alias Pleroma.Plugs.OAuthScopesPlug
   alias Pleroma.ReportNote
@@ -61,6 +62,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
            :right_add,
            :right_add_multiple,
            :right_delete,
+           :disable_mfa,
            :right_delete_multiple,
            :update_user_credentials
          ]
@@ -674,6 +676,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     json_response(conn, :no_content, "")
   end
 
+  @doc "Disable mfa for user's account."
+  def disable_mfa(conn, %{"nickname" => nickname}) do
+    case User.get_by_nickname(nickname) do
+      %User{} = user ->
+        MFA.disable(user)
+        json(conn, nickname)
+
+      _ ->
+        {:error, :not_found}
+    end
+  end
+
   @doc "Show a given user's credentials"
   def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
     with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do
index 470fc0215f63a3a0fce3634a9419068e32eeed61..70069d6f9671307ec3f07e44b5a8523dedd3fa26 100644 (file)
@@ -556,11 +556,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
     }
   end
 
-  defp array_of_accounts do
+  def array_of_accounts do
     %Schema{
       title: "ArrayOfAccounts",
       type: :array,
-      items: Account
+      items: Account,
+      example: [Account.schema().example]
     }
   end
 
diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex
new file mode 100644 (file)
index 0000000..e15c7dc
--- /dev/null
@@ -0,0 +1,76 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.PollOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Polls"],
+      summary: "View a poll",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param()],
+      operationId: "PollController.show",
+      responses: %{
+        200 => Operation.response("Poll", "application/json", Poll),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def vote_operation do
+    %Operation{
+      tags: ["Polls"],
+      summary: "Vote on a poll",
+      parameters: [id_param()],
+      operationId: "PollController.vote",
+      requestBody: vote_request(),
+      security: [%{"oAuth" => ["write:statuses"]}],
+      responses: %{
+        200 => Operation.response("Poll", "application/json", Poll),
+        422 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, FlakeID, "Poll ID",
+      example: "123",
+      required: true
+    )
+  end
+
+  defp vote_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        type: :object,
+        properties: %{
+          choices: %Schema{
+            type: :array,
+            items: %Schema{type: :integer},
+            description: "Array of own votes containing index for each option (starting from 0)"
+          }
+        },
+        required: [:choices]
+      },
+      required: true,
+      example: %{
+        "choices" => [0, 1, 2]
+      }
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex
new file mode 100644 (file)
index 0000000..6ea00a9
--- /dev/null
@@ -0,0 +1,207 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.SearchOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.AccountOperation
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.Status
+  alias Pleroma.Web.ApiSpec.Schemas.Tag
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def account_search_operation do
+    %Operation{
+      tags: ["Search"],
+      summary: "Search for matching accounts by username or display name",
+      operationId: "SearchController.account_search",
+      parameters: [
+        Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for",
+          required: true
+        ),
+        Operation.parameter(
+          :limit,
+          :query,
+          %Schema{type: :integer, default: 40},
+          "Maximum number of results"
+        ),
+        Operation.parameter(
+          :resolve,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Attempt WebFinger lookup. Use this when `q` is an exact address."
+        ),
+        Operation.parameter(
+          :following,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Only include accounts that the user is following"
+        )
+      ],
+      responses: %{
+        200 =>
+          Operation.response(
+            "Array of Account",
+            "application/json",
+            AccountOperation.array_of_accounts()
+          )
+      }
+    }
+  end
+
+  def search_operation do
+    %Operation{
+      tags: ["Search"],
+      summary: "Search results",
+      security: [%{"oAuth" => ["read:search"]}],
+      operationId: "SearchController.search",
+      deprecated: true,
+      parameters: [
+        Operation.parameter(
+          :account_id,
+          :query,
+          FlakeID,
+          "If provided, statuses returned will be authored only by this account"
+        ),
+        Operation.parameter(
+          :type,
+          :query,
+          %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]},
+          "Search type"
+        ),
+        Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true),
+        Operation.parameter(
+          :resolve,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Attempt WebFinger lookup"
+        ),
+        Operation.parameter(
+          :following,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Only include accounts that the user is following"
+        ),
+        Operation.parameter(
+          :offset,
+          :query,
+          %Schema{type: :integer},
+          "Offset"
+        )
+        | pagination_params()
+      ],
+      responses: %{
+        200 => Operation.response("Results", "application/json", results())
+      }
+    }
+  end
+
+  def search2_operation do
+    %Operation{
+      tags: ["Search"],
+      summary: "Search results",
+      security: [%{"oAuth" => ["read:search"]}],
+      operationId: "SearchController.search2",
+      parameters: [
+        Operation.parameter(
+          :account_id,
+          :query,
+          FlakeID,
+          "If provided, statuses returned will be authored only by this account"
+        ),
+        Operation.parameter(
+          :type,
+          :query,
+          %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]},
+          "Search type"
+        ),
+        Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for",
+          required: true
+        ),
+        Operation.parameter(
+          :resolve,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Attempt WebFinger lookup"
+        ),
+        Operation.parameter(
+          :following,
+          :query,
+          %Schema{allOf: [BooleanLike], default: false},
+          "Only include accounts that the user is following"
+        )
+        | pagination_params()
+      ],
+      responses: %{
+        200 => Operation.response("Results", "application/json", results2())
+      }
+    }
+  end
+
+  defp results2 do
+    %Schema{
+      title: "SearchResults",
+      type: :object,
+      properties: %{
+        accounts: %Schema{
+          type: :array,
+          items: Account,
+          description: "Accounts which match the given query"
+        },
+        statuses: %Schema{
+          type: :array,
+          items: Status,
+          description: "Statuses which match the given query"
+        },
+        hashtags: %Schema{
+          type: :array,
+          items: Tag,
+          description: "Hashtags which match the given query"
+        }
+      },
+      example: %{
+        "accounts" => [Account.schema().example],
+        "statuses" => [Status.schema().example],
+        "hashtags" => [Tag.schema().example]
+      }
+    }
+  end
+
+  defp results do
+    %Schema{
+      title: "SearchResults",
+      type: :object,
+      properties: %{
+        accounts: %Schema{
+          type: :array,
+          items: Account,
+          description: "Accounts which match the given query"
+        },
+        statuses: %Schema{
+          type: :array,
+          items: Status,
+          description: "Statuses which match the given query"
+        },
+        hashtags: %Schema{
+          type: :array,
+          items: %Schema{type: :string},
+          description: "Hashtags which match the given query"
+        }
+      },
+      example: %{
+        "accounts" => [Account.schema().example],
+        "statuses" => [Status.schema().example],
+        "hashtags" => ["cofe"]
+      }
+    }
+  end
+end
index 0474b550b8c2962633fb64b70ccc21a8f02c8ed2..c62096db0e302af6b21a2fa8d0f0282cc0b40335 100644 (file)
@@ -11,26 +11,72 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
 
   OpenApiSpex.schema(%{
     title: "Poll",
-    description: "Response schema for account custom fields",
+    description: "Represents a poll attached to a status",
     type: :object,
     properties: %{
       id: FlakeID,
-      expires_at: %Schema{type: :string, format: "date-time"},
-      expired: %Schema{type: :boolean},
-      multiple: %Schema{type: :boolean},
-      votes_count: %Schema{type: :integer},
-      voted: %Schema{type: :boolean},
-      emojis: %Schema{type: :array, items: Emoji},
+      expires_at: %Schema{
+        type: :string,
+        format: :"date-time",
+        nullable: true,
+        description: "When the poll ends"
+      },
+      expired: %Schema{type: :boolean, description: "Is the poll currently expired?"},
+      multiple: %Schema{
+        type: :boolean,
+        description: "Does the poll allow multiple-choice answers?"
+      },
+      votes_count: %Schema{
+        type: :integer,
+        nullable: true,
+        description: "How many votes have been received. Number, or null if `multiple` is false."
+      },
+      voted: %Schema{
+        type: :boolean,
+        nullable: true,
+        description:
+          "When called with a user token, has the authorized user voted? Boolean, or null if no current user."
+      },
+      emojis: %Schema{
+        type: :array,
+        items: Emoji,
+        description: "Custom emoji to be used for rendering poll options."
+      },
       options: %Schema{
         type: :array,
         items: %Schema{
+          title: "PollOption",
           type: :object,
           properties: %{
             title: %Schema{type: :string},
             votes_count: %Schema{type: :integer}
           }
-        }
+        },
+        description: "Possible answers for the poll."
       }
+    },
+    example: %{
+      id: "34830",
+      expires_at: "2019-12-05T04:05:08.302Z",
+      expired: true,
+      multiple: false,
+      votes_count: 10,
+      voters_count: nil,
+      voted: true,
+      own_votes: [
+        1
+      ],
+      options: [
+        %{
+          title: "accept",
+          votes_count: 6
+        },
+        %{
+          title: "deny",
+          votes_count: 4
+        }
+      ],
+      emojis: []
     }
   })
 end
index 7a804461fa9f8a2aaed0b467810ab69fb8a71315..2572c964141da52e14a6a6d5b6b67fc10c287e39 100644 (file)
@@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
   alias Pleroma.Web.ApiSpec.Schemas.Emoji
   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
   alias Pleroma.Web.ApiSpec.Schemas.Poll
+  alias Pleroma.Web.ApiSpec.Schemas.Tag
   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
 
   require OpenApiSpex
@@ -106,16 +107,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
       replies_count: %Schema{type: :integer},
       sensitive: %Schema{type: :boolean},
       spoiler_text: %Schema{type: :string},
-      tags: %Schema{
-        type: :array,
-        items: %Schema{
-          type: :object,
-          properties: %{
-            name: %Schema{type: :string},
-            url: %Schema{type: :string, format: :uri}
-          }
-        }
-      },
+      tags: %Schema{type: :array, items: Tag},
       uri: %Schema{type: :string, format: :uri},
       url: %Schema{type: :string, nullable: true, format: :uri},
       visibility: VisibilityScope
diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex
new file mode 100644 (file)
index 0000000..e693fb8
--- /dev/null
@@ -0,0 +1,27 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Tag",
+    description: "Represents a hashtag used within the content of a status",
+    type: :object,
+    properties: %{
+      name: %Schema{type: :string, description: "The value of the hashtag after the # sign"},
+      url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "A link to the hashtag on the instance"
+      }
+    },
+    example: %{
+      name: "cofe",
+      url: "https://lain.com/tag/cofe"
+    }
+  })
+end
index cb09664ce0316ce7b35a4ceda76a00e6aaf46840..a8f554aa39e8b5f30c2ef5abdaf7f955a404e5a7 100644 (file)
@@ -19,8 +19,8 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do
          {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do
       {:ok, user}
     else
-      error ->
-        {:error, error}
+      {:error, _reason} = error -> error
+      error -> {:error, error}
     end
   end
 
diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex
new file mode 100644 (file)
index 0000000..98aca9a
--- /dev/null
@@ -0,0 +1,45 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.TOTPAuthenticator do
+  alias Comeonin.Pbkdf2
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.User
+
+  @doc "Verify code or check backup code."
+  @spec verify(String.t(), User.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def verify(
+        token,
+        %User{
+          multi_factor_authentication_settings:
+            %{enabled: true, totp: %{secret: secret, confirmed: true}} = _
+        } = _user
+      )
+      when is_binary(token) and byte_size(token) > 0 do
+    TOTP.validate_token(secret, token)
+  end
+
+  def verify(_, _), do: {:error, :invalid_token}
+
+  @spec verify_recovery_code(User.t(), String.t()) ::
+          {:ok, :pass} | {:error, :invalid_token}
+  def verify_recovery_code(
+        %User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user,
+        code
+      )
+      when is_list(codes) and is_binary(code) do
+    hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end)
+
+    if hash_code do
+      MFA.invalidate_backup_code(user, hash_code)
+      {:ok, :pass}
+    else
+      {:error, :invalid_token}
+    end
+  end
+
+  def verify_recovery_code(_, _), do: {:error, :invalid_token}
+end
index 986e8d3f8bb8a6cc8ac6d3ca2f8a8a1553ea200c..1e6bbbab76a145ddc4fdaf4a88cd236fa594878b 100644 (file)
@@ -24,6 +24,14 @@ defmodule Pleroma.Web.CommonAPI do
   require Pleroma.Constants
   require Logger
 
+  def unblock(blocker, blocked) do
+    with %Activity{} = block <- Utils.fetch_latest_block(blocker, blocked),
+         {:ok, unblock_data, _} <- Builder.undo(blocker, block),
+         {:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do
+      {:ok, unblock}
+    end
+  end
+
   def follow(follower, followed) do
     timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout])
 
@@ -107,9 +115,12 @@ defmodule Pleroma.Web.CommonAPI do
 
   def unrepeat(id, user) do
     with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
-           {:find_activity, Activity.get_by_id(id)} do
-      object = Object.normalize(activity)
-      ActivityPub.unannounce(user, object)
+           {:find_activity, Activity.get_by_id(id)},
+         %Object{} = note <- Object.normalize(activity, false),
+         %Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note),
+         {:ok, undo, _} <- Builder.undo(user, announce),
+         {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
+      {:ok, activity}
     else
       {:find_activity, _} -> {:error, :not_found}
       _ -> {:error, dgettext("errors", "Could not unrepeat")}
@@ -166,9 +177,12 @@ defmodule Pleroma.Web.CommonAPI do
 
   def unfavorite(id, user) do
     with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
-           {:find_activity, Activity.get_by_id(id)} do
-      object = Object.normalize(activity)
-      ActivityPub.unlike(user, object)
+           {:find_activity, Activity.get_by_id(id)},
+         %Object{} = note <- Object.normalize(activity, false),
+         %Activity{} = like <- Utils.get_existing_like(user.ap_id, note),
+         {:ok, undo, _} <- Builder.undo(user, like),
+         {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
+      {:ok, activity}
     else
       {:find_activity, _} -> {:error, :not_found}
       _ -> {:error, dgettext("errors", "Could not unfavorite")}
@@ -186,8 +200,10 @@ defmodule Pleroma.Web.CommonAPI do
   end
 
   def unreact_with_emoji(id, user, emoji) do
-    with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji) do
-      ActivityPub.unreact_with_emoji(user, reaction_activity.data["id"])
+    with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji),
+         {:ok, undo, _} <- Builder.undo(user, reaction_activity),
+         {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
+      {:ok, activity}
     else
       _ ->
         {:error, dgettext("errors", "Could not remove reaction emoji")}
index 6540fa5d18ecb770dbde1f2d24e49dde5474f3ea..793f2e7f8e6356e3dab3e9725c505b78fa1e5652 100644 (file)
@@ -402,6 +402,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     end
   end
 
+  @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
   def confirm_current_password(user, password) do
     with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
          true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
index 8458cbdd5f39f5af27cc7eced2da8a928af95ccd..b9ed2d7b27e3ef34160c26503277b46f898e2d66 100644 (file)
@@ -356,8 +356,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
 
   @doc "POST /api/v1/accounts/:id/unblock"
   def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
-    with {:ok, _user_block} <- User.unblock(blocker, blocked),
-         {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do
+    with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
       render(conn, "relationship.json", user: blocker, target: blocked)
     else
       {:error, message} -> json_response(conn, :forbidden, %{error: message})
index af9b66eff17cf1d2f94d0d8f1af7acfc8130f8ac..db46ffcfc1ecbbdb1a83fb836452cb0f6454ccae 100644 (file)
@@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   plug(
     OAuthScopesPlug,
     %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
@@ -22,8 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
 
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
+
   @doc "GET /api/v1/polls/:id"
-  def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do
+  def show(%{assigns: %{user: user}} = conn, %{id: id}) do
     with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
          %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
          true <- Visibility.visible_for_user?(activity, user) do
@@ -35,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
   end
 
   @doc "POST /api/v1/polls/:id/votes"
-  def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
+  def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do
     with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
          %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
          true <- Visibility.visible_for_user?(activity, user),
index cd49da6ad5e236ddfb9ce072e55328e2f6423d83..0e0d54ba41891a9cfe400e2afdcf9c2f35ead2eb 100644 (file)
@@ -5,7 +5,7 @@
 defmodule Pleroma.Web.MastodonAPI.SearchController do
   use Pleroma.Web, :controller
 
-  import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 2, skip_relationships?: 1]
+  import Pleroma.Web.ControllerHelper, only: [skip_relationships?: 1]
 
   alias Pleroma.Activity
   alias Pleroma.Plugs.OAuthScopesPlug
@@ -18,6 +18,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
 
   require Logger
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
   plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
 
@@ -25,7 +27,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
 
   plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
 
-  def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
+
+  def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
     accounts = User.search(query, search_options(params, user))
 
     conn
@@ -36,7 +40,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
   def search2(conn, params), do: do_search(:v2, conn, params)
   def search(conn, params), do: do_search(:v1, conn, params)
 
-  defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = params) do
+  defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
     options = search_options(params, user)
     timeout = Keyword.get(Repo.config(), :timeout, 15_000)
     default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
@@ -44,7 +48,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
     result =
       default_values
       |> Enum.map(fn {resource, default_value} ->
-        if params["type"] in [nil, resource] do
+        if params[:type] in [nil, resource] do
           {resource, fn -> resource_search(version, resource, query, options) end}
         else
           {resource, fn -> default_value end}
@@ -68,11 +72,11 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
   defp search_options(params, user) do
     [
       skip_relationships: skip_relationships?(params),
-      resolve: params["resolve"] == "true",
-      following: params["following"] == "true",
-      limit: fetch_integer_param(params, "limit"),
-      offset: fetch_integer_param(params, "offset"),
-      type: params["type"],
+      resolve: params[:resolve],
+      following: params[:following],
+      limit: params[:limit],
+      offset: params[:offset],
+      type: params[:type],
       author: get_author(params),
       for_user: user
     ]
@@ -135,7 +139,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
     end
   end
 
-  defp get_author(%{"account_id" => account_id}) when is_binary(account_id),
+  defp get_author(%{account_id: account_id}) when is_binary(account_id),
     do: User.get_cached_by_id(account_id)
 
   defp get_author(_params), do: nil
index 9eea2e9eb1b4fd65cec5ceb5787d4317ca00f800..12e3ba15e22a1755a7a4428e41d66f4cb05c22a0 100644 (file)
@@ -206,9 +206,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   end
 
   @doc "POST /api/v1/statuses/:id/unreblog"
-  def unreblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
-    with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user),
-         %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do
+  def unreblog(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
+    with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user),
+         %Activity{} = activity <- Activity.get_by_id(activity_id) do
       try_render(conn, "show.json", %{activity: activity, for: user, as: :activity})
     end
   end
@@ -222,9 +222,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   end
 
   @doc "POST /api/v1/statuses/:id/unfavourite"
-  def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
-    with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user),
-         %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do
+  def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
+    with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user),
+         %Activity{} = activity <- Activity.get_by_id(activity_id) do
       try_render(conn, "show.json", activity: activity, for: user, as: :activity)
     end
   end
index 5652a37c19f08c5c1431e79c46e66d63d58e9ebc..e2ffd02d0a606081d2f2269a921d41b2a113af3e 100644 (file)
@@ -12,6 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
 
   @behaviour :cowboy_websocket
 
+  # Cowboy timeout period.
+  @timeout :timer.seconds(30)
+  # Hibernate every X messages
+  @hibernate_every 100
+
   @streams [
     "public",
     "public:local",
@@ -25,9 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   ]
   @anonymous_streams ["public", "public:local", "hashtag"]
 
-  # Handled by periodic keepalive in Pleroma.Web.Streamer.Ping.
-  @timeout :infinity
-
   def init(%{qs: qs} = req, state) do
     with params <- :cow_qs.parse_qs(qs),
          sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil),
@@ -42,7 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
           req
         end
 
-      {:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}}
+      {:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}}
     else
       {:error, code} ->
         Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}")
@@ -57,7 +59,13 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   end
 
   def websocket_init(state) do
-    send(self(), :subscribe)
+    Logger.debug(
+      "#{__MODULE__} accepted websocket connection for user #{
+        (state.user || %{id: "anonymous"}).id
+      }, topic #{state.topic}"
+    )
+
+    Streamer.add_socket(state.topic, state.user)
     {:ok, state}
   end
 
@@ -66,19 +74,24 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
     {:ok, state}
   end
 
-  def websocket_info(:subscribe, state) do
-    Logger.debug(
-      "#{__MODULE__} accepted websocket connection for user #{
-        (state.user || %{id: "anonymous"}).id
-      }, topic #{state.topic}"
-    )
+  def websocket_info({:render_with_user, view, template, item}, state) do
+    user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
 
-    Streamer.add_socket(state.topic, streamer_socket(state))
-    {:ok, state}
+    unless Streamer.filtered_by_user?(user, item) do
+      websocket_info({:text, view.render(template, item, user)}, %{state | user: user})
+    else
+      {:ok, state}
+    end
   end
 
   def websocket_info({:text, message}, state) do
-    {:reply, {:text, message}, state}
+    # If the websocket processed X messages, force an hibernate/GC.
+    # We don't hibernate at every message to balance CPU usage/latency with RAM usage.
+    if state.count > @hibernate_every do
+      {:reply, {:text, message}, %{state | count: 0}, :hibernate}
+    else
+      {:reply, {:text, message}, %{state | count: state.count + 1}}
+    end
   end
 
   def terminate(reason, _req, state) do
@@ -88,7 +101,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
       }, topic #{state.topic || "?"}: #{inspect(reason)}"
     )
 
-    Streamer.remove_socket(state.topic, streamer_socket(state))
+    Streamer.remove_socket(state.topic)
     :ok
   end
 
@@ -136,8 +149,4 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   end
 
   defp expand_topic(topic, _), do: topic
-
-  defp streamer_socket(state) do
-    %{transport_pid: self(), assigns: state}
-  end
 end
diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex
new file mode 100644 (file)
index 0000000..e52cccd
--- /dev/null
@@ -0,0 +1,97 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAController do
+  @moduledoc """
+  The model represents api to use Multi Factor authentications.
+  """
+
+  use Pleroma.Web, :controller
+
+  alias Pleroma.MFA
+  alias Pleroma.Web.Auth.TOTPAuthenticator
+  alias Pleroma.Web.OAuth.MFAView, as: View
+  alias Pleroma.Web.OAuth.OAuthController
+  alias Pleroma.Web.OAuth.Token
+
+  plug(:fetch_session when action in [:show, :verify])
+  plug(:fetch_flash when action in [:show, :verify])
+
+  @doc """
+  Display form to input mfa code or recovery code.
+  """
+  def show(conn, %{"mfa_token" => mfa_token} = params) do
+    template = Map.get(params, "challenge_type", "totp")
+
+    conn
+    |> put_view(View)
+    |> render("#{template}.html", %{
+      mfa_token: mfa_token,
+      redirect_uri: params["redirect_uri"],
+      state: params["state"]
+    })
+  end
+
+  @doc """
+  Verification code and continue authorization.
+  """
+  def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do
+    with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
+         {:ok, _} <- validates_challenge(user, mfa_params) do
+      conn
+      |> OAuthController.after_create_authorization(auth, %{
+        "authorization" => %{
+          "redirect_uri" => mfa_params["redirect_uri"],
+          "state" => mfa_params["state"]
+        }
+      })
+    else
+      _ ->
+        conn
+        |> put_flash(:error, "Two-factor authentication failed.")
+        |> put_status(:unauthorized)
+        |> show(mfa_params)
+    end
+  end
+
+  @doc """
+  Verification second step of MFA (or recovery) and returns access token.
+
+  ## Endpoint
+  POST /oauth/mfa/challenge
+
+  params:
+  `client_id`
+  `client_secret`
+  `mfa_token` - access token to check second step of mfa
+  `challenge_type` - 'totp' or 'recovery'
+  `code`
+
+  """
+  def challenge(conn, %{"mfa_token" => mfa_token} = params) do
+    with {:ok, app} <- Token.Utils.fetch_app(conn),
+         {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
+         {:ok, _} <- validates_challenge(user, params),
+         {:ok, token} <- Token.exchange_token(app, auth) do
+      json(conn, Token.Response.build(user, token))
+    else
+      _error ->
+        conn
+        |> put_status(400)
+        |> json(%{error: "Invalid code"})
+    end
+  end
+
+  # Verify TOTP Code
+  defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do
+    TOTPAuthenticator.verify(code, user)
+  end
+
+  # Verify Recovery Code
+  defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do
+    TOTPAuthenticator.verify_recovery_code(user, code)
+  end
+
+  defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type}
+end
diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex
new file mode 100644 (file)
index 0000000..e88e706
--- /dev/null
@@ -0,0 +1,8 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAView do
+  use Pleroma.Web, :view
+  import Phoenix.HTML.Form
+end
index 685269877f8e2c90246cfa711e7adb827867b673..7c804233c4460249b83b4d60ded37210b5fc42e8 100644 (file)
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   use Pleroma.Web, :controller
 
   alias Pleroma.Helpers.UriHelper
+  alias Pleroma.MFA
   alias Pleroma.Plugs.RateLimiter
   alias Pleroma.Registration
   alias Pleroma.Repo
@@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   alias Pleroma.Web.ControllerHelper
   alias Pleroma.Web.OAuth.App
   alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.MFAController
   alias Pleroma.Web.OAuth.Scopes
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
@@ -121,7 +123,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
         %{"authorization" => _} = params,
         opts \\ []
       ) do
-    with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
+    with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
+         {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
       after_create_authorization(conn, auth, params)
     else
       error ->
@@ -179,6 +182,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     |> authorize(params)
   end
 
+  defp handle_create_authorization_error(
+         %Plug.Conn{} = conn,
+         {:mfa_required, user, auth, _},
+         params
+       ) do
+    {:ok, token} = MFA.Token.create_token(user, auth)
+
+    data = %{
+      "mfa_token" => token.token,
+      "redirect_uri" => params["authorization"]["redirect_uri"],
+      "state" => params["authorization"]["state"]
+    }
+
+    MFAController.show(conn, data)
+  end
+
   defp handle_create_authorization_error(
          %Plug.Conn{} = conn,
          {:account_status, :password_reset_pending},
@@ -231,7 +250,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
 
       json(conn, Token.Response.build(user, token, response_attrs))
     else
-      _error -> render_invalid_credentials_error(conn)
+      error ->
+        handle_token_exchange_error(conn, error)
     end
   end
 
@@ -244,6 +264,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:account_status, :active} <- {:account_status, User.account_status(user)},
          {:ok, scopes} <- validate_scopes(app, params),
          {:ok, auth} <- Authorization.create_authorization(app, user, scopes),
+         {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
          {:ok, token} <- Token.exchange_token(app, auth) do
       json(conn, Token.Response.build(user, token))
     else
@@ -270,13 +291,20 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:ok, token} <- Token.exchange_token(app, auth) do
       json(conn, Token.Response.build_for_client_credentials(token))
     else
-      _error -> render_invalid_credentials_error(conn)
+      _error ->
+        handle_token_exchange_error(conn, :invalid_credentails)
     end
   end
 
   # Bad request
   def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
 
+  defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
+    conn
+    |> put_status(:forbidden)
+    |> json(build_and_response_mfa_token(user, auth))
+  end
+
   defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
     render_error(
       conn,
@@ -434,7 +462,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
     with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
          %Registration{} = registration <- Repo.get(Registration, registration_id),
-         {_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)},
+         {_, {:ok, auth, _user}} <-
+           {:create_authorization, do_create_authorization(conn, params)},
          %User{} = user <- Repo.preload(auth, :user).user,
          {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
       conn
@@ -500,8 +529,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          %App{} = app <- Repo.get_by(App, client_id: client_id),
          true <- redirect_uri in String.split(app.redirect_uris),
          {:ok, scopes} <- validate_scopes(app, auth_attrs),
-         {:account_status, :active} <- {:account_status, User.account_status(user)} do
-      Authorization.create_authorization(app, user, scopes)
+         {:account_status, :active} <- {:account_status, User.account_status(user)},
+         {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
+      {:ok, auth, user}
     end
   end
 
@@ -515,6 +545,12 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
     do: put_session(conn, :registration_id, registration_id)
 
+  defp build_and_response_mfa_token(user, auth) do
+    with {:ok, token} <- MFA.Token.create_token(user, auth) do
+      Token.Response.build_for_mfa_token(user, token)
+    end
+  end
+
   @spec validate_scopes(App.t(), map()) ::
           {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
   defp validate_scopes(%App{} = app, params) do
diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex
new file mode 100644 (file)
index 0000000..2c3bb9d
--- /dev/null
@@ -0,0 +1,38 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.Token.CleanWorker do
+  @moduledoc """
+  The module represents functions to clean an expired OAuth and MFA tokens.
+  """
+  use GenServer
+
+  @ten_seconds 10_000
+  @one_day 86_400_000
+
+  alias Pleroma.MFA
+  alias Pleroma.Web.OAuth
+  alias Pleroma.Workers.BackgroundWorker
+
+  def start_link(_), do: GenServer.start_link(__MODULE__, %{})
+
+  def init(_) do
+    Process.send_after(self(), :perform, @ten_seconds)
+    {:ok, nil}
+  end
+
+  @doc false
+  def handle_info(:perform, state) do
+    BackgroundWorker.enqueue("clean_expired_tokens", %{})
+    interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day)
+
+    Process.send_after(self(), :perform, interval)
+    {:noreply, state}
+  end
+
+  def perform(:clean) do
+    OAuth.Token.delete_expired_tokens()
+    MFA.Token.delete_expired_tokens()
+  end
+end
index 6f4713deefcdbe3c5f5a80bb680d452d24d6a910..0e72c31e90ff0bf16d6a71ef9ca75d6408f68952 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.OAuth.Token.Response do
   @moduledoc false
 
+  alias Pleroma.MFA
   alias Pleroma.User
   alias Pleroma.Web.OAuth.Token.Utils
 
@@ -32,5 +33,13 @@ defmodule Pleroma.Web.OAuth.Token.Response do
     }
   end
 
+  def build_for_mfa_token(user, mfa_token) do
+    %{
+      error: "mfa_required",
+      mfa_token: mfa_token.token,
+      supported_challenge_types: MFA.supported_methods(user)
+    }
+  end
+
   defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
 end
index 1bdb3aa4dcc531c00a24164f388af579a34a5411..4aa5c1dd866c0906414eedbd8bb8a06510571d34 100644 (file)
@@ -98,7 +98,8 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
         "id" => activity_id,
         "emoji" => emoji
       }) do
-    with {:ok, _activity, _object} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji),
+    with {:ok, _activity} <-
+           CommonAPI.unreact_with_emoji(activity_id, user, emoji),
          activity <- Activity.get_by_id(activity_id) do
       conn
       |> put_view(StatusView)
diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex
new file mode 100644 (file)
index 0000000..eb9989c
--- /dev/null
@@ -0,0 +1,133 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do
+  @moduledoc "The module represents actions to manage MFA"
+  use Pleroma.Web, :controller
+
+  import Pleroma.Web.ControllerHelper, only: [json_response: 3]
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Plugs.OAuthScopesPlug
+  alias Pleroma.Web.CommonAPI.Utils
+
+  plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings])
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes]
+  )
+
+  @doc """
+  Gets user multi factor authentication settings
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa
+
+  """
+  def settings(%{assigns: %{user: user}} = conn, _params) do
+    json(conn, %{settings: MFA.mfa_settings(user)})
+  end
+
+  @doc """
+  Prepare setup mfa method
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa/setup/[:method]
+
+  """
+  def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do
+    with {:ok, user} <- MFA.setup_totp(user),
+         %{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do
+      provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}")
+
+      json(conn, %{provisioning_uri: provisioning_uri, key: secret})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def setup(conn, _params) do
+    json_response(conn, :bad_request, %{error: "undefined method"})
+  end
+
+  @doc """
+  Confirms setup and enable mfa method
+
+  ## Endpoint
+  POST /api/pleroma/accounts/mfa/confirm/:method
+
+  - params:
+  `code` - confirmation code
+  `password` - current password
+  """
+  def confirm(
+        %{assigns: %{user: user}} = conn,
+        %{"method" => "totp", "password" => _, "code" => _} = params
+      ) do
+    with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.confirm_totp(user, params) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def confirm(conn, _) do
+    json_response(conn, :bad_request, %{error: "undefined mfa method"})
+  end
+
+  @doc """
+  Disable mfa method and disable mfa if need.
+  """
+  def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do
+    with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.disable_totp(user) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do
+    with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.disable(user) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def disable(conn, _) do
+    json_response(conn, :bad_request, %{error: "undefined mfa method"})
+  end
+
+  @doc """
+  Generates backup codes.
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa/backup_codes
+
+  ## Response
+  ### Success
+  `{codes: [codes]}`
+
+  ### Error
+  `{error: [error_message]}`
+
+  """
+  def backup_codes(%{assigns: %{user: user}} = conn, _params) do
+    with {:ok, codes} <- MFA.generate_backup_codes(user) do
+      json(conn, %{codes: codes})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+end
index 281516bb8bae79c5df4205ee2d91b61e4c9e3026..7a171f9fbf6462091e132aafe8fa4ca2dd76803e 100644 (file)
@@ -132,6 +132,7 @@ defmodule Pleroma.Web.Router do
     post("/users/follow", AdminAPIController, :user_follow)
     post("/users/unfollow", AdminAPIController, :user_unfollow)
 
+    put("/users/disable_mfa", AdminAPIController, :disable_mfa)
     delete("/users", AdminAPIController, :user_delete)
     post("/users", AdminAPIController, :users_create)
     patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
@@ -258,6 +259,16 @@ defmodule Pleroma.Web.Router do
     post("/follow_import", UtilController, :follow_import)
   end
 
+  scope "/api/pleroma", Pleroma.Web.PleromaAPI do
+    pipe_through(:authenticated_api)
+
+    get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
+    get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
+    get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
+    post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
+    delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
+  end
+
   scope "/oauth", Pleroma.Web.OAuth do
     scope [] do
       pipe_through(:oauth)
@@ -269,6 +280,10 @@ defmodule Pleroma.Web.Router do
     post("/revoke", OAuthController, :token_revoke)
     get("/registration_details", OAuthController, :registration_details)
 
+    post("/mfa/challenge", MFAController, :challenge)
+    post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
+    get("/mfa", MFAController, :show)
+
     scope [] do
       pipe_through(:browser)
 
diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex
deleted file mode 100644 (file)
index 7a08202..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Ping do
-  use GenServer
-  require Logger
-
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @keepalive_interval :timer.seconds(30)
-
-  def start_link(opts) do
-    ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval)
-    GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__)
-  end
-
-  def init(%{ping_interval: ping_interval} = args) do
-    Process.send_after(self(), :ping, ping_interval)
-    {:ok, args}
-  end
-
-  def handle_info(:ping, %{ping_interval: ping_interval} = state) do
-    State.get_sockets()
-    |> Map.values()
-    |> List.flatten()
-    |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} ->
-      Logger.debug("Sending keepalive ping")
-      send(transport_pid, {:text, ""})
-    end)
-
-    Process.send_after(self(), :ping, ping_interval)
-
-    {:noreply, state}
-  end
-end
diff --git a/lib/pleroma/web/streamer/state.ex b/lib/pleroma/web/streamer/state.ex
deleted file mode 100644 (file)
index 999550b..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.State do
-  use GenServer
-  require Logger
-
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @env Mix.env()
-
-  def start_link(_) do
-    GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__)
-  end
-
-  def add_socket(topic, socket) do
-    GenServer.call(__MODULE__, {:add, topic, socket})
-  end
-
-  def remove_socket(topic, socket) do
-    do_remove_socket(@env, topic, socket)
-  end
-
-  def get_sockets do
-    %{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state)
-    stream_sockets
-  end
-
-  def init(init_arg) do
-    {:ok, init_arg}
-  end
-
-  def handle_call(:get_state, _from, state) do
-    {:reply, state, state}
-  end
-
-  def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do
-    internal_topic = internal_topic(topic, socket)
-    stream_socket = StreamerSocket.from_socket(socket)
-
-    sockets_for_topic =
-      sockets
-      |> Map.get(internal_topic, [])
-      |> List.insert_at(0, stream_socket)
-      |> Enum.uniq()
-
-    state = put_in(state, [:sockets, internal_topic], sockets_for_topic)
-    Logger.debug("Got new conn for #{topic}")
-    {:reply, state, state}
-  end
-
-  def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do
-    internal_topic = internal_topic(topic, socket)
-    stream_socket = StreamerSocket.from_socket(socket)
-
-    sockets_for_topic =
-      sockets
-      |> Map.get(internal_topic, [])
-      |> List.delete(stream_socket)
-
-    state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic)
-    {:reply, state, state}
-  end
-
-  defp do_remove_socket(:test, _, _) do
-    :ok
-  end
-
-  defp do_remove_socket(_env, topic, socket) do
-    GenServer.call(__MODULE__, {:remove, topic, socket})
-  end
-
-  defp internal_topic(topic, socket)
-       when topic in ~w[user user:notification direct] do
-    "#{topic}:#{socket.assigns[:user].id}"
-  end
-
-  defp internal_topic(topic, _) do
-    topic
-  end
-end
index 814d5a7292519e881b4d81cc28139e02211fce8b..5ad4aa9367a8c588f6b3c1f4440f48375967b4c6 100644 (file)
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.Streamer do
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.Worker
+  require Logger
+
+  alias Pleroma.Activity
+  alias Pleroma.Config
+  alias Pleroma.Conversation.Participation
+  alias Pleroma.Notification
+  alias Pleroma.Object
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Visibility
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.StreamerView
 
-  @timeout 60_000
   @mix_env Mix.env()
+  @registry Pleroma.Web.StreamerRegistry
+
+  def registry, do: @registry
 
-  def add_socket(topic, socket) do
-    State.add_socket(topic, socket)
+  def add_socket(topic, %User{} = user) do
+    if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true)
   end
 
-  def remove_socket(topic, socket) do
-    State.remove_socket(topic, socket)
+  def add_socket(topic, _) do
+    if should_env_send?(), do: Registry.register(@registry, topic, false)
   end
 
-  def get_sockets do
-    State.get_sockets()
+  def remove_socket(topic) do
+    if should_env_send?(), do: Registry.unregister(@registry, topic)
   end
 
-  def stream(topics, items) do
-    if should_send?() do
-      Task.async(fn ->
-        :poolboy.transaction(
-          :streamer_worker,
-          &Worker.stream(&1, topics, items),
-          @timeout
-        )
+  def stream(topics, item) when is_list(topics) do
+    if should_env_send?() do
+      Enum.each(topics, fn t ->
+        spawn(fn -> do_stream(t, item) end)
       end)
     end
+
+    :ok
   end
 
-  def supervisor, do: Pleroma.Web.Streamer.Supervisor
+  def stream(topic, items) when is_list(items) do
+    if should_env_send?() do
+      Enum.each(items, fn i ->
+        spawn(fn -> do_stream(topic, i) end)
+      end)
 
-  defp should_send? do
-    handle_should_send(@mix_env)
+      :ok
+    end
   end
 
-  defp handle_should_send(:test) do
-    case Process.whereis(:streamer_worker) do
-      nil ->
-        false
+  def stream(topic, item) do
+    if should_env_send?() do
+      spawn(fn -> do_stream(topic, item) end)
+    end
+
+    :ok
+  end
 
-      pid ->
-        Process.alive?(pid)
+  def filtered_by_user?(%User{} = user, %Activity{} = item) do
+    %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
+      User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
+
+    recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
+    recipients = MapSet.new(item.recipients)
+    domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
+
+    with parent <- Object.normalize(item) || item,
+         true <-
+           Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
+         true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
+         true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
+         true <- MapSet.disjoint?(recipients, recipient_blocks),
+         %{host: item_host} <- URI.parse(item.actor),
+         %{host: parent_host} <- URI.parse(parent.data["actor"]),
+         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
+         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
+         true <- thread_containment(item, user),
+         false <- CommonAPI.thread_muted?(user, item) do
+      false
+    else
+      _ -> true
     end
   end
 
-  defp handle_should_send(:benchmark), do: false
+  def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do
+    filtered_by_user?(user, activity)
+  end
+
+  defp do_stream("direct", item) do
+    recipient_topics =
+      User.get_recipients_from_activity(item)
+      |> Enum.map(fn %{id: id} -> "direct:#{id}" end)
+
+    Enum.each(recipient_topics, fn user_topic ->
+      Logger.debug("Trying to push direct message to #{user_topic}\n\n")
+      push_to_socket(user_topic, item)
+    end)
+  end
+
+  defp do_stream("participation", participation) do
+    user_topic = "direct:#{participation.user_id}"
+    Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
 
-  defp handle_should_send(_), do: true
+    push_to_socket(user_topic, participation)
+  end
+
+  defp do_stream("list", item) do
+    # filter the recipient list if the activity is not public, see #270.
+    recipient_lists =
+      case Visibility.is_public?(item) do
+        true ->
+          Pleroma.List.get_lists_from_activity(item)
+
+        _ ->
+          Pleroma.List.get_lists_from_activity(item)
+          |> Enum.filter(fn list ->
+            owner = User.get_cached_by_id(list.user_id)
+
+            Visibility.visible_for_user?(item, owner)
+          end)
+      end
+
+    recipient_topics =
+      recipient_lists
+      |> Enum.map(fn %{id: id} -> "list:#{id}" end)
+
+    Enum.each(recipient_topics, fn list_topic ->
+      Logger.debug("Trying to push message to #{list_topic}\n\n")
+      push_to_socket(list_topic, item)
+    end)
+  end
+
+  defp do_stream(topic, %Notification{} = item)
+       when topic in ["user", "user:notification"] do
+    Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
+      Enum.each(list, fn {pid, _auth} ->
+        send(pid, {:render_with_user, StreamerView, "notification.json", item})
+      end)
+    end)
+  end
+
+  defp do_stream("user", item) do
+    Logger.debug("Trying to push to users")
+
+    recipient_topics =
+      User.get_recipients_from_activity(item)
+      |> Enum.map(fn %{id: id} -> "user:#{id}" end)
+
+    Enum.each(recipient_topics, fn topic ->
+      push_to_socket(topic, item)
+    end)
+  end
+
+  defp do_stream(topic, item) do
+    Logger.debug("Trying to push to #{topic}")
+    Logger.debug("Pushing item to #{topic}")
+    push_to_socket(topic, item)
+  end
+
+  defp push_to_socket(topic, %Participation{} = participation) do
+    rendered = StreamerView.render("conversation.json", participation)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, _} ->
+        send(pid, {:text, rendered})
+      end)
+    end)
+  end
+
+  defp push_to_socket(topic, %Activity{
+         data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
+       }) do
+    rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)})
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, _} ->
+        send(pid, {:text, rendered})
+      end)
+    end)
+  end
+
+  defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
+
+  defp push_to_socket(topic, item) do
+    anon_render = StreamerView.render("update.json", item)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, auth?} ->
+        if auth? do
+          send(pid, {:render_with_user, StreamerView, "update.json", item})
+        else
+          send(pid, {:text, anon_render})
+        end
+      end)
+    end)
+  end
+
+  defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
+
+  defp thread_containment(activity, user) do
+    if Config.get([:instance, :skip_thread_containment]) do
+      true
+    else
+      ActivityPub.contain_activity(activity, user)
+    end
+  end
+
+  # In test environement, only return true if the registry is started.
+  # In benchmark environment, returns false.
+  # In any other environment, always returns true.
+  cond do
+    @mix_env == :test ->
+      def should_env_send? do
+        case Process.whereis(@registry) do
+          nil ->
+            false
+
+          pid ->
+            Process.alive?(pid)
+        end
+      end
+
+    @mix_env == :benchmark ->
+      def should_env_send?, do: false
+
+    true ->
+      def should_env_send?, do: true
+  end
+
+  defp user_topic(topic, user)
+       when topic in ~w[user user:notification direct] do
+    "#{topic}:#{user.id}"
+  end
+
+  defp user_topic(topic, _) do
+    topic
+  end
 end
diff --git a/lib/pleroma/web/streamer/streamer_socket.ex b/lib/pleroma/web/streamer/streamer_socket.ex
deleted file mode 100644 (file)
index 7d5dcd3..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.StreamerSocket do
-  defstruct transport_pid: nil, user: nil
-
-  alias Pleroma.User
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  def from_socket(%{
-        transport_pid: transport_pid,
-        assigns: %{user: nil}
-      }) do
-    %StreamerSocket{
-      transport_pid: transport_pid
-    }
-  end
-
-  def from_socket(%{
-        transport_pid: transport_pid,
-        assigns: %{user: %User{} = user}
-      }) do
-    %StreamerSocket{
-      transport_pid: transport_pid,
-      user: user
-    }
-  end
-
-  def from_socket(%{transport_pid: transport_pid}) do
-    %StreamerSocket{
-      transport_pid: transport_pid
-    }
-  end
-end
diff --git a/lib/pleroma/web/streamer/supervisor.ex b/lib/pleroma/web/streamer/supervisor.ex
deleted file mode 100644 (file)
index bd9029b..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Supervisor do
-  use Supervisor
-
-  def start_link(opts) do
-    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
-  end
-
-  def init(args) do
-    children = [
-      {Pleroma.Web.Streamer.State, args},
-      {Pleroma.Web.Streamer.Ping, args},
-      :poolboy.child_spec(:streamer_worker, poolboy_config())
-    ]
-
-    opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor]
-    Supervisor.init(children, opts)
-  end
-
-  defp poolboy_config do
-    opts =
-      Pleroma.Config.get(:streamer,
-        workers: 3,
-        overflow_workers: 2
-      )
-
-    [
-      {:name, {:local, :streamer_worker}},
-      {:worker_module, Pleroma.Web.Streamer.Worker},
-      {:size, opts[:workers]},
-      {:max_overflow, opts[:overflow_workers]}
-    ]
-  end
-end
diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex
deleted file mode 100644 (file)
index f6160fa..0000000
+++ /dev/null
@@ -1,208 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Worker do
-  use GenServer
-
-  require Logger
-
-  alias Pleroma.Activity
-  alias Pleroma.Config
-  alias Pleroma.Conversation.Participation
-  alias Pleroma.Notification
-  alias Pleroma.Object
-  alias Pleroma.User
-  alias Pleroma.Web.ActivityPub.ActivityPub
-  alias Pleroma.Web.ActivityPub.Visibility
-  alias Pleroma.Web.CommonAPI
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.StreamerSocket
-  alias Pleroma.Web.StreamerView
-
-  def start_link(_) do
-    GenServer.start_link(__MODULE__, %{}, [])
-  end
-
-  def init(init_arg) do
-    {:ok, init_arg}
-  end
-
-  def stream(pid, topics, items) do
-    GenServer.call(pid, {:stream, topics, items})
-  end
-
-  def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do
-    Enum.each(topics, fn t ->
-      do_stream(%{topic: t, item: item})
-    end)
-
-    {:reply, state, state}
-  end
-
-  def handle_call({:stream, topic, items}, _from, state) when is_list(items) do
-    Enum.each(items, fn i ->
-      do_stream(%{topic: topic, item: i})
-    end)
-
-    {:reply, state, state}
-  end
-
-  def handle_call({:stream, topic, item}, _from, state) do
-    do_stream(%{topic: topic, item: item})
-
-    {:reply, state, state}
-  end
-
-  defp do_stream(%{topic: "direct", item: item}) do
-    recipient_topics =
-      User.get_recipients_from_activity(item)
-      |> Enum.map(fn %{id: id} -> "direct:#{id}" end)
-
-    Enum.each(recipient_topics, fn user_topic ->
-      Logger.debug("Trying to push direct message to #{user_topic}\n\n")
-      push_to_socket(State.get_sockets(), user_topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: "participation", item: participation}) do
-    user_topic = "direct:#{participation.user_id}"
-    Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
-
-    push_to_socket(State.get_sockets(), user_topic, participation)
-  end
-
-  defp do_stream(%{topic: "list", item: item}) do
-    # filter the recipient list if the activity is not public, see #270.
-    recipient_lists =
-      case Visibility.is_public?(item) do
-        true ->
-          Pleroma.List.get_lists_from_activity(item)
-
-        _ ->
-          Pleroma.List.get_lists_from_activity(item)
-          |> Enum.filter(fn list ->
-            owner = User.get_cached_by_id(list.user_id)
-
-            Visibility.visible_for_user?(item, owner)
-          end)
-      end
-
-    recipient_topics =
-      recipient_lists
-      |> Enum.map(fn %{id: id} -> "list:#{id}" end)
-
-    Enum.each(recipient_topics, fn list_topic ->
-      Logger.debug("Trying to push message to #{list_topic}\n\n")
-      push_to_socket(State.get_sockets(), list_topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: topic, item: %Notification{} = item})
-       when topic in ["user", "user:notification"] do
-    State.get_sockets()
-    |> Map.get("#{topic}:#{item.user_id}", [])
-    |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} ->
-      with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id),
-           true <- should_send?(user, item) do
-        send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)})
-      end
-    end)
-  end
-
-  defp do_stream(%{topic: "user", item: item}) do
-    Logger.debug("Trying to push to users")
-
-    recipient_topics =
-      User.get_recipients_from_activity(item)
-      |> Enum.map(fn %{id: id} -> "user:#{id}" end)
-
-    Enum.each(recipient_topics, fn topic ->
-      push_to_socket(State.get_sockets(), topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: topic, item: item}) do
-    Logger.debug("Trying to push to #{topic}")
-    Logger.debug("Pushing item to #{topic}")
-    push_to_socket(State.get_sockets(), topic, item)
-  end
-
-  defp should_send?(%User{} = user, %Activity{} = item) do
-    %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
-      User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
-
-    recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
-    recipients = MapSet.new(item.recipients)
-    domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
-
-    with parent <- Object.normalize(item) || item,
-         true <-
-           Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
-         true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
-         true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
-         true <- MapSet.disjoint?(recipients, recipient_blocks),
-         %{host: item_host} <- URI.parse(item.actor),
-         %{host: parent_host} <- URI.parse(parent.data["actor"]),
-         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
-         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
-         true <- thread_containment(item, user),
-         false <- CommonAPI.thread_muted?(user, item) do
-      true
-    else
-      _ -> false
-    end
-  end
-
-  defp should_send?(%User{} = user, %Notification{activity: activity}) do
-    should_send?(user, activity)
-  end
-
-  def push_to_socket(topics, topic, %Participation{} = participation) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
-      send(transport_pid, {:text, StreamerView.render("conversation.json", participation)})
-    end)
-  end
-
-  def push_to_socket(topics, topic, %Activity{
-        data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
-      }) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
-      send(
-        transport_pid,
-        {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()}
-      )
-    end)
-  end
-
-  def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
-
-  def push_to_socket(topics, topic, item) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{
-                                        transport_pid: transport_pid,
-                                        user: socket_user
-                                      } ->
-      # Get the current user so we have up-to-date blocks etc.
-      if socket_user do
-        user = User.get_cached_by_ap_id(socket_user.ap_id)
-
-        if should_send?(user, item) do
-          send(transport_pid, {:text, StreamerView.render("update.json", item, user)})
-        end
-      else
-        send(transport_pid, {:text, StreamerView.render("update.json", item)})
-      end
-    end)
-  end
-
-  @spec thread_containment(Activity.t(), User.t()) :: boolean()
-  defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
-
-  defp thread_containment(activity, user) do
-    if Config.get([:instance, :skip_thread_containment]) do
-      true
-    else
-      ActivityPub.contain_activity(activity, user)
-    end
-  end
-end
diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
new file mode 100644 (file)
index 0000000..750f653
--- /dev/null
@@ -0,0 +1,24 @@
+<%= if get_flash(@conn, :info) do %>
+<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+<% end %>
+<%= if get_flash(@conn, :error) do %>
+<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+<% end %>
+
+<h2>Two-factor recovery</h2>
+
+<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+<div class="input">
+  <%= label f, :code, "Recovery code" %>
+  <%= text_input f, :code %>
+  <%= hidden_input f, :mfa_token, value: @mfa_token %>
+  <%= hidden_input f, :state, value: @state %>
+  <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+  <%= hidden_input f, :challenge_type, value: "recovery" %>
+</div>
+
+<%= submit "Verify" %>
+<% end %>
+<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+  Enter a two-factor code
+</a>
diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
new file mode 100644 (file)
index 0000000..af6e546
--- /dev/null
@@ -0,0 +1,24 @@
+<%= if get_flash(@conn, :info) do %>
+<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+<% end %>
+<%= if get_flash(@conn, :error) do %>
+<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+<% end %>
+
+<h2>Two-factor authentication</h2>
+
+<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+<div class="input">
+  <%= label f, :code, "Authentication code" %>
+  <%= text_input f, :code %>
+  <%= hidden_input f, :mfa_token, value: @mfa_token %>
+  <%= hidden_input f, :state, value: @state %>
+  <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+  <%= hidden_input f, :challenge_type, value: "totp" %>
+</div>
+
+<%= submit "Verify" %>
+<% end %>
+<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+  Enter a two-factor recovery code
+</a>
diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex
new file mode 100644 (file)
index 0000000..adc3a3e
--- /dev/null
@@ -0,0 +1,13 @@
+<%= if @error do %>
+<h2><%= @error %></h2>
+<% end %>
+<h2>Two-factor authentication</h2>
+<p><%= @followee.nickname %></p>
+<img height="128" width="128" src="<%= avatar_url(@followee) %>">
+<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %>
+<%= text_input f, :code, placeholder: "Authentication code", required: true %>
+<br>
+<%= hidden_input f, :id, value: @followee.id %>
+<%= hidden_input f, :token, value: @mfa_token %>
+<%= submit "Authorize" %>
+<% end %>
index 89da760da335699fad61a7b0815a69ca79d85a4f..521dc9322af5a059f0cd463e6aad1a9f937d9f2c 100644 (file)
@@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
   require Logger
 
   alias Pleroma.Activity
+  alias Pleroma.MFA
   alias Pleroma.Object.Fetcher
   alias Pleroma.Plugs.OAuthScopesPlug
   alias Pleroma.User
   alias Pleroma.Web.Auth.Authenticator
+  alias Pleroma.Web.Auth.TOTPAuthenticator
   alias Pleroma.Web.CommonAPI
 
   @status_types ["Article", "Event", "Note", "Video", "Page", "Question"]
@@ -68,6 +70,8 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
 
   # POST  /ostatus_subscribe
   #
+  # adds a remote account in followers if user already is signed in.
+  #
   def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do
     with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
          {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
@@ -78,9 +82,33 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
     end
   end
 
+  # POST  /ostatus_subscribe
+  #
+  # step 1.
+  # checks login\password and displays step 2 form of MFA if need.
+  #
   def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do
-    with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
+    with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
          {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee},
+         {_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)},
+         {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
+      redirect(conn, to: "/users/#{followee.id}")
+    else
+      error ->
+        handle_follow_error(conn, error)
+    end
+  end
+
+  # POST  /ostatus_subscribe
+  #
+  # step 2
+  # checks TOTP code. otherwise displays form with errors
+  #
+  def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do
+    with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
+         {_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)},
+         {_, _, _, {:ok, _}} <-
+           {:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)},
          {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
       redirect(conn, to: "/users/#{followee.id}")
     else
@@ -94,6 +122,23 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
     render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."})
   end
 
+  defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do
+    render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
+  end
+
+  defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do
+    render(conn, "follow_mfa.html", %{
+      error: "Wrong authentication code",
+      followee: followee,
+      mfa_token: token
+    })
+  end
+
+  defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do
+    {:ok, %{token: token}} = MFA.Token.create_token(user)
+    render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false})
+  end
+
   defp handle_follow_error(conn, {:auth, _, followee} = _) do
     render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
   end
index 44386887851e820566af1d31256c57b22a28ff6b..237b29ded98ba4082ba6b49e22f7efb267400e42 100644 (file)
@@ -25,7 +25,7 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
-  def render("notification.json", %User{} = user, %Notification{} = notify) do
+  def render("notification.json", %Notification{} = notify, %User{} = user) do
     %{
       event: "notification",
       payload:
diff --git a/mix.exs b/mix.exs
index beb05aab9895d4ea016d2a354a91e85af17d2f33..6d65e18d4521774b5ab99f6e04146147ff743ae9 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -176,6 +176,7 @@ defmodule Pleroma.Mixfile do
       {:quack, "~> 0.1.1"},
       {:joken, "~> 2.0"},
       {:benchee, "~> 1.0"},
+      {:pot, "~> 0.10.2"},
       {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
       {:ex_const, "~> 0.2"},
       {:plug_static_index_html, "~> 1.0.0"},
index 28287cf9723695d7f42c1ef95c39a47b43ab3081..c400202b700b42a2463473476e6799be2d0ccb54 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -89,6 +89,7 @@
   "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
   "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
   "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
+  "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"},
   "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"},
   "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"},
   "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"},
diff --git a/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs
new file mode 100644 (file)
index 0000000..8b653c6
--- /dev/null
@@ -0,0 +1,9 @@
+defmodule Pleroma.Repo.Migrations.AddMultiFactorAuthenticationSettingsToUser do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      add(:multi_factor_authentication_settings, :map, default: %{})
+    end
+  end
+end
diff --git a/priv/repo/migrations/20190508193213_create_mfa_tokens.exs b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs
new file mode 100644 (file)
index 0000000..da9f8fa
--- /dev/null
@@ -0,0 +1,16 @@
+defmodule Pleroma.Repo.Migrations.CreateMfaTokens do
+  use Ecto.Migration
+
+  def change do
+    create table(:mfa_tokens) do
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+      add(:authorization_id, references(:oauth_authorizations, on_delete: :delete_all))
+      add(:token, :string)
+      add(:valid_until, :naive_datetime_usec)
+
+      timestamps()
+    end
+
+    create(unique_index(:mfa_tokens, :token))
+  end
+end
diff --git a/priv/static/adminfe/static/fonts/element-icons.535877f.woff b/priv/static/adminfe/static/fonts/element-icons.535877f.woff
deleted file mode 100644 (file)
index 02b9a25..0000000
Binary files a/priv/static/adminfe/static/fonts/element-icons.535877f.woff and /dev/null differ
diff --git a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf b/priv/static/adminfe/static/fonts/element-icons.732389d.ttf
deleted file mode 100644 (file)
index 91b74de..0000000
Binary files a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf and /dev/null differ
index bd229c55ff0e0448133a9a3d61ce82878c525512..109c7b4cb6dbda0714e8784f3b6275909279ab60 100644 (file)
@@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.OAuth
 
+  @moduletag needs_streamer: true, capture_log: true
+
   @path Pleroma.Web.Endpoint.url()
         |> URI.parse()
         |> Map.put(:scheme, "ws")
         |> Map.put(:path, "/api/v1/streaming")
         |> URI.to_string()
 
-  setup_all do
-    start_supervised(Pleroma.Web.Streamer.supervisor())
-    :ok
-  end
-
   def start_socket(qs \\ nil, headers \\ []) do
     path =
       case qs do
diff --git a/test/mfa/backup_codes_test.exs b/test/mfa/backup_codes_test.exs
new file mode 100644 (file)
index 0000000..7bc01b3
--- /dev/null
@@ -0,0 +1,11 @@
+defmodule Pleroma.MFA.BackupCodesTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.MFA.BackupCodes
+
+  test "generate backup codes" do
+    codes = BackupCodes.generate(number_of_codes: 2, length: 4)
+
+    assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes
+  end
+end
diff --git a/test/mfa/totp_test.exs b/test/mfa/totp_test.exs
new file mode 100644 (file)
index 0000000..50153d2
--- /dev/null
@@ -0,0 +1,17 @@
+defmodule Pleroma.MFA.TOTPTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.MFA.TOTP
+
+  test "create provisioning_uri to generate qrcode" do
+    uri =
+      TOTP.provisioning_uri("test-secrcet", "test@example.com",
+        issuer: "Plerome-42",
+        digits: 8,
+        period: 60
+      )
+
+    assert uri ==
+             "otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet"
+  end
+end
diff --git a/test/mfa_test.exs b/test/mfa_test.exs
new file mode 100644 (file)
index 0000000..94bc48c
--- /dev/null
@@ -0,0 +1,53 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFATest do
+  use Pleroma.DataCase
+
+  import Pleroma.Factory
+  alias Comeonin.Pbkdf2
+  alias Pleroma.MFA
+
+  describe "mfa_settings" do
+    test "returns settings user's" do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true}
+          }
+        )
+
+      settings = MFA.mfa_settings(user)
+      assert match?(^settings, %{enabled: true, totp: true})
+    end
+  end
+
+  describe "generate backup codes" do
+    test "returns backup codes" do
+      user = insert(:user)
+
+      {:ok, [code1, code2]} = MFA.generate_backup_codes(user)
+      updated_user = refresh_record(user)
+      [hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes
+      assert Pbkdf2.checkpw(code1, hash1)
+      assert Pbkdf2.checkpw(code2, hash2)
+    end
+  end
+
+  describe "invalidate_backup_code" do
+    test "invalid used code" do
+      user = insert(:user)
+
+      {:ok, _} = MFA.generate_backup_codes(user)
+      user = refresh_record(user)
+      assert length(user.multi_factor_authentication_settings.backup_codes) == 2
+      [hash_code | _] = user.multi_factor_authentication_settings.backup_codes
+
+      {:ok, user} = MFA.invalidate_backup_code(user, hash_code)
+
+      assert length(user.multi_factor_authentication_settings.backup_codes) == 1
+    end
+  end
+end
index 601a6c0ca1f4b3d70b16b13b8d8d7b242491b661..7f359711f940a84799a7cdcca95b6bb8f17b234e 100644 (file)
@@ -162,14 +162,18 @@ defmodule Pleroma.NotificationTest do
     @tag needs_streamer: true
     test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do
       user = insert(:user)
-      task = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
-      task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
-      Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}})
 
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task_user_notification.pid, assigns: %{user: user}}
-      )
+      task =
+        Task.async(fn ->
+          Streamer.add_socket("user", user)
+          assert_receive {:render_with_user, _, _, _}, 4_000
+        end)
+
+      task_user_notification =
+        Task.async(fn ->
+          Streamer.add_socket("user:notification", user)
+          assert_receive {:render_with_user, _, _, _}, 4_000
+        end)
 
       activity = insert(:note_activity)
 
@@ -724,7 +728,7 @@ defmodule Pleroma.NotificationTest do
 
       assert length(Notification.for_user(user)) == 1
 
-      {:ok, _, _, _} = CommonAPI.unfavorite(activity.id, other_user)
+      {:ok, _} = CommonAPI.unfavorite(activity.id, other_user)
 
       assert Enum.empty?(Notification.for_user(user))
     end
@@ -758,7 +762,7 @@ defmodule Pleroma.NotificationTest do
 
       assert length(Notification.for_user(user)) == 1
 
-      {:ok, _, _} = CommonAPI.unrepeat(activity.id, other_user)
+      {:ok, _} = CommonAPI.unrepeat(activity.id, other_user)
 
       assert Enum.empty?(Notification.for_user(user))
     end
index 4e6142aabbe156483f2b9d85b2922226146f998a..a0667c5e024e7f57afd51b5f6b165b863c2ecdd0 100644 (file)
@@ -24,6 +24,31 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
     end
   end
 
+  test "it halts if user is assigned and MFA enabled", %{conn: conn} do
+    conn =
+      conn
+      |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}})
+      |> assign(:auth_credentials, %{password: "xd-42"})
+      |> EnsureAuthenticatedPlug.call(%{})
+
+    assert conn.status == 403
+    assert conn.halted == true
+
+    assert conn.resp_body ==
+             "{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}"
+  end
+
+  test "it continues if user is assigned and MFA disabled", %{conn: conn} do
+    conn =
+      conn
+      |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}})
+      |> assign(:auth_credentials, %{password: "xd-42"})
+      |> EnsureAuthenticatedPlug.call(%{})
+
+    refute conn.status == 403
+    refute conn.halted
+  end
+
   describe "with :if_func / :unless_func options" do
     setup do
       %{
index 6e5a8e0594aab4a41e2980837aceca7b6158899e..7c4950bfa08a6b9559f587daa3e7bc5a49b77b24 100644 (file)
@@ -21,7 +21,15 @@ defmodule Pleroma.Builders.ActivityBuilder do
 
   def insert(data \\ %{}, opts \\ %{}) do
     activity = build(data, opts)
-    ActivityPub.insert(activity)
+
+    case ActivityPub.insert(activity) do
+      ok = {:ok, activity} ->
+        ActivityPub.notify_and_stream(activity)
+        ok
+
+      error ->
+        error
+    end
   end
 
   def insert_list(times, data \\ %{}, opts \\ %{}) do
index fcfea666f1a2f517f2b8a698e632cc0f1c1a9728..0d04907148f2e0d1cdd2173955e46eac610bbf56 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Builders.UserBuilder do
       bio: "A tester.",
       ap_id: "some id",
       last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
+      multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
       notification_settings: %Pleroma.User.NotificationSetting{}
     }
 
index 91c03b1a88843fd4d3919251b3bb4025a4846960..b23918dd1d981d6596dd255fd55d3584139615d2 100644 (file)
@@ -139,7 +139,11 @@ defmodule Pleroma.Web.ConnCase do
     end
 
     if tags[:needs_streamer] do
-      start_supervised(Pleroma.Web.Streamer.supervisor())
+      start_supervised(%{
+        id: Pleroma.Web.Streamer.registry(),
+        start:
+          {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+      })
     end
 
     {:ok, conn: Phoenix.ConnTest.build_conn()}
index 1669f252034713d8a8c4fbac016d7d331c65d528..ba8848952e11b0d7e7e7895f05b8099194a7177f 100644 (file)
@@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do
     end
 
     if tags[:needs_streamer] do
-      start_supervised(Pleroma.Web.Streamer.supervisor())
+      start_supervised(%{
+        id: Pleroma.Web.Streamer.registry(),
+        start:
+          {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+      })
     end
 
     :ok
index 4957647829bebc8d59b0a6ebc85f6d4daa50442f..c8c45e2a7b4dda9ea5e267dc3ef2021c3af7425f 100644 (file)
@@ -33,7 +33,8 @@ defmodule Pleroma.Factory do
       bio: sequence(:bio, &"Tester Number #{&1}"),
       last_digest_emailed_at: NaiveDateTime.utc_now(),
       last_refreshed_at: NaiveDateTime.utc_now(),
-      notification_settings: %Pleroma.User.NotificationSetting{}
+      notification_settings: %Pleroma.User.NotificationSetting{},
+      multi_factor_authentication_settings: %Pleroma.MFA.Settings{}
     }
 
     %{
@@ -422,4 +423,13 @@ defmodule Pleroma.Factory do
       last_read_id: "1"
     }
   end
+
+  def mfa_token_factory do
+    %Pleroma.MFA.Token{
+      token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
+      authorization: build(:oauth_authorization),
+      valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
+      user: build(:user)
+    }
+  end
 end
index cb847b516df2d1e36ac3deecae17aad620c3cf7a..17c63322a979eade277c42cf62a0782af95b8f0c 100644 (file)
@@ -172,6 +172,7 @@ defmodule Pleroma.UserSearchTest do
         |> Map.put(:search_rank, nil)
         |> Map.put(:search_type, nil)
         |> Map.put(:last_digest_emailed_at, nil)
+        |> Map.put(:multi_factor_authentication_settings, nil)
         |> Map.put(:notification_settings, nil)
 
       assert user == expected
index 5c8d20ac49750c502433ac2aac8db201b1845118..776ddc8d40bb1ad4e7abfa553d28398076ebbac7 100644 (file)
@@ -815,6 +815,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
       assert object["content"] == activity["object"]["content"]
     end
 
+    test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do
+      user = insert(:user)
+
+      activity =
+        activity
+        |> put_in(["object", "type"], "Benis")
+
+      _result =
+        conn
+        |> assign(:user, user)
+        |> put_req_header("content-type", "application/activity+json")
+        |> post("/users/#{user.nickname}/outbox", activity)
+        |> json_response(400)
+    end
+
     test "it inserts an incoming sensitive activity into the database", %{
       conn: conn,
       activity: activity
index 4dc9c0f0a8b72ab81ac5153f34c1a276bc09dbdf..0a8a7119d16438d4acaa6b88de520d89ddc62228 100644 (file)
@@ -939,122 +939,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
-  describe "unreacting to an object" do
-    test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do
-      Config.put([:instance, :federating], true)
-      user = insert(:user)
-      reactor = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
-      assert object = Object.normalize(activity)
-
-      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
-
-      assert called(Federator.publish(reaction_activity))
-
-      {:ok, unreaction_activity, _object} =
-        ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
-
-      assert called(Federator.publish(unreaction_activity))
-    end
-
-    test "adds an undo activity to the db" do
-      user = insert(:user)
-      reactor = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
-      assert object = Object.normalize(activity)
-
-      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
-
-      {:ok, unreaction_activity, _object} =
-        ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
-
-      assert unreaction_activity.actor == reactor.ap_id
-      assert unreaction_activity.data["object"] == reaction_activity.data["id"]
-
-      object = Object.get_by_ap_id(object.data["id"])
-      assert object.data["reaction_count"] == 0
-      assert object.data["reactions"] == []
-    end
-
-    test "reverts emoji unreact on error" do
-      [user, reactor] = insert_list(2, :user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "Status"})
-      object = Object.normalize(activity)
-
-      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "😀")
-
-      with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
-        assert {:error, :reverted} =
-                 ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
-      end
-
-      object = Object.get_by_ap_id(object.data["id"])
-
-      assert object.data["reaction_count"] == 1
-      assert object.data["reactions"] == [["😀", [reactor.ap_id]]]
-    end
-  end
-
-  describe "unliking" do
-    test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do
-      Config.put([:instance, :federating], true)
-
-      note_activity = insert(:note_activity)
-      object = Object.normalize(note_activity)
-      user = insert(:user)
-
-      {:ok, object} = ActivityPub.unlike(user, object)
-      refute called(Federator.publish())
-
-      {:ok, _like_activity} = CommonAPI.favorite(user, note_activity.id)
-      object = Object.get_by_id(object.id)
-      assert object.data["like_count"] == 1
-
-      {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
-      assert object.data["like_count"] == 0
-
-      assert called(Federator.publish(unlike_activity))
-    end
-
-    test "reverts unliking on error" do
-      note_activity = insert(:note_activity)
-      user = insert(:user)
-
-      {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
-      object = Object.normalize(note_activity)
-      assert object.data["like_count"] == 1
-
-      with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
-        assert {:error, :reverted} = ActivityPub.unlike(user, object)
-      end
-
-      assert Object.get_by_ap_id(object.data["id"]) == object
-      assert object.data["like_count"] == 1
-      assert Activity.get_by_id(like_activity.id)
-    end
-
-    test "unliking a previously liked object" do
-      note_activity = insert(:note_activity)
-      object = Object.normalize(note_activity)
-      user = insert(:user)
-
-      # Unliking something that hasn't been liked does nothing
-      {:ok, object} = ActivityPub.unlike(user, object)
-      assert object.data["like_count"] == 0
-
-      {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
-
-      object = Object.get_by_id(object.id)
-      assert object.data["like_count"] == 1
-
-      {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
-      assert object.data["like_count"] == 0
-
-      assert Activity.get_by_id(like_activity.id) == nil
-      assert note_activity.actor in unlike_activity.recipients
-    end
-  end
-
   describe "announcing an object" do
     test "adds an announce activity to the db" do
       note_activity = insert(:note_activity)
@@ -1124,52 +1008,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
-  describe "unannouncing an object" do
-    test "unannouncing a previously announced object" do
-      note_activity = insert(:note_activity)
-      object = Object.normalize(note_activity)
-      user = insert(:user)
-
-      # Unannouncing an object that is not announced does nothing
-      {:ok, object} = ActivityPub.unannounce(user, object)
-      refute object.data["announcement_count"]
-
-      {:ok, announce_activity, object} = ActivityPub.announce(user, object)
-      assert object.data["announcement_count"] == 1
-
-      {:ok, unannounce_activity, object} = ActivityPub.unannounce(user, object)
-      assert object.data["announcement_count"] == 0
-
-      assert unannounce_activity.data["to"] == [
-               User.ap_followers(user),
-               object.data["actor"]
-             ]
-
-      assert unannounce_activity.data["type"] == "Undo"
-      assert unannounce_activity.data["object"] == announce_activity.data
-      assert unannounce_activity.data["actor"] == user.ap_id
-      assert unannounce_activity.data["context"] == announce_activity.data["context"]
-
-      assert Activity.get_by_id(announce_activity.id) == nil
-    end
-
-    test "reverts unannouncing on error" do
-      note_activity = insert(:note_activity)
-      object = Object.normalize(note_activity)
-      user = insert(:user)
-
-      {:ok, _announce_activity, object} = ActivityPub.announce(user, object)
-      assert object.data["announcement_count"] == 1
-
-      with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
-        assert {:error, :reverted} = ActivityPub.unannounce(user, object)
-      end
-
-      object = Object.get_by_ap_id(object.data["id"])
-      assert object.data["announcement_count"] == 1
-    end
-  end
-
   describe "uploading files" do
     test "copies the file to the configured folder" do
       file = %Plug.Upload{
@@ -1276,7 +1114,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
-  describe "blocking / unblocking" do
+  describe "blocking" do
     test "reverts block activity on error" do
       [blocker, blocked] = insert_list(2, :user)
 
@@ -1298,38 +1136,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
       assert activity.data["actor"] == blocker.ap_id
       assert activity.data["object"] == blocked.ap_id
     end
-
-    test "reverts unblock activity on error" do
-      [blocker, blocked] = insert_list(2, :user)
-      {:ok, block_activity} = ActivityPub.block(blocker, blocked)
-
-      with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
-        assert {:error, :reverted} = ActivityPub.unblock(blocker, blocked)
-      end
-
-      assert block_activity.data["type"] == "Block"
-      assert block_activity.data["actor"] == blocker.ap_id
-
-      assert Repo.aggregate(Activity, :count, :id) == 1
-      assert Repo.aggregate(Object, :count, :id) == 1
-    end
-
-    test "creates an undo activity for the last block" do
-      blocker = insert(:user)
-      blocked = insert(:user)
-
-      {:ok, block_activity} = ActivityPub.block(blocker, blocked)
-      {:ok, activity} = ActivityPub.unblock(blocker, blocked)
-
-      assert activity.data["type"] == "Undo"
-      assert activity.data["actor"] == blocker.ap_id
-
-      embedded_object = activity.data["object"]
-      assert is_map(embedded_object)
-      assert embedded_object["type"] == "Block"
-      assert embedded_object["object"] == blocked.ap_id
-      assert embedded_object["id"] == block_activity.data["id"]
-    end
   end
 
   describe "timeline post-processing" do
index 744c46781d33dab03549624f1b3db52873eba764..174be5ec61c2b137c6852e7878164a27e422d435 100644 (file)
@@ -10,6 +10,46 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
 
   import Pleroma.Factory
 
+  describe "Undos" do
+    setup do
+      user = insert(:user)
+      {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"})
+      {:ok, like} = CommonAPI.favorite(user, post_activity.id)
+      {:ok, valid_like_undo, []} = Builder.undo(user, like)
+
+      %{user: user, like: like, valid_like_undo: valid_like_undo}
+    end
+
+    test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do
+      assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, [])
+    end
+
+    test "it does not validate if the actor of the undo is not the actor of the object", %{
+      valid_like_undo: valid_like_undo
+    } do
+      other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
+
+      bad_actor =
+        valid_like_undo
+        |> Map.put("actor", other_user.ap_id)
+
+      {:error, cng} = ObjectValidator.validate(bad_actor, [])
+
+      assert {:actor, {"not the same as object actor", []}} in cng.errors
+    end
+
+    test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do
+      missing_object =
+        valid_like_undo
+        |> Map.put("object", "https://gensokyo.2hu/objects/1")
+
+      {:error, cng} = ObjectValidator.validate(missing_object, [])
+
+      assert {:object, {"can't find object", []}} in cng.errors
+      assert length(cng.errors) == 1
+    end
+  end
+
   describe "deletes" do
     setup do
       user = insert(:user)
index a9598d7b36ecbdc2c21507c177fd7aa2a4b52710..aafc450d30879744dc69e12dea305e02d98fb1ef 100644 (file)
@@ -72,6 +72,106 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
     end
   end
 
+  describe "Undo objects" do
+    setup do
+      poster = insert(:user)
+      user = insert(:user)
+      {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"})
+      {:ok, like} = CommonAPI.favorite(user, post.id)
+      {:ok, reaction, _} = CommonAPI.react_with_emoji(post.id, user, "👍")
+      {:ok, announce, _} = CommonAPI.repeat(post.id, user)
+      {:ok, block} = ActivityPub.block(user, poster)
+      User.block(user, poster)
+
+      {:ok, undo_data, _meta} = Builder.undo(user, like)
+      {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+      {:ok, undo_data, _meta} = Builder.undo(user, reaction)
+      {:ok, reaction_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+      {:ok, undo_data, _meta} = Builder.undo(user, announce)
+      {:ok, announce_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+      {:ok, undo_data, _meta} = Builder.undo(user, block)
+      {:ok, block_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+      %{
+        like_undo: like_undo,
+        post: post,
+        like: like,
+        reaction_undo: reaction_undo,
+        reaction: reaction,
+        announce_undo: announce_undo,
+        announce: announce,
+        block_undo: block_undo,
+        block: block,
+        poster: poster,
+        user: user
+      }
+    end
+
+    test "deletes the original block", %{block_undo: block_undo, block: block} do
+      {:ok, _block_undo, _} = SideEffects.handle(block_undo)
+      refute Activity.get_by_id(block.id)
+    end
+
+    test "unblocks the blocked user", %{block_undo: block_undo, block: block} do
+      blocker = User.get_by_ap_id(block.data["actor"])
+      blocked = User.get_by_ap_id(block.data["object"])
+
+      {:ok, _block_undo, _} = SideEffects.handle(block_undo)
+      refute User.blocks?(blocker, blocked)
+    end
+
+    test "an announce undo removes the announce from the object", %{
+      announce_undo: announce_undo,
+      post: post
+    } do
+      {:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
+
+      object = Object.get_by_ap_id(post.data["object"])
+
+      assert object.data["announcement_count"] == 0
+      assert object.data["announcements"] == []
+    end
+
+    test "deletes the original announce", %{announce_undo: announce_undo, announce: announce} do
+      {:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
+      refute Activity.get_by_id(announce.id)
+    end
+
+    test "a reaction undo removes the reaction from the object", %{
+      reaction_undo: reaction_undo,
+      post: post
+    } do
+      {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
+
+      object = Object.get_by_ap_id(post.data["object"])
+
+      assert object.data["reaction_count"] == 0
+      assert object.data["reactions"] == []
+    end
+
+    test "deletes the original reaction", %{reaction_undo: reaction_undo, reaction: reaction} do
+      {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
+      refute Activity.get_by_id(reaction.id)
+    end
+
+    test "a like undo removes the like from the object", %{like_undo: like_undo, post: post} do
+      {:ok, _like_undo, _} = SideEffects.handle(like_undo)
+
+      object = Object.get_by_ap_id(post.data["object"])
+
+      assert object.data["like_count"] == 0
+      assert object.data["likes"] == []
+    end
+
+    test "deletes the original like", %{like_undo: like_undo, like: like} do
+      {:ok, _like_undo, _} = SideEffects.handle(like_undo)
+      refute Activity.get_by_id(like.id)
+    end
+  end
+
   describe "like objects" do
     setup do
       poster = insert(:user)
diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs
new file mode 100644 (file)
index 0000000..6f5e61a
--- /dev/null
@@ -0,0 +1,185 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.Activity
+  alias Pleroma.Object
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.Transmogrifier
+  alias Pleroma.Web.CommonAPI
+
+  import Pleroma.Factory
+
+  test "it works for incoming emoji reaction undos" do
+    user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
+    {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌")
+
+    data =
+      File.read!("test/fixtures/mastodon-undo-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", reaction_activity.data["id"])
+      |> Map.put("actor", user.ap_id)
+
+    {:ok, activity} = Transmogrifier.handle_incoming(data)
+
+    assert activity.actor == user.ap_id
+    assert activity.data["id"] == data["id"]
+    assert activity.data["type"] == "Undo"
+  end
+
+  test "it returns an error for incoming unlikes wihout a like activity" do
+    user = insert(:user)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
+
+    data =
+      File.read!("test/fixtures/mastodon-undo-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", activity.data["object"])
+
+    assert Transmogrifier.handle_incoming(data) == :error
+  end
+
+  test "it works for incoming unlikes with an existing like activity" do
+    user = insert(:user)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
+
+    like_data =
+      File.read!("test/fixtures/mastodon-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", activity.data["object"])
+
+    _liker = insert(:user, ap_id: like_data["actor"], local: false)
+
+    {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
+
+    data =
+      File.read!("test/fixtures/mastodon-undo-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", like_data)
+      |> Map.put("actor", like_data["actor"])
+
+    {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+    assert data["actor"] == "http://mastodon.example.org/users/admin"
+    assert data["type"] == "Undo"
+    assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
+    assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
+
+    note = Object.get_by_ap_id(like_data["object"])
+    assert note.data["like_count"] == 0
+    assert note.data["likes"] == []
+  end
+
+  test "it works for incoming unlikes with an existing like activity and a compact object" do
+    user = insert(:user)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
+
+    like_data =
+      File.read!("test/fixtures/mastodon-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", activity.data["object"])
+
+    _liker = insert(:user, ap_id: like_data["actor"], local: false)
+
+    {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
+
+    data =
+      File.read!("test/fixtures/mastodon-undo-like.json")
+      |> Poison.decode!()
+      |> Map.put("object", like_data["id"])
+      |> Map.put("actor", like_data["actor"])
+
+    {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+    assert data["actor"] == "http://mastodon.example.org/users/admin"
+    assert data["type"] == "Undo"
+    assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
+    assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
+  end
+
+  test "it works for incoming unannounces with an existing notice" do
+    user = insert(:user)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
+
+    announce_data =
+      File.read!("test/fixtures/mastodon-announce.json")
+      |> Poison.decode!()
+      |> Map.put("object", activity.data["object"])
+
+    _announcer = insert(:user, ap_id: announce_data["actor"], local: false)
+
+    {:ok, %Activity{data: announce_data, local: false}} =
+      Transmogrifier.handle_incoming(announce_data)
+
+    data =
+      File.read!("test/fixtures/mastodon-undo-announce.json")
+      |> Poison.decode!()
+      |> Map.put("object", announce_data)
+      |> Map.put("actor", announce_data["actor"])
+
+    {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+    assert data["type"] == "Undo"
+
+    assert data["object"] ==
+             "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
+  end
+
+  test "it works for incomming unfollows with an existing follow" do
+    user = insert(:user)
+
+    follow_data =
+      File.read!("test/fixtures/mastodon-follow-activity.json")
+      |> Poison.decode!()
+      |> Map.put("object", user.ap_id)
+
+    _follower = insert(:user, ap_id: follow_data["actor"], local: false)
+
+    {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data)
+
+    data =
+      File.read!("test/fixtures/mastodon-unfollow-activity.json")
+      |> Poison.decode!()
+      |> Map.put("object", follow_data)
+
+    {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+    assert data["type"] == "Undo"
+    assert data["object"]["type"] == "Follow"
+    assert data["object"]["object"] == user.ap_id
+    assert data["actor"] == "http://mastodon.example.org/users/admin"
+
+    refute User.following?(User.get_cached_by_ap_id(data["actor"]), user)
+  end
+
+  test "it works for incoming unblocks with an existing block" do
+    user = insert(:user)
+
+    block_data =
+      File.read!("test/fixtures/mastodon-block-activity.json")
+      |> Poison.decode!()
+      |> Map.put("object", user.ap_id)
+
+    _blocker = insert(:user, ap_id: block_data["actor"], local: false)
+
+    {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
+
+    data =
+      File.read!("test/fixtures/mastodon-unblock-activity.json")
+      |> Poison.decode!()
+      |> Map.put("object", block_data)
+
+    {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+    assert data["type"] == "Undo"
+    assert data["object"] == block_data["id"]
+
+    blocker = User.get_cached_by_ap_id(data["actor"])
+
+    refute User.blocks?(blocker, user)
+  end
+end
index 6d43c3365e49daa4d596c4635c486fad66d3364e..4fd6c8b00298aaab70267a9694110f7ddb34a85b 100644 (file)
@@ -362,87 +362,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert :error = Transmogrifier.handle_incoming(data)
     end
 
-    test "it works for incoming emoji reaction undos" do
-      user = insert(:user)
-
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
-      {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌")
-
-      data =
-        File.read!("test/fixtures/mastodon-undo-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", reaction_activity.data["id"])
-        |> Map.put("actor", user.ap_id)
-
-      {:ok, activity} = Transmogrifier.handle_incoming(data)
-
-      assert activity.actor == user.ap_id
-      assert activity.data["id"] == data["id"]
-      assert activity.data["type"] == "Undo"
-    end
-
-    test "it returns an error for incoming unlikes wihout a like activity" do
-      user = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
-      data =
-        File.read!("test/fixtures/mastodon-undo-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", activity.data["object"])
-
-      assert Transmogrifier.handle_incoming(data) == :error
-    end
-
-    test "it works for incoming unlikes with an existing like activity" do
-      user = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
-      like_data =
-        File.read!("test/fixtures/mastodon-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", activity.data["object"])
-
-      {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
-
-      data =
-        File.read!("test/fixtures/mastodon-undo-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", like_data)
-        |> Map.put("actor", like_data["actor"])
-
-      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
-      assert data["actor"] == "http://mastodon.example.org/users/admin"
-      assert data["type"] == "Undo"
-      assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
-      assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
-    end
-
-    test "it works for incoming unlikes with an existing like activity and a compact object" do
-      user = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
-      like_data =
-        File.read!("test/fixtures/mastodon-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", activity.data["object"])
-
-      {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
-
-      data =
-        File.read!("test/fixtures/mastodon-undo-like.json")
-        |> Poison.decode!()
-        |> Map.put("object", like_data["id"])
-        |> Map.put("actor", like_data["actor"])
-
-      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
-      assert data["actor"] == "http://mastodon.example.org/users/admin"
-      assert data["type"] == "Undo"
-      assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
-      assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
-    end
-
     test "it works for incoming announces" do
       data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!()
 
@@ -766,35 +685,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert user.locked == true
     end
 
-    test "it works for incoming unannounces with an existing notice" do
-      user = insert(:user)
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
-
-      announce_data =
-        File.read!("test/fixtures/mastodon-announce.json")
-        |> Poison.decode!()
-        |> Map.put("object", activity.data["object"])
-
-      {:ok, %Activity{data: announce_data, local: false}} =
-        Transmogrifier.handle_incoming(announce_data)
-
-      data =
-        File.read!("test/fixtures/mastodon-undo-announce.json")
-        |> Poison.decode!()
-        |> Map.put("object", announce_data)
-        |> Map.put("actor", announce_data["actor"])
-
-      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
-      assert data["type"] == "Undo"
-      assert object_data = data["object"]
-      assert object_data["type"] == "Announce"
-      assert object_data["object"] == activity.data["object"]
-
-      assert object_data["id"] ==
-               "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
-    end
-
     test "it works for incomming unfollows with an existing follow" do
       user = insert(:user)
 
@@ -889,32 +779,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       refute User.following?(blocked, blocker)
     end
 
-    test "it works for incoming unblocks with an existing block" do
-      user = insert(:user)
-
-      block_data =
-        File.read!("test/fixtures/mastodon-block-activity.json")
-        |> Poison.decode!()
-        |> Map.put("object", user.ap_id)
-
-      {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
-
-      data =
-        File.read!("test/fixtures/mastodon-unblock-activity.json")
-        |> Poison.decode!()
-        |> Map.put("object", block_data)
-
-      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-      assert data["type"] == "Undo"
-      assert data["object"]["type"] == "Block"
-      assert data["object"]["object"] == user.ap_id
-      assert data["actor"] == "http://mastodon.example.org/users/admin"
-
-      blocker = User.get_cached_by_ap_id(data["actor"])
-
-      refute User.blocks?(blocker, user)
-    end
-
     test "it works for incoming accepts which were pre-accepted" do
       follower = insert(:user)
       followed = insert(:user)
index b0bfed9178a53cba7a5f034cbe0cc1060d4f487e..b8d811c73d96fc1e02bc740dcd1f29d6433e0107 100644 (file)
@@ -102,34 +102,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
     end
   end
 
-  describe "make_unlike_data/3" do
-    test "returns data for unlike activity" do
-      user = insert(:user)
-      like_activity = insert(:like_activity, data_attrs: %{"context" => "test context"})
-
-      object = Object.normalize(like_activity.data["object"])
-
-      assert Utils.make_unlike_data(user, like_activity, nil) == %{
-               "type" => "Undo",
-               "actor" => user.ap_id,
-               "object" => like_activity.data,
-               "to" => [user.follower_address, object.data["actor"]],
-               "cc" => [Pleroma.Constants.as_public()],
-               "context" => like_activity.data["context"]
-             }
-
-      assert Utils.make_unlike_data(user, like_activity, "9mJEZK0tky1w2xD2vY") == %{
-               "type" => "Undo",
-               "actor" => user.ap_id,
-               "object" => like_activity.data,
-               "to" => [user.follower_address, object.data["actor"]],
-               "cc" => [Pleroma.Constants.as_public()],
-               "context" => like_activity.data["context"],
-               "id" => "9mJEZK0tky1w2xD2vY"
-             }
-    end
-  end
-
   describe "make_like_data" do
     setup do
       user = insert(:user)
index 7ab7cc15c6412656aea8950aabe87d8a197c1eeb..4697af50ebcb367dede7a8f9580d38feb9278682 100644 (file)
@@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   alias Pleroma.Config
   alias Pleroma.ConfigDB
   alias Pleroma.HTML
+  alias Pleroma.MFA
   alias Pleroma.ModerationLog
   alias Pleroma.Repo
   alias Pleroma.ReportNote
@@ -1278,6 +1279,38 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
              "@#{admin.nickname} deactivated users: @#{user.nickname}"
   end
 
+  describe "PUT disable_mfa" do
+    test "returns 200 and disable 2fa", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+          }
+        )
+
+      response =
+        conn
+        |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname})
+        |> json_response(200)
+
+      assert response == user.nickname
+      mfa_settings = refresh_record(user).multi_factor_authentication_settings
+
+      refute mfa_settings.enabled
+      refute mfa_settings.totp.confirmed
+    end
+
+    test "returns 404 if user not found", %{conn: conn} do
+      response =
+        conn
+        |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"})
+        |> json_response(404)
+
+      assert response == "Not found"
+    end
+  end
+
   describe "POST /api/pleroma/admin/users/invite_token" do
     test "without options", %{conn: conn} do
       conn = post(conn, "/api/pleroma/admin/users/invite_token")
diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs
new file mode 100644 (file)
index 0000000..7125c50
--- /dev/null
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do
+  use Pleroma.Web.ConnCase
+
+  alias Pleroma.Web.Auth.PleromaAuthenticator
+  import Pleroma.Factory
+
+  setup do
+    password = "testpassword"
+    name = "AgentSmith"
+    user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+    {:ok, [user: user, name: name, password: password]}
+  end
+
+  test "get_user/authorization", %{user: user, name: name, password: password} do
+    params = %{"authorization" => %{"name" => name, "password" => password}}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:ok, user} == res
+  end
+
+  test "get_user/authorization with invalid password", %{name: name} do
+    params = %{"authorization" => %{"name" => name, "password" => "password"}}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:error, {:checkpw, false}} == res
+  end
+
+  test "get_user/grant_type_password", %{user: user, name: name, password: password} do
+    params = %{"grant_type" => "password", "username" => name, "password" => password}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:ok, user} == res
+  end
+
+  test "error credintails" do
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}})
+    assert {:error, :invalid_credentials} == res
+  end
+end
diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs
new file mode 100644 (file)
index 0000000..e080694
--- /dev/null
@@ -0,0 +1,51 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do
+  use Pleroma.Web.ConnCase
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Web.Auth.TOTPAuthenticator
+
+  import Pleroma.Factory
+
+  test "verify token" do
+    otp_secret = TOTP.generate_secret()
+    otp_token = TOTP.generate_token(otp_secret)
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+        }
+      )
+
+    assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass}
+    assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token}
+    assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token}
+  end
+
+  test "checks backup codes" do
+    [code | _] = backup_codes = BackupCodes.generate()
+
+    hashed_codes =
+      backup_codes
+      |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          backup_codes: hashed_codes,
+          totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+        }
+      )
+
+    assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass}
+    refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass}
+  end
+end
index 62a2665b639682841c2ee78731c84723001dc234..9a37d18875e8146a51d5ac04653ee481a92ae3df 100644 (file)
@@ -375,10 +375,11 @@ defmodule Pleroma.Web.CommonAPITest do
       {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
       {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍")
 
-      {:ok, unreaction, _} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
+      {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
 
       assert unreaction.data["type"] == "Undo"
       assert unreaction.data["object"] == reaction.data["id"]
+      assert unreaction.local
     end
 
     test "repeating a status" do
index 88b13a25aaeb7548bca59be2141634d578c1c07d..d8f34aa863047aa7c31289b385575ba4b7f3acce 100644 (file)
@@ -24,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       conn = get(conn, "/api/v1/polls/#{object.id}")
 
-      response = json_response(conn, 200)
+      response = json_response_and_validate_schema(conn, 200)
       id = to_string(object.id)
       assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
     end
@@ -43,7 +43,7 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       conn = get(conn, "/api/v1/polls/#{object.id}")
 
-      assert json_response(conn, 404)
+      assert json_response_and_validate_schema(conn, 404)
     end
   end
 
@@ -65,9 +65,12 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
 
-      assert json_response(conn, 200)
+      assert json_response_and_validate_schema(conn, 200)
       object = Object.get_by_id(object.id)
 
       assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
@@ -85,8 +88,9 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
       object = Object.normalize(activity)
 
       assert conn
+             |> put_req_header("content-type", "application/json")
              |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
-             |> json_response(422) == %{"error" => "Poll's author can't vote"}
+             |> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
 
       object = Object.get_by_id(object.id)
 
@@ -105,8 +109,9 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
       object = Object.normalize(activity)
 
       assert conn
+             |> put_req_header("content-type", "application/json")
              |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
-             |> json_response(422) == %{"error" => "Too many choices"}
+             |> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
 
       object = Object.get_by_id(object.id)
 
@@ -126,15 +131,21 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
 
-      assert json_response(conn, 422) == %{"error" => "Invalid indices"}
+      assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
     end
 
     test "returns 404 error when object is not exist", %{conn: conn} do
-      conn = post(conn, "/api/v1/polls/1/votes", %{"choices" => [0]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/1/votes", %{"choices" => [0]})
 
-      assert json_response(conn, 404) == %{"error" => "Record not found"}
+      assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
     end
 
     test "returns 404 when poll is private and not available for user", %{conn: conn} do
@@ -149,9 +160,12 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
 
-      assert json_response(conn, 404) == %{"error" => "Record not found"}
+      assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
     end
   end
 end
index 11133ff66becbdf2d2f7c7fea660ee128677c266..02476acb60888084e98484118d91673f8bc9afb5 100644 (file)
@@ -27,8 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
         capture_log(fn ->
           results =
             conn
-            |> get("/api/v2/search", %{"q" => "2hu"})
-            |> json_response(200)
+            |> get("/api/v2/search?q=2hu")
+            |> json_response_and_validate_schema(200)
 
           assert results["accounts"] == []
           assert results["statuses"] == []
@@ -54,8 +54,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v2/search", %{"q" => "2hu #private"})
-        |> json_response(200)
+        |> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
+        |> json_response_and_validate_schema(200)
 
       [account | _] = results["accounts"]
       assert account["id"] == to_string(user_three.id)
@@ -68,8 +68,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
       assert status["id"] == to_string(activity.id)
 
       results =
-        get(conn, "/api/v2/search", %{"q" => "天子"})
-        |> json_response(200)
+        get(conn, "/api/v2/search?q=天子")
+        |> json_response_and_validate_schema(200)
 
       [status] = results["statuses"]
       assert status["id"] == to_string(activity.id)
@@ -89,8 +89,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
-        |> get("/api/v2/search", %{"q" => "Agent"})
-        |> json_response(200)
+        |> get("/api/v2/search?q=Agent")
+        |> json_response_and_validate_schema(200)
 
       status_ids = Enum.map(results["statuses"], fn g -> g["id"] end)
 
@@ -107,8 +107,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/accounts/search", %{"q" => "shp"})
-        |> json_response(200)
+        |> get("/api/v1/accounts/search?q=shp")
+        |> json_response_and_validate_schema(200)
 
       result_ids = for result <- results, do: result["acct"]
 
@@ -117,8 +117,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/accounts/search", %{"q" => "2hu"})
-        |> json_response(200)
+        |> get("/api/v1/accounts/search?q=2hu")
+        |> json_response_and_validate_schema(200)
 
       result_ids = for result <- results, do: result["acct"]
 
@@ -130,8 +130,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/accounts/search", %{"q" => "shp@shitposter.club xxx "})
-        |> json_response(200)
+        |> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
+        |> json_response_and_validate_schema(200)
 
       assert length(results) == 1
     end
@@ -146,8 +146,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
         capture_log(fn ->
           results =
             conn
-            |> get("/api/v1/search", %{"q" => "2hu"})
-            |> json_response(200)
+            |> get("/api/v1/search?q=2hu")
+            |> json_response_and_validate_schema(200)
 
           assert results["accounts"] == []
           assert results["statuses"] == []
@@ -173,8 +173,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/search", %{"q" => "2hu"})
-        |> json_response(200)
+        |> get("/api/v1/search?q=2hu")
+        |> json_response_and_validate_schema(200)
 
       [account | _] = results["accounts"]
       assert account["id"] == to_string(user_three.id)
@@ -194,8 +194,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
         results =
           conn
-          |> get("/api/v1/search", %{"q" => "https://shitposter.club/notice/2827873"})
-          |> json_response(200)
+          |> get("/api/v1/search?q=https://shitposter.club/notice/2827873")
+          |> json_response_and_validate_schema(200)
 
         [status, %{"id" => ^activity_id}] = results["statuses"]
 
@@ -212,10 +212,12 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
         })
 
       capture_log(fn ->
+        q = Object.normalize(activity).data["id"]
+
         results =
           conn
-          |> get("/api/v1/search", %{"q" => Object.normalize(activity).data["id"]})
-          |> json_response(200)
+          |> get("/api/v1/search?q=#{q}")
+          |> json_response_and_validate_schema(200)
 
         [] = results["statuses"]
       end)
@@ -228,8 +230,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
-        |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "true"})
-        |> json_response(200)
+        |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=true")
+        |> json_response_and_validate_schema(200)
 
       [account] = results["accounts"]
       assert account["acct"] == "mike@osada.macgirvin.com"
@@ -238,8 +240,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
     test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do
       results =
         conn
-        |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "false"})
-        |> json_response(200)
+        |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false")
+        |> json_response_and_validate_schema(200)
 
       assert [] == results["accounts"]
     end
@@ -254,16 +256,16 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       result =
         conn
-        |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1})
+        |> get("/api/v1/search?q=2hu&limit=1")
 
-      assert results = json_response(result, 200)
+      assert results = json_response_and_validate_schema(result, 200)
       assert [%{"id" => activity_id1}] = results["statuses"]
       assert [_] = results["accounts"]
 
       results =
         conn
-        |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1, "offset" => 1})
-        |> json_response(200)
+        |> get("/api/v1/search?q=2hu&limit=1&offset=1")
+        |> json_response_and_validate_schema(200)
 
       assert [%{"id" => activity_id2}] = results["statuses"]
       assert [] = results["accounts"]
@@ -279,13 +281,13 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} =
                conn
-               |> get("/api/v1/search", %{"q" => "2hu", "type" => "statuses"})
-               |> json_response(200)
+               |> get("/api/v1/search?q=2hu&type=statuses")
+               |> json_response_and_validate_schema(200)
 
       assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} =
                conn
-               |> get("/api/v1/search", %{"q" => "2hu", "type" => "accounts"})
-               |> json_response(200)
+               |> get("/api/v1/search?q=2hu&type=accounts")
+               |> json_response_and_validate_schema(200)
     end
 
     test "search uses account_id to filter statuses by the author", %{conn: conn} do
@@ -297,8 +299,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user.id})
-        |> json_response(200)
+        |> get("/api/v1/search?q=2hu&account_id=#{user.id}")
+        |> json_response_and_validate_schema(200)
 
       assert [%{"id" => activity_id1}] = results["statuses"]
       assert activity_id1 == activity1.id
@@ -306,8 +308,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
 
       results =
         conn
-        |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user_two.id})
-        |> json_response(200)
+        |> get("/api/v1/search?q=2hu&account_id=#{user_two.id}")
+        |> json_response_and_validate_schema(200)
 
       assert [%{"id" => activity_id2}] = results["statuses"]
       assert activity_id2 == activity2.id
diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs
new file mode 100644 (file)
index 0000000..ce4a073
--- /dev/null
@@ -0,0 +1,306 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAControllerTest do
+  use Pleroma.Web.ConnCase
+  import Pleroma.Factory
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Repo
+  alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.OAuthController
+
+  setup %{conn: conn} do
+    otp_secret = TOTP.generate_secret()
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          backup_codes: [Comeonin.Pbkdf2.hashpwsalt("test-code")],
+          totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+        }
+      )
+
+    app = insert(:oauth_app)
+    {:ok, conn: conn, user: user, app: app}
+  end
+
+  describe "show" do
+    setup %{conn: conn, user: user, app: app} do
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      {:ok, conn: conn, mfa_token: mfa_token}
+    end
+
+    test "GET /oauth/mfa renders mfa forms", %{conn: conn, mfa_token: mfa_token} do
+      conn =
+        get(
+          conn,
+          "/oauth/mfa",
+          %{
+            "mfa_token" => mfa_token.token,
+            "state" => "a_state",
+            "redirect_uri" => "http://localhost:8080/callback"
+          }
+        )
+
+      assert response = html_response(conn, 200)
+      assert response =~ "Two-factor authentication"
+      assert response =~ mfa_token.token
+      assert response =~ "http://localhost:8080/callback"
+    end
+
+    test "GET /oauth/mfa renders mfa recovery forms", %{conn: conn, mfa_token: mfa_token} do
+      conn =
+        get(
+          conn,
+          "/oauth/mfa",
+          %{
+            "mfa_token" => mfa_token.token,
+            "state" => "a_state",
+            "redirect_uri" => "http://localhost:8080/callback",
+            "challenge_type" => "recovery"
+          }
+        )
+
+      assert response = html_response(conn, 200)
+      assert response =~ "Two-factor recovery"
+      assert response =~ mfa_token.token
+      assert response =~ "http://localhost:8080/callback"
+    end
+  end
+
+  describe "verify" do
+    setup %{conn: conn, user: user, app: app} do
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      {:ok, conn: conn, user: user, mfa_token: mfa_token, app: app}
+    end
+
+    test "POST /oauth/mfa/verify, verify totp code", %{
+      conn: conn,
+      user: user,
+      mfa_token: mfa_token,
+      app: app
+    } do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      conn =
+        conn
+        |> post("/oauth/mfa/verify", %{
+          "mfa" => %{
+            "mfa_token" => mfa_token.token,
+            "challenge_type" => "totp",
+            "code" => otp_token,
+            "state" => "a_state",
+            "redirect_uri" => OAuthController.default_redirect_uri(app)
+          }
+        })
+
+      target = redirected_to(conn)
+      target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+      query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+      assert %{"state" => "a_state", "code" => code} = query
+      assert target_url == OAuthController.default_redirect_uri(app)
+      auth = Repo.get_by(Authorization, token: code)
+      assert auth.scopes == ["write"]
+    end
+
+    test "POST /oauth/mfa/verify, verify recovery code", %{
+      conn: conn,
+      mfa_token: mfa_token,
+      app: app
+    } do
+      conn =
+        conn
+        |> post("/oauth/mfa/verify", %{
+          "mfa" => %{
+            "mfa_token" => mfa_token.token,
+            "challenge_type" => "recovery",
+            "code" => "test-code",
+            "state" => "a_state",
+            "redirect_uri" => OAuthController.default_redirect_uri(app)
+          }
+        })
+
+      target = redirected_to(conn)
+      target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+      query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+      assert %{"state" => "a_state", "code" => code} = query
+      assert target_url == OAuthController.default_redirect_uri(app)
+      auth = Repo.get_by(Authorization, token: code)
+      assert auth.scopes == ["write"]
+    end
+  end
+
+  describe "challenge/totp" do
+    test "returns access token with valid code", %{conn: conn, user: user, app: app} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(:ok)
+
+      ap_id = user.ap_id
+
+      assert match?(
+               %{
+                 "access_token" => _,
+                 "expires_in" => 600,
+                 "me" => ^ap_id,
+                 "refresh_token" => _,
+                 "scope" => "write",
+                 "token_type" => "Bearer"
+               },
+               response
+             )
+    end
+
+    test "returns errors when mfa token invalid", %{conn: conn, user: user, app: app} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => "XXX",
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+
+    test "returns error when otp code is invalid", %{conn: conn, user: user, app: app} do
+      mfa_token = insert(:mfa_token, user: user)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => "XXX",
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+
+    test "returns error when client credentails is wrong ", %{conn: conn, user: user} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+      mfa_token = insert(:mfa_token, user: user)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => "xxx",
+          "client_secret" => "xxx"
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+  end
+
+  describe "challenge/recovery" do
+    setup %{conn: conn} do
+      app = insert(:oauth_app)
+      {:ok, conn: conn, app: app}
+    end
+
+    test "returns access token with valid code", %{conn: conn, app: app} do
+      otp_secret = TOTP.generate_secret()
+
+      [code | _] = backup_codes = BackupCodes.generate()
+
+      hashed_codes =
+        backup_codes
+        |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            backup_codes: hashed_codes,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "recovery",
+          "code" => code,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(:ok)
+
+      ap_id = user.ap_id
+
+      assert match?(
+               %{
+                 "access_token" => _,
+                 "expires_in" => 600,
+                 "me" => ^ap_id,
+                 "refresh_token" => _,
+                 "scope" => "write",
+                 "token_type" => "Bearer"
+               },
+               response
+             )
+
+      error_response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "recovery",
+          "code" => code,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert error_response == %{"error" => "Invalid code"}
+    end
+  end
+end
index f2f98d768101b193bf9c7a8c132036ed59eb069c..7a107584d61fdad35b7ee4264e2ac579b1dba279 100644 (file)
@@ -6,6 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
   use Pleroma.Web.ConnCase
   import Pleroma.Factory
 
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
   alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.Web.OAuth.Authorization
@@ -604,6 +606,41 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       end
     end
 
+    test "redirect to on two-factor auth page" do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      app = insert(:oauth_app, scopes: ["read", "write", "follow"])
+
+      conn =
+        build_conn()
+        |> post("/oauth/authorize", %{
+          "authorization" => %{
+            "name" => user.nickname,
+            "password" => "test",
+            "client_id" => app.client_id,
+            "redirect_uri" => app.redirect_uris,
+            "scope" => "read write",
+            "state" => "statepassed"
+          }
+        })
+
+      result = html_response(conn, 200)
+
+      mfa_token = Repo.get_by(MFA.Token, user_id: user.id)
+      assert result =~ app.redirect_uris
+      assert result =~ "statepassed"
+      assert result =~ mfa_token.token
+      assert result =~ "Two-factor authentication"
+    end
+
     test "returns 401 for wrong credentials", %{conn: conn} do
       user = insert(:user)
       app = insert(:oauth_app)
@@ -735,6 +772,46 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       assert token.scopes == app.scopes
     end
 
+    test "issues a mfa token for `password` grant_type, when MFA enabled" do
+      password = "testpassword"
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      app = insert(:oauth_app, scopes: ["read", "write"])
+
+      response =
+        build_conn()
+        |> post("/oauth/token", %{
+          "grant_type" => "password",
+          "username" => user.nickname,
+          "password" => password,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(403)
+
+      assert match?(
+               %{
+                 "supported_challenge_types" => "totp",
+                 "mfa_token" => _,
+                 "error" => "mfa_required"
+               },
+               response
+             )
+
+      token = Repo.get_by(MFA.Token, token: response["mfa_token"])
+      assert token.user_id == user.id
+      assert token.authorization_id
+    end
+
     test "issues a token for request with HTTP basic auth client credentials" do
       user = insert(:user)
       app = insert(:oauth_app, scopes: ["scope1", "scope2", "scope3"])
index 61a1689b991583f27a69f620a7d8576c69a18efb..299dbad41e8d6e5751c9cc5a4113e9aaf06822d4 100644 (file)
@@ -3,12 +3,14 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
+  use Oban.Testing, repo: Pleroma.Repo
   use Pleroma.Web.ConnCase
 
   alias Pleroma.Conversation.Participation
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
 
@@ -41,7 +43,9 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
     other_user = insert(:user)
 
     {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"})
-    {:ok, activity, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+    {:ok, _reaction, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+    ObanHelpers.perform_all()
 
     result =
       conn
@@ -52,7 +56,9 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
     assert %{"id" => id} = json_response(result, 200)
     assert to_string(activity.id) == id
 
-    object = Object.normalize(activity)
+    ObanHelpers.perform_all()
+
+    object = Object.get_by_ap_id(activity.data["object"])
 
     assert object.data["reaction_count"] == 0
   end
diff --git a/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs
new file mode 100644 (file)
index 0000000..d23d08a
--- /dev/null
@@ -0,0 +1,260 @@
+defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+  alias Pleroma.MFA.Settings
+  alias Pleroma.MFA.TOTP
+
+  describe "GET /api/pleroma/accounts/mfa/settings" do
+    test "returns user mfa settings for new user", %{conn: conn} do
+      token = insert(:oauth_token, scopes: ["read", "follow"])
+      token2 = insert(:oauth_token, scopes: ["write"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(:ok) == %{
+               "settings" => %{"enabled" => false, "totp" => false}
+             }
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: read:security."
+             }
+    end
+
+    test "returns user mfa settings with enabled totp", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            enabled: true,
+            totp: %Settings.TOTP{secret: "XXX", delivery_type: "app", confirmed: true}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["read", "follow"], user: user)
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(:ok) == %{
+               "settings" => %{"enabled" => true, "totp" => true}
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/backup_codes" do
+    test "returns backup codes", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: "secret"}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/backup_codes")
+        |> json_response(:ok)
+
+      assert [<<_::bytes-size(6)>>, <<_::bytes-size(6)>>] = response["codes"]
+      user = refresh_record(user)
+      mfa_settings = user.multi_factor_authentication_settings
+      assert mfa_settings.totp.secret == "secret"
+      refute mfa_settings.backup_codes == ["1", "2", "3"]
+      refute mfa_settings.backup_codes == []
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa/backup_codes")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/setup/totp" do
+    test "return errors when method is invalid", %{conn: conn} do
+      user = insert(:user)
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/setup/torf")
+        |> json_response(400)
+
+      assert response == %{"error" => "undefined method"}
+    end
+
+    test "returns key and provisioning_uri", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{backup_codes: ["1", "2", "3"]}
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/setup/totp")
+        |> json_response(:ok)
+
+      user = refresh_record(user)
+      mfa_settings = user.multi_factor_authentication_settings
+      secret = mfa_settings.totp.secret
+      refute mfa_settings.enabled
+      assert mfa_settings.backup_codes == ["1", "2", "3"]
+
+      assert response == %{
+               "key" => secret,
+               "provisioning_uri" => TOTP.provisioning_uri(secret, "#{user.email}")
+             }
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa/setup/totp")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/confirm/totp" do
+    test "returns success result", %{conn: conn} do
+      secret = TOTP.generate_secret()
+      code = TOTP.generate_token(secret)
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+             |> json_response(:ok)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      assert settings.enabled
+      assert settings.totp.secret == secret
+      assert settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+
+    test "returns error if password incorrect", %{conn: conn} do
+      secret = TOTP.generate_secret()
+      code = TOTP.generate_token(secret)
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "xxx", code: code})
+        |> json_response(422)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      refute settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+      assert response == %{"error" => "Invalid password."}
+    end
+
+    test "returns error if code incorrect", %{conn: conn} do
+      secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+        |> json_response(422)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      refute settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+      assert response == %{"error" => "invalid_token"}
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "DELETE /api/pleroma/accounts/mfa/totp" do
+    test "returns success result", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: "secret"}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+             |> json_response(:ok)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      assert settings.totp.secret == nil
+      refute settings.totp.confirmed
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+end
diff --git a/test/web/streamer/ping_test.exs b/test/web/streamer/ping_test.exs
deleted file mode 100644 (file)
index 5df6c1c..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.PingTest do
-  use Pleroma.DataCase
-
-  import Pleroma.Factory
-  alias Pleroma.Web.Streamer
-
-  setup do
-    start_supervised({Streamer.supervisor(), [ping_interval: 30]})
-
-    :ok
-  end
-
-  describe "sockets" do
-    setup do
-      user = insert(:user)
-      {:ok, %{user: user}}
-    end
-
-    test "it sends pings", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, 40
-          assert_receive {:text, received_event}, 40
-          assert_receive {:text, received_event}, 40
-        end)
-
-      Streamer.add_socket("public", %{transport_pid: task.pid, assigns: %{user: user}})
-
-      Task.await(task)
-    end
-  end
-end
diff --git a/test/web/streamer/state_test.exs b/test/web/streamer/state_test.exs
deleted file mode 100644 (file)
index a755e75..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.StateTest do
-  use Pleroma.DataCase
-
-  import Pleroma.Factory
-  alias Pleroma.Web.Streamer
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @moduletag needs_streamer: true
-
-  describe "sockets" do
-    setup do
-      user = insert(:user)
-      user2 = insert(:user)
-      {:ok, %{user: user, user2: user2}}
-    end
-
-    test "it can add a socket", %{user: user} do
-      Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
-
-      assert(%{"public" => [%StreamerSocket{transport_pid: 1}]} = Streamer.get_sockets())
-    end
-
-    test "it can add multiple sockets per user", %{user: user} do
-      Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
-      Streamer.add_socket("public", %{transport_pid: 2, assigns: %{user: user}})
-
-      assert(
-        %{
-          "public" => [
-            %StreamerSocket{transport_pid: 2},
-            %StreamerSocket{transport_pid: 1}
-          ]
-        } = Streamer.get_sockets()
-      )
-    end
-
-    test "it will not add a duplicate socket", %{user: user} do
-      Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
-      Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
-
-      assert(
-        %{
-          "activity" => [
-            %StreamerSocket{transport_pid: 1}
-          ]
-        } = Streamer.get_sockets()
-      )
-    end
-  end
-end
index 3c0f240f54168daaa4e91e8567f0bdb00be04a13..ee530f4e94515db157efd0e5264b9b63e3abc3ca 100644 (file)
@@ -12,13 +12,9 @@ defmodule Pleroma.Web.StreamerTest do
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.Streamer
-  alias Pleroma.Web.Streamer.StreamerSocket
-  alias Pleroma.Web.Streamer.Worker
 
   @moduletag needs_streamer: true, capture_log: true
 
-  @streamer_timeout 150
-  @streamer_start_wait 10
   setup do: clear_config([:instance, :skip_thread_containment])
 
   describe "user streams" do
@@ -29,69 +25,35 @@ defmodule Pleroma.Web.StreamerTest do
     end
 
     test "it streams the user's post in the 'user' stream", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user", user)
       {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
-
-      Streamer.stream("user", activity)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
 
     test "it streams boosts of the user in the 'user' stream", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user", user)
 
       other_user = insert(:user)
       {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"})
       {:ok, announce, _} = CommonAPI.repeat(activity.id, user)
 
-      Streamer.stream("user", announce)
-      Task.await(task)
+      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+      refute Streamer.filtered_by_user?(user, announce)
     end
 
     test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user", user)
       Streamer.stream("user", notify)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^notify}
+      refute Streamer.filtered_by_user?(user, notify)
     end
 
     test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user:notification", user)
       Streamer.stream("user:notification", notify)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^notify}
+      refute Streamer.filtered_by_user?(user, notify)
     end
 
     test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{
@@ -100,18 +62,12 @@ defmodule Pleroma.Web.StreamerTest do
       blocked = insert(:user)
       {:ok, _user_relationship} = User.block(user, blocked)
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user:notification", user)
 
       {:ok, activity} = CommonAPI.post(user, %{"status" => ":("})
-      {:ok, notif} = CommonAPI.favorite(blocked, activity.id)
+      {:ok, _} = CommonAPI.favorite(blocked, activity.id)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      refute_receive _
     end
 
     test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{
@@ -119,45 +75,50 @@ defmodule Pleroma.Web.StreamerTest do
     } do
       user2 = insert(:user)
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
+      {:ok, _} = CommonAPI.add_mute(user, activity)
 
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user:notification", user)
 
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-      {:ok, activity} = CommonAPI.add_mute(user, activity)
-      {:ok, notif} = CommonAPI.favorite(user2, activity.id)
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      refute_receive _
+      assert Streamer.filtered_by_user?(user, favorite_activity)
     end
 
-    test "it doesn't send notify to the 'user:notification' stream' when a domain is blocked", %{
+    test "it sends favorite to 'user:notification' stream'", %{
       user: user
     } do
       user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
+      Streamer.add_socket("user:notification", user)
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
+
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert notif.activity.id == favorite_activity.id
+      refute Streamer.filtered_by_user?(user, notif)
+    end
 
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+    test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{
+      user: user
+    } do
+      user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
 
       {:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
       {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-      {:ok, notif} = CommonAPI.favorite(user2, activity.id)
+      Streamer.add_socket("user:notification", user)
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      refute_receive _
+      assert Streamer.filtered_by_user?(user, favorite_activity)
     end
 
     test "it sends follow activities to the 'user:notification' stream", %{
       user: user
     } do
       user_url = user.ap_id
+      user2 = insert(:user)
 
       body =
         File.read!("test/fixtures/users_mock/localhost.json")
@@ -169,47 +130,24 @@ defmodule Pleroma.Web.StreamerTest do
           %Tesla.Env{status: 200, body: body}
       end)
 
-      user2 = insert(:user)
-      task = Task.async(fn -> assert_receive {:text, _}, @streamer_timeout end)
-
-      Process.sleep(@streamer_start_wait)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user:notification", user)
+      {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
 
-      {:ok, _follower, _followed, _activity} = CommonAPI.follow(user2, user)
-
-      # We don't directly pipe the notification to the streamer as it's already
-      # generated as a side effect of CommonAPI.follow().
-      Task.await(task)
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert notif.activity.id == follow_activity.id
+      refute Streamer.filtered_by_user?(user, notif)
     end
   end
 
-  test "it sends to public" do
+  test "it sends to public authenticated" do
     user = insert(:user)
     other_user = insert(:user)
 
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, @streamer_timeout
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user
-    }
-
-    {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
+    Streamer.add_socket("public", other_user)
 
-    topics = %{
-      "public" => [fake_socket]
-    }
-
-    Worker.push_to_socket(topics, "public", activity)
-
-    Task.await(task)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
+    assert_receive {:render_with_user, _, _, ^activity}
+    refute Streamer.filtered_by_user?(user, activity)
   end
 
   test "works for deletions" do
@@ -217,37 +155,32 @@ defmodule Pleroma.Web.StreamerTest do
     other_user = insert(:user)
     {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
 
-    task =
-      Task.async(fn ->
-        expected_event =
-          %{
-            "event" => "delete",
-            "payload" => activity.id
-          }
-          |> Jason.encode!()
-
-        assert_receive {:text, received_event}, @streamer_timeout
-        assert received_event == expected_event
-      end)
+    Streamer.add_socket("public", user)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user
-    }
+    {:ok, _} = CommonAPI.delete(activity.id, other_user)
+    activity_id = activity.id
+    assert_receive {:text, event}
+    assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
+  end
 
-    {:ok, activity} = CommonAPI.delete(activity.id, other_user)
+  test "it sends to public unauthenticated" do
+    user = insert(:user)
 
-    topics = %{
-      "public" => [fake_socket]
-    }
+    Streamer.add_socket("public", nil)
 
-    Worker.push_to_socket(topics, "public", activity)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
+    activity_id = activity.id
+    assert_receive {:text, event}
+    assert %{"event" => "update", "payload" => payload} = Jason.decode!(event)
+    assert %{"id" => ^activity_id} = Jason.decode!(payload)
 
-    Task.await(task)
+    {:ok, _} = CommonAPI.delete(activity.id, user)
+    assert_receive {:text, event}
+    assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
   end
 
   describe "thread_containment" do
-    test "it doesn't send to user if recipients invalid and thread containment is enabled" do
+    test "it filters to user if recipients invalid and thread containment is enabled" do
       Pleroma.Config.put([:instance, :skip_thread_containment], false)
       author = insert(:user)
       user = insert(:user)
@@ -262,12 +195,10 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> refute_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
-
-      Task.await(task)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
+      assert_receive {:render_with_user, _, _, ^activity}
+      assert Streamer.filtered_by_user?(user, activity)
     end
 
     test "it sends message if recipients invalid and thread containment is disabled" do
@@ -285,12 +216,11 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
 
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
 
     test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do
@@ -308,255 +238,168 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
 
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
   end
 
   describe "blocks" do
-    test "it doesn't send messages involving blocked users" do
+    test "it filters messages involving blocked users" do
       user = insert(:user)
       blocked_user = insert(:user)
       {:ok, _user_relationship} = User.block(user, blocked_user)
 
+      Streamer.add_socket("public", user)
       {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"})
-
-      task =
-        Task.async(fn ->
-          refute_receive {:text, _}, 1_000
-        end)
-
-      fake_socket = %StreamerSocket{
-        transport_pid: task.pid,
-        user: user
-      }
-
-      topics = %{
-        "public" => [fake_socket]
-      }
-
-      Worker.push_to_socket(topics, "public", activity)
-
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      assert Streamer.filtered_by_user?(user, activity)
     end
 
-    test "it doesn't send messages transitively involving blocked users" do
+    test "it filters messages transitively involving blocked users" do
       blocker = insert(:user)
       blockee = insert(:user)
       friend = insert(:user)
 
-      task =
-        Task.async(fn ->
-          refute_receive {:text, _}, 1_000
-        end)
-
-      fake_socket = %StreamerSocket{
-        transport_pid: task.pid,
-        user: blocker
-      }
-
-      topics = %{
-        "public" => [fake_socket]
-      }
+      Streamer.add_socket("public", blocker)
 
       {:ok, _user_relationship} = User.block(blocker, blockee)
 
       {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_one)
+      assert_receive {:render_with_user, _, _, ^activity_one}
+      assert Streamer.filtered_by_user?(blocker, activity_one)
 
       {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_two)
+      assert_receive {:render_with_user, _, _, ^activity_two}
+      assert Streamer.filtered_by_user?(blocker, activity_two)
 
       {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_three)
-
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity_three}
+      assert Streamer.filtered_by_user?(blocker, activity_three)
     end
   end
 
-  test "it doesn't send unwanted DMs to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
-    user_c = insert(:user)
-
-    {:ok, user_a} = User.follow(user_a, user_b)
-
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
-
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "@#{user_c.nickname} Test",
-        "visibility" => "direct"
-      })
-
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
-
-    topics = %{
-      "list:#{list.id}" => [fake_socket]
-    }
-
-    Worker.handle_call({:stream, "list", activity}, self(), topics)
-
-    Task.await(task)
-  end
-
-  test "it doesn't send unwanted private posts to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
+  describe "lists" do
+    test "it doesn't send unwanted DMs to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
+      user_c = insert(:user)
 
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
+      {:ok, user_a} = User.follow(user_a, user_b)
 
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "Test",
-        "visibility" => "private"
-      })
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
 
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
+      Streamer.add_socket("list:#{list.id}", user_a)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
+      {:ok, _activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "@#{user_c.nickname} Test",
+          "visibility" => "direct"
+        })
 
-    topics = %{
-      "list:#{list.id}" => [fake_socket]
-    }
+      refute_receive _
+    end
 
-    Worker.handle_call({:stream, "list", activity}, self(), topics)
+    test "it doesn't send unwanted private posts to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
 
-    Task.await(task)
-  end
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
 
-  test "it sends wanted private posts to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
+      Streamer.add_socket("list:#{list.id}", user_a)
 
-    {:ok, user_a} = User.follow(user_a, user_b)
+      {:ok, _activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "Test",
+          "visibility" => "private"
+        })
 
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
+      refute_receive _
+    end
 
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "Test",
-        "visibility" => "private"
-      })
+    test "it sends wanted private posts to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
 
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, 1_000
-      end)
+      {:ok, user_a} = User.follow(user_a, user_b)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
 
-    Streamer.add_socket(
-      "list:#{list.id}",
-      fake_socket
-    )
+      Streamer.add_socket("list:#{list.id}", user_a)
 
-    Worker.handle_call({:stream, "list", activity}, self(), %{})
+      {:ok, activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "Test",
+          "visibility" => "private"
+        })
 
-    Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user_a, activity)
+    end
   end
 
-  test "it doesn't send muted reblogs" do
-    user1 = insert(:user)
-    user2 = insert(:user)
-    user3 = insert(:user)
-    CommonAPI.hide_reblogs(user1, user2)
-
-    {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
-    {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
-
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user1
-    }
-
-    topics = %{
-      "public" => [fake_socket]
-    }
-
-    Worker.push_to_socket(topics, "public", announce_activity)
+  describe "muted reblogs" do
+    test "it filters muted reblogs" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      user3 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    Task.await(task)
-  end
+      {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
 
-  test "it does send non-reblog notification for reblog-muted actors" do
-    user1 = insert(:user)
-    user2 = insert(:user)
-    user3 = insert(:user)
-    CommonAPI.hide_reblogs(user1, user2)
+      Streamer.add_socket("user", user1)
+      {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
+      assert_receive {:render_with_user, _, _, ^announce_activity}
+      assert Streamer.filtered_by_user?(user1, announce_activity)
+    end
 
-    {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
-    {:ok, favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
+    test "it filters reblog notification for reblog-muted actors" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, 1_000
-      end)
+      {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
+      Streamer.add_socket("user", user1)
+      {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user1
-    }
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert Streamer.filtered_by_user?(user1, notif)
+    end
 
-    topics = %{
-      "public" => [fake_socket]
-    }
+    test "it send non-reblog notification for reblog-muted actors" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    Worker.push_to_socket(topics, "public", favorite_activity)
+      {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
+      Streamer.add_socket("user", user1)
+      {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
 
-    Task.await(task)
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      refute Streamer.filtered_by_user?(user1, notif)
+    end
   end
 
-  test "it doesn't send posts from muted threads" do
+  test "it filters posts from muted threads" do
     user = insert(:user)
     user2 = insert(:user)
+    Streamer.add_socket("user", user2)
     {:ok, user2, user, _activity} = CommonAPI.follow(user2, user)
-
     {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-
-    {:ok, activity} = CommonAPI.add_mute(user2, activity)
-
-    task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
-
-    Streamer.add_socket(
-      "user",
-      %{transport_pid: task.pid, assigns: %{user: user2}}
-    )
-
-    Streamer.stream("user", activity)
-    Task.await(task)
+    {:ok, _} = CommonAPI.add_mute(user2, activity)
+    assert_receive {:render_with_user, _, _, ^activity}
+    assert Streamer.filtered_by_user?(user2, activity)
   end
 
   describe "direct streams" do
@@ -568,22 +411,7 @@ defmodule Pleroma.Web.StreamerTest do
       user = insert(:user)
       another_user = insert(:user)
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-
-          assert %{"event" => "conversation", "payload" => received_payload} =
-                   Jason.decode!(received_event)
-
-          assert %{"last_status" => last_status} = Jason.decode!(received_payload)
-          [participation] = Participation.for_user(user)
-          assert last_status["pleroma"]["direct_conversation_id"] == participation.id
-        end)
-
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("direct", user)
 
       {:ok, _create_activity} =
         CommonAPI.post(another_user, %{
@@ -591,42 +419,47 @@ defmodule Pleroma.Web.StreamerTest do
           "visibility" => "direct"
         })
 
-      Task.await(task)
+      assert_receive {:text, received_event}
+
+      assert %{"event" => "conversation", "payload" => received_payload} =
+               Jason.decode!(received_event)
+
+      assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+      [participation] = Participation.for_user(user)
+      assert last_status["pleroma"]["direct_conversation_id"] == participation.id
     end
 
     test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted" do
       user = insert(:user)
       another_user = insert(:user)
 
+      Streamer.add_socket("direct", user)
+
       {:ok, create_activity} =
         CommonAPI.post(another_user, %{
           "status" => "hi @#{user.nickname}",
           "visibility" => "direct"
         })
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-          assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
+      create_activity_id = create_activity.id
+      assert_receive {:render_with_user, _, _, ^create_activity}
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
 
-          refute_receive {:text, _}, @streamer_timeout
-        end)
+      {:ok, _} = CommonAPI.delete(create_activity_id, another_user)
 
-      Process.sleep(@streamer_start_wait)
+      assert_receive {:text, received_event}
 
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      assert %{"event" => "delete", "payload" => ^create_activity_id} =
+               Jason.decode!(received_event)
 
-      {:ok, _} = CommonAPI.delete(create_activity.id, another_user)
-
-      Task.await(task)
+      refute_receive _
     end
 
     test "it sends conversation update to the 'direct' stream when a message is deleted" do
       user = insert(:user)
       another_user = insert(:user)
+      Streamer.add_socket("direct", user)
 
       {:ok, create_activity} =
         CommonAPI.post(another_user, %{
@@ -636,35 +469,30 @@ defmodule Pleroma.Web.StreamerTest do
 
       {:ok, create_activity2} =
         CommonAPI.post(another_user, %{
-          "status" => "hi @#{user.nickname}",
+          "status" => "hi @#{user.nickname} 2",
           "in_reply_to_status_id" => create_activity.id,
           "visibility" => "direct"
         })
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-          assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
-
-          assert_receive {:text, received_event}, @streamer_timeout
+      assert_receive {:render_with_user, _, _, ^create_activity}
+      assert_receive {:render_with_user, _, _, ^create_activity2}
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
 
-          assert %{"event" => "conversation", "payload" => received_payload} =
-                   Jason.decode!(received_event)
-
-          assert %{"last_status" => last_status} = Jason.decode!(received_payload)
-          assert last_status["id"] == to_string(create_activity.id)
-        end)
+      {:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
 
-      Process.sleep(@streamer_start_wait)
+      assert_receive {:text, received_event}
+      assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
 
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      assert_receive {:text, received_event}
 
-      {:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
+      assert %{"event" => "conversation", "payload" => received_payload} =
+               Jason.decode!(received_event)
 
-      Task.await(task)
+      assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+      assert last_status["id"] == to_string(create_activity.id)
     end
   end
 end
index 5ff8694a8d40d518cb12476331f7eaa95d5c5182..f7e54c26ae6ed5d2ce7ef54b11f34ca6d845a188 100644 (file)
@@ -6,11 +6,14 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
   use Pleroma.Web.ConnCase
 
   alias Pleroma.Config
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
 
   import ExUnit.CaptureLog
   import Pleroma.Factory
+  import Ecto.Query
 
   setup do
     Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -160,6 +163,119 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
     end
   end
 
+  describe "POST /ostatus_subscribe - follow/2 with enabled Two-Factor Auth " do
+    test "render the MFA login form", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      user2 = insert(:user)
+
+      response =
+        conn
+        |> post(remote_follow_path(conn, :do_follow), %{
+          "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
+        })
+        |> response(200)
+
+      mfa_token = Pleroma.Repo.one(from(q in Pleroma.MFA.Token, where: q.user_id == ^user.id))
+
+      assert response =~ "Two-factor authentication"
+      assert response =~ "Authentication code"
+      assert response =~ mfa_token.token
+      refute user2.follower_address in User.following(user)
+    end
+
+    test "returns error when password is incorrect", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      user2 = insert(:user)
+
+      response =
+        conn
+        |> post(remote_follow_path(conn, :do_follow), %{
+          "authorization" => %{"name" => user.nickname, "password" => "test1", "id" => user2.id}
+        })
+        |> response(200)
+
+      assert response =~ "Wrong username or password"
+      refute user2.follower_address in User.following(user)
+    end
+
+    test "follows", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+      user2 = insert(:user)
+      otp_token = TOTP.generate_token(otp_secret)
+
+      conn =
+        conn
+        |> post(
+          remote_follow_path(conn, :do_follow),
+          %{
+            "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+          }
+        )
+
+      assert redirected_to(conn) == "/users/#{user2.id}"
+      assert user2.follower_address in User.following(user)
+    end
+
+    test "returns error when auth code is incorrect", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+      user2 = insert(:user)
+      otp_token = TOTP.generate_token(TOTP.generate_secret())
+
+      response =
+        conn
+        |> post(
+          remote_follow_path(conn, :do_follow),
+          %{
+            "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+          }
+        )
+        |> response(200)
+
+      assert response =~ "Wrong authentication code"
+      refute user2.follower_address in User.following(user)
+    end
+  end
+
   describe "POST /ostatus_subscribe - follow/2 without assigned user " do
     test "follows", %{conn: conn} do
       user = insert(:user)