Make captcha (kocaptcha) stateless See merge request pleroma/pleroma!585tags/v0.9.9
@@ -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" | |||
@@ -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 | |||
@@ -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, | |||
@@ -32,6 +32,16 @@ defmodule Pleroma.Application do | |||
worker( | |||
Cachex, | |||
[ | |||
:used_captcha_cache, | |||
[ | |||
ttl_interval: :timer.seconds(Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])) | |||
] | |||
], | |||
id: :cachex_used_captcha_cache | |||
), | |||
worker( | |||
Cachex, | |||
[ | |||
:user_cache, | |||
[ | |||
default_ttl: 25000, | |||
@@ -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]) | |||
@@ -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 |
@@ -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 |
@@ -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]) | |||
@@ -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 |
@@ -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 |