Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 26 additions & 1 deletion apps/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
from participants.utils import (
has_participated_in_require_complete_profile_challenge,
)
from rest_auth.serializers import PasswordResetSerializer
from rest_auth.serializers import (
PasswordChangeSerializer,
PasswordResetConfirmSerializer,
PasswordResetSerializer,
)
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from .models import JwtToken, Profile
from .token_revocation import revoke_all_user_tokens

LOCKED_PROFILE_FIELDS = [
"first_name",
Expand Down Expand Up @@ -337,6 +342,26 @@ def create(self, validated_data):
return email_address


class CustomPasswordChangeSerializer(PasswordChangeSerializer):
"""
Change password and revoke all existing API/JWT credentials.
"""

def save(self):
self.set_password_form.save()
revoke_all_user_tokens(self.user)
Comment on lines +350 to +352

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether request-level transactions already cover these password endpoints.
rg -n --glob 'settings/**' 'ATOMIC_REQUESTS|DATABASES'
rg -n --glob 'apps/**/*.py' 'transaction\.atomic|revoke_all_user_tokens|CustomPassword(Change|ResetConfirm)Serializer'

Repository: Cloud-CV/EvalAI

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files | rg '(^|/)(apps/accounts/serializers\.py|.*settings.*\.py|.*settings/.*\.py|.*database.*\.py)$'

printf '\n== serializers outline ==\n'
ast-grep outline apps/accounts/serializers.py --view expanded || true

printf '\n== relevant settings hits ==\n'
rg -n 'ATOMIC_REQUESTS|DATABASES|AUTOCOMMIT|atomic\(' -g '*.py' .

printf '\n== revocation helper hits ==\n'
rg -n 'def revoke_all_user_tokens|revoke_all_user_tokens\(' -g '*.py' apps .

Repository: Cloud-CV/EvalAI

Length of output: 4480


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== serializers section ==\n'
sed -n '340,370p' apps/accounts/serializers.py

printf '\n== token revocation helper ==\n'
sed -n '1,220p' apps/accounts/token_revocation.py

printf '\n== settings common ==\n'
sed -n '1,220p' settings/common.py

printf '\n== settings env files atomics ==\n'
for f in settings/dev.py settings/prod.py settings/staging.py settings/test.py; do
  printf '\n--- %s ---\n' "$f"
  rg -n 'ATOMIC_REQUESTS|DATABASES|AUTOCOMMIT|transaction' "$f" || true
done

Repository: Cloud-CV/EvalAI

Length of output: 8491


Wrap password save/revocation in a transaction.
apps/accounts/serializers.py:350-362 Both serializers commit the new password before revoke_all_user_tokens(). If any revocation write fails, the password change can persist while old credentials remain usable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/accounts/serializers.py` around lines 350 - 352, The password update and
token revocation are not atomic in the serializer save path, so a failure in
revocation can leave the new password committed while old tokens still work.
Update the relevant serializer save methods in apps/accounts/serializers.py to
wrap both self.set_password_form.save() and revoke_all_user_tokens(self.user) in
a single transaction using the existing save method(s) as the entry point,
ensuring both operations succeed or fail together.



class CustomPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
"""
Reset password from email link and revoke all existing credentials.
"""

def save(self):
self.set_password_form.save()
revoke_all_user_tokens(self.user)


class CustomPasswordResetSerializer(PasswordResetSerializer):
"""
Serializer to check Account Active Status.
Expand Down
28 changes: 28 additions & 0 deletions apps/accounts/token_revocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from rest_framework_expiring_authtoken.models import ExpiringToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken

from .models import JwtToken


def revoke_all_user_tokens(user):
"""
Invalidate every active credential for a user.

Called after password change or reset so existing browser sessions,
API tokens, and JWT refresh tokens cannot keep working.
"""
ExpiringToken.objects.filter(user=user).delete()
JwtToken.objects.filter(user=user).delete()

for outstanding_token in OutstandingToken.objects.filter(user=user):
if BlacklistedToken.objects.filter(token=outstanding_token).exists():
continue
try:
RefreshToken(outstanding_token.token).blacklist()
except TokenError:
BlacklistedToken.objects.get_or_create(token=outstanding_token)
7 changes: 4 additions & 3 deletions frontend/src/js/controllers/changePwdCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@
parameters.callback = {
onSuccess: function() {
vm.user.error = false;
$rootScope.notify("success", "Your password has been changed successfully!");
$rootScope.notify("success", "Your password has been changed successfully! Please log in again.");
vm.stopLoader();
// navigate to challenge page
// $state.go('web.challenge-page.overview');
utilities.resetStorage();
$rootScope.isAuth = false;
$state.go('auth.login');
},
onError: function(response) {
vm.user.error = "Failed";
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/js/controllers/profileCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,10 @@
parameters.callback = {
onSuccess: function() {
vm.user.error = false;
$rootScope.notify("success", "Your password has been changed successfully!");
$state.go('web.profile.AuthToken');
$rootScope.notify("success", "Your password has been changed successfully! Please log in again.");
utilities.resetStorage();
$rootScope.isAuth = false;
$state.go('auth.login');
},
onError: function(response) {
vm.user.error = "Failed";
Expand Down
7 changes: 6 additions & 1 deletion frontend/tests/controllers-test/changePwdCtrl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,16 @@ describe('Unit tests for change password controller', function () {
var resetconfirmFormValid = true;
success = true;
$state.params.user_id = 1;
spyOn(utilities, 'resetStorage');
spyOn($state, 'go');

vm.changePassword(resetconfirmFormValid);
expect(vm.user.error).toBeFalsy();
expect($rootScope.notify).toHaveBeenCalledWith("success", "Your password has been changed successfully!");
expect($rootScope.notify).toHaveBeenCalledWith("success", "Your password has been changed successfully! Please log in again.");
expect(vm.stopLoader).toHaveBeenCalled();
expect(utilities.resetStorage).toHaveBeenCalled();
expect($rootScope.isAuth).toBeFalsy();
expect($state.go).toHaveBeenCalledWith('auth.login');
});

it('when old password is valid', function () {
Expand Down
9 changes: 6 additions & 3 deletions frontend/tests/controllers-test/profileCtrl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,14 +572,17 @@ describe('Unit tests for profile controller', function () {
$state.params = { user_id: 1 };
});

it('should call notify and go to AuthToken on success', function () {
it('should log out and redirect to login on success', function () {
spyOn(utilities, 'sendRequest').and.callFake(function (params) {
params.callback.onSuccess();
});
spyOn(utilities, 'resetStorage');
vm.changePassword(true);
expect(vm.user.error).toBe(false);
expect($rootScope.notify).toHaveBeenCalledWith("success", "Your password has been changed successfully!");
expect($state.go).toHaveBeenCalledWith('web.profile.AuthToken');
expect($rootScope.notify).toHaveBeenCalledWith("success", "Your password has been changed successfully! Please log in again.");
expect(utilities.resetStorage).toHaveBeenCalled();
expect($rootScope.isAuth).toBe(false);
expect($state.go).toHaveBeenCalledWith('auth.login');
});

it('should handle old_password error', function () {
Expand Down
6 changes: 6 additions & 0 deletions settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@
REST_AUTH_SERIALIZERS = {
"USER_DETAILS_SERIALIZER": "accounts.serializers.ProfileSerializer",
"PASSWORD_RESET_SERIALIZER": "accounts.serializers.CustomPasswordResetSerializer",
"PASSWORD_CHANGE_SERIALIZER": (
"accounts.serializers.CustomPasswordChangeSerializer"
),
"PASSWORD_RESET_CONFIRM_SERIALIZER": (
"accounts.serializers.CustomPasswordResetConfirmSerializer"
),
}

# Default email for sending emails..
Expand Down
159 changes: 159 additions & 0 deletions tests/unit/accounts/test_token_revocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from allauth.account.models import EmailAddress
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.urls import reverse_lazy
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from rest_framework_expiring_authtoken.models import ExpiringToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken

from accounts.models import JwtToken
from accounts.token_revocation import revoke_all_user_tokens


class BaseAPITestClass(APITestCase):
def setUp(self):
self.client = APIClient(enforce_csrf_checks=True)

self.user = User.objects.create(
username="someuser",
email="user@test.com",
password="secret_password",
)
Comment on lines +25 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant test file and nearby context
git ls-files tests/unit/accounts/test_token_revocation.py
wc -l tests/unit/accounts/test_token_revocation.py
sed -n '1,220p' tests/unit/accounts/test_token_revocation.py

# Find the user model / auth helpers used in this area
rg -n "create_user|set_password|check_password|old_password|token revocation|revocation" tests src . -g '!**/node_modules/**'

Repository: Cloud-CV/EvalAI

Length of output: 21870


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the password-change / reset serializers and any helper they use.
sed -n '300,390p' apps/accounts/serializers.py

# Inspect the token revocation helper for the side effects the tests assert.
sed -n '1,220p' apps/accounts/token_revocation.py

# Inspect the model used to store JWT tokens.
sed -n '1,220p' apps/accounts/models.py

Repository: Cloud-CV/EvalAI

Length of output: 7547


Create the fixture user with a hashed password. User.objects.create(...) leaves password unusable for old_password checks, so these password-change tests will fail before the revocation assertions run. Use create_user(...) or call set_password().

🧰 Tools
🪛 Ruff (0.15.18)

[error] 28-28: Possible hardcoded password assigned to argument: "password"

(S106)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accounts/test_token_revocation.py` around lines 25 - 29, The test
fixture user is being created with an unhashed password via User.objects.create,
which will break old_password validation in the revocation flow. Update the
setup in the test class to use User.objects.create_user or create the User and
call set_password before saving so the password-change and token revocation
checks operate on a valid hashed password.


EmailAddress.objects.create(
user=self.user, email="user@test.com", primary=True, verified=True
)


class RevokeAllUserTokensTest(BaseAPITestClass):
def test_revoke_all_user_tokens_deletes_api_token(self):
api_token = ExpiringToken.objects.create(user=self.user)

revoke_all_user_tokens(self.user)

self.assertFalse(ExpiringToken.objects.filter(pk=api_token.pk).exists())

def test_revoke_all_user_tokens_blacklists_jwt_and_deletes_record(self):
refresh = RefreshToken.for_user(self.user)
jwt_token = JwtToken.objects.create(
user=self.user,
refresh_token=str(refresh),
access_token=str(refresh.access_token),
)
outstanding_token = OutstandingToken.objects.filter(user=self.user).latest(
"created_at"
)

revoke_all_user_tokens(self.user)

self.assertFalse(JwtToken.objects.filter(pk=jwt_token.pk).exists())
self.assertTrue(
BlacklistedToken.objects.filter(token=outstanding_token).exists()
)
with self.assertRaises(TokenError):
RefreshToken(str(refresh))


class PasswordChangeTokenRevocationTest(BaseAPITestClass):
url = reverse_lazy("rest_password_change")

def setUp(self):
super().setUp()
self.api_token = ExpiringToken.objects.create(user=self.user)
self.client.credentials(HTTP_AUTHORIZATION="Token {}".format(self.api_token.key))

def test_password_change_revokes_existing_api_token(self):
response = self.client.post(
"/api/auth/password/change/",
{
"old_password": "secret_password",
"new_password1": "new_secret_password",
"new_password2": "new_secret_password",
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(
ExpiringToken.objects.filter(pk=self.api_token.pk).exists()
)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("new_secret_password"))

def test_password_change_revokes_jwt_credentials(self):
refresh = RefreshToken.for_user(self.user)
JwtToken.objects.create(
user=self.user,
refresh_token=str(refresh),
access_token=str(refresh.access_token),
)
outstanding_token = OutstandingToken.objects.filter(user=self.user).latest(
"created_at"
)

response = self.client.post(
"/api/auth/password/change/",
{
"old_password": "secret_password",
"new_password1": "new_secret_password",
"new_password2": "new_secret_password",
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(JwtToken.objects.filter(user=self.user).exists())
self.assertTrue(
BlacklistedToken.objects.filter(token=outstanding_token).exists()
)


class PasswordResetConfirmTokenRevocationTest(BaseAPITestClass):
url = reverse_lazy("rest_password_reset_confirm")

def setUp(self):
super().setUp()
self.api_token = ExpiringToken.objects.create(user=self.user)
self.uid = urlsafe_base64_encode(force_bytes(self.user.pk))
self.token = default_token_generator.make_token(self.user)

def test_password_reset_confirm_revokes_existing_credentials(self):
refresh = RefreshToken.for_user(self.user)
JwtToken.objects.create(
user=self.user,
refresh_token=str(refresh),
access_token=str(refresh.access_token),
)
outstanding_token = OutstandingToken.objects.filter(user=self.user).latest(
"created_at"
)

response = self.client.post(
"/api/auth/password/reset/confirm/",
{
"uid": self.uid,
"token": self.token,
"new_password1": "reset_secret_password",
"new_password2": "reset_secret_password",
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(
ExpiringToken.objects.filter(pk=self.api_token.pk).exists()
)
self.assertFalse(JwtToken.objects.filter(user=self.user).exists())
self.assertTrue(
BlacklistedToken.objects.filter(token=outstanding_token).exists()
)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("reset_secret_password"))
Loading