Merge branch 'captcha' into 'develop'
Make captcha (kocaptcha) stateless See merge request pleroma/pleroma!585
This commit is contained in:
commit
b73a1a33de
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
[
|
[
|
||||||
|
|
|
@ -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])
|
||||||
|
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
|
||||||
|
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
|
end
|
||||||
|
|
||||||
@doc false
|
{:reply, result, state}
|
||||||
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])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue