Files
www/membership/members/views.py
T
2026-10-23 17:23:29 -04:00

353 lines
20 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 get_object_or_404, redirect, render
from django.db.models import Q
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, shkeeper
from .forms import PasswordForm, RegistrationForm
from .models import Account, CryptoInvoice, 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 []
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, 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="Follow the confirmation link in your email to choose a password and finish creating your free eom.dev account. If you already have an account, sign in 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 eom.dev account is ready. Sign in to explore the community and its services, or choose an optional paid membership for additional benefits.")
except ValueError:
form.add_error(None, "An account with those details may already exist. Try signing in or contact us for help.")
except Exception as exc:
logger.error("Account provisioning failed kind=%s", type(exc).__name__)
form.add_error(None, "Account creation could not finish. Try signing in first, or contact us for help 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.rstrip("/") + "/.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)
if CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=timezone.now())).exists():
return page(request, title="Prepaid membership already exists", message="Finish your SHKeeper invoice or wait for prepaid membership to expire before starting a recurring Stripe subscription.")
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)
@require_http_methods(["GET", "POST"])
@account_required
def crypto_checkout(request, tier):
config = settings.MEMBERSHIP_TIERS.get(tier)
if not settings.SHKEEPER_ENABLED or not config or not config.get("usd_amount"):
return HttpResponse(status=404)
if request.method == "GET":
return render(request, "crypto_checkout.html", {"tier": config, "tier_key": tier, "currencies": settings.SHKEEPER_CURRENCIES})
crypto = request.POST.get("crypto", "")
if crypto not in settings.SHKEEPER_CURRENCIES:
return HttpResponse("Choose an available cryptocurrency.", status=400)
if limited("crypto-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)
invoice = CryptoInvoice.objects.filter(account=account, credited_at__isnull=True).first()
if invoice:
# Reuse the existing asset/tier even if a stale tab asks for a
# different one. Never orphan an address a user may have paid.
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
if account.stripe_customer:
api = billing.client()
subs = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100})
if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in subs.auto_paging_iter()):
return page(request, title="Recurring membership already exists", message="Use Stripe billing to cancel renewal and wait until that subscription ends before paying through SHKeeper. This avoids double payments.")
if account.checkout_id:
checkout_session = api.checkout.sessions.retrieve(account.checkout_id)
if checkout_session.get("status") == "open":
return page(request, title="Stripe checkout is still open", message="Finish or let the existing Stripe Checkout expire before changing payment providers.")
if CryptoInvoice.objects.filter(account=account, access_end__gt=timezone.now()).exclude(tier=tier).exists():
return page(request, title="Another prepaid tier is active", message="Renew the same tier, or wait for the current prepaid membership to expire before changing tiers.")
invoice = CryptoInvoice.objects.create(account=account, tier=tier, crypto=crypto, usd_amount=config["usd_amount"])
remember_groups(account)
# Reserve/commit UUID BEFORE API call, so retries and worker recovery
# use the same external ID if the process dies after invoice creation.
shkeeper.prepare_invoice(account.pk, invoice.pk)
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
except Exception as exc:
logger.error("SHKeeper checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__)
return page(request, title="Crypto checkout unavailable", message="Please try again later. If an invoice was reserved, it remains on your membership page and will be retried automatically. Do not send funds until payment details appear.")
@require_GET
@account_required
def crypto_invoice(request, invoice_id):
invoice = get_object_or_404(CryptoInvoice, pk=invoice_id, account=request.member)
return render(request, "crypto_invoice.html", {"invoice": invoice, "now": timezone.now()})
@csrf_exempt
@require_POST
def shkeeper_callback(request):
# SHKeeper documents no callback signature. Treat ALL incoming payloads as
# untrusted hints: do not parse them, write billing data, trigger network
# requests or grant access. The durable worker polls known invoice UUIDs.
return HttpResponse(status=200 if settings.SHKEEPER_ENABLED else 503)