Skip to content

Commit 9f97309

Browse files
committed
Handle user creation.
* Use a default username algo. * Allow users to override the algo through settings.
1 parent 75341f4 commit 9f97309

File tree

4 files changed

+150
-8
lines changed

4 files changed

+150
-8
lines changed

mozilla_django_oidc/auth.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
import base64
2+
import hashlib
23
import jwt
4+
import logging
35
import requests
46

57
try:
68
from urllib import urlencode
79
except ImportError:
810
from urllib.parse import urlencode
911

12+
try:
13+
from django.utils.encoding import smart_bytes
14+
except ImportError:
15+
from django.utils.encoding import smart_str as smart_bytes
1016
from django.contrib.auth import get_user_model
1117
from django.core.urlresolvers import reverse
1218

1319
from mozilla_django_oidc.utils import absolutify, import_from_settings
1420

1521

22+
LOGGER = logging.getLogger(__name__)
23+
24+
25+
def default_username_algo(email):
26+
# bluntly stolen from django-browserid
27+
# store the username as a base64 encoded sha224 of the email address
28+
# this protects against data leakage because usernames are often
29+
# treated as public identifiers (so we can't use the email address).
30+
return base64.urlsafe_b64encode(
31+
hashlib.sha224(smart_bytes(email)).digest()
32+
).rstrip(b'=')
33+
34+
1635
class OIDCAuthenticationBackend(object):
1736
"""Override Django's authentication."""
1837

@@ -25,6 +44,20 @@ def __init__(self, *args, **kwargs):
2544

2645
self.UserModel = get_user_model()
2746

47+
def create_user(self, email, **kwargs):
48+
"""Return object for a newly created user account."""
49+
# bluntly stolen from django-browserid
50+
# https://github.com/mozilla/django-browserid/blob/master/django_browserid/auth.py
51+
52+
username_algo = import_from_settings('OIDC_USERNAME_ALGO', None)
53+
54+
if username_algo:
55+
username = username_algo(email)
56+
else:
57+
username = default_username_algo(email)
58+
59+
return self.UserModel.objects.create_user(username, email)
60+
2861
def verify_token(self, token, **kwargs):
2962
"""Validate the token signature."""
3063

@@ -68,12 +101,28 @@ def authenticate(self, code=None, state=None):
68101
user_response = requests.get('{url}?{query}'.format(url=self.OIDC_OP_USER_ENDPOINT,
69102
query=query))
70103
user_info = user_response.json()
104+
email = user_info.get('email')
105+
if not email:
106+
return None
71107

108+
create_user = False
72109
try:
73-
return self.UserModel.objects.get(email=user_info['email'])
110+
return self.UserModel.objects.get(email=email)
111+
except self.UserModel.MultipleObjectsReturned:
112+
# In the rare case that two user accounts have the same email address,
113+
# log and bail. Randomly selecting one seems really wrong.
114+
LOGGER.warn('Multiple users with email address %s.', email)
115+
return None
74116
except self.UserModel.DoesNotExist:
75-
return self.UserModel.objects.create_user(username=user_info['nickname'],
76-
email=user_info['email'])
117+
create_user = import_from_settings('OIDC_CREATE_USER', True)
118+
119+
if create_user:
120+
user = self.create_user(email)
121+
return user
122+
else:
123+
LOGGER.debug('Login failed: No user with email %s found, and '
124+
'OIDC_CREATE_USER is False', email)
125+
return None
77126
return None
78127

79128
def get_user(self, user_id):

mozilla_django_oidc/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
from django.core.exceptions import ImproperlyConfigured
88

99

10-
def import_from_settings(attr, default_val=None):
10+
def import_from_settings(attr, *args):
1111
"""
1212
Load an attribute from the django settings.
1313
1414
:raises:
1515
ImproperlyConfigured
1616
"""
1717
try:
18-
if default_val is not None:
19-
return getattr(settings, attr, default_val)
18+
if args:
19+
return getattr(settings, attr, args[0])
2020
return getattr(settings, attr)
2121
except AttributeError:
2222
raise ImproperlyConfigured('Setting {0} not found'.format(attr))

runtests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
SITE_ID=1,
2323
SITE_URL='http://example.com',
2424
MIDDLEWARE_CLASSES=(),
25+
OIDC_USERNAME_ALGO=None
2526
)
2627

2728
try:

tests/test_auth.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from mock import Mock, call, patch
22

3+
from django.conf import settings
34
from django.contrib.auth import get_user_model
45
from django.test import TestCase, override_settings
56

@@ -88,12 +89,14 @@ def test_successful_authentication_existing_user(self, token_mock, request_mock)
8889
'https://server.example.com/user?access_token=access_granted'
8990
)
9091

92+
@patch.object(settings, 'OIDC_USERNAME_ALGO')
9193
@patch('mozilla_django_oidc.auth.requests')
9294
@patch('mozilla_django_oidc.auth.OIDCAuthenticationBackend.verify_token')
9395
@override_settings(SITE_URL='http://site-url.com')
94-
def test_successful_authentication_new_user(self, token_mock, request_mock):
96+
def test_successful_authentication_new_user(self, token_mock, request_mock, algo_mock):
9597
"""Test successful authentication and user creation."""
9698

99+
algo_mock.return_value = 'username_algo'
97100
token_mock.return_value = True
98101
get_json_mock = Mock()
99102
get_json_mock.json.return_value = {
@@ -119,7 +122,7 @@ def test_successful_authentication_new_user(self, token_mock, request_mock):
119122
self.assertEqual(User.objects.all().count(), 1)
120123
user = User.objects.all()[0]
121124
self.assertEquals(user.email, '[email protected]')
122-
self.assertEquals(user.username, 'a_username')
125+
self.assertEquals(user.username, 'username_algo')
123126

124127
token_mock.assert_called_once_with('id_token')
125128
request_mock.post.assert_called_once_with('https://server.example.com/token',
@@ -189,3 +192,92 @@ def test_jwt_decode_params_verify_false(self, request_mock, jwt_mock):
189192

190193
self.backend.authenticate(code='foo', state='bar')
191194
jwt_mock.decode.assert_has_calls(calls)
195+
196+
@override_settings(OIDC_CREATE_USER=False)
197+
@patch('mozilla_django_oidc.auth.jwt')
198+
@patch('mozilla_django_oidc.auth.requests')
199+
def test_create_user_disabled(self, request_mock, jwt_mock):
200+
"""Test with user creation disabled and no user found."""
201+
202+
jwt_mock.return_value = True
203+
get_json_mock = Mock()
204+
get_json_mock.json.return_value = {
205+
'nickname': 'a_username',
206+
'email': '[email protected]'
207+
}
208+
request_mock.get.return_value = get_json_mock
209+
post_json_mock = Mock()
210+
post_json_mock.json.return_value = {
211+
'id_token': 'id_token',
212+
'access_token': 'access_granted'
213+
}
214+
request_mock.post.return_value = post_json_mock
215+
self.assertEqual(self.backend.authenticate(code='foo', state='bar'), None)
216+
217+
@patch('mozilla_django_oidc.auth.jwt')
218+
@patch('mozilla_django_oidc.auth.requests')
219+
def test_create_user_enabled(self, request_mock, jwt_mock):
220+
"""Test with user creation enabled and no user found."""
221+
222+
self.assertEqual(User.objects.filter(email='[email protected]').exists(), False)
223+
jwt_mock.return_value = True
224+
get_json_mock = Mock()
225+
get_json_mock.json.return_value = {
226+
'nickname': 'a_username',
227+
'email': '[email protected]'
228+
}
229+
request_mock.get.return_value = get_json_mock
230+
post_json_mock = Mock()
231+
post_json_mock.json.return_value = {
232+
'id_token': 'id_token',
233+
'access_token': 'access_granted'
234+
}
235+
request_mock.post.return_value = post_json_mock
236+
self.assertEqual(self.backend.authenticate(code='foo', state='bar'),
237+
User.objects.get(email='[email protected]'))
238+
239+
@patch.object(settings, 'OIDC_USERNAME_ALGO')
240+
@patch('mozilla_django_oidc.auth.jwt')
241+
@patch('mozilla_django_oidc.auth.requests')
242+
def test_custom_username_algo(self, request_mock, jwt_mock, algo_mock):
243+
"""Test user creation with custom username algorithm."""
244+
245+
self.assertEqual(User.objects.filter(email='[email protected]').exists(), False)
246+
algo_mock.return_value = 'username_algo'
247+
jwt_mock.return_value = True
248+
get_json_mock = Mock()
249+
get_json_mock.json.return_value = {
250+
'nickname': 'a_username',
251+
'email': '[email protected]'
252+
}
253+
request_mock.get.return_value = get_json_mock
254+
post_json_mock = Mock()
255+
post_json_mock.json.return_value = {
256+
'id_token': 'id_token',
257+
'access_token': 'access_granted'
258+
}
259+
request_mock.post.return_value = post_json_mock
260+
self.assertEqual(self.backend.authenticate(code='foo', state='bar'),
261+
User.objects.get(username='username_algo'))
262+
263+
@patch('mozilla_django_oidc.auth.jwt')
264+
@patch('mozilla_django_oidc.auth.requests')
265+
def test_duplicate_emails(self, request_mock, jwt_mock):
266+
"""Test auth with two users having the same email."""
267+
268+
User.objects.create(username='user1', email='[email protected]')
269+
User.objects.create(username='user2', email='[email protected]')
270+
jwt_mock.return_value = True
271+
get_json_mock = Mock()
272+
get_json_mock.json.return_value = {
273+
'nickname': 'a_username',
274+
'email': '[email protected]'
275+
}
276+
request_mock.get.return_value = get_json_mock
277+
post_json_mock = Mock()
278+
post_json_mock.json.return_value = {
279+
'id_token': 'id_token',
280+
'access_token': 'access_granted'
281+
}
282+
request_mock.post.return_value = post_json_mock
283+
self.assertEqual(self.backend.authenticate(code='foo', state='bar'), None)

0 commit comments

Comments
 (0)