Make captcha (kocaptcha) stateless
authorEkaterina Vaartis <vaartis@cock.li>
Thu, 20 Dec 2018 21:32:37 +0000 (00:32 +0300)
committerEkaterina Vaartis <vaartis@cock.li>
Fri, 21 Dec 2018 06:55:47 +0000 (09:55 +0300)
Also rename seconds_retained to seconds_valid since that's how it is
now. Put it down from 180 to 20 seconds. The answer data is now
stored in an encrypted text transfered to the client and back, so no
ETS is needed

config/config.exs
docs/config.md
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 65d7346deaee6e4a7194502c6377b7c1bd4e57d1..b8ef448b423225c6ca216bac78f9af0051b2ffbc 100644 (file)
@@ -12,7 +12,7 @@ config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes
 
 config :pleroma, Pleroma.Captcha,
   enabled: false,
-  seconds_retained: 180,
+  seconds_valid: 20,
   method: Pleroma.Captcha.Kocaptcha
 
 config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch"
index c35769f21f0a75ec0ca37878d1d2f869c3c0563e..cfeca8eb45f9d7d87a522584df3efaef833c1c15 100644 (file)
@@ -168,7 +168,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 5630f6b571d2e709d3acee4a0c90c6ac47b94e2c..61a0f907f42bc78e5c19bdcfd3a00899796e5f39 100644 (file)
@@ -1,8 +1,6 @@
 defmodule Pleroma.Captcha do
   use GenServer
 
-  @ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}]
-
   @doc false
   def start_link() do
     GenServer.start_link(__MODULE__, [], name: __MODULE__)
@@ -10,14 +8,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
 
@@ -31,8 +21,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
@@ -47,19 +37,8 @@ defmodule Pleroma.Captcha do
   end
 
   @doc false
-  def handle_call({:validate, token, captcha}, _from, state) do
-    {:reply, method().validate(token, captcha), state}
-  end
-
-  @doc false
-  def handle_info(:cleanup, state) do
-    :ok = method().cleanup()
-
-    seconds_retained = Pleroma.Config.get!([__MODULE__, :seconds_retained])
-    # Schedule the next clenup
-    Process.send_after(self(), :cleanup, 1000 * seconds_retained)
-
-    {:noreply, state}
+  def handle_call({:validate, token, captcha, answer_data}, _from, state) do
+    {:reply, method().validate(token, captcha, answer_data), state}
   end
 
   defp method, do: Pleroma.Config.get!([__MODULE__, :method])
index 8d0b76f86340ef099e9a5d03b6b469a936514653..6f36d29b013b4a6a038b640d63645872609b6c78 100644 (file)
@@ -14,15 +14,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 :: String.t()
+            ) :: :ok | {:error, String.t()}
 end
index 51900d123735925be1187d8fa8803861bb1f5ce0..f881c7b652a00303608763dd692e0d7d788de10f 100644 (file)
@@ -1,11 +1,11 @@
 defmodule Pleroma.Captcha.Kocaptcha do
+  alias Plug.Crypto.KeyGenerator
+  alias Plug.Crypto.MessageEncryptor
   alias Calendar.DateTime
 
   alias Pleroma.Captcha.Service
   @behaviour Service
 
-  @ets __MODULE__.Ets
-
   @impl Service
   def new() do
     endpoint = Pleroma.Config.get!([__MODULE__, :endpoint])
@@ -18,50 +18,56 @@ defmodule Pleroma.Captcha.Kocaptcha do
         json_resp = Poison.decode!(res.body)
 
         token = json_resp["token"]
+        answer_md5 = json_resp["md5"]
 
-        true =
-          :ets.insert(
-            @ets,
-            {token, json_resp["md5"], DateTime.now_utc() |> DateTime.Format.unix()}
-          )
+        secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base])
 
-        %{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)
+        # This make salt a little different for two keys
+        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_md5: answer_md5
+          }
+          |> :erlang.term_to_binary()
+          |> MessageEncryptor.encrypt(secret, sign_secret)
 
-      true
-    else
-      _ -> false
+        %{
+          type: :kocaptcha,
+          token: token,
+          url: endpoint <> json_resp["url"],
+          answer_data: encrypted_captcha_answer
+        }
     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()
+  def validate(token, captcha, answer_data) 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")
 
-    :ets.select_delete(
-      @ets,
-      [
-        {
-          {:_, :_, :"$1"},
-          [{:<, :"$1", {:const, delete_after}}],
-          [true]
-        }
-      ]
-    )
+    # 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)
 
-    :ok
+    with {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret),
+         %{at: at, answer_md5: answer_md5} <- :erlang.binary_to_term(data) do
+      if DateTime.after?(at, valid_if_after) do
+        if not is_nil(captcha) and
+             :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_md5),
+           do: :ok,
+           else: {:error, "Invalid CAPTCHA"}
+      else
+        {:error, "CAPTCHA expired"}
+      end
+    else
+      _ -> {:error, "Invalid answer data"}
+    end
   end
 end
index d816dc3bca9563eb6ea321f83835bd0f05372d0f..9e15f2c33e55038b04123c3a7d6b3d1d61874a40 100644 (file)
@@ -136,22 +136,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 54ffbd92f9d203e832501fa4eac15f92cbd19c51..93b8930daab1c53aecfb51a080e0cd78c3554231 100644 (file)
@@ -25,16 +25,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 898aa17b884d4eec85622801bcb276f7e3818eb9..410318dc4e72c12a868babdf57ce0b189fc13efe 100644 (file)
@@ -6,8 +6,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