Preserve account context after invalid sign-in links

This commit is contained in:
SimpleTest 2026-08-25 23:18:58 +03:00
parent dcac2fa0bc
commit 305bdebdd1
6 changed files with 83 additions and 17 deletions

View File

@ -192,15 +192,13 @@ as forward-only rather than receiving an invented database rollback.
the production Google OAuth client and exact callback origin. the production Google OAuth client and exact callback origin.
- [x] A production-generated authentication email reaches an external mailbox - [x] A production-generated authentication email reaches an external mailbox
and is DKIM-signed by the production domain. and is DKIM-signed by the production domain.
- [ ] Authentication-email action URLs use the production domain directly. - [x] Authentication-email action URLs use the production domain directly.
Brevo currently rewrites the action href through its tracking domain even The app-only production release at `dcac2fa` removed the HTML action
though the visible fallback origin is `https://whoneedhelp.com/`. A anchor and leaves one visible copy-and-paste URL. A newly generated
read-only account check on 2026-08-21 found only Brevo's account-wide production message was inspected in Gmail on 2026-08-25: Gmail's detected
anonymous-tracking control; Brevo documents that this still records link targeted `https://whoneedhelp.com` directly, no Brevo or UniSender
aggregate clicks, so it is not evidence that the primary action remains tracking host appeared, and the one-time link completed the explicit
direct. A newly delivered message was checked on 2026-08-25: its direct production reauthentication flow.
production-domain fallback completed reauthentication and immediate
reuse was rejected, while its primary button was still provider-tracked.
- [x] The Web Push provider accepts a production notification for a real active - [x] The Web Push provider accepts a production notification for a real active
browser subscription without disabling the device. browser subscription without disabling the device.
- [ ] A person has observed the resulting operating-system browser notification - [ ] A person has observed the resulting operating-system browser notification

View File

@ -1368,7 +1368,7 @@ this audit.
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. The Play-delivered production build repeated the disclosure, foreground permission, minimized-app sampling, notification Stop, raw-position deletion, offline recovery, and process-recreation paths on a physical phone. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. The Play-delivered production build repeated the disclosure, foreground permission, minimized-app sampling, notification Stop, raw-position deletion, offline recovery, and process-recreation paths on a physical phone. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. On 2026-08-25, a newly delivered message reached Gmail Inbox, its direct `https://whoneedhelp.com` fallback completed production reauthentication, and immediate reuse of the same token was rejected. | Brevo still rewrites the primary button href through its tracking domain. The direct fallback is verified, but eliminating the provider-tracked primary action remains an open deliverability/privacy decision. | | Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. On 2026-08-25, a message generated after production release `dcac2fa` reached Gmail Inbox, Gmail detected one direct `https://whoneedhelp.com` link without a provider tracking host, and that link completed the explicit production reauthentication flow. | The application cannot guarantee how every mailbox provider chooses to auto-link visible text. SMTP exactly-once delivery is not claimed. |
| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban push jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Immediate per-request nearby email is retired. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | A batched nearby email digest is not implemented; it requires a defined cadence and delivery cursor. Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. | | Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban push jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Immediate per-request nearby email is retired. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | A batched nearby email digest is not implemented; it requires a defined cadence and delivery cursor. Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. |
@ -1377,7 +1377,7 @@ this audit.
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
| Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. |
| External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. Production checks covered Google OIDC, Brevo authentication-email receipt, a direct production-domain fallback link with one-time-token rejection, browser Web Push provider acceptance, and FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, a non-tracked primary authentication CTA, visible browser OS-notification interaction, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | | External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. Production checks covered Google OIDC, Brevo authentication-email receipt with a direct production-domain action, browser Web Push provider acceptance, and FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, visible browser OS-notification interaction, and APNs remain unverified. SMTP exactly-once delivery is not claimed. |
## Reproducible checks ## Reproducible checks
@ -3367,6 +3367,45 @@ promoted.
not changed. The direct-action checklist item remains open until a newly not changed. The direct-action checklist item remains open until a newly
delivered production message is inspected after an app-only release. delivered production message is inspected after an app-only release.
# 2026-08-25 direct production authentication-link verification
- Commit `dcac2fa0bc4b086ba76f4d9082306447926d040b` was deployed through an
app-only production release. The release did not include a migration. The
production readiness endpoint remained healthy with zero container restarts;
the frozen hackathon test deployment remained at
`cf7bacdf61ffb171ebac85e32dfcf12bc6972d59` and was not changed.
- One passwordless sign-in request for the existing operator generated one new
production message in Gmail. Gmail's auto-linked target used the direct
`https://whoneedhelp.com` origin; no Brevo or UniSender tracking hostname was
present in that target. The message was returned to unread state after the
check.
- Opening the direct link displayed the explicit same-account
reauthentication screen. Choosing the persistent-session action completed
sign-in and returned to the production home with the success notice. This
closes the direct authentication-action checklist item for the observed
production delivery; it does not claim that every mailbox client renders
plain URL text identically.
- Reopening a consumed or invalid magic link while already signed in exposed a
separate presentation defect: the readonly account field was blank. The
controller now populates that field only from the authenticated session's
account and otherwise preserves the existing empty unauthenticated form. A
focused regression test first failed against the old behavior and then
passed with the fix.
- Updating the controller shifted gettext source references. The catalog was
regenerated with `mix gettext.extract` before the final check. The quality
run also exposed a nondeterministic shell-test defect when a valid generated
VAPID key began with `-`; the test now terminates grep options with `--`
before comparing generated key material.
- The isolated full quality unit
`codex-heavy-wnh-invalid-link-quality-r2-20260825-231311-462995.service`
completed successfully in 4 minutes 18.128 seconds. ExUnit reported 488
passing tests, and every configured quality and security gate passed. The
observed unit peak was 244.3 MiB with 11.9 MiB of swap.
- Exact post-run inspection found no container, network, volume, temporary
quality/security image, or gettext-generation image from the run. These last
controller, test, catalog, and quality-script changes remain local at the
time of this record; the public remote and frozen test were not changed.
# 2026-08-21 connected-device Play delivery recheck # 2026-08-21 connected-device Play delivery recheck
- The authorised physical device `72551e60` reported installed package - The authorised physical device `72551e60` reported installed package

View File

@ -48,7 +48,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
{:error, :not_found} -> {:error, :not_found} ->
conn conn
|> put_flash(:error, gettext("The link is invalid or it has expired.")) |> put_flash(:error, gettext("The link is invalid or it has expired."))
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) |> render(:new, form: invalid_magic_link_form(conn))
end end
end end
@ -158,6 +158,16 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user")) |> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
end end
defp invalid_magic_link_form(%{
assigns: %{current_scope: %Scope{user: %Accounts.User{email: email}}}
}) do
Phoenix.Component.to_form(%{"email" => email}, as: "user")
end
defp invalid_magic_link_form(_conn) do
Phoenix.Component.to_form(%{}, as: "user")
end
defp maybe_restore_pending_google(conn, %{"google_link" => token}) when is_binary(token) do defp maybe_restore_pending_google(conn, %{"google_link" => token}) when is_binary(token) do
case GoogleAuthPending.restore(conn, token) do case GoogleAuthPending.restore(conn, token) do
{:ok, conn} -> conn {:ok, conn} -> conn

View File

@ -49,7 +49,7 @@ msgstr ""
#: lib/who_need_help_web/components/layouts.ex:95 #: lib/who_need_help_web/components/layouts.ex:95
#: lib/who_need_help_web/components/layouts.ex:168 #: lib/who_need_help_web/components/layouts.ex:168
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:14 #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:14
#: lib/who_need_help_web/controllers/user_session_controller.ex:215 #: lib/who_need_help_web/controllers/user_session_controller.ex:225
#: lib/who_need_help_web/controllers/user_session_html/new.html.heex:10 #: lib/who_need_help_web/controllers/user_session_html/new.html.heex:10
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Log in" msgid "Log in"
@ -3757,7 +3757,7 @@ msgstr ""
msgid "Email me a verification link" msgid "Email me a verification link"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/user_session_controller.ex:183 #: lib/who_need_help_web/controllers/user_session_controller.ex:193
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Google sign-in connected. You can use it next time." msgid "Google sign-in connected. You can use it next time."
msgstr "" msgstr ""
@ -3809,7 +3809,7 @@ msgstr ""
msgid "We sent a verification link to %{email}. Open it to finish connecting Google." msgid "We sent a verification link to %{email}. Open it to finish connecting Google."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/user_session_controller.ex:196 #: lib/who_need_help_web/controllers/user_session_controller.ex:206
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You are signed in, but Google could not be connected. Try again in account settings." msgid "You are signed in, but Google could not be connected. Try again in account settings."
msgstr "" msgstr ""

View File

@ -1756,9 +1756,9 @@ test "$generated_vapid_public" != "$generated_vapid_private"
grep -Fx 'WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test' \ grep -Fx 'WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test' \
"$generated_vapid_env" >/dev/null "$generated_vapid_env" >/dev/null
if printf '%s' "$vapid_output" | if printf '%s' "$vapid_output" |
grep -F "$generated_vapid_public" >/dev/null || grep -F -- "$generated_vapid_public" >/dev/null ||
printf '%s' "$vapid_output" | printf '%s' "$vapid_output" |
grep -F "$generated_vapid_private" >/dev/null; then grep -F -- "$generated_vapid_private" >/dev/null; then
echo "VAPID generator printed generated key material." >&2 echo "VAPID generator printed generated key material." >&2
exit 1 exit 1
fi fi

View File

@ -296,6 +296,25 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
assert html_response(conn, 200) =~ "The link is invalid or it has expired." assert html_response(conn, 200) =~ "The link is invalid or it has expired."
end end
test "keeps the signed-in account visible when a magic link is invalid", %{
conn: conn,
user: user
} do
html =
conn
|> log_in_user(user)
|> post(~p"/users/log-in", %{"user" => %{"token" => "invalid"}})
|> html_response(200)
assert html =~ "The link is invalid or it has expired."
assert html =~
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
assert html =~
~r/<input(?=[^>]*id="login_form_magic_email")(?=[^>]*value="#{Regex.escape(user.email)}")(?=[^>]*\sreadonly(?:[=\s>]))[^>]*>/
end
end end
describe "DELETE /users/log-out" do describe "DELETE /users/log-out" do