Files
www/membership/members/views.py
T
2026-10-23 12:02:50 -04:00

290 lines
15 KiB
Python
Executable File

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)