Merge branch 'captcha' into 'develop'

Make captcha (kocaptcha) stateless

See merge request pleroma/pleroma!585
This commit is contained in:
rinpatch 2019-01-03 17:04:27 +00:00
commit b73a1a33de
10 changed files with 127 additions and 99 deletions

View File

@ -12,7 +12,7 @@
config :pleroma, Pleroma.Captcha, config :pleroma, Pleroma.Captcha,
enabled: false, enabled: false,
seconds_retained: 180, seconds_valid: 60,
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

@ -9,7 +9,8 @@
# Disable captha for tests # Disable captha for tests
config :pleroma, Pleroma.Captcha, config :pleroma, Pleroma.Captcha,
enabled: true, # It should not be enabled for automatic tests
enabled: false,
# A fake captcha service for tests # A fake captcha service for tests
method: Pleroma.Captcha.Mock method: Pleroma.Captcha.Mock

View File

@ -172,7 +172,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

@ -29,6 +29,16 @@ def start(_type, _args) do
supervisor(Pleroma.Repo, []), supervisor(Pleroma.Repo, []),
worker(Pleroma.Emoji, []), worker(Pleroma.Emoji, []),
worker(Pleroma.Captcha, []), 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( worker(
Cachex, Cachex,
[ [

View File

@ -3,9 +3,11 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Captcha do 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 @doc false
def start_link() do def start_link() do
@ -14,14 +16,6 @@ def start_link() 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
@ -35,8 +29,8 @@ def new() 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
@ -46,24 +40,71 @@ def handle_call(:new, _from, state) do
if !enabled do if !enabled do
{:reply, %{type: :none}, state} {:reply, %{type: :none}, state}
else 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
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} secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base])
end secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt")
sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign")
@doc false # If the time found is less than (current_time - seconds_valid), then the time has already passed.
def handle_info(:cleanup, state) do # Later we check that the time found is more than the presumed invalidatation time, that means
:ok = method().cleanup() # 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)
seconds_retained = Pleroma.Config.get!([__MODULE__, :seconds_retained]) result =
# Schedule the next clenup with {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret),
Process.send_after(self(), :cleanup, 1000 * seconds_retained) %{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"})
{:noreply, state} 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
{:reply, result, state}
end end
defp method, do: Pleroma.Config.get!([__MODULE__, :method]) defp method, do: Pleroma.Config.get!([__MODULE__, :method])

View File

@ -8,9 +8,14 @@ defmodule Pleroma.Captcha.Service do
Returns: 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 """ @doc """
Validated the provided captcha solution. Validated the provided captcha solution.
@ -18,15 +23,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 :: any()
""" ) :: :ok | {:error, String.t()}
@callback cleanup() :: :ok
end end

View File

@ -3,13 +3,9 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Captcha.Kocaptcha do defmodule Pleroma.Captcha.Kocaptcha do
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])
@ -21,51 +17,21 @@ def new() do
{:ok, res} -> {:ok, res} ->
json_resp = Poison.decode!(res.body) json_resp = Poison.decode!(res.body)
token = json_resp["token"] %{
type: :kocaptcha,
true = token: json_resp["token"],
:ets.insert( url: endpoint <> json_resp["url"],
@ets, answer_data: json_resp["md5"]
{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
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
# 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
end end

View File

@ -140,22 +140,28 @@ def register_user(params) 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

@ -29,16 +29,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

@ -10,8 +10,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