Add single-use registration cancellation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 19:27:00 +02:00
parent 752d91cf5c
commit 98250e5838
3 changed files with 71 additions and 6 deletions

View file

@ -28,8 +28,11 @@ class SQLiteDeliveryStore:
request_id TEXT PRIMARY KEY, handle_hash TEXT UNIQUE NOT NULL,
registration_id TEXT NOT NULL, email TEXT NOT NULL, username TEXT NOT NULL,
client_id TEXT NOT NULL, tenant TEXT NOT NULL, display_name TEXT,
expires_at TEXT NOT NULL, consumed_at TEXT
expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT
)""")
columns = {row[1] for row in self.db.execute("PRAGMA table_info(verifications)")}
if "canceled_at" not in columns:
self.db.execute("ALTER TABLE verifications ADD COLUMN canceled_at TEXT")
self.db.commit()
def reference(self, event_id: str) -> str | None:
@ -43,7 +46,10 @@ class SQLiteDeliveryStore:
def create_verification(self, payload: dict, handle_hash: str, expires_at: str) -> str:
request_id = f"vrq_{secrets.token_hex(12)}"
with self.db:
self.db.execute("INSERT INTO verifications VALUES (?,?,?,?,?,?,?,?,?,NULL)", (
self.db.execute("""INSERT INTO verifications
(request_id, handle_hash, registration_id, email, username,
client_id, tenant, display_name, expires_at, consumed_at, canceled_at)
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)""", (
request_id, handle_hash, payload["registration_id"], payload["normalized_email"],
payload["preferred_username"], payload["client_id"], payload["tenant"],
payload.get("display_name"), expires_at,
@ -53,7 +59,7 @@ class SQLiteDeliveryStore:
def consume_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute("SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)).fetchone()
if row is None or row[9] is not None:
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
@ -62,6 +68,22 @@ class SQLiteDeliveryStore:
return {"request_id":row[0],"registration_id":row[2],"email":row[3],
"preferred_username":row[4],"client_id":row[5],"tenant":row[6],"display_name":row[7]}
def cancel_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute(
"SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)
).fetchone()
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
self.db.execute(
"UPDATE verifications SET canceled_at=? WHERE request_id=?",
(datetime.now(timezone.utc).isoformat(), row[0]),
)
return {"request_id":row[0],"registration_id":row[2],"email":row[3],
"preferred_username":row[4],"client_id":row[5],"tenant":row[6],"display_name":row[7]}
class SMTPProvider:
def __init__(self, host: str, port: int, username: str, password: str, sender: str) -> None:
@ -87,7 +109,7 @@ class TransactionalApplication:
if environ.get("PATH_INFO") in ("/healthz", "/readyz"):
return self._json(start_response, HTTPStatus.OK, {"status":"ok"})
path = environ.get("PATH_INFO")
if path not in ("/v1/send", "/v1/registration-verifications", "/v1/registration-verifications/consume") or environ.get("REQUEST_METHOD") != "POST":
if path not in ("/v1/send", "/v1/registration-verifications", "/v1/registration-verifications/consume", "/v1/registration-verifications/cancel") or environ.get("REQUEST_METHOD") != "POST":
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error":"not_found"})
if not hmac.compare_digest(str(environ.get("HTTP_AUTHORIZATION", "")), f"Bearer {self.token}"):
return self._json(start_response, HTTPStatus.UNAUTHORIZED, {"error":"unauthorized"})
@ -99,6 +121,8 @@ class TransactionalApplication:
return self._request_verification(start_response, payload)
if path == "/v1/registration-verifications/consume":
return self._consume_verification(start_response, payload)
if path == "/v1/registration-verifications/cancel":
return self._cancel_verification(start_response, payload)
event_id = str(payload["id"])
if environ.get("HTTP_IDEMPOTENCY_KEY") != event_id: raise ValueError("idempotency_key_mismatch")
if payload.get("source") != "user-engine" or payload.get("type") not in ALLOWED_EVENTS: raise ValueError("template_not_allowed")
@ -129,7 +153,9 @@ class TransactionalApplication:
)
self.provider.send(
str(payload["normalized_email"]), "Verify your NetKingdom registration",
f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\nThis link expires in 30 minutes.\n",
f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\n"
f"Cancel this request at {self.portal_url}/registration/cancel?handle={handle}\n"
"These links expire in 30 minutes.\n",
)
return self._json(start_response, HTTPStatus.ACCEPTED,
{"request_id":request_id,"accepted":True})
@ -145,6 +171,20 @@ class TransactionalApplication:
"assurance":{"mailbox_control":True},
})
def _cancel_verification(self, start_response, payload):
handle = str(payload.get("handle") or "")
if len(handle) < 32:
raise ValueError("verification_invalid")
evidence = self.store.cancel_verification(
hashlib.sha256(handle.encode()).hexdigest()
)
return self._json(start_response, HTTPStatus.OK, {
"purpose":"public-registration-cancel",
"verification_id":f"fvc_{secrets.token_hex(12)}",
**evidence, "source_system":"email-connect",
"assurance":{"mailbox_control":True},
})
@staticmethod
def _json(start_response, status, payload):
body=json.dumps(payload).encode(); start_response(f"{status.value} {status.phrase}", [("Content-Type","application/json"),("Content-Length",str(len(body)))])