@@ -1,6 +1,16 @@
Changelog
=========

5.0.0
-----

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

@@ -24,12 +24,6 @@ django-nopassword settings

By default, the login code url requires a POST request to authenticate the user. A GET request renders a form that must be submitted by the user to perform authentication. To authenticate directly inside the initial GET request instead, set this to ``True``.

.. attribute:: NOPASSWORD_CODE_LENGTH

Default: ``64``

The length of the code used to log people in.

.. attribute:: NOPASSWORD_TWILIO_SID

Account ID for Twilio.
@@ -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)
@@ -1 +1 @@
__version__ = '4.0.1'
__version__ = '5.0.0'
@@ -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
@@ -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,
)

@@ -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.'),
},
@@ -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:
@@ -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()
@@ -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),
),
]
@@ -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 @@


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 @@ def create_code_for_user(cls, user, next=None):
if not user.is_active:
return None

code = cls.generate_code(length=getattr(settings, 'NOPASSWORD_CODE_LENGTH', 64))
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, length=64):
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))[-length:]
else:
hashed = m.hexdigest()[:length]
return hashed
@@ -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):
@@ -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

@@ -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 @@
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',

'rest_framework',
'rest_framework.authtoken',
@@ -49,6 +51,7 @@
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages'
],
},
},
@@ -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': (
@@ -16,20 +16,18 @@ 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))
self.assertIsNone(LoginCode.create_code_for_user(self.inactive_user))

@override_settings(NOPASSWORD_CODE_LENGTH=8)
def test_shorter_code(self):
code = LoginCode.create_code_for_user(self.user)
self.assertEqual(len(code.code), 8)

@override_settings(NOPASSWORD_NUMERIC_CODES=True)
def test_numeric_code(self):
code = LoginCode.create_code_for_user(self.user)
self.assertEqual(len(code.code), 64)
self.assertGreater(len(code.code), 64)
self.assertTrue(code.code.isdigit())

def test_next_value(self):
@@ -43,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')
@@ -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,
)

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

@@ -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):
@@ -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)

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

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

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 @@ def test_login_post(self):
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 @@ def test_login_missing_code_post(self):

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 @@ def test_login_missing_code_get(self):

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 @@ def test_logout_post(self):
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)

@@ -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')),
]
@@ -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