Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,59 @@ 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()
is not None
)
45 changes: 45 additions & 0 deletions flexmeasures/api/v3_0/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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("/<id>/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("/<id>/auditlog")
@use_kwargs(UserId, location="path")
@permission_required_for_context(
Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/data/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions flexmeasures/data/services/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!).
Expand Down
20 changes: 20 additions & 0 deletions flexmeasures/ui/templates/admin/logged_in_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -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</a>
</div>
<div>
<button class="btn btn-sm btn-warning nav-button"
onclick="resetAuthToken('{{ logged_in_user.id }}')"
title="Invalidate all outstanding API tokens for your account. Your browser session will remain active.">Reset access token</button>
</div>
</div>
</div>
<div class="col-md-8">
Expand Down Expand Up @@ -97,4 +102,19 @@ <h2>User Overview</h2>
</div>
</div>

<script>
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. Your browser session remains active.', 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
const data = await response.json();
showToast('Error: ' + (data.message || 'An unknown error occurred.'), 'error');
}
}
</script>

{% endblock %}
20 changes: 20 additions & 0 deletions flexmeasures/ui/templates/users/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
title="Reset the password and send instructions how to choose a new one.">Reset password</a>
</div>
{% endif %}

{% if can_edit_user_details %}
<div>
<button class="btn btn-sm btn-warning nav-button"
onclick="resetAuthToken('{{ user.id }}')"
title="Invalidate all outstanding API tokens for this user. Their browser session will remain active.">Reset access token</button>
</div>
{% endif %}
</div>

</div>
Expand Down Expand Up @@ -328,5 +336,17 @@ <h5 class="modal-title pe-2">Edit {{ user.username }}'s details</h5>
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');
}
}
</script>
{% endblock %}
4 changes: 3 additions & 1 deletion flexmeasures/utils/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 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

Expand Down
Loading