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
+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."
}
}