feat: prevent join requests with equal mail
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
40a4461d23
commit
086ecdcb1b
22 changed files with 534 additions and 11 deletions
|
|
@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do
|
|||
|
||||
change Mv.Membership.JoinRequest.Changes.RejectRequest
|
||||
end
|
||||
|
||||
# Internal: resend confirmation (new token) when user submits form again with same email.
|
||||
# Called from domain with authorize?: false; not exposed to public.
|
||||
update :regenerate_confirmation_token do
|
||||
description "Set new confirmation token and expiry (resend flow)"
|
||||
require_atomic? false
|
||||
|
||||
argument :confirmation_token, :string, allow_nil?: false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
|
|
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
|
|||
attribute :approved_at, :utc_datetime_usec
|
||||
attribute :rejected_at, :utc_datetime_usec
|
||||
attribute :reviewed_by_user_id, :uuid
|
||||
|
||||
attribute :reviewed_by_display, :string do
|
||||
description "Denormalized reviewer display (e.g. email) for UI without loading User"
|
||||
end
|
||||
|
||||
attribute :source, :string
|
||||
|
||||
create_timestamp :inserted_at
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
|
|||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
||||
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
|
|
|
|||
|
|
@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
|
|||
end
|
||||
|
||||
def actor_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Extracts the actor's email for display (e.g. reviewed_by_display).
|
||||
|
||||
Supports both atom and string keys for compatibility with different actor representations.
|
||||
"""
|
||||
@spec actor_email(term()) :: String.t() | nil
|
||||
def actor_email(nil), do: nil
|
||||
|
||||
def actor_email(actor) when is_map(actor) do
|
||||
raw = Map.get(actor, :email) || Map.get(actor, "email")
|
||||
if is_nil(raw), do: nil, else: actor_email_string(raw)
|
||||
end
|
||||
|
||||
def actor_email(_), do: nil
|
||||
|
||||
defp actor_email_string(raw) do
|
||||
s = raw |> to_string() |> String.trim()
|
||||
if s == "", do: nil, else: s
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
|
||||
@moduledoc """
|
||||
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
|
||||
|
||||
Used when the user submits the join form again with the same email while a request
|
||||
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@confirmation_validity_hours 24
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
|
||||
|
||||
if is_binary(token) and token != "" do
|
||||
hash = JoinRequest.hash_confirmation_token(token)
|
||||
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, DateTime.utc_now())
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
|
|||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|
||||
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ defmodule Mv.Membership do
|
|||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Emails.JoinAlreadyMemberEmail
|
||||
alias MvWeb.Emails.JoinAlreadyPendingEmail
|
||||
alias MvWeb.Emails.JoinConfirmationEmail
|
||||
require Logger
|
||||
|
||||
|
|
@ -365,15 +369,130 @@ defmodule Mv.Membership do
|
|||
|
||||
## Returns
|
||||
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
|
||||
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
|
||||
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
|
||||
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
|
||||
- `{:error, error}` - Validation or authorization error
|
||||
"""
|
||||
def submit_join_request(attrs, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||
email = normalize_submit_email(attrs)
|
||||
|
||||
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
|
||||
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
|
||||
pending =
|
||||
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
|
||||
|
||||
cond do
|
||||
email != nil and email != "" and member_exists_with_email?(email) ->
|
||||
send_already_member_and_return(email)
|
||||
|
||||
pending != nil ->
|
||||
handle_already_pending(email, pending)
|
||||
|
||||
true ->
|
||||
do_create_join_request(attrs, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_submit_email(attrs) do
|
||||
raw = attrs["email"] || attrs[:email]
|
||||
if is_binary(raw), do: String.trim(raw), else: nil
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = [actor: system_actor, domain: __MODULE__]
|
||||
|
||||
case Ash.get(Member, %{email: email}, opts) do
|
||||
{:ok, _member} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(_), do: false
|
||||
|
||||
defp pending_join_request_with_email(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
|
||||
{:ok, request} -> request
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_join_request_with_email(_), do: nil
|
||||
|
||||
defp apply_anti_enumeration_delay do
|
||||
Process.sleep(100 + :rand.uniform(200))
|
||||
end
|
||||
|
||||
defp send_already_member_and_return(email) do
|
||||
case JoinAlreadyMemberEmail.send(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
{:ok, :notified_already_member}
|
||||
end
|
||||
|
||||
defp handle_already_pending(email, existing) do
|
||||
if existing.status == :pending_confirmation do
|
||||
resend_confirmation_to_pending(email, existing)
|
||||
else
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp resend_confirmation_to_pending(email, request) do
|
||||
new_token = generate_confirmation_token()
|
||||
|
||||
case request
|
||||
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
|
||||
confirmation_token: new_token
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__, authorize?: false) do
|
||||
{:ok, _updated} ->
|
||||
case JoinConfirmationEmail.send(email, new_token, resend: true) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
{:ok, :notified_already_pending}
|
||||
|
||||
{:error, _} ->
|
||||
# Fallback: do not create duplicate; send generic pending email
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_already_pending_and_return(email) do
|
||||
case JoinAlreadyPendingEmail.send(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
{:ok, :notified_already_pending}
|
||||
end
|
||||
|
||||
defp do_create_join_request(attrs, actor) do
|
||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
||||
|
||||
case Ash.create(JoinRequest, attrs_with_token,
|
||||
|
|
@ -384,6 +503,7 @@ defmodule Mv.Membership do
|
|||
{:ok, request} ->
|
||||
case JoinConfirmationEmail.send(request.email, token) do
|
||||
{:ok, _email} ->
|
||||
apply_anti_enumeration_delay()
|
||||
{:ok, request}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue