Memberships

This commit is contained in:
2026-10-23 12:02:50 -04:00
parent 87dd5453e4
commit 6cf0f0525f
42 changed files with 2145 additions and 7 deletions
Executable
+45
View File
@@ -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."}}
+7
View File
@@ -2,3 +2,10 @@ htdocs/archive/*
*.swp
*.swo
authorized_keys
.env
.env.*
!.env.example
__pycache__/
*.pyc
*.sqlite3
.venv/
Vendored Executable
+9
View File
@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:system",
"packageManager": "ms-python.python:pip"
}
]
}
Vendored Executable
+22
View File
@@ -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"
}
]
}
+187
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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">
+38
View File
@@ -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%; } }
+5
View File
@@ -0,0 +1,5 @@
__pycache__
*.pyc
*.sqlite3
.env*
.venv
+10
View File
@@ -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", "-"]
+24
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Membership service Django configuration."""
+14
View File
@@ -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",
}}
+113
View File
@@ -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"])
+16
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from django.urls import include, path
urlpatterns = [path("members/", include("members.urls"))]
+6
View File
@@ -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
View File
@@ -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
+37
View File
@@ -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
+9
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Public accounts and paid directory membership."""
+133
View File
@@ -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
+109
View File
@@ -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
+26
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Membership operations."""
+1
View File
@@ -0,0 +1 @@
"""Management command package."""
+53
View File
@@ -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"])
+10
View File
@@ -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
View File
@@ -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")),
]),
]
+1
View File
@@ -0,0 +1 @@
"""Database schema migrations."""
+57
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Offline membership regression tests."""
+464
View File
@@ -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()
+73
View File
@@ -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)
+55
View File
@@ -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")
+17
View File
@@ -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"),
]
+289
View File
@@ -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)
+7
View File
@@ -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
+88
View File
@@ -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>
+37
View File
@@ -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."
}
}