Captcha See merge request pleroma/pleroma!550tags/v0.9.9
@@ -10,6 +10,13 @@ config :pleroma, ecto_repos: [Pleroma.Repo] | |||
config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes | |||
config :pleroma, Pleroma.Captcha, | |||
enabled: false, | |||
seconds_retained: 180, | |||
method: Pleroma.Captcha.Kocaptcha | |||
config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch" | |||
# Upload configuration | |||
config :pleroma, Pleroma.Upload, | |||
uploader: Pleroma.Uploaders.Local, | |||
@@ -7,7 +7,7 @@ If you run Pleroma with ``MIX_ENV=prod`` the file is ``prod.secret.exs``, otherw | |||
* `uploader`: Select which `Pleroma.Uploaders` to use | |||
* `filters`: List of `Pleroma.Upload.Filter` to use. | |||
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host. | |||
* `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. | |||
* `proxy_remote`: If you\'re using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. | |||
* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. | |||
Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. | |||
@@ -163,3 +163,15 @@ Web Push Notifications configuration. You can use the mix task `mix web_push.gen | |||
* ``subject``: a mailto link for the administrative contact. It’s best if this email is not a personal email address, but rather a group email so that if a person leaves an organization, is unavailable for an extended period, or otherwise can’t respond, someone else on the list can. | |||
* ``public_key``: VAPID public key | |||
* ``private_key``: VAPID private key | |||
## 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) | |||
### Pleroma.Captcha.Kocaptcha | |||
Kocaptcha is a very simple captcha service with a single API endpoint, | |||
the source code is here: https://github.com/koto-bank/kocaptcha. The default endpoint | |||
`https://captcha.kotobank.ch` is hosted by the developer. | |||
* `endpoint`: the kocaptcha endpoint to use |
@@ -7,6 +7,12 @@ config :pleroma, Pleroma.Web.Endpoint, | |||
url: [port: 4001], | |||
server: true | |||
# Disable captha for tests | |||
config :pleroma, Pleroma.Captcha, | |||
enabled: true, | |||
# A fake captcha service for tests | |||
method: Pleroma.Captcha.Mock | |||
# Print only warnings and errors during test | |||
config :logger, level: :warn | |||
@@ -24,6 +24,7 @@ defmodule Pleroma.Application do | |||
# Start the Ecto repository | |||
supervisor(Pleroma.Repo, []), | |||
worker(Pleroma.Emoji, []), | |||
worker(Pleroma.Captcha, []), | |||
worker( | |||
Cachex, | |||
[ | |||
@@ -0,0 +1,66 @@ | |||
defmodule Pleroma.Captcha do | |||
use GenServer | |||
@ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}] | |||
@doc false | |||
def start_link() do | |||
GenServer.start_link(__MODULE__, [], name: __MODULE__) | |||
end | |||
@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 | |||
@doc """ | |||
Ask the configured captcha service for a new captcha | |||
""" | |||
def new() do | |||
GenServer.call(__MODULE__, :new) | |||
end | |||
@doc """ | |||
Ask the configured captcha service to validate the captcha | |||
""" | |||
def validate(token, captcha) do | |||
GenServer.call(__MODULE__, {:validate, token, captcha}) | |||
end | |||
@doc false | |||
def handle_call(:new, _from, state) do | |||
enabled = Pleroma.Config.get([__MODULE__, :enabled]) | |||
if !enabled do | |||
{:reply, %{type: :none}, state} | |||
else | |||
{:reply, method().new(), state} | |||
end | |||
end | |||
@doc false | |||
def handle_call({:validate, token, captcha}, _from, state) do | |||
{:reply, method().validate(token, captcha), 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 | |||
defp method, do: Pleroma.Config.get!([__MODULE__, :method]) | |||
end |
@@ -0,0 +1,28 @@ | |||
defmodule Pleroma.Captcha.Service do | |||
@doc """ | |||
Request new captcha from a captcha service. | |||
Returns: | |||
Service-specific data for using the newly created captcha | |||
""" | |||
@callback new() :: map | |||
@doc """ | |||
Validated the provided captcha solution. | |||
Arguments: | |||
* `token` the captcha is associated with | |||
* `captcha` solution of the captcha to validate | |||
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 | |||
end |
@@ -0,0 +1,67 @@ | |||
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]) | |||
case Tesla.get(endpoint <> "/new") do | |||
{:error, _} -> | |||
%{error: "Kocaptcha service unavailable"} | |||
{: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 | |||
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 | |||
end | |||
end |
@@ -99,6 +99,7 @@ defmodule Pleroma.Web.Router do | |||
get("/password_reset/:token", UtilController, :show_password_reset) | |||
post("/password_reset", UtilController, :password_reset) | |||
get("/emoji", UtilController, :emoji) | |||
get("/captcha", UtilController, :captcha) | |||
end | |||
scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do | |||
@@ -284,4 +284,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do | |||
json(conn, %{error: msg}) | |||
end | |||
end | |||
def captcha(conn, _params) do | |||
json(conn, Pleroma.Captcha.new()) | |||
end | |||
end |
@@ -132,38 +132,55 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do | |||
bio: User.parse_bio(params["bio"]), | |||
email: params["email"], | |||
password: params["password"], | |||
password_confirmation: params["confirm"] | |||
password_confirmation: params["confirm"], | |||
captcha_solution: params["captcha_solution"], | |||
captcha_token: params["captcha_token"] | |||
} | |||
registrations_open = Pleroma.Config.get([:instance, :registrations_open]) | |||
# no need to query DB if registration is open | |||
token = | |||
unless registrations_open || is_nil(tokenString) do | |||
Repo.get_by(UserInviteToken, %{token: tokenString}) | |||
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 | |||
else | |||
Pleroma.Captcha.validate(params[:captcha_token], params[:captcha_solution]) | |||
end | |||
cond do | |||
registrations_open || (!is_nil(token) && !token.used) -> | |||
changeset = User.register_changeset(%User{info: %{}}, params) | |||
with {:ok, user} <- Repo.insert(changeset) do | |||
!registrations_open && UserInviteToken.mark_as_used(token.token) | |||
{:ok, user} | |||
else | |||
{:error, changeset} -> | |||
errors = | |||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) | |||
|> Jason.encode!() | |||
# Captcha invalid | |||
if not captcha_ok do | |||
# I have no idea how this error handling works | |||
{:error, %{error: Jason.encode!(%{captcha: ["Invalid CAPTCHA"]})}} | |||
else | |||
registrations_open = Pleroma.Config.get([:instance, :registrations_open]) | |||
{:error, %{error: errors}} | |||
# no need to query DB if registration is open | |||
token = | |||
unless registrations_open || is_nil(tokenString) do | |||
Repo.get_by(UserInviteToken, %{token: tokenString}) | |||
end | |||
!registrations_open && is_nil(token) -> | |||
{:error, "Invalid token"} | |||
cond do | |||
registrations_open || (!is_nil(token) && !token.used) -> | |||
changeset = User.register_changeset(%User{info: %{}}, params) | |||
!registrations_open && token.used -> | |||
{:error, "Expired token"} | |||
with {:ok, user} <- Repo.insert(changeset) do | |||
!registrations_open && UserInviteToken.mark_as_used(token.token) | |||
{:ok, user} | |||
else | |||
{:error, changeset} -> | |||
errors = | |||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) | |||
|> Jason.encode!() | |||
{:error, %{error: errors}} | |||
end | |||
!registrations_open && is_nil(token) -> | |||
{:error, "Invalid token"} | |||
!registrations_open && token.used -> | |||
{:error, "Expired token"} | |||
end | |||
end | |||
end | |||
@@ -0,0 +1,40 @@ | |||
defmodule Pleroma.CaptchaTest do | |||
use ExUnit.Case | |||
import Tesla.Mock | |||
alias Pleroma.Captcha.Kocaptcha | |||
@ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}] | |||
describe "Kocaptcha" do | |||
setup do | |||
ets_name = Kocaptcha.Ets | |||
^ets_name = :ets.new(ets_name, @ets_options) | |||
mock(fn | |||
%{method: :get, url: "https://captcha.kotobank.ch/new"} -> | |||
json(%{ | |||
md5: "63615261b77f5354fb8c4e4986477555", | |||
token: "afa1815e14e29355e6c8f6b143a39fa2", | |||
url: "/captchas/afa1815e14e29355e6c8f6b143a39fa2.png" | |||
}) | |||
end) | |||
:ok | |||
end | |||
test "new and validate" do | |||
assert Kocaptcha.new() == %{ | |||
type: :kocaptcha, | |||
token: "afa1815e14e29355e6c8f6b143a39fa2", | |||
url: "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png" | |||
} | |||
assert Kocaptcha.validate( | |||
"afa1815e14e29355e6c8f6b143a39fa2", | |||
"7oEy8c" | |||
) | |||
end | |||
end | |||
end |
@@ -0,0 +1,13 @@ | |||
defmodule Pleroma.Captcha.Mock do | |||
alias Pleroma.Captcha.Service | |||
@behaviour Service | |||
@impl Service | |||
def new(), do: %{type: :mock} | |||
@impl Service | |||
def validate(_token, _captcha), do: true | |||
@impl Service | |||
def cleanup(), do: :ok | |||
end |