296 lines
19 KiB
Markdown
296 lines
19 KiB
Markdown
# WWW
|
|
|
|
Apache serves the static eom.dev website. A separate Django service provides
|
|
public accounts, Dex sign-in, and Stripe membership under **/members/**.
|
|
|
|
Optional SHKeeper support adds prepaid crypto membership alongside Stripe.
|
|
Both payment methods feed the same LDAP group reconciliation and local expiry.
|
|
|
|
## Membership flow
|
|
|
|
1. Anyone can request a free account. A one-hour, single-use email link allows
|
|
them to choose a password. The account is created as an `inetOrgPerson` in
|
|
OpenLDAP; the password goes only through RFC 3062 Password Modify over
|
|
certificate-verified LDAPS/StartTLS. This app never stores a password/hash.
|
|
2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies
|
|
the RS256 ID token, issuer, audience, signature and lifetime. A signed
|
|
`federated_claims` connector/user ID must match the configured LDAP connector
|
|
and LDAP `entryUUID`; accounts are **not** linked using email alone.
|
|
3. Authenticated users choose a server-allowlisted recurring Stripe Price.
|
|
Stripe hosts Checkout and the billing portal. Return URLs never grant access.
|
|
4. Stripe webhooks are signature-verified and durably deduplicated. The worker
|
|
rereads canonical subscriptions and paid invoice lines, so duplicate, missing,
|
|
or reordered webhooks cannot restore expired access.
|
|
5. The worker first sweeps local expiries for all accounts, then reconciles
|
|
Stripe state. An account belongs to the configured tier group only while it
|
|
has paid service time. A failed renewal does not extend that time. Immediate
|
|
cancellation, pause, unpaid status or unknown prices revoke access. Cancel
|
|
at period end retains access until the paid expiry. Trialing does not grant
|
|
paid access. A zero-total invoice marked paid (e.g. a coupon) counts as paid.
|
|
6. Expiry removes **group membership, not the LDAP account**. LDAP writes are
|
|
idempotent, and failed synchronization is retried on every sweep. Old group
|
|
mappings remain in the database so deleting/changing a tier also revokes its
|
|
former group. Account row locks serialize Checkout and reconciliation.
|
|
|
|
The worker interval is 60 seconds **after a complete sweep**, not a hard
|
|
one-minute expiry SLA. LDAP/network outages delay changes until recovery.
|
|
Stripe outages do not extend locally recorded paid expiry. Already-issued Dex
|
|
tokens and downstream sessions do not instantly lose their cached claims; set
|
|
short token lifetimes and enforce reauthorization in each relying application.
|
|
|
|
## Existing Stripe product
|
|
|
|
The live Stripe dashboard was inspected on 2026-10-23. This integration reuses
|
|
**Subscription**, product `prod_Sc9N5J0vNAdLCn`, without modifying its prices,
|
|
metadata, Discourse integration, or existing subscriptions.
|
|
|
|
| Tier | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group |
|
|
| --- | ---: | --- | --- |
|
|
| Level 0 | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-0` |
|
|
| Level 1 | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-1` |
|
|
| Level 2 | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-2` |
|
|
| Level 3 | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-3` |
|
|
| Level 4 | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-4` |
|
|
|
|
The deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json).
|
|
The **group DNs are deployment proposals**, not verified existing groups: create
|
|
them or edit the mapping to your dedicated groups before starting the worker.
|
|
Higher tiers do not implicitly inherit lower-tier groups. Map your applications'
|
|
authorization accordingly. Never use administrator or unrelated service groups.
|
|
Display prices are informational; the configured Stripe Price is authoritative.
|
|
For tax obligations, review the product tax code and Stripe settings separately.
|
|
|
|
Existing Discourse customers/subscriptions are **not automatically imported**.
|
|
New website subscriptions carry `metadata.membership_account` and are scoped to
|
|
the signed-in account's stored customer ID. Never attach a pre-existing customer
|
|
or subscription based only on matching email; a separately reviewed ownership
|
|
migration is required. The dashboard showed zero active subscriptions on each
|
|
of these five prices when inspected.
|
|
|
|
## Production setup
|
|
|
|
1. Copy [.env.example](.env.example) to the ignored local environment file. Fill
|
|
secrets directly through your secret manager/editor, not chat. Generate
|
|
independent, high-entropy Django, PostgreSQL, Dex and LDAP credentials.
|
|
Fill SMTP settings and a real sender address for verification mail.
|
|
2. Create the account OU and sentinel/groups after reviewing
|
|
[membership/deploy/groups.example.ldif](membership/deploy/groups.example.ldif).
|
|
Configure a **least-privilege** LDAP service account allowed to:
|
|
- search the account tree for `uid`, `mail`, `entryUUID` and DN;
|
|
- add/delete entries only within the website account OU;
|
|
- perform Password Modify only on those newly provisioned entries;
|
|
- read and add/delete `uniqueMember` only on the dedicated paid groups;
|
|
- read the sentinel, which must never be a login identity.
|
|
Do not use the directory root/admin bind. Permit Dex a separate read/search
|
|
service identity. Prevent users from writing their own paid group membership.
|
|
3. Verify OpenLDAP's Argon2 module/password policy (`olcPasswordHash: {ARGON2}`),
|
|
require suitable password strength/history, reject unauthenticated binds,
|
|
and enforce directory-side **unique uid and mail across the full search
|
|
tree**, including other account registrars. Only then set
|
|
`LDAP_PASSWORD_SCHEME={ARGON2}`. The app does not alter slapd configuration.
|
|
If a private CA is used, mount its certificate read-only into both membership
|
|
and worker services and set `LDAP_CA_CERT_FILE`. TLS verification is mandatory.
|
|
4. Add a dedicated Dex client/LDAP connector using
|
|
[membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml).
|
|
The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash).
|
|
Set the same client secret in Dex and `DEX_CLIENT_SECRET`, and set
|
|
`DEX_CONNECTOR_ID=www-ldap`. This connector uses `idAttr: entryUUID`,
|
|
`preferredUsernameAttr: uid`, `emailAttr: mail`; scopes include `federated:id`.
|
|
**Do not change the existing connector's idAttr blindly**: that would change
|
|
subjects in other apps. Use the additional connector instead. Dex must search
|
|
the account OU without requiring membership in a paid group. RS256 and all
|
|
discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase
|
|
LDAP usernames matching the registration syntax can sign in without signup.
|
|
5. The supplied tier file contains live Price IDs. Set `STRIPE_LIVE_MODE=1` only
|
|
with the matching live Stripe API key. For sandbox testing, override
|
|
`MEMBERSHIP_TIERS_JSON` with **test-mode** recurring prices and set live mode
|
|
to 0. Set the JSON object to `{}` to disable paid tiers. Grant the API key only
|
|
required customer, Checkout, subscription, invoice/line read, Price read, and
|
|
billing portal permissions; the app never creates Products or Prices.
|
|
6. Create/configure a Stripe billing portal with **only these five allowed
|
|
prices**, payment method updates and cancellation. Set its configuration ID in
|
|
`STRIPE_PORTAL_CONFIGURATION`. For tier changes, use immediate paid proration
|
|
for upgrades and period-end downgrades. Access always follows the current
|
|
price's paid invoice lines; an unpaid upgrade does not grant the higher group.
|
|
An arbitrary instant downgrade without a paid line for the new price can
|
|
suspend access until Stripe invoices it; use scheduled downgrades instead.
|
|
7. Add a Stripe webhook endpoint at
|
|
`https://eom.dev/members/stripe/webhook/`. Subscribe to
|
|
`checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
|
`checkout.session.async_payment_failed`, `customer.subscription.created`,
|
|
`customer.subscription.updated`, `customer.subscription.deleted`,
|
|
`invoice.paid`, `invoice.payment_failed`. Use API version
|
|
**2025-03-31.basil** and set the endpoint signing secret in
|
|
`STRIPE_WEBHOOK_SECRET`. Do not edit/delete the Discourse webhook. The worker's
|
|
full sweep recovers missing events without relying on payload arrival order.
|
|
8. Deploy with Docker Compose. A one-shot migration service runs before the web
|
|
and worker services. PostgreSQL has no published port. Its Compose network is
|
|
private; use `POSTGRES_SSLMODE=verify-full` with a mounted root CA for remote
|
|
PostgreSQL, and use encrypted disks/backups. Do not use the SQLite setting in
|
|
production: real PostgreSQL row locks are required.
|
|
9. Terminate TLS at your existing edge and forward only to Apache's private
|
|
listener. The host port now defaults to loopback (127.0.0.1:8080); if your TLS
|
|
proxy is on another host/container, explicitly set a private bind address and
|
|
firewall access. Apache **overwrites**, rather than trusts, forwarded scheme
|
|
and client-IP headers. Do not expose the membership service port. Do not
|
|
enable `MEMBERSHIP_TRUST_PROXY` outside this isolated topology. For actual IP
|
|
throttling behind another proxy, configure Apache's trusted `mod_remoteip`
|
|
proxies; otherwise the default conservatively throttles by the TLS edge IP.
|
|
10. Disable/redact access/error tracing for verification URLs and OIDC callback
|
|
codes at **every** edge, CDN, APM and log collector. Apache suppresses member
|
|
access logs; Gunicorn access logs are disabled. Do not enable request/body
|
|
debug logging. Sessions are server-side, rotate on login, and expire in one
|
|
hour. CSRF protects state-changing forms; only signed Stripe webhooks are
|
|
CSRF-exempt. Backups contain identity/billing metadata; restrict access,
|
|
encrypt them, and define a retention/deletion policy.
|
|
|
|
Configuration does not automatically provision remote LDAP/Dex/Stripe resources.
|
|
No production credentials or live account changes are made by the test suite.
|
|
|
|
## Operations and limitations
|
|
|
|
### SHKeeper prepaid crypto
|
|
|
|
Stripe remains the automatically recurring payment method. SHKeeper accepts
|
|
**one-time payments for one calendar month** of the same tiers, at the USD
|
|
amounts in [membership/tiers.eom.json](membership/tiers.eom.json). It does not
|
|
debit a wallet or automatically renew. The user must create and pay a new
|
|
invoice for each month. Amounts are server-side configuration, never supplied by
|
|
the browser and never parsed from the display-price label. Keep `usd_amount`
|
|
aligned with your Stripe prices when changing prices; existing orders retain
|
|
the original amount.
|
|
|
|
To enable:
|
|
|
|
1. Set `SHKEEPER_URL` to your actual HTTPS origin, `SHKEEPER_API_KEY` to its
|
|
invoice API key, and `SHKEEPER_CURRENCIES` to a comma-separated allowlist of
|
|
the API currency identifiers enabled on **your** instance (e.g. BTC or XMR).
|
|
These identifiers are network-specific for tokens (e.g. ETH-USDT is not
|
|
interchangeable with TRX-USDT). No instance URL, wallets or keys are assumed.
|
|
2. For an internal CA, mount the trusted CA file read-only into both web and
|
|
worker containers and set `SHKEEPER_CA_CERT_FILE`. TLS verification is always
|
|
enabled. API requests have timeouts, disallow redirects, and cap response
|
|
size; the API key never reaches the browser. No payout/UI administrator
|
|
credentials or wallet private keys are needed. Restrict network/API access
|
|
because SHKeeper's API key may expose more than invoice endpoints.
|
|
3. Review SHKeeper fee, exchange-rate recalculation, payment-tolerance and
|
|
confirmation settings. This integration requires a full invoice amount and
|
|
confirmed transactions; it intentionally does not grant access for partial,
|
|
underpaid or unconfirmed transfers, even if a provider tolerance says paid.
|
|
The payment page identifies its initial quote as such; a partial/late
|
|
payment may require administrator assistance with the updated remainder.
|
|
4. Each new invoice supplies the callback URL
|
|
`https://eom.dev/members/shkeeper/callback/`. Permit SHKeeper to reach it.
|
|
**SHKeeper's documented callbacks have no signature.** This endpoint only
|
|
acknowledges them: it never trusts amounts/status/IDs, writes payment state,
|
|
or triggers an API request. The worker polls known local invoice UUIDs over
|
|
authenticated HTTPS, so forged, repeated, missing or reordered callbacks
|
|
cannot grant access or extend a term.
|
|
5. Redeploy the image and run the migration service, which applies
|
|
[membership/members/migrations/0002_crypto_invoices.py](membership/members/migrations/0002_crypto_invoices.py).
|
|
Keep the membership worker running; it independently refreshes Stripe and
|
|
SHKeeper, and performs a local-only expiry sweep before remote requests.
|
|
6. Smoke-test a low-value invoice on your actual deployment before offering
|
|
public crypto payments. The adapter follows the documented SHKeeper 2.1 API:
|
|
`POST /api/v1/{crypto}/payment_request` and
|
|
`GET /api/v1/invoices/{external_id}`. Require the GET response to include
|
|
`external_id`, `fiat`, `amount_fiat`, `balance_fiat`, `status` and `txs`
|
|
(transaction status, asset identifier, address and txid). Unsupported or
|
|
ambiguous responses do not grant access. Different SHKeeper versions must
|
|
be verified against these fields, especially transaction crypto identifiers.
|
|
|
|
Payment lifecycle:
|
|
|
|
- An authenticated user selects the currency, then sees a private invoice
|
|
with its deposit address and quote. One unfinished invoice is allowed per
|
|
account. Repeated submissions reopen that same invoice, even if a second
|
|
tab requests another tier or currency. The UUID and USD amount are committed
|
|
**before** contacting SHKeeper; retrying after a timeout reuses SHKeeper's
|
|
documented create/update-by-external-ID operation with the same parameters.
|
|
- There is no fabricated invoice deadline: SHKeeper deposit addresses can
|
|
receive late transfers. Unpaid/partial orders remain polled and cannot be
|
|
abandoned/switched from the UI. Contact the administrator if the wrong
|
|
network was selected; do not delete orders with potentially in-flight funds.
|
|
- Access begins when the worker first verifies a fully paid invoice with
|
|
matching USD order, amount, receiving address and asset and confirmed txs.
|
|
A renewal of the **same tier** starts at the later of now or that tier's
|
|
previous prepaid end and adds one calendar month in UTC. Month-end dates
|
|
clamp to the last day of the next month. Queued future terms do not grant
|
|
early access. Delayed worker verification starts a new term at verification,
|
|
not at an untrusted callback timestamp.
|
|
- Each invoice can grant **exactly one term**. Overpayments, repeated callbacks
|
|
and additional transfers to a paid address never buy extra months. Create a
|
|
new invoice for renewal. Refund/overpayment handling is manual; this app does
|
|
not issue cryptocurrency payouts.
|
|
- Active/queued paid crypto invoices are rechecked until their end. If an
|
|
invoice reverts to unpaid/partial/unconfirmed (e.g. a chain reorganization),
|
|
its grant is removed. Recovery restores only its original term, not another
|
|
month. During API outages, the last verified term remains valid only until
|
|
its stored expiry; local expiry never waits for provider recovery. Alert on
|
|
stale `last_shkeeper_sync`, failed polls and worker errors.
|
|
- LDAP desired groups are the **union** of all currently valid Stripe and
|
|
SHKeeper terms. One provider's failure/cancellation cannot revoke a still
|
|
valid grant from another. Removed tiers are revoked regardless of provider.
|
|
- To avoid accidental double payments, a current Stripe subscription or open
|
|
Stripe Checkout blocks a new crypto invoice. Unfinished or unexpired crypto
|
|
orders block a new Stripe subscription; another crypto tier must expire
|
|
before switching tiers. Same-tier crypto renewals remain available. This
|
|
does not cancel existing Stripe recurring charges: finish/cancel them through
|
|
Stripe's billing portal before changing methods. Provider-side/manual changes
|
|
can still create overlapping payments; grants remain a safe union.
|
|
- Leaving SHKeeper configuration empty hides new crypto checkouts. Previously
|
|
verified prepaid grants still expire locally, but remote status cannot be
|
|
rechecked while the provider is disabled. Do not delete payment history or
|
|
change your SHKeeper server/key to an unrelated instance while orders exist.
|
|
`CryptoInvoice` protects its parent Account from accidental deletion.
|
|
|
|
### General operations
|
|
|
|
- Keep `membership-worker` running. Alert on its errors and on accounts whose
|
|
`last_ldap_sync` or `last_billing_sync` is stale. A one-shot
|
|
`sync_memberships` command exits nonzero on a failed sweep. Logs record account
|
|
IDs/error classes, not passwords/tokens or raw webhook payloads.
|
|
- The worker removes expired registration/session/rate-limit records and retains
|
|
processed webhook IDs for 30 days. A later replay remains harmless because
|
|
grants are always recalculated from canonical Stripe data.
|
|
- Do not delete Account/ManagedGroup rows before removing their LDAP grants.
|
|
Do not manually remove an LDAP account without first revoking paid groups.
|
|
Directory renames are reconciled using `entryUUID`; the old DN is removed too.
|
|
- Registration cannot atomically commit LDAP and PostgreSQL together. If LDAP
|
|
creation succeeds and the database write fails, the UI directs the user to
|
|
Dex; successful login safely recovers the directory account by its UUID.
|
|
Failed LDAP rollback may require administrator cleanup. Never reset an
|
|
existing account's password merely because someone verified its email.
|
|
- Full Stripe invoice history is paginated for correctness. For a large member
|
|
base, add a durable incremental invoice cache and shard workers before relying
|
|
on a tight revocation latency. No refunds/dispute policy is inferred: the
|
|
current implementation follows paid invoices plus subscription state. If a
|
|
refund/dispute should revoke access, cancel/suspend the subscription as part of
|
|
your reviewed refund/dispute process.
|
|
- Free accounts are `inetOrgPerson`, not automatically POSIX/shell accounts and
|
|
not automatically members of any service group. Password resets remain your
|
|
existing LDAP/Dex account recovery process.
|
|
|
|
## Verification
|
|
|
|
Offline tests use an isolated SQLite database, mocked Stripe/SMTP/LDAP network
|
|
calls, real signed Stripe webhook verification, and real RSA-signed Authlib
|
|
state/PKCE/nonce/issuer/audience/signature tests. PostgreSQL tests run the same
|
|
suite against a disposable container using
|
|
[membership/compose.test.yaml](membership/compose.test.yaml).
|
|
|
|
The suite covers verification expiry/replay, password validation, CSRF, directory
|
|
identity linking, allowed checkout prices, customer scoping, duplicate/reordered
|
|
webhooks, paid invoice periods, cancellation, failed renewals, unavailable Stripe,
|
|
LDAP retries, tier removal, multiple subscriptions, and the last member's removal
|
|
from a `groupOfUniqueNames` using its sentinel. Live Stripe, Dex, SMTP and OpenLDAP
|
|
must still be smoke-tested together in a non-production environment before
|
|
enabling public registration/payment. SHKeeper regressions additionally cover
|
|
API-key/TLS transport, immutable prices and account-scoped invoices, callback
|
|
spoofing, partial/unconfirmed/underpaid transfers, duplicate confirmation,
|
|
calendar-month renewals, overpayments, reorg recovery, provider outages, and
|
|
cross-provider overlap. PostgreSQL also verifies that concurrent workers credit
|
|
one crypto invoice only once.
|
|
|
|
|