Merge pull request 'Manually define PATH for Arch Linux users in systemd unit' (...
[akkoma] / lib / pleroma / captcha.ex
index 31f3bc797e02eae73718541f03c23a52611a851b..bad7b3a66e3e65db3b529943990e4e3865825826 100644 (file)
-defmodule Pleroma.Captcha do
-  use GenServer
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
 
-  @ets __MODULE__.Ets
-  @ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}]
+defmodule Pleroma.Captcha do
+  alias Calendar.DateTime
+  alias Plug.Crypto.KeyGenerator
+  alias Plug.Crypto.MessageEncryptor
 
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
 
-  @doc false
-  def start_link() do
-    GenServer.start_link(__MODULE__, [], name: __MODULE__)
-  end
+  @doc """
+  Ask the configured captcha service for a new captcha
+  """
+  def new do
+    if not enabled?() do
+      %{type: :none}
+    else
+      new_captcha = method().new()
 
+      # This make salt a little different for two keys
+      {secret, sign_secret} = secret_pair(new_captcha[:token])
 
-  @doc false
-  def init(_) do
-    @ets = :ets.new(@ets, @ets_options)
+      # Basically 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)
 
-    {:ok, nil}
+      # Replace the answer with the encrypted answer
+      %{new_captcha | answer_data: encrypted_captcha_answer}
+    end
   end
 
-  def new() do
-    GenServer.call(__MODULE__, :new)
+  @doc """
+  Ask the configured captcha service to validate the captcha
+  """
+  def validate(token, captcha, answer_data) do
+    with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data),
+         :ok <- validate_expiration(at),
+         :ok <- validate_usage(token),
+         :ok <- method().validate(token, captcha, answer_md5),
+         {:ok, _} <- mark_captcha_as_used(token) do
+      :ok
+    end
   end
 
-  def validate(token, captcha) do
-    GenServer.call(__MODULE__, {:validate, token, captcha})
+  def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false)
+
+  defp seconds_valid, do: Pleroma.Config.get!([__MODULE__, :seconds_valid])
+
+  defp secret_pair(token) 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")
+
+    {secret, sign_secret}
   end
 
-  @doc false
-  def handle_call(:new, _from, state) do
-    method = Pleroma.Config.get!([__MODULE__, :method])
-
-    case method do
-      __MODULE__.Kocaptcha ->
-        endpoint = Pleroma.Config.get!([method, :endpoint])
-        case HTTPoison.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"]})
-
-            {
-              :reply,
-              %{type: :kocaptcha, token: token, url: endpoint <> json_resp["url"]},
-              state
-            }
-        end
+  defp validate_answer_data(token, answer_data) do
+    {secret, sign_secret} = secret_pair(token)
+
+    with false <- is_nil(answer_data),
+         {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret),
+         %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do
+      {:ok, %{at: at, answer_data: answer_md5}}
+    else
+      _ -> {:error, :invalid_answer_data}
     end
   end
 
-  @doc false
-  def handle_call({:validate, token, captcha}, _from, state) 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)
+  defp validate_expiration(created_at) do
+    # 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
+
+    valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid())
 
-      {:reply, true, state}
+    if DateTime.before?(created_at, valid_if_after) do
+      {:error, :expired}
     else
-      e -> IO.inspect(e); {:reply, false, state}
+      :ok
     end
   end
+
+  defp validate_usage(token) do
+    if is_nil(@cachex.get!(:used_captcha_cache, token)) do
+      :ok
+    else
+      {:error, :already_used}
+    end
+  end
+
+  defp mark_captcha_as_used(token) do
+    ttl = seconds_valid() |> :timer.seconds()
+    @cachex.put(:used_captcha_cache, token, true, ttl: ttl)
+  end
+
+  defp method, do: Pleroma.Config.get!([__MODULE__, :method])
 end