CVE-2019-10682 (published 2020-03-18T15:15Z)

django-nopassword before 5.0.0 stores cleartext secrets in the database.

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-312
cwe_name: Cleartext Storage of Sensitive Information
description: The product stores sensitive information in cleartext within a resource that might be accessible to another control sphere.
extended_description: 
	 Because the information is stored in cleartext (i.e., unencrypted), attackers could potentially read it. Even if the information is encoded in a way that is not human-readable, certain techniques could determine which encoding is being used, then decode the information.
	 When organizations adopt cloud services, it can be easier for attackers to access the data from anywhere on the Internet.
	 In some systems/environments such as cloud, the use of "double encryption" (at both the software and hardware layer) might be required, and the developer might be solely responsible for both layers, instead of shared responsibility with the administrator of the broader system/environment.
       
         

Fix information:
repo_url: https://github.com/relekang/django-nopassword
commit_details: repo_url: https://github.com/relekang/django-nopassword
author: Rolf Erik Lekang
author_date: 2019-04-06 21:33:21+02:00
msg: fix: Remove code from database
repository: repo_url: https://github.com/relekang/django-nopassword
repo_name: relekang/django-nopassword
description: Authentication backend for django that uses a one time code instead of passwords
repo_language: Python
owner: relekang
file_changes: filename: changelog.rst
change_type: ModificationType.MODIFY
diff: @@ -7,6 +7,9 @@ Changelog
 Breaking changes:
 
 - Removed custom length of login codes
+- Do not store the code in the database. Hash and compare on login instead. This might have an performance impact.
+- Add user id to the login code form and urls sent to the user.
+- Changing the secret key will now invalidate all login codes.
 
 4.0.1
 -----

programming_language: Markdown
filename: manage.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,11 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)

programming_language: Python
filename: base.py
change_type: ModificationType.MODIFY
diff: @@ -31,10 +31,13 @@ class NoPasswordBackend(ModelBackend):
             # as that is done during validation of the login form
             # and validation should not have any side effects.
             # It is the responsibility of the view/form to delete the token
-            # as soon as the login was successfull.
-            user.login_code = LoginCode.objects.get(user=user, code=code, timestamp__gt=timestamp)
+            # as soon as the login was successful.
 
-            return user
+            for c in LoginCode.objects.filter(user=user, timestamp__gt=timestamp):
+                if c.code == code:
+                    user.login_code = c
+                    return user
+            return
 
         except (get_user_model().DoesNotExist, LoginCode.DoesNotExist):
             return

programming_language: Python
filename: forms.py
change_type: ModificationType.MODIFY
diff: @@ -61,10 +61,11 @@ class LoginForm(forms.Form):
         else:
             site_name = domain = domain_override
 
-        url = '{}://{}{}?code={}'.format(
+        url = '{}://{}{}?user={}&code={}'.format(
             'https' if request.is_secure() else 'http',
             domain,
             resolve_url(login_code_url),
+            login_code.user.pk,
             login_code.code,
         )
 
@@ -95,11 +96,9 @@ class LoginForm(forms.Form):
 
 
 class LoginCodeForm(forms.Form):
-    code = forms.ModelChoiceField(
+    user = forms.CharField()
+    code = forms.CharField(
         label=_('Login code'),
-        queryset=models.LoginCode.objects.select_related('user'),
-        to_field_name='code',
-        widget=forms.TextInput,
         error_messages={
             'invalid_choice': _('Login code is invalid. It might have expired.'),
         },
@@ -114,12 +113,19 @@ class LoginCodeForm(forms.Form):
 
         self.request = request
 
-    def clean_code(self):
+    def clean(self):
+        user_id = self.cleaned_data.get('user', None)
+        if user_id is None:
+            raise forms.ValidationError(
+                self.error_messages['invalid_code'],
+                code='invalid_code',
+            )
+
+        user = get_user_model().objects.get(pk=user_id)
         code = self.cleaned_data['code']
-        username = code.user.get_username()
         user = authenticate(self.request, **{
-            get_user_model().USERNAME_FIELD: username,
-            'code': code.code,
+            get_user_model().USERNAME_FIELD: user.username,
+            'code': code,
         })
 
         if not user:
@@ -130,10 +136,11 @@ class LoginCodeForm(forms.Form):
 
         self.cleaned_data['user'] = user
 
-        return code
+        return self.cleaned_data
 
     def get_user(self):
         return self.cleaned_data.get('user')
 
     def save(self):
-        self.cleaned_data['code'].delete()
+        if self.get_user().login_code:
+            self.get_user().login_code.delete()

programming_language: Python
filename: 0002_auto_20190406_1322.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,24 @@
+# Generated by Django 2.2 on 2019-04-06 13:22
+
+import uuid
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('nopassword', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='logincode',
+            name='code',
+        ),
+        migrations.AlterField(
+            model_name='logincode',
+            name='id',
+            field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True,
+                                   serialize=False),
+        ),
+    ]

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 import hashlib
-import os
+import uuid
 
 from django.conf import settings
 from django.db import models
@@ -9,15 +9,27 @@ from django.utils.translation import ugettext_lazy as _
 
 
 class LoginCode(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='login_codes',
                              editable=False, verbose_name=_('user'), on_delete=models.CASCADE)
-    code = models.CharField(max_length=20, editable=False, verbose_name=_('code'))
     timestamp = models.DateTimeField(editable=False)
     next = models.TextField(editable=False, blank=True)
 
     def __str__(self):
         return "%s - %s" % (self.user, self.timestamp)
 
+    @property
+    def code(self):
+        hash_algorithm = getattr(settings, 'NOPASSWORD_HASH_ALGORITHM', 'sha256')
+        m = getattr(hashlib, hash_algorithm)()
+        m.update(getattr(settings, 'SECRET_KEY', None).encode('utf-8'))
+        m.update(str(self.id).encode())
+        if getattr(settings, 'NOPASSWORD_NUMERIC_CODES', False):
+            hashed = str(int(m.hexdigest(), 16))
+        else:
+            hashed = m.hexdigest()
+        return hashed
+
     def save(self, *args, **kwargs):
         self.timestamp = timezone.now()
 
@@ -31,21 +43,8 @@ class LoginCode(models.Model):
         if not user.is_active:
             return None
 
-        code = cls.generate_code()
-        login_code = LoginCode(user=user, code=code)
+        login_code = LoginCode(user=user)
         if next is not None:
             login_code.next = next
         login_code.save()
         return login_code
-
-    @classmethod
-    def generate_code(cls):
-        hash_algorithm = getattr(settings, 'NOPASSWORD_HASH_ALGORITHM', 'sha256')
-        m = getattr(hashlib, hash_algorithm)()
-        m.update(getattr(settings, 'SECRET_KEY', None).encode('utf-8'))
-        m.update(os.urandom(16))
-        if getattr(settings, 'NOPASSWORD_NUMERIC_CODES', False):
-            hashed = str(int(m.hexdigest(), 16))
-        else:
-            hashed = m.hexdigest()
-        return hashed

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -54,7 +54,7 @@ class LoginCodeView(GenericAPIView):
             context=self.get_serializer_context(),
         )
         data = token_serializer.data
-        data['next'] = self.serializer.validated_data['code'].next
+        data['next'] = self.serializer.validated_data['user'].login_code.next
         return Response(data, status=status.HTTP_200_OK)
 
     def post(self, request, *args, **kwargs):

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,7 @@
 from django.db import models
 
 try:
-    from django.contrib.auth.models import AbstractUser
+    from django.contrib.auth.models import AbstractUser, UserManager
 except ImportError:
     from django.db.models import Model as AbstractUser
 

programming_language: Python
filename: settings.py
change_type: ModificationType.MODIFY
diff: @@ -1,13 +1,14 @@
 # -*- coding: utf8 -*-
+import os
 
 import django
 
-DEBUG = False
+DEBUG = True
 
 DATABASES = {
     'default': {
         'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': ':memory:',
+        'NAME': os.environ.get('DB_NAME', ':memory:'),
     }
 }
 
@@ -20,6 +21,7 @@ INSTALLED_APPS = [
     'django.contrib.auth',
     'django.contrib.contenttypes',
     'django.contrib.sessions',
+    'django.contrib.messages',
 
     'rest_framework',
     'rest_framework.authtoken',
@@ -49,6 +51,7 @@ TEMPLATES = [
         'OPTIONS': {
             'context_processors': [
                 'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages'
             ],
         },
     },
@@ -67,7 +70,7 @@ if django.VERSION < (1, 10):
 
 ROOT_URLCONF = 'tests.urls'
 
-EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
+EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
 
 REST_FRAMEWORK = {
     'DEFAULT_AUTHENTICATION_CLASSES': (

programming_language: Python
filename: test_models.py
change_type: ModificationType.MODIFY
diff: @@ -16,6 +16,9 @@ class TestLoginCodes(TestCase):
         self.inactive_user = get_user_model().objects.create(username='inactive', is_active=False)
         self.code = LoginCode.create_code_for_user(self.user)
 
+    def tearDown(self):
+        LoginCode.objects.all().delete()
+
     def test_login_backend(self):
         self.assertEqual(len(self.code.code), 64)
         self.assertIsNotNone(authenticate(username=self.user.username, code=self.code.code))
@@ -38,5 +41,5 @@ class TestLoginCodes(TestCase):
         self.assertIsNone(authenticate(username=self.user.username, code=timeout_code.code))
 
     def test_str(self):
-        code = LoginCode(user=self.user, code='foo', timestamp=datetime(2018, 7, 1))
+        code = LoginCode(user=self.user, timestamp=datetime(2018, 7, 1))
         self.assertEqual(str(code), 'test_user - 2018-07-01 00:00:00')

programming_language: Python
filename: test_rest_views.py
change_type: ModificationType.MODIFY
diff: @@ -26,7 +26,10 @@ class TestRestViews(TestCase):
         self.assertEqual(login_code.next, '/private/')
         self.assertEqual(len(mail.outbox), 1)
         self.assertIn(
-            'http://testserver/accounts/login/code/?code={}'.format(login_code.code),
+            'http://testserver/accounts/login/code/?user={}&code={}'.format(
+                login_code.user.pk,
+                login_code.code
+            ),
             mail.outbox[0].body,
         )
 
@@ -62,9 +65,10 @@ class TestRestViews(TestCase):
         })
 
     def test_login(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar', next='/private/')
+        login_code = LoginCode.objects.create(user=self.user, next='/private/')
 
         response = self.client.post('/accounts-rest/login/code/', {
+            'user': login_code.user.pk,
             'code': login_code.code,
         })
 
@@ -94,14 +98,15 @@ class TestRestViews(TestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
-            'code': ['Login code is invalid. It might have expired.'],
+            '__all__': ['Unable to log in with provided login code.'],
+            'user': ['This field is required.']
         })
 
     def test_login_inactive_user(self):
         self.user.is_active = False
         self.user.save()
 
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         response = self.client.post('/accounts-rest/login/code/', {
             'code': login_code.code,
@@ -109,7 +114,8 @@ class TestRestViews(TestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
-            'code': ['Unable to log in with provided login code.'],
+            '__all__': ['Unable to log in with provided login code.'],
+            'user': ['This field is required.']
         })
 
     def test_logout(self):
@@ -124,7 +130,7 @@ class TestRestViews(TestCase):
         self.assertFalse(Token.objects.filter(user=self.user).exists())
 
     def test_logout_unknown_token(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         self.client.login(username=self.user.username, code=login_code.code)
 

programming_language: Python
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -26,7 +26,10 @@ class TestViews(TestCase):
         self.assertEqual(login_code.next, '/private/')
         self.assertEqual(len(mail.outbox), 1)
         self.assertIn(
-            'http://testserver/accounts/login/code/?code={}'.format(login_code.code),
+            'http://testserver/accounts/login/code/?user={}&code={}'.format(
+                login_code.user.pk,
+                login_code.code
+            ),
             mail.outbox[0].body,
         )
 
@@ -62,9 +65,10 @@ class TestViews(TestCase):
         })
 
     def test_login_post(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar', next='/private/')
+        login_code = LoginCode.objects.create(user=self.user, next='/private/')
 
         response = self.client.post('/accounts/login/code/', {
+            'user': login_code.user.pk,
             'code': login_code.code,
         })
 
@@ -74,22 +78,24 @@ class TestViews(TestCase):
         self.assertFalse(LoginCode.objects.filter(pk=login_code.pk).exists())
 
     def test_login_get(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         response = self.client.get('/accounts/login/code/', {
+            'user': login_code.user.pk,
             'code': login_code.code,
         })
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.context['form'].cleaned_data['code'], login_code)
+        self.assertEqual(response.context['form'].cleaned_data['code'], login_code.code)
         self.assertTrue(response.wsgi_request.user.is_anonymous)
         self.assertTrue(LoginCode.objects.filter(pk=login_code.pk).exists())
 
     @override_settings(NOPASSWORD_LOGIN_ON_GET=True)
     def test_login_get_non_idempotent(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar', next='/private/')
+        login_code = LoginCode.objects.create(user=self.user, next='/private/')
 
         response = self.client.get('/accounts/login/code/', {
+            'user': login_code.user.pk,
             'code': login_code.code,
         })
 
@@ -103,7 +109,9 @@ class TestViews(TestCase):
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.context['form'].errors, {
+            'user': ['This field is required.'],
             'code': ['This field is required.'],
+            '__all__': ['Unable to log in with provided login code.']
         })
 
     def test_login_missing_code_get(self):
@@ -114,31 +122,33 @@ class TestViews(TestCase):
 
     def test_login_unknown_code(self):
         response = self.client.post('/accounts/login/code/', {
+            'user': 1,
             'code': 'unknown',
         })
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.context['form'].errors, {
-            'code': ['Login code is invalid. It might have expired.'],
+            '__all__': ['Unable to log in with provided login code.'],
         })
 
     def test_login_inactive_user(self):
         self.user.is_active = False
         self.user.save()
 
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         response = self.client.post('/accounts/login/code/', {
+            'user': login_code.user.pk,
             'code': login_code.code,
         })
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.context['form'].errors, {
-            'code': ['Unable to log in with provided login code.'],
+            '__all__': ['Unable to log in with provided login code.']
         })
 
     def test_logout_post(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         self.client.login(username=self.user.username, code=login_code.code)
 
@@ -149,7 +159,7 @@ class TestViews(TestCase):
         self.assertTrue(response.wsgi_request.user.is_anonymous)
 
     def test_logout_get(self):
-        login_code = LoginCode.objects.create(user=self.user, code='foobar')
+        login_code = LoginCode.objects.create(user=self.user)
 
         self.client.login(username=self.user.username, code=login_code.code)
 

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,9 @@
 # -*- coding: utf8 -*-
 from django.conf.urls import include, url
+from django.contrib import admin
 
 urlpatterns = [
+    url(r'^admin/', admin.site.urls),
     url(r'^accounts/', include('nopassword.urls')),
     url(r'^accounts-rest/', include('nopassword.rest.urls')),
 ]

programming_language: Python
filename: tox.ini
change_type: ModificationType.MODIFY
diff: @@ -3,7 +3,7 @@ envlist =
     flake8,
     isort,
     py2-{django1_11},
-    py3-{django1_11,django2_0,django2_1},
+    py3-{django1_11,django2_1,django2_2},
     coverage
 skipsdist = True
 
@@ -13,13 +13,14 @@ basepython =
     py2: python2
 setenv =
     PYTHONPATH = {toxinidir}:{toxinidir}
+    DB_NAME = :memory:
 commands =
     coverage run -p --source=nopassword runtests.py
 deps =
     -r{toxinidir}/requirements.txt
     django1_11: Django>=1.11,<1.12
-    django2_0: Django>=2.0,<2.1
     django2_1: Django>=2.1,<2.2
+    django2_2: Django>=2.2,<2.3
 
 [testenv:flake8]
 basepython = python3

programming_language: Python