diff --git a/app.py b/app.py index 1025562..85488f6 100644 --- a/app.py +++ b/app.py @@ -128,47 +128,44 @@ def demo(demo_name): return render_template(template, **params) ################################# -# Risk / Filter (registration) +# 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. + # A registration is evaluated before the account exists, so it is anonymous + # activity sent to /filter with the form params (email/phone only). A brand- + # new email is an attempt; an email that already belongs to a user is a + # failed registration, resolved to that user via matching_user_id. if email == os.getenv("valid_username"): castle_status = "$failed" - castle_api_endpoint = "filter" + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + 'params': {'email': email}, + 'matching_user_id': os.getenv("valid_user_id"), + 'request_token': request_token, + } 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_status = "$attempted" + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + 'params': {'email': email}, + '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) + verdict = castle.filter(payload_to_castle) return { - "api_endpoint": castle_api_endpoint, + "api_endpoint": "filter", "payload_to_castle": payload_to_castle, "result": verdict, "castle_type": castle_type, @@ -176,73 +173,62 @@ def evaluate_signup(): }, 200, {'ContentType': 'application/json'} ################################# -# Risk / Filter (login) +# Filter -> Risk (login) ################################# @app.route('/evaluate_login', methods=['POST']) def evaluate_login(): - global registered_at - - print(request.json) - email = request.json["email"] password = request.json["password"] request_token = request.json["request_token"] - # check validity of username + password combo - if email == os.getenv("valid_username"): + castle_type = "$login" - user_id = os.getenv("valid_user_id") + # A login reuses one request token across two calls: first Filter the attempt + # while the visitor is still anonymous, then — on success — assess the + # authenticated user with Risk. A failed attempt stays on Filter. + castle = Client.from_request(request) - if password == os.getenv("valid_password"): - castle_type = "$login" - castle_status = "$succeeded" - castle_api_endpoint = "risk" + def run_step(api_endpoint, castle_status, fields): + payload_to_castle = { + 'type': castle_type, + 'status': castle_status, + **fields, + 'request_token': request_token, + } + if api_endpoint == "risk": + verdict = castle.risk(payload_to_castle) else: - castle_type = "$login" - castle_status = "$failed" - castle_api_endpoint = "filter" + verdict = castle.filter(payload_to_castle) + return { + "api_endpoint": api_endpoint, + "payload_to_castle": payload_to_castle, + "result": verdict, + "castle_type": castle_type, + "castle_status": castle_status, + } + + # Step 1 — always filter the attempt up front (anonymous -> params). + steps = [run_step("filter", "$attempted", {'params': {'email': email}})] + + # Step 2 — the outcome, on the same request token. + if email == os.getenv("valid_username") and password == os.getenv("valid_password"): + steps.append(run_step("risk", "$succeeded", { + 'user': { + 'id': os.getenv("valid_user_id"), + 'email': email, + 'registered_at': registered_at, + }, + })) else: - castle_api_endpoint = "filter" - castle_type = "$login" - castle_status = "$failed" - user_id = None - registered_at = None + fields = {'params': {'email': email}} + # A known email with a wrong password resolves to the existing user. + if email == os.getenv("valid_username"): + fields['matching_user_id'] = os.getenv("valid_user_id") + steps.append(run_step("filter", "$failed", fields)) - payload_to_castle = { - 'type': castle_type, - 'status': castle_status, - 'user': { - 'id': user_id, - 'email': email - }, - 'request_token': request_token - } - - if registered_at: - payload_to_castle["user"]["registered_at"] = registered_at - - castle = Client.from_request(request) - - if castle_api_endpoint == "risk": - verdict = castle.risk(payload_to_castle) - - elif castle_api_endpoint == "filter": - verdict = castle.filter(payload_to_castle) - - print("verdict:") - print(verdict) - - r = { - "api_endpoint": castle_api_endpoint, - "payload_to_castle": payload_to_castle, - "result": verdict, - "castle_type": castle_type, - "castle_status": castle_status - } - - return r, 200, {'ContentType':'application/json'} + return {"steps": steps}, 200, {'ContentType': 'application/json'} ################################# # Risk (profile update) diff --git a/demo_config.py b/demo_config.py index 395ce81..99fd920 100644 --- a/demo_config.py +++ b/demo_config.py @@ -6,11 +6,11 @@ demos = { "signup": { "friendly_name": "sign up", - "blurb": "Evaluate a registration ($registration) with the risk endpoint." + "blurb": "Filter a registration ($registration) before the account exists." }, "login": { "friendly_name": "login", - "blurb": "Evaluate a login with the risk and filter endpoints.", + "blurb": "Filter the attempt, then assess a successful login with Risk.", "wsd": "https://www.websequencediagrams.com/files/render?link=Q9WYp8rNThVZhA1inf2FSLfjChYZTdHXyGB9zqvMNpsaAvKvJPARgo5LI5fM5K4D" }, "account": { diff --git a/readme.md b/readme.md index 43ba6bc..23a1833 100644 --- a/readme.md +++ b/readme.md @@ -9,8 +9,8 @@ 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, which calls Castle and acts on the verdict. -- **sign up** – `$registration` to `risk` (a new email) or `filter` (an email that already exists) -- **login** – `$login` to `risk` (successful) or `filter` (failed) +- **sign up** – `$registration` to `filter` (anonymous, so the email goes in `params`): `$attempted` for a new email, `$failed` (resolved via `matching_user_id`) for an email that already exists +- **login** – `$login` reusing one request token across two calls: `filter` `$attempted` first, then `risk` `$succeeded` on success or `filter` `$failed` (wrong password / unknown user) - **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`) diff --git a/static/app.js b/static/app.js index f1aea59..7318be6 100644 --- a/static/app.js +++ b/static/app.js @@ -96,3 +96,17 @@ function renderCastleResponse(data) { } showResultsCard(); } + +// Renders an ordered sequence of Castle calls (e.g. the login Filter -> Risk +// flow), one endpoint/payload/result block per step. +function renderCastleSteps(steps) { + clearResults(); + (steps || []).forEach(function (step) { + if (step.api_endpoint) addEndpointBadge(step.api_endpoint); + if (step.payload_to_castle) addJSONBlock("Payload sent to Castle", step.payload_to_castle); + if (step.result !== undefined && step.result !== null) { + addJSONBlock("Response from Castle", step.result); + } + }); + showResultsCard(); +} diff --git a/templates/login.html b/templates/login.html index cdea33f..acdc7f9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -30,11 +30,11 @@ {% block desc %} -

A login attempt has three common outcomes, and each maps to a different Castle endpoint:

+

A login reuses one request token across a two-step sequence:

    +
  1. the attempt is always filtered first → $login / $attempted sent to /filter (anonymous, so the email goes in params).
  2. valid username + valid password$login / $succeeded sent to /risk; act on the verdict (allow, challenge, deny).
  3. -
  4. valid username + invalid password$login / $failed sent to /filter.
  5. -
  6. invalid username$login / $failed (user id = null) sent to /filter.
  7. +
  8. wrong password / unknown user$login / $failed sent to /filter.
{% endblock %} @@ -67,8 +67,12 @@ password: document.getElementById("password").value, request_token: requestToken, }).then(function (data) { - renderCastleResponse(data); - const action = data.result && data.result.policy && data.result.policy.action; + renderCastleSteps(data.steps); + // Act on the outcome (the last step): a /risk allow lets the + // user continue to their account. + const steps = data.steps || []; + const last = steps[steps.length - 1]; + const action = last && last.result && last.result.policy && last.result.policy.action; if (action === "allow") { const results = document.getElementById("results"); const wrap = document.createElement("div"); diff --git a/templates/signup.html b/templates/signup.html index 2aaea1e..bebc26a 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -32,10 +32,10 @@ {% block desc %} -

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

+

A registration is evaluated before the account exists, so it is anonymous activity sent to /filter with the form params:

    -
  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. +
  5. a new email$registration / $attempted; act on the verdict (allow, challenge, deny) before creating the account.
  6. +
  7. an email that already exists$registration / $failed (resolved to the existing user via matching_user_id).
{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index e9baddd..4cf15ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,8 +25,8 @@ import app as app_module # noqa: E402 (must follow the env setup above) -# The known-good registration date the app uses as a module-level default. The -# `evaluate_login` handler mutates this global, so we restore it before each test. +# The known-good registration date the app uses as a module-level default. It is +# restored before each test so module state stays deterministic across the suite. DEFAULT_REGISTERED_AT = "2020-02-23T22:28:55.387Z" diff --git a/tests/test_sdk_integration.py b/tests/test_sdk_integration.py index 888e191..158718a 100644 --- a/tests/test_sdk_integration.py +++ b/tests/test_sdk_integration.py @@ -36,7 +36,8 @@ def fake_sdk(): # Risk / filter (login) # --------------------------------------------------------------------------- class TestEvaluateLogin: - def test_valid_credentials_call_risk(self, client, fake_sdk): + def test_valid_credentials_filter_attempt_then_risk(self, client, fake_sdk): + fake_sdk.filter.return_value = {"policy": {"action": "allow"}} fake_sdk.risk.return_value = {"policy": {"action": "allow"}} resp = _post(client, "/evaluate_login", { @@ -46,14 +47,19 @@ def test_valid_credentials_call_risk(self, client, fake_sdk): }) assert resp.status_code == 200 - body = resp.get_json() - assert body["api_endpoint"] == "risk" - assert body["castle_type"] == "$login" - assert body["castle_status"] == "$succeeded" - assert body["result"] == {"policy": {"action": "allow"}} + steps = resp.get_json()["steps"] + assert len(steps) == 2 + + attempt, outcome = steps + assert attempt["api_endpoint"] == "filter" + assert attempt["castle_status"] == "$attempted" + assert attempt["payload_to_castle"]["params"]["email"] == "clark.kent@dailyplanet.com" + assert outcome["api_endpoint"] == "risk" + assert outcome["castle_status"] == "$succeeded" + + fake_sdk.filter.assert_called_once() fake_sdk.risk.assert_called_once() - fake_sdk.filter.assert_not_called() sent = fake_sdk.risk.call_args.args[0] assert sent["type"] == "$login" assert sent["status"] == "$succeeded" @@ -62,7 +68,7 @@ def test_valid_credentials_call_risk(self, client, fake_sdk): assert sent["user"]["registered_at"] assert sent["request_token"] == "tok-123" - def test_valid_user_wrong_password_calls_filter(self, client, fake_sdk): + def test_wrong_password_filters_failure_with_matching_user(self, client, fake_sdk): fake_sdk.filter.return_value = {"policy": {"action": "deny"}} resp = _post(client, "/evaluate_login", { @@ -71,18 +77,17 @@ def test_valid_user_wrong_password_calls_filter(self, client, fake_sdk): "request_token": "tok-456", }) - body = resp.get_json() - assert body["api_endpoint"] == "filter" - assert body["castle_status"] == "$failed" + outcome = resp.get_json()["steps"][1] + assert outcome["api_endpoint"] == "filter" + assert outcome["castle_status"] == "$failed" - fake_sdk.filter.assert_called_once() + assert fake_sdk.filter.call_count == 2 fake_sdk.risk.assert_not_called() - sent = fake_sdk.filter.call_args.args[0] - # A known user keeps their id and registered_at. - assert sent["user"]["id"] == "00000000" - assert "registered_at" in sent["user"] + sent = outcome["payload_to_castle"] + assert sent["params"]["email"] == "clark.kent@dailyplanet.com" + assert sent["matching_user_id"] == "00000000" - def test_unknown_user_calls_filter_without_user_id(self, client, fake_sdk): + def test_unknown_user_filters_failure_without_matching_user(self, client, fake_sdk): fake_sdk.filter.return_value = {"policy": {"action": "deny"}} resp = _post(client, "/evaluate_login", { @@ -91,20 +96,20 @@ def test_unknown_user_calls_filter_without_user_id(self, client, fake_sdk): "request_token": "tok-789", }) - body = resp.get_json() - assert body["api_endpoint"] == "filter" - sent = fake_sdk.filter.call_args.args[0] - assert sent["user"]["id"] is None - # registered_at is dropped for an unknown user. - assert "registered_at" not in sent["user"] + outcome = resp.get_json()["steps"][1] + assert outcome["api_endpoint"] == "filter" + assert outcome["castle_status"] == "$failed" + sent = outcome["payload_to_castle"] + assert sent["params"]["email"] == "stranger@example.com" + assert "matching_user_id" not in sent # --------------------------------------------------------------------------- -# Risk / filter (registration) +# Filter (registration) # --------------------------------------------------------------------------- class TestEvaluateSignup: - def test_new_email_is_risk_assessed(self, client, fake_sdk): - fake_sdk.risk.return_value = {"policy": {"action": "allow"}} + def test_new_email_filtered_as_attempted(self, client, fake_sdk): + fake_sdk.filter.return_value = {"policy": {"action": "allow"}} resp = _post(client, "/evaluate_signup", { "name": "Lois Lane", @@ -114,15 +119,17 @@ def test_new_email_is_risk_assessed(self, client, fake_sdk): assert resp.status_code == 200 body = resp.get_json() - assert body["api_endpoint"] == "risk" + assert body["api_endpoint"] == "filter" 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" + assert body["castle_status"] == "$attempted" + fake_sdk.filter.assert_called_once() + fake_sdk.risk.assert_not_called() + sent = fake_sdk.filter.call_args.args[0] + assert sent["params"]["email"] == "lois.lane@dailyplanet.com" + assert "user" not in sent + assert "matching_user_id" not in sent - def test_existing_email_goes_to_filter(self, client, fake_sdk): + def test_existing_email_filtered_as_failed(self, client, fake_sdk): fake_sdk.filter.return_value = {"policy": {"action": "deny"}} resp = _post(client, "/evaluate_signup", { @@ -136,6 +143,9 @@ def test_existing_email_goes_to_filter(self, client, fake_sdk): assert body["castle_status"] == "$failed" fake_sdk.filter.assert_called_once() fake_sdk.risk.assert_not_called() + sent = fake_sdk.filter.call_args.args[0] + assert sent["params"]["email"] == "clark.kent@dailyplanet.com" + assert sent["matching_user_id"] == "00000000" # ---------------------------------------------------------------------------