Skip to content

Commit ded8383

Browse files
authored
OpenConceptLab/ocl_online#482 | SSO sign-in matches a legacy mixed-case username on its verified email instead of creating a case twin (#958)
1 parent d0ae522 commit ded8383

5 files changed

Lines changed: 125 additions & 31 deletions

File tree

‎core/common/backends.py‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from pydash import get
99
from redis import Sentinel
1010

11-
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused
11+
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused, SSOSignInRefused, \
12+
SSOUsernameCaseMismatchRefused
1213

1314
logger = logging.getLogger('oclapi')
1415

@@ -97,19 +98,16 @@ def update_user(self, user, claims):
9798
def authenticate(self, request, **kwargs):
9899
try:
99100
return super().authenticate(request, **kwargs)
100-
except (DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused):
101+
except SSOSignInRefused:
101102
# the refused identity's tokens were stored in the session before its account was looked up
102103
for key in ('oidc_access_token', 'oidc_id_token'):
103104
request.session.pop(key, None)
104105
return None
105106

106107
def filter_users_by_claims(self, claims):
107108
"""
108-
Matches the account linked to the claims' Keycloak id (`sub`), else the one with the claims' username exactly
109-
as Keycloak sends it, unless that account is linked to a different Keycloak user. A deactivated account is never
110-
matched on its username alone (ocl_online#339): it's reactivated when the claims carry its email, verified by
111-
Keycloak, and otherwise the sign-in is refused and the account left untouched. The username can't go to a new
112-
account either, since usernames are unique.
109+
Matches the account linked to the claims' `sub`, else by username exactly, else ignoring case. A deactivated
110+
or case-only match needs the claims' verified email; otherwise the sign-in is refused, never a new account.
113111
"""
114112
from core.users.models import UserProfile
115113

@@ -123,6 +121,8 @@ def filter_users_by_claims(self, claims):
123121
users = UserProfile.objects.filter(sso_id=sub) if sub else UserProfile.objects.none()
124122
if not users:
125123
users = UserProfile.objects.filter(username=username)
124+
if not users:
125+
users = self.filter_users_by_username_ignoring_case(username, claims)
126126
if users and sub and users[0].sso_id not in (None, sub):
127127
logger.warning('OIDC sign-in refused: user %s is linked to another Keycloak user', users[0].id)
128128
raise SSOIdentityMismatchRefused()
@@ -137,6 +137,19 @@ def filter_users_by_claims(self, claims):
137137

138138
return users
139139

140+
def filter_users_by_username_ignoring_case(self, username, claims):
141+
from core.users.models import UserProfile
142+
143+
users = UserProfile.objects.filter(username__iexact=username)
144+
if not users:
145+
return users
146+
if len(users) > 1 or not self.has_verified_email_of(users[0], claims):
147+
logger.warning(
148+
'OIDC sign-in refused: users %s match the username only ignoring case, and the claims lack the '
149+
'verified email of exactly one', [user.id for user in users])
150+
raise SSOUsernameCaseMismatchRefused()
151+
return users
152+
140153
@staticmethod
141154
def has_verified_email_of(user, claims):
142155
email = claims.get('email')

‎core/common/exceptions.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class Http403(APIException):
2828
default_code = 'forbidden'
2929

3030

31-
class DeactivatedAccountLoginRefused(AuthenticationFailed):
31+
class SSOSignInRefused(AuthenticationFailed):
32+
pass
33+
34+
35+
class DeactivatedAccountLoginRefused(SSOSignInRefused):
3236
"""
3337
Raised when a Keycloak sign-in's username matches a deactivated account but the claims don't carry that
3438
account's email, verified by Keycloak (ocl_online#339). The account is left as it was; staff can reactivate it.
@@ -42,7 +46,7 @@ def __init__(self):
4246
)
4347

4448

45-
class SSOIdentityMismatchRefused(AuthenticationFailed):
49+
class SSOIdentityMismatchRefused(SSOSignInRefused):
4650
"""Raised when a Keycloak sign-in's username matches an account already linked to a different Keycloak user."""
4751
default_code = 'sso_identity_mismatch'
4852

@@ -53,6 +57,17 @@ def __init__(self):
5357
)
5458

5559

60+
class SSOUsernameCaseMismatchRefused(SSOSignInRefused):
61+
"""Raised when a Keycloak sign-in's username matches an account only ignoring case, without its verified email."""
62+
default_code = 'sso_username_case_mismatch'
63+
64+
def __init__(self):
65+
super().__init__(
66+
'This username belongs to an existing OCL account with different capitalization. If it is yours, '
67+
f'contact {settings.COMMUNITY_EMAIL} and we will link it to your sign-in.'
68+
)
69+
70+
5671
class BatchIndexingError(Exception):
5772
"""
5873
Raised once a batched ES indexing run has attempted every batch and one or more failed (ocl_online#241).

‎core/common/tests.py‎

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
from core.users.models import UserProfile
7777
from core.users.tests.factories import UserProfileFactory
7878
from .backends import OCLOIDCAuthenticationBackend
79-
from .exceptions import SSOIdentityMismatchRefused
79+
from .exceptions import SSOIdentityMismatchRefused, SSOUsernameCaseMismatchRefused
8080
from .checksums import Checksum
8181
from .serializers import IdentifierSerializer
8282
from .validators import URIValidator
@@ -2802,20 +2802,71 @@ def test_update_user_keeps_a_stored_sso_id(self):
28022802
self.backend.update_user(user, {**self.claim, 'sub': 'sub-2'})
28032803
self.assertEqual(user.sso_id, 'sub-1')
28042804

2805-
def test_filter_users_by_claims_matches_username_exactly(self):
2806-
"""Keycloak sends usernames in lowercase, and a mixed-case account is never matched, active or not"""
2805+
def test_filter_users_by_claims_prefers_the_exact_username(self):
28072806
UserProfileFactory(username='Batman', email='batman@gotham.com')
2807+
batman = UserProfileFactory(username='batman', email='batman@gotham.com')
2808+
2809+
with self.assertNumQueries(1):
2810+
users = self.backend.filter_users_by_claims(self.claim)
2811+
self.assertEqual(list(users), [batman])
2812+
2813+
def test_filter_users_by_claims_matches_username_ignoring_case_with_its_verified_email(self):
2814+
batman = UserProfileFactory(username='Batman', email=' Batman@Gotham.com')
2815+
2816+
users = self.backend.filter_users_by_claims({**self.claim, 'sub': 'sub-1'})
2817+
self.assertEqual(list(users), [batman])
2818+
self.backend.update_user(users[0], {**self.claim, 'sub': 'sub-1'})
2819+
2820+
batman.refresh_from_db()
2821+
self.assertEqual(batman.username, 'Batman')
2822+
self.assertEqual(batman.sso_id, 'sub-1')
2823+
self.assertEqual(UserProfile.objects.filter(username__iexact='batman').count(), 1)
2824+
2825+
def test_filter_users_by_claims_refuses_username_ignoring_case_without_its_verified_email(self):
2826+
batman = UserProfileFactory(username='Batman', email='batman@gotham.com', first_name='Bat')
2827+
without_email_verified = {key: value for key, value in self.claim.items() if key != 'email_verified'}
2828+
2829+
for claims in [
2830+
{**self.claim, 'email_verified': False},
2831+
{**self.claim, 'email_verified': 'true'},
2832+
without_email_verified,
2833+
{**self.claim, 'email': 'joker@gotham.com'},
2834+
{**self.claim, 'email': None},
2835+
{**self.claim, 'email': ''},
2836+
]:
2837+
with self.subTest(claims=claims):
2838+
with patch('core.users.models.UserProfile.save') as save_mock:
2839+
with self.assertRaises(SSOUsernameCaseMismatchRefused):
2840+
self.backend.filter_users_by_claims(claims)
2841+
save_mock.assert_not_called()
2842+
2843+
batman.refresh_from_db()
2844+
self.assertEqual(batman.first_name, 'Bat')
2845+
self.assertIsNone(batman.sso_id)
2846+
2847+
def test_filter_users_by_claims_refuses_several_accounts_matching_username_ignoring_case(self):
2848+
UserProfileFactory(username='Batman', email='batman@gotham.com')
2849+
UserProfileFactory(username='BatMan', email='joker@gotham.com')
2850+
2851+
with self.assertRaises(SSOUsernameCaseMismatchRefused):
2852+
self.backend.filter_users_by_claims(self.claim)
2853+
2854+
def test_filter_users_by_claims_refuses_username_ignoring_case_linked_to_a_different_sub(self):
2855+
UserProfileFactory(username='Batman', email='batman@gotham.com', sso_id='sub-1')
2856+
2857+
with self.assertRaises(SSOIdentityMismatchRefused):
2858+
self.backend.filter_users_by_claims({**self.claim, 'sub': 'sub-2'})
2859+
2860+
def test_filter_users_by_claims_reactivates_deactivated_user_ignoring_case_with_its_verified_email(self):
28082861
robin = UserProfileFactory(username='Robin', email='robin@gotham.com')
28092862
robin.deactivate()
28102863

2811-
self.assertEqual(self.backend.filter_users_by_claims(self.claim).count(), 0)
2812-
self.assertEqual(
2813-
self.backend.filter_users_by_claims(
2814-
{**self.claim, 'preferred_username': 'robin', 'email': 'robin@gotham.com'}).count(),
2815-
0
2816-
)
2864+
users = self.backend.filter_users_by_claims(
2865+
{**self.claim, 'preferred_username': 'robin', 'email': 'robin@gotham.com'})
2866+
2867+
self.assertEqual(list(users), [robin])
28172868
robin.refresh_from_db()
2818-
self.assertFalse(robin.is_active)
2869+
self.assertTrue(robin.is_active)
28192870

28202871
def test_filter_users_by_claims_reactivates_deactivated_user_with_its_verified_email(self):
28212872
batman = UserProfileFactory(username='batman', email='Batman@Gotham.com ')
@@ -2936,19 +2987,24 @@ def test_get_or_create_user_for_deactivated_user(self, get_userinfo_mock):
29362987
self.assertEqual(batman.first_name, 'Bruce')
29372988

29382989
@patch('core.common.backends.OCLOIDCAuthenticationBackend.get_userinfo')
2939-
def test_get_or_create_user_leaves_mixed_case_deactivated_user(self, get_userinfo_mock):
2940-
get_userinfo_mock.return_value = self.claim
2990+
def test_get_or_create_user_for_mixed_case_user(self, get_userinfo_mock):
29412991
old_batman = UserProfileFactory(username='Batman', email='batman@gotham.com', first_name='Bat')
2942-
old_batman.deactivate()
29432992

2993+
get_userinfo_mock.return_value = {**self.claim, 'email': 'joker@gotham.com'}
2994+
with patch.object(self.backend, 'create_user') as create_user_mock:
2995+
with self.assertRaises(SSOUsernameCaseMismatchRefused):
2996+
self.backend.get_or_create_user('access-token', None, None)
2997+
create_user_mock.assert_not_called()
2998+
self.assertEqual(UserProfile.objects.filter(username__iexact='batman').count(), 1)
2999+
3000+
get_userinfo_mock.return_value = self.claim
29443001
user = self.backend.get_or_create_user('access-token', None, None)
29453002

2946-
self.assertNotEqual(user, old_batman)
2947-
self.assertEqual(user.username, 'batman')
2948-
self.assertTrue(user.is_active)
3003+
self.assertEqual(user, old_batman)
3004+
self.assertEqual(UserProfile.objects.filter(username__iexact='batman').count(), 1)
29493005
old_batman.refresh_from_db()
2950-
self.assertFalse(old_batman.is_active)
2951-
self.assertEqual(old_batman.first_name, 'Bat')
3006+
self.assertEqual(old_batman.username, 'Batman')
3007+
self.assertEqual(old_batman.first_name, 'Bruce')
29523008

29533009
@patch('mozilla_django_oidc.auth.OIDCAuthenticationBackend.authenticate')
29543010
def test_authenticate_fails_and_drops_stored_tokens_when_sign_in_is_refused(self, authenticate_mock):

‎core/middlewares/middlewares.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from core.common.authentication import OCLAuthentication
1515
from core.common.constants import VERSION_HEADER, REQUEST_USER_HEADER, RESPONSE_TIME_HEADER, REQUEST_URL_HEADER, \
1616
REQUEST_METHOD_HEADER
17-
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused
17+
from core.common.exceptions import SSOSignInRefused
1818
from core.common.throttling import FhirMinuteThrottle, ThrottleUtil
1919
from core.common.utils import set_current_user, set_request_url
2020
from core.fhir.constants import FHIR_JSON
@@ -135,7 +135,7 @@ def __call__(self, request):
135135
"""Allow exempt and approved anonymous traffic, otherwise return 403."""
136136
try:
137137
is_allowed = self.is_request_allowed(request)
138-
except (DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused) as ex:
138+
except SSOSignInRefused as ex:
139139
# a refused sign-in says why, rather than reading as anonymous access (ocl_online#339)
140140
headers = {'WWW-Authenticate': 'Bearer realm="api"'}
141141
if is_fhir_path(request.path):
@@ -181,7 +181,7 @@ def get_authenticated_user(request):
181181

182182
try:
183183
auth_result = OCLAuthentication().authenticate(Request(request))
184-
except (DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused):
184+
except SSOSignInRefused:
185185
raise
186186
except AuthenticationFailed:
187187
return user

‎core/middlewares/tests.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from django.test import RequestFactory, SimpleTestCase, override_settings
88
from rest_framework.exceptions import AuthenticationFailed
99

10-
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused
10+
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused, \
11+
SSOUsernameCaseMismatchRefused
1112
from core.middlewares.middlewares import RequireAuthenticationMiddleware
1213

1314

@@ -93,6 +94,15 @@ def test_rejects_sign_in_refused_for_identity_mismatch(self, authenticate_mock):
9394
self.assertEqual(response['WWW-Authenticate'], 'Bearer realm="api"')
9495
self.assertIn('different sign-in identity', json.loads(response.content)['detail'])
9596

97+
@patch('core.middlewares.middlewares.OCLAuthentication.authenticate')
98+
def test_rejects_sign_in_refused_for_username_case_mismatch(self, authenticate_mock):
99+
authenticate_mock.side_effect = SSOUsernameCaseMismatchRefused()
100+
101+
response = self.middleware(self.make_request('/user/', HTTP_AUTHORIZATION='Bearer sso-token'))
102+
103+
self.assertEqual(response.status_code, 401)
104+
self.assertIn('different capitalization', json.loads(response.content)['detail'])
105+
96106
def test_blocks_anonymous_request_for_protected_path(self):
97107
"""Anonymous traffic to protected API paths should receive a 403 response."""
98108
response = self.middleware(self.make_request('/orgs/OCL/'))

0 commit comments

Comments
 (0)