From d0861bb4da35b6bc9b3c82899f46df69803a9ab6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 21:42:24 +0000 Subject: [PATCH 1/4] Changes before error encountered Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/fe1f22ac-0352-484a-96f4-2f4057311057 Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/data/models/user.py | 2 ++ flexmeasures/utils/config_defaults.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/user.py b/flexmeasures/data/models/user.py index 2f0239dfec..e558e1f700 100644 --- a/flexmeasures/data/models/user.py +++ b/flexmeasures/data/models/user.py @@ -272,6 +272,8 @@ class User(db.Model, UserMixin, AuthModelMixin): tf_primary_method = db.Column(db.String(255), nullable=True, default="email") # Faster token checking fs_uniquifier = Column(String(64), unique=True, nullable=False) + # Separate token uniquifier so API tokens can be rotated independently of sessions + fs_token_uniquifier = Column(String(64), unique=True, nullable=True, index=True) timezone = Column(String(255), default="Europe/Amsterdam") account_id = Column(Integer, db.ForeignKey("account.id"), nullable=False) diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index 4cbcc26cd3..6e0ba8e78d 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -69,7 +69,7 @@ class Config(object): SECURITY_RECOVERABLE: bool = True SECURITY_RESET_PASSWORD_TEMPLATE: str = "admin/reset_password.html" SECURITY_TOKEN_AUTHENTICATION_HEADER: str = "Authorization" - SECURITY_TOKEN_MAX_AGE: int = 60 * 60 * 6 # six hours + SECURITY_TOKEN_MAX_AGE: int | None = None # no expiry by default; set to e.g. 21600 for 6 hours SECURITY_TRACKABLE: bool = False # this is more in line with modern privacy law SECURITY_PASSWORD_SALT: str | None = None From eb0fdf8bdfee0a7fef3ae4a4740db7e96999b3f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:18:10 +0000 Subject: [PATCH 2/4] Add reset-auth-token endpoint and UI buttons (Phase 1) Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- .../tests/test_api_v3_0_users_fresh_db.py | 53 +++++++++++++++++++ flexmeasures/api/v3_0/users.py | 45 ++++++++++++++++ flexmeasures/data/services/users.py | 14 +++++ .../ui/templates/admin/logged_in_user.html | 20 +++++++ flexmeasures/ui/templates/users/user.html | 20 +++++++ 5 files changed, 152 insertions(+) diff --git a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py index 433190d188..7f0a1aeda5 100644 --- a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py @@ -142,3 +142,56 @@ def test_user_creation_api( ) print("Server responded with:\n%s" % user_creation_response.data) assert user_creation_response.status_code == status_code + + +@pytest.mark.parametrize( + "requesting_user, status_code", + [ + (None, 401), + ("test_prosumer_user@seita.nl", 403), + ("test_prosumer_user_2@seita.nl", 200), + ("test_admin_user@seita.nl", 200), + ("inactive_user@seita.nl", 401), + ("inactive_admin@seita.nl", 401), + ], + indirect=["requesting_user"], +) +def test_user_reset_auth_token( + fresh_db, app, client, setup_inactive_user_fresh_db, requesting_user, status_code +): + """ + Reset the auth token of User 2. + Only the admin user and User 2 themselves are allowed to do that. + The session uniquifier (fs_uniquifier) must NOT change; only the token uniquifier + (fs_token_uniquifier) must rotate. + """ + with UserContext("test_prosumer_user_2@seita.nl") as user2: + user2_id = user2.id + old_fs_uniquifier = user2.fs_uniquifier + old_token_uniquifier = user2.fs_token_uniquifier + + response = client.patch( + url_for("UserAPI:reset_user_auth_token", id=user2_id), + ) + print("Server responded with:\n%s" % response.json) + + assert response.status_code == status_code + if status_code != 200: + return + + user2 = find_user_by_email("test_prosumer_user_2@seita.nl") + + # The session uniquifier must NOT change (sessions stay valid) + assert user2.fs_uniquifier == old_fs_uniquifier + + # The token uniquifier must have been rotated (tokens invalidated) + assert user2.fs_token_uniquifier != old_token_uniquifier + + # An audit log entry must have been created + assert fresh_db.session.execute( + select(AuditLog).filter_by( + affected_user_id=user2.id, + event=f"Auth token reset for user {user2.username}", + active_user_id=requesting_user.id, + ) + ).scalar_one_or_none() diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index 6f7857cf21..e494c55ec3 100644 --- a/flexmeasures/api/v3_0/users.py +++ b/flexmeasures/api/v3_0/users.py @@ -20,6 +20,7 @@ from flexmeasures.data.schemas.users import UserSchema from flexmeasures.data.services.users import ( reset_password, + reset_token_access, remove_cookie_and_token_access, set_random_password, ) @@ -542,6 +543,50 @@ def reset_user_password(self, id: int, user: UserModel): # commit only if sending instructions worked, as well db.session.commit() + @route("//reset-auth-token", methods=["PATCH"]) + @use_kwargs(UserId, location="path") + @permission_required_for_context("update", ctx_arg_name="user") + @as_json + def reset_user_auth_token(self, id: int, user: UserModel): + """ + .. :quickref: Users; Reset auth token + --- + patch: + summary: Reset the user's auth token + description: | + Rotate the user's API auth token, invalidating all outstanding tokens without + affecting existing browser sessions. + This is useful when you suspect a token has been leaked or compromised. + + Users can reset their own tokens. Admins and account-admins can reset tokens of + users in their account. + parameters: + - in: path + name: id + required: true + schema: UserId + description: ID of the user whose auth token to reset. + responses: + 200: + description: PROCESSED + 400: + description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS + 401: + description: UNAUTHORIZED + 403: + description: INVALID_SENDER + 422: + description: UNPROCESSABLE_ENTITY + tags: + - Users + """ + reset_token_access(user) + user_audit_log = create_user_audit_log( + f"Auth token reset for user {user.username}", user + ) + db.session.add(user_audit_log) + db.session.commit() + @route("//auditlog") @use_kwargs(UserId, location="path") @permission_required_for_context( diff --git a/flexmeasures/data/services/users.py b/flexmeasures/data/services/users.py index ce96ba13b6..7ce221bd00 100644 --- a/flexmeasures/data/services/users.py +++ b/flexmeasures/data/services/users.py @@ -211,6 +211,20 @@ def remove_cookie_and_token_access(user: User): user_datastore.reset_user_access(user) +def reset_token_access(user: User): + """ + Rotate only the auth token uniquifier for a user, invalidating outstanding + API tokens without affecting existing browser sessions. + + Use this when you want to revoke API tokens without logging the user out + of their current browser session. + + Does not commit the session. + """ + user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role) + user_datastore.set_token_uniquifier(user) + + def delete_user(user: User): """ Delete the user (and also his assets and power measurements!). diff --git a/flexmeasures/ui/templates/admin/logged_in_user.html b/flexmeasures/ui/templates/admin/logged_in_user.html index 5938de011e..416c41c346 100644 --- a/flexmeasures/ui/templates/admin/logged_in_user.html +++ b/flexmeasures/ui/templates/admin/logged_in_user.html @@ -17,6 +17,11 @@ class="btn btn-sm btn-info delete-button" title="Reset the password and send instructions how to choose a new one.">Reset password +
+ +
@@ -97,4 +102,19 @@

User Overview

+ + {% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/users/user.html b/flexmeasures/ui/templates/users/user.html index af16dcef0b..22eb5b958d 100644 --- a/flexmeasures/ui/templates/users/user.html +++ b/flexmeasures/ui/templates/users/user.html @@ -31,6 +31,14 @@ title="Reset the password and send instructions how to choose a new one.">Reset password {% endif %} + + {% if can_edit_user_details %} +
+ +
+ {% endif %} @@ -328,5 +336,17 @@ initialRoles.forEach(role => currentRoles.add(role)); renderTags(); }); + +async function resetAuthToken(userId) { + const response = await fetch('/api/v3_0/users/' + userId + '/reset-auth-token', { + method: 'PATCH', + }); + if (response.ok) { + showToast('Access token has been reset. The user\'s browser session remains active.', 'success'); + } else { + const data = await response.json(); + showToast('Error: ' + (data.message || 'An unknown error occurred.'), 'error'); + } +} {% endblock %} \ No newline at end of file From b915c898550854a25d8759e451be338580ac1f56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:20:23 +0000 Subject: [PATCH 3/4] Apply remaining changes Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- .../v3_0/tests/test_api_v3_0_users_fresh_db.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py index 7f0a1aeda5..1e499b18a2 100644 --- a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py @@ -188,10 +188,13 @@ def test_user_reset_auth_token( assert user2.fs_token_uniquifier != old_token_uniquifier # An audit log entry must have been created - assert fresh_db.session.execute( - select(AuditLog).filter_by( - affected_user_id=user2.id, - event=f"Auth token reset for user {user2.username}", - active_user_id=requesting_user.id, - ) - ).scalar_one_or_none() + assert ( + fresh_db.session.execute( + select(AuditLog).filter_by( + affected_user_id=user2.id, + event=f"Auth token reset for user {user2.username}", + active_user_id=requesting_user.id, + ) + ).scalar_one_or_none() + is not None + ) From e7d75cc5d397979aa3de675df457d78123af3627 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 1 Jun 2026 12:36:06 +0200 Subject: [PATCH 4/4] style: black Signed-off-by: F.N. Claessen --- flexmeasures/utils/config_defaults.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index 6e0ba8e78d..762d224232 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -69,7 +69,9 @@ class Config(object): SECURITY_RECOVERABLE: bool = True SECURITY_RESET_PASSWORD_TEMPLATE: str = "admin/reset_password.html" SECURITY_TOKEN_AUTHENTICATION_HEADER: str = "Authorization" - SECURITY_TOKEN_MAX_AGE: int | None = None # no expiry by default; set to e.g. 21600 for 6 hours + SECURITY_TOKEN_MAX_AGE: int | None = ( + None # no expiry by default; set to e.g. 21600 for 6 hours + ) SECURITY_TRACKABLE: bool = False # this is more in line with modern privacy law SECURITY_PASSWORD_SALT: str | None = None