Skip to content
This repository has been archived by the owner on May 5, 2020. It is now read-only.
Permalink
Browse files Browse the repository at this point in the history
fix: Remove code from database
  • Loading branch information
relekang committed Apr 6, 2019
1 parent ea2ba48 commit d8b4615
Show file tree
Hide file tree
Showing 14 changed files with 126 additions and 54 deletions.
3 changes: 3 additions & 0 deletions docs/changelog.rst
Expand Up @@ -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
-----
Expand Down
11 changes: 11 additions & 0 deletions manage.py
@@ -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)
9 changes: 6 additions & 3 deletions nopassword/backends/base.py
Expand Up @@ -31,10 +31,13 @@ def authenticate(self, request, username=None, code=None, **kwargs):
# 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
Expand Down
29 changes: 18 additions & 11 deletions nopassword/forms.py
Expand Up @@ -61,10 +61,11 @@ def save(self, request, login_code_url='login_code', domain_override=None, extra
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,
)

Expand Down Expand Up @@ -95,11 +96,9 @@ def send_login_code(self, login_code, context, **kwargs):


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.'),
},
Expand All @@ -114,12 +113,19 @@ def __init__(self, request=None, *args, **kwargs):

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:
Expand All @@ -130,10 +136,11 @@ def clean_code(self):

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()
24 changes: 24 additions & 0 deletions nopassword/migrations/0002_auto_20190406_1322.py
@@ -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),
),
]
31 changes: 15 additions & 16 deletions nopassword/models.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import hashlib
import os
import uuid

from django.conf import settings
from django.db import models
Expand All @@ -9,15 +9,27 @@


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()

Expand All @@ -31,21 +43,8 @@ def create_code_for_user(cls, user, next=None):
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
2 changes: 1 addition & 1 deletion nopassword/rest/views.py
Expand Up @@ -54,7 +54,7 @@ def get_response(self):
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):
Expand Down
2 changes: 1 addition & 1 deletion tests/models.py
@@ -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

Expand Down
9 changes: 6 additions & 3 deletions tests/settings.py
@@ -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:'),
}
}

Expand All @@ -20,6 +21,7 @@
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',

'rest_framework',
'rest_framework.authtoken',
Expand Down Expand Up @@ -49,6 +51,7 @@
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages'
],
},
},
Expand All @@ -67,7 +70,7 @@

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': (
Expand Down
5 changes: 4 additions & 1 deletion tests/test_models.py
Expand Up @@ -16,6 +16,9 @@ def setUp(self):
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))
Expand All @@ -38,5 +41,5 @@ def test_code_timeout(self):
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')
18 changes: 12 additions & 6 deletions tests/test_rest_views.py
Expand Up @@ -26,7 +26,10 @@ def test_request_login_code(self):
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,
)

Expand Down Expand Up @@ -62,9 +65,10 @@ def test_request_login_code_inactive_user(self):
})

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,
})

Expand Down Expand Up @@ -94,22 +98,24 @@ def test_login_unknown_code(self):

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,
})

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):
Expand All @@ -124,7 +130,7 @@ def test_logout(self):
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)

Expand Down

0 comments on commit d8b4615

Please sign in to comment.