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
This commit is contained in:
Ekaterina Vaartis 2018-12-21 00:32:37 +03:00
parent 61a88a6757
commit 336e37d98f
8 changed files with 82 additions and 92 deletions

View File

@ -12,7 +12,7 @@ config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes
config :pleroma, Pleroma.Captcha, config :pleroma, Pleroma.Captcha,
enabled: false, enabled: false,
seconds_retained: 180, seconds_valid: 20,
method: Pleroma.Captcha.Kocaptcha method: Pleroma.Captcha.Kocaptcha
config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch" config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch"

View File

@ -168,7 +168,7 @@ Web Push Notifications configuration. You can use the mix task `mix web_push.gen
## Pleroma.Captcha ## Pleroma.Captcha
* `enabled`: Whether the captcha should be shown on registration * `enabled`: Whether the captcha should be shown on registration
* `method`: The method/service to use for captcha * `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 ### Pleroma.Captcha.Kocaptcha
Kocaptcha is a very simple captcha service with a single API endpoint, Kocaptcha is a very simple captcha service with a single API endpoint,

View File

@ -1,8 +1,6 @@
defmodule Pleroma.Captcha do defmodule Pleroma.Captcha do
use GenServer use GenServer
@ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}]
@doc false @doc false
def start_link() do def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__) GenServer.start_link(__MODULE__, [], name: __MODULE__)
@ -10,14 +8,6 @@ defmodule Pleroma.Captcha do
@doc false @doc false
def init(_) do 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} {:ok, nil}
end end
@ -31,8 +21,8 @@ defmodule Pleroma.Captcha do
@doc """ @doc """
Ask the configured captcha service to validate the captcha Ask the configured captcha service to validate the captcha
""" """
def validate(token, captcha) do def validate(token, captcha, answer_data) do
GenServer.call(__MODULE__, {:validate, token, captcha}) GenServer.call(__MODULE__, {:validate, token, captcha, answer_data})
end end
@doc false @doc false
@ -47,19 +37,8 @@ defmodule Pleroma.Captcha do
end end
@doc false @doc false
def handle_call({:validate, token, captcha}, _from, state) do def handle_call({:validate, token, captcha, answer_data}, _from, state) do
{:reply, method().validate(token, captcha), state} {:reply, method().validate(token, captcha, answer_data), 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}
end end
defp method, do: Pleroma.Config.get!([__MODULE__, :method]) defp method, do: Pleroma.Config.get!([__MODULE__, :method])

View File

@ -14,15 +14,15 @@ defmodule Pleroma.Captcha.Service do
Arguments: Arguments:
* `token` the captcha is associated with * `token` the captcha is associated with
* `captcha` solution of the captcha to validate * `captcha` solution of the captcha to validate
* `answer_data` is the data needed to validate the answer (presumably encrypted)
Returns: Returns:
`true` if captcha is valid, `false` if not `true` if captcha is valid, `false` if not
""" """
@callback validate(token :: String.t(), captcha :: String.t()) :: boolean @callback validate(
token :: String.t(),
@doc """ captcha :: String.t(),
This function is called periodically to clean up old captchas answer_data :: String.t()
""" ) :: :ok | {:error, String.t()}
@callback cleanup() :: :ok
end end

View File

@ -1,11 +1,11 @@
defmodule Pleroma.Captcha.Kocaptcha do defmodule Pleroma.Captcha.Kocaptcha do
alias Plug.Crypto.KeyGenerator
alias Plug.Crypto.MessageEncryptor
alias Calendar.DateTime alias Calendar.DateTime
alias Pleroma.Captcha.Service alias Pleroma.Captcha.Service
@behaviour Service @behaviour Service
@ets __MODULE__.Ets
@impl Service @impl Service
def new() do def new() do
endpoint = Pleroma.Config.get!([__MODULE__, :endpoint]) endpoint = Pleroma.Config.get!([__MODULE__, :endpoint])
@ -18,50 +18,56 @@ defmodule Pleroma.Captcha.Kocaptcha do
json_resp = Poison.decode!(res.body) json_resp = Poison.decode!(res.body)
token = json_resp["token"] token = json_resp["token"]
answer_md5 = json_resp["md5"]
true = secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base])
:ets.insert(
@ets,
{token, json_resp["md5"], DateTime.now_utc() |> DateTime.Format.unix()}
)
%{type: :kocaptcha, token: token, url: endpoint <> json_resp["url"]} # This make salt a little different for two keys
end secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt")
end 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)
@impl Service %{
def validate(token, captcha) do type: :kocaptcha,
with false <- is_nil(captcha), token: token,
[{^token, saved_md5, _}] <- :ets.lookup(@ets, token), url: endpoint <> json_resp["url"],
true <- :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(saved_md5) do answer_data: encrypted_captcha_answer
# Clear the saved value
:ets.delete(@ets, token)
true
else
_ -> false
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]
} }
] end
) end
:ok @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
end end
end end

View File

@ -136,22 +136,28 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
password: params["password"], password: params["password"],
password_confirmation: params["confirm"], password_confirmation: params["confirm"],
captcha_solution: params["captcha_solution"], 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]) captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled])
# true if captcha is disabled or enabled and valid, false otherwise # true if captcha is disabled or enabled and valid, false otherwise
captcha_ok = captcha_ok =
if !captcha_enabled do if !captcha_enabled do
true :ok
else else
Pleroma.Captcha.validate(params[:captcha_token], params[:captcha_solution]) Pleroma.Captcha.validate(
params[:captcha_token],
params[:captcha_solution],
params[:captcha_answer_data]
)
end end
# Captcha invalid # 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 # I have no idea how this error handling works
{:error, %{error: Jason.encode!(%{captcha: ["Invalid CAPTCHA"]})}} {:error, %{error: Jason.encode!(%{captcha: [error]})}}
else else
registrations_open = Pleroma.Config.get([:instance, :registrations_open]) registrations_open = Pleroma.Config.get([:instance, :registrations_open])

View File

@ -25,16 +25,18 @@ defmodule Pleroma.CaptchaTest do
end end
test "new and validate" do test "new and validate" do
assert Kocaptcha.new() == %{ new = Kocaptcha.new()
type: :kocaptcha, assert new[:type] == :kocaptcha
token: "afa1815e14e29355e6c8f6b143a39fa2", assert new[:token] == "afa1815e14e29355e6c8f6b143a39fa2"
url: "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
} assert new[:url] ==
"https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
assert Kocaptcha.validate( assert Kocaptcha.validate(
"afa1815e14e29355e6c8f6b143a39fa2", new[:token],
"7oEy8c" "7oEy8c",
) new[:answer_data]
) == :ok
end end
end end
end end

View File

@ -6,8 +6,5 @@ defmodule Pleroma.Captcha.Mock do
def new(), do: %{type: :mock} def new(), do: %{type: :mock}
@impl Service @impl Service
def validate(_token, _captcha), do: true def validate(_token, _captcha, _data), do: :ok
@impl Service
def cleanup(), do: :ok
end end