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

config/config.exs
config/test.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

index 11ee220e737b2054f04f733059164fdee6558e41..34b516e02f983eedacf96bb6caf9a11948b966ad 100644 (file)
@@ -12,7 +12,7 @@ config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes
 
 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"
index 51aace4077debd3dfa1d567dc8968825388ad473..67ed4737f12c7aa03dccb5a17574a330f47fa5fe 100644 (file)
@@ -9,7 +9,8 @@ config :pleroma, Pleroma.Web.Endpoint,
 
 # Disable captha for tests
 config :pleroma, Pleroma.Captcha,
-  enabled: true,
+  # It should not be enabled for automatic tests
+  enabled: false,
   # A fake captcha service for tests
   method: Pleroma.Captcha.Mock
 
index f4bcae3fd0251635e09e6c7d07f22df05ecb3559..1c3219efee3498f6fa13f4a1d71af62549a55a5c 100644 (file)
@@ -172,7 +172,7 @@ Web Push Notifications configuration. You can use the mix task `mix web_push.gen
 ## 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,
index 4542ed6230cf02ca869212e9c89f2e9d5f5139fc..cb3e6b69b0c43f3e16ce5bf8ae60635cca21740a 100644 (file)
@@ -29,6 +29,16 @@ defmodule Pleroma.Application do
         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,
           [
index 133a9fd68cf67a73a79e5c2704bc12d4b341626a..0207bcbea6261df88c000bb8d7b9d27d9c42ab2e 100644 (file)
@@ -3,9 +3,11 @@
 # 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
@@ -14,14 +16,6 @@ defmodule Pleroma.Captcha 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 +29,8 @@ defmodule Pleroma.Captcha do
   @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
@@ -46,24 +40,71 @@ defmodule Pleroma.Captcha do
     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..8d27c04f1e5a5a621c91d7ac8be3ae3b560dc972 100644 (file)
@@ -8,9 +8,14 @@ defmodule Pleroma.Captcha.Service do
 
   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.
@@ -18,15 +23,15 @@ defmodule Pleroma.Captcha.Service do
   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..34a6114928eb47ae782e546e0d8fad5b7722ad17 100644 (file)
@@ -3,13 +3,9 @@
 # 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])
@@ -21,51 +17,21 @@ defmodule Pleroma.Captcha.Kocaptcha do
       {: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..ecf81d492799aaa1fa179ef1ccbbbef9ce75779f 100644 (file)
@@ -140,22 +140,28 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
       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])
 
index 7f559ac72d764611f4343f2df515031852a03407..7ca9a460701287cc1df15381d4b1838b07100d5c 100644 (file)
@@ -29,16 +29,18 @@ defmodule Pleroma.CaptchaTest do
     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..9061f2b4549cdf4d4001ef2e7a4a231f9df1383e 100644 (file)
@@ -10,8 +10,5 @@ defmodule Pleroma.Captcha.Mock do
   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