From 336e37d98f1b86c0332c9f260e27455a14714fa6 Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Fri, 21 Dec 2018 00:32:37 +0300 Subject: Make captcha (kocaptcha) stateless 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 --- lib/pleroma/captcha/captcha.ex | 29 ++--------- lib/pleroma/captcha/captcha_service.ex | 12 ++--- lib/pleroma/captcha/kocaptcha.ex | 82 ++++++++++++++++-------------- lib/pleroma/web/twitter_api/twitter_api.ex | 16 ++++-- 4 files changed, 65 insertions(+), 74 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index 5630f6b57..61a0f907f 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -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]) diff --git a/lib/pleroma/captcha/captcha_service.ex b/lib/pleroma/captcha/captcha_service.ex index 8d0b76f86..6f36d29b0 100644 --- a/lib/pleroma/captcha/captcha_service.ex +++ b/lib/pleroma/captcha/captcha_service.ex @@ -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 diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 51900d123..f881c7b65 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -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 diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index d816dc3bc..9e15f2c33 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -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]) -- cgit v1.2.3 From b386e560ba22ca93b56ba74ffc134fe7e6de8b2d Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Sat, 22 Dec 2018 22:39:08 +0300 Subject: Move the encryption out of kocaptcha into general captcha module That way there won't be a need to reimplement it for other captcha services --- lib/pleroma/captcha/captcha.ex | 53 ++++++++++++++++++++++++++++++-- lib/pleroma/captcha/captcha_service.ex | 11 +++++-- lib/pleroma/captcha/kocaptcha.ex | 56 +++++----------------------------- 3 files changed, 67 insertions(+), 53 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index 61a0f907f..04769d4b2 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -1,4 +1,8 @@ defmodule Pleroma.Captcha do + alias Plug.Crypto.KeyGenerator + alias Plug.Crypto.MessageEncryptor + alias Calendar.DateTime + use GenServer @doc false @@ -32,13 +36,58 @@ 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) + + IO.inspect(%{new_captcha | answer_data: encrypted_captcha_answer}) + + { + :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, answer_data}, _from, state) do - {:reply, method().validate(token, captcha, answer_data), state} + 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") + + # 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 + if DateTime.after?(at, valid_if_after), + do: method().validate(token, captcha, answer_md5), + else: {:error, "CAPTCHA expired"} + else + _ -> {:error, "Invalid answer data"} + end + + {:reply, result, state} end defp method, do: Pleroma.Config.get!([__MODULE__, :method]) diff --git a/lib/pleroma/captcha/captcha_service.ex b/lib/pleroma/captcha/captcha_service.ex index 6f36d29b0..6c5ab6c36 100644 --- a/lib/pleroma/captcha/captcha_service.ex +++ b/lib/pleroma/captcha/captcha_service.ex @@ -4,9 +4,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. @@ -23,6 +28,6 @@ defmodule Pleroma.Captcha.Service do @callback validate( token :: String.t(), captcha :: String.t(), - answer_data :: String.t() + answer_data :: any() ) :: :ok | {:error, String.t()} end diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index f881c7b65..cd0eb6f21 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -1,8 +1,4 @@ defmodule Pleroma.Captcha.Kocaptcha do - alias Plug.Crypto.KeyGenerator - alias Plug.Crypto.MessageEncryptor - alias Calendar.DateTime - alias Pleroma.Captcha.Service @behaviour Service @@ -17,57 +13,21 @@ defmodule Pleroma.Captcha.Kocaptcha do {:ok, res} -> json_resp = Poison.decode!(res.body) - token = json_resp["token"] - answer_md5 = json_resp["md5"] - - secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) - - # 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) - %{ type: :kocaptcha, - token: token, + token: json_resp["token"], url: endpoint <> json_resp["url"], - answer_data: encrypted_captcha_answer + answer_data: json_resp["md5"] } end end @impl Service - 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") - - # 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) - - 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 + 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 -- cgit v1.2.3 From 448af3601a365e4f24a2db54af366d075af7e90f Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Thu, 27 Dec 2018 00:12:20 +0300 Subject: Up captcha timer to 60 secs again, save used captchas in cachex --- lib/pleroma/application.ex | 10 ++++++++++ lib/pleroma/captcha/captcha.ex | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index e15991957..bdd0ee26f 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -25,6 +25,16 @@ defmodule Pleroma.Application do supervisor(Pleroma.Repo, []), worker(Pleroma.Emoji, []), worker(Pleroma.Captcha, []), + worker( + Cachex, + [ + :used_captcha_cache, + [ + ttl_interval: :timer.seconds(60 * 2) + ] + ], + id: :cachex_used_captcha_cache + ), worker( Cachex, [ diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index 04769d4b2..c7abafeb1 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -80,9 +80,24 @@ defmodule Pleroma.Captcha do result = with {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do - if DateTime.after?(at, valid_if_after), - do: method().validate(token, captcha, answer_md5), - else: {:error, "CAPTCHA expired"} + 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)) + + :ok + catch + :throw, e -> e + end else _ -> {:error, "Invalid answer data"} end -- cgit v1.2.3 From 708a22891921d54c9c716ac250e75ae6a889eb7b Mon Sep 17 00:00:00 2001 From: vaartis Date: Thu, 27 Dec 2018 07:42:03 +0000 Subject: Set ttl_interval to the seconds_valid time --- lib/pleroma/application.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index bdd0ee26f..8dbacf258 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Application do [ :used_captcha_cache, [ - ttl_interval: :timer.seconds(60 * 2) + ttl_interval: :timer.seconds(Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])) ] ], id: :cachex_used_captcha_cache -- cgit v1.2.3 From 816db3f494c6fcc60d0a700dfc473a9cc49c84a0 Mon Sep 17 00:00:00 2001 From: vaartis Date: Sat, 29 Dec 2018 17:44:26 +0000 Subject: Remove the debugging IO.inspect --- lib/pleroma/captcha/captcha.ex | 2 -- 1 file changed, 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index c7abafeb1..424ad4add 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -54,8 +54,6 @@ defmodule Pleroma.Captcha do |> :erlang.term_to_binary() |> MessageEncryptor.encrypt(secret, sign_secret) - IO.inspect(%{new_captcha | answer_data: encrypted_captcha_answer}) - { :reply, # Repalce the answer with the encrypted answer -- cgit v1.2.3