Merge branch 'captcha' into 'develop'
authorrinpatch <rinpatch@sdf.org>
Thu, 3 Jan 2019 17:04:27 +0000 (17:04 +0000)
committerrinpatch <rinpatch@sdf.org>
Thu, 3 Jan 2019 17:04:27 +0000 (17:04 +0000)
Make captcha (kocaptcha) stateless

See merge request pleroma/pleroma!585

1  2 
config/config.exs
docs/config.md
lib/pleroma/application.ex
lib/pleroma/captcha/captcha.ex
lib/pleroma/captcha/captcha_service.ex
lib/pleroma/captcha/kocaptcha.ex
lib/pleroma/web/twitter_api/twitter_api.ex
test/captcha_test.exs
test/support/captcha_mock.ex

diff --combined config/config.exs
index 11ee220e737b2054f04f733059164fdee6558e41,9cb81cf4739a8e201a703e67303e5a181eaf8d13..34b516e02f983eedacf96bb6caf9a11948b966ad
@@@ -12,7 -12,7 +12,7 @@@ config :pleroma, Pleroma.Repo, types: P
  
  config :pleroma, Pleroma.Captcha,
    enabled: false,
-   seconds_retained: 180,
+   seconds_valid: 60,
    method: Pleroma.Captcha.Kocaptcha
  
  config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch"
@@@ -54,17 -54,6 +54,17 @@@ config :pleroma, :uri_schemes
      "xmpp"
    ]
  
 +websocket_config = [
 +  path: "/websocket",
 +  serializer: [
 +    {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"},
 +    {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"}
 +  ],
 +  timeout: 60_000,
 +  transport_log: false,
 +  compress: false
 +]
 +
  # Configures the endpoint
  config :pleroma, Pleroma.Web.Endpoint,
    url: [host: "localhost"],
@@@ -73,8 -62,6 +73,8 @@@
        {:_,
         [
           {"/api/v1/streaming", Elixir.Pleroma.Web.MastodonAPI.WebsocketHandler, []},
 +         {"/socket/websocket", Phoenix.Endpoint.CowboyWebSocket,
 +          {nil, {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, websocket_config}}},
           {:_, Plug.Adapters.Cowboy.Handler, {Pleroma.Web.Endpoint, []}}
         ]}
      ]
@@@ -111,8 -98,7 +111,8 @@@ config :pleroma, :instance
    name: "Pleroma",
    email: "example@example.com",
    description: "A Pleroma instance, an alternative fediverse server",
 -  limit: 5000,
 +  limit: 5_000,
 +  remote_limit: 100_000,
    upload_limit: 16_000_000,
    avatar_upload_limit: 2_000_000,
    background_upload_limit: 4_000_000,
@@@ -151,8 -137,8 +151,8 @@@ config :pleroma, :fe
    logo_mask: true,
    logo_margin: "0.1em",
    background: "/static/aurora_borealis.jpg",
 -  redirect_root_no_login: "/~/main/all",
 -  redirect_root_login: "/~/main/friends",
 +  redirect_root_no_login: "/main/all",
 +  redirect_root_login: "/main/friends",
    show_instance_panel: true,
    scope_options_enabled: false,
    formatting_options_enabled: false,
@@@ -177,8 -163,6 +177,8 @@@ config :pleroma, :mrf_rejectnonpublic
    allow_followersonly: false,
    allow_direct: false
  
 +config :pleroma, :mrf_hellthread, threshold: 10
 +
  config :pleroma, :mrf_simple,
    media_removal: [],
    media_nsfw: [],
@@@ -234,46 -218,6 +234,46 @@@ config :cors_plug
    credentials: true,
    headers: ["Authorization", "Content-Type", "Idempotency-Key"]
  
 +config :pleroma, Pleroma.User,
 +  restricted_nicknames: [
 +    "about",
 +    "~",
 +    "main",
 +    "users",
 +    "settings",
 +    "objects",
 +    "activities",
 +    "web",
 +    "registration",
 +    "friend-requests",
 +    "pleroma",
 +    "api",
 +    "tag",
 +    "notice",
 +    "status",
 +    "user-search",
 +    "ostatus_subscribe",
 +    "oauth",
 +    "push",
 +    "relay",
 +    "inbox",
 +    ".well-known",
 +    "nodeinfo",
 +    "auth",
 +    "proxy",
 +    "dev",
 +    "internal",
 +    "media"
 +  ]
 +
 +config :pleroma, Pleroma.Web.Federator, max_jobs: 50
 +
 +config :pleroma, Pleroma.Web.Federator.RetryQueue,
 +  enabled: false,
 +  max_jobs: 20,
 +  initial_timeout: 30,
 +  max_retries: 5
 +
  # Import environment specific config. This must remain at the bottom
  # of this file so it overrides the configuration defined above.
  import_config "#{Mix.env()}.exs"
diff --combined docs/config.md
index f4bcae3fd0251635e09e6c7d07f22df05ecb3559,cfeca8eb45f9d7d87a522584df3efaef833c1c15..1c3219efee3498f6fa13f4a1d71af62549a55a5c
@@@ -63,7 -63,6 +63,7 @@@ config :pleroma, Pleroma.Mailer
  * `email`: Email used to reach an Administrator/Moderator of the instance
  * `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``
  * `limit`: Posts character limit (CW/Subject included in the counter)
 +* `remote_limit`: Hard character limit beyond which remote posts will be dropped.
  * `upload_limit`: File size limit of uploads (except for avatar, background, banner)
  * `avatar_upload_limit`: File size limit of user’s profile avatars
  * `background_upload_limit`: File size limit of user’s profile backgrounds
@@@ -122,9 -121,6 +122,9 @@@ This section is used to configure Plero
  * `allow_followersonly`: whether to allow followers-only posts
  * `allow_direct`: whether to allow direct messages
  
 +## :mrf_hellthread
 +* `threshold`: Number of mentioned users after which the message gets discarded as spam
 +
  ## :media_proxy
  * `enabled`: Enables proxying of remote media to the instance’s proxy
  * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.
@@@ -172,7 -168,7 +172,7 @@@ Web Push Notifications configuration. Y
  ## Pleroma.Captcha
  * `enabled`: Whether the captcha should be shown on registration
  * `method`: The method/service to use for captcha
- * `seconds_retained`: The time in seconds for which the captcha is valid (stored in the cache)
+ * `seconds_valid`: The time in seconds for which the captcha is valid
  
  ### Pleroma.Captcha.Kocaptcha
  Kocaptcha is a very simple captcha service with a single API endpoint,
@@@ -193,14 -189,3 +193,14 @@@ You can then d
  ```
  curl "http://localhost:4000/api/pleroma/admin/invite_token?admin_token=somerandomtoken"
  ```
 +
 +## Pleroma.Web.Federator
 +
 +* `max_jobs`: The maximum amount of parallel federation jobs running at the same time.
 +
 +## Pleroma.Web.Federator.RetryQueue
 +
 +* `enabled`: If set to `true`, failed federation jobs will be retried
 +* `max_jobs`: The maximum amount of parallel federation jbos running at the same time.
 +* `initial_timeout`: The initial timeout in seconds
 +* `max_retries`: The maximum number of times a federation job is retried
index 4542ed6230cf02ca869212e9c89f2e9d5f5139fc,8dbacf2582b54632a55eac81a9adb6bc5e277870..cb3e6b69b0c43f3e16ce5bf8ae60635cca21740a
@@@ -1,7 -1,3 +1,7 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Application do
    use Application
    import Supervisor.Spec
          supervisor(Pleroma.Repo, []),
          worker(Pleroma.Emoji, []),
          worker(Pleroma.Captcha, []),
+         worker(
+           Cachex,
+           [
+             :used_captcha_cache,
+             [
+               ttl_interval: :timer.seconds(Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid]))
+             ]
+           ],
+           id: :cachex_used_captcha_cache
+         ),
          worker(
            Cachex,
            [
            ],
            id: :cachex_object
          ),
 +        worker(
 +          Cachex,
 +          [
 +            :scrubber_cache,
 +            [
 +              limit: 2500
 +            ]
 +          ],
 +          id: :cachex_scrubber
 +        ),
          worker(
            Cachex,
            [
index 133a9fd68cf67a73a79e5c2704bc12d4b341626a,424ad4add2c78cca8fd418323bb61aedfe8b8c0b..0207bcbea6261df88c000bb8d7b9d27d9c42ab2e
@@@ -1,11 -1,9 +1,13 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Captcha do
-   use GenServer
+   alias Plug.Crypto.KeyGenerator
+   alias Plug.Crypto.MessageEncryptor
+   alias Calendar.DateTime
  
-   @ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}]
+   use GenServer
  
    @doc false
    def start_link() do
  
    @doc false
    def init(_) do
-     # Create a ETS table to store captchas
-     ets_name = Module.concat(method(), Ets)
-     ^ets_name = :ets.new(Module.concat(method(), Ets), @ets_options)
-     # Clean up old captchas every few minutes
-     seconds_retained = Pleroma.Config.get!([__MODULE__, :seconds_retained])
-     Process.send_after(self(), :cleanup, 1000 * seconds_retained)
      {:ok, nil}
    end
  
@@@ -35,8 -25,8 +29,8 @@@
    @doc """
    Ask the configured captcha service to validate the captcha
    """
-   def validate(token, captcha) do
-     GenServer.call(__MODULE__, {:validate, token, captcha})
+   def validate(token, captcha, answer_data) do
+     GenServer.call(__MODULE__, {:validate, token, captcha, answer_data})
    end
  
    @doc false
      if !enabled do
        {:reply, %{type: :none}, state}
      else
-       {:reply, method().new(), state}
+       new_captcha = method().new()
+       secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base])
+       # This make salt a little different for two keys
+       token = new_captcha[:token]
+       secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt")
+       sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign")
+       # Basicallty copy what Phoenix.Token does here, add the time to
+       # the actual data and make it a binary to then encrypt it
+       encrypted_captcha_answer =
+         %{
+           at: DateTime.now_utc(),
+           answer_data: new_captcha[:answer_data]
+         }
+         |> :erlang.term_to_binary()
+         |> MessageEncryptor.encrypt(secret, sign_secret)
+       {
+         :reply,
+         # Repalce the answer with the encrypted answer
+         %{new_captcha | answer_data: encrypted_captcha_answer},
+         state
+       }
      end
    end
  
    @doc false
-   def handle_call({:validate, token, captcha}, _from, state) do
-     {:reply, method().validate(token, captcha), state}
-   end
+   def handle_call({:validate, token, captcha, answer_data}, _from, state) do
+     secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base])
+     secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt")
+     sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign")
  
-   @doc false
-   def handle_info(:cleanup, state) do
-     :ok = method().cleanup()
+     # If the time found is less than (current_time - seconds_valid), then the time has already passed.
+     # Later we check that the time found is more than the presumed invalidatation time, that means
+     # that the data is still valid and the captcha can be checked
+     seconds_valid = Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])
+     valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid)
+     result =
+       with {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret),
+            %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do
+         try do
+           if DateTime.before?(at, valid_if_after), do: throw({:error, "CAPTCHA expired"})
+           if not is_nil(Cachex.get!(:used_captcha_cache, token)),
+             do: throw({:error, "CAPTCHA already used"})
+           res = method().validate(token, captcha, answer_md5)
+           # Throw if an error occurs
+           if res != :ok, do: throw(res)
+           # Mark this captcha as used
+           {:ok, _} =
+             Cachex.put(:used_captcha_cache, token, true, ttl: :timer.seconds(seconds_valid))
  
-     seconds_retained = Pleroma.Config.get!([__MODULE__, :seconds_retained])
-     # Schedule the next clenup
-     Process.send_after(self(), :cleanup, 1000 * seconds_retained)
+           :ok
+         catch
+           :throw, e -> e
+         end
+       else
+         _ -> {:error, "Invalid answer data"}
+       end
  
-     {:noreply, state}
+     {:reply, result, state}
    end
  
    defp method, do: Pleroma.Config.get!([__MODULE__, :method])
index a820751a8f6622ec7619caf77bfbf961ed9a3fb4,6c5ab6c36b7af7840004527384bbb5e21bb5032a..8d27c04f1e5a5a621c91d7ac8be3ae3b560dc972
@@@ -1,16 -1,17 +1,21 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Captcha.Service do
    @doc """
    Request new captcha from a captcha service.
  
    Returns:
  
-   Service-specific data for using the newly created captcha
+   Type/Name of the service, the token to identify the captcha,
+   the data of the answer and service-specific data to use the newly created captcha
    """
-   @callback new() :: map
+   @callback new() :: %{
+               type: atom(),
+               token: String.t(),
+               answer_data: any()
+             }
  
    @doc """
    Validated the provided captcha solution.
    Arguments:
    * `token` the captcha is associated with
    * `captcha` solution of the captcha to validate
+   * `answer_data` is the data needed to validate the answer (presumably encrypted)
  
    Returns:
  
    `true` if captcha is valid, `false` if not
    """
-   @callback validate(token :: String.t(), captcha :: String.t()) :: boolean
-   @doc """
-   This function is called periodically to clean up old captchas
-   """
-   @callback cleanup() :: :ok
+   @callback validate(
+               token :: String.t(),
+               captcha :: String.t(),
+               answer_data :: any()
+             ) :: :ok | {:error, String.t()}
  end
index 66f9ce7544bcd7260ade66c49f77902294175773,cd0eb6f2141d0360c69f710ad7a5ed9bd2649d27..34a6114928eb47ae782e546e0d8fad5b7722ad17
@@@ -1,15 -1,7 +1,11 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Captcha.Kocaptcha do
-   alias Calendar.DateTime
    alias Pleroma.Captcha.Service
    @behaviour Service
  
-   @ets __MODULE__.Ets
    @impl Service
    def new() do
      endpoint = Pleroma.Config.get!([__MODULE__, :endpoint])
        {:ok, res} ->
          json_resp = Poison.decode!(res.body)
  
-         token = json_resp["token"]
-         true =
-           :ets.insert(
-             @ets,
-             {token, json_resp["md5"], DateTime.now_utc() |> DateTime.Format.unix()}
-           )
-         %{type: :kocaptcha, token: token, url: endpoint <> json_resp["url"]}
-     end
-   end
-   @impl Service
-   def validate(token, captcha) do
-     with false <- is_nil(captcha),
-          [{^token, saved_md5, _}] <- :ets.lookup(@ets, token),
-          true <- :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(saved_md5) do
-       # Clear the saved value
-       :ets.delete(@ets, token)
-       true
-     else
-       _ -> false
+         %{
+           type: :kocaptcha,
+           token: json_resp["token"],
+           url: endpoint <> json_resp["url"],
+           answer_data: json_resp["md5"]
+         }
      end
    end
  
    @impl Service
-   def cleanup() do
-     seconds_retained = Pleroma.Config.get!([Pleroma.Captcha, :seconds_retained])
-     # If the time in ETS is less than current_time - seconds_retained, then the time has
-     # already passed
-     delete_after =
-       DateTime.subtract!(DateTime.now_utc(), seconds_retained) |> DateTime.Format.unix()
-     :ets.select_delete(
-       @ets,
-       [
-         {
-           {:_, :_, :"$1"},
-           [{:<, :"$1", {:const, delete_after}}],
-           [true]
-         }
-       ]
-     )
-     :ok
+   def validate(_token, captcha, answer_data) do
+     # Here the token is unsed, because the unencrypted captcha answer is just passed to method
+     if not is_nil(captcha) and
+          :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data),
+        do: :ok,
+        else: {:error, "Invalid CAPTCHA"}
    end
  end
index 0aa4a8d232b1ec4ed7320b1789c5109ba82b68b1,9e15f2c33e55038b04123c3a7d6b3d1d61874a40..ecf81d492799aaa1fa179ef1ccbbbef9ce75779f
@@@ -1,7 -1,3 +1,7 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
    alias Pleroma.{UserInviteToken, User, Activity, Repo, Object}
    alias Pleroma.{UserEmail, Mailer}
        password: params["password"],
        password_confirmation: params["confirm"],
        captcha_solution: params["captcha_solution"],
-       captcha_token: params["captcha_token"]
+       captcha_token: params["captcha_token"],
+       captcha_answer_data: params["captcha_answer_data"]
      }
  
      captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled])
      # true if captcha is disabled or enabled and valid, false otherwise
      captcha_ok =
        if !captcha_enabled do
-         true
+         :ok
        else
-         Pleroma.Captcha.validate(params[:captcha_token], params[:captcha_solution])
+         Pleroma.Captcha.validate(
+           params[:captcha_token],
+           params[:captcha_solution],
+           params[:captcha_answer_data]
+         )
        end
  
      # Captcha invalid
-     if not captcha_ok do
+     if captcha_ok != :ok do
+       {:error, error} = captcha_ok
        # I have no idea how this error handling works
-       {:error, %{error: Jason.encode!(%{captcha: ["Invalid CAPTCHA"]})}}
+       {:error, %{error: Jason.encode!(%{captcha: [error]})}}
      else
        registrations_open = Pleroma.Config.get([:instance, :registrations_open])
  
diff --combined test/captcha_test.exs
index 7f559ac72d764611f4343f2df515031852a03407,93b8930daab1c53aecfb51a080e0cd78c3554231..7ca9a460701287cc1df15381d4b1838b07100d5c
@@@ -1,7 -1,3 +1,7 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.CaptchaTest do
    use ExUnit.Case
  
      end
  
      test "new and validate" do
-       assert Kocaptcha.new() == %{
-                type: :kocaptcha,
-                token: "afa1815e14e29355e6c8f6b143a39fa2",
-                url: "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
-              }
+       new = Kocaptcha.new()
+       assert new[:type] == :kocaptcha
+       assert new[:token] == "afa1815e14e29355e6c8f6b143a39fa2"
+       assert new[:url] ==
+                "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
  
        assert Kocaptcha.validate(
-                "afa1815e14e29355e6c8f6b143a39fa2",
-                "7oEy8c"
-              )
+                new[:token],
+                "7oEy8c",
+                new[:answer_data]
+              ) == :ok
      end
    end
  end
index 3ab02916f19b1a62dc2a700fbbb3896258450dab,410318dc4e72c12a868babdf57ce0b189fc13efe..9061f2b4549cdf4d4001ef2e7a4a231f9df1383e
@@@ -1,7 -1,3 +1,7 @@@
 +# Pleroma: A lightweight social networking server
 +# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
 +# SPDX-License-Identifier: AGPL-3.0-only
 +
  defmodule Pleroma.Captcha.Mock do
    alias Pleroma.Captcha.Service
    @behaviour Service
@@@ -10,8 -6,5 +10,5 @@@
    def new(), do: %{type: :mock}
  
    @impl Service
-   def validate(_token, _captcha), do: true
-   @impl Service
-   def cleanup(), do: :ok
+   def validate(_token, _captcha, _data), do: :ok
  end