import hashlib import logging import re 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.validators import validate_email 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) # Server-side, short-lived proof permits first binding of ONLY the # account just provisioned in this browser, never an email match. request.session["registration_identity"] = {"account_id": str(account.pk), "expires_at": (timezone.now() + timedelta(hours=1)).timestamp()} 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, *, registration_account_id=None): 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.") uid, email = claims.get("preferred_username"), claims.get("email") 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 uid as preferred_username.") if not isinstance(email, str) or len(email) > 254: raise ValueError("A verified Dex email is required.") validate_email(email) federated = claims.get("federated_claims") or {} if not isinstance(federated, dict) or federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != uid: raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.") # Authentication is entirely OIDC. Never contact LDAP or silently rebind an # existing account to a different subject based on username/email matches. with transaction.atomic(): account = Account.objects.select_for_update().filter(oidc_issuer=claims["iss"], oidc_sub=claims["sub"]).first() if account: if account.uid != uid: raise ValueError("Dex identity username changed; an explicit migration is required.") else: account, created = Account.objects.select_for_update().get_or_create(uid=uid, defaults={"email": email, "oidc_issuer": claims["iss"], "oidc_sub": claims["sub"]}) if not created: if account.oidc_sub: if account.oidc_issuer != claims["iss"] or account.oidc_sub != claims["sub"]: raise ValueError("Existing account requires an explicit Dex identity mapping.") elif str(account.pk) != registration_account_id: raise ValueError("Existing account requires an explicit Dex identity mapping.") account.oidc_issuer = claims["iss"] account.oidc_sub = claims["sub"] account.email = email account.save(update_fields=["oidc_issuer", "oidc_sub", "email"]) 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.") proof = request.session.get("registration_identity") or {} expires_at = proof.get("expires_at") if isinstance(proof, dict) else None registration_account_id = proof.get("account_id") if isinstance(expires_at, (int, float)) and expires_at > timezone.now().timestamp() else None account = link_identity(token.get("userinfo") or {}, registration_account_id=registration_account_id) 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, "tiers": settings.MEMBERSHIP_TIERS, "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)