Add tests for account registration with captcha enabled and improve errors
This commit is contained in:
parent
2f77842bd3
commit
7b0c8f0fde
9 changed files with 246 additions and 129 deletions
|
|
@ -3,8 +3,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Captcha do
|
||||
import Pleroma.Web.Gettext
|
||||
|
||||
alias Calendar.DateTime
|
||||
alias Plug.Crypto.KeyGenerator
|
||||
alias Plug.Crypto.MessageEncryptor
|
||||
|
|
@ -37,19 +35,14 @@ defmodule Pleroma.Captcha do
|
|||
|
||||
@doc false
|
||||
def handle_call(:new, _from, state) do
|
||||
enabled = Pleroma.Config.get([__MODULE__, :enabled])
|
||||
|
||||
if !enabled do
|
||||
if not enabled?() do
|
||||
{:reply, %{type: :none}, state}
|
||||
else
|
||||
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")
|
||||
{secret, sign_secret} = secret_pair(new_captcha[:token])
|
||||
|
||||
# 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 =
|
||||
|
|
@ -71,44 +64,68 @@ defmodule Pleroma.Captcha do
|
|||
|
||||
@doc false
|
||||
def handle_call({:validate, token, captcha, answer_data}, _from, state) do
|
||||
{:reply, do_validate(token, captcha, answer_data), state}
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
defp do_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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
seconds_valid = Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])
|
||||
valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid)
|
||||
|
||||
result =
|
||||
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
|
||||
try do
|
||||
if DateTime.before?(at, valid_if_after),
|
||||
do: throw({:error, dgettext("errors", "CAPTCHA expired")})
|
||||
valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid())
|
||||
|
||||
if not is_nil(Cachex.get!(:used_captcha_cache, token)),
|
||||
do: throw({:error, dgettext("errors", "CAPTCHA already used")})
|
||||
if DateTime.before?(created_at, valid_if_after) do
|
||||
{:error, :expired}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
res = method().validate(token, captcha, answer_md5)
|
||||
# Throw if an error occurs
|
||||
if res != :ok, do: throw(res)
|
||||
defp validate_usage(token) do
|
||||
if is_nil(Cachex.get!(:used_captcha_cache, token)) do
|
||||
:ok
|
||||
else
|
||||
{:error, :already_used}
|
||||
end
|
||||
end
|
||||
|
||||
# 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, dgettext("errors", "Invalid answer data")}
|
||||
end
|
||||
|
||||
{:reply, result, state}
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Captcha.Kocaptcha do
|
||||
import Pleroma.Web.Gettext
|
||||
alias Pleroma.Captcha.Service
|
||||
@behaviour Service
|
||||
|
||||
|
|
@ -13,7 +12,7 @@ defmodule Pleroma.Captcha.Kocaptcha do
|
|||
|
||||
case Tesla.get(endpoint <> "/new") do
|
||||
{:error, _} ->
|
||||
%{error: dgettext("errors", "Kocaptcha service unavailable")}
|
||||
%{error: :kocaptcha_service_unavailable}
|
||||
|
||||
{:ok, res} ->
|
||||
json_resp = Jason.decode!(res.body)
|
||||
|
|
@ -33,6 +32,6 @@ defmodule Pleroma.Captcha.Kocaptcha do
|
|||
if not is_nil(captcha) and
|
||||
:crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data),
|
||||
do: :ok,
|
||||
else: {:error, dgettext("errors", "Invalid CAPTCHA")}
|
||||
else: {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Captcha.Native do
|
||||
import Pleroma.Web.Gettext
|
||||
alias Pleroma.Captcha.Service
|
||||
@behaviour Service
|
||||
|
||||
|
|
@ -11,7 +10,7 @@ defmodule Pleroma.Captcha.Native do
|
|||
def new do
|
||||
case Captcha.get() do
|
||||
:error ->
|
||||
%{error: dgettext("errors", "Captcha error")}
|
||||
%{error: :captcha_error}
|
||||
|
||||
{:ok, answer_data, img_binary} ->
|
||||
%{
|
||||
|
|
@ -25,7 +24,7 @@ defmodule Pleroma.Captcha.Native do
|
|||
|
||||
@impl Service
|
||||
def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok
|
||||
def validate(_token, _captcha, _answer), do: {:error, dgettext("errors", "Invalid CAPTCHA")}
|
||||
def validate(_token, _captcha, _answer), do: {:error, :invalid}
|
||||
|
||||
defp token do
|
||||
10
|
||||
|
|
|
|||
|
|
@ -94,24 +94,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
|
|||
|
||||
@doc "POST /api/v1/accounts"
|
||||
def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do
|
||||
params =
|
||||
params
|
||||
|> Map.take([
|
||||
:email,
|
||||
:bio,
|
||||
:captcha_solution,
|
||||
:captcha_token,
|
||||
:captcha_answer_data,
|
||||
:token,
|
||||
:password,
|
||||
:fullname
|
||||
])
|
||||
|> Map.put(:nickname, params.username)
|
||||
|> Map.put(:fullname, Map.get(params, :fullname, params.username))
|
||||
|> Map.put(:confirm, params.password)
|
||||
|> Map.put(:trusted_app, app.trusted)
|
||||
|
||||
with :ok <- validate_email_param(params),
|
||||
:ok <- TwitterAPI.validate_captcha(app, params),
|
||||
{:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true),
|
||||
{:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do
|
||||
json(conn, %{
|
||||
|
|
@ -121,7 +105,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
|
|||
created_at: Token.Utils.format_created_at(token)
|
||||
})
|
||||
else
|
||||
{:error, errors} -> json_response(conn, :bad_request, errors)
|
||||
{:error, error} -> json_response(conn, :bad_request, %{error: error})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -133,11 +117,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
|
|||
render_error(conn, :forbidden, "Invalid credentials")
|
||||
end
|
||||
|
||||
defp validate_email_param(%{:email => email}) when not is_nil(email), do: :ok
|
||||
defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok
|
||||
|
||||
defp validate_email_param(_) do
|
||||
case Pleroma.Config.get([:instance, :account_activation_required]) do
|
||||
true -> {:error, %{"error" => "Missing parameters"}}
|
||||
true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")}
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,54 +3,27 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
|
||||
import Pleroma.Web.Gettext
|
||||
|
||||
alias Pleroma.Emails.Mailer
|
||||
alias Pleroma.Emails.UserEmail
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.UserInviteToken
|
||||
|
||||
require Pleroma.Constants
|
||||
|
||||
def register_user(params, opts \\ []) do
|
||||
params =
|
||||
params
|
||||
|> Map.take([
|
||||
:nickname,
|
||||
:password,
|
||||
:captcha_solution,
|
||||
:captcha_token,
|
||||
:captcha_answer_data,
|
||||
:token,
|
||||
:email,
|
||||
:trusted_app
|
||||
])
|
||||
|> Map.put(:bio, User.parse_bio(params[:bio] || ""))
|
||||
|> Map.put(:name, params.fullname)
|
||||
|> Map.put(:password_confirmation, params[:confirm])
|
||||
|> Map.take([:email, :token, :password])
|
||||
|> Map.put(:bio, params |> Map.get(:bio, "") |> User.parse_bio())
|
||||
|> Map.put(:nickname, params[:username])
|
||||
|> Map.put(:name, Map.get(params, :fullname, params[:username]))
|
||||
|> Map.put(:password_confirmation, params[:password])
|
||||
|
||||
case validate_captcha(params) do
|
||||
:ok ->
|
||||
if Pleroma.Config.get([:instance, :registrations_open]) do
|
||||
create_user(params, opts)
|
||||
else
|
||||
create_user_with_invite(params, opts)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
# I have no idea how this error handling works
|
||||
{:error, %{error: Jason.encode!(%{captcha: [error]})}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_captcha(params) do
|
||||
if params[:trusted_app] || not Pleroma.Config.get([Pleroma.Captcha, :enabled]) do
|
||||
:ok
|
||||
if Pleroma.Config.get([:instance, :registrations_open]) do
|
||||
create_user(params, opts)
|
||||
else
|
||||
Pleroma.Captcha.validate(
|
||||
params[:captcha_token],
|
||||
params[:captcha_solution],
|
||||
params[:captcha_answer_data]
|
||||
)
|
||||
create_user_with_invite(params, opts)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -75,16 +48,17 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
|
|||
|
||||
{:error, changeset} ->
|
||||
errors =
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|
||||
|> Jason.encode!()
|
||||
|
||||
{:error, %{error: errors}}
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
def password_reset(nickname_or_email) do
|
||||
with true <- is_binary(nickname_or_email),
|
||||
%User{local: true, email: email} = user when not is_nil(email) <-
|
||||
%User{local: true, email: email} = user when is_binary(email) <-
|
||||
User.get_by_nickname_or_email(nickname_or_email),
|
||||
{:ok, token_record} <- Pleroma.PasswordResetToken.create_token(user) do
|
||||
user
|
||||
|
|
@ -106,4 +80,58 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
|
|||
{:error, "unknown user"}
|
||||
end
|
||||
end
|
||||
|
||||
def validate_captcha(app, params) do
|
||||
if app.trusted || not Pleroma.Captcha.enabled?() do
|
||||
:ok
|
||||
else
|
||||
do_validate_captcha(params)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_validate_captcha(params) do
|
||||
with :ok <- validate_captcha_presence(params),
|
||||
:ok <-
|
||||
Pleroma.Captcha.validate(
|
||||
params[:captcha_token],
|
||||
params[:captcha_solution],
|
||||
params[:captcha_answer_data]
|
||||
) do
|
||||
:ok
|
||||
else
|
||||
{:error, :captcha_error} ->
|
||||
captcha_error(dgettext("errors", "CAPTCHA Error"))
|
||||
|
||||
{:error, :invalid} ->
|
||||
captcha_error(dgettext("errors", "Invalid CAPTCHA"))
|
||||
|
||||
{:error, :kocaptcha_service_unavailable} ->
|
||||
captcha_error(dgettext("errors", "Kocaptcha service unavailable"))
|
||||
|
||||
{:error, :expired} ->
|
||||
captcha_error(dgettext("errors", "CAPTCHA expired"))
|
||||
|
||||
{:error, :already_used} ->
|
||||
captcha_error(dgettext("errors", "CAPTCHA already used"))
|
||||
|
||||
{:error, :invalid_answer_data} ->
|
||||
captcha_error(dgettext("errors", "Invalid answer data"))
|
||||
|
||||
{:error, error} ->
|
||||
captcha_error(error)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_captcha_presence(params) do
|
||||
[:captcha_solution, :captcha_token, :captcha_answer_data]
|
||||
|> Enum.find_value(:ok, fn key ->
|
||||
unless is_binary(params[key]) do
|
||||
error = dgettext("errors", "Invalid CAPTCHA (Missing parameter: %{name})", name: key)
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# For some reason FE expects error message to be a serialized JSON
|
||||
defp captcha_error(error), do: {:error, Jason.encode!(%{captcha: [error]})}
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue