feat: add Google authentication and harden sign-in
This commit is contained in:
parent
a080c3e183
commit
c4721e6702
|
|
@ -54,6 +54,11 @@ GITHUB_OAUTH_TOKEN_URL=
|
|||
GITHUB_OAUTH_USER_URL=
|
||||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_BASE_URL=
|
||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
|
||||
E2E_BASE_URL=https://proxy
|
||||
E2E_MAILPIT_URL=http://mailpit:8025
|
||||
|
|
|
|||
11
.env.example
11
.env.example
|
|
@ -52,6 +52,17 @@ GITHUB_OAUTH_USER_URL=
|
|||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
|
||||
# Optional Google OpenID Connect registration and sign-in. Leave both empty
|
||||
# until a Google OAuth Web client exists. Its callback URL must be:
|
||||
# https://YOUR_PHX_HOST/auth/google/callback
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
# Leave endpoint and timeout overrides empty for Google's discovery endpoint
|
||||
# and Req defaults. The base URL override exists for isolated protocol tests.
|
||||
GOOGLE_OAUTH_BASE_URL=
|
||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
|
||||
# Optional provider-neutral HTTP push boundary. Leave both endpoint and token
|
||||
# empty to disable product push jobs. When enabled, all four numeric values are
|
||||
# required deployment inputs; the project does not claim universal production
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ GITHUB_OAUTH_TOKEN_URL=
|
|||
GITHUB_OAUTH_USER_URL=
|
||||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_BASE_URL=
|
||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
|
||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||
|
||||
# These are reproducible experiment inputs, not capacity requirements,
|
||||
# production traffic forecasts, alert thresholds, or recommended limits.
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -12,8 +12,9 @@ thank-you link; money goes directly between users outside the platform.
|
|||
|
||||
## What is implemented
|
||||
|
||||
- Phoenix 1.8 LiveView application with email magic-link/password auth and 18+
|
||||
self-attestation.
|
||||
- Phoenix 1.8 LiveView application with passwordless email magic links,
|
||||
optional passwords, optional Google OpenID Connect registration/sign-in, and
|
||||
18+ self-attestation.
|
||||
- Data-driven, translated category tree with validated per-category fields,
|
||||
seven selectable urgent/roadside scenarios, community proposals/votes, and
|
||||
human approve/reject/merge tools.
|
||||
|
|
@ -140,7 +141,7 @@ restores it into a new temporary database, checks corruption and interruption
|
|||
failure paths, removes those temporary buckets, and retains the successful
|
||||
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
|
||||
|
||||
Exercise the real OAuth/SMTP protocol clients and the provider-neutral push
|
||||
Exercise the real GitHub OAuth, Google OIDC, SMTP protocol clients, and the provider-neutral push
|
||||
boundary entirely inside an isolated Docker network:
|
||||
|
||||
```bash
|
||||
|
|
@ -178,8 +179,9 @@ printing them, writes mode `0600`, and refuses to replace an existing file:
|
|||
```
|
||||
|
||||
Configure the verified reverse-proxy source IP/CIDR and transactional SMTP
|
||||
provider in that file, then validate its structure and the production Compose
|
||||
render:
|
||||
provider in that file. Email registration and magic-link login are unusable for
|
||||
real recipients until that relay and its accepted sender are configured.
|
||||
Then validate the file structure and the production Compose render:
|
||||
|
||||
```bash
|
||||
./scripts/validate-production-env.sh .env.production whoneedhelp.com
|
||||
|
|
@ -207,6 +209,39 @@ docker compose up -d --wait
|
|||
This preserves the named PostgreSQL volume, but invalidates existing browser
|
||||
sessions and changes handover codes for active local requests.
|
||||
|
||||
To rotate only the local PostgreSQL role and matching `DATABASE_URL`, without
|
||||
changing Phoenix sessions or handover codes, use:
|
||||
|
||||
```bash
|
||||
./scripts/rotate-local-secrets.sh --database-only
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
### Optional Google registration and sign-in
|
||||
|
||||
Create a Google OAuth 2.0 Web application only for an owned HTTPS origin. Add
|
||||
this exact authorized redirect URI in Google Cloud:
|
||||
|
||||
```text
|
||||
https://YOUR_PHX_HOST/auth/google/callback
|
||||
```
|
||||
|
||||
Set both `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` in the
|
||||
ignored `.env` or deployment Secret, then recreate the application containers.
|
||||
If both values are empty, the Google buttons remain visible but disabled with
|
||||
an explanation. A partial pair is rejected at startup and by the production
|
||||
environment validator.
|
||||
|
||||
The flow requests `openid email profile`, verifies the provider email claim,
|
||||
uses state, nonce, and PKCE, and discards provider tokens. Google registration
|
||||
creates a confirmed local account only after the user accepts the 18+ safety
|
||||
terms. Google login works only for an identity already linked to that local
|
||||
account. An existing local account is never merged merely because Google
|
||||
returns the same email; sign in by email or password and connect Google from
|
||||
the sudo-protected account settings page instead. Leave
|
||||
`GOOGLE_OAUTH_BASE_URL` and the Google HTTP timeout variables empty outside the
|
||||
isolated protocol drill.
|
||||
|
||||
### Optional verified GitHub linking
|
||||
|
||||
Create a GitHub OAuth App with this exact callback URL for the active public
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ x-boundary-app-environment: &boundary-app-environment
|
|||
ALLOW_INSECURE_EXTERNAL_HTTP: "true"
|
||||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
|
||||
GOOGLE_OAUTH_CLIENT_ID: ${EXTERNAL_OAUTH_CLIENT_ID:?Set EXTERNAL_OAUTH_CLIENT_ID}
|
||||
GOOGLE_OAUTH_CLIENT_SECRET: ${EXTERNAL_OAUTH_CLIENT_SECRET:?Set EXTERNAL_OAUTH_CLIENT_SECRET}
|
||||
GOOGLE_OAUTH_BASE_URL: http://external-mock:8080
|
||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
|
||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
|
||||
SMTP_RELAY: external-mock
|
||||
SMTP_PORT: "2525"
|
||||
SMTP_USERNAME: ""
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ x-app-environment: &app-environment
|
|||
GITHUB_OAUTH_USER_URL: ${GITHUB_OAUTH_USER_URL:-}
|
||||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-}
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
||||
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||
GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||
GOOGLE_OAUTH_BASE_URL: ${GOOGLE_OAUTH_BASE_URL:-}
|
||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-}
|
||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
||||
PUSH_HTTP_ENDPOINT: ${PUSH_HTTP_ENDPOINT:-}
|
||||
PUSH_HTTP_BEARER_TOKEN: ${PUSH_HTTP_BEARER_TOKEN:-}
|
||||
PUSH_HTTP_MAX_ATTEMPTS: ${PUSH_HTTP_MAX_ATTEMPTS:-}
|
||||
|
|
|
|||
|
|
@ -133,20 +133,21 @@ oauth_endpoint = fn name, default ->
|
|||
end
|
||||
end
|
||||
|
||||
oauth_http_options =
|
||||
oauth_http_options = fn prefix ->
|
||||
[retry: false]
|
||||
|> then(fn options ->
|
||||
case optional_positive_integer.("GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS") do
|
||||
case optional_positive_integer.("#{prefix}_OAUTH_HTTP_RECEIVE_TIMEOUT_MS") do
|
||||
nil -> options
|
||||
timeout -> Keyword.put(options, :receive_timeout, timeout)
|
||||
end
|
||||
end)
|
||||
|> then(fn options ->
|
||||
case optional_positive_integer.("GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS") do
|
||||
case optional_positive_integer.("#{prefix}_OAUTH_HTTP_CONNECT_TIMEOUT_MS") do
|
||||
nil -> options
|
||||
timeout -> Keyword.put(options, :connect_options, timeout: timeout)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
github_oauth =
|
||||
case {
|
||||
|
|
@ -172,7 +173,7 @@ github_oauth =
|
|||
"https://github.com/login/oauth/access_token"
|
||||
),
|
||||
user_url: oauth_endpoint.("GITHUB_OAUTH_USER_URL", "https://api.github.com/user"),
|
||||
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options}
|
||||
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GITHUB")}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +188,33 @@ github_oauth =
|
|||
|
||||
config :who_need_help, :social_oauth, github_oauth
|
||||
|
||||
google_auth =
|
||||
case {
|
||||
System.get_env("GOOGLE_OAUTH_CLIENT_ID"),
|
||||
System.get_env("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||
} do
|
||||
{client_id, client_secret}
|
||||
when is_binary(client_id) and client_id != "" and is_binary(client_secret) and
|
||||
client_secret != "" ->
|
||||
[
|
||||
client_id: client_id,
|
||||
client_secret: client_secret,
|
||||
base_url: oauth_endpoint.("GOOGLE_OAUTH_BASE_URL", "https://accounts.google.com/"),
|
||||
authorization_params: [scope: "email profile"],
|
||||
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")}
|
||||
]
|
||||
|
||||
{client_id, client_secret} when client_id in [nil, ""] and client_secret in [nil, ""] ->
|
||||
[]
|
||||
|
||||
_partial_configuration ->
|
||||
raise """
|
||||
GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must either both be set or both be empty.
|
||||
"""
|
||||
end
|
||||
|
||||
config :who_need_help, :google_auth, google_auth
|
||||
|
||||
push_configuration =
|
||||
case {
|
||||
System.get_env("PUSH_HTTP_ENDPOINT"),
|
||||
|
|
@ -366,6 +394,23 @@ if config_env() == :prod do
|
|||
other -> raise "SMTP_SSL must be true, false, 1, or 0; got #{inspect(other)}"
|
||||
end
|
||||
|
||||
smtp_username = System.get_env("SMTP_USERNAME")
|
||||
smtp_password = System.get_env("SMTP_PASSWORD")
|
||||
smtp_username_set? = is_binary(smtp_username) and smtp_username != ""
|
||||
smtp_password_set? = is_binary(smtp_password) and smtp_password != ""
|
||||
|
||||
if smtp_username_set? != smtp_password_set? do
|
||||
raise "SMTP_USERNAME and SMTP_PASSWORD must either both be set or both be empty."
|
||||
end
|
||||
|
||||
if smtp_auth == :always and not (smtp_username_set? and smtp_password_set?) do
|
||||
raise "SMTP_USERNAME and SMTP_PASSWORD are required when SMTP_AUTH is always."
|
||||
end
|
||||
|
||||
if smtp_ssl and smtp_tls != :never do
|
||||
raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection."
|
||||
end
|
||||
|
||||
smtp_config =
|
||||
[
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
|
|
@ -376,13 +421,13 @@ if config_env() == :prod do
|
|||
ssl: smtp_ssl
|
||||
]
|
||||
|> then(fn config ->
|
||||
case System.get_env("SMTP_USERNAME") do
|
||||
case smtp_username do
|
||||
value when is_binary(value) and value != "" -> Keyword.put(config, :username, value)
|
||||
_ -> config
|
||||
end
|
||||
end)
|
||||
|> then(fn config ->
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
case smtp_password do
|
||||
value when is_binary(value) and value != "" -> Keyword.put(config, :password, value)
|
||||
_ -> config
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
|||
# In test we don't send emails
|
||||
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test
|
||||
config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake
|
||||
config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake
|
||||
config :who_need_help, :metrics_token, "test-metrics-token"
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ app:
|
|||
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
|
||||
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both
|
||||
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
|
||||
# GitHub linking. Push is opt-in: provide PUSH_HTTP_ENDPOINT,
|
||||
# GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and
|
||||
# GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Push is
|
||||
# opt-in: provide PUSH_HTTP_ENDPOINT,
|
||||
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
|
||||
# PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and
|
||||
# PUSH_HTTP_RETRY_DELAY_MS as one complete set.
|
||||
|
|
|
|||
|
|
@ -47,8 +47,13 @@ and are not represented as complete.
|
|||
|
||||
## Application boundaries
|
||||
|
||||
- `Accounts`: users, authentication, social identities, privacy preferences,
|
||||
blocks, and roles.
|
||||
- `Accounts`: users, email/password authentication, private authentication
|
||||
identities, public social identities, privacy preferences, blocks, and
|
||||
roles.
|
||||
- `GoogleAuth`: optional Google OpenID Connect boundary for registration,
|
||||
sign-in, and account linking. It uses state, nonce, and PKCE, returns only a
|
||||
normalized verified-email identity, and does not expose provider tokens to
|
||||
controllers or persistence.
|
||||
- `SocialOAuth`: optional provider boundary for verified social linking. The
|
||||
GitHub adapter uses OAuth state and PKCE, returns only normalized identity
|
||||
attributes to the controller, and does not expose the provider token to the
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ do not copy the temporary VPN value into an unrelated server.
|
|||
Configure the transactional SMTP relay and sender accepted by that provider.
|
||||
Provider-specific auth, TLS, ports, and credentials can be supplied to the
|
||||
initializer through the documented `PRODUCTION_SMTP_*` environment values or
|
||||
edited in the resulting ignored file. Then run:
|
||||
edited in the resulting ignored file. If Google registration/sign-in is
|
||||
enabled, also set both Google Web client credentials and register
|
||||
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
|
||||
URI. Leave both credentials empty to keep the feature disabled. Then run:
|
||||
|
||||
```bash
|
||||
./scripts/validate-production-env.sh .env.production whoneedhelp.com
|
||||
|
|
@ -73,8 +76,9 @@ docker compose \
|
|||
The production override keeps Mailpit behind its inactive `local-mail` profile,
|
||||
so public registration cannot appear to succeed while mail is only retained
|
||||
locally. The validator checks file ownership/mode, origin consistency,
|
||||
template markers, independent generated secrets, supported SMTP values, the
|
||||
generated database URL, and the final Compose render without printing secrets.
|
||||
template markers, independent generated secrets, supported SMTP values, a
|
||||
complete-or-empty Google credential pair, the generated database URL, and the
|
||||
final Compose render without printing secrets.
|
||||
It does not contact DNS, TLS, SMTP, the reverse proxy, or the application.
|
||||
After deployment, verify `/healthz/ready`, inspect all replica health and logs,
|
||||
register a unique address through the public browser, receive its message at
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ helper per request. Every state transition is authorized and recorded.
|
|||
|
||||
## MVP scope
|
||||
|
||||
- Email magic-link/password authentication.
|
||||
- Passwordless email magic-link authentication, optional passwords, and
|
||||
optional Google OpenID Connect registration/sign-in.
|
||||
- English default interface plus Ukrainian and Russian translations.
|
||||
- Medicine pickup/delivery requests.
|
||||
- Safe, non-emergency fuel delivery, car-wheel help, bicycle and motorcycle
|
||||
|
|
@ -77,8 +78,9 @@ helper per request. Every state transition is authorized and recorded.
|
|||
- Multiple simultaneous helpers on one request.
|
||||
- Production-signed Android release, store publication, and native iOS
|
||||
application.
|
||||
- OAuth verification for providers other than GitHub and automated
|
||||
social-network identity scoring.
|
||||
- OAuth verification of public social-profile badges for providers other than
|
||||
GitHub, and automated social-network identity scoring. Google authentication
|
||||
is a private sign-in identity, not a public verified social-profile badge.
|
||||
- Automatic punitive abuse enforcement and unapproved production thresholds.
|
||||
- Claims that identity, safety, or fraud prevention is perfect.
|
||||
|
||||
|
|
@ -96,6 +98,12 @@ configured GitHub OAuth flow can set `verified_at` after provider authorization;
|
|||
one provider account cannot be linked to two local accounts. The provider
|
||||
access token is discarded rather than stored.
|
||||
|
||||
Google OpenID Connect identities are stored separately from public social
|
||||
links. A verified Google email can create a confirmed local account after the
|
||||
same 18+ safety acknowledgement, or can be connected from a recently
|
||||
reauthenticated account-settings session. Matching email addresses never merge
|
||||
accounts automatically, and Google provider tokens are not persisted.
|
||||
|
||||
## Privacy settings
|
||||
|
||||
Every user chooses one location visibility level:
|
||||
|
|
|
|||
|
|
@ -16,16 +16,38 @@ results from product limits and unknown production properties.
|
|||
| 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. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | 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. |
|
||||
| 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. |
|
||||
| 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 current 186-test suite includes callback replay/state checks. 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. |
|
||||
| Account registration and sign-in | Implemented and browser-verified | Email registration sends a confirmation magic link and does not require a password. 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 260-test suite. A headed Chrome run completed registration, confirmation through Mailpit, password setup, logout, password login, and localized settings with zero console warnings or errors. | The local Compose environment captures email in Mailpit. A production SMTP relay and a real Google Web client are not configured or externally verified. |
|
||||
| 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. |
|
||||
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
|
||||
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Seven lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests passed on each of API 30, 34, and 37. The API 37 staging smoke asserted the public home and Safety DOM over HTTPS. A run-scoped Android/browser staging test passed login, private chat in both directions, foreground tracking, live marker appearance/removal, and exact cleanup. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
|
||||
| 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. |
|
||||
| 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 locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against an internal-only mock; OAuth and SMTP success/rejection/retry/replay/timeout paths passed. The HTTP push boundary passed disabled, retry, rejection, timeout, and idempotency paths. Request acceptance and new-chat transactions created durable jobs processed by two Oban worker replicas; the chat event completed on Oban attempt 2 after an injected temporary failure. | This does not verify external provider availability or device delivery. FCM/APNs token registration and provider selection remain external work; SMTP exactly-once delivery is not claimed. |
|
||||
| External protocol boundaries | Implemented and locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against internal-only mocks. GitHub OAuth, Google OIDC discovery/authorization/token/JWKS with nonce and PKCE, and SMTP success/rejection/retry/replay/timeout paths passed. The HTTP push boundary passed disabled, retry, rejection, timeout, and idempotency paths. Request acceptance and new-chat transactions created durable jobs processed by two Oban worker replicas; the chat event completed on Oban attempt 2 after an injected temporary failure. | This does not verify real external provider availability or device delivery. Google/GitHub production clients, production SMTP, FCM/APNs token registration, and provider selection remain external work; SMTP exactly-once delivery is not claimed. |
|
||||
|
||||
## Reproducible checks
|
||||
|
||||
- On 2026-07-20, after adding Google OpenID Connect authentication,
|
||||
`./scripts/test.sh` and `mix precommit` each passed 260 tests with zero
|
||||
failures. The full isolated `./scripts/quality.sh` gate also passed format,
|
||||
compilation with warnings as errors, xref, Credo, Sobelow, Dialyzer, Hex and
|
||||
npm audits, Compose/Helm rendering, environment validation, and configured
|
||||
HIGH/CRITICAL source/image scans.
|
||||
- The Google boundary drill used the production Assent adapter against a local
|
||||
OIDC server with discovery, authorization, token, signed ID token, JWKS,
|
||||
state, nonce, PKCE, unverified-email rejection, and one-time callback replay
|
||||
checks. It passed together with GitHub OAuth, SMTP, push, and two-worker Oban
|
||||
paths. Evidence is retained at
|
||||
`output/external-boundaries/google-auth-fixed-20260720`.
|
||||
- Headed Chrome verified the public temporary HTTPS origin through the
|
||||
email-only registration form, Mailpit confirmation link, one-time login,
|
||||
password creation, logout, password login, Russian locale selection, and
|
||||
Google connection settings. The configured Google credential pair is empty,
|
||||
so the UI correctly left Google actions disabled. Browser console inspection
|
||||
reported zero errors and zero warnings. The run-owned account and its one
|
||||
cascading login token were removed after read-only relationship checks; no
|
||||
help, activity, message, tracking, review, or OAuth rows belonged to it. The
|
||||
browser was left open.
|
||||
- The isolated Phoenix suite completed on 2026-07-19 with 172
|
||||
tests and 0 failures after cursor pagination, database aggregation, and the
|
||||
full localization changes
|
||||
|
|
@ -242,6 +264,14 @@ decoded lengths were inspected: the PostgreSQL password is 64 characters and
|
|||
each application secret is 128 characters. Secret values were not printed or
|
||||
written to tracked files.
|
||||
|
||||
Before the local `auth_identities` migration, a PostgreSQL custom-format dump
|
||||
was created at `output/backups/compose-20260720-203913.dump`; its SHA-256 is
|
||||
`ed8d663b114f3823479198005857af5501a90dacbec3f0855296327ee9cd6d20`.
|
||||
An isolated restore drill read 23 public tables, 1,488 rows, 12 applied
|
||||
migrations, and PostGIS 3.6.4 from that dump, then removed the temporary
|
||||
database. The source Compose database subsequently reported 13 applied
|
||||
migrations and an empty `auth_identities` table before browser verification.
|
||||
|
||||
## Dependency-upgrade observations
|
||||
|
||||
- The running Compose and kind releases reported Elixir 1.20.2 and Erlang/OTP
|
||||
|
|
@ -1031,6 +1061,10 @@ is `output/external-boundaries/preprod-gitea-pass`.
|
|||
and the availability model selected for real usage.
|
||||
- Configure a real transactional email provider before public registration.
|
||||
The current Compose mail path terminates at local Mailpit.
|
||||
- Create a Google OAuth Web client for the final owned HTTPS origin, configure
|
||||
its exact `/auth/google/callback` redirect, and exercise registration,
|
||||
sign-in, and settings linking against Google if Google authentication is to
|
||||
be enabled. The feature remains disabled when both credentials are empty.
|
||||
- Configure and verify a real mobile push provider and device-token lifecycle
|
||||
if native push is required. The provider-neutral HTTP boundary and product
|
||||
jobs are tested; FCM/APNs device delivery is not.
|
||||
|
|
|
|||
|
|
@ -315,12 +315,15 @@ export async function registerAndConfirm(
|
|||
const page = await context.newPage();
|
||||
|
||||
await gotoWithTransientRetry(page, "/users/register");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Display name").fill(displayName);
|
||||
await page
|
||||
const form = page.locator("#email_registration_form");
|
||||
await form.getByLabel("Email").fill(email);
|
||||
await form.getByLabel("Display name").fill(displayName);
|
||||
await form
|
||||
.getByLabel("I am 18 or older and accept the safety rules")
|
||||
.check();
|
||||
await page.getByRole("button", { name: "Create an account" }).click();
|
||||
await form
|
||||
.getByRole("button", { name: "Create account and email me a link" })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/users\/log-in$/);
|
||||
await expect(
|
||||
page
|
||||
|
|
@ -352,7 +355,7 @@ export async function loginWithMagicLink(
|
|||
await gotoWithTransientRetry(page, "/users/log-in");
|
||||
const form = page.locator("#login_form_magic");
|
||||
await form.getByLabel("Email").fill(email);
|
||||
await form.getByRole("button", { name: "Log in with email" }).click();
|
||||
await form.getByRole("button", { name: "Email me a sign-in link" }).click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"If your email is in our system, you will receive instructions for logging in shortly.",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ defmodule WhoNeedHelp.Accounts do
|
|||
alias WhoNeedHelp.Pagination
|
||||
alias WhoNeedHelp.Repo
|
||||
|
||||
alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier}
|
||||
alias WhoNeedHelp.Accounts.{
|
||||
AuthIdentity,
|
||||
Scope,
|
||||
SocialIdentity,
|
||||
User,
|
||||
UserNotifier,
|
||||
UserToken
|
||||
}
|
||||
|
||||
## Database getters
|
||||
|
||||
|
|
@ -21,6 +28,14 @@ defmodule WhoNeedHelp.Accounts do
|
|||
:user_id,
|
||||
:inserted_at
|
||||
]
|
||||
@registration_key_atoms %{
|
||||
"display_name" => :display_name,
|
||||
"email" => :email,
|
||||
"email_verified" => :email_verified,
|
||||
"locale" => :locale,
|
||||
"provider_uid" => :provider_uid,
|
||||
"terms_accepted" => :terms_accepted
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the deliberately small user projection used by public and
|
||||
|
|
@ -340,6 +355,260 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
end
|
||||
|
||||
## Authentication identities
|
||||
|
||||
def google_auth_connected?(%User{id: user_id}) do
|
||||
Repo.exists?(
|
||||
from identity in AuthIdentity,
|
||||
where: identity.user_id == ^user_id and identity.provider == :google
|
||||
)
|
||||
end
|
||||
|
||||
def login_user_by_google(identity_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
identity =
|
||||
AuthIdentity
|
||||
|> where(
|
||||
[identity],
|
||||
identity.provider == :google and
|
||||
identity.provider_uid == ^identity_attrs.provider_uid
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
case identity do
|
||||
nil ->
|
||||
{:error, :not_linked}
|
||||
|
||||
%AuthIdentity{} = identity ->
|
||||
with %User{} = user <-
|
||||
User
|
||||
|> where([user], user.id == ^identity.user_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one(),
|
||||
true <- user.moderation_status != :suspended,
|
||||
{:ok, _identity} <-
|
||||
identity
|
||||
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|
||||
|> Repo.update() do
|
||||
{:ok, user}
|
||||
else
|
||||
_missing_or_suspended -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def register_user_by_google(identity_attrs, registration_attrs)
|
||||
when is_map(registration_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
identity =
|
||||
AuthIdentity
|
||||
|> where(
|
||||
[identity],
|
||||
identity.provider == :google and
|
||||
identity.provider_uid == ^identity_attrs.provider_uid
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
case identity do
|
||||
%AuthIdentity{} = identity ->
|
||||
google_identity_user(identity, identity_attrs.email)
|
||||
|
||||
nil ->
|
||||
register_google_identity(identity_attrs, registration_attrs)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def register_user_by_google(_identity_attrs, _registration_attrs),
|
||||
do: {:error, :invalid_registration}
|
||||
|
||||
def link_google_identity(%User{id: user_id}, identity_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
user =
|
||||
User
|
||||
|> where([user], user.id == ^user_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
cond do
|
||||
is_nil(user) or user.moderation_status == :suspended ->
|
||||
{:error, :not_found}
|
||||
|
||||
true ->
|
||||
upsert_google_identity_for_user(user, identity_attrs)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_google_identity(%User{id: user_id}) do
|
||||
Repo.transact(fn ->
|
||||
identity =
|
||||
AuthIdentity
|
||||
|> where(
|
||||
[identity],
|
||||
identity.provider == :google and identity.user_id == ^user_id
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
case identity do
|
||||
%AuthIdentity{} = identity -> Repo.delete(identity)
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp register_google_identity(identity_attrs, registration_attrs) do
|
||||
existing_user =
|
||||
User
|
||||
|> where([user], user.email == ^identity_attrs.email)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
if existing_user do
|
||||
{:error, :email_already_registered}
|
||||
else
|
||||
attrs = %{
|
||||
"email" => identity_attrs.email,
|
||||
"display_name" => identity_attrs.display_name,
|
||||
"locale" => registration_value(registration_attrs, "locale", "en"),
|
||||
"terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
|
||||
}
|
||||
|
||||
with {:ok, user} <- register_user(attrs),
|
||||
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
|
||||
{:ok, identity} <-
|
||||
%AuthIdentity{}
|
||||
|> AuthIdentity.changeset(%{
|
||||
provider: :google,
|
||||
provider_uid: identity_attrs.provider_uid,
|
||||
email: identity_attrs.email,
|
||||
user_id: user.id
|
||||
})
|
||||
|> Repo.insert() do
|
||||
{:ok, {user, identity}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp google_identity_user(identity, current_email) do
|
||||
user =
|
||||
User
|
||||
|> where([user], user.id == ^identity.user_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
with %User{} = user <- user,
|
||||
true <- user.moderation_status != :suspended,
|
||||
{:ok, identity} <-
|
||||
identity
|
||||
|> AuthIdentity.changeset(%{email: current_email})
|
||||
|> Repo.update() do
|
||||
{:ok, {user, identity}}
|
||||
else
|
||||
_missing_or_suspended -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp upsert_google_identity_for_user(user, identity_attrs) do
|
||||
provider_identity =
|
||||
AuthIdentity
|
||||
|> where(
|
||||
[identity],
|
||||
identity.provider == :google and
|
||||
identity.provider_uid == ^identity_attrs.provider_uid
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
user_identity =
|
||||
AuthIdentity
|
||||
|> where(
|
||||
[identity],
|
||||
identity.provider == :google and identity.user_id == ^user.id
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
cond do
|
||||
provider_identity && provider_identity.user_id != user.id ->
|
||||
{:error, :already_linked}
|
||||
|
||||
user_identity && user_identity.provider_uid != identity_attrs.provider_uid ->
|
||||
{:error, :provider_already_linked}
|
||||
|
||||
identity = provider_identity || user_identity ->
|
||||
identity
|
||||
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|
||||
|> Repo.update()
|
||||
|
||||
true ->
|
||||
%AuthIdentity{}
|
||||
|> AuthIdentity.changeset(%{
|
||||
provider: :google,
|
||||
provider_uid: identity_attrs.provider_uid,
|
||||
email: identity_attrs.email,
|
||||
user_id: user.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
|
||||
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
|
||||
email = registration_value(identity_attrs, "email", nil)
|
||||
email_verified = registration_value(identity_attrs, "email_verified", false)
|
||||
display_name = registration_value(identity_attrs, "display_name", nil)
|
||||
|
||||
cond do
|
||||
email_verified != true ->
|
||||
{:error, :email_not_verified}
|
||||
|
||||
not is_binary(provider_uid) or provider_uid == "" or byte_size(provider_uid) > 255 ->
|
||||
{:error, :invalid_provider_identity}
|
||||
|
||||
not is_binary(email) or email == "" or byte_size(email) > 160 ->
|
||||
{:error, :invalid_provider_identity}
|
||||
|
||||
not is_binary(display_name) or String.trim(display_name) == "" ->
|
||||
{:error, :invalid_provider_identity}
|
||||
|
||||
true ->
|
||||
{:ok,
|
||||
%{
|
||||
provider_uid: provider_uid,
|
||||
email: email |> String.trim() |> String.downcase(),
|
||||
email_verified: true,
|
||||
display_name: display_name |> String.trim() |> String.slice(0, 80)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_google_identity(_identity_attrs),
|
||||
do: {:error, :invalid_provider_identity}
|
||||
|
||||
defp registration_value(attrs, key, default) do
|
||||
case Map.fetch(attrs, key) do
|
||||
{:ok, value} ->
|
||||
value
|
||||
|
||||
:error ->
|
||||
case Map.fetch(@registration_key_atoms, key) do
|
||||
{:ok, atom_key} -> Map.get(attrs, atom_key, default)
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
36
lib/who_need_help/accounts/auth_identity.ex
Normal file
36
lib/who_need_help/accounts/auth_identity.ex
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
defmodule WhoNeedHelp.Accounts.AuthIdentity do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "auth_identities" do
|
||||
field :provider, Ecto.Enum, values: [:google]
|
||||
field :provider_uid, :string
|
||||
field :email, :string
|
||||
belongs_to :user, WhoNeedHelp.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(identity, attrs) do
|
||||
identity
|
||||
|> cast(attrs, [:provider, :provider_uid, :email, :user_id])
|
||||
|> update_change(:email, &normalize_email/1)
|
||||
|> validate_required([:provider, :provider_uid, :email, :user_id])
|
||||
|> validate_inclusion(:provider, [:google])
|
||||
|> validate_length(:provider_uid, min: 1, max: 255)
|
||||
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
|
||||
message: "must have the @ sign and no spaces"
|
||||
)
|
||||
|> validate_length(:email, max: 160)
|
||||
|> unique_constraint([:provider, :provider_uid])
|
||||
|> unique_constraint([:user_id, :provider])
|
||||
end
|
||||
|
||||
defp normalize_email(email) when is_binary(email),
|
||||
do: email |> String.trim() |> String.downcase()
|
||||
|
||||
defp normalize_email(email), do: email
|
||||
end
|
||||
|
|
@ -17,9 +17,11 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
|
|||
alias WhoNeedHelp.Push.DisabledAdapter, as: PushDisabledAdapter
|
||||
alias WhoNeedHelp.Push.HTTPAdapter, as: PushHTTPAdapter
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.GoogleAuth.AssentAdapter, as: GoogleAssentAdapter
|
||||
alias WhoNeedHelp.SocialOAuth.AssentAdapter
|
||||
|
||||
@oauth_redirect_uri "http://boundary.local/auth/social/github/callback"
|
||||
@google_redirect_uri "http://boundary.local/auth/google/callback"
|
||||
@output_directory "/output"
|
||||
@output_path "/output/summary.json"
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
|
|||
summary = %{
|
||||
status: "passed",
|
||||
oauth: oauth_drill(base_url),
|
||||
google_oidc: google_oidc_drill(base_url),
|
||||
smtp: smtp_drill(base_url),
|
||||
push: Map.put(protocol_push, :domain_workflow_integration, product_push.status),
|
||||
push_product_integration: product_push
|
||||
|
|
@ -49,6 +52,118 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp google_oidc_drill(base_url) do
|
||||
assert!(GoogleAssentAdapter.enabled?(), "runtime Google OIDC config is disabled")
|
||||
|
||||
control!(base_url, "google", "success")
|
||||
{success_session, success_params} = google_authorization!()
|
||||
|
||||
{:ok, identity} =
|
||||
GoogleAssentAdapter.callback(
|
||||
@google_redirect_uri,
|
||||
success_params,
|
||||
success_session
|
||||
)
|
||||
|
||||
assert!(
|
||||
identity.provider_uid == "google-local-subject",
|
||||
"Google subject was not normalized"
|
||||
)
|
||||
|
||||
assert!(
|
||||
identity.email == "google-helper@example.invalid",
|
||||
"Google email was not normalized"
|
||||
)
|
||||
|
||||
assert!(
|
||||
not Map.has_key?(identity, :access_token),
|
||||
"Google access token escaped adapter boundary"
|
||||
)
|
||||
|
||||
success_state = state!(base_url)["google"]
|
||||
assert!(success_state["token_requests"] == 1, "Google token request was not observed")
|
||||
assert!(success_state["jwks_requests"] == 1, "Google JWKS request was not observed")
|
||||
assert!(success_state["consumed_codes"] == 1, "Google code was not consumed")
|
||||
|
||||
assert_error!(
|
||||
GoogleAssentAdapter.callback(
|
||||
@google_redirect_uri,
|
||||
success_params,
|
||||
success_session
|
||||
),
|
||||
"Google authorization code replay unexpectedly succeeded"
|
||||
)
|
||||
|
||||
replay_state = state!(base_url)["google"]
|
||||
assert!(replay_state["token_requests"] == 2, "Google replay did not reach token endpoint")
|
||||
assert!(replay_state["jwks_requests"] == 1, "Google replay reached the JWKS endpoint")
|
||||
|
||||
control!(base_url, "google", "success")
|
||||
{mismatch_session, mismatch_params} = google_authorization!()
|
||||
|
||||
assert_error!(
|
||||
GoogleAssentAdapter.callback(
|
||||
@google_redirect_uri,
|
||||
Map.put(mismatch_params, "state", "mismatched-state"),
|
||||
mismatch_session
|
||||
),
|
||||
"Google state mismatch unexpectedly succeeded"
|
||||
)
|
||||
|
||||
mismatch_state = state!(base_url)["google"]
|
||||
assert!(mismatch_state["token_requests"] == 0, "Google state mismatch reached token endpoint")
|
||||
|
||||
control!(base_url, "google", "nonce_mismatch")
|
||||
{nonce_session, nonce_params} = google_authorization!()
|
||||
|
||||
assert_error!(
|
||||
GoogleAssentAdapter.callback(
|
||||
@google_redirect_uri,
|
||||
nonce_params,
|
||||
nonce_session
|
||||
),
|
||||
"Google nonce mismatch unexpectedly succeeded"
|
||||
)
|
||||
|
||||
nonce_state = state!(base_url)["google"]
|
||||
assert!(nonce_state["token_requests"] == 1, "Google nonce test skipped token exchange")
|
||||
assert!(nonce_state["jwks_requests"] == 1, "Google nonce test skipped signature verification")
|
||||
|
||||
control!(base_url, "google", "unverified_email")
|
||||
{email_session, email_params} = google_authorization!()
|
||||
|
||||
assert!(
|
||||
GoogleAssentAdapter.callback(
|
||||
@google_redirect_uri,
|
||||
email_params,
|
||||
email_session
|
||||
) == {:error, :email_not_verified},
|
||||
"Google unverified email was accepted"
|
||||
)
|
||||
|
||||
%{
|
||||
success: "passed",
|
||||
state_mismatch_blocked_before_token: true,
|
||||
one_time_code_replay_rejected: true,
|
||||
nonce_mismatch_rejected_after_signature_verification: true,
|
||||
unverified_email_rejected: true,
|
||||
access_token_returned_to_application: false
|
||||
}
|
||||
end
|
||||
|
||||
defp google_authorization! do
|
||||
{:ok, %{url: url, session_params: session_params}} =
|
||||
GoogleAssentAdapter.authorize_url(@google_redirect_uri)
|
||||
|
||||
response = Req.get!(url, redirect: false, retry: false)
|
||||
assert!(response.status == 302, "Google OIDC mock did not redirect")
|
||||
|
||||
[location] = Req.Response.get_header(response, "location")
|
||||
params = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
|
||||
|
||||
{session_params, params}
|
||||
end
|
||||
|
||||
defp oauth_drill(base_url) do
|
||||
assert!(AssentAdapter.enabled?(:github), "runtime GitHub OAuth config is disabled")
|
||||
|
||||
|
|
|
|||
35
lib/who_need_help/google_auth.ex
Normal file
35
lib/who_need_help/google_auth.ex
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
defmodule WhoNeedHelp.GoogleAuth do
|
||||
@moduledoc """
|
||||
Provider boundary for Google OpenID Connect authentication.
|
||||
|
||||
Provider access and ID tokens are consumed inside the adapter and are never
|
||||
returned to controllers or persisted by the application.
|
||||
"""
|
||||
|
||||
@type identity :: %{
|
||||
provider_uid: String.t(),
|
||||
email: String.t(),
|
||||
email_verified: true,
|
||||
display_name: String.t()
|
||||
}
|
||||
|
||||
@callback enabled?() :: boolean()
|
||||
@callback authorize_url(String.t()) ::
|
||||
{:ok, %{url: String.t(), session_params: map()}} | {:error, term()}
|
||||
@callback callback(String.t(), map(), map()) :: {:ok, identity()} | {:error, term()}
|
||||
|
||||
def enabled?, do: adapter().enabled?()
|
||||
|
||||
def authorize_url(redirect_uri), do: adapter().authorize_url(redirect_uri)
|
||||
|
||||
def callback(redirect_uri, params, session_params),
|
||||
do: adapter().callback(redirect_uri, params, session_params)
|
||||
|
||||
defp adapter do
|
||||
Application.get_env(
|
||||
:who_need_help,
|
||||
:google_auth_adapter,
|
||||
WhoNeedHelp.GoogleAuth.AssentAdapter
|
||||
)
|
||||
end
|
||||
end
|
||||
91
lib/who_need_help/google_auth/assent_adapter.ex
Normal file
91
lib/who_need_help/google_auth/assent_adapter.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
||||
@moduledoc false
|
||||
|
||||
@behaviour WhoNeedHelp.GoogleAuth
|
||||
|
||||
alias Assent.Strategy.Google
|
||||
|
||||
@impl true
|
||||
def enabled?, do: not is_nil(provider_config())
|
||||
|
||||
@impl true
|
||||
def authorize_url(redirect_uri) do
|
||||
with {:ok, config} <- config(redirect_uri) do
|
||||
config
|
||||
|> Keyword.put(:nonce, random_url_token(32))
|
||||
|> Keyword.put(:code_verifier, true)
|
||||
|> Google.authorize_url()
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def callback(redirect_uri, params, session_params) do
|
||||
with {:ok, config} <- config(redirect_uri),
|
||||
{:ok, %{user: user}} <-
|
||||
config
|
||||
|> Keyword.put(:code_verifier, true)
|
||||
|> Keyword.put(:session_params, session_params)
|
||||
|> Google.callback(params) do
|
||||
normalize_identity(user)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_identity(
|
||||
%{
|
||||
"sub" => provider_uid,
|
||||
"email" => email,
|
||||
"email_verified" => true
|
||||
} = claims
|
||||
)
|
||||
when is_binary(provider_uid) and provider_uid != "" and is_binary(email) and email != "" do
|
||||
normalized_email = email |> String.trim() |> String.downcase()
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
provider_uid: provider_uid,
|
||||
email: normalized_email,
|
||||
email_verified: true,
|
||||
display_name: display_name(normalized_email, Map.get(claims, "name"))
|
||||
}}
|
||||
end
|
||||
|
||||
def normalize_identity(%{"email_verified" => false}), do: {:error, :email_not_verified}
|
||||
def normalize_identity(_claims), do: {:error, :invalid_provider_identity}
|
||||
|
||||
defp config(redirect_uri) do
|
||||
case provider_config() do
|
||||
nil -> {:error, :provider_disabled}
|
||||
config -> {:ok, Keyword.put(config, :redirect_uri, redirect_uri)}
|
||||
end
|
||||
end
|
||||
|
||||
defp provider_config do
|
||||
case Application.get_env(:who_need_help, :google_auth, []) do
|
||||
config when is_list(config) and config != [] -> config
|
||||
_disabled -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp display_name(email, name) when is_binary(name) do
|
||||
case String.trim(name) do
|
||||
"" -> fallback_display_name(email)
|
||||
value -> String.slice(value, 0, 80)
|
||||
end
|
||||
end
|
||||
|
||||
defp display_name(email, _name), do: fallback_display_name(email)
|
||||
|
||||
defp fallback_display_name(email) do
|
||||
candidate = email |> String.split("@", parts: 2) |> hd() |> String.trim()
|
||||
|
||||
if String.length(candidate) >= 2,
|
||||
do: String.slice(candidate, 0, 80),
|
||||
else: "Google user"
|
||||
end
|
||||
|
||||
defp random_url_token(length) do
|
||||
length
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
end
|
||||
|
|
@ -123,6 +123,59 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the Google authentication action with the standard multicolor G.
|
||||
|
||||
The icon geometry and light button treatment follow Google's published
|
||||
Sign in with Google branding guidance. The form itself owns the action and
|
||||
CSRF token so the button remains a normal accessible submit control.
|
||||
"""
|
||||
attr :label, :string, required: true
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :rest, :global, include: ~w(id name value form)
|
||||
|
||||
def google_auth_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="submit"
|
||||
disabled={@disabled}
|
||||
class={[
|
||||
"flex h-11 w-full items-center justify-center rounded-full border border-[#747775]",
|
||||
"bg-white px-3 text-sm font-semibold text-[#1f1f1f] transition hover:bg-[#f8faff]",
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]",
|
||||
@disabled && "cursor-not-allowed opacity-50"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
class="mr-2.5 shrink-0"
|
||||
>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M21.6 12.23c0-.71-.06-1.4-.18-2.05H12v3.87h5.38a4.6 4.6 0 0 1-2 3.02v2.51h3.24c1.9-1.75 2.98-4.33 2.98-7.35Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 22c2.7 0 4.97-.9 6.62-2.42l-3.24-2.51c-.9.6-2.05.96-3.38.96-2.6 0-4.81-1.76-5.6-4.12H3.05v2.59A10 10 0 0 0 12 22Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M6.4 13.91A6.01 6.01 0 0 1 6.08 12c0-.66.11-1.3.32-1.91V7.5H3.05A10 10 0 0 0 2 12c0 1.61.39 3.14 1.05 4.5l3.35-2.59Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.97c1.47 0 2.79.5 3.82 1.5l2.87-2.87A9.62 9.62 0 0 0 12 2a10 10 0 0 0-8.95 5.5l3.35 2.59c.79-2.36 3-4.12 5.6-4.12Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{@label}</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
|
|
|
|||
278
lib/who_need_help_web/controllers/google_auth_controller.ex
Normal file
278
lib/who_need_help_web/controllers/google_auth_controller.ex
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||
use WhoNeedHelpWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.{Accounts, GoogleAuth, Repo, Trust}
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
|
||||
|
||||
@session_key "google_auth_flow"
|
||||
@supported_locales ~w(en uk ru)
|
||||
|
||||
plug :put_no_store
|
||||
plug :require_sudo_mode when action in [:start_link, :disconnect]
|
||||
|
||||
def start_login(conn, _params) do
|
||||
start_flow(conn, "login", %{}, ~p"/users/log-in")
|
||||
end
|
||||
|
||||
def start_registration(conn, %{"google_registration" => params}) when is_map(params) do
|
||||
locale = normalize_locale(params["locale"])
|
||||
terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"]
|
||||
|
||||
if terms_accepted do
|
||||
start_flow(
|
||||
conn,
|
||||
"register",
|
||||
%{"locale" => locale, "terms_accepted" => true},
|
||||
~p"/users/register"
|
||||
)
|
||||
else
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Confirm that you are 18 or older and accept the safety rules first.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/register")
|
||||
end
|
||||
end
|
||||
|
||||
def start_registration(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:error, gettext("The Google registration form is invalid."))
|
||||
|> redirect(to: ~p"/users/register")
|
||||
end
|
||||
|
||||
def start_link(conn, _params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
start_flow(conn, "link", %{"user_id" => user.id}, ~p"/users/settings")
|
||||
end
|
||||
|
||||
def disconnect(conn, _params) do
|
||||
current_user = conn.assigns.current_scope.user
|
||||
|
||||
case unlink_google_identity_and_audit(current_user) do
|
||||
{:ok, _identity} ->
|
||||
conn
|
||||
|> put_flash(:info, gettext("Google sign-in disconnected."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("No Google account is connected."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, params) do
|
||||
flow = get_session(conn, @session_key)
|
||||
conn = delete_session(conn, @session_key)
|
||||
|
||||
with %{"flow" => flow_name, "session_params" => session_params} <- flow,
|
||||
true <- flow_name in ["login", "register", "link"],
|
||||
{:ok, identity_attrs} <-
|
||||
GoogleAuth.callback(callback_url(conn), params, session_params) do
|
||||
finish_flow(conn, flow_name, flow, identity_attrs)
|
||||
else
|
||||
nil ->
|
||||
callback_error(
|
||||
conn,
|
||||
flow,
|
||||
gettext("Google sign-in session expired. Please start again.")
|
||||
)
|
||||
|
||||
false ->
|
||||
callback_error(conn, flow, gettext("Google sign-in session is invalid."))
|
||||
|
||||
{:error, :email_not_verified} ->
|
||||
callback_error(
|
||||
conn,
|
||||
flow,
|
||||
gettext("Google did not provide a verified email address.")
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Google OAuth callback failed (#{error_name(error)})")
|
||||
callback_error(conn, flow, gettext("Google sign-in failed. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
defp start_flow(conn, flow, extra, failure_path) do
|
||||
if GoogleAuth.enabled?() do
|
||||
case GoogleAuth.authorize_url(callback_url(conn)) do
|
||||
{:ok, %{url: url, session_params: session_params}} ->
|
||||
session =
|
||||
extra
|
||||
|> Map.put("flow", flow)
|
||||
|> Map.put("session_params", session_params)
|
||||
|
||||
conn
|
||||
|> put_session(@session_key, session)
|
||||
|> redirect(external: url)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Unable to start Google OAuth (#{error_name(error)})")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, gettext("Google sign-in could not be started."))
|
||||
|> redirect(to: failure_path)
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, gettext("Google sign-in is not configured yet."))
|
||||
|> redirect(to: failure_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp finish_flow(conn, "login", _flow, identity_attrs) do
|
||||
case Accounts.login_user_by_google(identity_attrs) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, gettext("Welcome back!"))
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, :not_linked} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext(
|
||||
"This Google account is not connected yet. Create an account or sign in by email and connect Google in account settings."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp finish_flow(conn, "register", flow, identity_attrs) do
|
||||
with {:ok, _limit} <- RateLimiter.check(:registration_email, identity_attrs.email),
|
||||
{:ok, {user, _identity}} <-
|
||||
Accounts.register_user_by_google(identity_attrs, %{
|
||||
"locale" => flow["locale"],
|
||||
"terms_accepted" => flow["terms_accepted"]
|
||||
}) do
|
||||
conn
|
||||
|> put_flash(:info, gettext("Your account was created with Google."))
|
||||
|> UserAuth.log_in_user(user)
|
||||
else
|
||||
{:error, :email_already_registered} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext(
|
||||
"An account with this email already exists. Sign in by email or password, then connect Google in account settings."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Too many registration attempts in the configured time window.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/register")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/register")
|
||||
end
|
||||
end
|
||||
|
||||
defp finish_flow(conn, "link", flow, identity_attrs) do
|
||||
current_user = get_in(conn.assigns, [:current_scope, Access.key(:user)])
|
||||
|
||||
if current_user && flow["user_id"] == current_user.id &&
|
||||
Accounts.sudo_mode?(current_user, -10) do
|
||||
case link_google_identity_and_audit(current_user, identity_attrs) do
|
||||
{:ok, _identity} ->
|
||||
conn
|
||||
|> put_flash(:info, gettext("Google sign-in connected."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, :already_linked} ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("That Google account belongs to another local account."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, :provider_already_linked} ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("A different Google account is already connected."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/settings")
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, gettext("Google account connection session is invalid."))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp link_google_identity_and_audit(current_user, identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, identity} <- Accounts.link_google_identity(current_user, identity_attrs),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
current_user.id,
|
||||
"auth_identity.connected",
|
||||
"auth_identity",
|
||||
identity.id,
|
||||
%{"provider" => "google"}
|
||||
) do
|
||||
{:ok, identity}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp unlink_google_identity_and_audit(current_user) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, identity} <- Accounts.unlink_google_identity(current_user),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
current_user.id,
|
||||
"auth_identity.disconnected",
|
||||
"auth_identity",
|
||||
identity.id,
|
||||
%{"provider" => "google"}
|
||||
) do
|
||||
{:ok, identity}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp callback_error(conn, flow, message) do
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: failure_path(flow))
|
||||
end
|
||||
|
||||
defp google_account_unavailable(conn, path) do
|
||||
conn
|
||||
|> put_flash(:error, gettext("This Google account cannot be used right now."))
|
||||
|> redirect(to: path)
|
||||
end
|
||||
|
||||
defp failure_path(%{"flow" => "register"}), do: ~p"/users/register"
|
||||
defp failure_path(%{"flow" => "link"}), do: ~p"/users/settings"
|
||||
defp failure_path(_flow), do: ~p"/users/log-in"
|
||||
|
||||
defp callback_url(conn), do: url(conn, ~p"/auth/google/callback")
|
||||
|
||||
defp normalize_locale(locale) when locale in @supported_locales, do: locale
|
||||
defp normalize_locale(_locale), do: "en"
|
||||
|
||||
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
|
||||
defp error_name(%{__struct__: module}), do: inspect(module)
|
||||
defp error_name(_error), do: "unknown_error"
|
||||
|
||||
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
|
||||
end
|
||||
|
|
@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.{Accounts, GoogleAuth}
|
||||
alias WhoNeedHelp.Accounts.User
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
|
||||
def new(conn, _params) do
|
||||
changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|
||||
render(conn, :new, changeset: changeset)
|
||||
render_registration(conn, changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) when is_map(user_params) do
|
||||
|
|
@ -37,7 +37,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
|
||||
registration_response(conn)
|
||||
else
|
||||
render(conn, :new, changeset: changeset)
|
||||
render_registration(conn, changeset)
|
||||
end
|
||||
end
|
||||
else
|
||||
|
|
@ -48,9 +48,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
:error,
|
||||
gettext("Too many registration attempts in the configured time window.")
|
||||
)
|
||||
|> render(:new,
|
||||
changeset: Accounts.change_user_registration(%User{}, user_params)
|
||||
)
|
||||
|> render_registration(Accounts.change_user_registration(%User{}, user_params))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -58,8 +56,8 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> put_flash(:error, gettext("The registration form is invalid."))
|
||||
|> render(:new,
|
||||
changeset: Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|
||||
|> render_registration(
|
||||
Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -124,5 +122,12 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
end)
|
||||
end
|
||||
|
||||
defp render_registration(conn, changeset) do
|
||||
render(conn, :new,
|
||||
changeset: changeset,
|
||||
google_auth_enabled: GoogleAuth.enabled?()
|
||||
)
|
||||
end
|
||||
|
||||
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="mx-auto max-w-sm space-y-5">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
{gettext("Register for an account")}
|
||||
|
|
@ -13,7 +13,49 @@
|
|||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@changeset} action={~p"/users/register"}>
|
||||
<p class="rounded-2xl bg-base-200 p-4 text-sm text-base-content/70">
|
||||
{gettext(
|
||||
"A password is not required. We will email a one-time confirmation link. You can add a password later in account settings."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<.form
|
||||
for={%{}}
|
||||
as={:google_registration}
|
||||
action={~p"/auth/google/register"}
|
||||
id="google_registration_form"
|
||||
class="space-y-3"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="google_registration[locale]"
|
||||
value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
|
||||
/>
|
||||
<.input
|
||||
type="checkbox"
|
||||
id="google_registration_terms"
|
||||
name="google_registration[terms_accepted]"
|
||||
value="false"
|
||||
label={gettext("I am 18 or older and accept the safety rules")}
|
||||
required
|
||||
/>
|
||||
<.google_auth_button
|
||||
label={gettext("Sign up with Google")}
|
||||
disabled={!@google_auth_enabled}
|
||||
/>
|
||||
<p :if={!@google_auth_enabled} class="text-center text-xs text-base-content/55">
|
||||
{gettext("Google sign-in will be available after the operator configures it.")}
|
||||
</p>
|
||||
</.form>
|
||||
|
||||
<div class="divider">{gettext("or sign up with email")}</div>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={~p"/users/register"}
|
||||
id="email_registration_form"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f[:locale].name}
|
||||
|
|
@ -55,8 +97,8 @@
|
|||
)}
|
||||
</p>
|
||||
|
||||
<.button phx-disable-with={gettext("Creating account...")} class="btn btn-primary w-full">
|
||||
{gettext("Create an account")}
|
||||
<.button phx-disable-with={gettext("Sending link...")} class="btn btn-primary w-full">
|
||||
{gettext("Create account and email me a link")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.{Accounts, GoogleAuth}
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
|
|
@ -126,11 +126,12 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
|
||||
defp assign_magic_link_form(conn, _opts) do
|
||||
assign(
|
||||
conn,
|
||||
conn
|
||||
|> assign(
|
||||
:magic_link_form,
|
||||
Phoenix.Component.to_form(%{"token" => ""}, as: "user")
|
||||
)
|
||||
|> assign(:google_auth_enabled, GoogleAuth.enabled?())
|
||||
end
|
||||
|
||||
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
|
||||
|
|
|
|||
|
|
@ -63,6 +63,24 @@
|
|||
</.form>
|
||||
|
||||
<div id="standard-login-options">
|
||||
<.form
|
||||
:if={!@current_scope}
|
||||
for={%{}}
|
||||
as={:google_login}
|
||||
action={~p"/auth/google/login"}
|
||||
id="google_login_form"
|
||||
>
|
||||
<.google_auth_button
|
||||
label={gettext("Sign in with Google")}
|
||||
disabled={!@google_auth_enabled}
|
||||
/>
|
||||
<p :if={!@google_auth_enabled} class="mt-2 text-center text-xs text-base-content/55">
|
||||
{gettext("Google sign-in will be available after the operator configures it.")}
|
||||
</p>
|
||||
</.form>
|
||||
|
||||
<div :if={!@current_scope} class="divider">{gettext("or use email")}</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
|
|
@ -74,14 +92,29 @@
|
|||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<p class="mb-3 text-xs text-base-content/60">
|
||||
{gettext("We will send a one-time sign-in link. No password is required.")}
|
||||
</p>
|
||||
<.button class="btn btn-primary w-full">
|
||||
{gettext("Log in with email")} <span aria-hidden="true">→</span>
|
||||
{gettext("Email me a sign-in link")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider">{gettext("or")}</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||
<details class="collapse collapse-arrow mt-4 rounded-2xl border border-base-300">
|
||||
<summary class="collapse-title font-semibold">
|
||||
{gettext("Use a password instead")}
|
||||
</summary>
|
||||
<div class="collapse-content">
|
||||
<p class="mb-3 text-xs text-base-content/60">
|
||||
{gettext("This works only if you previously added a password in account settings.")}
|
||||
</p>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
as={:user}
|
||||
id="login_form_password"
|
||||
action={~p"/users/log-in"}
|
||||
>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
|
|
@ -97,6 +130,7 @@
|
|||
label={gettext("Password")}
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
|
||||
|
|
@ -106,5 +140,7 @@
|
|||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.{Accounts, GoogleAuth}
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
|
|
@ -103,6 +103,8 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
conn
|
||||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
|> assign(:google_auth_enabled, GoogleAuth.enabled?())
|
||||
|> assign(:google_auth_connected, Accounts.google_auth_connected?(user))
|
||||
end
|
||||
|
||||
defp deliver_email_change_instructions(conn, user, changeset) do
|
||||
|
|
|
|||
|
|
@ -25,6 +25,55 @@
|
|||
|
||||
<div class="divider" />
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-5">
|
||||
<h2 class="text-lg font-bold">{gettext("Google sign-in")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
<%= if @google_auth_connected do %>
|
||||
{gettext("A Google account is connected. You can use it to sign in.")}
|
||||
<% else %>
|
||||
{gettext(
|
||||
"Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
|
||||
)}
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<.form
|
||||
:if={!@google_auth_connected}
|
||||
for={%{}}
|
||||
as={:google_link}
|
||||
action={~p"/auth/google/link"}
|
||||
id="google_link_form"
|
||||
class="mt-4"
|
||||
>
|
||||
<.google_auth_button
|
||||
label={gettext("Connect Google account")}
|
||||
disabled={!@google_auth_enabled}
|
||||
/>
|
||||
<p :if={!@google_auth_enabled} class="mt-2 text-center text-xs text-base-content/55">
|
||||
{gettext("Google sign-in will be available after the operator configures it.")}
|
||||
</p>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@google_auth_connected}
|
||||
for={%{}}
|
||||
action={~p"/auth/google/link"}
|
||||
method="delete"
|
||||
id="google_unlink_form"
|
||||
class="mt-4"
|
||||
>
|
||||
<.button
|
||||
variant="primary"
|
||||
class="btn-soft"
|
||||
data-confirm={gettext("Disconnect Google sign-in from this account?")}
|
||||
>
|
||||
{gettext("Disconnect Google")}
|
||||
</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
|
||||
<input type="hidden" name="action" value="update_password" />
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
|
||||
get "/users/register", UserRegistrationController, :new
|
||||
post "/users/register", UserRegistrationController, :create
|
||||
post "/auth/google/login", GoogleAuthController, :start_login
|
||||
post "/auth/google/register", GoogleAuthController, :start_registration
|
||||
end
|
||||
|
||||
scope "/", WhoNeedHelpWeb do
|
||||
|
|
@ -112,6 +114,8 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
|
||||
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
|
||||
post "/auth/google/link", GoogleAuthController, :start_link
|
||||
delete "/auth/google/link", GoogleAuthController, :disconnect
|
||||
get "/auth/social/:provider", SocialOAuthController, :request
|
||||
get "/auth/social/:provider/callback", SocialOAuthController, :callback
|
||||
end
|
||||
|
|
@ -143,6 +147,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
|
||||
get "/users/log-in", UserSessionController, :new
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
get "/auth/google/callback", GoogleAuthController, :callback
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ class BoundaryState:
|
|||
self.oauth_code_counter = 0
|
||||
self.oauth_codes: dict[str, dict[str, Any]] = {}
|
||||
self.oauth_tokens: set[str] = set()
|
||||
self.google_mode = "success"
|
||||
self.google_discovery_requests = 0
|
||||
self.google_authorize_requests = 0
|
||||
self.google_token_requests = 0
|
||||
self.google_jwks_requests = 0
|
||||
self.google_code_counter = 0
|
||||
self.google_codes: dict[str, dict[str, Any]] = {}
|
||||
self.push_mode = "success"
|
||||
self.push_attempts = 0
|
||||
self.push_receipts: dict[str, str] = {}
|
||||
|
|
@ -56,6 +63,13 @@ class BoundaryState:
|
|||
self.oauth_user_requests = 0
|
||||
self.oauth_codes = {}
|
||||
self.oauth_tokens = set()
|
||||
elif component == "google":
|
||||
self.google_mode = mode
|
||||
self.google_discovery_requests = 0
|
||||
self.google_authorize_requests = 0
|
||||
self.google_token_requests = 0
|
||||
self.google_jwks_requests = 0
|
||||
self.google_codes = {}
|
||||
elif component == "push":
|
||||
self.push_mode = mode
|
||||
self.push_attempts = 0
|
||||
|
|
@ -87,6 +101,17 @@ class BoundaryState:
|
|||
1 for code in self.oauth_codes.values() if code["used"]
|
||||
),
|
||||
},
|
||||
"google": {
|
||||
"mode": self.google_mode,
|
||||
"discovery_requests": self.google_discovery_requests,
|
||||
"authorize_requests": self.google_authorize_requests,
|
||||
"token_requests": self.google_token_requests,
|
||||
"jwks_requests": self.google_jwks_requests,
|
||||
"issued_codes": len(self.google_codes),
|
||||
"consumed_codes": sum(
|
||||
1 for code in self.google_codes.values() if code["used"]
|
||||
),
|
||||
},
|
||||
"push": {
|
||||
"mode": self.push_mode,
|
||||
"attempts": self.push_attempts,
|
||||
|
|
@ -107,11 +132,74 @@ STATE = BoundaryState()
|
|||
OAUTH_CLIENT_ID = os.environ["MOCK_OAUTH_CLIENT_ID"]
|
||||
OAUTH_CLIENT_SECRET = os.environ["MOCK_OAUTH_CLIENT_SECRET"]
|
||||
PUSH_BEARER_TOKEN = os.environ["MOCK_PUSH_BEARER_TOKEN"]
|
||||
GOOGLE_ISSUER = "http://external-mock:8080"
|
||||
GOOGLE_KEY_ID = "local-google-rs256"
|
||||
GOOGLE_RSA_MODULUS = int(
|
||||
"a42d39e6e3244bdb67afce39612a6c54c27c88d3b730eaabdb70615c59d005e7"
|
||||
"cdb22585d196bd337b4c9d80feb5bdc04046ccdba2c523bfbb567cb8165bfa33"
|
||||
"f9df6f88828a7acbe6586a003d105b709bdfdd6bbfee36167582f9daa410d792"
|
||||
"bcc1c7d3f9bfc8964d8c58250a35540dc8424cf661ec76f76326162d518be5fa"
|
||||
"f345922951f0f1f805c34a1a83c2c4f3805677e0faf37f3850fcbbaf4f6e90db"
|
||||
"b0f90f5b55548f56a43e69a2f805b257dec650d8ad7417188e379df5050214"
|
||||
"fc52f05886b8b5407775793afd6145fae83b1e9728c272e71b80d87ed5d0bec0"
|
||||
"63c3703996c8dae1e672803855d59f561fdbb5ee64464d7d09146f530fc03259c5",
|
||||
16,
|
||||
)
|
||||
GOOGLE_RSA_PRIVATE_EXPONENT = int(
|
||||
"14b1d2bba4e41d5fc1b92a70972be6cde45a18513fa53ddf7de0b39515892045"
|
||||
"70eb44c9927ac2ccab7d23d96fc1eef23de7eec8bcc2c6d7d3407aa625c3604d"
|
||||
"8ef0b83967e316c97ef6a41df5948b422d93d17054982d5f355ed629d6467d35"
|
||||
"f4ef2446371412afc784aa53b8eeb1f2aecc94b0f5f4fda5ff6c7c9d27cb4fa8"
|
||||
"d78f0902df05a4c068e180b1a345ee46607dc4cdf920ee31405a7931dcb61b40"
|
||||
"65e1e03963df352553a485a1e31cc072adc46eda1a4dcce71bbe3947c1bc5d7f"
|
||||
"a8134d2e58d93792b0776958abf3bc7376846c1cac4c0b634371c80476d847b4"
|
||||
"11cd607add231e66341d3def88005d84707ef7715fe288bd881ea5d8430eb001",
|
||||
16,
|
||||
)
|
||||
GOOGLE_RSA_EXPONENT = 65537
|
||||
|
||||
|
||||
def json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True).encode()
|
||||
|
||||
def base64url(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def unsigned_bytes(value: int) -> bytes:
|
||||
return value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
|
||||
|
||||
def google_id_token(code: dict[str, Any], mode: str) -> str:
|
||||
now = int(time.time())
|
||||
header = {"alg": "RS256", "kid": GOOGLE_KEY_ID, "typ": "JWT"}
|
||||
claims = {
|
||||
"iss": GOOGLE_ISSUER,
|
||||
"sub": "google-local-subject",
|
||||
"aud": OAUTH_CLIENT_ID,
|
||||
"exp": now + 300,
|
||||
"iat": now,
|
||||
"nonce": (
|
||||
"invalid-nonce" if mode == "nonce_mismatch" else code["nonce"]
|
||||
),
|
||||
"email": "google-helper@example.invalid",
|
||||
"email_verified": mode != "unverified_email",
|
||||
"name": "Google Local Helper",
|
||||
}
|
||||
signing_input = (
|
||||
f"{base64url(json_bytes(header))}.{base64url(json_bytes(claims))}"
|
||||
).encode()
|
||||
digest_info = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
digest_info += hashlib.sha256(signing_input).digest()
|
||||
padding = b"\xff" * (256 - len(digest_info) - 3)
|
||||
encoded_message = b"\x00\x01" + padding + b"\x00" + digest_info
|
||||
signature = pow(
|
||||
int.from_bytes(encoded_message, "big"),
|
||||
GOOGLE_RSA_PRIVATE_EXPONENT,
|
||||
GOOGLE_RSA_MODULUS,
|
||||
).to_bytes(256, "big")
|
||||
return f"{signing_input.decode()}.{base64url(signature)}"
|
||||
|
||||
|
||||
class BoundaryHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
|
@ -151,6 +239,12 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
|
|||
self.oauth_authorize(parsed)
|
||||
elif parsed.path == "/oauth/user":
|
||||
self.oauth_user()
|
||||
elif parsed.path == "/.well-known/openid-configuration":
|
||||
self.google_discovery()
|
||||
elif parsed.path == "/google/authorize":
|
||||
self.google_authorize(parsed)
|
||||
elif parsed.path == "/google/jwks":
|
||||
self.google_jwks()
|
||||
else:
|
||||
self.send_json(404, {"error": "not_found"})
|
||||
|
||||
|
|
@ -167,6 +261,8 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
|
|||
self.control(body)
|
||||
elif parsed.path == "/oauth/token":
|
||||
self.oauth_token(body)
|
||||
elif parsed.path == "/google/token":
|
||||
self.google_token(body)
|
||||
elif parsed.path == "/push":
|
||||
self.push(body)
|
||||
else:
|
||||
|
|
@ -314,6 +410,139 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
|
|||
},
|
||||
)
|
||||
|
||||
def google_discovery(self) -> None:
|
||||
with STATE.lock:
|
||||
STATE.google_discovery_requests += 1
|
||||
|
||||
self.send_json(
|
||||
200,
|
||||
{
|
||||
"issuer": GOOGLE_ISSUER,
|
||||
"authorization_endpoint": f"{GOOGLE_ISSUER}/google/authorize",
|
||||
"token_endpoint": f"{GOOGLE_ISSUER}/google/token",
|
||||
"jwks_uri": f"{GOOGLE_ISSUER}/google/jwks",
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
},
|
||||
)
|
||||
|
||||
def google_authorize(self, parsed: urllib.parse.SplitResult) -> None:
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
required = {
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"state",
|
||||
"nonce",
|
||||
"scope",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
}
|
||||
|
||||
if (
|
||||
not required.issubset(params)
|
||||
or params["client_id"][0] != OAUTH_CLIENT_ID
|
||||
or params["code_challenge_method"][0] != "S256"
|
||||
or set(params["scope"][0].split()) != {"openid", "email", "profile"}
|
||||
):
|
||||
self.send_json(400, {"error": "invalid_google_authorization_request"})
|
||||
return
|
||||
|
||||
redirect_uri = params["redirect_uri"][0]
|
||||
state = params["state"][0]
|
||||
|
||||
with STATE.lock:
|
||||
STATE.google_authorize_requests += 1
|
||||
STATE.google_code_counter += 1
|
||||
code = f"google-boundary-code-{STATE.google_code_counter}"
|
||||
STATE.google_codes[code] = {
|
||||
"challenge": params["code_challenge"][0],
|
||||
"redirect_uri": redirect_uri,
|
||||
"nonce": params["nonce"][0],
|
||||
"used": False,
|
||||
}
|
||||
|
||||
location = (
|
||||
f"{redirect_uri}?{urllib.parse.urlencode({'code': code, 'state': state})}"
|
||||
)
|
||||
self.send_response(302)
|
||||
self.send_header("location", location)
|
||||
self.send_header("content-length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def google_token(self, body: bytes) -> None:
|
||||
params = {
|
||||
key: values[0]
|
||||
for key, values in urllib.parse.parse_qs(body.decode()).items()
|
||||
}
|
||||
|
||||
with STATE.lock:
|
||||
STATE.google_token_requests += 1
|
||||
mode = STATE.google_mode
|
||||
code = STATE.google_codes.get(params.get("code", ""))
|
||||
|
||||
verifier = params.get("code_verifier", "")
|
||||
challenge = base64url(hashlib.sha256(verifier.encode()).digest())
|
||||
checks = {
|
||||
"client_id": params.get("client_id") == OAUTH_CLIENT_ID,
|
||||
"client_secret": params.get("client_secret") == OAUTH_CLIENT_SECRET,
|
||||
"grant_type": params.get("grant_type") == "authorization_code",
|
||||
"known_code": code is not None,
|
||||
"unused_code": code is not None and not code["used"],
|
||||
"redirect_uri": (
|
||||
code is not None and code["redirect_uri"] == params.get("redirect_uri")
|
||||
),
|
||||
"pkce": code is not None and code["challenge"] == challenge,
|
||||
}
|
||||
valid = all(checks.values())
|
||||
|
||||
if not valid:
|
||||
failed_checks = sorted(name for name, passed in checks.items() if not passed)
|
||||
if not checks["pkce"] and code is not None:
|
||||
failed_checks.append(
|
||||
"pkce_lengths_"
|
||||
f"{len(verifier)}_{len(code['challenge'])}_{len(challenge)}"
|
||||
)
|
||||
self.send_json(
|
||||
400,
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": ",".join(failed_checks),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
with STATE.lock:
|
||||
code["used"] = True
|
||||
|
||||
self.send_json(
|
||||
200,
|
||||
{
|
||||
"access_token": "google-boundary-access-token",
|
||||
"id_token": google_id_token(code, mode),
|
||||
"scope": "openid email profile",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
)
|
||||
|
||||
def google_jwks(self) -> None:
|
||||
with STATE.lock:
|
||||
STATE.google_jwks_requests += 1
|
||||
|
||||
self.send_json(
|
||||
200,
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"alg": "RS256",
|
||||
"e": base64url(unsigned_bytes(GOOGLE_RSA_EXPONENT)),
|
||||
"kid": GOOGLE_KEY_ID,
|
||||
"kty": "RSA",
|
||||
"n": base64url(unsigned_bytes(GOOGLE_RSA_MODULUS)),
|
||||
"use": "sig",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def push(self, body: bytes) -> None:
|
||||
try:
|
||||
notification = json.loads(body)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,18 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.CreateAuthIdentities do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:auth_identities, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :provider, :string, null: false
|
||||
add :provider_uid, :string, null: false
|
||||
add :email, :string, null: false
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:auth_identities, [:provider, :provider_uid])
|
||||
create unique_index(:auth_identities, [:user_id, :provider])
|
||||
end
|
||||
end
|
||||
|
|
@ -154,6 +154,8 @@ PHX_URL_PORT=80
|
|||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
PUSH_HTTP_ENDPOINT=
|
||||
PUSH_HTTP_BEARER_TOKEN=
|
||||
POSTGRES_DB=$postgres_db
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ CODEX_SESSION_ID=local-e2e
|
|||
RATE_LIMIT_POLICIES_JSON={}
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
E2E_BASE_URL=https://proxy
|
||||
E2E_MAILPIT_URL=http://mailpit:8025
|
||||
E2E_OUTPUT_DIR=$ROOT/output/e2e/generated-per-run
|
||||
|
|
|
|||
|
|
@ -146,6 +146,12 @@ jq -e '
|
|||
.oauth.fresh_flow_retry_after_temporary_failure == "passed" and
|
||||
.oauth.timeout_failed_closed == true and
|
||||
.oauth.access_token_returned_to_application == false and
|
||||
.google_oidc.success == "passed" and
|
||||
.google_oidc.state_mismatch_blocked_before_token == true and
|
||||
.google_oidc.one_time_code_replay_rejected == true and
|
||||
.google_oidc.nonce_mismatch_rejected_after_signature_verification == true and
|
||||
.google_oidc.unverified_email_rejected == true and
|
||||
.google_oidc.access_token_returned_to_application == false and
|
||||
.smtp.success == "passed" and
|
||||
.smtp.permanent_rejection_not_retried == true and
|
||||
.smtp.temporary_greeting_retried_once == true and
|
||||
|
|
|
|||
|
|
@ -103,6 +103,16 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
|
|||
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
||||
test "$(stat -c '%a' "$production_env")" = 600
|
||||
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||
partial_google_env="$scan_dir/.env.production.partial-google"
|
||||
cp "$production_env" "$partial_google_env"
|
||||
chmod 600 "$partial_google_env"
|
||||
sed -i 's/^GOOGLE_OAUTH_CLIENT_ID=.*/GOOGLE_OAUTH_CLIENT_ID=quality-client/' \
|
||||
"$partial_google_env"
|
||||
if ./scripts/validate-production-env.sh \
|
||||
"$partial_google_env" help.test >/dev/null 2>&1; then
|
||||
echo "Production environment validator accepted partial Google OAuth credentials." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; then
|
||||
echo "Production environment initializer overwrote an existing file." >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ set -eu
|
|||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env"
|
||||
TEST_IMAGE="${TEST_IMAGE:-who-need-help:test}"
|
||||
MODE=${1:-all}
|
||||
|
||||
case "$MODE" in
|
||||
all | --database-only) ;;
|
||||
*)
|
||||
echo "Usage: $0 [--database-only]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Missing $ENV_FILE. Copy .env.example to .env first." >&2
|
||||
|
|
@ -37,9 +46,6 @@ generate_phoenix_secret() {
|
|||
docker run --rm "$TEST_IMAGE" mix phx.gen.secret
|
||||
}
|
||||
|
||||
new_secret_key_base=$(generate_phoenix_secret)
|
||||
new_handover_secret=$(generate_phoenix_secret)
|
||||
new_release_cookie=$(generate_phoenix_secret)
|
||||
new_postgres_password=$(openssl rand -hex 32)
|
||||
new_database_url="ecto://${POSTGRES_USER}:${new_postgres_password}@db/${POSTGRES_DB}"
|
||||
|
||||
|
|
@ -47,11 +53,23 @@ tmp_env=$(mktemp "${ENV_FILE}.rotate.XXXXXX")
|
|||
trap 'rm -f "$tmp_env"' EXIT HUP INT TERM
|
||||
chmod 600 "$tmp_env"
|
||||
|
||||
NEW_SECRET_KEY_BASE=$new_secret_key_base \
|
||||
NEW_HANDOVER_SECRET=$new_handover_secret \
|
||||
NEW_RELEASE_COOKIE=$new_release_cookie \
|
||||
NEW_POSTGRES_PASSWORD=$new_postgres_password \
|
||||
NEW_DATABASE_URL=$new_database_url \
|
||||
if [ "$MODE" = "--database-only" ]; then
|
||||
NEW_POSTGRES_PASSWORD=$new_postgres_password \
|
||||
NEW_DATABASE_URL=$new_database_url \
|
||||
perl -pe '
|
||||
s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$ENV{NEW_POSTGRES_PASSWORD}/;
|
||||
s/^DATABASE_URL=.*/DATABASE_URL=$ENV{NEW_DATABASE_URL}/;
|
||||
' "$ENV_FILE" >"$tmp_env"
|
||||
else
|
||||
new_secret_key_base=$(generate_phoenix_secret)
|
||||
new_handover_secret=$(generate_phoenix_secret)
|
||||
new_release_cookie=$(generate_phoenix_secret)
|
||||
|
||||
NEW_SECRET_KEY_BASE=$new_secret_key_base \
|
||||
NEW_HANDOVER_SECRET=$new_handover_secret \
|
||||
NEW_RELEASE_COOKIE=$new_release_cookie \
|
||||
NEW_POSTGRES_PASSWORD=$new_postgres_password \
|
||||
NEW_DATABASE_URL=$new_database_url \
|
||||
perl -pe '
|
||||
s/^SECRET_KEY_BASE=.*/SECRET_KEY_BASE=$ENV{NEW_SECRET_KEY_BASE}/;
|
||||
s/^HANDOVER_SECRET=.*/HANDOVER_SECRET=$ENV{NEW_HANDOVER_SECRET}/;
|
||||
|
|
@ -59,6 +77,7 @@ NEW_DATABASE_URL=$new_database_url \
|
|||
s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$ENV{NEW_POSTGRES_PASSWORD}/;
|
||||
s/^DATABASE_URL=.*/DATABASE_URL=$ENV{NEW_DATABASE_URL}/;
|
||||
' "$ENV_FILE" >"$tmp_env"
|
||||
fi
|
||||
|
||||
printf "ALTER ROLE \"%s\" WITH PASSWORD '%s';\n" \
|
||||
"$POSTGRES_USER" "$new_postgres_password" |
|
||||
|
|
@ -70,5 +89,9 @@ mv "$tmp_env" "$ENV_FILE"
|
|||
chmod 600 "$ENV_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
echo "Local application and PostgreSQL secrets rotated without printing their values."
|
||||
if [ "$MODE" = "--database-only" ]; then
|
||||
echo "Local PostgreSQL password rotated without printing its value."
|
||||
else
|
||||
echo "Local application and PostgreSQL secrets rotated without printing their values."
|
||||
fi
|
||||
echo "Run docker compose up -d --wait to apply the new application environment."
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ read_value() {
|
|||
' "$env_file"
|
||||
}
|
||||
|
||||
optional_value() {
|
||||
local key=$1
|
||||
|
||||
read_value "$key" 2>/dev/null || true
|
||||
}
|
||||
|
||||
require_value() {
|
||||
local key=$1
|
||||
local value
|
||||
|
|
@ -80,10 +86,14 @@ release_cookie=$(require_value RELEASE_COOKIE)
|
|||
metrics_token=$(require_value METRICS_TOKEN)
|
||||
smtp_relay=$(require_value SMTP_RELAY)
|
||||
smtp_port=$(require_value SMTP_PORT)
|
||||
smtp_username=$(optional_value SMTP_USERNAME)
|
||||
smtp_password=$(optional_value SMTP_PASSWORD)
|
||||
smtp_auth=$(require_value SMTP_AUTH)
|
||||
smtp_tls=$(require_value SMTP_TLS)
|
||||
smtp_ssl=$(require_value SMTP_SSL)
|
||||
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
||||
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
||||
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
||||
|
||||
[[ "$phx_host" == "$expected_domain" ]] || {
|
||||
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
|
||||
|
|
@ -137,11 +147,35 @@ done
|
|||
echo "SMTP_SSL has an unsupported value." >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then
|
||||
[[ -n "$smtp_username" && -n "$smtp_password" ]] || {
|
||||
echo "SMTP username and password must either both be set or both be empty." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then
|
||||
echo "SMTP username and password are required when SMTP_AUTH is always." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
|
||||
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "$email_from_address" == *@* ]] || {
|
||||
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
|
||||
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || {
|
||||
echo "Google OAuth client ID and secret must either both be set or both be empty." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
reject_marker GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id"
|
||||
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
||||
fi
|
||||
|
||||
secrets=(
|
||||
"$postgres_password"
|
||||
"$secret_key_base"
|
||||
|
|
|
|||
46
test/support/google_auth_fake.ex
Normal file
46
test/support/google_auth_fake.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule WhoNeedHelp.GoogleAuthFake do
|
||||
@behaviour WhoNeedHelp.GoogleAuth
|
||||
|
||||
@impl true
|
||||
def enabled?, do: true
|
||||
|
||||
@impl true
|
||||
def authorize_url(redirect_uri) do
|
||||
{:ok,
|
||||
%{
|
||||
url:
|
||||
"https://accounts.google.example/authorize?" <>
|
||||
URI.encode_query(%{"redirect_uri" => redirect_uri, "state" => "google-test-state"}),
|
||||
session_params: %{
|
||||
state: "google-test-state",
|
||||
nonce: "google-test-nonce",
|
||||
code_verifier: "google-test-verifier"
|
||||
}
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def callback(_redirect_uri, params, %{state: state}) do
|
||||
cond do
|
||||
params["state"] != state ->
|
||||
{:error, :invalid_state}
|
||||
|
||||
params["code"] in [nil, ""] ->
|
||||
{:error, :missing_code}
|
||||
|
||||
params["email_verified"] == "false" ->
|
||||
{:error, :email_not_verified}
|
||||
|
||||
true ->
|
||||
email = params["email"] || "google-user@example.com"
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
provider_uid: params["uid"] || "google-user-42",
|
||||
email: email,
|
||||
email_verified: true,
|
||||
display_name: params["name"] || "Google Neighbor"
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
71
test/who_need_help/google_auth_test.exs
Normal file
71
test/who_need_help/google_auth_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule WhoNeedHelp.GoogleAuthTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias WhoNeedHelp.GoogleAuth.AssentAdapter
|
||||
|
||||
test "Google authorization uses OIDC state, nonce, PKCE, and identity-only scopes" do
|
||||
nonce = "session-bound-nonce"
|
||||
|
||||
assert {:ok, %{url: url, session_params: session_params}} =
|
||||
Assent.Strategy.Google.authorize_url(
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uri: "https://example.test/auth/google/callback",
|
||||
nonce: nonce,
|
||||
code_verifier: true,
|
||||
openid_configuration: %{
|
||||
"authorization_endpoint" => "https://accounts.google.test/o/oauth2/v2/auth"
|
||||
}
|
||||
)
|
||||
|
||||
uri = URI.parse(url)
|
||||
params = URI.decode_query(uri.query)
|
||||
|
||||
assert uri.host == "accounts.google.test"
|
||||
assert params["redirect_uri"] == "https://example.test/auth/google/callback"
|
||||
assert params["scope"] == "openid email profile"
|
||||
assert params["state"] == session_params.state
|
||||
assert params["nonce"] == nonce
|
||||
assert session_params.nonce == nonce
|
||||
assert params["code_challenge_method"] == "S256"
|
||||
assert is_binary(session_params.code_verifier)
|
||||
refute Map.has_key?(params, "access_type")
|
||||
end
|
||||
|
||||
test "normalizes only a verified Google identity and discards token-shaped claims" do
|
||||
assert {:ok, identity} =
|
||||
AssentAdapter.normalize_identity(%{
|
||||
"sub" => "google-subject-123",
|
||||
"email" => " Alice@Example.COM ",
|
||||
"email_verified" => true,
|
||||
"name" => "Alice Neighbor",
|
||||
"access_token" => "must-not-leak",
|
||||
"id_token" => "must-not-leak"
|
||||
})
|
||||
|
||||
assert identity == %{
|
||||
provider_uid: "google-subject-123",
|
||||
email: "alice@example.com",
|
||||
email_verified: true,
|
||||
display_name: "Alice Neighbor"
|
||||
}
|
||||
|
||||
refute Map.has_key?(identity, :access_token)
|
||||
refute Map.has_key?(identity, :id_token)
|
||||
end
|
||||
|
||||
test "rejects an unverified or incomplete Google email" do
|
||||
assert {:error, :email_not_verified} =
|
||||
AssentAdapter.normalize_identity(%{
|
||||
"sub" => "subject",
|
||||
"email" => "alice@example.com",
|
||||
"email_verified" => false
|
||||
})
|
||||
|
||||
assert {:error, :invalid_provider_identity} =
|
||||
AssentAdapter.normalize_identity(%{
|
||||
"sub" => "subject",
|
||||
"email_verified" => true
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||
use WhoNeedHelpWeb.ConnCase, async: false
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Accounts.AuthIdentity
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Trust.AuditEvent
|
||||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
|
||||
test "starts a login flow with callback state, nonce, and PKCE session data", %{conn: conn} do
|
||||
conn = post(conn, ~p"/auth/google/login")
|
||||
|
||||
assert redirected_to(conn) =~ "https://accounts.google.example/authorize?"
|
||||
|
||||
assert %{
|
||||
"flow" => "login",
|
||||
"session_params" => %{
|
||||
state: "google-test-state",
|
||||
nonce: "google-test-nonce",
|
||||
code_verifier: "google-test-verifier"
|
||||
}
|
||||
} = get_session(conn, "google_auth_flow")
|
||||
end
|
||||
|
||||
test "requires adult and safety consent before starting Google registration", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/auth/google/register", %{
|
||||
"google_registration" => %{"locale" => "en", "terms_accepted" => "false"}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/register"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older"
|
||||
assert is_nil(get_session(conn, "google_auth_flow"))
|
||||
end
|
||||
|
||||
test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do
|
||||
email = unique_user_email()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/register", %{
|
||||
"google_registration" => %{"locale" => "ru", "terms_accepted" => "true"}
|
||||
})
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=one-time-code&state=google-test-state&uid=google-123&email=#{email}&name=Google%20Helper"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert get_session(conn, :user_token)
|
||||
assert is_nil(get_session(conn, "google_auth_flow"))
|
||||
|
||||
user = Accounts.get_user_by_email(email)
|
||||
assert user.confirmed_at
|
||||
assert user.accepted_terms_at
|
||||
assert user.locale == "ru"
|
||||
assert user.display_name == "Google Helper"
|
||||
assert is_nil(user.hashed_password)
|
||||
|
||||
assert %AuthIdentity{
|
||||
provider: :google,
|
||||
provider_uid: "google-123",
|
||||
email: ^email,
|
||||
user_id: user_id
|
||||
} = Repo.get_by(AuthIdentity, provider: :google, provider_uid: "google-123")
|
||||
|
||||
assert user_id == user.id
|
||||
end
|
||||
|
||||
test "does not automatically merge an existing email account", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/register", %{
|
||||
"google_registration" => %{"locale" => "en", "terms_accepted" => "true"}
|
||||
})
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=new-google&email=#{user.email}"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "already exists"
|
||||
refute get_session(conn, :user_token)
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "new-google")
|
||||
end
|
||||
|
||||
test "logs in only through an identity already linked to the local account", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
assert {:ok, _identity} =
|
||||
Accounts.link_google_identity(user, %{
|
||||
provider_uid: "returning-google",
|
||||
email: user.email,
|
||||
email_verified: true,
|
||||
display_name: user.display_name
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=returning-google&email=#{user.email}"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert get_session(conn, :user_token)
|
||||
end
|
||||
|
||||
test "does not create an account from the login-only flow", %{conn: conn} do
|
||||
email = unique_user_email()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=unknown-google&email=#{email}"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not connected"
|
||||
refute Accounts.get_user_by_email(email)
|
||||
refute get_session(conn, :user_token)
|
||||
end
|
||||
|
||||
test "rejects unverified Google email and consumes the flow session", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(~p"/auth/google/callback?code=code&state=google-test-state&email_verified=false")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "verified email"
|
||||
assert is_nil(get_session(conn, "google_auth_flow"))
|
||||
end
|
||||
|
||||
test "callback cannot be replayed", %{conn: conn} do
|
||||
first =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(~p"/auth/google/callback?code=code&state=google-test-state")
|
||||
|
||||
replay =
|
||||
first
|
||||
|> recycle()
|
||||
|> get(~p"/auth/google/callback?code=code&state=google-test-state")
|
||||
|
||||
assert redirected_to(replay) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(replay.assigns.flash, :error) =~ "session expired"
|
||||
end
|
||||
|
||||
test "links Google only to the authenticated flow owner and records an audit event", %{
|
||||
conn: conn
|
||||
} do
|
||||
user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/auth/google/link")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=linked-google&email=linked@example.com"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "connected"
|
||||
|
||||
identity = Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "linked-google")
|
||||
assert identity.user_id == user.id
|
||||
|
||||
assert %AuditEvent{actor_id: actor_id, target_id: target_id} =
|
||||
Repo.get_by!(AuditEvent,
|
||||
action: "auth_identity.connected",
|
||||
target_id: identity.id
|
||||
)
|
||||
|
||||
assert actor_id == user.id
|
||||
assert target_id == identity.id
|
||||
end
|
||||
|
||||
test "rejects a link callback after the authenticated account changes", %{conn: conn} do
|
||||
first_user = user_fixture()
|
||||
second_user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(first_user)
|
||||
|> post(~p"/auth/google/link")
|
||||
|> recycle()
|
||||
|> log_in_user(second_user)
|
||||
|> get(~p"/auth/google/callback?code=code&state=google-test-state&uid=wrong-owner")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid"
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "wrong-owner")
|
||||
end
|
||||
|
||||
test "requires authentication to start account linking", %{conn: conn} do
|
||||
conn = post(conn, ~p"/auth/google/link")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "rejects a link callback after the sudo window expires", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> post(~p"/auth/google/link")
|
||||
|
||||
token = get_session(conn, :user_token)
|
||||
|
||||
override_token_authenticated_at(
|
||||
token,
|
||||
DateTime.utc_now(:second) |> DateTime.add(-11, :minute)
|
||||
)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/google/callback?code=code&state=google-test-state&uid=stale-google")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "stale-google")
|
||||
end
|
||||
|
||||
test "disconnects Google in sudo mode and records the audit event", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
assert {:ok, identity} =
|
||||
Accounts.link_google_identity(user, %{
|
||||
provider_uid: "disconnect-google",
|
||||
email: user.email,
|
||||
email_verified: true,
|
||||
display_name: user.display_name
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> delete(~p"/auth/google/link")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "disconnected"
|
||||
refute Repo.get(AuthIdentity, identity.id)
|
||||
|
||||
assert %AuditEvent{actor_id: actor_id, target_id: target_id} =
|
||||
Repo.get_by!(AuditEvent,
|
||||
action: "auth_identity.disconnected",
|
||||
target_id: identity.id
|
||||
)
|
||||
|
||||
assert actor_id == user.id
|
||||
assert target_id == identity.id
|
||||
end
|
||||
end
|
||||
|
|
@ -12,6 +12,9 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
|
|||
assert response =~ "Register"
|
||||
assert response =~ ~p"/users/log-in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "A password is not required"
|
||||
assert response =~ "Sign up with Google"
|
||||
assert response =~ ~p"/auth/google/register"
|
||||
end
|
||||
|
||||
test "redirects if already logged in", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert response =~ "Log in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "Log in with email"
|
||||
assert response =~ "Email me a sign-in link"
|
||||
assert response =~ "Sign in with Google"
|
||||
assert response =~ "Use a password instead"
|
||||
assert response =~ ~s(id="magic-link-fragment-form")
|
||||
assert response =~ ~s(hidden)
|
||||
end
|
||||
|
|
@ -30,7 +32,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
|
||||
assert html =~ "You need to reauthenticate"
|
||||
refute html =~ "Register"
|
||||
assert html =~ "Log in with email"
|
||||
assert html =~ "Email me a sign-in link"
|
||||
|
||||
assert html =~
|
||||
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
|
||||
|
|
@ -41,7 +43,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
response = html_response(conn, 200)
|
||||
assert response =~ "Log in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "Log in with email"
|
||||
assert response =~ "Email me a sign-in link"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert response =~ "Settings"
|
||||
assert response =~ ~s(id="update_password_username")
|
||||
assert response =~ ~s(autocomplete="username")
|
||||
assert response =~ "Google sign-in"
|
||||
assert response =~ "Connect Google account"
|
||||
end
|
||||
|
||||
test "redirects if user is not logged in" do
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user