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
+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)