524 lines
32 KiB
Python
Executable File
524 lines
32 KiB
Python
Executable File
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.files.uploadedfile import SimpleUploadedFile
|
|
from django.core.mail import send_mail
|
|
from django.db import ProgrammingError, 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, profiles, shkeeper
|
|
from .forms import PasswordForm, RegistrationForm
|
|
from .service_groups import configured_groups, service_access_catalog
|
|
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
|
|
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, 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 dn in configured_groups(settings.MEMBERSHIP_TIERS, settings.MEMBERSHIP_SERVICE_GROUPS):
|
|
ManagedGroup.objects.get_or_create(account=account, dn=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 []
|
|
# Presentation copies only: never mutate authorization/payment settings.
|
|
tiers = settings.MEMBERSHIP_TIERS
|
|
services = service_access_catalog(tiers, settings.MEMBERSHIP_SERVICE_GROUPS)
|
|
return page(request, account=account, tiers=tiers, services=services, 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 ProgrammingError as exc:
|
|
# PostgreSQL diagnostics and SQL text can contain identity data. Log
|
|
# only a validated SQLSTATE, never the exception message or traceback.
|
|
sqlstate = getattr(exc.__cause__, "sqlstate", None)
|
|
sqlstate = sqlstate if isinstance(sqlstate, str) and re.fullmatch(r"[0-9A-Z]{5}", sqlstate) else "unknown"
|
|
hint = {
|
|
"42703": "undefined_column: check pending migrations, especially members.0004_dex_login_identity, against the web database",
|
|
"42P01": "undefined_table: check migrations and the configured database/schema",
|
|
"42501": "insufficient_privilege: check database role permissions",
|
|
}.get(sqlstate, "check database schema, permissions and matching application/migration images")
|
|
logger.error("Membership database failure during Dex callback kind=ProgrammingError sqlstate=%s; %s", sqlstate, hint)
|
|
return page(request, title="Sign-in could not be verified", message="Try signing in again, or contact the administrator.")
|
|
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 _profile_context(account, values):
|
|
return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "has_photo": bool(values["jpegPhoto"]), "email_values": values["mail"], "revision": profiles.revision(account, values)}
|
|
|
|
|
|
@sensitive_post_parameters()
|
|
@require_http_methods(["GET", "POST"])
|
|
@account_required
|
|
def profile(request):
|
|
member = request.member
|
|
# Bound before any password verification; never log posted values or LDAP
|
|
# diagnostic messages. Account and IP limits protect directory binds.
|
|
if limited("profile-read", str(member.pk), 120):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
try:
|
|
values = profiles.read_profile(member)
|
|
except Exception as exc:
|
|
logger.error("Profile read failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
return render(request, "profile.html", {"account": member, "profile_loaded": False}, status=503)
|
|
context = _profile_context(member, values)
|
|
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1")
|
|
if request.method == "POST":
|
|
if limited("profile-write", str(member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
action = request.POST.get("action")
|
|
forms = {"profile": ("form", ProfileForm), "email": ("email_form", ProfileEmailForm), "password": ("password_form", ProfilePasswordForm)}
|
|
if action not in forms:
|
|
return HttpResponse(status=400)
|
|
form_key, form_class = forms[action]
|
|
form = form_class(request.POST, request.FILES) if action == "profile" else form_class(request.POST)
|
|
context[form_key] = form
|
|
if form.is_valid():
|
|
try:
|
|
# Serialize directory pinning and edits against the worker.
|
|
with transaction.atomic():
|
|
member = Account.objects.select_for_update().get(pk=member.pk)
|
|
if action == "profile":
|
|
profiles.update_profile(member, form.cleaned_data, request.POST.get("revision", ""))
|
|
elif action == "password":
|
|
profiles.change_password(member, form.cleaned_data["current_password"], form.cleaned_data["password"])
|
|
else:
|
|
profiles.verify_password(member, form.cleaned_data["current_password"])
|
|
token = secrets.token_urlsafe(32)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
ProfileEmailChange.objects.filter(account=member).delete()
|
|
ProfileEmailChange.objects.create(token_hash=digest, account=member, email=form.cleaned_data["email"], ldap_uuid=member.ldap_uuid, expires_at=timezone.now() + timedelta(hours=1))
|
|
url = settings.PUBLIC_URL + reverse("members:verify_profile_email", args=[token])
|
|
send_mail("Verify your new eom.dev email", f"Confirm this email address for your account (expires in one hour):\n\n{url}\n\nYou must sign in to the same account and enter your current password to confirm. If you did not request this, ignore this message.", settings.DEFAULT_FROM_EMAIL, [form.cleaned_data["email"]])
|
|
logger.info("Profile change completed account=%s action=%s", member.pk, action)
|
|
if action == "password":
|
|
request.session.flush()
|
|
return page(request, title="Password changed", message="Your password was changed. Sign in again. Existing Dex and other service sessions are not automatically signed out.")
|
|
return redirect(reverse("members:profile") + ("?email_sent=1" if action == "email" else "?saved=1"))
|
|
except Exception as exc:
|
|
logger.error("Profile change failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
form.add_error(None, "Changes could not be completed. Check your current password and reload your profile before retrying. Contact the administrator if this continues.")
|
|
# Keep the original revision on validation errors, never silently allow
|
|
# a stale bound form to overwrite concurrently updated directory data.
|
|
if action == "profile":
|
|
context["revision"] = request.POST.get("revision", "")
|
|
return render(request, "profile.html", context)
|
|
|
|
|
|
@require_GET
|
|
@account_required
|
|
def profile_photo(request):
|
|
if limited("profile-photo", str(request.member.pk), 120):
|
|
return HttpResponse(status=429)
|
|
try:
|
|
values = profiles.read_profile(request.member)
|
|
if not values["jpegPhoto"]:
|
|
return HttpResponse(status=404)
|
|
# Existing LDAP photos are untrusted too: rewrite before browser delivery.
|
|
raw = values["jpegPhoto"][0]
|
|
if not isinstance(raw, bytes):
|
|
raise ValueError("Invalid stored image.")
|
|
form = ProfileForm(files={"jpegPhoto": SimpleUploadedFile("photo.jpg", raw, content_type="image/jpeg")})
|
|
form.is_valid()
|
|
image = form.cleaned_data.get("jpegPhoto")
|
|
if not image:
|
|
return HttpResponse(status=404)
|
|
response = HttpResponse(image, content_type="image/jpeg")
|
|
response["Content-Disposition"] = 'inline; filename="profile.jpg"'
|
|
return response
|
|
except Exception as exc:
|
|
logger.error("Profile photo failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
|
return HttpResponse(status=404)
|
|
|
|
|
|
@require_GET
|
|
@account_required
|
|
def verify_profile_email(request, token):
|
|
if limited("profile-email-verify", client_ip(request), 60):
|
|
return HttpResponse(status=429)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
if not ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).exists():
|
|
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
|
request.session["profile_email_hash"] = digest
|
|
return redirect("members:confirm_profile_email")
|
|
|
|
|
|
@sensitive_post_parameters("current_password")
|
|
@require_http_methods(["GET", "POST"])
|
|
@account_required
|
|
def confirm_profile_email(request):
|
|
digest = request.session.get("profile_email_hash")
|
|
pending = ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).first()
|
|
if not pending:
|
|
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
|
form = CurrentPasswordForm(request.POST if request.method == "POST" else None)
|
|
if request.method == "POST":
|
|
if limited("profile-write", str(request.member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
if form.is_valid():
|
|
try:
|
|
with transaction.atomic():
|
|
member = Account.objects.select_for_update().get(pk=request.member.pk)
|
|
pending = ProfileEmailChange.objects.select_for_update().get(pk=digest, account=member, expires_at__gt=timezone.now())
|
|
profiles.read_profile(member)
|
|
if member.ldap_uuid != pending.ldap_uuid:
|
|
raise ValueError("Directory identity changed.")
|
|
profiles.change_email(member, form.cleaned_data["current_password"], pending.email)
|
|
member.email = pending.email
|
|
member.save(update_fields=["email"])
|
|
ProfileEmailChange.objects.filter(account=member).delete()
|
|
request.session.pop("profile_email_hash", None)
|
|
request.session.cycle_key()
|
|
return redirect(reverse("members:profile") + "?saved=1")
|
|
except Exception as exc:
|
|
logger.error("Profile email confirmation failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
|
form.add_error(None, "Email change could not be completed. Check your current password or request a new verification link.")
|
|
return render(request, "profile.html", {"account": request.member, "confirm_email": True, "confirmation_email": pending.email, "confirmation_form": form})
|
|
|
|
|
|
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)
|