353 lines
20 KiB
Python
Executable File
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="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)
|
|
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)
|