Harden public contact verification lifecycle
This commit is contained in:
parent
accc0845bd
commit
7be5fcf478
|
|
@ -227,6 +227,11 @@ SUPPORT_INBOX_ADDRESS=
|
|||
# staff workspace is the canonical queue. Set immediate only when a monitored
|
||||
# mailbox should receive one metadata-only alert for each verified case/update.
|
||||
SUPPORT_OPERATOR_EMAIL_MODE=disabled
|
||||
# Pilot policy: an anonymous support/removal email must be confirmed within one
|
||||
# day. Confirmed case links remain usable for one year. Both values are seconds
|
||||
# and can be changed without rebuilding the release.
|
||||
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400
|
||||
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
|
||||
|
||||
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
|
||||
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ x-app-environment: &app-environment
|
|||
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
||||
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
||||
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
|
||||
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400}
|
||||
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000}
|
||||
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
|
||||
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
|
||||
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ config :who_need_help,
|
|||
e2e_routes: false,
|
||||
secure_cookies: false,
|
||||
rate_limit_policies: %{},
|
||||
public_contact_verification_max_age_seconds: 86_400,
|
||||
public_case_access_max_age_seconds: 31_536_000,
|
||||
tracking_presence_cleanup_grace_ms: 5_000,
|
||||
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
android_app_links: nil,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ positive_integer_with_default = fn name, default ->
|
|||
optional_positive_integer.(name) || default
|
||||
end
|
||||
|
||||
config :who_need_help,
|
||||
public_contact_verification_max_age_seconds:
|
||||
positive_integer_with_default.("PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS", 86_400),
|
||||
public_case_access_max_age_seconds:
|
||||
positive_integer_with_default.("PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS", 31_536_000)
|
||||
|
||||
if config_env() == :prod do
|
||||
config :who_need_help, Oban,
|
||||
queues: [
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ spec:
|
|||
value: {{ $root.Values.app.codexSessionId | quote }}
|
||||
- name: RATE_LIMIT_POLICIES_JSON
|
||||
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
|
||||
- name: PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS
|
||||
value: {{ $root.Values.app.publicContactVerificationMaxAgeSeconds | quote }}
|
||||
- name: PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS
|
||||
value: {{ $root.Values.app.publicCaseAccessMaxAgeSeconds | quote }}
|
||||
- name: MAP_TILE_URL
|
||||
value: {{ $root.Values.app.mapTileUrl | quote }}
|
||||
- name: DNS_CLUSTER_QUERY
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ app:
|
|||
codexSessionId: not-configured
|
||||
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
||||
rateLimitPoliciesJson: "{}"
|
||||
publicContactVerificationMaxAgeSeconds: "86400"
|
||||
publicCaseAccessMaxAgeSeconds: "31536000"
|
||||
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
emailDeliveryProvider: smtp
|
||||
smtpRelay: mailpit
|
||||
|
|
|
|||
|
|
@ -119,6 +119,16 @@ intake and reporter acknowledgement still work, but no operator inbox alert is
|
|||
sent. The configured inbox must be monitored operationally; the application
|
||||
cannot prove staffing or response availability.
|
||||
|
||||
Anonymous submissions use two distinct private links. The confirmation link is
|
||||
valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot
|
||||
policy is 24 hours). Confirming it moves the record into the permission-scoped
|
||||
staff queue and sends a second status link. The status link lifetime is
|
||||
`PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS` (the initial pilot policy is one year).
|
||||
Expired unconfirmed records are removed by the maintenance worker together with
|
||||
their creation audit entry; confirmed support and legal records are outside this
|
||||
cleanup. Each deployment can change both values through its own `.env` without
|
||||
rebuilding the release.
|
||||
|
||||
## Account deletion and data export
|
||||
|
||||
The web application and Android WebView expose the account-deletion request from
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
@access_salt "content-removal-access"
|
||||
@confirmation_salt "content-removal-confirmation"
|
||||
@urgent_categories [
|
||||
:non_consensual_intimate_media,
|
||||
:child_sexual_abuse_material,
|
||||
|
|
@ -88,9 +89,15 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
def get_by_access_token(id, token) when is_binary(token) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
{:ok, ^id} <-
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
|
||||
max_age: case_access_max_age_seconds()
|
||||
),
|
||||
%Notice{} = notice <- Repo.get(Notice, id) do
|
||||
verify_contact(notice)
|
||||
if notice.contact_verified_at do
|
||||
{:ok, notice}
|
||||
else
|
||||
verify_legacy_confirmation(notice, token)
|
||||
end
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
|
|
@ -98,8 +105,31 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
|
||||
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
||||
|
||||
def verify_by_confirmation_token(id, token) when is_binary(token) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
{:ok, ^id} <-
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
),
|
||||
%Notice{} = notice <- Repo.get(Notice, id) do
|
||||
verify_contact(notice)
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
|
||||
|
||||
def access_token(%Notice{id: id}) do
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
|
||||
max_age: case_access_max_age_seconds()
|
||||
)
|
||||
end
|
||||
|
||||
def confirmation_token(%Notice{id: id}) do
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
)
|
||||
end
|
||||
|
||||
def status_url(%Notice{} = notice) do
|
||||
|
|
@ -109,6 +139,13 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
"/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}"
|
||||
end
|
||||
|
||||
def confirmation_url(%Notice{} = notice) do
|
||||
token = confirmation_token(notice)
|
||||
|
||||
WhoNeedHelpWeb.Endpoint.url() <>
|
||||
"/legal/content-removal/#{notice.id}/verify?token=#{URI.encode_www_form(token)}"
|
||||
end
|
||||
|
||||
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||
if Accounts.authorized?(user, :legal_view) do
|
||||
limit = Pagination.limit(options)
|
||||
|
|
@ -194,7 +231,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
|
||||
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
|
||||
when is_binary(email) and email != "" do
|
||||
case Notifier.deliver_confirmation(notice, status_url(notice)) do
|
||||
case Notifier.deliver_confirmation(notice, confirmation_url(notice)) do
|
||||
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
|
||||
_error -> {:ok, notice}
|
||||
end
|
||||
|
|
@ -270,6 +307,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
|
||||
|
||||
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
|
||||
_ = Notifier.deliver_received(notice, status_url(notice))
|
||||
_ = Notifier.deliver_operator_alert(notice)
|
||||
{:ok, notice}
|
||||
end
|
||||
|
|
@ -277,6 +315,23 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
|
||||
defp notify_verified_notice(result), do: result
|
||||
|
||||
defp verify_legacy_confirmation(%Notice{id: id} = notice, token) do
|
||||
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
) do
|
||||
{:ok, ^id} -> verify_contact(notice)
|
||||
_error -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp contact_verification_max_age_seconds do
|
||||
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
|
||||
end
|
||||
|
||||
defp case_access_max_age_seconds do
|
||||
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
|
||||
end
|
||||
|
||||
defp mark_acknowledgement_sent(notice) do
|
||||
notice
|
||||
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ defmodule WhoNeedHelp.Support do
|
|||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
@access_salt "support-request-access"
|
||||
@confirmation_salt "support-request-confirmation"
|
||||
@staff_topic "support:staff"
|
||||
|
||||
def subscribe_request(id),
|
||||
|
|
@ -106,10 +107,14 @@ defmodule WhoNeedHelp.Support do
|
|||
def get_by_access_token(id, token) when is_binary(token) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
{:ok, ^id} <-
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
|
||||
max_age: case_access_max_age_seconds()
|
||||
),
|
||||
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
|
||||
with {:ok, request} <- verify_contact(request) do
|
||||
if request.contact_verified_at do
|
||||
{:ok, preload_conversation(request)}
|
||||
else
|
||||
verify_legacy_confirmation(request, token)
|
||||
end
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
|
|
@ -118,6 +123,21 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
||||
|
||||
def verify_by_confirmation_token(id, token) when is_binary(token) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
{:ok, ^id} <-
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
),
|
||||
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
|
||||
verify_contact(request)
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
|
||||
|
||||
def get_for_viewer(scope, id, token \\ nil)
|
||||
|
||||
def get_for_viewer(%Scope{} = scope, id, token) do
|
||||
|
|
@ -130,7 +150,15 @@ defmodule WhoNeedHelp.Support do
|
|||
def get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
|
||||
|
||||
def access_token(%SupportRequest{id: id}) do
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
|
||||
max_age: case_access_max_age_seconds()
|
||||
)
|
||||
end
|
||||
|
||||
def confirmation_token(%SupportRequest{id: id}) do
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
)
|
||||
end
|
||||
|
||||
def status_url(%SupportRequest{} = request) do
|
||||
|
|
@ -140,6 +168,13 @@ defmodule WhoNeedHelp.Support do
|
|||
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
|
||||
end
|
||||
|
||||
def confirmation_url(%SupportRequest{} = request) do
|
||||
token = confirmation_token(request)
|
||||
|
||||
WhoNeedHelpWeb.Endpoint.url() <>
|
||||
"/support/cases/#{request.id}/verify?token=#{URI.encode_www_form(token)}"
|
||||
end
|
||||
|
||||
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||
if Accounts.authorized?(user, :support_view) do
|
||||
limit = Pagination.limit(options)
|
||||
|
|
@ -373,7 +408,7 @@ defmodule WhoNeedHelp.Support do
|
|||
end
|
||||
|
||||
defp notify_created({:ok, {request, :pending_verification}}) do
|
||||
_ = Notifier.deliver_confirmation(request, status_url(request))
|
||||
_ = Notifier.deliver_confirmation(request, confirmation_url(request))
|
||||
{:ok, request}
|
||||
end
|
||||
|
||||
|
|
@ -487,6 +522,7 @@ defmodule WhoNeedHelp.Support do
|
|||
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
|
||||
|
||||
defp notify_verified_request({:ok, {request, :newly_verified}}) do
|
||||
_ = Notifier.deliver_received(request, status_url(request))
|
||||
_ = Notifier.deliver_operator_alert(request)
|
||||
|
||||
event = {:support_request_updated, request.id}
|
||||
|
|
@ -499,6 +535,28 @@ defmodule WhoNeedHelp.Support do
|
|||
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
|
||||
defp notify_verified_request(result), do: result
|
||||
|
||||
defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do
|
||||
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
|
||||
max_age: contact_verification_max_age_seconds()
|
||||
) do
|
||||
{:ok, ^id} ->
|
||||
with {:ok, verified} <- verify_contact(request) do
|
||||
{:ok, preload_conversation(verified)}
|
||||
end
|
||||
|
||||
_error ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp contact_verification_max_age_seconds do
|
||||
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
|
||||
end
|
||||
|
||||
defp case_access_max_age_seconds do
|
||||
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
|
||||
end
|
||||
|
||||
defp preload_conversation(%SupportRequest{} = request) do
|
||||
Repo.preload(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -3,17 +3,21 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
|
||||
import Ecto.Query
|
||||
alias WhoNeedHelp.Help
|
||||
alias WhoNeedHelp.ContentRemoval.Notice
|
||||
alias WhoNeedHelp.Help.HelpRequest
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Support.SupportRequest
|
||||
alias WhoNeedHelp.Tracking
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelp.Trust.{AuditEvent, RateLimiter}
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(_job) do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
with {:ok, expired} <- expire_available(now, 0),
|
||||
{:ok, cleanup} <- Tracking.cleanup_finished_sessions() do
|
||||
{:ok, cleanup} <- Tracking.cleanup_finished_sessions(),
|
||||
{:ok, support_pruned} <- prune_unverified_support(verification_cutoff(now), 0),
|
||||
{:ok, removal_pruned} <- prune_unverified_removal(verification_cutoff(now), 0) do
|
||||
{pruned_buckets, _} = RateLimiter.prune_expired()
|
||||
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
|
||||
|
||||
|
|
@ -22,6 +26,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
expired: expired,
|
||||
tracking_positions_deleted: cleanup.positions_deleted,
|
||||
tracking_sessions_ended: cleanup.sessions_ended,
|
||||
unverified_support_pruned: support_pruned,
|
||||
unverified_removal_pruned: removal_pruned,
|
||||
rate_limit_buckets_pruned: pruned_buckets,
|
||||
user_tokens_pruned: pruned_user_tokens
|
||||
}}
|
||||
|
|
@ -65,4 +71,77 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp prune_unverified_support(cutoff, pruned) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
SupportRequest
|
||||
|> where(
|
||||
[request],
|
||||
is_nil(request.requester_id) and is_nil(request.contact_verified_at) and
|
||||
request.inserted_at <= ^cutoff
|
||||
)
|
||||
|> order_by([request], asc: request.inserted_at, asc: request.id)
|
||||
|> limit(1)
|
||||
|> lock("FOR UPDATE SKIP LOCKED")
|
||||
|> Repo.one()
|
||||
|
||||
case request do
|
||||
nil ->
|
||||
{:ok, :empty}
|
||||
|
||||
request ->
|
||||
delete_target_audit("support_request", request.id)
|
||||
Repo.delete(request)
|
||||
end
|
||||
end)
|
||||
|
||||
continue_pruning(result, pruned, &prune_unverified_support(cutoff, &1))
|
||||
end
|
||||
|
||||
defp prune_unverified_removal(cutoff, pruned) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
notice =
|
||||
Notice
|
||||
|> where(
|
||||
[notice],
|
||||
is_nil(notice.requester_id) and not is_nil(notice.contact_email) and
|
||||
is_nil(notice.contact_verified_at) and notice.inserted_at <= ^cutoff
|
||||
)
|
||||
|> order_by([notice], asc: notice.inserted_at, asc: notice.id)
|
||||
|> limit(1)
|
||||
|> lock("FOR UPDATE SKIP LOCKED")
|
||||
|> Repo.one()
|
||||
|
||||
case notice do
|
||||
nil ->
|
||||
{:ok, :empty}
|
||||
|
||||
notice ->
|
||||
delete_target_audit("content_removal_notice", notice.id)
|
||||
Repo.delete(notice)
|
||||
end
|
||||
end)
|
||||
|
||||
continue_pruning(result, pruned, &prune_unverified_removal(cutoff, &1))
|
||||
end
|
||||
|
||||
defp continue_pruning({:ok, :empty}, pruned, _continue), do: {:ok, pruned}
|
||||
defp continue_pruning({:ok, _record}, pruned, continue), do: continue.(pruned + 1)
|
||||
defp continue_pruning({:error, reason}, _pruned, _continue), do: {:error, reason}
|
||||
|
||||
defp delete_target_audit(target_type, target_id) do
|
||||
AuditEvent
|
||||
|> where([event], event.target_type == ^target_type and event.target_id == ^target_id)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
defp verification_cutoff(now) do
|
||||
max_age =
|
||||
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
|
||||
|
||||
DateTime.add(now, -max_age, :second)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,6 +62,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
|
|||
end
|
||||
end
|
||||
|
||||
def verify(conn, %{"id" => id, "token" => token}) do
|
||||
case ContentRemoval.verify_by_confirmation_token(id, token) do
|
||||
{:ok, notice} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("Your email was confirmed and the notice was sent for review.")
|
||||
)
|
||||
|> redirect(
|
||||
to: ~p"/legal/content-removal/#{notice.id}?token=#{ContentRemoval.access_token(notice)}"
|
||||
)
|
||||
|
||||
{:error, :not_found} ->
|
||||
send_resp(conn, :not_found, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
|
||||
|
||||
defp create_notice(conn, regime, params) do
|
||||
case ContentRemoval.create_notice(
|
||||
conn.assigns.current_scope,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,23 @@ defmodule WhoNeedHelpWeb.SupportController do
|
|||
end
|
||||
end
|
||||
|
||||
def verify(conn, %{"id" => id, "token" => token}) do
|
||||
case Support.verify_by_confirmation_token(id, token) do
|
||||
{:ok, request} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("Your email was confirmed and the request was sent to support.")
|
||||
)
|
||||
|> redirect(to: case_path(request.id, Support.access_token(request)))
|
||||
|
||||
{:error, :not_found} ->
|
||||
send_resp(conn, :not_found, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
|
||||
|
||||
def add_message(conn, %{"id" => id, "message" => params} = outer_params)
|
||||
when is_map(params) do
|
||||
token = outer_params["token"]
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
get "/support/new", SupportController, :new
|
||||
post "/support", SupportController, :create
|
||||
get "/support/received", SupportController, :received
|
||||
get "/support/cases/:id/verify", SupportController, :verify
|
||||
get "/support/cases/:id", SupportController, :show
|
||||
post "/support/cases/:id/messages", SupportController, :add_message
|
||||
post "/support/cases/:id/reopen", SupportController, :reopen
|
||||
|
|
@ -157,6 +158,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
scope "/", WhoNeedHelpWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/legal/content-removal/:id/verify", ContentRemovalController, :verify
|
||||
get "/legal/content-removal/:id", ContentRemovalController, :show
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
assert_email_sent(fn email ->
|
||||
matches_request =
|
||||
email.subject =~ request.reference and
|
||||
email.text_body =~ "/support/cases/#{request.id}?token="
|
||||
email.text_body =~ "/support/cases/#{request.id}/verify?token="
|
||||
|
||||
if matches_request, do: send(test_pid, {:support_acknowledgement, email})
|
||||
matches_request
|
||||
|
|
@ -37,19 +37,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
|
||||
assert_receive {:support_acknowledgement, email}
|
||||
|
||||
[status_url] =
|
||||
[confirmation_url] =
|
||||
Regex.run(
|
||||
~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}\?token=[^\s]+},
|
||||
~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}/verify\?token=[^\s]+},
|
||||
email.text_body
|
||||
)
|
||||
|
||||
emailed_token =
|
||||
status_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("token")
|
||||
confirmation_token =
|
||||
confirmation_url
|
||||
|> URI.parse()
|
||||
|> Map.fetch!(:query)
|
||||
|> URI.decode_query()
|
||||
|> Map.fetch!("token")
|
||||
|
||||
assert {:error, :not_found} = Support.get_by_access_token(request.id, "invalid")
|
||||
assert {:error, :not_found} = Support.get_by_access_token(request.id, confirmation_token)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Support.verify_by_confirmation_token(request.id, Support.access_token(request))
|
||||
|
||||
assert {:ok, verified} =
|
||||
Support.get_by_access_token(request.id, emailed_token)
|
||||
Support.verify_by_confirmation_token(request.id, confirmation_token)
|
||||
|
||||
verified = Repo.preload(verified, :status_events)
|
||||
|
||||
assert verified.contact_verified_at
|
||||
assert verified.status == :open
|
||||
|
|
@ -66,6 +76,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
event.action == "support_request.contact_verified" and
|
||||
event.target_id == ^request.id
|
||||
)
|
||||
|
||||
assert_email_sent(fn received ->
|
||||
received.to == [{"", request.contact_email}] and
|
||||
received.subject == "Who Need Help support request #{request.reference}" and
|
||||
received.text_body =~ "/support/cases/#{request.id}?token="
|
||||
end)
|
||||
end
|
||||
|
||||
test "operator alert is sent only after public contact verification" do
|
||||
|
|
@ -97,10 +113,16 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
refute_receive {:email, _operator_alert}, 50
|
||||
|
||||
assert {:ok, verified} =
|
||||
Support.get_by_access_token(request.id, Support.access_token(request))
|
||||
Support.verify_by_confirmation_token(request.id, Support.confirmation_token(request))
|
||||
|
||||
assert verified.status == :open
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.to == [{"", "appeal@example.com"}] and
|
||||
email.subject == "Who Need Help support request #{request.reference}" and
|
||||
email.text_body =~ "/support/cases/#{request.id}?token="
|
||||
end)
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
|
||||
email.text_body =~ "/support/operations" and
|
||||
|
|
@ -191,7 +213,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
})
|
||||
|
||||
assert {:ok, verified} =
|
||||
Support.get_by_access_token(pending.id, Support.access_token(pending))
|
||||
Support.verify_by_confirmation_token(
|
||||
pending.id,
|
||||
Support.confirmation_token(pending)
|
||||
)
|
||||
|
||||
assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id]
|
||||
end
|
||||
|
|
@ -432,7 +457,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
})
|
||||
|
||||
assert {:ok, verified} =
|
||||
ContentRemoval.get_by_access_token(notice.id, ContentRemoval.access_token(notice))
|
||||
ContentRemoval.verify_by_confirmation_token(
|
||||
notice.id,
|
||||
ContentRemoval.confirmation_token(notice)
|
||||
)
|
||||
|
||||
assert verified.contact_verified_at
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
|||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
|
||||
alias WhoNeedHelp.{Catalog, Help, Repo}
|
||||
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
|
||||
alias WhoNeedHelp.ContentRemoval.Notice
|
||||
alias WhoNeedHelp.Help.HelpRequest
|
||||
alias WhoNeedHelp.Support.SupportRequest
|
||||
alias WhoNeedHelp.Trust.AuditEvent
|
||||
alias WhoNeedHelp.Workers.ExpireRequests
|
||||
|
||||
|
|
@ -72,4 +74,110 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
|||
|
||||
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
||||
end
|
||||
|
||||
test "prunes only expired unverified public support and removal submissions" do
|
||||
support_attrs = fn email, subject ->
|
||||
%{
|
||||
"kind" => "technical_issue",
|
||||
"contact_email" => email,
|
||||
"subject" => subject,
|
||||
"details" => "This public support request exercises verification lifecycle cleanup."
|
||||
}
|
||||
end
|
||||
|
||||
removal_attrs = fn email, suffix ->
|
||||
%{
|
||||
"category" => "privacy_violation",
|
||||
"submitter_name" => "Lifecycle reporter",
|
||||
"contact_email" => email,
|
||||
"relationship" => "self",
|
||||
"content_locations" => "https://example.test/requests/lifecycle-#{suffix}",
|
||||
"explanation" => "This public removal notice exercises verification lifecycle cleanup.",
|
||||
"electronic_signature" => "Lifecycle reporter",
|
||||
"good_faith" => "true",
|
||||
"accurate_complete" => "true"
|
||||
}
|
||||
end
|
||||
|
||||
assert {:ok, expired_support} =
|
||||
Support.create_request(
|
||||
nil,
|
||||
support_attrs.("expired-support@example.com", "Expired public support")
|
||||
)
|
||||
|
||||
assert {:ok, fresh_support} =
|
||||
Support.create_request(
|
||||
nil,
|
||||
support_attrs.("fresh-support@example.com", "Fresh public support")
|
||||
)
|
||||
|
||||
assert {:ok, verified_support} =
|
||||
Support.create_request(
|
||||
nil,
|
||||
support_attrs.("verified-support@example.com", "Verified public support")
|
||||
)
|
||||
|
||||
assert {:ok, verified_support} =
|
||||
Support.verify_by_confirmation_token(
|
||||
verified_support.id,
|
||||
Support.confirmation_token(verified_support)
|
||||
)
|
||||
|
||||
assert {:ok, expired_removal} =
|
||||
ContentRemoval.create_notice(
|
||||
nil,
|
||||
:general,
|
||||
removal_attrs.("expired-removal@example.com", "expired")
|
||||
)
|
||||
|
||||
assert {:ok, fresh_removal} =
|
||||
ContentRemoval.create_notice(
|
||||
nil,
|
||||
:general,
|
||||
removal_attrs.("fresh-removal@example.com", "fresh")
|
||||
)
|
||||
|
||||
max_age =
|
||||
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
|
||||
|
||||
expired_at = DateTime.add(DateTime.utc_now(:second), -(max_age + 1), :second)
|
||||
|
||||
Repo.update_all(
|
||||
from(request in SupportRequest, where: request.id == ^expired_support.id),
|
||||
set: [inserted_at: expired_at]
|
||||
)
|
||||
|
||||
Repo.update_all(
|
||||
from(notice in Notice, where: notice.id == ^expired_removal.id),
|
||||
set: [inserted_at: expired_at]
|
||||
)
|
||||
|
||||
assert {:ok,
|
||||
%{
|
||||
unverified_support_pruned: 1,
|
||||
unverified_removal_pruned: 1
|
||||
}} = ExpireRequests.perform(%Oban.Job{})
|
||||
|
||||
refute Repo.get(SupportRequest, expired_support.id)
|
||||
refute Repo.get(Notice, expired_removal.id)
|
||||
assert Repo.get(SupportRequest, fresh_support.id)
|
||||
assert Repo.get(SupportRequest, verified_support.id)
|
||||
assert Repo.get(Notice, fresh_removal.id)
|
||||
|
||||
refute Repo.exists?(
|
||||
from(event in AuditEvent,
|
||||
where:
|
||||
event.target_type == "support_request" and
|
||||
event.target_id == ^expired_support.id
|
||||
)
|
||||
)
|
||||
|
||||
refute Repo.exists?(
|
||||
from(event in AuditEvent,
|
||||
where:
|
||||
event.target_type == "content_removal_notice" and
|
||||
event.target_id == ^expired_removal.id
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -120,6 +120,56 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
assert_email_sent()
|
||||
end
|
||||
|
||||
test "confirmation endpoints exchange short-lived confirmation links for status links", %{
|
||||
conn: conn
|
||||
} do
|
||||
assert {:ok, support_request} =
|
||||
WhoNeedHelp.Support.create_request(nil, %{
|
||||
"kind" => "technical_issue",
|
||||
"contact_email" => "confirmation-support@example.com",
|
||||
"subject" => "Confirm the public support contact",
|
||||
"details" =>
|
||||
"The confirmation endpoint must redirect to a separate private status link."
|
||||
})
|
||||
|
||||
support_conn =
|
||||
get(
|
||||
conn,
|
||||
~p"/support/cases/#{support_request.id}/verify?token=#{WhoNeedHelp.Support.confirmation_token(support_request)}"
|
||||
)
|
||||
|
||||
support_location = redirected_to(support_conn)
|
||||
assert support_location =~ "/support/cases/#{support_request.id}?token="
|
||||
refute support_location =~ "/verify"
|
||||
assert Repo.get!(SupportRequest, support_request.id).contact_verified_at
|
||||
|
||||
assert {:ok, removal_notice} =
|
||||
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
|
||||
"category" => "privacy_violation",
|
||||
"submitter_name" => "Confirmation reporter",
|
||||
"contact_email" => "confirmation-removal@example.com",
|
||||
"relationship" => "self",
|
||||
"content_locations" => "https://example.test/requests/confirmation-removal",
|
||||
"explanation" =>
|
||||
"The removal confirmation endpoint must issue a separate status link.",
|
||||
"electronic_signature" => "Confirmation reporter",
|
||||
"good_faith" => "true",
|
||||
"accurate_complete" => "true"
|
||||
})
|
||||
|
||||
removal_conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/legal/content-removal/#{removal_notice.id}/verify?token=#{WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)}"
|
||||
)
|
||||
|
||||
removal_location = redirected_to(removal_conn)
|
||||
assert removal_location =~ "/legal/content-removal/#{removal_notice.id}?token="
|
||||
refute removal_location =~ "/verify"
|
||||
assert Repo.get!(WhoNeedHelp.ContentRemoval.Notice, removal_notice.id).contact_verified_at
|
||||
end
|
||||
|
||||
test "removal intake enforces independent contact and client IP limits", %{conn: conn} do
|
||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
|
|
@ -381,15 +431,15 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
})
|
||||
|
||||
assert {:ok, _verified_removal_notice} =
|
||||
WhoNeedHelp.ContentRemoval.get_by_access_token(
|
||||
WhoNeedHelp.ContentRemoval.verify_by_confirmation_token(
|
||||
removal_notice.id,
|
||||
WhoNeedHelp.ContentRemoval.access_token(removal_notice)
|
||||
WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)
|
||||
)
|
||||
|
||||
assert {:ok, _verified_support_request} =
|
||||
WhoNeedHelp.Support.get_by_access_token(
|
||||
WhoNeedHelp.Support.verify_by_confirmation_token(
|
||||
support_request.id,
|
||||
WhoNeedHelp.Support.access_token(support_request)
|
||||
WhoNeedHelp.Support.confirmation_token(support_request)
|
||||
)
|
||||
|
||||
staff_conn = log_in_user(conn, moderator)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user