227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""Additive Business authorization; never an identity provider or key store."""
|
|||
|
|
import hashlib
|
||
|
|
import ipaddress
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import secrets
|
||
|
|
import time
|
||
|
|
from http.cookies import SimpleCookie, CookieError
|
||
|
|
from collections import OrderedDict
|
||
|
|
|
||
|
|
COOKIE = "jwb_session"
|
||
|
|
PRIVILEGED_GET = {"/api/dashboard", "/api/pos"}
|
||
|
|
PRIVILEGED_POST = {"/api/config", "/api/business-config", "/api/business-profile"}
|
||
|
|
RATE_BUCKETS = OrderedDict()
|
||
|
|
|
||
|
|
|
||
|
|
def canonical_npub(value):
|
||
|
|
"""Validate NIP-19 bech32 checksum and the 32-byte public key length."""
|
||
|
|
alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||
|
|
if not isinstance(value, str) or len(value) != 63 or not value.startswith("npub1"):
|
||
|
|
raise ValueError("invalid npub")
|
||
|
|
try:
|
||
|
|
data = [alphabet.index(char) for char in value[5:]]
|
||
|
|
except ValueError:
|
||
|
|
raise ValueError("invalid npub") from None
|
||
|
|
checksum = 1
|
||
|
|
expanded = [ord(c) >> 5 for c in "npub"] + [0] + [ord(c) & 31 for c in "npub"]
|
||
|
|
for item in expanded + data:
|
||
|
|
high = checksum >> 25
|
||
|
|
checksum = ((checksum & 0x1ffffff) << 5) ^ item
|
||
|
|
for bit, generator in enumerate((0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)):
|
||
|
|
if (high >> bit) & 1: checksum ^= generator
|
||
|
|
if checksum != 1 or data[-7] & 15:
|
||
|
|
raise ValueError("invalid npub")
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def client_ip(remote, headers):
|
||
|
|
"""Trust one Nginx-overwritten X-Real-IP only from explicitly enabled loopback."""
|
||
|
|
def normalize(value):
|
||
|
|
if not isinstance(value, str) or '%' in value:
|
||
|
|
raise ValueError("invalid address")
|
||
|
|
address = ipaddress.ip_address(value.strip())
|
||
|
|
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
||
|
|
return address.ipv4_mapped
|
||
|
|
return address
|
||
|
|
|
||
|
|
try:
|
||
|
|
peer = normalize(remote)
|
||
|
|
except ValueError:
|
||
|
|
return "unknown"
|
||
|
|
if os.environ.get("JWB_TRUST_PROXY", "0") != "1" or not peer.is_loopback:
|
||
|
|
return str(peer)
|
||
|
|
# HTTPMessage permits duplicate headers; a normal dict is used by unit tests.
|
||
|
|
values = headers.get_all("X-Real-IP", []) if hasattr(headers, "get_all") else [headers.get("X-Real-IP", "")]
|
||
|
|
if len(values) == 1:
|
||
|
|
try:
|
||
|
|
return str(normalize(values[0]))
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
return str(peer)
|
||
|
|
|
||
|
|
|
||
|
|
def rate_scope(path, values):
|
||
|
|
# Table flows ignore caller-supplied npub; opaque tokens never enter bucket keys.
|
||
|
|
if path in {"/api/orders", "/api/payments"}:
|
||
|
|
for field in ("token", "order_id", "payment_id"):
|
||
|
|
value = values.get(field)
|
||
|
|
if isinstance(value, str) and value:
|
||
|
|
return field + ":" + token_hash(value)
|
||
|
|
return "unscoped"
|
||
|
|
try:
|
||
|
|
return "merchant:" + canonical_npub(values.get("npub", ""))
|
||
|
|
except ValueError:
|
||
|
|
return "unscoped"
|
||
|
|
|
||
|
|
|
||
|
|
def rate_allowed(remote, path, values=None, headers=None):
|
||
|
|
# Caller holds the process lock. Keep only a digest of the normalized peer.
|
||
|
|
timestamp = int(time.time()) // 60
|
||
|
|
peer = client_ip(remote, headers or {})
|
||
|
|
key = (token_hash(peer), path, rate_scope(path, values or {}))
|
||
|
|
prior_window, count = RATE_BUCKETS.pop(key, (timestamp, 0))
|
||
|
|
count = count + 1 if prior_window == timestamp else 1
|
||
|
|
RATE_BUCKETS[key] = (timestamp, count)
|
||
|
|
while len(RATE_BUCKETS) > 2048: RATE_BUCKETS.popitem(last=False)
|
||
|
|
return count <= (20 if path == "/api/business-owner-login" else 120)
|
||
|
|
|
||
|
|
|
||
|
|
def mode():
|
||
|
|
value = os.environ.get("JWB_AUTH_MODE", "shadow")
|
||
|
|
if value not in {"off", "shadow", "enforce"}:
|
||
|
|
raise RuntimeError("JWB_AUTH_MODE must be off, shadow or enforce")
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def migrate(conn):
|
||
|
|
conn.executescript("""
|
||
|
|
CREATE TABLE IF NOT EXISTS business_sessions (
|
||
|
|
token_hash TEXT PRIMARY KEY, npub TEXT NOT NULL, created_at INTEGER NOT NULL,
|
||
|
|
expires_at INTEGER NOT NULL);
|
||
|
|
CREATE INDEX IF NOT EXISTS business_sessions_expiry ON business_sessions(expires_at);
|
||
|
|
CREATE TABLE IF NOT EXISTS business_audit (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL,
|
||
|
|
event TEXT NOT NULL, route TEXT NOT NULL, merchant TEXT NOT NULL,
|
||
|
|
actor TEXT NOT NULL, reason TEXT NOT NULL);
|
||
|
|
""")
|
||
|
|
import business_rollout
|
||
|
|
business_rollout.migrate(conn)
|
||
|
|
|
||
|
|
|
||
|
|
def audit(conn, event, route="", merchant="", actor="", reason=""):
|
||
|
|
# Only fixed event/reason codes and public identities; never bodies, cookies or exceptions.
|
||
|
|
timestamp = int(time.time())
|
||
|
|
route = re.sub(r"/api/(orders|payments|bookings)/[^/]+/", r"/api/\1/:id/", route)
|
||
|
|
merchant = merchant if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}|localpub-demo", merchant) else ""
|
||
|
|
actor = actor if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}", actor) else ""
|
||
|
|
if event == "would_deny" and conn.execute(
|
||
|
|
"SELECT 1 FROM business_audit WHERE event=? AND route=? AND merchant=? AND reason=? AND occurred_at>? LIMIT 1",
|
||
|
|
(event, route, merchant, reason, timestamp - 60)).fetchone():
|
||
|
|
return
|
||
|
|
conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,?,?,?,?,?)",
|
||
|
|
(timestamp, event, route, merchant, actor, reason))
|
||
|
|
conn.execute("DELETE FROM business_audit WHERE id NOT IN (SELECT id FROM business_audit ORDER BY id DESC LIMIT 10000)")
|
||
|
|
conn.execute("DELETE FROM business_sessions WHERE expires_at<=?", (timestamp,))
|
||
|
|
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
|
||
|
|
def token_hash(token):
|
||
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def session(conn, headers):
|
||
|
|
try:
|
||
|
|
cookie = SimpleCookie()
|
||
|
|
cookie.load(headers.get("Cookie", ""))
|
||
|
|
token = cookie[COOKIE].value
|
||
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
|
||
|
|
return None
|
||
|
|
row = conn.execute("SELECT s.npub,s.expires_at FROM business_sessions s LEFT JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND (a.audience=? OR a.audience IS NULL)",
|
||
|
|
(token_hash(token), int(time.time()), headers.get("Host", "").lower())).fetchone()
|
||
|
|
return dict(row) if row else None
|
||
|
|
except (KeyError, ValueError, CookieError):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def revoke(conn, headers, all_sessions=False):
|
||
|
|
current = session(conn, headers)
|
||
|
|
if all_sessions and current:
|
||
|
|
conn.execute("DELETE FROM business_sessions WHERE npub=?", (current["npub"],))
|
||
|
|
else:
|
||
|
|
cookie = SimpleCookie()
|
||
|
|
try:
|
||
|
|
cookie.load(headers.get("Cookie", ""))
|
||
|
|
conn.execute("DELETE FROM business_sessions WHERE token_hash=?", (token_hash(cookie[COOKIE].value),))
|
||
|
|
except (KeyError, ValueError, CookieError):
|
||
|
|
pass
|
||
|
|
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
|
||
|
|
conn.commit()
|
||
|
|
return current
|
||
|
|
|
||
|
|
|
||
|
|
def cookie_header(token="", max_age=0):
|
||
|
|
# Local HTTP opt-out is operator configuration, never inferred from attacker-controlled Host.
|
||
|
|
secure = "" if os.environ.get("JWB_COOKIE_SECURE", "1") == "0" else "; Secure"
|
||
|
|
return f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
|
||
|
|
|
||
|
|
|
||
|
|
def issue(conn, npub, headers):
|
||
|
|
npub = canonical_npub(npub)
|
||
|
|
revoke(conn, headers)
|
||
|
|
token = secrets.token_urlsafe(32)
|
||
|
|
ttl = max(60, min(86400, int(os.environ.get("JWB_SESSION_TTL", "28800"))))
|
||
|
|
expires = int(time.time()) + ttl
|
||
|
|
conn.execute("INSERT INTO business_sessions(token_hash,npub,created_at,expires_at) VALUES (?,?,?,?)", (token_hash(token), npub, int(time.time()), expires))
|
||
|
|
conn.execute("INSERT INTO business_session_audiences VALUES (?,?)", (token_hash(token), headers.get("Host", "").lower()))
|
||
|
|
conn.execute("DELETE FROM business_sessions WHERE npub=? AND token_hash NOT IN (SELECT token_hash FROM business_sessions WHERE npub=? ORDER BY created_at DESC,rowid DESC LIMIT 10)", (npub, npub))
|
||
|
|
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
|
||
|
|
conn.commit()
|
||
|
|
return cookie_header(token, ttl), expires
|
||
|
|
|
||
|
|
|
||
|
|
def target(conn, method, path, values):
|
||
|
|
from business_rollout import legacy_merchant
|
||
|
|
if method == "GET" and path in PRIVILEGED_GET:
|
||
|
|
return legacy_merchant(conn) if path == "/api/dashboard" else str(values.get("npub", "")).strip()
|
||
|
|
if method == "POST" and path in PRIVILEGED_POST:
|
||
|
|
return legacy_merchant(conn) if path == "/api/config" else str(values.get("npub", "")).strip()
|
||
|
|
match = re.fullmatch(r"/api/(orders|payments|bookings)/([^/]+)/(status|acknowledge)", path)
|
||
|
|
if method == "POST" and match:
|
||
|
|
table, identity, _ = match.groups()
|
||
|
|
row = conn.execute(f"SELECT merchant_id FROM {table} WHERE id=?", (identity,)).fetchone()
|
||
|
|
return row[0] if row else ""
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def same_origin(headers):
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
origin = headers.get("Origin")
|
||
|
|
if headers.get("Sec-Fetch-Site") == "cross-site":
|
||
|
|
return False
|
||
|
|
if not origin:
|
||
|
|
return True # non-browser clients; browser JSON requests require CORS preflight
|
||
|
|
parsed = urlparse(origin)
|
||
|
|
return parsed.scheme in {"http", "https"} and parsed.netloc.lower() == headers.get("Host", "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def authorize(conn, method, path, values, headers):
|
||
|
|
merchant = target(conn, method, path, values)
|
||
|
|
if merchant is None:
|
||
|
|
return True, None
|
||
|
|
current = session(conn, headers)
|
||
|
|
reason = ""
|
||
|
|
if not current:
|
||
|
|
reason = "missing_session"
|
||
|
|
elif current["npub"] != merchant:
|
||
|
|
reason = "tenant_mismatch"
|
||
|
|
elif method != "GET" and not same_origin(headers):
|
||
|
|
reason = "cross_origin"
|
||
|
|
from business_rollout import effective_mode
|
||
|
|
selected = effective_mode(conn, method, path, merchant)
|
||
|
|
if reason and selected != "off":
|
||
|
|
audit(conn, "denied" if selected == "enforce" else "would_deny", path, merchant,
|
||
|
|
current["npub"] if current else "", reason)
|
||
|
|
return not reason or selected != "enforce", (merchant, current["npub"] if current else "")
|