logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma
commit: 52ac7dce5c460d27d946d26070eb123e89af2914
parent: c6d5f189d9847b0c23d8150f41c05d87402680eb
Author: lambda <pleromagit@rogerbraun.net>
Date:   Mon, 17 Dec 2018 19:45:14 +0000

Merge branch 'captcha' into 'develop'

Captcha

See merge request pleroma/pleroma!550

Diffstat:

Mconfig/config.exs7+++++++
Mconfig/config.md15++++++++++++++-
Mconfig/test.exs6++++++
Mlib/pleroma/application.ex1+
Alib/pleroma/captcha/captcha.ex66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/captcha/captcha_service.ex28++++++++++++++++++++++++++++
Alib/pleroma/captcha/kocaptcha.ex67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/router.ex1+
Mlib/pleroma/web/twitter_api/controllers/util_controller.ex4++++
Mlib/pleroma/web/twitter_api/twitter_api.ex65+++++++++++++++++++++++++++++++++++++++++------------------------
Atest/captcha_test.exs40++++++++++++++++++++++++++++++++++++++++
Atest/support/captcha_mock.ex13+++++++++++++
12 files changed, 288 insertions(+), 25 deletions(-)

diff --git a/config/config.exs b/config/config.exs @@ -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, diff --git a/config/config.md b/config/config.md @@ -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+ \ No newline at end of file diff --git a/config/test.exs b/config/test.exs @@ -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 diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Application do # Start the Ecto repository supervisor(Pleroma.Repo, []), worker(Pleroma.Emoji, []), + worker(Pleroma.Captcha, []), worker( Cachex, [ diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex @@ -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 diff --git a/lib/pleroma/captcha/captcha_service.ex b/lib/pleroma/captcha/captcha_service.ex @@ -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 diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -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 diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -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 diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -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 diff --git a/test/captcha_test.exs b/test/captcha_test.exs @@ -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 diff --git a/test/support/captcha_mock.ex b/test/support/captcha_mock.ex @@ -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