diff options
70 files changed, 2857 insertions, 979 deletions
| diff --git a/config/config.exs b/config/config.exs index ca9bbab64..e703c1632 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/config/description.exs b/config/description.exs index 1b2afebef..39e094082 100644 --- a/config/description.exs +++ b/config/description.exs @@ -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." +              } +            ] +          } +        ]        }      ]    }, diff --git a/config/test.exs b/config/test.exs index cbf775109..e38b9967d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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: diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 23af08961..c455047cc 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -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 diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index b927be026..5895613a3 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -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` diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2524918d4..707d7fdbd 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -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`. diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 308d8cffa..a00bc0624 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -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 index 000000000..d353a4dad --- /dev/null +++ b/lib/pleroma/mfa.ex @@ -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 index 000000000..2b5ec34f8 --- /dev/null +++ b/lib/pleroma/mfa/backup_codes.ex @@ -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 index 000000000..9b020aa8e --- /dev/null +++ b/lib/pleroma/mfa/changeset.ex @@ -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 index 000000000..2764b889c --- /dev/null +++ b/lib/pleroma/mfa/settings.ex @@ -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 index 000000000..25ff7fb29 --- /dev/null +++ b/lib/pleroma/mfa/token.ex @@ -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 index 000000000..1407afc57 --- /dev/null +++ b/lib/pleroma/mfa/totp.ex @@ -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 diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 9d5176e2b..3fe550806 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -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 diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 323eb2a41..a6f51f0be 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -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 diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 697336019..75b7b1b59 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -177,12 +177,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 -> @@ -205,6 +199,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), @@ -281,6 +284,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 @@ -308,6 +312,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 @@ -332,6 +337,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 @@ -351,6 +357,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 @@ -372,6 +379,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 @@ -398,6 +406,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do           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), +         _ <- notify_and_stream(activity),           :ok <- maybe_federate(activity) do        {:ok, activity, object}      else @@ -420,6 +429,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do           {:ok, unlike_activity} <- insert(unlike_data, local),           {:ok, _activity} <- Repo.delete(like_activity),           {:ok, object} <- remove_like_from_object(like_activity, object), +         _ <- notify_and_stream(unlike_activity),           :ok <- maybe_federate(unlike_activity) do        {:ok, unlike_activity, like_activity, object}      else @@ -449,6 +459,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 @@ -475,6 +486,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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), +         _ <- notify_and_stream(unannounce_activity),           :ok <- maybe_federate(unannounce_activity),           {:ok, _activity} <- Repo.delete(announce_activity),           {:ok, object} <- remove_announce_from_object(announce_activity, object) do @@ -497,6 +509,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 @@ -518,6 +531,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 @@ -547,6 +561,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 @@ -567,6 +582,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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), +         _ <- notify_and_stream(activity),           :ok <- maybe_federate(activity) do        {:ok, activity}      else @@ -601,6 +617,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) @@ -624,7 +641,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", %{ diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 80a4ebaac..9f1fd3aeb 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -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 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 index 000000000..e15c7dc95 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -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/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex index 0474b550b..c62096db0 100644 --- a/lib/pleroma/web/api_spec/schemas/poll.ex +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -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 diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index cb09664ce..a8f554aa3 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -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 index 000000000..98aca9a51 --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -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 diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index b0b1bd559..47fd6a523 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -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 diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index af9b66eff..db46ffcfc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -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), diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 5652a37c1..6ef3fe2dd 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -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, user, item)}, %{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 index 000000000..e52cccd85 --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -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 index 000000000..e88e7066b --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -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 diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 685269877..7c804233c 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -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 -> @@ -181,6 +184,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do    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},           %{"authorization" => _} = params         ) do @@ -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 index 000000000..2c3bb9ded --- /dev/null +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -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 diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 6f4713dee..0e72c31e9 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -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 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 index 000000000..eb9989cdf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 6b16cfa5d..4b264c43e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -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 index 7a08202a9..000000000 --- a/lib/pleroma/web/streamer/ping.ex +++ /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 index 999550b88..000000000 --- a/lib/pleroma/web/streamer/state.ex +++ /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 diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 814d5a729..5ad4aa936 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -3,53 +3,241 @@  # 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 index 7d5dcd34e..000000000 --- a/lib/pleroma/web/streamer/streamer_socket.ex +++ /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 index bd9029bc0..000000000 --- a/lib/pleroma/web/streamer/supervisor.ex +++ /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 index f6160fa4d..000000000 --- a/lib/pleroma/web/streamer/worker.ex +++ /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 index 000000000..750f65386 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -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 index 000000000..af6e546b0 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -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 index 000000000..adc3a3e3d --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex @@ -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 %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 89da760da..521dc9322 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -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 @@ -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"}, @@ -37,7 +37,7 @@    "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"},    "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},    "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, -  "ex_syslogger": {:hex, :ex_syslogger, "1.5.0", "bc936ee3fd13d9e592cb4c3a1e8a55fccd33b05e3aa7b185f211f3ed263ff8f0", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.0.5", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "f3b4b184dcdd5f356b7c26c6cd72ab0918ba9dfb4061ccfaf519e562942af87b"}, +  "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"},    "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},    "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},    "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, @@ -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"}, @@ -102,7 +103,7 @@    "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},    "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"},    "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, -  "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, +  "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"},    "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},    "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]},    "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, 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 index 000000000..8b653c61f --- /dev/null +++ b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs @@ -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 index 000000000..da9f8fabe --- /dev/null +++ b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs @@ -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.woffBinary files differ deleted file mode 100644 index 02b9a2539..000000000 --- a/priv/static/adminfe/static/fonts/element-icons.535877f.woff +++ /dev/null diff --git a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf b/priv/static/adminfe/static/fonts/element-icons.732389d.ttfBinary files differ deleted file mode 100644 index 91b74de36..000000000 --- a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf +++ /dev/null diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index bd229c55f..109c7b4cb 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -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 index 000000000..7bc01b36b --- /dev/null +++ b/test/mfa/backup_codes_test.exs @@ -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 index 000000000..50153d208 --- /dev/null +++ b/test/mfa/totp_test.exs @@ -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 index 000000000..94bc48c26 --- /dev/null +++ b/test/mfa_test.exs @@ -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 diff --git a/test/notification_test.exs b/test/notification_test.exs index 601a6c0ca..5c85f3368 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -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) diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs index 4e6142aab..a0667c5e0 100644 --- a/test/plugs/ensure_authenticated_plug_test.exs +++ b/test/plugs/ensure_authenticated_plug_test.exs @@ -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        %{ diff --git a/test/support/builders/activity_builder.ex b/test/support/builders/activity_builder.ex index 6e5a8e059..7c4950bfa 100644 --- a/test/support/builders/activity_builder.ex +++ b/test/support/builders/activity_builder.ex @@ -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 diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex index fcfea666f..0d0490714 100644 --- a/test/support/builders/user_builder.ex +++ b/test/support/builders/user_builder.ex @@ -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{}      } diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 91c03b1a8..b23918dd1 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -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()} diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 1669f2520..ba8848952 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex index 495764782..c8c45e2a7 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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 diff --git a/test/user_search_test.exs b/test/user_search_test.exs index cb847b516..17c63322a 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -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 diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 7ab7cc15c..4697af50e 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -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 index 000000000..7125c5081 --- /dev/null +++ b/test/web/auth/pleroma_authenticator_test.exs @@ -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 index 000000000..e08069490 --- /dev/null +++ b/test/web/auth/totp_authenticator_test.exs @@ -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 diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs index 88b13a25a..d8f34aa86 100644 --- a/test/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/web/mastodon_api/controllers/poll_controller_test.exs @@ -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 diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs new file mode 100644 index 000000000..ce4a07320 --- /dev/null +++ b/test/web/oauth/mfa_controller_test.exs @@ -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 diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index f2f98d768..7a107584d 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -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"]) 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 index 000000000..d23d08a00 --- /dev/null +++ b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs @@ -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 index 5df6c1cc3..000000000 --- a/test/web/streamer/ping_test.exs +++ /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 index a755e75c0..000000000 --- a/test/web/streamer/state_test.exs +++ /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 diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 3c0f240f5..ee530f4e9 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -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 diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs index 5ff8694a8..f7e54c26a 100644 --- a/test/web/twitter_api/remote_follow_controller_test.exs +++ b/test/web/twitter_api/remote_follow_controller_test.exs @@ -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) | 
