From 6bb9d0e2d084b7580319ade625dcd62e5f88e390 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 19:36:15 +0200 Subject: [PATCH] Cover the full user lifecycle with browser request tokens Restructure the demo around the complete Castle user lifecycle. Every action mints a fresh request token in the browser (castle.js) and forwards it to the backend. - add sign up ($registration -> risk/filter) - add a post-login account page: profile update ($profile_update -> risk), a custom event (Castle.custom), and logout ($logout via the log endpoint) - drop the Events (monitoring) API demo - tests for the new endpoints; readme updated for the new flow --- .env_example | 1 + app.py | 168 ++++++++++++++++++++++++---------- demo_config.py | 12 ++- readme.md | 11 ++- templates/account.html | 76 +++++++++++++++ templates/events.html | 50 ---------- templates/login.html | 17 +++- templates/signup.html | 84 +++++++++++++++++ tests/test_pages.py | 4 +- tests/test_sdk_integration.py | 132 +++++++++++++++----------- 10 files changed, 393 insertions(+), 162 deletions(-) create mode 100644 templates/account.html delete mode 100644 templates/events.html create mode 100644 templates/signup.html diff --git a/.env_example b/.env_example index 46c3f50..bd139c9 100644 --- a/.env_example +++ b/.env_example @@ -5,5 +5,6 @@ location=localhost invalid_password=qwerty valid_password={{valid_password}} valid_username=clark.kent@dailyplanet.com +valid_name=Clark Kent valid_user_id=00000000 webhook_url=https://webhook.site diff --git a/app.py b/app.py index 6fe5f86..63bbdf7 100644 --- a/app.py +++ b/app.py @@ -51,6 +51,7 @@ def get_default_params(): "invalid_password": os.getenv("invalid_password"), "valid_password": os.getenv("valid_password"), "valid_username": os.getenv("valid_username"), + "valid_name": os.getenv("valid_name") or "Clark Kent", "webhook_url": os.getenv("webhook_url") } @@ -104,6 +105,54 @@ def demo(demo_name): return render_template(template, **params) +################################# +# Risk / Filter (registration) +################################# + +@app.route('/evaluate_signup', methods=['POST']) +def evaluate_signup(): + + name = request.json.get("name") + email = request.json["email"] + request_token = request.json["request_token"] + + castle_type = "$registration" + + # An email that's already taken (the known demo user) is a failed + # registration and goes to /filter; a fresh sign-up is risk-assessed. + if email == os.getenv("valid_username"): + castle_status = "$failed" + castle_api_endpoint = "filter" + else: + castle_status = "$succeeded" + castle_api_endpoint = "risk" + + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + 'user': { + 'id': os.getenv("valid_user_id"), + 'email': email, + 'name': name, + }, + 'request_token': request_token, + } + + castle = Client.from_request(request) + + if castle_api_endpoint == "risk": + verdict = castle.risk(payload_to_castle) + else: + verdict = castle.filter(payload_to_castle) + + return { + "api_endpoint": castle_api_endpoint, + "payload_to_castle": payload_to_castle, + "result": verdict, + "castle_type": castle_type, + "castle_status": castle_status, + }, 200, {'ContentType': 'application/json'} + ################################# # Risk / Filter (login) ################################# @@ -173,6 +222,44 @@ def evaluate_login(): return r, 200, {'ContentType':'application/json'} +################################# +# Risk (profile update) +################################# + +@app.route('/evaluate_profile_update', methods=['POST']) +def evaluate_profile_update(): + + name = request.json.get("name") + email = request.json.get("email") or os.getenv("valid_username") + request_token = request.json["request_token"] + + castle_type = "$profile_update" + castle_status = "$succeeded" + + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + 'user': { + 'id': os.getenv("valid_user_id"), + 'email': email, + 'name': name, + 'registered_at': registered_at, + }, + 'request_token': request_token, + } + + # A profile change is a sensitive action, so evaluate it with /risk. + castle = Client.from_request(request) + verdict = castle.risk(payload_to_castle) + + return { + "api_endpoint": "risk", + "payload_to_castle": payload_to_castle, + "result": verdict, + "castle_type": castle_type, + "castle_status": castle_status, + }, 200, {'ContentType': 'application/json'} + ################################# # Log (password reset) ################################# @@ -218,6 +305,39 @@ def evaluate_new_password(): return r, 200, {'ContentType':'application/json'} +################################# +# Log (logout) +################################# + +@app.route('/evaluate_logout', methods=['POST']) +def evaluate_logout(): + + request_token = request.json["request_token"] + + castle_type = "$logout" + castle_status = "$succeeded" + + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + 'user': { + 'id': os.getenv("valid_user_id"), + 'email': os.getenv("valid_username"), + }, + 'request_token': request_token, + } + + # Logout is recorded with the non-blocking log endpoint as well. + castle = Client.from_request(request) + castle.log(payload_to_castle) + + return { + "api_endpoint": "log", + "payload_to_castle": payload_to_castle, + "castle_type": castle_type, + "castle_status": castle_status, + }, 200, {'ContentType': 'application/json'} + ################################# # Lists API ################################# @@ -283,51 +403,3 @@ def privacy_user_data(): "result": result, }, 200, {'ContentType': 'application/json'} -################################# -# Events API -################################# - -@app.route('/events_schema', methods=['POST']) -def events_schema(): - - castle = castle_client() - - try: - result = castle.events_schema() - except CastleError as error: - result = {"error": str(error)} - - return { - "api_endpoint": "events/schema", - "payload_to_castle": {}, - "result": result, - }, 200, {'ContentType': 'application/json'} - -@app.route('/query_events', methods=['POST']) -def query_events(): - - print(request.json) - - payload = { - 'filters': [ - { - 'field': request.json.get('field') or 'name', - 'op': request.json.get('op') or '$eq', - 'value': request.json.get('value') or '$login', - } - ], - 'sort': {'field': 'created_at', 'order': 'desc'}, - } - - castle = castle_client() - - try: - result = castle.query_events(payload) - except CastleError as error: - result = {"error": str(error)} - - return { - "api_endpoint": "events/query", - "payload_to_castle": payload, - "result": result, - }, 200, {'ContentType': 'application/json'} diff --git a/demo_config.py b/demo_config.py index a8b2506..1a8524a 100644 --- a/demo_config.py +++ b/demo_config.py @@ -4,11 +4,19 @@ ################################# demos = { + "signup": { + "friendly_name": "sign up", + "blurb": "Evaluate a registration ($registration) with the risk endpoint." + }, "login": { "friendly_name": "login", "blurb": "Evaluate a login with the risk and filter endpoints.", "wsd": "https://www.websequencediagrams.com/files/render?link=Q9WYp8rNThVZhA1inf2FSLfjChYZTdHXyGB9zqvMNpsaAvKvJPARgo5LI5fM5K4D" }, + "account": { + "friendly_name": "account", + "blurb": "Update your profile, send a custom event, and log out." + }, "password_reset": { "friendly_name": "password reset", "blurb": "Record a password-reset event with the non-blocking log endpoint." @@ -20,10 +28,6 @@ "privacy": { "friendly_name": "privacy", "blurb": "Request or delete a user's data with the Privacy API." - }, - "events": { - "friendly_name": "events", - "blurb": "Inspect your event schema and query events." } } diff --git a/readme.md b/readme.md index 024681c..44c20cd 100644 --- a/readme.md +++ b/readme.md @@ -4,11 +4,16 @@ This project demonstrates key components of several essential Castle workflows. ## What's demonstrated -- **login** – `risk` (successful login) and `filter` (failed login) endpoints -- **password reset** – the non-blocking `log` endpoint +The app walks through a full user lifecycle. Every action mints a fresh Castle +request token in the browser (`Castle.createRequestToken()`) and forwards it to +the backend. + +- **sign up** – `$registration` to `risk` (a new email) or `filter` (an email that already exists) +- **login** – `$login` to `risk` (successful) or `filter` (failed) +- **account** – post-login actions: profile update (`$profile_update` to `risk`), a custom event (`Castle.custom()`), and logout (`$logout` via the non-blocking `log` endpoint) +- **password reset** – `$password_reset` via the non-blocking `log` endpoint - **lists** – the Lists API (`create_list`, `get_all_lists`) - **privacy** – the Privacy API (`request_user_data`, `delete_user_data`) -- **events** – the Events API (`events_schema`, `query_events`) ## Screenshots diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..8f9ddc8 --- /dev/null +++ b/templates/account.html @@ -0,0 +1,76 @@ +{% extends 'demo.html' %} + +{% block ui %} + +

Signed in as {{ valid_username }}. These actions run once a user is authenticated.

+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ +{% endblock %} + +{% block desc %} + +

The post-login actions each mint a fresh request token from castle.js:

+
    +
  1. profile update$profile_update sent to /risk.
  2. +
  3. custom eventCastle.custom() in the browser.
  4. +
  5. logout$logout via the non-blocking /log endpoint.
  6. +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/events.html b/templates/events.html deleted file mode 100644 index d199b4c..0000000 --- a/templates/events.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends 'demo.html' %} - -{% block ui %} - -
- -
- -
- -
- - -
-
- - -
-
- - -
-
- -
- -{% endblock %} - -{% block desc %} - -

The Events API (added in castle 7.1) lets you inspect your event schema (/events/schema) and query recorded events (/events/query).

-

A valid Castle API secret is required for these calls to succeed.

- -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/templates/login.html b/templates/login.html index ecfb525..cdea33f 100644 --- a/templates/login.html +++ b/templates/login.html @@ -20,6 +20,10 @@
+ {% endblock %} @@ -62,7 +66,18 @@ email: document.getElementById("email").value, password: document.getElementById("password").value, request_token: requestToken, - }).then(renderCastleResponse); + }).then(function (data) { + renderCastleResponse(data); + const action = data.result && data.result.policy && data.result.policy.action; + if (action === "allow") { + const results = document.getElementById("results"); + const wrap = document.createElement("div"); + wrap.className = "result-block"; + wrap.innerHTML = + 'Continue to your account →'; + results.appendChild(wrap); + } + }); }); } diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..2aaea1e --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,84 @@ +{% extends 'demo.html' %} + +{% block ui %} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +{% endblock %} + +{% block desc %} + +

A registration is a sensitive action, so it is risk-assessed:

+
    +
  1. a new email$registration / $succeeded sent to /risk; act on the verdict (allow, challenge, deny).
  2. +
  3. an email that already exists$registration / $failed sent to /filter.
  4. +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tests/test_pages.py b/tests/test_pages.py index 2cb63be..06bb911 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -19,7 +19,9 @@ def test_every_demo_page_renders(client, demo_name): def test_demo_list_matches_config(): # Guards against the demo list and the URL allowlist drifting apart. - assert set(valid_urls) == {"login", "password_reset", "lists", "privacy", "events"} + assert set(valid_urls) == { + "signup", "login", "account", "password_reset", "lists", "privacy" + } def test_unknown_demo_renders_error_page(client): diff --git a/tests/test_sdk_integration.py b/tests/test_sdk_integration.py index bf79b8d..93e7219 100644 --- a/tests/test_sdk_integration.py +++ b/tests/test_sdk_integration.py @@ -99,6 +99,83 @@ def test_unknown_user_calls_filter_without_user_id(self, client, fake_sdk): assert "registered_at" not in sent["user"] +# --------------------------------------------------------------------------- +# Risk / filter (registration) +# --------------------------------------------------------------------------- +class TestEvaluateSignup: + def test_new_email_is_risk_assessed(self, client, fake_sdk): + fake_sdk.risk.return_value = {"policy": {"action": "allow"}} + + resp = _post(client, "/evaluate_signup", { + "name": "Lois Lane", + "email": "lois.lane@dailyplanet.com", + "request_token": "tok-1", + }) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["api_endpoint"] == "risk" + assert body["castle_type"] == "$registration" + assert body["castle_status"] == "$succeeded" + fake_sdk.risk.assert_called_once() + sent = fake_sdk.risk.call_args.args[0] + assert sent["user"]["email"] == "lois.lane@dailyplanet.com" + assert sent["user"]["name"] == "Lois Lane" + + def test_existing_email_goes_to_filter(self, client, fake_sdk): + fake_sdk.filter.return_value = {"policy": {"action": "deny"}} + + resp = _post(client, "/evaluate_signup", { + "name": "Clark Kent", + "email": "clark.kent@dailyplanet.com", + "request_token": "tok-2", + }) + + body = resp.get_json() + assert body["api_endpoint"] == "filter" + assert body["castle_status"] == "$failed" + fake_sdk.filter.assert_called_once() + fake_sdk.risk.assert_not_called() + + +# --------------------------------------------------------------------------- +# Risk (profile update) +# --------------------------------------------------------------------------- +class TestEvaluateProfileUpdate: + def test_profile_update_calls_risk(self, client, fake_sdk): + fake_sdk.risk.return_value = {"policy": {"action": "allow"}} + + resp = _post(client, "/evaluate_profile_update", { + "name": "Kal-El", + "email": "kal.el@dailyplanet.com", + "request_token": "tok-3", + }) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["api_endpoint"] == "risk" + assert body["castle_type"] == "$profile_update" + fake_sdk.risk.assert_called_once() + sent = fake_sdk.risk.call_args.args[0] + assert sent["user"]["name"] == "Kal-El" + assert sent["user"]["email"] == "kal.el@dailyplanet.com" + + +# --------------------------------------------------------------------------- +# Log (logout) +# --------------------------------------------------------------------------- +class TestEvaluateLogout: + def test_logout_logs_event(self, client, fake_sdk): + resp = _post(client, "/evaluate_logout", {"request_token": "tok-4"}) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["api_endpoint"] == "log" + assert body["castle_type"] == "$logout" + fake_sdk.log.assert_called_once() + assert fake_sdk.log.call_args.args[0]["type"] == "$logout" + + # --------------------------------------------------------------------------- # Log (password reset) # --------------------------------------------------------------------------- @@ -224,58 +301,3 @@ def test_castle_error_is_handled(self, client, fake_sdk): body = resp.get_json() assert body["api_endpoint"] == "privacy" assert body["result"] == {"error": "privacy failure"} - - -# --------------------------------------------------------------------------- -# Events API -# --------------------------------------------------------------------------- -class TestEvents: - def test_events_schema_success(self, client, fake_sdk): - fake_sdk.events_schema.return_value = {"fields": ["name"]} - - resp = _post(client, "/events_schema", {}) - - assert resp.status_code == 200 - body = resp.get_json() - assert body["api_endpoint"] == "events/schema" - assert body["result"] == {"fields": ["name"]} - fake_sdk.events_schema.assert_called_once() - - def test_events_schema_handles_castle_error(self, client, fake_sdk): - fake_sdk.events_schema.side_effect = CastleError("schema down") - - resp = _post(client, "/events_schema", {}) - - assert resp.get_json()["result"] == {"error": "schema down"} - - def test_query_events_default_filter(self, client, fake_sdk): - fake_sdk.query_events.return_value = {"events": []} - - resp = _post(client, "/query_events", {}) - - body = resp.get_json() - assert body["api_endpoint"] == "events/query" - sent = fake_sdk.query_events.call_args.args[0] - assert sent["filters"] == [{"field": "name", "op": "$eq", "value": "$login"}] - assert sent["sort"] == {"field": "created_at", "order": "desc"} - - def test_query_events_custom_filter(self, client, fake_sdk): - fake_sdk.query_events.return_value = {"events": []} - - resp = _post(client, "/query_events", { - "field": "user.id", - "op": "$neq", - "value": "00000000", - }) - - sent = fake_sdk.query_events.call_args.args[0] - assert sent["filters"] == [ - {"field": "user.id", "op": "$neq", "value": "00000000"} - ] - - def test_query_events_handles_castle_error(self, client, fake_sdk): - fake_sdk.query_events.side_effect = CastleError("query down") - - resp = _post(client, "/query_events", {}) - - assert resp.get_json()["result"] == {"error": "query down"}