Memberships
This commit is contained in:
Executable
+45
@@ -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."}}
|
||||
@@ -2,3 +2,10 @@ htdocs/archive/*
|
||||
*.swp
|
||||
*.swo
|
||||
authorized_keys
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.sqlite3
|
||||
.venv/
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
"envManager": "ms-python.python:system",
|
||||
"packageManager": "ms-python.python:pip"
|
||||
}
|
||||
]
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
+18
-3
@@ -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/"
|
||||
<Location "/members/">
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}"
|
||||
LimitRequestBody 262144
|
||||
</Location>
|
||||
|
||||
#
|
||||
# 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-<VirtualHost> 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
|
||||
|
||||
+65
-1
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="#projects">Projects</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="https://discourse.eom.dev/latest">Community</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="#letters">Letters</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link nav-support" href="#support">Support</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link nav-support" href="/members/">Membership</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="#start-here">Explore the projects</a>
|
||||
<a class="button button-secondary" href="https://discourse.eom.dev/latest">Join the conversation</a>
|
||||
<a class="button button-secondary" href="https://lists.eom.dev/accounts/register/">Create an eom.dev account</a>
|
||||
<a class="button button-secondary" href="/members/register/">Create an eom.dev account</a>
|
||||
</div>
|
||||
<p class="hero-note">Independent software, open-source projects, music, and games—made in Raleigh, North Carolina.</p>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@
|
||||
<p>Contributions help cover the servers, storage, and time behind these projects. If you need hands-on help, you can also book a consultation.</p>
|
||||
</div>
|
||||
<div class="support-actions">
|
||||
<a class="button button-primary" href="https://discourse.eom.dev/s/prod_Sc9N5J0vNAdLCn">Become a monthly supporter</a>
|
||||
<a class="button button-primary" href="/members/">Explore memberships</a>
|
||||
<a class="button button-secondary" href="https://nextcloud.eom.dev/apps/appointments/pub/McQqQYl49cD5ZzZ9/form">Book a free introductory call</a>
|
||||
<a class="quiet-link" href="https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00">Make a one-time contribution</a>
|
||||
<details class="crypto-support">
|
||||
|
||||
Executable
+38
@@ -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%; } }
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.sqlite3
|
||||
.env*
|
||||
.venv
|
||||
Executable
+10
@@ -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", "-"]
|
||||
Executable
+24
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Membership service Django configuration."""
|
||||
Executable
+14
@@ -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",
|
||||
}}
|
||||
Executable
+113
@@ -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"])
|
||||
Executable
+16
@@ -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
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [path("members/", include("members.urls"))]
|
||||
Executable
+6
@@ -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()
|
||||
+35
@@ -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
|
||||
Executable
+37
@@ -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
|
||||
Executable
+9
@@ -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)
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Public accounts and paid directory membership."""
|
||||
Executable
+133
@@ -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
|
||||
Executable
+109
@@ -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
|
||||
Executable
+26
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Membership operations."""
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Management command package."""
|
||||
@@ -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"])
|
||||
Executable
+10
@@ -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
|
||||
+55
@@ -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")),
|
||||
]),
|
||||
]
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Database schema migrations."""
|
||||
Executable
+57
@@ -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)
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Offline membership regression tests."""
|
||||
Executable
+464
@@ -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()
|
||||
Executable
+73
@@ -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)
|
||||
Executable
+55
@@ -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")
|
||||
Executable
+17
@@ -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/<str:token>/", 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/<slug:tier>/", views.checkout, name="checkout"),
|
||||
path("billing/", views.portal, name="portal"),
|
||||
path("stripe/webhook/", views.webhook, name="webhook"),
|
||||
]
|
||||
Executable
+289
@@ -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)
|
||||
Executable
+7
@@ -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
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{% if title %}{{ title }}{% else %}Membership{% endif %} · eom.dev</title>
|
||||
<link rel="stylesheet" href="/static/home.css">
|
||||
<link rel="stylesheet" href="/static/membership.css">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
<body>
|
||||
<header class="member-nav">
|
||||
<a class="brand" href="/">eom.dev</a>
|
||||
<nav aria-label="Membership navigation"><a href="/">Back to the lab</a><a href="{% url 'members:home' %}">Membership</a></nav>
|
||||
</header>
|
||||
<main id="main-content" class="member-main">
|
||||
{% if title %}
|
||||
<section class="member-panel form-panel">
|
||||
<p class="eyebrow">One account. An independent community.</p>
|
||||
<h1>{{ title }}</h1>
|
||||
{% if message %}<p>{{ message }}</p>{% endif %}
|
||||
{% if registration %}<p>Everyone can join. Verify your email, set a password in OpenLDAP, then sign in with Dex. A paid membership is optional.</p>{% endif %}
|
||||
{% if form %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button class="button button-primary" type="submit">{{ submit }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<p><a href="{% url 'members:home' %}">Back to membership</a></p>
|
||||
<form action="{% url 'members:login' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign in with Dex</button></form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="member-hero">
|
||||
<p class="eyebrow">Keep your own corner of the internet alive</p>
|
||||
<h1>A place to belong.<br>A way to give back.</h1>
|
||||
<p class="member-intro">Start with a free eom.dev account. Choose an optional membership to support the servers, experiments, and independent work behind the community.</p>
|
||||
{% if account %}
|
||||
<div class="member-panel account-panel">
|
||||
<div><p class="eyebrow">Signed in through Dex</p><h2>{{ account.uid }}</h2><p>{{ account.email }}</p></div>
|
||||
<div class="member-actions">
|
||||
{% if account.stripe_customer %}<form action="{% url 'members:portal' %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Manage billing</button></form>{% endif %}
|
||||
<form action="{% url 'members:logout' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% if request.GET.checkout %}<p role="status" class="member-notice">Thanks for supporting the work. Stripe is confirming your subscription. Access appears after payment is verified and the membership worker has synchronized it.</p>{% endif %}
|
||||
{% for subscription in subscriptions %}
|
||||
<p class="member-notice">{{ 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 %}</p>
|
||||
{% endfor %}
|
||||
<p class="member-small">Billing and directory status update automatically, usually within a few minutes. Signing out here does not sign you out of other Dex applications.</p>
|
||||
{% else %}
|
||||
<div class="member-actions">
|
||||
<a class="button button-primary" href="{% url 'members:register' %}">Create a free account</a>
|
||||
<form action="{% url 'members:login' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign in with Dex</button></form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<section aria-labelledby="tiers-heading">
|
||||
<div class="section-heading"><p class="eyebrow">Your support, your choice</p><h2 id="tiers-heading">Find your membership</h2><p>Your account stays free. Paid group access lasts only for the service period covered by your subscription.</p></div>
|
||||
<div class="member-grid">
|
||||
<article class="member-panel tier-card free-tier">
|
||||
<p class="card-kicker">The starting point</p><h3>Community</h3><p class="tier-price">Free <span>/ always</span></p>
|
||||
<p>An account for the eom.dev community, stored in OpenLDAP and signed into through Dex.</p>
|
||||
<ul><li>No payment required</li><li>One account for compatible community services</li><li>Upgrade whenever you choose</li></ul>
|
||||
{% if not account %}<a class="button button-secondary" href="{% url 'members:register' %}">Join the community</a>{% else %}<p class="tier-footnote">Your free account is ready.</p>{% endif %}
|
||||
</article>
|
||||
{% for key, tier in tiers.items %}
|
||||
<article class="member-panel tier-card">
|
||||
<p class="card-kicker">Independent work, supported together</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
|
||||
<p>{{ tier.description|default:"Help keep the projects and community running." }}</p>
|
||||
<ul><li>Tier-specific community access</li><li>Secure checkout hosted by Stripe</li><li>Manage renewal from your billing portal</li></ul>
|
||||
{% if account %}<form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Choose {{ tier.label }}</button></form>{% else %}<a class="button button-primary" href="{% url 'members:register' %}">Create an account to join</a>{% endif %}
|
||||
</article>
|
||||
{% empty %}<div class="member-panel"><h3>Paid memberships are coming soon.</h3><p>You can create your free account now. Paid tiers will appear when their prices and groups are configured.</p></div>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="member-faq" aria-labelledby="membership-faq">
|
||||
<h2 id="membership-faq">Simple, by design.</h2>
|
||||
<details><summary>What happens when a membership ends?</summary><p>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.</p></details>
|
||||
<details><summary>Where is my password stored?</summary><p>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.</p></details>
|
||||
<details><summary>Can I change or cancel a plan?</summary><p>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.</p></details>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer class="member-footer">Made independently. Supported by the community. <a href="mailto:eric@eom.dev">Questions? Get in touch.</a></footer>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+37
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user