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)))])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue