From 69ff10562d3c8d7f7dccd87e2fc46942053fc5e4 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Tue, 8 Sep 2026 12:05:04 +0200 Subject: [PATCH 1/2] Send completed password resets as $profile_reset on Risk --- app.py | 19 +++++++++---------- demo_config.py | 2 +- readme.md | 2 +- templates/password_reset.html | 8 ++++---- tests/test_sdk_integration.py | 25 ++++++++++++++++--------- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/app.py b/app.py index 85488f6..4625250 100644 --- a/app.py +++ b/app.py @@ -269,7 +269,7 @@ def evaluate_profile_update(): }, 200, {'ContentType': 'application/json'} ################################# -# Log (password reset) +# Risk (password reset) ################################# @app.route('/evaluate_new_password', methods=['POST']) @@ -286,7 +286,7 @@ def evaluate_new_password(): else: castle_status = "$succeeded" - castle_type = "$password_reset" + castle_type = "$profile_reset" payload_to_castle = { 'type': castle_type, @@ -298,20 +298,19 @@ def evaluate_new_password(): }, 'request_token': request_token } + if castle_status == "$succeeded": + payload_to_castle['changeset'] = {'password': {'changed': True}} - # $password_reset is a good fit for the non-blocking log endpoint: we want - # to record the event without waiting on a verdict. castle = Client.from_request(request) - castle.log(payload_to_castle) + verdict = castle.risk(payload_to_castle) - r = { - "api_endpoint": "log", + return { + "api_endpoint": "risk", "payload_to_castle": payload_to_castle, + "result": verdict, 'type': castle_type, 'status': castle_status, - } - - return r, 200, {'ContentType':'application/json'} + }, 200, {'ContentType':'application/json'} ################################# # Log (logout) diff --git a/demo_config.py b/demo_config.py index 99fd920..a03c78c 100644 --- a/demo_config.py +++ b/demo_config.py @@ -19,7 +19,7 @@ }, "password_reset": { "friendly_name": "password reset", - "blurb": "Record a password-reset event with the non-blocking log endpoint." + "blurb": "Assess a completed password reset ($profile_reset) with Risk." }, "lists": { "friendly_name": "lists", diff --git a/readme.md b/readme.md index 89607a4..3fd6515 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ the backend, which calls Castle and acts on the verdict. - **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 +- **password reset** – `$profile_reset` to `risk` (completed reset, after the user already passed the reset challenge) - **lists** – the Lists API (`create_list`, `get_all_lists`) - **privacy** – the Privacy API (`request_user_data`, `delete_user_data`) - **webhooks** – incoming Castle webhooks are signature-verified with `WebhooksVerify` (against the `X-Castle-Signature` header) and the most recent payloads are listed diff --git a/templates/password_reset.html b/templates/password_reset.html index e9132d8..5d65052 100644 --- a/templates/password_reset.html +++ b/templates/password_reset.html @@ -18,8 +18,8 @@ {% block desc %} -

This demo records the password-reset event with the non-blocking /log endpoint, which stores the event without returning a verdict.

-

Assume the user already passed your reset challenge (e.g. an emailed OTP). Enter a value different from the valid password to send $password_reset / $succeeded, or the valid password to send $password_reset / $failed. (The password is not actually changed.)

+

This demo assesses a completed password reset with $profile_reset on /risk.

+

Assume the user already passed your reset challenge (e.g. an emailed OTP). Enter a value different from the valid password to send $profile_reset / $succeeded, or the valid password to send $profile_reset / $failed. (The password is not actually changed.)

{% endblock %} @@ -33,8 +33,8 @@ }).then(function (data) { renderCastleResponse(data); document.getElementById("desc").innerHTML = - "

Logged " + data.type + " / " + data.status + - " via the /log endpoint.

"; + "

Assessed " + data.type + " / " + data.status + + " via the /risk endpoint.

"; }); }); } diff --git a/tests/test_sdk_integration.py b/tests/test_sdk_integration.py index 158718a..96b7834 100644 --- a/tests/test_sdk_integration.py +++ b/tests/test_sdk_integration.py @@ -187,10 +187,12 @@ def test_logout_logs_event(self, client, fake_sdk): # --------------------------------------------------------------------------- -# Log (password reset) +# Risk (password reset) # --------------------------------------------------------------------------- class TestEvaluateNewPassword: - def test_new_password_logs_succeeded(self, client, fake_sdk): + def test_new_password_risks_succeeded(self, client, fake_sdk): + fake_sdk.risk.return_value = {"policy": {"action": "allow"}} + resp = _post(client, "/evaluate_new_password", { "password": "a-brand-new-password", "request_token": "tok-1", @@ -198,16 +200,19 @@ def test_new_password_logs_succeeded(self, client, fake_sdk): assert resp.status_code == 200 body = resp.get_json() - assert body["api_endpoint"] == "log" + assert body["api_endpoint"] == "risk" assert body["status"] == "$succeeded" - fake_sdk.log.assert_called_once() - sent = fake_sdk.log.call_args.args[0] - assert sent["type"] == "$password_reset" + fake_sdk.risk.assert_called_once() + sent = fake_sdk.risk.call_args.args[0] + assert sent["type"] == "$profile_reset" assert sent["status"] == "$succeeded" assert sent["user"]["email"] == "clark.kent@dailyplanet.com" + assert sent["changeset"] == {"password": {"changed": True}} + + def test_reusing_current_password_risks_failed(self, client, fake_sdk): + fake_sdk.risk.return_value = {"policy": {"action": "allow"}} - def test_reusing_current_password_logs_failed(self, client, fake_sdk): resp = _post(client, "/evaluate_new_password", { "password": "supersecret", "request_token": "tok-2", @@ -215,8 +220,10 @@ def test_reusing_current_password_logs_failed(self, client, fake_sdk): body = resp.get_json() assert body["status"] == "$failed" - fake_sdk.log.assert_called_once() - assert fake_sdk.log.call_args.args[0]["status"] == "$failed" + fake_sdk.risk.assert_called_once() + sent = fake_sdk.risk.call_args.args[0] + assert sent["status"] == "$failed" + assert "changeset" not in sent # --------------------------------------------------------------------------- From a0b0e58e817aa812b4cfe19142204e9fccc12499 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Tue, 8 Sep 2026 12:17:32 +0200 Subject: [PATCH 2/2] Log completed password resets as $profile_reset --- app.py | 17 +++++++++-------- demo_config.py | 2 +- readme.md | 2 +- templates/password_reset.html | 6 +++--- tests/test_sdk_integration.py | 24 +++++++++--------------- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/app.py b/app.py index 4625250..c2bb829 100644 --- a/app.py +++ b/app.py @@ -269,7 +269,7 @@ def evaluate_profile_update(): }, 200, {'ContentType': 'application/json'} ################################# -# Risk (password reset) +# Log (password reset) ################################# @app.route('/evaluate_new_password', methods=['POST']) @@ -298,19 +298,20 @@ def evaluate_new_password(): }, 'request_token': request_token } - if castle_status == "$succeeded": - payload_to_castle['changeset'] = {'password': {'changed': True}} + # $profile_reset is a good fit for the non-blocking log endpoint: we want + # to record the event without waiting on a verdict. castle = Client.from_request(request) - verdict = castle.risk(payload_to_castle) + castle.log(payload_to_castle) - return { - "api_endpoint": "risk", + r = { + "api_endpoint": "log", "payload_to_castle": payload_to_castle, - "result": verdict, 'type': castle_type, 'status': castle_status, - }, 200, {'ContentType':'application/json'} + } + + return r, 200, {'ContentType':'application/json'} ################################# # Log (logout) diff --git a/demo_config.py b/demo_config.py index a03c78c..3c0598e 100644 --- a/demo_config.py +++ b/demo_config.py @@ -19,7 +19,7 @@ }, "password_reset": { "friendly_name": "password reset", - "blurb": "Assess a completed password reset ($profile_reset) with Risk." + "blurb": "Record a password-reset event ($profile_reset) with the non-blocking log endpoint." }, "lists": { "friendly_name": "lists", diff --git a/readme.md b/readme.md index 3fd6515..58e4606 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ the backend, which calls Castle and acts on the verdict. - **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** – `$profile_reset` to `risk` (completed reset, after the user already passed the reset challenge) +- **password reset** – `$profile_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`) - **webhooks** – incoming Castle webhooks are signature-verified with `WebhooksVerify` (against the `X-Castle-Signature` header) and the most recent payloads are listed diff --git a/templates/password_reset.html b/templates/password_reset.html index 5d65052..e78311c 100644 --- a/templates/password_reset.html +++ b/templates/password_reset.html @@ -18,7 +18,7 @@ {% block desc %} -

This demo assesses a completed password reset with $profile_reset on /risk.

+

This demo records the password-reset event with the non-blocking /log endpoint, which stores the event without returning a verdict.

Assume the user already passed your reset challenge (e.g. an emailed OTP). Enter a value different from the valid password to send $profile_reset / $succeeded, or the valid password to send $profile_reset / $failed. (The password is not actually changed.)

{% endblock %} @@ -33,8 +33,8 @@ }).then(function (data) { renderCastleResponse(data); document.getElementById("desc").innerHTML = - "

Assessed " + data.type + " / " + data.status + - " via the /risk endpoint.

"; + "

Logged " + data.type + " / " + data.status + + " via the /log endpoint.

"; }); }); } diff --git a/tests/test_sdk_integration.py b/tests/test_sdk_integration.py index 96b7834..ba47317 100644 --- a/tests/test_sdk_integration.py +++ b/tests/test_sdk_integration.py @@ -187,12 +187,10 @@ def test_logout_logs_event(self, client, fake_sdk): # --------------------------------------------------------------------------- -# Risk (password reset) +# Log (password reset) # --------------------------------------------------------------------------- class TestEvaluateNewPassword: - def test_new_password_risks_succeeded(self, client, fake_sdk): - fake_sdk.risk.return_value = {"policy": {"action": "allow"}} - + def test_new_password_logs_succeeded(self, client, fake_sdk): resp = _post(client, "/evaluate_new_password", { "password": "a-brand-new-password", "request_token": "tok-1", @@ -200,19 +198,16 @@ def test_new_password_risks_succeeded(self, client, fake_sdk): assert resp.status_code == 200 body = resp.get_json() - assert body["api_endpoint"] == "risk" + assert body["api_endpoint"] == "log" assert body["status"] == "$succeeded" - fake_sdk.risk.assert_called_once() - sent = fake_sdk.risk.call_args.args[0] + fake_sdk.log.assert_called_once() + sent = fake_sdk.log.call_args.args[0] assert sent["type"] == "$profile_reset" assert sent["status"] == "$succeeded" assert sent["user"]["email"] == "clark.kent@dailyplanet.com" - assert sent["changeset"] == {"password": {"changed": True}} - - def test_reusing_current_password_risks_failed(self, client, fake_sdk): - fake_sdk.risk.return_value = {"policy": {"action": "allow"}} + def test_reusing_current_password_logs_failed(self, client, fake_sdk): resp = _post(client, "/evaluate_new_password", { "password": "supersecret", "request_token": "tok-2", @@ -220,10 +215,9 @@ def test_reusing_current_password_risks_failed(self, client, fake_sdk): body = resp.get_json() assert body["status"] == "$failed" - fake_sdk.risk.assert_called_once() - sent = fake_sdk.risk.call_args.args[0] - assert sent["status"] == "$failed" - assert "changeset" not in sent + fake_sdk.log.assert_called_once() + assert fake_sdk.log.call_args.args[0]["type"] == "$profile_reset" + assert fake_sdk.log.call_args.args[0]["status"] == "$failed" # ---------------------------------------------------------------------------