diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..e150d6a --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# Copy to .env. Never commit real secrets. Generate each secret independently. +MEMBERSHIP_SECRET_KEY= +MEMBERSHIP_PUBLIC_URL=https://eom.dev +MEMBERSHIP_ALLOW_HTTP=0 +# Safe only while membership:8000 is private and Apache overwrites headers. +MEMBERSHIP_TRUST_PROXY=1 +WWW_BIND_ADDRESS=127.0.0.1 +WWW_PORT=8080 +POSTGRES_DB=membership +POSTGRES_USER=membership +POSTGRES_PASSWORD= +POSTGRES_HOST=membership-db +# Compose's local database is on an isolated network. For a remote database use +# verify-full and mount its trusted root certificate via PGSSLROOTCERT. +POSTGRES_SSLMODE=prefer +DEX_ISSUER=https://dex.eom.dev +DEX_CLIENT_ID=www-membership +DEX_CLIENT_SECRET= +DEX_CONNECTOR_ID=www-ldap +LDAP_URL=ldaps://ldap.eom.dev +LDAP_BIND_DN= +LDAP_BIND_PASSWORD= +LDAP_CA_CERT_FILE= +LDAP_ACCOUNT_BASE_DN=ou=www,ou=people,dc=eom,dc=dev +LDAP_SEARCH_BASE_DN=ou=people,dc=eom,dc=dev +# Set ONLY after independently verifying slapd olcPasswordHash and password policy. +LDAP_PASSWORD_SCHEME= +LDAP_GROUP_PLACEHOLDER_DN=cn=membership-sentinel,ou=system,dc=eom,dc=dev +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=1 +DEFAULT_FROM_EMAIL=accounts@eom.dev +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_LIVE_MODE=1 +STRIPE_PORTAL_CONFIGURATION= +# Set real recurring Stripe Price IDs and pre-created dedicated LDAP groups. +# The display price is informational; Stripe Checkout is authoritative. +MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json +# These are existing LIVE-mode prices; use separate test-mode prices in sandbox. +# Set MEMBERSHIP_TIERS_JSON instead to override the file ({} disables paid tiers). +# Example shape (names/prices/group DNs are your choice, not created by the app): +# MEMBERSHIP_TIERS_JSON={"supporter":{"label":"Supporter","price_id":"price_REPLACE","price_label":"$5 / month","group_dn":"cn=www-supporter,ou=groups,dc=eom,dc=dev","description":"Support independent projects and community services."}} diff --git a/.gitignore b/.gitignore index 94af53c..2f64e49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ htdocs/archive/* *.swp *.swo authorized_keys +.env +.env.* +!.env.example +__pycache__/ +*.pyc +*.sqlite3 +.venv/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..afe5d7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100755 index 0000000..2ad7e50 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Test membership offline", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "${workspaceFolder}/membership/manage.py", + "test", + "members.tests.test_membership", + "members.tests.test_oidc", + "members.tests.test_postgres", + "--settings=config.test_settings", + "--verbosity=1" + ], + "isBackground": false, + "problemMatcher": [], + "group": "test" + } + ] +} diff --git a/README.md b/README.md index 3eb9e3b..4e792cb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,190 @@ # WWW +Apache serves the static eom.dev website. A separate Django service provides +public accounts, Dex sign-in, and Stripe membership under **/members/**. + +## Membership flow + +1. Anyone can request a free account. A one-hour, single-use email link allows + them to choose a password. The account is created as an `inetOrgPerson` in + OpenLDAP; the password goes only through RFC 3062 Password Modify over + certificate-verified LDAPS/StartTLS. This app never stores a password/hash. +2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies + the RS256 ID token, issuer, audience, signature and lifetime. A signed + `federated_claims` connector/user ID must match the configured LDAP connector + and LDAP `entryUUID`; accounts are **not** linked using email alone. +3. Authenticated users choose a server-allowlisted recurring Stripe Price. + Stripe hosts Checkout and the billing portal. Return URLs never grant access. +4. Stripe webhooks are signature-verified and durably deduplicated. The worker + rereads canonical subscriptions and paid invoice lines, so duplicate, missing, + or reordered webhooks cannot restore expired access. +5. The worker first sweeps local expiries for all accounts, then reconciles + Stripe state. An account belongs to the configured tier group only while it + has paid service time. A failed renewal does not extend that time. Immediate + cancellation, pause, unpaid status or unknown prices revoke access. Cancel + at period end retains access until the paid expiry. Trialing does not grant + paid access. A zero-total invoice marked paid (e.g. a coupon) counts as paid. +6. Expiry removes **group membership, not the LDAP account**. LDAP writes are + idempotent, and failed synchronization is retried on every sweep. Old group + mappings remain in the database so deleting/changing a tier also revokes its + former group. Account row locks serialize Checkout and reconciliation. + +The worker interval is 60 seconds **after a complete sweep**, not a hard +one-minute expiry SLA. LDAP/network outages delay changes until recovery. +Stripe outages do not extend locally recorded paid expiry. Already-issued Dex +tokens and downstream sessions do not instantly lose their cached claims; set +short token lifetimes and enforce reauthorization in each relying application. + +## Existing Stripe product + +The live Stripe dashboard was inspected on 2026-10-23. This integration reuses +**Subscription**, product `prod_Sc9N5J0vNAdLCn`, without modifying its prices, +metadata, Discourse integration, or existing subscriptions. + +| Tier | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group | +| --- | ---: | --- | --- | +| Level 0 | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-0` | +| Level 1 | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-1` | +| Level 2 | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-2` | +| Level 3 | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-3` | +| Level 4 | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-4` | + +The deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json). +The **group DNs are deployment proposals**, not verified existing groups: create +them or edit the mapping to your dedicated groups before starting the worker. +Higher tiers do not implicitly inherit lower-tier groups. Map your applications' +authorization accordingly. Never use administrator or unrelated service groups. +Display prices are informational; the configured Stripe Price is authoritative. +For tax obligations, review the product tax code and Stripe settings separately. + +Existing Discourse customers/subscriptions are **not automatically imported**. +New website subscriptions carry `metadata.membership_account` and are scoped to +the signed-in account's stored customer ID. Never attach a pre-existing customer +or subscription based only on matching email; a separately reviewed ownership +migration is required. The dashboard showed zero active subscriptions on each +of these five prices when inspected. + +## Production setup + +1. Copy [.env.example](.env.example) to the ignored local environment file. Fill + secrets directly through your secret manager/editor, not chat. Generate + independent, high-entropy Django, PostgreSQL, Dex and LDAP credentials. + Fill SMTP settings and a real sender address for verification mail. +2. Create the account OU and sentinel/groups after reviewing + [membership/deploy/groups.example.ldif](membership/deploy/groups.example.ldif). + Configure a **least-privilege** LDAP service account allowed to: + - search the account tree for `uid`, `mail`, `entryUUID` and DN; + - add/delete entries only within the website account OU; + - perform Password Modify only on those newly provisioned entries; + - read and add/delete `uniqueMember` only on the dedicated paid groups; + - read the sentinel, which must never be a login identity. + Do not use the directory root/admin bind. Permit Dex a separate read/search + service identity. Prevent users from writing their own paid group membership. +3. Verify OpenLDAP's Argon2 module/password policy (`olcPasswordHash: {ARGON2}`), + require suitable password strength/history, reject unauthenticated binds, + and enforce directory-side **unique uid and mail across the full search + tree**, including other account registrars. Only then set + `LDAP_PASSWORD_SCHEME={ARGON2}`. The app does not alter slapd configuration. + If a private CA is used, mount its certificate read-only into both membership + and worker services and set `LDAP_CA_CERT_FILE`. TLS verification is mandatory. +4. Add a dedicated Dex client/LDAP connector using + [membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml). + The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash). + Set the same client secret in Dex and `DEX_CLIENT_SECRET`, and set + `DEX_CONNECTOR_ID=www-ldap`. This connector uses `idAttr: entryUUID`, + `preferredUsernameAttr: uid`, `emailAttr: mail`; scopes include `federated:id`. + **Do not change the existing connector's idAttr blindly**: that would change + subjects in other apps. Use the additional connector instead. Dex must search + the account OU without requiring membership in a paid group. RS256 and all + discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase + LDAP usernames matching the registration syntax can sign in without signup. +5. The supplied tier file contains live Price IDs. Set `STRIPE_LIVE_MODE=1` only + with the matching live Stripe API key. For sandbox testing, override + `MEMBERSHIP_TIERS_JSON` with **test-mode** recurring prices and set live mode + to 0. Set the JSON object to `{}` to disable paid tiers. Grant the API key only + required customer, Checkout, subscription, invoice/line read, Price read, and + billing portal permissions; the app never creates Products or Prices. +6. Create/configure a Stripe billing portal with **only these five allowed + prices**, payment method updates and cancellation. Set its configuration ID in + `STRIPE_PORTAL_CONFIGURATION`. For tier changes, use immediate paid proration + for upgrades and period-end downgrades. Access always follows the current + price's paid invoice lines; an unpaid upgrade does not grant the higher group. + An arbitrary instant downgrade without a paid line for the new price can + suspend access until Stripe invoices it; use scheduled downgrades instead. +7. Add a Stripe webhook endpoint at + `https://eom.dev/members/stripe/webhook/`. Subscribe to + `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed`, `customer.subscription.created`, + `customer.subscription.updated`, `customer.subscription.deleted`, + `invoice.paid`, `invoice.payment_failed`. Use API version + **2025-03-31.basil** and set the endpoint signing secret in + `STRIPE_WEBHOOK_SECRET`. Do not edit/delete the Discourse webhook. The worker's + full sweep recovers missing events without relying on payload arrival order. +8. Deploy with Docker Compose. A one-shot migration service runs before the web + and worker services. PostgreSQL has no published port. Its Compose network is + private; use `POSTGRES_SSLMODE=verify-full` with a mounted root CA for remote + PostgreSQL, and use encrypted disks/backups. Do not use the SQLite setting in + production: real PostgreSQL row locks are required. +9. Terminate TLS at your existing edge and forward only to Apache's private + listener. The host port now defaults to loopback (127.0.0.1:8080); if your TLS + proxy is on another host/container, explicitly set a private bind address and + firewall access. Apache **overwrites**, rather than trusts, forwarded scheme + and client-IP headers. Do not expose the membership service port. Do not + enable `MEMBERSHIP_TRUST_PROXY` outside this isolated topology. For actual IP + throttling behind another proxy, configure Apache's trusted `mod_remoteip` + proxies; otherwise the default conservatively throttles by the TLS edge IP. +10. Disable/redact access/error tracing for verification URLs and OIDC callback + codes at **every** edge, CDN, APM and log collector. Apache suppresses member + access logs; Gunicorn access logs are disabled. Do not enable request/body + debug logging. Sessions are server-side, rotate on login, and expire in one + hour. CSRF protects state-changing forms; only signed Stripe webhooks are + CSRF-exempt. Backups contain identity/billing metadata; restrict access, + encrypt them, and define a retention/deletion policy. + +Configuration does not automatically provision remote LDAP/Dex/Stripe resources. +No production credentials or live account changes are made by the test suite. + +## Operations and limitations + +- Keep `membership-worker` running. Alert on its errors and on accounts whose + `last_ldap_sync` or `last_billing_sync` is stale. A one-shot + `sync_memberships` command exits nonzero on a failed sweep. Logs record account + IDs/error classes, not passwords/tokens or raw webhook payloads. +- The worker removes expired registration/session/rate-limit records and retains + processed webhook IDs for 30 days. A later replay remains harmless because + grants are always recalculated from canonical Stripe data. +- Do not delete Account/ManagedGroup rows before removing their LDAP grants. + Do not manually remove an LDAP account without first revoking paid groups. + Directory renames are reconciled using `entryUUID`; the old DN is removed too. +- Registration cannot atomically commit LDAP and PostgreSQL together. If LDAP + creation succeeds and the database write fails, the UI directs the user to + Dex; successful login safely recovers the directory account by its UUID. + Failed LDAP rollback may require administrator cleanup. Never reset an + existing account's password merely because someone verified its email. +- Full Stripe invoice history is paginated for correctness. For a large member + base, add a durable incremental invoice cache and shard workers before relying + on a tight revocation latency. No refunds/dispute policy is inferred: the + current implementation follows paid invoices plus subscription state. If a + refund/dispute should revoke access, cancel/suspend the subscription as part of + your reviewed refund/dispute process. +- Free accounts are `inetOrgPerson`, not automatically POSIX/shell accounts and + not automatically members of any service group. Password resets remain your + existing LDAP/Dex account recovery process. + +## Verification + +Offline tests use an isolated SQLite database, mocked Stripe/SMTP/LDAP network +calls, real signed Stripe webhook verification, and real RSA-signed Authlib +state/PKCE/nonce/issuer/audience/signature tests. PostgreSQL tests run the same +suite against a disposable container using +[membership/compose.test.yaml](membership/compose.test.yaml). + +The suite covers verification expiry/replay, password validation, CSRF, directory +identity linking, allowed checkout prices, customer scoping, duplicate/reordered +webhooks, paid invoice periods, cancellation, failed renewals, unavailable Stripe, +LDAP retries, tier removal, multiple subscriptions, and the last member's removal +from a `groupOfUniqueNames` using its sentinel. Live Stripe, Dex, SMTP and OpenLDAP +must still be smoke-tested together in a non-production environment before +enabling public registration/payment. + diff --git a/conf/httpd.conf b/conf/httpd.conf index 8fc3541..92a214c 100644 --- a/conf/httpd.conf +++ b/conf/httpd.conf @@ -139,10 +139,10 @@ LoadModule headers_module modules/mod_headers.so LoadModule setenvif_module modules/mod_setenvif.so LoadModule version_module modules/mod_version.so #LoadModule remoteip_module modules/mod_remoteip.so -#LoadModule proxy_module modules/mod_proxy.so +LoadModule proxy_module modules/mod_proxy.so #LoadModule proxy_connect_module modules/mod_proxy_connect.so #LoadModule proxy_ftp_module modules/mod_proxy_ftp.so -#LoadModule proxy_http_module modules/mod_proxy_http.so +LoadModule proxy_http_module modules/mod_proxy_http.so #LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so #LoadModule proxy_scgi_module modules/mod_proxy_scgi.so #LoadModule proxy_uwsgi_module modules/mod_proxy_uwsgi.so @@ -240,6 +240,18 @@ ServerAdmin eric@eom.dev # ServerName eom.dev:443 +# Membership is isolated behind Apache. The outer TLS terminator must expose +# this listener only over its private network; never publish port 8080 directly. +ProxyRequests Off +ProxyPreserveHost On +ProxyPass "/members/" "http://membership:8000/members/" connectiontimeout=5 timeout=120 +ProxyPassReverse "/members/" "http://membership:8000/members/" + + RequestHeader set X-Forwarded-Proto "https" + RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}" + LimitRequestBody 262144 + + # # Deny access to the entirety of your server's filesystem. You must # explicitly permit access to web content directories in other @@ -357,7 +369,10 @@ LogLevel warn # define per- access logfiles, transactions will be # logged therein and *not* in this file. # - CustomLog /proc/self/fd/1 common + # Avoid logging email bearer tokens and OIDC authorization codes, including + # in request lines/query strings. Apply the same policy at the TLS edge. + SetEnvIf Request_URI "^/members/" membership_sensitive + CustomLog /proc/self/fd/1 common env=!membership_sensitive # # If you prefer a logfile with access, agent, and referer information diff --git a/docker-compose.yml b/docker-compose.yml index 596ff6a..fe50dd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,68 @@ services: - ./htdocs:/usr/local/apache2/htdocs/ - ./conf/httpd.conf:/usr/local/apache2/conf/httpd.conf ports: - - 8080:80 + - "${WWW_BIND_ADDRESS:-127.0.0.1}:${WWW_PORT:-8080}:80" + depends_on: + membership: + condition: service_started + networks: + - frontend + + membership-db: + image: postgres:17 + environment: + POSTGRES_DB: ${POSTGRES_DB:-membership} + POSTGRES_USER: ${POSTGRES_USER:-membership} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - membership-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + networks: + - database + + membership-migrate: + build: ./membership + env_file: .env + command: ["python", "manage.py", "migrate", "--noinput"] + depends_on: + membership-db: + condition: service_healthy + networks: + - database + + membership: + build: ./membership + env_file: .env + depends_on: + membership-migrate: + condition: service_completed_successfully + restart: unless-stopped + networks: + - frontend + - database + # No published port: only the trusted Apache proxy can supply request headers. + + membership-worker: + build: ./membership + env_file: .env + command: ["python", "manage.py", "sync_memberships", "--loop", "--interval", "60"] + depends_on: + membership-migrate: + condition: service_completed_successfully + restart: unless-stopped + networks: + - frontend + - database + +volumes: + membership-db: + +networks: + frontend: + database: + internal: true diff --git a/htdocs/common/topnav.html b/htdocs/common/topnav.html index 0dcdb43..e9dae50 100644 --- a/htdocs/common/topnav.html +++ b/htdocs/common/topnav.html @@ -8,7 +8,7 @@
  • Projects
  • Community
  • Letters
  • -
  • Support
  • +
  • Membership
  • diff --git a/htdocs/index.html b/htdocs/index.html index a11421e..cc9f013 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -12,7 +12,7 @@
    Explore the projects Join the conversation - Create an eom.dev account + Create an eom.dev account

    Independent software, open-source projects, music, and games—made in Raleigh, North Carolina.

    @@ -143,7 +143,7 @@

    Contributions help cover the servers, storage, and time behind these projects. If you need hands-on help, you can also book a consultation.

    - Become a monthly supporter + Explore memberships Book a free introductory call Make a one-time contribution
    diff --git a/htdocs/static/membership.css b/htdocs/static/membership.css new file mode 100755 index 0000000..5f6f27d --- /dev/null +++ b/htdocs/static/membership.css @@ -0,0 +1,38 @@ +.member-nav, .member-main, .member-footer { width: min(1160px, calc(100% - 3rem)); margin-inline: auto; } +.member-nav { display: flex; justify-content: space-between; gap: 1rem; align-items: center; padding-block: 1.5rem; border-bottom: 1px solid var(--border); } +.member-nav .brand { font-size: 1.5rem; font-weight: 800; color: var(--text); text-decoration: none; } +.member-nav nav { display: flex; gap: 1.5rem; } +.member-main { padding-bottom: 4rem; } +.member-hero { padding-block: clamp(3rem, 8vw, 6rem); } +.member-hero h1 { font-size: clamp(2.8rem, 6vw, 5rem); max-width: 900px; } +.member-intro { max-width: 700px; color: var(--muted); font-size: 1.2rem; } +.member-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; } +.member-actions form { margin: 0; } +button.button { cursor: pointer; font-size: inherit; line-height: inherit; } +.member-panel { padding: 2rem; border: 1px solid var(--border); border-radius: 1.3rem; background: var(--surface); } +.member-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 1.5rem; } +.tier-card { display: flex; flex-direction: column; border-top: 3px solid var(--accent); } +.free-tier { border-top-color: var(--green); } +.tier-card h3 { font-size: 1.8rem; } +.tier-card ul { padding-left: 1.2rem; margin-bottom: 2rem; color: var(--muted); } +.tier-card > form, .tier-card > .button, .tier-footnote { margin-top: auto; } +.tier-price { color: var(--text); font-size: 1.8rem; font-weight: 700; } +.tier-price span { font-size: 1rem; font-weight: 400; color: var(--muted); } +.tier-card .button { width: 100%; text-align: center; } +.account-panel { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 1rem; margin-top: 2rem; } +.account-panel h2, .account-panel p { margin-bottom: .5rem; } +.member-notice { margin-top: 1rem; padding: 1rem 1.3rem; border-left: 3px solid var(--green); background: var(--surface); border-radius: .3rem; } +.member-small, .member-footer { color: var(--muted); font-size: .9rem; } +.form-panel { max-width: 630px; margin: 4rem auto; } +.form-panel h1 { font-size: clamp(2rem, 5vw, 3rem); } +.form-panel label { display: block; margin-bottom: .3rem; font-weight: 600; } +.form-panel input { width: 100%; padding: .8rem 1rem; border-radius: .6rem; border: 1px solid var(--border); background: var(--page-deep); color: var(--text); font: inherit; } +.form-panel input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.form-panel .helptext { display: block; color: var(--muted); font-size: .85rem; margin-top: .4rem; } +.errorlist { color: var(--red); } +.member-faq { margin-top: 4rem; max-width: 800px; } +.member-faq details { padding: 1rem 0; border-bottom: 1px solid var(--border); } +.member-faq summary { cursor: pointer; font-weight: 600; } +.member-faq details p { color: var(--muted); margin: 1rem 0 .5rem; } +.member-footer { padding-block: 2rem; border-top: 1px solid var(--border); } +@media (max-width: 540px) { .member-nav, .member-main, .member-footer { width: calc(100% - 2rem); } .member-nav nav { gap: .8rem; font-size: .85rem; } .member-panel { padding: 1.4rem; } .member-actions { align-items: stretch; flex-direction: column; } .member-actions .button { width: 100%; } } diff --git a/membership/.dockerignore b/membership/.dockerignore new file mode 100755 index 0000000..c3c7231 --- /dev/null +++ b/membership/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +*.sqlite3 +.env* +.venv diff --git a/membership/Dockerfile b/membership/Dockerfile new file mode 100755 index 0000000..9d19756 --- /dev/null +++ b/membership/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt && useradd --create-home --uid 10001 membership +COPY --chown=membership:membership . ./ +USER membership +EXPOSE 8000 +# Access logs omit query strings and verification tokens; the edge must too. +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--error-logfile", "-"] diff --git a/membership/compose.test.yaml b/membership/compose.test.yaml new file mode 100755 index 0000000..d82d52e --- /dev/null +++ b/membership/compose.test.yaml @@ -0,0 +1,24 @@ +# Disposable fixture; a separate Compose project prevents touching production. +services: + db: + image: postgres:17 + environment: + POSTGRES_DB: membership_test + POSTGRES_USER: membership_test + POSTGRES_PASSWORD: ${MEMBERSHIP_TEST_DB_PASSWORD:?Set a disposable test password} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U membership_test -d membership_test"] + interval: 2s + timeout: 5s + retries: 30 + tmpfs: + - /var/lib/postgresql/data + tests: + build: . + image: www-membership-test + environment: + MEMBERSHIP_TEST_DB_PASSWORD: ${MEMBERSHIP_TEST_DB_PASSWORD:?Set a disposable test password} + command: ["python", "manage.py", "test", "members.tests", "--settings=config.postgres_test_settings", "--verbosity=1"] + depends_on: + db: + condition: service_healthy diff --git a/membership/config/__init__.py b/membership/config/__init__.py new file mode 100755 index 0000000..d2fdeb7 --- /dev/null +++ b/membership/config/__init__.py @@ -0,0 +1 @@ +"""Membership service Django configuration.""" diff --git a/membership/config/postgres_test_settings.py b/membership/config/postgres_test_settings.py new file mode 100755 index 0000000..8cb4b1b --- /dev/null +++ b/membership/config/postgres_test_settings.py @@ -0,0 +1,14 @@ +"""Disposable PostgreSQL integration tests (Compose fixture only).""" + +import os + +from .test_settings import * # noqa: F403 + +DATABASES = {"default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "membership_test", + "USER": "membership_test", + "PASSWORD": os.environ["MEMBERSHIP_TEST_DB_PASSWORD"], + "HOST": "db", + "PORT": "5432", +}} diff --git a/membership/config/settings.py b/membership/config/settings.py new file mode 100755 index 0000000..8a888b6 --- /dev/null +++ b/membership/config/settings.py @@ -0,0 +1,113 @@ +"""Production defaults; development HTTP must be explicitly enabled.""" + +import json +import os +import re +from pathlib import Path +from urllib.parse import urlsplit + +from django.core.exceptions import ImproperlyConfigured + +BASE_DIR = Path(__file__).absolute().parent.parent +DEBUG = False +ALLOW_HTTP = os.environ.get("MEMBERSHIP_ALLOW_HTTP", "0") == "1" +SECRET_KEY = os.environ.get("MEMBERSHIP_SECRET_KEY", "") +if len(SECRET_KEY) < 32: + raise ImproperlyConfigured("Set MEMBERSHIP_SECRET_KEY to at least 32 random characters.") +PUBLIC_URL = os.environ.get("MEMBERSHIP_PUBLIC_URL", "https://eom.dev").rstrip("/") +public = urlsplit(PUBLIC_URL) +if public.scheme not in ({"http", "https"} if ALLOW_HTTP else {"https"}) or not public.hostname or public.path or public.query or public.fragment or public.username: + raise ImproperlyConfigured("MEMBERSHIP_PUBLIC_URL must be an HTTPS origin (no path).") +ALLOWED_HOSTS = [public.hostname] +ROOT_URLCONF = "config.urls" +WSGI_APPLICATION = "config.wsgi.application" +INSTALLED_APPS = ["django.contrib.contenttypes", "django.contrib.sessions", "members"] +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "members.middleware.SecurityHeaders", +] +TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": {"context_processors": ["django.template.context_processors.request"]}}] +DATABASES = {"default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "membership"), + "USER": os.environ.get("POSTGRES_USER", "membership"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), + "HOST": os.environ.get("POSTGRES_HOST", "membership-db"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + "OPTIONS": {"sslmode": os.environ.get("POSTGRES_SSLMODE", "prefer")}, +}} +if os.environ.get("MEMBERSHIP_SQLITE_PATH"): + DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.environ["MEMBERSHIP_SQLITE_PATH"]}} +USE_TZ = True +TIME_ZONE = "UTC" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +SESSION_COOKIE_NAME = "eom_membership" +SESSION_COOKIE_PATH = "/members/" +SESSION_COOKIE_SECURE = not ALLOW_HTTP +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_AGE = 3600 +CSRF_COOKIE_NAME = "eom_membership_csrf" +CSRF_COOKIE_PATH = "/members/" +CSRF_COOKIE_SECURE = not ALLOW_HTTP +SECURE_SSL_REDIRECT = not ALLOW_HTTP +# Only enable behind an isolated proxy which OVERWRITES X-Forwarded-Proto. +if os.environ.get("MEMBERSHIP_TRUST_PROXY", "0") == "1": + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_HSTS_SECONDS = 31536000 if not ALLOW_HTTP else 0 +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" +DATA_UPLOAD_MAX_MEMORY_SIZE = 262144 +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 12}}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") +EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost") +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" +EMAIL_TIMEOUT = 10 +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "accounts@eom.dev") + +DEX_ISSUER = os.environ.get("DEX_ISSUER", "https://dex.eom.dev").rstrip("/") +if urlsplit(DEX_ISSUER).scheme != "https" or not urlsplit(DEX_ISSUER).hostname: + raise ImproperlyConfigured("DEX_ISSUER must use HTTPS.") +DEX_CLIENT_ID = os.environ.get("DEX_CLIENT_ID", "www-membership") +DEX_CLIENT_SECRET = os.environ.get("DEX_CLIENT_SECRET", "") +DEX_CONNECTOR_ID = os.environ.get("DEX_CONNECTOR_ID", "ldap") + +LDAP_URL = os.environ.get("LDAP_URL", "") +LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", "") +LDAP_BIND_PASSWORD = os.environ.get("LDAP_BIND_PASSWORD", "") +LDAP_CA_CERT_FILE = os.environ.get("LDAP_CA_CERT_FILE") or None +LDAP_ACCOUNT_BASE_DN = os.environ.get("LDAP_ACCOUNT_BASE_DN", "") +LDAP_SEARCH_BASE_DN = os.environ.get("LDAP_SEARCH_BASE_DN", LDAP_ACCOUNT_BASE_DN) +LDAP_PASSWORD_SCHEME = os.environ.get("LDAP_PASSWORD_SCHEME", "") +LDAP_GROUP_PLACEHOLDER_DN = os.environ.get("LDAP_GROUP_PLACEHOLDER_DN", "") + +STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") +STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") +STRIPE_LIVE_MODE = os.environ.get("STRIPE_LIVE_MODE", "0") == "1" +STRIPE_PORTAL_CONFIGURATION = os.environ.get("STRIPE_PORTAL_CONFIGURATION", "") +STRIPE_API_VERSION = "2025-03-31.basil" +tier_file = os.environ.get("MEMBERSHIP_TIERS_FILE", "") +MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHIP_TIERS_JSON" in os.environ else json.loads(Path(tier_file).read_text(encoding="utf-8")) if tier_file else {} +if not isinstance(MEMBERSHIP_TIERS, dict): + raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.") +prices = set() +for key, tier in MEMBERSHIP_TIERS.items(): + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,47}", key) or not isinstance(tier, dict): + raise ImproperlyConfigured("Invalid membership tier.") + for field in ("label", "price_id", "group_dn", "price_label"): + if not isinstance(tier.get(field), str) or not tier[field].strip(): + raise ImproperlyConfigured(f"Tier {key} requires {field}.") + if not tier["price_id"].startswith("price_") or tier["price_id"] in prices: + raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.") + prices.add(tier["price_id"]) diff --git a/membership/config/test_settings.py b/membership/config/test_settings.py new file mode 100755 index 0000000..c503802 --- /dev/null +++ b/membership/config/test_settings.py @@ -0,0 +1,16 @@ +"""Isolated offline tests. Never use this settings module for deployment.""" + +import os + +os.environ.setdefault("MEMBERSHIP_SECRET_KEY", "test-only-not-a-production-secret-0000000000000000") +os.environ.setdefault("MEMBERSHIP_SQLITE_PATH", ":memory:") + +from .settings import * # noqa: F403 + +ALLOWED_HOSTS = ["testserver", "localhost", "127.0.0.1", "eom.dev"] +SECURE_SSL_REDIRECT = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +PUBLIC_URL = "https://eom.dev" +STRIPE_LIVE_MODE = False diff --git a/membership/config/urls.py b/membership/config/urls.py new file mode 100755 index 0000000..bb697e8 --- /dev/null +++ b/membership/config/urls.py @@ -0,0 +1,3 @@ +from django.urls import include, path + +urlpatterns = [path("members/", include("members.urls"))] diff --git a/membership/config/wsgi.py b/membership/config/wsgi.py new file mode 100755 index 0000000..08dea8d --- /dev/null +++ b/membership/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/membership/deploy/dex-membership.example.yaml b/membership/deploy/dex-membership.example.yaml new file mode 100755 index 0000000..2f1ddff --- /dev/null +++ b/membership/deploy/dex-membership.example.yaml @@ -0,0 +1,35 @@ +# Merge with the EXISTING Dex configuration; do not replace its storage/issuer. +# A new connector avoids changing subjects for other applications using LDAP. +staticClients: + - id: www-membership + name: eom.dev Membership + secretEnv: WWW_DEX_CLIENT_SECRET + redirectURIs: + - https://eom.dev/members/oidc/callback/ + +connectors: + - type: ldap + id: www-ldap + name: eom.dev accounts + config: + host: ldap.eom.dev:636 + insecureNoSSL: false + insecureSkipVerify: false + # rootCA: /etc/dex/ldap-ca.pem # Mount your CA when not publicly trusted. + bindDN: cn=dex-reader,ou=system,dc=eom,dc=dev + bindPW: $DEX_LDAP_BIND_PASSWORD + userSearch: + baseDN: ou=people,dc=eom,dc=dev + filter: "(objectClass=inetOrgPerson)" + username: uid + idAttr: entryUUID + emailAttr: mail + nameAttr: cn + preferredUsernameAttr: uid + groupSearch: + baseDN: ou=groups,dc=eom,dc=dev + filter: "(objectClass=groupOfUniqueNames)" + userMatchers: + - userAttr: DN + groupAttr: uniqueMember + nameAttr: cn diff --git a/membership/deploy/groups.example.ldif b/membership/deploy/groups.example.ldif new file mode 100755 index 0000000..3ba4e38 --- /dev/null +++ b/membership/deploy/groups.example.ldif @@ -0,0 +1,37 @@ +# Review these DNs against your directory BEFORE applying. Parent OUs and the +# application bind identity must already exist. Sentinel has no login/password. +dn: cn=membership-sentinel,ou=system,dc=eom,dc=dev +objectClass: top +objectClass: organizationalRole +cn: membership-sentinel +description: Non-login placeholder for empty paid membership groups + +dn: cn=www-level-0,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-0 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev + +dn: cn=www-level-1,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-1 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev + +dn: cn=www-level-2,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-2 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev + +dn: cn=www-level-3,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-3 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev + +dn: cn=www-level-4,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-4 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev diff --git a/membership/manage.py b/membership/manage.py new file mode 100755 index 0000000..da28563 --- /dev/null +++ b/membership/manage.py @@ -0,0 +1,9 @@ +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +if __name__ == "__main__": + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/membership/members/__init__.py b/membership/members/__init__.py new file mode 100755 index 0000000..d0cba5c --- /dev/null +++ b/membership/members/__init__.py @@ -0,0 +1 @@ +"""Public accounts and paid directory membership.""" diff --git a/membership/members/billing.py b/membership/members/billing.py new file mode 100755 index 0000000..eb22875 --- /dev/null +++ b/membership/members/billing.py @@ -0,0 +1,133 @@ +"""Canonical Stripe reads, not event arrival order, determine entitlements.""" + +import logging +from datetime import datetime, timezone as dt_timezone + +import stripe +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import transaction +from django.utils import timezone + +from .directory import reconcile_groups +from .models import Account, ManagedGroup, Subscription, WebhookEvent + +logger = logging.getLogger(__name__) + + +def client(): + if not settings.STRIPE_SECRET_KEY: + raise ImproperlyConfigured("STRIPE_SECRET_KEY is required.") + return stripe.StripeClient(settings.STRIPE_SECRET_KEY, stripe_version=settings.STRIPE_API_VERSION, max_network_retries=1, http_client=stripe.RequestsClient(timeout=10)).v1 + + +def _id(value): + return value.get("id") if isinstance(value, dict) else value + + +def _paid_until(api, subscription, price_id, now): + """Recover paid time after outages, including when the latest invoice is open. + + Only paid recurring lines for the CURRENT price and subscription count. + Positive prorations can grant an upgrade; credit lines cannot grant access. + Pagination is essential: invoice headers' period_end is NOT the service end. + """ + end = None + invoices = api.invoices.list({"subscription": subscription["id"], "status": "paid", "limit": 100}) + for invoice in invoices.auto_paging_iter(): + parent = (invoice.get("parent") or {}).get("subscription_details") or {} + if _id(invoice.get("customer")) != _id(subscription["customer"]) or _id(parent.get("subscription")) != subscription["id"] or invoice.get("livemode") != settings.STRIPE_LIVE_MODE: + continue + lines = api.invoices.line_items.list(invoice["id"], {"limit": 100}) + for line in lines.auto_paging_iter(): + details = (line.get("parent") or {}).get("subscription_item_details") or {} + price = ((line.get("pricing") or {}).get("price_details") or {}).get("price") + period = line.get("period") or {} + if _id(price) != price_id or _id(details.get("subscription")) != subscription["id"] or line.get("amount", -1) < 0: + continue + start, finish = period.get("start"), period.get("end") + if not isinstance(start, int) or not isinstance(finish, int) or start > now.timestamp() or finish <= now.timestamp(): + continue + candidate = datetime.fromtimestamp(finish, dt_timezone.utc) + end = max(end, candidate) if end else candidate + # No old invoice can provide future service after its successor's period. + # Keep scanning for unusual billing/proration histories; never guess. + return end + + +def refresh_billing(account): + """Caller holds the account row lock. Commit only a complete Stripe snapshot.""" + if not account.stripe_customer: + return + api = client() + now = timezone.now() + snapshots = [] + prices = {tier["price_id"]: key for key, tier in settings.MEMBERSHIP_TIERS.items()} + for sub in api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100}).auto_paging_iter(): + if _id(sub.get("customer")) != account.stripe_customer or sub.get("livemode") != settings.STRIPE_LIVE_MODE or (sub.get("metadata") or {}).get("membership_account") != str(account.pk): + continue + items = sub.get("items") or {} + data = items.get("data") or [] + price_id = _id(data[0].get("price")) if len(data) == 1 and not items.get("has_more") else None + tier = prices.get(price_id, "") + eligible = tier and sub.get("status") in {"active", "past_due"} and not sub.get("pause_collection") + paid_until = _paid_until(api, sub, price_id, now) if eligible else None + if paid_until and sub.get("cancel_at"): + paid_until = min(paid_until, datetime.fromtimestamp(sub["cancel_at"], dt_timezone.utc)) + snapshots.append((sub["id"], {"tier": tier, "status": sub["status"], "paid_until": paid_until, "cancel_at_period_end": bool(sub.get("cancel_at_period_end"))})) + # Do not partially overwrite paid time if a later Stripe page/read fails. + with transaction.atomic(): + seen = [] + for stripe_id, values in snapshots: + Subscription.objects.update_or_create(stripe_id=stripe_id, account=account, defaults=values) + seen.append(stripe_id) + Subscription.objects.filter(account=account).exclude(stripe_id__in=seen).update(status="missing", paid_until=None) + account.last_billing_sync = now + account.save(update_fields=["last_billing_sync"]) + + +def apply_groups(account): + now = timezone.now() + desired = set() + for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now): + tier = settings.MEMBERSHIP_TIERS.get(sub.tier) + if tier: + desired.add(tier["group_dn"]) + managed = set(ManagedGroup.objects.filter(account=account).values_list("dn", flat=True)) + account.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn + account.last_ldap_sync = now + account.save(update_fields=["ldap_dn", "last_ldap_sync"]) + return len(desired) + + +def sync_account(account_id, refresh_stripe=True): + """Serialize Stripe/LDAP writes per account across web and worker processes. + + Local expiry runs even when Stripe is unreachable. LDAP failure is retried on + the next pass, with persisted managed DNs allowing crash-safe revocation. + """ + # Commit management intent independently before external side effects. If + # LDAP succeeds but a later DB commit fails, the next sweep still knows + # which old groups to revoke, even after the tier is removed from config. + with transaction.atomic(): + account = Account.objects.select_for_update().get(pk=account_id) + for tier in settings.MEMBERSHIP_TIERS.values(): + ManagedGroup.objects.get_or_create(account=account, dn=tier["group_dn"]) + with transaction.atomic(): + account = Account.objects.select_for_update().get(pk=account_id) + # Never hold local expiry hostage to an invoice-history API read. + count = apply_groups(account) + if not refresh_stripe: + return True + billing_ok = True + try: + refresh_billing(account) + except Exception as exc: + billing_ok = False + logger.error("Billing refresh failed account=%s kind=%s", account.pk, type(exc).__name__) + now = timezone.now() + count = apply_groups(account) + if billing_ok: + WebhookEvent.objects.filter(account=account, processed_at__isnull=True).update(processed_at=now) + logger.info("Membership reconciled account=%s groups=%s billing_ok=%s", account.pk, count, billing_ok) + return billing_ok diff --git a/membership/members/directory.py b/membership/members/directory.py new file mode 100755 index 0000000..bd1d711 --- /dev/null +++ b/membership/members/directory.py @@ -0,0 +1,109 @@ +"""TLS-only LDAP; paid groups are never granted during registration or login.""" + +import re +import ssl +from contextlib import contextmanager +from urllib.parse import urlsplit + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, Connection, Server, Tls +from ldap3.core.exceptions import LDAPException +from ldap3.utils.conv import escape_filter_chars +from ldap3.utils.dn import escape_rdn + + +@contextmanager +def connection(): + url = urlsplit(settings.LDAP_URL) + if url.scheme not in {"ldap", "ldaps"} or not url.hostname or not settings.LDAP_BIND_DN or not settings.LDAP_BIND_PASSWORD or not settings.LDAP_ACCOUNT_BASE_DN: + raise ImproperlyConfigured("Configure LDAP URL, service bind, and account base DN.") + server = Server(url.hostname, port=url.port or (636 if url.scheme == "ldaps" else 389), use_ssl=url.scheme == "ldaps", tls=Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=settings.LDAP_CA_CERT_FILE, version=ssl.PROTOCOL_TLS_CLIENT), connect_timeout=5) + conn = Connection(server, user=settings.LDAP_BIND_DN, password=settings.LDAP_BIND_PASSWORD, raise_exceptions=True, receive_timeout=8, auto_referrals=False) + try: + conn.open() + if url.scheme == "ldap" and not conn.start_tls(): + raise LDAPException("StartTLS failed; refusing to bind.") + if not conn.bind(): + raise LDAPException("LDAP bind failed.") + yield conn + finally: + conn.unbind() + + +def _find(conn, query): + conn.search(settings.LDAP_SEARCH_BASE_DN, query, attributes=["uid", "mail", "entryUUID"]) + return list(conn.entries) + + +def _identity(entry): + return {"uid": str(entry.uid.value), "email": str(entry.mail.value), "ldap_dn": entry.entry_dn, "ldap_uuid": str(entry.entryUUID.value)} + + +def identity_for_login(uid): + if not isinstance(uid, str) or not re.fullmatch(r"[a-z][a-z0-9._-]{2,47}", uid): + raise ValueError("Dex must return a valid LDAP uid as preferred_username.") + with connection() as conn: + entries = _find(conn, f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(uid)}))") + if len(entries) != 1: + raise ValueError("LDAP identity is missing or ambiguous.") + return _identity(entries[0]) + + +def create_account(uid, email, password): + if settings.LDAP_PASSWORD_SCHEME != "{ARGON2}": + raise ImproperlyConfigured("Verify slapd's Argon2 password policy before enabling registration.") + with connection() as conn: + if _find(conn, f"(|(uid={escape_filter_chars(uid)})(mail={escape_filter_chars(email)}))"): + # Never reset/reuse an existing LDAP password based on mailbox access. + raise ValueError("Account already exists. Sign in through Dex or contact the administrator.") + dn = f"uid={escape_rdn(uid)},{settings.LDAP_ACCOUNT_BASE_DN}" + created = False + try: + if not conn.add(dn, attributes={"objectClass": ["top", "inetOrgPerson"], "uid": uid, "cn": uid, "sn": uid, "mail": email}): + raise LDAPException("LDAP account creation failed.") + created = True + if not conn.extend.standard.modify_password(dn, new_password=password): + raise LDAPException("LDAP password modify failed.") + conn.search(dn, "(objectClass=inetOrgPerson)", search_scope=BASE, attributes=["uid", "mail", "entryUUID"]) + if len(conn.entries) != 1: + raise LDAPException("Cannot read created identity.") + return _identity(conn.entries[0]) + except Exception: + if created: + conn.delete(dn) + raise + + +def reconcile_groups(account, desired, managed): + """Idempotent, atomic per-group changes. A real sentinel keeps groups nonempty.""" + placeholder = settings.LDAP_GROUP_PLACEHOLDER_DN + if not placeholder: + raise ImproperlyConfigured("LDAP_GROUP_PLACEHOLDER_DN is required to revoke the final member.") + with connection() as conn: + entries = _find(conn, f"(entryUUID={escape_filter_chars(account.ldap_uuid)})") + if len(entries) != 1: + raise ValueError("Membership identity missing or ambiguous; refusing to modify groups.") + dn = entries[0].entry_dn + if placeholder.casefold() == dn.casefold(): + raise ImproperlyConfigured("The LDAP sentinel must not be a login account.") + if not conn.search(placeholder, "(objectClass=*)", search_scope=BASE, attributes=["objectClass"]): + raise ImproperlyConfigured("LDAP sentinel does not exist.") + # Revoke obsolete tiers before granting new tiers on plan changes. + for group_dn in sorted(managed, key=lambda group: (group in desired, group)): + if not conn.search(group_dn, "(objectClass=groupOfUniqueNames)", search_scope=BASE, attributes=["uniqueMember"]): + raise ImproperlyConfigured("Managed group is missing or not groupOfUniqueNames.") + members = {str(value).casefold() for value in conn.entries[0].uniqueMember.values} + changes = [] + if placeholder.casefold() not in members: + changes.append((MODIFY_ADD, [placeholder])) + # Remove a stale DN too, e.g. after a directory rename. + candidates = {value.casefold(): value for value in (account.ldap_dn, dn)} + for candidate in candidates.values(): + if candidate.casefold() in members and (group_dn not in desired or candidate.casefold() != dn.casefold()): + changes.append((MODIFY_DELETE, [candidate])) + if group_dn in desired and dn.casefold() not in members: + changes.append((MODIFY_ADD, [dn])) + if changes and not conn.modify(group_dn, {"uniqueMember": changes}): + raise LDAPException("LDAP group synchronization failed.") + return dn diff --git a/membership/members/forms.py b/membership/members/forms.py new file mode 100755 index 0000000..8cbb023 --- /dev/null +++ b/membership/members/forms.py @@ -0,0 +1,26 @@ +from django import forms +from django.contrib.auth.password_validation import validate_password + + +class RegistrationForm(forms.Form): + uid = forms.RegexField(regex=r"^[a-z][a-z0-9._-]{2,47}$", max_length=48, label="Username", help_text="3–48 lowercase letters, numbers, dots, underscores or hyphens. Start with a letter.") + email = forms.EmailField(max_length=254) + + def clean_email(self): + return self.cleaned_data["email"].strip().casefold() + + +class PasswordForm(forms.Form): + password = forms.CharField(max_length=1024, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), label="Password", help_text="At least 12 characters. Stored only in OpenLDAP, never on this website.") + confirm_password = forms.CharField(max_length=1024, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), label="Confirm password") + + def clean_password(self): + password = self.cleaned_data["password"] + validate_password(password) + return password + + def clean(self): + data = super().clean() + if data.get("password") != data.get("confirm_password"): + self.add_error("confirm_password", "Passwords must match.") + return data diff --git a/membership/members/management/__init__.py b/membership/members/management/__init__.py new file mode 100755 index 0000000..5b32cee --- /dev/null +++ b/membership/members/management/__init__.py @@ -0,0 +1 @@ +"""Membership operations.""" diff --git a/membership/members/management/commands/__init__.py b/membership/members/management/commands/__init__.py new file mode 100755 index 0000000..5fd9e3d --- /dev/null +++ b/membership/members/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management command package.""" diff --git a/membership/members/management/commands/sync_memberships.py b/membership/members/management/commands/sync_memberships.py new file mode 100755 index 0000000..97790d7 --- /dev/null +++ b/membership/members/management/commands/sync_memberships.py @@ -0,0 +1,53 @@ +import logging +import time +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.sessions.models import Session +from django.utils import timezone + +from members.billing import sync_account +from members.models import Account, RateWindow, Registration, WebhookEvent + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Reconcile Stripe subscriptions and LDAP groups, including offline expiry." + + def add_arguments(self, parser): + parser.add_argument("--loop", action="store_true") + parser.add_argument("--interval", type=int, default=60) + + def handle(self, *args, **options): + if options["interval"] < 10: + raise CommandError("Use an interval of at least 10 seconds.") + while True: + failed = 0 + # Expire EVERY account before any potentially slow Stripe history + # reads. This pass requires only the local DB and LDAP. + for account_id in Account.objects.values_list("pk", flat=True).iterator(): + try: + sync_account(account_id, refresh_stripe=False) + except Exception as exc: + failed += 1 + logger.error("Local expiry sync failed account=%s kind=%s", account_id, type(exc).__name__) + # A complete sweep is intentional: webhook loss must not prevent + # renewal recovery or revocation. Each account has its own lock. + for account_id in Account.objects.values_list("pk", flat=True).iterator(): + try: + if not sync_account(account_id): + failed += 1 + except Exception as exc: + failed += 1 + logger.error("Membership sync failed account=%s kind=%s", account_id, type(exc).__name__) + now = timezone.now() + Session.objects.filter(expire_date__lte=now).delete() + Registration.objects.filter(expires_at__lte=now).delete() + RateWindow.objects.filter(expires_at__lte=now).delete() + WebhookEvent.objects.filter(processed_at__lt=now - timedelta(days=30)).delete() + if not options["loop"]: + if failed: + raise CommandError(f"{failed} account(s) failed to synchronize; inspect worker logs.") + return + time.sleep(options["interval"]) diff --git a/membership/members/middleware.py b/membership/members/middleware.py new file mode 100755 index 0000000..6e1bdda --- /dev/null +++ b/membership/members/middleware.py @@ -0,0 +1,10 @@ +class SecurityHeaders: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response["Cache-Control"] = "no-store" + response["Referrer-Policy"] = "no-referrer" + response["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" + return response diff --git a/membership/members/migrations/0001_initial.py b/membership/members/migrations/0001_initial.py new file mode 100755 index 0000000..692d977 --- /dev/null +++ b/membership/members/migrations/0001_initial.py @@ -0,0 +1,55 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + dependencies = [] + operations = [ + migrations.CreateModel(name="Account", fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("uid", models.CharField(max_length=48, unique=True)), + ("email", models.EmailField(max_length=254)), + ("ldap_dn", models.CharField(max_length=1024)), + ("ldap_uuid", models.CharField(max_length=64, unique=True)), + ("oidc_sub", models.CharField(max_length=512, null=True, unique=True)), + ("stripe_customer", models.CharField(max_length=128, null=True, unique=True)), + ("checkout_id", models.CharField(blank=True, max_length=128)), + ("checkout_key", models.UUIDField(default=uuid.uuid4)), + ("last_billing_sync", models.DateTimeField(null=True)), + ("last_ldap_sync", models.DateTimeField(null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ]), + migrations.CreateModel(name="Registration", fields=[ + ("token_hash", models.CharField(max_length=64, primary_key=True, serialize=False)), + ("uid", models.CharField(max_length=48)), + ("email", models.EmailField(max_length=254)), + ("expires_at", models.DateTimeField(db_index=True)), + ]), + migrations.CreateModel(name="RateWindow", fields=[ + ("key", models.CharField(max_length=64, primary_key=True, serialize=False)), + ("count", models.PositiveIntegerField(default=0)), + ("expires_at", models.DateTimeField(db_index=True)), + ]), + migrations.CreateModel(name="Subscription", fields=[ + ("stripe_id", models.CharField(max_length=128, primary_key=True, serialize=False)), + ("tier", models.CharField(blank=True, max_length=48)), + ("status", models.CharField(max_length=32)), + ("paid_until", models.DateTimeField(null=True)), + ("cancel_at_period_end", models.BooleanField(default=False)), + ("account", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="subscriptions", to="members.account")), + ]), + migrations.CreateModel(name="ManagedGroup", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("dn", models.CharField(max_length=1024)), + ("account", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="managed_groups", to="members.account")), + ], options={"constraints": [models.UniqueConstraint(fields=("account", "dn"), name="unique_account_group")]}), + migrations.CreateModel(name="WebhookEvent", fields=[ + ("stripe_id", models.CharField(max_length=128, primary_key=True, serialize=False)), + ("received_at", models.DateTimeField(auto_now_add=True)), + ("processed_at", models.DateTimeField(null=True)), + ("account", models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="members.account")), + ]), + ] diff --git a/membership/members/migrations/__init__.py b/membership/members/migrations/__init__.py new file mode 100755 index 0000000..50b7581 --- /dev/null +++ b/membership/members/migrations/__init__.py @@ -0,0 +1 @@ +"""Database schema migrations.""" diff --git a/membership/members/models.py b/membership/members/models.py new file mode 100755 index 0000000..56ddcc4 --- /dev/null +++ b/membership/members/models.py @@ -0,0 +1,57 @@ +import uuid + +from django.db import models + + +class Account(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + uid = models.CharField(max_length=48, unique=True) + email = models.EmailField() + ldap_dn = models.CharField(max_length=1024) + ldap_uuid = models.CharField(max_length=64, unique=True) + oidc_sub = models.CharField(max_length=512, unique=True, null=True) + stripe_customer = models.CharField(max_length=128, unique=True, null=True) + checkout_id = models.CharField(max_length=128, blank=True) + checkout_key = models.UUIDField(default=uuid.uuid4) + last_billing_sync = models.DateTimeField(null=True) + last_ldap_sync = models.DateTimeField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class Registration(models.Model): + token_hash = models.CharField(max_length=64, primary_key=True) + uid = models.CharField(max_length=48) + email = models.EmailField() + expires_at = models.DateTimeField(db_index=True) + + +class RateWindow(models.Model): + key = models.CharField(max_length=64, primary_key=True) + count = models.PositiveIntegerField(default=0) + expires_at = models.DateTimeField(db_index=True) + + +class Subscription(models.Model): + stripe_id = models.CharField(max_length=128, primary_key=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="subscriptions") + tier = models.CharField(max_length=48, blank=True) + status = models.CharField(max_length=32) + paid_until = models.DateTimeField(null=True) + cancel_at_period_end = models.BooleanField(default=False) + + +class ManagedGroup(models.Model): + """Retain old mappings so removing a tier from config also revokes its group.""" + + account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="managed_groups") + dn = models.CharField(max_length=1024) + + class Meta: + constraints = [models.UniqueConstraint(fields=["account", "dn"], name="unique_account_group")] + + +class WebhookEvent(models.Model): + stripe_id = models.CharField(max_length=128, primary_key=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, null=True) + received_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True) diff --git a/membership/members/tests/__init__.py b/membership/members/tests/__init__.py new file mode 100755 index 0000000..310ae79 --- /dev/null +++ b/membership/members/tests/__init__.py @@ -0,0 +1 @@ +"""Offline membership regression tests.""" diff --git a/membership/members/tests/test_membership.py b/membership/members/tests/test_membership.py new file mode 100755 index 0000000..279e218 --- /dev/null +++ b/membership/members/tests/test_membership.py @@ -0,0 +1,464 @@ +import hashlib +import hmac +import json +import re +import time +from contextlib import contextmanager +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.conf import settings +from django.core import mail +from django.test import Client, SimpleTestCase, TestCase, override_settings +from django.utils import timezone +from ldap3 import Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, Server + +from members import billing, directory, views +from members.models import Account, ManagedGroup, Registration, Subscription, WebhookEvent + +GROUP = "cn=supporter,ou=groups,dc=example,dc=org" +OTHER_GROUP = "cn=patron,ou=groups,dc=example,dc=org" +BASE = "ou=people,dc=example,dc=org" +DN = "uid=alice," + BASE +SENTINEL = "cn=sentinel,dc=example,dc=org" +TIERS = { + "supporter": {"label": "Supporter", "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP}, + "patron": {"label": "Patron", "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP}, +} + + +def account(**kwargs): + return Account.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="11111111-1111-1111-1111-111111111111", **kwargs) + + +def paged(values): + response = MagicMock() + response.auto_paging_iter.side_effect = lambda: iter(values) + return response + + +@override_settings(MEMBERSHIP_TIERS=TIERS) +class RegistrationTests(TestCase): + def test_public_home_and_registration(self): + response = self.client.get("/members/") + self.assertContains(response, "Create a free account") + self.assertContains(response, "Supporter") + self.assertEqual(response.headers["Cache-Control"], "no-store") + self.assertContains(self.client.get("/members/register/"), "Send verification email") + + def start_signup(self): + self.client.post("/members/register/", {"uid": "alice", "email": "ALICE@example.org"}) + match = re.search(r"/verify/([^/]+)/", str(mail.outbox[-1].body)) + assert match is not None + token = match.group(1) + self.assertNotEqual(Registration.objects.get().pk, token) + return token + + @patch("members.views.directory.create_account") + def test_verified_one_use_signup_no_paid_access(self, provision): + provision.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} + token = self.start_signup() + self.assertFalse(Account.objects.exists()) + self.assertFalse(provision.called) + self.assertRedirects(self.client.get(f"/members/verify/{token}/"), "/members/register/finish/") + password = "correct-horse-purple-planet-943!" + response = self.client.post("/members/register/finish/", {"password": password, "confirm_password": password}) + self.assertContains(response, "Account created") + provision.assert_called_once_with("alice", "alice@example.org", password) + self.assertEqual(Account.objects.count(), 1) + self.assertFalse(Subscription.objects.exists()) + self.assertFalse(Registration.objects.exists()) + self.assertNotIn("account_id", self.client.session) + self.assertNotIn(password, str(list(Account.objects.values()))) + self.client.post("/members/register/finish/", {"password": password, "confirm_password": password}) + self.assertEqual(provision.call_count, 1) + + @patch("members.views.directory.create_account") + def test_expired_token_cannot_provision(self, provision): + token = self.start_signup() + Registration.objects.update(expires_at=timezone.now() - timedelta(seconds=1)) + self.assertContains(self.client.get(f"/members/verify/{token}/"), "Link expired") + self.client.post("/members/register/finish/", {"password": "not-used"}) + provision.assert_not_called() + + @patch("members.views.directory.create_account") + def test_password_validation(self, provision): + token = self.start_signup() + self.client.get(f"/members/verify/{token}/") + response = self.client.post("/members/register/finish/", {"password": "123", "confirm_password": "123"}) + self.assertContains(response, "too short") + provision.assert_not_called() + + def test_signup_validation_and_throttle(self): + self.client.post("/members/register/", {"uid": "*)(uid=*)", "email": "not-an-email"}) + self.assertFalse(Registration.objects.exists()) + for _ in range(3): + self.assertEqual(self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}).status_code, 200) + self.assertEqual(self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}).status_code, 429) + + @patch("members.views.send_mail", side_effect=OSError("SMTP unavailable")) + def test_mail_failure_invalidates_pending_token(self, send): + response = self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}) + self.assertContains(response, "Email unavailable") + self.assertFalse(Registration.objects.exists()) + + def test_csrf_and_method_safety(self): + strict = Client(enforce_csrf_checks=True) + for path in ("register/", "login/", "logout/", "billing/", "checkout/supporter/"): + self.assertEqual(strict.post("/members/" + path).status_code, 403) + self.assertEqual(self.client.get("/members/login/").status_code, 405) + self.assertEqual(self.client.get("/members/checkout/supporter/").status_code, 405) + + +@override_settings(MEMBERSHIP_TIERS=TIERS) +class IdentityTests(TestCase): + def claims(self, **changes): + return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "uuid-alice"}, **changes} + + @patch("members.views.directory.identity_for_login") + def test_existing_directory_account_links_without_password(self, identity): + identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} + first = views.link_identity(self.claims()) + second = views.link_identity(self.claims()) + self.assertEqual(first.pk, second.pk) + self.assertEqual(first.oidc_sub, "dex-alice") + self.assertFalse(Subscription.objects.exists()) + with self.assertRaises(ValueError): + views.link_identity(self.claims(sub="different-sub")) + + @patch("members.views.directory.identity_for_login") + def test_no_email_only_or_unverified_link(self, identity): + for changes in ({"email_verified": False}, {"iss": "https://evil.example"}, {"sub": ""}): + with self.assertRaises(ValueError): + views.link_identity(self.claims(**changes)) + identity.assert_not_called() + identity.return_value = {"uid": "alice", "email": "other@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} + with self.assertRaises(ValueError): + views.link_identity(self.claims()) + self.assertFalse(Account.objects.exists()) + + @patch("members.views.directory.identity_for_login") + def test_other_connector_or_recreated_ldap_account_rejected(self, identity): + identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} + for federated in ({"connector_id": "github", "user_id": "uuid-alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uuid"}, {}): + with self.assertRaises(ValueError): + views.link_identity(self.claims(federated_claims=federated)) + self.assertFalse(Account.objects.exists()) + + @patch("members.views.directory.identity_for_login") + def test_same_uid_different_uuid_cannot_take_over_account(self, identity): + original = account(oidc_sub="original") + identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "new-directory-entry"} + with self.assertRaises(ValueError): + views.link_identity(self.claims()) + original.refresh_from_db() + self.assertEqual(original.oidc_sub, "original") + + @patch("members.views.dex") + @patch("members.views.link_identity") + def test_callback_rotates_session_and_does_not_store_tokens(self, link, dex): + member = account() + link.return_value = member + session = self.client.session + session["old_state"] = "old" + session.save() + old_key = session.session_key + dex.return_value.authorize_access_token.return_value = {"id_token": "never-persist-this", "userinfo": self.claims(), "access_token": "also-private"} + self.assertRedirects(self.client.get("/members/oidc/callback/?state=valid&code=example"), "/members/") + self.assertNotEqual(self.client.session.session_key, old_key) + self.assertEqual(dict(self.client.session), {"account_id": str(member.pk)}) + + @patch("members.views.dex") + def test_bad_callback_has_no_login(self, dex): + dex.return_value.authorize_access_token.side_effect = ValueError("bad state / nonce") + response = self.client.get("/members/oidc/callback/?code=bad") + self.assertContains(response, "could not be verified") + self.assertNotIn("account_id", self.client.session) + + +@override_settings(MEMBERSHIP_TIERS=TIERS, STRIPE_WEBHOOK_SECRET="whsec_test_only", STRIPE_PORTAL_CONFIGURATION="bpc_test") +class BillingViewsTests(TestCase): + def setUp(self): + self.member = account(stripe_customer="cus_alice") + session = self.client.session + session["account_id"] = str(self.member.pk) + session.save() + + def signed_event(self, event, age=0): + payload = json.dumps(event) + timestamp = int(time.time()) - age + signature = hmac.new(settings.STRIPE_WEBHOOK_SECRET.encode(), f"{timestamp}.{payload}".encode(), hashlib.sha256).hexdigest() + return self.client.post("/members/stripe/webhook/", payload, content_type="application/json", HTTP_STRIPE_SIGNATURE=f"t={timestamp},v1={signature}") + + def event(self, **changes): + return {"id": "evt_example", "object": "event", "livemode": False, "type": "invoice.paid", "data": {"object": {"customer": "cus_alice"}}, **changes} + + def test_signed_webhook_durable_and_duplicate_safe(self): + self.assertEqual(self.signed_event(self.event()).status_code, 200) + self.assertEqual(self.signed_event(self.event()).status_code, 200) + self.assertEqual(WebhookEvent.objects.count(), 1) + self.assertEqual(WebhookEvent.objects.get().account, self.member) + self.assertIsNone(WebhookEvent.objects.get().processed_at) + self.assertFalse(Subscription.objects.exists()) + + def test_rejects_bad_expired_wrong_mode_and_connect_events(self): + self.assertEqual(self.client.post("/members/stripe/webhook/", "{}", content_type="application/json").status_code, 400) + self.assertEqual(self.signed_event(self.event(), age=301).status_code, 400) + self.assertEqual(self.signed_event(self.event(livemode=True)).status_code, 400) + self.assertEqual(self.signed_event(self.event(account="acct_other")).status_code, 400) + self.assertFalse(WebhookEvent.objects.exists()) + + @patch("members.views.billing.client") + def test_checkout_server_allowlist_and_metadata(self, api_factory): + api = api_factory.return_value + api.subscriptions.list.return_value = paged([]) + api.checkout.sessions.create.return_value = {"id": "cs_first", "url": "https://checkout.stripe.com/c/pay/first"} + response = self.client.post("/members/checkout/supporter/", {"price_id": "price_attacker", "customer": "cus_other"}) + self.assertEqual(response.status_code, 302) + params = api.checkout.sessions.create.call_args.args[0] + self.assertEqual(params["customer"], "cus_alice") + self.assertEqual(params["line_items"], [{"price": "price_supporter", "quantity": 1}]) + self.assertEqual(params["subscription_data"]["metadata"]["membership_account"], str(self.member.pk)) + self.assertFalse(Subscription.objects.exists()) + self.assertEqual(self.client.post("/members/checkout/attacker/").status_code, 404) + + @patch("members.views.billing.client") + def test_open_checkout_reused_and_duplicate_subscription_blocked(self, api_factory): + api = api_factory.return_value + api.subscriptions.list.return_value = paged([]) + Account.objects.filter(pk=self.member.pk).update(checkout_id="cs_open") + api.checkout.sessions.retrieve.return_value = {"status": "open", "url": "https://checkout.stripe.com/c/pay/open"} + self.assertEqual(self.client.post("/members/checkout/patron/").status_code, 302) + api.checkout.sessions.create.assert_not_called() + api.subscriptions.list.return_value = paged([{"status": "active"}]) + self.assertContains(self.client.post("/members/checkout/patron/"), "already have a subscription") + + @patch("members.views.billing.client") + def test_portal_scoped_to_session_account(self, api_factory): + api = api_factory.return_value + api.billing_portal.sessions.create.return_value = {"url": "https://billing.stripe.com/p/session/test"} + response = self.client.post("/members/billing/", {"customer": "cus_victim"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(api.billing_portal.sessions.create.call_args.args[0]["customer"], "cus_alice") + + def test_billing_requires_session(self): + self.client.post("/members/logout/") + self.assertRedirects(self.client.post("/members/checkout/supporter/"), "/members/") + + +@override_settings(MEMBERSHIP_TIERS=TIERS) +class ReconciliationTests(TestCase): + def setUp(self): + self.member = account(stripe_customer="cus_alice") + self.now = timezone.now() + self.end = int((self.now + timedelta(days=30)).timestamp()) + self.sub = {"id": "sub_alice", "customer": "cus_alice", "livemode": False, "metadata": {"membership_account": str(self.member.pk)}, "status": "active", "items": {"data": [{"price": {"id": "price_supporter"}}], "has_more": False}, "cancel_at_period_end": False} + self.invoice = {"id": "in_paid", "customer": "cus_alice", "status": "paid", "livemode": False, "parent": {"subscription_details": {"subscription": "sub_alice"}}} + self.line = {"amount": 500, "parent": {"subscription_item_details": {"subscription": "sub_alice"}}, "pricing": {"price_details": {"price": "price_supporter"}}, "period": {"start": int(self.now.timestamp()) - 60, "end": self.end}} + + def api(self): + api = MagicMock() + api.subscriptions.list.return_value = paged([self.sub]) + api.invoices.list.return_value = paged([self.invoice]) + api.invoices.line_items.list.return_value = paged([self.line]) + return api + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_paid_invoice_grants_and_records_service_expiry(self, factory, ldap): + factory.return_value = self.api() + self.assertTrue(billing.sync_account(self.member.pk)) + sub = Subscription.objects.get() + assert sub.paid_until is not None + self.assertEqual(int(sub.paid_until.timestamp()), self.end) + self.assertEqual(sub.tier, "supporter") + self.assertEqual(ldap.call_args.args[1], {GROUP}) + self.assertEqual(ManagedGroup.objects.count(), 2) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_cancellation_at_period_end_retains_paid_time(self, factory, ldap): + self.sub["cancel_at_period_end"] = True + factory.return_value = self.api() + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], {GROUP}) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_canceled_trial_unpaid_paused_unknown_never_grant(self, factory, ldap): + for status in ("canceled", "trialing", "unpaid", "incomplete", "incomplete_expired", "paused"): + self.sub["status"] = status + factory.return_value = self.api() + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set(), status) + self.sub["status"] = "active" + self.sub["items"]["data"][0]["price"]["id"] = "price_unknown" + factory.return_value = self.api() + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_no_invoice_no_grant_even_if_active(self, factory, ldap): + api = self.api() + api.invoices.list.return_value = paged([]) + factory.return_value = api + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_wrong_invoice_price_negative_and_future_lines_rejected(self, factory, ldap): + for change in ({"amount": -500}, {"pricing": {"price_details": {"price": "price_other"}}}, {"period": {"start": self.end, "end": self.end + 300}}): + api = self.api() + api.invoices.line_items.list.return_value = paged([{**self.line, **change}]) + factory.return_value = api + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_past_due_only_retains_previously_paid_time(self, factory, ldap): + self.sub["status"] = "past_due" + factory.return_value = self.api() + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], {GROUP}) + self.line["period"]["end"] = int((self.now - timedelta(seconds=1)).timestamp()) + factory.return_value = self.api() + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client", side_effect=OSError("offline")) + def test_local_expiry_during_stripe_outage(self, factory, ldap): + Subscription.objects.create(stripe_id="sub_alice", account=self.member, tier="supporter", status="active", paid_until=self.now - timedelta(seconds=1)) + self.assertFalse(billing.sync_account(self.member.pk)) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_local_only_sweep_never_calls_stripe(self, factory, ldap): + Subscription.objects.create(stripe_id="sub_expired", account=self.member, tier="supporter", status="active", paid_until=self.now - timedelta(seconds=1)) + self.assertTrue(billing.sync_account(self.member.pk, refresh_stripe=False)) + factory.assert_not_called() + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_snapshot_is_not_partially_applied_on_error(self, factory, ldap): + old_end = self.now + timedelta(days=1) + Subscription.objects.create(stripe_id="sub_alice", account=self.member, tier="supporter", status="active", paid_until=old_end) + api = self.api() + api.invoices.line_items.list.side_effect = OSError("network unavailable") + factory.return_value = api + self.assertFalse(billing.sync_account(self.member.pk)) + self.assertEqual(Subscription.objects.get().paid_until, old_end) + self.assertEqual(ldap.call_args.args[1], {GROUP}) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_removed_tier_still_revoked(self, factory, ldap): + factory.return_value = self.api() + billing.sync_account(self.member.pk) + with override_settings(MEMBERSHIP_TIERS={}): + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + self.assertIn(GROUP, ldap.call_args.args[2]) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_wrong_customer_mode_metadata_cannot_grant(self, factory, ldap): + for changes in ({"customer": "cus_victim"}, {"livemode": True}, {"metadata": {"membership_account": "attacker"}}): + api = self.api() + api.subscriptions.list.return_value = paged([{**self.sub, **changes}]) + factory.return_value = api + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + + @patch("members.billing.reconcile_groups", side_effect=OSError("LDAP offline")) + @patch("members.billing.client") + def test_ldap_failure_keeps_retry_event_and_management_intent(self, factory, ldap): + factory.return_value = self.api() + WebhookEvent.objects.create(stripe_id="evt_pending", account=self.member) + with self.assertRaises(OSError): + billing.sync_account(self.member.pk) + self.assertIsNone(WebhookEvent.objects.get().processed_at) + self.assertEqual(ManagedGroup.objects.count(), 2) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_reordered_webhook_never_restores_canceled_access(self, factory, ldap): + self.sub["status"] = "canceled" + factory.return_value = self.api() + WebhookEvent.objects.create(stripe_id="evt_old_paid", account=self.member) + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], set()) + self.assertIsNotNone(WebhookEvent.objects.get().processed_at) + + @patch("members.billing.reconcile_groups", return_value=DN) + @patch("members.billing.client") + def test_multiple_subscriptions_aggregate_groups(self, factory, ldap): + api = self.api() + other_sub = {**self.sub, "id": "sub_other", "items": {"data": [{"price": {"id": "price_patron"}}]}} + other_invoice = {**self.invoice, "id": "in_other", "parent": {"subscription_details": {"subscription": "sub_other"}}} + other_line = {**self.line, "parent": {"subscription_item_details": {"subscription": "sub_other"}}, "pricing": {"price_details": {"price": "price_patron"}}} + api.subscriptions.list.return_value = paged([self.sub, other_sub]) + api.invoices.list.side_effect = lambda params: paged([self.invoice] if params["subscription"] == "sub_alice" else [other_invoice]) + api.invoices.line_items.list.side_effect = lambda inv, params: paged([self.line] if inv == "in_paid" else [other_line]) + factory.return_value = api + billing.sync_account(self.member.pk) + self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP}) + + +@override_settings(LDAP_SEARCH_BASE_DN=BASE, LDAP_GROUP_PLACEHOLDER_DN=SENTINEL) +class DirectoryTests(SimpleTestCase): + def setUp(self): + # ldap3 supports offline schemas; its third-party annotation omits them. + server = Server("mock", get_info=OFFLINE_SLAPD_2_4) # pyright: ignore[reportArgumentType] + self.conn = Connection(server, client_strategy=MOCK_SYNC) + self.conn.bind() + self.conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "cn": "alice", "sn": "alice", "mail": "alice@example.org", "entryUUID": "11111111-1111-1111-1111-111111111111"}) + self.conn.strategy.add_entry(SENTINEL, {"objectClass": ["organizationalRole"], "cn": "sentinel"}) + self.conn.strategy.add_entry(GROUP, {"objectClass": ["groupOfUniqueNames"], "cn": "supporter", "uniqueMember": [DN]}) + self.member = Account(uid="alice", ldap_dn=DN, ldap_uuid="11111111-1111-1111-1111-111111111111") + + @contextmanager + def mock_connection(self): + yield self.conn + + def members(self): + self.conn.search(GROUP, "(objectClass=*)", attributes=["uniqueMember"]) + return set(self.conn.entries[0].uniqueMember.values) + + def test_final_member_revoked_with_sentinel_and_idempotency(self): + with patch("members.directory.connection", self.mock_connection): + directory.reconcile_groups(self.member, set(), {GROUP}) + self.assertEqual(self.members(), {SENTINEL}) + directory.reconcile_groups(self.member, set(), {GROUP}) + directory.reconcile_groups(self.member, {GROUP}, {GROUP}) + directory.reconcile_groups(self.member, {GROUP}, {GROUP}) + self.assertEqual(self.members(), {SENTINEL, DN}) + + def test_missing_sentinel_and_missing_identity_fail_closed(self): + with patch("members.directory.connection", self.mock_connection): + self.conn.delete(SENTINEL) + with self.assertRaises(Exception): + directory.reconcile_groups(self.member, set(), {GROUP}) + self.assertEqual(self.members(), {DN}) + self.member.ldap_uuid = "missing" + with self.assertRaises(ValueError): + directory.reconcile_groups(self.member, {GROUP}, {GROUP}) + + def test_login_uid_rejects_filter_injection(self): + with self.assertRaises(ValueError): + directory.identity_for_login("*)(uid=*)") + + @override_settings(LDAP_URL="ldap://ldap.example.org", LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE) + @patch("members.directory.Connection") + def test_starttls_failure_never_binds_credentials(self, conn_factory): + conn_factory.return_value.start_tls.return_value = False + with self.assertRaises(Exception): + with directory.connection(): + pass + conn_factory.return_value.bind.assert_not_called() + conn_factory.return_value.unbind.assert_called_once() diff --git a/membership/members/tests/test_oidc.py b/membership/members/tests/test_oidc.py new file mode 100755 index 0000000..5dc5497 --- /dev/null +++ b/membership/members/tests/test_oidc.py @@ -0,0 +1,73 @@ +import secrets +import time +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from authlib.integrations.django_client import OAuth +from django.conf import settings +from django.contrib.sessions.backends.db import SessionStore +from django.test import RequestFactory, TestCase +from joserfc import jwt +from joserfc.jwk import RSAKey + + +class AuthlibFlowTests(TestCase): + """Exercise real Authlib state, PKCE, nonce and signature validation offline.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.key = RSAKey.generate_key(2048, parameters={"kid": "test-key"}) + + def flow(self, **changes): + issuer = settings.DEX_ISSUER + remote = OAuth().register("dex", client_id=settings.DEX_CLIENT_ID, client_secret="test-only", issuer=issuer, authorization_endpoint=issuer + "/auth", token_endpoint=issuer + "/token", jwks_uri=issuer + "/keys", id_token_signing_alg_values_supported=["RS256"], client_kwargs={"scope": "openid email profile federated:id", "code_challenge_method": "S256"}) + request = RequestFactory().get("/members/login/", secure=True) + request.session = SessionStore() + nonce = secrets.token_urlsafe(32) + response = remote.authorize_redirect(request, "https://eom.dev/members/oidc/callback/", nonce=nonce) + auth_params = parse_qs(urlsplit(response.url).query) + self.assertEqual(auth_params["code_challenge_method"], ["S256"]) + self.assertTrue(auth_params["code_challenge"][0]) + callback = RequestFactory().get("/members/oidc/callback/", {"code": "test-code", "state": auth_params["state"][0]}, secure=True) + callback.session = request.session + claims = {"iss": issuer, "sub": "test-sub", "aud": settings.DEX_CLIENT_ID, "iat": int(time.time()), "exp": int(time.time()) + 300, "nonce": nonce, **changes} + token = {"id_token": jwt.encode({"alg": "RS256", "kid": "test-key"}, claims, self.key)} + return remote, callback, token + + def exchange(self, remote, callback, token): + with patch.object(remote, "fetch_access_token", return_value=token) as exchange, patch.object(remote, "fetch_jwk_set", return_value={"keys": [self.key.as_dict(private=False)]}): + result = remote.authorize_access_token(callback, claims_options={"iss": {"essential": True, "value": settings.DEX_ISSUER}}, leeway=0) + self.assertTrue(exchange.call_args.kwargs["code_verifier"]) + return result + + def test_real_pkce_nonce_signature_and_one_use_state(self): + remote, callback, token = self.flow() + result = self.exchange(remote, callback, token) + self.assertEqual(result["userinfo"]["sub"], "test-sub") + with self.assertRaises(Exception): + self.exchange(remote, callback, token) + + def test_wrong_nonce_issuer_audience_expiry_rejected(self): + for changes in ({"nonce": "attacker"}, {"iss": "https://evil.example"}, {"aud": "another-client"}, {"exp": int(time.time()) - 100}): + with self.subTest(changes=changes): + remote, callback, token = self.flow(**changes) + with self.assertRaises(Exception): + self.exchange(remote, callback, token) + + def test_wrong_state_never_exchanges_code(self): + remote, callback, token = self.flow() + callback.GET = callback.GET.copy() + callback.GET["state"] = "unknown-state" + with patch.object(remote, "fetch_access_token") as exchange: + with self.assertRaises(Exception): + remote.authorize_access_token(callback) + exchange.assert_not_called() + + def test_bad_signature_rejected(self): + remote, callback, token = self.flow() + other = RSAKey.generate_key(2048, parameters={"kid": "test-key"}) + claims = {"iss": settings.DEX_ISSUER, "sub": "attacker", "aud": settings.DEX_CLIENT_ID, "iat": int(time.time()), "exp": int(time.time()) + 300, "nonce": "attacker"} + token["id_token"] = jwt.encode({"alg": "RS256", "kid": "test-key"}, claims, other) + with self.assertRaises(Exception): + self.exchange(remote, callback, token) diff --git a/membership/members/tests/test_postgres.py b/membership/members/tests/test_postgres.py new file mode 100755 index 0000000..ddef297 --- /dev/null +++ b/membership/members/tests/test_postgres.py @@ -0,0 +1,55 @@ +import threading +from concurrent.futures import ThreadPoolExecutor +from unittest import skipUnless +from unittest.mock import patch + +from django.db import close_old_connections, connection, transaction +from django.test import TransactionTestCase, override_settings + +from members.billing import sync_account +from members.models import Account, ManagedGroup + + +@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks") +class PostgreSQLLockTests(TransactionTestCase): + def setUp(self): + self.account = Account.objects.create(uid="lock-test", email="lock@example.org", ldap_dn="uid=lock-test,dc=example,dc=org", ldap_uuid="lock-test-uuid") + + @override_settings(MEMBERSHIP_TIERS={}) + def test_checkout_account_lock_excludes_reconciliation(self): + attempted = threading.Event() + acquired = threading.Event() + + def worker(): + close_old_connections() + attempted.set() + try: + sync_account(self.account.pk) + finally: + close_old_connections() + + def refresh(member): + acquired.set() + + with patch("members.billing.refresh_billing", side_effect=refresh): + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + # Checkout uses this identical row lock before Stripe reads. + Account.objects.select_for_update().get(pk=self.account.pk) + future = pool.submit(worker) + self.assertTrue(attempted.wait(5)) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + # The worker cannot reach billing while Checkout owns lock. + self.assertFalse(acquired.is_set()) + future.result(timeout=10) + self.assertTrue(acquired.is_set()) + + @override_settings(MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}}) + def test_intent_commits_before_external_grant_failure(self): + with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")): + with self.assertRaises(OSError): + sync_account(self.account.pk) + # A later failed external side effect rolls back its own transaction, + # not the previously committed record of the managed group. + self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org") diff --git a/membership/members/urls.py b/membership/members/urls.py new file mode 100755 index 0000000..1b1bd38 --- /dev/null +++ b/membership/members/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "members" +urlpatterns = [ + path("", views.home, name="home"), + path("register/", views.register, name="register"), + path("verify//", views.verify, name="verify"), + path("register/finish/", views.finish_registration, name="finish_registration"), + path("login/", views.login, name="login"), + path("oidc/callback/", views.callback, name="callback"), + path("logout/", views.logout, name="logout"), + path("checkout//", views.checkout, name="checkout"), + path("billing/", views.portal, name="portal"), + path("stripe/webhook/", views.webhook, name="webhook"), +] diff --git a/membership/members/views.py b/membership/members/views.py new file mode 100755 index 0000000..54e6712 --- /dev/null +++ b/membership/members/views.py @@ -0,0 +1,289 @@ +import hashlib +import logging +import secrets +import uuid +from datetime import timedelta +from functools import wraps +from urllib.parse import urlsplit + +import stripe +from authlib.integrations.django_client import OAuth +from django.conf import settings +from django.core.mail import send_mail +from django.db import transaction +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.crypto import salted_hmac +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.debug import sensitive_post_parameters +from django.views.decorators.http import require_GET, require_POST, require_http_methods + +from . import billing, directory +from .forms import PasswordForm, RegistrationForm +from .models import Account, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent + +logger = logging.getLogger(__name__) + + +def page(request, **context): + return render(request, "members.html", context) + + +def limited(scope, value, limit, seconds=3600): + now = timezone.now() + window = int(now.timestamp()) // seconds + key = salted_hmac("membership-rate", f"{scope}:{value}:{window}").hexdigest() + with transaction.atomic(): + entry, _ = RateWindow.objects.get_or_create(key=key, defaults={"expires_at": now + timedelta(seconds=seconds)}) + entry = RateWindow.objects.select_for_update().get(pk=entry.pk) + entry.count += 1 + entry.save(update_fields=["count"]) + return entry.count > limit + + +def client_ip(request): + if getattr(settings, "SECURE_PROXY_SSL_HEADER", None): + return request.META.get("HTTP_X_MEMBERSHIP_CLIENT_IP", request.META.get("REMOTE_ADDR", "")) + return request.META.get("REMOTE_ADDR", "") + + +def account_required(view): + @wraps(view) + def wrapped(request, *args, **kwargs): + try: + request.member = Account.objects.get(pk=request.session.get("account_id")) + except (Account.DoesNotExist, ValueError, TypeError): + return redirect("members:home") + return view(request, *args, **kwargs) + return wrapped + + +def remember_groups(account): + for tier in settings.MEMBERSHIP_TIERS.values(): + ManagedGroup.objects.get_or_create(account=account, dn=tier["group_dn"]) + + +@require_GET +def home(request): + account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None + subscriptions = Subscription.objects.filter(account=account) if account else [] + return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, now=timezone.now()) + + +@require_http_methods(["GET", "POST"]) +def register(request): + form = RegistrationForm(request.POST or None) + if request.method == "POST" and form.is_valid(): + email = form.cleaned_data["email"] + if limited("signup-ip", client_ip(request), 20) or limited("signup-email", email, 3): + return HttpResponse("Please try again later.", status=429) + token = secrets.token_urlsafe(32) + digest = hashlib.sha256(token.encode()).hexdigest() + Registration.objects.create(token_hash=digest, uid=form.cleaned_data["uid"], email=email, expires_at=timezone.now() + timedelta(hours=1)) + url = settings.PUBLIC_URL + reverse("members:verify", args=[token]) + try: + send_mail("Verify your eom.dev account", f"Finish creating your account (expires in one hour):\n\n{url}\n\nIf you did not request this, ignore this message. Existing accounts are never changed by this link.", settings.DEFAULT_FROM_EMAIL, [email]) + except Exception as exc: + Registration.objects.filter(pk=digest).delete() + logger.error("Registration email failed kind=%s", type(exc).__name__) + return page(request, title="Email unavailable", message="Account email could not be sent. Please try again later.") + return page(request, title="Check your inbox", message="Use the verification link to choose a password and create your free account. If you already have an account, sign in through Dex instead.") + return page(request, title="Create your free account", form=form, submit="Send verification email", registration=True) + + +@sensitive_post_parameters("password", "confirm_password") +@require_http_methods(["GET", "POST"]) +def verify(request, token): + if limited("verify-ip", client_ip(request), 60): + return HttpResponse("Please try again later.", status=429) + digest = hashlib.sha256(token.encode()).hexdigest() + # Exchange the emailed bearer token for server-side session state, then + # redirect so passwords are never posted to a URL containing that token. + if request.method == "GET": + if not Registration.objects.filter(pk=digest, expires_at__gt=timezone.now()).exists(): + return page(request, title="Link expired", message="Request a new verification email.") + request.session["registration_hash"] = digest + return redirect("members:finish_registration") + return HttpResponse(status=405) + + +@sensitive_post_parameters("password", "confirm_password") +@require_http_methods(["GET", "POST"]) +def finish_registration(request): + digest = request.session.get("registration_hash") + if not digest or not Registration.objects.filter(pk=digest, expires_at__gt=timezone.now()).exists(): + return page(request, title="Link expired", message="Request a new verification email.") + form = PasswordForm(request.POST or None) + if request.method == "POST" and form.is_valid(): + if limited("provision-ip", client_ip(request), 10): + return HttpResponse("Please try again later.", status=429) + try: + with transaction.atomic(): + registration = Registration.objects.select_for_update().get(pk=digest, expires_at__gt=timezone.now()) + identity = directory.create_account(registration.uid, registration.email, form.cleaned_data["password"]) + account = Account.objects.create(**identity) + remember_groups(account) + Registration.objects.filter(uid=registration.uid).delete() + request.session.pop("registration_hash", None) + return page(request, title="Account created", message="Your free account is now in OpenLDAP. Sign in through Dex to manage membership.") + except ValueError: + form.add_error(None, "An account with those details may already exist. Sign in through Dex or contact the administrator.") + except Exception as exc: + logger.error("Account provisioning failed kind=%s", type(exc).__name__) + form.add_error(None, "Account creation could not finish. Try Dex sign-in first, or contact the administrator before retrying.") + return page(request, title="Choose your password", form=form, submit="Create free account") + + +def dex(): + oauth = OAuth() + remote = oauth.register("dex", client_id=settings.DEX_CLIENT_ID, client_secret=settings.DEX_CLIENT_SECRET, server_metadata_url=settings.DEX_ISSUER + "/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile groups federated:id", "code_challenge_method": "S256", "timeout": 10}) + metadata = remote.load_server_metadata() + if metadata.get("issuer") != settings.DEX_ISSUER: + raise ValueError("Dex discovery issuer mismatch.") + metadata["id_token_signing_alg_values_supported"] = ["RS256"] + for endpoint in ("authorization_endpoint", "token_endpoint", "jwks_uri"): + url = urlsplit(metadata.get(endpoint, "")) + if url.scheme != "https" or url.netloc != urlsplit(settings.DEX_ISSUER).netloc or url.username: + raise ValueError("Dex endpoints must be HTTPS URLs on the configured issuer origin.") + return remote + + +@require_POST +def login(request): + if limited("login-ip", client_ip(request), 60): + return HttpResponse(status=429) + try: + return dex().authorize_redirect(request, settings.PUBLIC_URL + reverse("members:callback"), nonce=secrets.token_urlsafe(32), connector_id=settings.DEX_CONNECTOR_ID) + except Exception as exc: + logger.error("Dex login unavailable kind=%s", type(exc).__name__) + return page(request, title="Sign-in unavailable", message="Please try again later.") + + +def link_identity(claims): + if claims.get("iss") != settings.DEX_ISSUER or claims.get("email_verified") is not True or not isinstance(claims.get("sub"), str) or not claims["sub"] or len(claims["sub"]) > 512: + raise ValueError("A verified Dex identity is required.") + identity = directory.identity_for_login(claims.get("preferred_username")) + federated = claims.get("federated_claims") or {} + if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["ldap_uuid"]: + raise ValueError("Dex must authenticate the configured LDAP connector with entryUUID as idAttr.") + if identity["email"].casefold() != str(claims.get("email", "")).casefold(): + raise ValueError("Dex and LDAP email disagree.") + # Link using the signed LDAP uid plus immutable entryUUID, NEVER by email. + with transaction.atomic(): + account, _ = Account.objects.get_or_create(ldap_uuid=identity["ldap_uuid"], defaults=identity) + account = Account.objects.select_for_update().get(pk=account.pk) + if account.oidc_sub and account.oidc_sub != claims["sub"]: + raise ValueError("Identity is already linked to another Dex subject.") + account.oidc_sub = claims["sub"] + account.email = identity["email"] + account.ldap_dn = identity["ldap_dn"] + account.save(update_fields=["oidc_sub", "email", "ldap_dn"]) + remember_groups(account) + return account + + +@require_GET +def callback(request): + try: + token = dex().authorize_access_token(request, claims_options={"iss": {"essential": True, "value": settings.DEX_ISSUER}}, leeway=30) + if not token.get("id_token"): + raise ValueError("ID token is required.") + account = link_identity(token.get("userinfo") or {}) + except Exception as exc: + logger.error("Dex callback rejected kind=%s", type(exc).__name__) + return page(request, title="Sign-in could not be verified", message="Try signing in again, or contact the administrator.") + # Do not persist access/refresh/ID tokens or stale group claims in sessions. + request.session.flush() + request.session["account_id"] = str(account.pk) + return redirect("members:home") + + +@require_POST +def logout(request): + request.session.flush() + return redirect("members:home") + + +def stripe_redirect(url, host): + parsed = urlsplit(url or "") + if parsed.scheme != "https" or parsed.hostname != host or parsed.username: + raise ValueError("Unexpected Stripe redirect host.") + return redirect(url) + + +@require_POST +@account_required +def checkout(request, tier): + config = settings.MEMBERSHIP_TIERS.get(tier) + if not config: + return HttpResponse(status=404) + if limited("checkout", str(request.member.pk), 20): + return HttpResponse(status=429) + try: + with transaction.atomic(): + account = Account.objects.select_for_update().get(pk=request.member.pk) + api = billing.client() + if not account.stripe_customer: + customer = api.customers.create({"email": account.email, "metadata": {"membership_account": str(account.pk)}}, options={"idempotency_key": f"member-customer-{account.pk}"}) + account.stripe_customer = customer["id"] + account.save(update_fields=["stripe_customer"]) + # Canonical check prevents duplicate subscriptions even before sync. + existing = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100}) + if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in existing.auto_paging_iter()): + return page(request, title="You already have a subscription", message="Use Manage billing to change your plan or payment details.") + if account.checkout_id: + session = api.checkout.sessions.retrieve(account.checkout_id) + if session.get("status") == "open": + return stripe_redirect(session["url"], "checkout.stripe.com") + account.checkout_key = uuid.uuid4() + session = api.checkout.sessions.create({ + "mode": "subscription", "customer": account.stripe_customer, + "line_items": [{"price": config["price_id"], "quantity": 1}], + "client_reference_id": str(account.pk), + "subscription_data": {"metadata": {"membership_account": str(account.pk)}}, + "success_url": settings.PUBLIC_URL + reverse("members:home") + "?checkout=complete", + "cancel_url": settings.PUBLIC_URL + reverse("members:home"), + }, options={"idempotency_key": f"member-checkout-{account.pk}-{account.checkout_key}"}) + account.checkout_id = session["id"] + account.save(update_fields=["checkout_id", "checkout_key"]) + return stripe_redirect(session["url"], "checkout.stripe.com") + except Exception as exc: + logger.error("Checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__) + return page(request, title="Checkout unavailable", message="Please try again later. Membership is only granted after Stripe confirms payment.") + + +@require_POST +@account_required +def portal(request): + if not request.member.stripe_customer or not settings.STRIPE_PORTAL_CONFIGURATION: + return page(request, title="Billing unavailable", message="No billing account or billing portal configuration is available.") + if limited("portal", str(request.member.pk), 30): + return HttpResponse(status=429) + try: + session = billing.client().billing_portal.sessions.create({"customer": request.member.stripe_customer, "configuration": settings.STRIPE_PORTAL_CONFIGURATION, "return_url": settings.PUBLIC_URL + reverse("members:home")}) + return stripe_redirect(session["url"], "billing.stripe.com") + except Exception as exc: + logger.error("Billing portal failed kind=%s", type(exc).__name__) + return page(request, title="Billing unavailable", message="Please try again later.") + + +@csrf_exempt +@require_POST +def webhook(request): + if not settings.STRIPE_WEBHOOK_SECRET: + return HttpResponse(status=503) + try: + event = stripe.Webhook.construct_event(request.body, request.headers.get("Stripe-Signature", ""), settings.STRIPE_WEBHOOK_SECRET, tolerance=300) + if event.get("livemode") != settings.STRIPE_LIVE_MODE or event.get("account"): + return HttpResponse(status=400) + obj = event["data"]["object"] + customer = billing._id(obj.get("customer")) + account = Account.objects.filter(stripe_customer=customer).first() if customer else None + # Store no raw payload/PII. The worker rereads canonical Stripe state; + # duplicate, delayed and reordered events can never restore expired time. + WebhookEvent.objects.get_or_create(stripe_id=event["id"], defaults={"account": account, "processed_at": None if account else timezone.now()}) + except (ValueError, KeyError, TypeError, stripe.SignatureVerificationError): + return HttpResponse(status=400) + return HttpResponse(status=200) diff --git a/membership/requirements.txt b/membership/requirements.txt new file mode 100755 index 0000000..415afd1 --- /dev/null +++ b/membership/requirements.txt @@ -0,0 +1,7 @@ +Django==5.2.18 +Authlib==1.9.1 +requests>=2.32.4,<3 +ldap3==2.9.1 +stripe==14.4.1 +psycopg[binary]>=3.2,<4 +gunicorn>=23,<24 diff --git a/membership/templates/members.html b/membership/templates/members.html new file mode 100755 index 0000000..b821322 --- /dev/null +++ b/membership/templates/members.html @@ -0,0 +1,88 @@ + + + + + + + {% if title %}{{ title }}{% else %}Membership{% endif %} · eom.dev + + + + + +
    + eom.dev + +
    +
    +{% if title %} +
    +

    One account. An independent community.

    +

    {{ title }}

    + {% if message %}

    {{ message }}

    {% endif %} + {% if registration %}

    Everyone can join. Verify your email, set a password in OpenLDAP, then sign in with Dex. A paid membership is optional.

    {% endif %} + {% if form %} +
    + {% csrf_token %} + {{ form.as_p }} + +
    + {% endif %} +

    Back to membership

    +
    {% csrf_token %}
    +
    +{% else %} +
    +

    Keep your own corner of the internet alive

    +

    A place to belong.
    A way to give back.

    +

    Start with a free eom.dev account. Choose an optional membership to support the servers, experiments, and independent work behind the community.

    + {% if account %} + + {% if request.GET.checkout %}

    Thanks for supporting the work. Stripe is confirming your subscription. Access appears after payment is verified and the membership worker has synchronized it.

    {% endif %} + {% for subscription in subscriptions %} +

    {{ subscription.tier|default:"Membership"|title }} · {{ subscription.status }}{% if subscription.paid_until > now %} · Paid access through {{ subscription.paid_until|date:"M j, Y H:i T" }}{% else %} · No current paid access{% endif %}{% if subscription.cancel_at_period_end %} · Does not renew{% endif %}

    + {% endfor %} +

    Billing and directory status update automatically, usually within a few minutes. Signing out here does not sign you out of other Dex applications.

    + {% else %} +
    + Create a free account +
    {% csrf_token %}
    +
    + {% endif %} +
    +
    +

    Your support, your choice

    Find your membership

    Your account stays free. Paid group access lasts only for the service period covered by your subscription.

    +
    +
    +

    The starting point

    Community

    Free / always

    +

    An account for the eom.dev community, stored in OpenLDAP and signed into through Dex.

    +
    • No payment required
    • One account for compatible community services
    • Upgrade whenever you choose
    + {% if not account %}Join the community{% else %}

    Your free account is ready.

    {% endif %} +
    + {% for key, tier in tiers.items %} +
    +

    Independent work, supported together

    {{ tier.label }}

    {{ tier.price_label }}

    +

    {{ tier.description|default:"Help keep the projects and community running." }}

    +
    • Tier-specific community access
    • Secure checkout hosted by Stripe
    • Manage renewal from your billing portal
    + {% if account %}
    {% csrf_token %}
    {% else %}Create an account to join{% endif %} +
    + {% empty %}

    Paid memberships are coming soon.

    You can create your free account now. Paid tiers will appear when their prices and groups are configured.

    {% endfor %} +
    +
    +
    +

    Simple, by design.

    +
    What happens when a membership ends?

    Your LDAP account stays. Paid group access is removed automatically when the paid service period expires. An immediate cancellation removes access on the next successful sync.

    +
    Where is my password stored?

    Only OpenLDAP stores your password hash. This website sends your initial password over verified TLS to OpenLDAP's password-modify operation. Future sign-ins happen through Dex.

    +
    Can I change or cancel a plan?

    Use Manage billing after signing in. The billing portal offers the changes enabled for your plan. Cancellation at period end keeps access through the paid expiry date.

    +
    +{% endif %} +
    + + + diff --git a/membership/tiers.eom.json b/membership/tiers.eom.json new file mode 100755 index 0000000..bb28ece --- /dev/null +++ b/membership/tiers.eom.json @@ -0,0 +1,37 @@ +{ + "level-0": { + "label": "Level 0", + "price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn", + "price_label": "$4 USD / month", + "group_dn": "cn=www-level-0,ou=groups,dc=eom,dc=dev", + "description": "Support the independent work with a Level 0 membership." + }, + "level-1": { + "label": "Level 1", + "price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5", + "price_label": "$8 USD / month", + "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev", + "description": "Support the independent work with a Level 1 membership." + }, + "level-2": { + "label": "Level 2", + "price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf", + "price_label": "$16 USD / month", + "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev", + "description": "Support the independent work with a Level 2 membership." + }, + "level-3": { + "label": "Level 3", + "price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm", + "price_label": "$32 USD / month", + "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev", + "description": "Support the independent work with a Level 3 membership." + }, + "level-4": { + "label": "Level 4", + "price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa", + "price_label": "$64 USD / month", + "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev", + "description": "Support the independent work with a Level 4 membership." + } +}