forked from AkkomaGang/akkoma
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:
parent
61a88a6757
commit
336e37d98f
8 changed files with 82 additions and 92 deletions
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 @@ 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
|
||||||
|
|
||||||
|
@ -31,8 +21,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
|
||||||
|
@ -47,19 +37,8 @@ def handle_call(:new, _from, state) 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])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 @@ def new() 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
|
||||||
|
|
|
@ -136,22 +136,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])
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue