Add single-use registration cancellation
This commit is contained in:
parent
752d91cf5c
commit
98250e5838
3 changed files with 71 additions and 6 deletions
|
|
@ -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)))])
|
||||
|
|
|
|||
|
|
@ -36,3 +36,21 @@ def test_registration_verification_is_digest_only_and_single_use(tmp_path):
|
|||
env["wsgi.input"]=io.BytesIO(consume)
|
||||
replay=json.loads(b"".join(app(env,lambda s,h:result.update(status=s))))
|
||||
assert replay["error"]=="verification_invalid"
|
||||
|
||||
def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_path):
|
||||
provider=Provider(); store=SQLiteDeliveryStore(str(tmp_path/"mail.db")); app=TransactionalApplication(store,provider,"opaque","https://users.example")
|
||||
request={"registration_id":"reg-cancel","normalized_email":"person@example.test","preferred_username":"person","client_id":"coulomb-social","tenant":"tenant:coulomb","correlation_id":"corr-cancel"}
|
||||
raw=json.dumps(request).encode(); result={}
|
||||
app({"PATH_INFO":"/v1/registration-verifications","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(raw)),"wsgi.input":io.BytesIO(raw),"HTTP_AUTHORIZATION":"Bearer opaque"},lambda s,h:result.update(status=s))
|
||||
text=provider.calls[0][2]; handle=text.split("handle=",1)[1].splitlines()[0]
|
||||
assert f"/registration/cancel?handle={handle}" in text
|
||||
payload=json.dumps({"handle":handle}).encode()
|
||||
cancel_env={"PATH_INFO":"/v1/registration-verifications/cancel","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"}
|
||||
canceled=json.loads(b"".join(app(cancel_env,lambda s,h:result.update(status=s))))
|
||||
assert canceled["purpose"]=="public-registration-cancel"
|
||||
consume_env={"PATH_INFO":"/v1/registration-verifications/consume","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"}
|
||||
rejected=json.loads(b"".join(app(consume_env,lambda s,h:result.update(status=s))))
|
||||
assert rejected["error"]=="verification_invalid"
|
||||
cancel_env["wsgi.input"]=io.BytesIO(payload)
|
||||
replay=json.loads(b"".join(app(cancel_env,lambda s,h:result.update(status=s))))
|
||||
assert replay["error"]=="verification_invalid"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-08-08"
|
||||
updated: "2026-08-08"
|
||||
updated: "2026-08-10"
|
||||
depends_on:
|
||||
- NK-WP-0024
|
||||
state_hub_workstream_id: "a37e5e4d-090a-4a75-88d2-aacd0c0fd235"
|
||||
|
|
@ -68,6 +68,13 @@ SQLite retains a SHA-256 digest, evidence is purpose/binding scoped, expiry is
|
|||
enforced, and consumption is atomic and single-use. All 23 repository tests
|
||||
pass.
|
||||
|
||||
2026-08-10 cancellation increment: verification messages now include a fixed
|
||||
portal cancellation link using the same opaque handle. The SQLite store keeps
|
||||
only its digest and atomically records either consumption or cancellation, so
|
||||
verification and cancellation invalidate one another and replay fails closed.
|
||||
Purpose-bound cancellation evidence lets user-engine abandon the matching
|
||||
registration without accepting browser identity claims. All 24 tests pass.
|
||||
|
||||
## T03 - Establish custody and deploy the service
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue