diff --git a/.coveragerc b/.coveragerc index ba55d2f..a492e2d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,7 @@ [report] show_missing = True exclude_lines = + pragma: no cover raise NotImplementedError if __name__ == .__main__.: if settings.DEBUG: diff --git a/.gitignore b/.gitignore index 2fe7964..8633a21 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ target/ tests/local.py docs/_build/ venv - +.python-version diff --git a/README.md b/README.md index 0c7b231..357cd17 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Run this command to install django-nopassword pip install django-nopassword ### Requirements -Django >= 1.4 (1.5 custom user is supported) +Django >= 1.11 (custom user is supported) ## Usage Add the app to installed apps @@ -22,26 +22,83 @@ INSTALLED_APPS = ( ) ``` -Set the authentication backend to *EmailBackend* +Add the authentication backend *EmailBackend* - AUTHENTICATION_BACKENDS = ('nopassword.backends.email.EmailBackend',) +```python +AUTHENTICATION_BACKENDS = ( + # Needed to login by username in Django admin, regardless of `nopassword` + 'django.contrib.auth.backends.ModelBackend', + + # Send login codes via email + 'nopassword.backends.email.EmailBackend', +) +``` Add urls to your *urls.py* ```python urlpatterns = patterns('', ... - url(r'^accounts/', include('nopassword.urls', namespace='nopassword')), + url(r'^accounts/', include('nopassword.urls')), + ... +) +``` + +### REST API + +To use the REST API, *djangorestframework* must be installed + + pip install djangorestframework + +Add rest framework to installed apps + +```python +INSTALLED_APPS = ( + ... + 'rest_framework', + 'rest_framework.authtoken', + 'nopassword', ... ) ``` +Add *TokenAuthentication* to default authentication classes + +```python +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ) +} +``` + +Add urls to your *urls.py* + +```python +urlpatterns = patterns('', + ... + url(r'^api/accounts/', include('nopassword.rest.urls')), + ... +) +``` + +You will have the following endpoints available: + +- `/api/accounts/login/` (POST) + - username + - next (optional, will be returned in `/api/accounts/login/code/` to be handled by the frontend) + - Sends a login code to the user +- `/api/accounts/login/code/` (POST) + - code + - Returns `key` (authentication token) and `next` (provided by `/api/accounts/login/`) +- `/api/accounts/logout/` (POST) + - Performs logout + ### Settings Information about the available settings can be found in the [docs](http://django-nopassword.readthedocs.org/en/latest/#settings) ## Tests Run with `python setup.py test`. -To run with sqlite add `USE_SQLITE = True` in tests/local.py -------- MIT © Rolf Erik Lekang diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..bcb16dd --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,34 @@ +Changelog +========= + +4.0.0 +----- + +Added: + +- Added ``LoginCodeAdmin`` +- Added rest support + +Breaking changes: + +- Remove support for Django < 1.11 +- Add support for Django 2 +- ``NoPasswordBackend.authenticate`` doesn't have side effects anymore, it only checks if a login code is valid. +- ``NoPasswordBackend`` now uses the default django method ``user_can_authenticate`` instead of ``verify_user``. +- Changed signature of ``NoPasswordBackend.send_login_code`` to ``send_login_code(code, context, **kwargs)``, to support custom template context. +- ``EmailBackend`` doesn't attach a html message to the email by default. You can provide a template ``registration/login_email.html`` to do so. +- Removed setting ``NOPASSWORD_LOGIN_EMAIL_SUBJECT`` in favor of template ``registration/login_subject.txt`` +- Renamed form ``AuthenticationForm`` to ``LoginForm`` +- ``LoginForm`` (previously ``AuthenticationForm``) doesn't have side effects anymore while cleaning. +- ``LoginForm`` (previously ``AuthenticationForm``) doesn't check for cookie support anymore. +- Removed methods ``get_user`` and ``get_user_id`` from ``LoginForm`` (previously ``AuthenticationForm``). +- Removed method ``login_url`` and ``send_login_code`` from ``LoginCode`` (previously ``AuthenticationForm``). +- Renamed template ``registration/login.html`` to ``registration/login_form.html``. +- Changed content of default templates. +- Removed views ``login_with_code_and_username``. +- Refactored views to be class based views and to use forms instead of url parameters. +- Changed url paths +- Removed setting ``NOPASSWORD_POST_REDIRECT``, use ``NOPASSWORD_LOGIN_ON_GET`` instead. +- Removed setting ``NOPASSWORD_NAMESPACE``. +- Removed setting ``NOPASSWORD_HIDE_USERNAME``. +- Removed setting ``NOPASSWORD_LOGIN_EMAIL_SUBJECT``. diff --git a/docs/index.rst b/docs/index.rst index 38fc993..f48515f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,9 @@ Run this command to install django-nopassword:: pip install django-nopassword Requirements: -Django >= 1.4 (1.5 custom user is supported) +Django >= 1.11 (custom user is supported) .. include:: usage.rst +.. include:: rest.rst .. include:: settings.rst +.. include:: changelog.rst diff --git a/docs/rest.rst b/docs/rest.rst new file mode 100644 index 0000000..44c010e --- /dev/null +++ b/docs/rest.rst @@ -0,0 +1,43 @@ +REST API +-------- +To use the REST API, *djangorestframework* must be installed:: + + pip install djangorestframework + +Add rest framework to installed apps:: + + INSTALLED_APPS = ( + ... + 'rest_framework', + 'rest_framework.authtoken', + 'nopassword', + ... + ) + +Add *TokenAuthentication* to default authentication classes:: + + REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ) + } + +Add urls to your *urls.py*:: + + urlpatterns = patterns('', + ... + url(r'^api/accounts/', include('nopassword.rest.urls')), + ... + ) + +You will have the following endpoints available: + +- `/api/accounts/login/` (POST) + - username + - next (optional, will be returned in ``/api/accounts/login/code/`` to be handled by the frontend) + - Sends a login code to the user +- `/api/accounts/login/code/` (POST) + - code + - Returns ``key`` (authentication token) and ``next`` (provided by ``/api/accounts/login/``) +- `/api/accounts/logout/` (POST) + - Performs logout diff --git a/docs/settings.rst b/docs/settings.rst index 05b529f..81f6df9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -12,36 +12,17 @@ django-nopassword settings Defines how long a login code is valid in seconds. -.. attribute:: NOPASSWORD_NAMESPACE - - Default: ``'nopassword'`` - - Defines the namespace for the urls, this must match the namespace of the include of - nopassword.urls. - -.. attribute:: NOPASSWORD_HIDE_USERNAME - - Default: ``False`` - - If set to True, the login url will not contain the username. - -.. attribute:: NOPASSWORD_LOGIN_EMAIL_SUBJECT - - Default: ``_('Login code')`` - - Sets Email Subject for Login Emails. - .. attribute:: NOPASSWORD_HASH_ALGORITHM Default: ``'sha256'`` Set the algorithm for used in logincode generation. Possible values are those who are supported in hashlib. The value should be set as the name of the attribute in hashlib. Example `hashlib.sha256()` would be `NOPASSWORD_HASH_ALGORITHM = 'sha256'. -.. attribute:: NOPASSWORD_POST_REDIRECT +.. attribute:: NOPASSWORD_LOGIN_ON_GET - Default: ``True`` + Default: ``False`` - By default, the login code url requires a POST request to authenticate the user. A GET request renders ``registration/login_submit.html``, which contains some Javascript that automatically performs the POST on page load. To authenticate directly inside the initial GET request instead, set this to ``False``. + 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 @@ -66,15 +47,6 @@ django-nopassword settings Django settings used in django-nopassword +++++++++++++++++++++++++++++++++++++++++ -.. attribute:: SERVER_URL - - Default: ``'example.com'`` - - By default, ``nopassword.views.login`` passes the result of ``result.get_host()`` to - ``LoginCode.send_login_code`` to build the login URL. If you write your own view - and/or want to avoid this behavior by not passing a value for host, the - ``SERVER_URL`` setting will be used instead. - .. attribute:: DEFAULT_FROM_EMAIL Default: ``'root@example.com'`` diff --git a/docs/usage.rst b/docs/usage.rst index c0b1b3a..f628d56 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -6,9 +6,15 @@ Add the app to installed apps:: 'nopassword', ) -Set the authentication backend to *EmailBackend*:: +Add the authentication backend *EmailBackend*:: - AUTHENTICATION_BACKENDS = ( 'nopassword.backends.email.EmailBackend', ) + AUTHENTICATION_BACKENDS = ( + # Needed to login by username in Django admin, regardless of `nopassword` + 'django.contrib.auth.backends.ModelBackend', + + # Send login codes via email + 'nopassword.backends.email.EmailBackend', + ) Add urls to your *urls.py*:: @@ -16,12 +22,6 @@ Add urls to your *urls.py*:: url(r'^accounts/', include('nopassword.urls')), ) -Verify users -~~~~~~~~~~~~ -If it is necessary to verify that users still are active in another system. Override -*verify_user(user)* to implement your check. In *NoPasswordBackend* that method checks -whether the user is active in the django app. - Backends ++++++++ There are several predefined backends. Usage of those backends are listed below. @@ -30,33 +30,36 @@ There are several predefined backends. Usage of those backends are listed below. .. class:: EmailBackend Delivers the code by email. It uses the django send email functionality to send -the emails. It will attach both HTML and plain-text versions of the email. +the emails. + +Override the following templates to customize emails: + +- ``registration/login_email.txt`` - Plain text message +- ``registration/login_email.html`` - HTML message (note that no default html message is attached) +- ``registration/login_subject.txt`` - Subject .. currentmodule:: nopassword.backends.sms .. class:: TwilioBackend Delivers the code by sms sent through the twilio service. +Override the following template to customize messages: + +- ``registration/login_sms.txt`` - SMS message + Custom backends ~~~~~~~~~~~~~~~ In backends.py there is a *NoPasswordBackend*, from which it is possible to build custom backends. The *EmailBackend* described above inherits from -this backend. Creating your own backend is can be done by creating a subclass -of *NoPasswordBackend* and implementing *send_login_code*. A good example is -the *EmailBackend*:: - - class EmailBackend(NoPasswordBackend): - - def send_login_code(self, code, secure=False, host=None): - subject = getattr(settings, 'NOPASSWORD_LOGIN_EMAIL_SUBJECT', _('Login code')) - to_email = [code.user.email] - from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'root@example.com') - - context = {'url': code.login_url(secure=secure, host=host), 'code': code} - text_content = render_to_string('registration/login_email.txt', context) - html_content = render_to_string('registration/login_email.html', context) - - msg = EmailMultiAlternatives(subject, text_content, from_email, to_email) - msg.attach_alternative(html_content, 'text/html') - msg.send() +this backend. Creating your own backend can be done by creating a subclass +of *NoPasswordBackend* and implementing *send_login_code*.:: + + class CustomBackend(NoPasswordBackend): + + def send_login_code(self, code, context, **kwargs): + """ + Use code.user to get contact information + Use context to render a custom template + Use kwargs in case you have a custom view that provides additional configuration + """ diff --git a/nopassword/admin.py b/nopassword/admin.py new file mode 100644 index 0000000..10089e2 --- /dev/null +++ b/nopassword/admin.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin + +from nopassword import models + + +@admin.register(models.LoginCode) +class LoginCodeAdmin(admin.ModelAdmin): + list_display = ('code', 'user', 'timestamp') + ordering = ('-timestamp',) + readonly_fields = ('code', 'user', 'timestamp', 'next') diff --git a/nopassword/backends/__init__.py b/nopassword/backends/__init__.py index 919b294..bf7e548 100644 --- a/nopassword/backends/__init__.py +++ b/nopassword/backends/__init__.py @@ -3,5 +3,5 @@ try: from .sms import TwilioBackend # noqa -except ImportError: +except ImportError: # pragma: no cover pass diff --git a/nopassword/backends/base.py b/nopassword/backends/base.py index 37386fc..5e5fe54 100644 --- a/nopassword/backends/base.py +++ b/nopassword/backends/base.py @@ -1,35 +1,43 @@ # -*- coding: utf-8 -*- -from datetime import datetime, timedelta +from datetime import timedelta from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend -from django.core.exceptions import FieldError +from django.utils import timezone from nopassword.models import LoginCode -from nopassword.utils import get_user_model class NoPasswordBackend(ModelBackend): - def authenticate(self, request=None, code=None, **credentials): + + def authenticate(self, request, username=None, code=None, **kwargs): + if username is None: + username = kwargs.get(get_user_model().USERNAME_FIELD) + + if not username or not code: + return + try: - user = get_user_model().objects.get(**credentials) - if not self.verify_user(user): - return None - if code is None: - return LoginCode.create_code_for_user(user) - else: - timeout = getattr(settings, 'NOPASSWORD_LOGIN_CODE_TIMEOUT', 900) - timestamp = datetime.now() - timedelta(seconds=timeout) - login_code = LoginCode.objects.get(user=user, code=code, timestamp__gt=timestamp) - user = login_code.user - user.code = login_code - login_code.delete() - return user - except (TypeError, get_user_model().DoesNotExist, LoginCode.DoesNotExist, FieldError): - return None - - def send_login_code(self, code, secure=False, host=None, **kwargs): - raise NotImplementedError + user = get_user_model()._default_manager.get_by_natural_key(username) + + if not self.user_can_authenticate(user): + return - def verify_user(self, user): - return user.is_active + timeout = getattr(settings, 'NOPASSWORD_LOGIN_CODE_TIMEOUT', 900) + timestamp = timezone.now() - timedelta(seconds=timeout) + + # We don't delete the login code when authenticating, + # 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) + + return user + + except (get_user_model().DoesNotExist, LoginCode.DoesNotExist): + return + + def send_login_code(self, code, context, **kwargs): + raise NotImplementedError diff --git a/nopassword/backends/email.py b/nopassword/backends/email.py index 8d2a32b..10569c5 100644 --- a/nopassword/backends/email.py +++ b/nopassword/backends/email.py @@ -1,23 +1,30 @@ # -*- coding: utf-8 -*- -from django.conf import settings from django.core.mail import EmailMultiAlternatives +from django.template.exceptions import TemplateDoesNotExist from django.template.loader import render_to_string -from django.utils.translation import gettext_lazy as _ -from .base import NoPasswordBackend +from nopassword.backends.base import NoPasswordBackend class EmailBackend(NoPasswordBackend): + template_name = 'registration/login_email.txt' + html_template_name = 'registration/login_email.html' + subject_template_name = 'registration/login_subject.txt' + from_email = None - def send_login_code(self, code, secure=False, host=None, **kwargs): - subject = getattr(settings, 'NOPASSWORD_LOGIN_EMAIL_SUBJECT', _('Login code')) - to_email = [code.user.email] - from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'root@example.com') + def send_login_code(self, code, context, **kwargs): + to_email = code.user.email + subject = render_to_string(self.subject_template_name, context) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + body = render_to_string(self.template_name, context) - context = {'url': code.login_url(secure=secure, host=host), 'code': code} - text_content = render_to_string('registration/login_email.txt', context) - html_content = render_to_string('registration/login_email.html', context) + email_message = EmailMultiAlternatives(subject, body, self.from_email, [to_email]) - msg = EmailMultiAlternatives(subject, text_content, from_email, to_email) - msg.attach_alternative(html_content, 'text/html') - msg.send() + try: + html_email = render_to_string(self.html_template_name, context) + email_message.attach_alternative(html_email, 'text/html') + except TemplateDoesNotExist: + pass + + email_message.send() diff --git a/nopassword/backends/sms.py b/nopassword/backends/sms.py index 847b14b..42f9223 100644 --- a/nopassword/backends/sms.py +++ b/nopassword/backends/sms.py @@ -3,10 +3,13 @@ from django.template.loader import render_to_string from twilio.rest import TwilioRestClient -from .base import NoPasswordBackend +from nopassword.backends.base import NoPasswordBackend class TwilioBackend(NoPasswordBackend): + template_name = 'registration/login_sms.txt' + from_number = None + def __init__(self): self.twilio_client = TwilioRestClient( settings.NOPASSWORD_TWILIO_SID, @@ -14,14 +17,12 @@ def __init__(self): ) super(TwilioBackend, self).__init__() - def send_login_code(self, code, secure=False, host=None, **kwargs): + def send_login_code(self, code, context, **kwargs): """ Send a login code via SMS """ - from_number = getattr(settings, 'DEFAULT_FROM_NUMBER') - - context = {'url': code.login_url(secure=secure, host=host), 'code': code} - sms_content = render_to_string('registration/login_sms.txt', context) + from_number = self.from_number or getattr(settings, 'DEFAULT_FROM_NUMBER') + sms_content = render_to_string(self.template_name, context) self.twilio_client.messages.create( to=code.user.phone_number, diff --git a/nopassword/forms.py b/nopassword/forms.py index af7552e..16633fe 100644 --- a/nopassword/forms.py +++ b/nopassword/forms.py @@ -1,61 +1,139 @@ # -*- coding: utf-8 -*- from django import forms -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_backends, get_user_model +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import resolve_url from django.utils.translation import ugettext_lazy as _ -from .models import LoginCode -from .utils import get_username_field +from nopassword import models -class AuthenticationForm(forms.Form): - """ - Base class for authenticating users. Extend this to get a form that accepts - username logins. - """ - username = forms.CharField(label=_("Username"), max_length=30) - +class LoginForm(forms.Form): error_messages = { - 'invalid_login': _("Please enter a correct username. " - "Note that it is case-sensitive."), - 'no_cookies': _("Your Web browser doesn't appear to have cookies " - "enabled. Cookies are required for logging in."), + 'invalid_username': _( + "Please enter a correct %(username)s. " + "Note that it is case-sensitive." + ), 'inactive': _("This account is inactive."), } + next = forms.CharField(max_length=200, required=False, widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + + self.username_field = get_user_model()._meta.get_field(get_user_model().USERNAME_FIELD) + self.fields['username'] = self.username_field.formfield() + + def clean_username(self): + username = self.cleaned_data['username'] + + try: + user = get_user_model()._default_manager.get_by_natural_key(username) + except get_user_model().DoesNotExist: + raise forms.ValidationError( + self.error_messages['invalid_username'], + code='invalid_username', + params={'username': self.username_field.verbose_name}, + ) + + if not user.is_active: + raise forms.ValidationError( + self.error_messages['inactive'], + code='inactive', + ) + + self.cleaned_data['user'] = user + + return username + + def save(self, request, login_code_url='login_code', domain_override=None, extra_context=None): + login_code = models.LoginCode.create_code_for_user( + user=self.cleaned_data['user'], + next=self.cleaned_data['next'], + ) + + if not domain_override: + current_site = get_current_site(request) + site_name = current_site.name + domain = current_site.domain + else: + site_name = domain = domain_override + + url = '{}://{}{}?code={}'.format( + 'https' if request.is_secure() else 'http', + domain, + resolve_url(login_code_url), + login_code.code, + ) + + context = { + 'domain': domain, + 'site_name': site_name, + 'code': login_code.code, + 'url': url, + } + + if extra_context: + context.update(extra_context) + + self.send_login_code(login_code, context) + + return login_code + + def send_login_code(self, login_code, context, **kwargs): + for backend in get_backends(): + if hasattr(backend, 'send_login_code'): + backend.send_login_code(login_code, context, **kwargs) + break + else: + raise ImproperlyConfigured( + 'Please add a nopassword authentication backend to settings, ' + 'e.g. `nopassword.backends.EmailBackend`' + ) + + +class LoginCodeForm(forms.Form): + code = forms.ModelChoiceField( + 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.'), + }, + ) + + error_messages = { + 'invalid_code': _("Unable to log in with provided login code."), + } + def __init__(self, request=None, *args, **kwargs): - """ - If request is passed in, the form will validate that cookies are - enabled. Note that the request (a HttpRequest object) must have set a - cookie with the key TEST_COOKIE_NAME and value TEST_COOKIE_VALUE before - running this validation. - """ + super(LoginCodeForm, self).__init__(*args, **kwargs) + self.request = request - self.user_cache = None - super(AuthenticationForm, self).__init__(*args, **kwargs) - self.fields['username'].label = _(get_username_field().capitalize()) - - def clean(self): - username = self.cleaned_data.get('username') - - if username: - self.user_cache = authenticate(**{get_username_field(): username}) - if self.user_cache is None: - raise forms.ValidationError( - self.error_messages['invalid_login']) - elif not isinstance(self.user_cache, LoginCode) and \ - not self.user_cache.is_active: - raise forms.ValidationError(self.error_messages['inactive']) - self.check_for_test_cookie() - return self.cleaned_data - - def check_for_test_cookie(self): - if self.request and not self.request.session.test_cookie_worked(): - raise forms.ValidationError(self.error_messages['no_cookies']) - - def get_user_id(self): - if self.user_cache: - return self.user_cache.id - return None + + def clean_code(self): + code = self.cleaned_data['code'] + username = code.user.get_username() + user = authenticate(self.request, **{ + get_user_model().USERNAME_FIELD: username, + 'code': code.code, + }) + + if not user: + raise forms.ValidationError( + self.error_messages['invalid_code'], + code='invalid_code', + ) + + self.cleaned_data['user'] = user + + return code def get_user(self): - return self.user_cache + return self.cleaned_data.get('user') + + def save(self): + self.cleaned_data['code'].delete() diff --git a/nopassword/migrations/0001_initial.py b/nopassword/migrations/0001_initial.py index d6a1c8f..779fcde 100644 --- a/nopassword/migrations/0001_initial.py +++ b/nopassword/migrations/0001_initial.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('timestamp', models.DateTimeField(editable=False)), ('next', models.TextField(blank=True, editable=False)), ('user', models.ForeignKey(related_name='login_codes', verbose_name='user', - to=settings.AUTH_USER_MODEL, editable=False)), + to=settings.AUTH_USER_MODEL, editable=False, on_delete=models.CASCADE)), ], options={ }, diff --git a/nopassword/models.py b/nopassword/models.py index 69eaf89..3045d5b 100644 --- a/nopassword/models.py +++ b/nopassword/models.py @@ -1,64 +1,30 @@ # -*- coding: utf-8 -*- import hashlib import os -from datetime import datetime from django.conf import settings -from django.contrib.auth import get_backends -from django.core.urlresolvers import reverse_lazy from django.db import models from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from .utils import AUTH_USER_MODEL, get_username - class LoginCode(models.Model): - user = models.ForeignKey(AUTH_USER_MODEL, related_name='login_codes', - editable=False, verbose_name=_('user')) + 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 __unicode__(self): + def __str__(self): return "%s - %s" % (self.user, self.timestamp) def save(self, *args, **kwargs): - if settings.USE_TZ: - self.timestamp = timezone.now() - else: - self.timestamp = datetime.now() + self.timestamp = timezone.now() if not self.next: self.next = '/' - super(LoginCode, self).save(*args, **kwargs) - def login_url(self, secure=False, host=None): - url_namespace = getattr(settings, 'NOPASSWORD_NAMESPACE', 'nopassword') - username = get_username(self.user) - host = host or getattr(settings, 'SERVER_URL', None) or 'example.com' - if getattr(settings, 'NOPASSWORD_HIDE_USERNAME', False): - view = reverse_lazy( - '{0}:login_with_code'.format(url_namespace), - args=[self.code] - ), - else: - view = reverse_lazy( - '{0}:login_with_code_and_username'.format(url_namespace), - args=[username, self.code] - ), - - return '%s://%s%s?next=%s' % ( - 'https' if secure else 'http', - host, - view[0], - self.next - ) - - def send_login_code(self, secure=False, host=None, **kwargs): - for backend in get_backends(): - if hasattr(backend, 'send_login_code'): - backend.send_login_code(self, secure=secure, host=host, **kwargs) + super(LoginCode, self).save(*args, **kwargs) @classmethod def create_code_for_user(cls, user, next=None): diff --git a/nopassword/rest/__init__.py b/nopassword/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nopassword/rest/serializers.py b/nopassword/rest/serializers.py new file mode 100644 index 0000000..de27857 --- /dev/null +++ b/nopassword/rest/serializers.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers +from rest_framework.authtoken.models import Token + +from nopassword import forms + + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + next = serializers.CharField(required=False, allow_null=True) + + form_class = forms.LoginForm + + def validate(self, data): + self.form = self.form_class(data=self.initial_data) + + if not self.form.is_valid(): + raise serializers.ValidationError(self.form.errors) + + return self.form.cleaned_data + + def save(self): + request = self.context.get('request') + return self.form.save(request=request) + + +class LoginCodeSerializer(serializers.Serializer): + code = serializers.CharField() + + form_class = forms.LoginCodeForm + + def validate(self, data): + request = self.context.get('request') + + self.form = self.form_class(data=self.initial_data, request=request) + + if not self.form.is_valid(): + raise serializers.ValidationError(self.form.errors) + + return self.form.cleaned_data + + def save(self): + self.form.save() + + +class TokenSerializer(serializers.ModelSerializer): + + class Meta: + model = Token + fields = ('key',) diff --git a/nopassword/rest/urls.py b/nopassword/rest/urls.py new file mode 100644 index 0000000..eced114 --- /dev/null +++ b/nopassword/rest/urls.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from django.conf.urls import url + +from nopassword.rest import views + +urlpatterns = [ + url(r'^login/$', views.LoginView.as_view(), name='rest_login'), + url(r'^login/code/$', views.LoginCodeView.as_view(), name='rest_login_code'), + url(r'^logout/$', views.LogoutView.as_view(), name='rest_logout'), +] diff --git a/nopassword/rest/views.py b/nopassword/rest/views.py new file mode 100644 index 0000000..780eb59 --- /dev/null +++ b/nopassword/rest/views.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +from django.contrib.auth import login as django_login +from django.contrib.auth import logout as django_logout +from django.core.exceptions import ObjectDoesNotExist +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.debug import sensitive_post_parameters +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from nopassword.rest import serializers + + +class LoginView(GenericAPIView): + serializer_class = serializers.LoginSerializer + permission_classes = (AllowAny,) + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + {"detail": _("Login code has been sent.")}, + status=status.HTTP_200_OK + ) + + +@method_decorator(sensitive_post_parameters('code'), 'dispatch') +class LoginCodeView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.LoginCodeSerializer + token_serializer_class = serializers.TokenSerializer + token_model = Token + + def process_login(self): + django_login(self.request, self.user) + + def login(self): + self.user = self.serializer.validated_data['user'] + self.token, created = self.token_model.objects.get_or_create(user=self.user) + + if getattr(settings, 'REST_SESSION_LOGIN', True): + self.process_login() + + def get_response(self): + token_serializer = self.token_serializer_class( + instance=self.token, + context=self.get_serializer_context(), + ) + data = token_serializer.data + data['next'] = self.serializer.validated_data['code'].next + return Response(data, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + self.serializer = self.get_serializer(data=request.data) + self.serializer.is_valid(raise_exception=True) + self.serializer.save() + self.login() + return self.get_response() + + +class LogoutView(APIView): + permission_classes = (AllowAny,) + + def post(self, request, *args, **kwargs): + return self.logout(request) + + def logout(self, request): + try: + request.user.auth_token.delete() + except (AttributeError, ObjectDoesNotExist): + pass + + django_logout(request) + + return Response( + {"detail": _("Successfully logged out.")}, + status=status.HTTP_200_OK, + ) diff --git a/nopassword/south_migrations/0001_initial.py b/nopassword/south_migrations/0001_initial.py deleted file mode 100644 index ed36461..0000000 --- a/nopassword/south_migrations/0001_initial.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -from south.db import db -from south.v2 import SchemaMigration - -from .utils import AUTH_USER_MODEL - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'LoginCode' - db.create_table(u'nopassword_logincode', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')( - related_name='login_codes', to=orm[AUTH_USER_MODEL] - )), - ('code', self.gf('django.db.models.fields.CharField')(max_length=20)), - ('timestamp', self.gf('django.db.models.fields.DateTimeField')()), - ('next', self.gf('django.db.models.fields.TextField')(blank=True)), - )) - db.send_create_signal(u'nopassword', ['LoginCode']) - - def backwards(self, orm): - # Deleting model 'LoginCode' - db.delete_table(u'nopassword_logincode') - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], { - 'unique': 'True', 'max_length': '80' - }), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], { - 'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True' - }) - }, - u'auth.permission': { - 'Meta': { - 'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", - 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission' - }, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], { - 'to': u"orm['contenttypes.ContentType']" - }), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u'contenttypes.contenttype': { - 'Meta': { - 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", - 'object_name': 'ContentType', 'db_table': "'django_content_type'" - }, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'nopassword.logincode': { - 'Meta': {'object_name': 'LoginCode'}, - 'code': ('django.db.models.fields.CharField', [], {'max_length': '20'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'next': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'timestamp': ('django.db.models.fields.DateTimeField', [], {}), - 'user': ('django.db.models.fields.related.ForeignKey', [], { - 'related_name': "'login_codes'", 'to': u"orm[" + AUTH_USER_MODEL + "]" - }) - } - } - - complete_apps = ['nopassword'] diff --git a/nopassword/templates/registration/logged_out.html b/nopassword/templates/registration/logged_out.html new file mode 100644 index 0000000..a1b8ddd --- /dev/null +++ b/nopassword/templates/registration/logged_out.html @@ -0,0 +1,8 @@ +{% load i18n %} + + + + + {% trans "Thanks for spending some quality time with the Web site today." %} + + diff --git a/nopassword/templates/registration/login.html b/nopassword/templates/registration/login.html deleted file mode 100755 index e3b1b84..0000000 --- a/nopassword/templates/registration/login.html +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} - -
- {% csrf_token %} - {{ form.media }} -
- -
-
- -
-
diff --git a/nopassword/templates/registration/login_code.html b/nopassword/templates/registration/login_code.html new file mode 100644 index 0000000..35524f8 --- /dev/null +++ b/nopassword/templates/registration/login_code.html @@ -0,0 +1,18 @@ +{% load i18n %} + + + + +
+ {% csrf_token %} + +

+ {% trans "Please enter the login code that was sent to you." %} +

+ + {{ form }} + + +
+ + diff --git a/nopassword/templates/registration/login_email.html b/nopassword/templates/registration/login_email.html deleted file mode 100644 index 452f8ad..0000000 --- a/nopassword/templates/registration/login_email.html +++ /dev/null @@ -1 +0,0 @@ -{% load i18n %}

{% trans "Login with this url " %}{{ url }}.

diff --git a/nopassword/templates/registration/login_email.txt b/nopassword/templates/registration/login_email.txt index 1e8cb78..d156a02 100644 --- a/nopassword/templates/registration/login_email.txt +++ b/nopassword/templates/registration/login_email.txt @@ -1 +1,14 @@ -{% load i18n %}{% trans "Login with this url " %}{{ url }}. +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because you requested a login code for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please enter the following code for sign in:" %} +{{ code }} + +{% trans "You can also follow this link to sign in automatically:" %} +{{ url }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/nopassword/templates/registration/login_form.html b/nopassword/templates/registration/login_form.html new file mode 100644 index 0000000..210178a --- /dev/null +++ b/nopassword/templates/registration/login_form.html @@ -0,0 +1,12 @@ +{% load i18n %} + + + + +
+ {% csrf_token %} + {{ form }} + +
+ + diff --git a/nopassword/templates/registration/login_subject.txt b/nopassword/templates/registration/login_subject.txt new file mode 100644 index 0000000..78422ce --- /dev/null +++ b/nopassword/templates/registration/login_subject.txt @@ -0,0 +1,3 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}Login code request on {{ site_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/nopassword/templates/registration/login_submit.html b/nopassword/templates/registration/login_submit.html deleted file mode 100644 index beffd28..0000000 --- a/nopassword/templates/registration/login_submit.html +++ /dev/null @@ -1,16 +0,0 @@ -{% load i18n %} - - - - {% trans "Logging in" %}... - - -

{% trans "Logging in" %}...

-
- {% csrf_token %} - -
- - diff --git a/nopassword/templates/registration/sent_mail.html b/nopassword/templates/registration/sent_mail.html deleted file mode 100644 index cb689bc..0000000 --- a/nopassword/templates/registration/sent_mail.html +++ /dev/null @@ -1 +0,0 @@ -{% load i18n %}

{% trans "We sent you a mail with a login link" %}

diff --git a/nopassword/urls.py b/nopassword/urls.py index 0921a06..815b462 100644 --- a/nopassword/urls.py +++ b/nopassword/urls.py @@ -4,24 +4,7 @@ from nopassword import views urlpatterns = [ - url( - r'^login/$', - views.login, - name='login' - ), - url( - r'^login-code/(?P[a-zA-Z0-9]+)/$', - views.login_with_code, - name='login_with_code' - ), - url( - r'^login-code/(?P[a-zA-Z0-9_@\.\+-]+)/(?P[a-zA-Z0-9]+)/$', - views.login_with_code_and_username, - name='login_with_code_and_username' - ), - url( - r'^logout/$', - views.logout, - name='logout' - ), + url(r'^login/$', views.LoginView.as_view(), name='login'), + url(r'^login/code/$', views.LoginCodeView.as_view(), name='login_code'), + url(r'^logout/$', views.LogoutView.as_view(), name='logout'), ] diff --git a/nopassword/utils.py b/nopassword/utils.py deleted file mode 100644 index 34a40b6..0000000 --- a/nopassword/utils.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -import django -from django.conf import settings -from django.utils.functional import allow_lazy - -if django.VERSION >= (1, 5): - from django.contrib.auth import get_user_model - AUTH_USER_MODEL = settings.AUTH_USER_MODEL - get_user_model = allow_lazy(get_user_model, AUTH_USER_MODEL) - get_username_field = allow_lazy(lambda: get_user_model().USERNAME_FIELD, str) -else: - from django.contrib.auth.models import User - AUTH_USER_MODEL = 'auth.User' - - def get_user_model(): - return User - - def get_username_field(): - return 'username' - - -def get_username(user): - try: - return user.get_username() - except AttributeError: - return user.username diff --git a/nopassword/views.py b/nopassword/views.py index 41b9310..43b46fe 100644 --- a/nopassword/views.py +++ b/nopassword/views.py @@ -1,58 +1,68 @@ # -*- coding: utf-8 -*- from django.conf import settings -from django.contrib.auth import login as auth_login -from django.contrib.auth import logout as auth_logout -from django.contrib.auth import authenticate -from django.contrib.auth.views import login as django_login -from django.http import Http404 -from django.shortcuts import get_object_or_404, redirect, render - -from .forms import AuthenticationForm -from .models import LoginCode -from .utils import get_username, get_username_field - - -def login(request): - if request.method == 'POST': - form = AuthenticationForm(data=request.POST) - if form.is_valid(): - code = LoginCode.objects.filter(**{ - 'user__%s' % get_username_field(): request.POST.get('username') - })[0] - code.next = request.GET.get('next') - code.save() - code.send_login_code( - secure=request.is_secure(), - host=request.get_host(), - ) - return render(request, 'registration/sent_mail.html') - - return django_login(request, authentication_form=AuthenticationForm) - - -def login_with_code(request, login_code): - code = get_object_or_404(LoginCode.objects.select_related('user'), code=login_code) - return login_with_code_and_username(request, username=get_username(code.user), - login_code=login_code) - - -def login_with_code_and_username(request, username, login_code): - code = get_object_or_404(LoginCode, code=login_code) - login_with_post = getattr(settings, 'NOPASSWORD_POST_REDIRECT', True) - - if request.method == 'POST' or not login_with_post: - user = authenticate(**{get_username_field(): username, 'code': login_code}) - if user is None: - raise Http404 - user = auth_login(request, user) - return redirect(code.next) - - return render(request, 'registration/login_submit.html') - - -def logout(request, redirect_to=None): - auth_logout(request) - if redirect_to is None: - return redirect('{0}:login'.format(getattr(settings, 'NOPASSWORD_NAMESPACE', 'nopassword'))) - else: - return redirect(redirect_to) +from django.contrib.auth.views import LoginView as DjangoLoginView +from django.contrib.auth.views import LogoutView as DjangoLogoutView +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +from django.views.generic.edit import FormView + +from nopassword import forms + + +class LoginView(FormView): + """ + Sends a login code to the user. + It doesn't authenticate a user but it is the entry point for the login process (login url). + """ + + form_class = forms.LoginForm + success_url = reverse_lazy('login_code') + template_name = 'registration/login_form.html' + + @method_decorator(csrf_protect) + def dispatch(self, request, *args, **kwargs): + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(LoginView, self).get_form_kwargs() + kwargs['initial'] = {'next': self.request.GET.get('next')} + return kwargs + + def form_valid(self, form): + form.save(request=self.request) + return super(LoginView, self).form_valid(form) + + +class LoginCodeView(DjangoLoginView): + """ + Authenticates a user with a login code. + """ + + form_class = forms.LoginCodeForm + template_name = 'registration/login_code.html' + + def get(self, request, *args, **kwargs): + if 'code' in self.request.GET and getattr(settings, 'NOPASSWORD_LOGIN_ON_GET', False): + return super(LoginCodeView, self).post(request, *args, **kwargs) + return super(LoginCodeView, self).get(request, *args, **kwargs) + + def form_valid(self, form): + form.save() + return super(LoginCodeView, self).form_valid(form) + + def get_form_kwargs(self): + kwargs = super(LoginCodeView, self).get_form_kwargs() + + if self.request.method == 'GET' and 'code' in self.request.GET: + kwargs['data'] = self.request.GET + + return kwargs + + def get_redirect_url(self): + login_code = getattr(self.request.user, 'login_code', None) + return login_code.next if login_code else '' + + +class LogoutView(DjangoLogoutView): + pass diff --git a/requirements.txt b/requirements.txt index c4a4b8c..3a7c221 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ twilio==5.4.0 mock>=1.0 +djangorestframework>=3.1.3 coverage -psycopg2 diff --git a/setup.py b/setup.py index 9662869..0489be6 100644 --- a/setup.py +++ b/setup.py @@ -39,10 +39,13 @@ def _read_long_description(): long_description=_read_long_description(), packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), install_requires=[ - 'django>=1.8', + 'django>=1.11', ], + extras_require={ + 'rest': ['djangorestframework>=3.1.3'], + }, tests_require=[ - 'django>=1.8', + 'django>=1.11', 'twilio==4.4.0', 'mock>=1.0' ], diff --git a/tests/models.py b/tests/models.py index 42c19ae..3f39cb4 100644 --- a/tests/models.py +++ b/tests/models.py @@ -8,7 +8,7 @@ class CustomUser(AbstractUser): extra_field = models.CharField(max_length=2) - new_username_field = models.CharField(unique=True, max_length=20) + new_username_field = models.CharField('userid', unique=True, max_length=20) USERNAME_FIELD = 'new_username_field' diff --git a/tests/settings.py b/tests/settings.py index acd7b3d..16c1d4e 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,39 +1,29 @@ # -*- coding: utf8 -*- +import django + DEBUG = False -USE_SQLITE = False - -try: - from .local import USE_SQLITE -except ImportError: - pass - -if USE_SQLITE: - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } - } -else: - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'django_nopassword', - } +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', } - +} AUTH_USER_MODEL = 'tests.CustomUser' NOPASSWORD_LOGIN_CODE_TIMEOUT = 900 INSTALLED_APPS = [ + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', + 'rest_framework', + 'rest_framework.authtoken', + 'nopassword', 'tests', ] @@ -57,12 +47,14 @@ 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { - # ... some options here ... + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + ], }, }, ] -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -70,6 +62,16 @@ 'django.contrib.messages.middleware.MessageMiddleware', ) +if django.VERSION < (1, 10): + MIDDLEWARE_CLASSES = MIDDLEWARE + ROOT_URLCONF = 'tests.urls' EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ), +} diff --git a/tests/test_backends.py b/tests/test_backends.py index cd79573..cd75cc8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -1,8 +1,5 @@ # -*- coding: utf8 -*- -from unittest import skipIf - -import django -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_user_model from django.test import TestCase from django.test.utils import mail, override_settings from mock import MagicMock, patch @@ -11,10 +8,10 @@ from nopassword.backends.email import EmailBackend from nopassword.backends.sms import TwilioBackend from nopassword.models import LoginCode -from nopassword.utils import get_user_model class AuthenticationBackendTests(TestCase): + @override_settings(AUTH_USER_MODULE='tests.NoUsernameUser') def test_authenticate_with_custom_user_model(self): """When a custom user model is used that doesn't have a field @@ -24,45 +21,29 @@ def test_authenticate_with_custom_user_model(self): self.assertIsNone(result) -@skipIf(django.VERSION < (1, 5), 'Custom user not supported') @override_settings(AUTH_USER_MODEL='tests.PhoneNumberUser', NOPASSWORD_TWILIO_SID="aaaaaaaa", NOPASSWORD_TWILIO_AUTH_TOKEN="bbbbbbbb", DEFAULT_FROM_NUMBER="+15555555") class TwilioBackendTests(TestCase): + def setUp(self): self.user = get_user_model().objects.create(username='twilio_user') self.code = LoginCode.create_code_for_user(self.user, next='/secrets/') self.assertEqual(len(self.code.code), 20) self.assertIsNotNone(authenticate(username=self.user.username, code=self.code.code)) - self.assertEqual(LoginCode.objects.filter(user=self.user, code=self.code.code).count(), 0) - - def tearDown(self): - self.user.delete() @patch('nopassword.backends.sms.TwilioRestClient') def test_twilio_backend(self, mock_object): self.backend = TwilioBackend() self.backend.twilio_client.messages.create = MagicMock() - self.backend.send_login_code(self.code) + self.backend.send_login_code(self.code, {'url': 'https://example.com'}) self.assertTrue(mock_object.called) self.assertTrue(self.backend.twilio_client.messages.create.called) _, kwargs = self.backend.twilio_client.messages.create.call_args - self.assertIn(self.code.login_url(secure=False), kwargs.get('body')) + self.assertIn('https://example.com', kwargs.get('body')) - authenticate(username=self.user.username) - self.assertEqual(LoginCode.objects.filter(user=self.user).count(), 1) - @patch('nopassword.backends.sms.TwilioRestClient') - def test_twilio_backend_with_https(self, mock_object): - self.backend = TwilioBackend() - self.backend.twilio_client.messages.create = MagicMock() - self.backend.send_login_code(self.code, secure=True, host='secure.example.com') - _, kwargs = self.backend.twilio_client.messages.create.call_args - login_url = self.code.login_url(secure=True, host='secure.example.com') - self.assertIn(login_url, kwargs.get('body')) - - -@skipIf(django.VERSION < (1, 5), 'Custom user not supported') class EmailBackendTests(TestCase): + def setUp(self): self.user = get_user_model().objects.create( username='email_user', @@ -71,47 +52,28 @@ def setUp(self): self.code = LoginCode.create_code_for_user(self.user, next='/secrets/') self.backend = EmailBackend() - def tearDown(self): - self.user.delete() - def test_email_backend(self): "Send email via EmailBackend with default options" - mail.outbox = [] - self.backend.send_login_code(self.code) + self.backend.send_login_code(self.code, {'url': 'https://example.com'}) self.assertEqual(1, len(mail.outbox)) message = mail.outbox[0] - http_url = self.code.login_url() - self.assertIn(http_url, message.body) - self.assertTrue(http_url.startswith('http:')) + self.assertIn('https://example.com', message.body) self.assertEqual([self.user.email], message.to) + self.assertEqual(0, len(message.alternatives)) - def test_email_backend_with_https(self): - "Send email via EmailBackend with secure=True" - mail.outbox = [] - self.backend.send_login_code(self.code, secure=True, host='secure.example.com') + def test_html_template_name(self): + # We don't have an existing html template, so we just use the txt template + self.backend.html_template_name = 'registration/login_email.txt' + self.backend.send_login_code(self.code, {'url': 'https://example.com'}) self.assertEqual(1, len(mail.outbox)) message = mail.outbox[0] - https_url = self.code.login_url(secure=True, host='secure.example.com') - self.assertTrue(https_url.startswith('https:')) - self.assertIn(https_url, message.body) + self.assertIn('https://example.com', message.body) + self.assertEqual(1, len(message.alternatives)) + self.assertIn('https://example.com', message.alternatives[0][0]) class TestBackendUtils(TestCase): - def setUp(self): - self.user = get_user_model().objects.create(username='test_user') - self.inactive_user = get_user_model().objects.create(username='inactive', is_active=False) - self.backend = NoPasswordBackend() - - def tearDown(self): - self.user.delete() - self.inactive_user.delete() - - def test_verify_user(self): - self.assertTrue(self.backend.verify_user(self.user)) - self.assertFalse(self.backend.verify_user(self.inactive_user)) def test_send_login_code(self): - self.assertRaises(NotImplementedError, - self.backend.send_login_code, - code=None, - secure=False) + backend = NoPasswordBackend() + self.assertRaises(NotImplementedError, backend.send_login_code, code=None, context=None) diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..64fd06c --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,52 @@ +# -*- coding: utf8 -*- +from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured +from django.test import RequestFactory, TestCase, override_settings +from mock import MagicMock + +from nopassword import forms + + +class TestLoginForm(TestCase): + + def setUp(self): + self.factory = RequestFactory() + self.user = get_user_model().objects.create(username='user') + + def test_domain_override(self): + request = self.factory.post('/accounts/login/', { + 'username': 'user', + }) + form = forms.LoginForm(data=request.POST) + form.send_login_code = MagicMock() + + self.assertTrue(form.is_valid()) + + form.save(request, domain_override='foobar.com') + + self.assertTrue(form.send_login_code.called) + (login_code, context), _ = form.send_login_code.call_args + self.assertIn('http://foobar.com', context['url']) + + def test_extra_context(self): + request = self.factory.post('/accounts/login/', { + 'username': 'user', + }) + form = forms.LoginForm(data=request.POST) + form.send_login_code = MagicMock() + + self.assertTrue(form.is_valid()) + + form.save(request, extra_context={'foo': 'bar'}) + + self.assertTrue(form.send_login_code.called) + (login_code, context), _ = form.send_login_code.call_args + self.assertTrue('url' in context) + self.assertEqual('bar', context['foo']) + + @override_settings( + AUTHENTICATION_BACKENDS=['django.contrib.auth.backends.ModelBackend'], + ) + def test_missing_backend(self): + form = forms.LoginForm() + self.assertRaises(ImproperlyConfigured, form.send_login_code, None, None) diff --git a/tests/test_models.py b/tests/test_models.py index b1e79db..9fb2682 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,34 +1,25 @@ # -*- coding: utf8 -*- import time +from datetime import datetime -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_user_model from django.test import TestCase from django.test.utils import override_settings from nopassword.models import LoginCode -from nopassword.utils import get_user_model class TestLoginCodes(TestCase): + def setUp(self): self.user = get_user_model().objects.create(username='test_user') 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): - self.user.delete() - self.inactive_user.delete() - def test_login_backend(self): self.assertEqual(len(self.code.code), 20) self.assertIsNotNone(authenticate(username=self.user.username, code=self.code.code)) - self.assertEqual(LoginCode.objects.filter(user=self.user, code=self.code.code).count(), 0) - - authenticate(username=self.user.username) - self.assertEqual(LoginCode.objects.filter(user=self.user).count(), 1) - self.assertIsNone(LoginCode.create_code_for_user(self.inactive_user)) - self.assertIsNone(authenticate(username=self.inactive_user.username)) @override_settings(NOPASSWORD_CODE_LENGTH=8) def test_shorter_code(self): @@ -51,20 +42,6 @@ def test_code_timeout(self): time.sleep(3) self.assertIsNone(authenticate(username=self.user.username, code=timeout_code.code)) - def test_login_url_secure(self): - self.assertTrue(self.code.login_url(secure=True).startswith('https:')) - - def test_login_url_insecure(self): - self.assertTrue(self.code.login_url().startswith('http:')) - - def test_login_url_host(self): - host = 'nopassword.example.com' - self.assertIn(host, self.code.login_url(host=host)) - - @override_settings(SERVER_URL='server_url_setting.example.com') - def test_login_url_default_setting(self): - self.assertIn('server_url_setting.example.com', self.code.login_url()) - - @override_settings(SERVER_URL=None) - def test_login_url_no_setting(self): - self.assertIn('example.com', self.code.login_url()) + def test_str(self): + code = LoginCode(user=self.user, code='foo', timestamp=datetime(2018, 7, 1)) + self.assertEqual(str(code), 'test_user - 2018-07-01 00:00:00') diff --git a/tests/test_rest_views.py b/tests/test_rest_views.py new file mode 100644 index 0000000..d67b37f --- /dev/null +++ b/tests/test_rest_views.py @@ -0,0 +1,136 @@ +# -*- coding: utf8 -*- +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase +from rest_framework.authtoken.models import Token + +from nopassword.models import LoginCode + + +class TestRestViews(TestCase): + + def setUp(self): + self.user = get_user_model().objects.create(username='user', email='foo@bar.com') + + def test_request_login_code(self): + response = self.client.post('/accounts-rest/login/', { + 'username': self.user.username, + 'next': '/private/', + }) + + self.assertEqual(response.status_code, 200) + + login_code = LoginCode.objects.filter(user=self.user).first() + + self.assertIsNotNone(login_code) + self.assertEqual(login_code.next, '/private/') + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + 'http://testserver/accounts/login/code/?code={}'.format(login_code.code), + mail.outbox[0].body, + ) + + def test_request_login_code_missing_username(self): + response = self.client.post('/accounts-rest/login/') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'username': ['This field is required.'], + }) + + def test_request_login_code_unknown_user(self): + response = self.client.post('/accounts-rest/login/', { + 'username': 'unknown', + }) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'username': ['Please enter a correct userid. Note that it is case-sensitive.'], + }) + + def test_request_login_code_inactive_user(self): + self.user.is_active = False + self.user.save() + + response = self.client.post('/accounts-rest/login/', { + 'username': self.user.username, + }) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'username': ['This account is inactive.'], + }) + + def test_login(self): + login_code = LoginCode.objects.create(user=self.user, code='foobar', next='/private/') + + response = self.client.post('/accounts-rest/login/code/', { + 'code': login_code.code, + }) + + self.assertEqual(response.status_code, 200) + self.assertFalse(LoginCode.objects.filter(pk=login_code.pk).exists()) + + token = Token.objects.filter(user=self.user).first() + + self.assertIsNotNone(token) + self.assertEqual(response.data, { + 'key': token.key, + 'next': '/private/', + }) + + def test_login_missing_code(self): + response = self.client.post('/accounts-rest/login/code/') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'code': ['This field is required.'], + }) + + def test_login_unknown_code(self): + response = self.client.post('/accounts-rest/login/code/', { + 'code': 'unknown', + }) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'code': ['Login code is invalid. It might have expired.'], + }) + + def test_login_inactive_user(self): + self.user.is_active = False + self.user.save() + + login_code = LoginCode.objects.create(user=self.user, code='foobar') + + 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.'], + }) + + def test_logout(self): + token = Token.objects.create(user=self.user, key='foobar') + + response = self.client.post( + '/accounts-rest/logout/', + HTTP_AUTHORIZATION='Token {}'.format(token.key), + ) + + self.assertEqual(response.status_code, 200) + 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') + + self.client.login(username=self.user.username, code=login_code.code) + + response = self.client.post( + '/accounts-rest/logout/', + HTTP_AUTHORIZATION='Token unknown', + ) + + self.assertEqual(response.status_code, 200) diff --git a/tests/test_views.py b/tests/test_views.py index 0211a93..a001fe6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,93 +1,160 @@ # -*- coding: utf8 -*- -from django.contrib.auth import SESSION_KEY -from django.test import Client, TestCase -from django.test.utils import override_settings -from mock import patch +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase, override_settings from nopassword.models import LoginCode -from nopassword.utils import get_user_model class TestViews(TestCase): def setUp(self): - self.c = Client() - self.user = get_user_model().objects.create(username='user') + self.user = get_user_model().objects.create(username='user', email='foo@bar.com') - def tearDown(self): - self.user.delete() + def test_request_login_code(self): + response = self.client.post('/accounts/login/', { + 'username': self.user.username, + 'next': '/private/', + }) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/accounts/login/code/') + + login_code = LoginCode.objects.filter(user=self.user).first() + + self.assertIsNotNone(login_code) + self.assertEqual(login_code.next, '/private/') + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + 'http://testserver/accounts/login/code/?code={}'.format(login_code.code), + mail.outbox[0].body, + ) + + def test_request_login_code_missing_username(self): + response = self.client.post('/accounts/login/') - def test_login(self): - response = self.c.get('/accounts/login/') self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].errors, { + 'username': ['This field is required.'], + }) - login = self.c.post('/accounts/login/?next=/secret/', {'username': self.user.username}) - self.assertEqual(login.status_code, 200) + def test_request_login_code_unknown_user(self): + response = self.client.post('/accounts/login/', { + 'username': 'unknown', + }) - login_with_code = self.c.get('/accounts/login-code/%s/%s/' % (self.user.username, - 'wrongcode')) - self.assertEqual(login_with_code.status_code, 404) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].errors, { + 'username': ['Please enter a correct userid. Note that it is case-sensitive.'], + }) - login_url = '/accounts/login-code/%s/%s/' % ( - self.user.username, - LoginCode.objects.all()[0].code - ) - login_with_code = self.c.get(login_url) - self.assertEqual(login_with_code.status_code, 200) + def test_request_login_code_inactive_user(self): + self.user.is_active = False + self.user.save() - login_post = self.c.post(login_url) - self.assertEqual(login_post.status_code, 302) - self.assertIn(SESSION_KEY, self.c.session) + response = self.client.post('/accounts/login/', { + 'username': self.user.username, + }) - logout = self.c.get('/accounts/logout/') - self.assertEqual(logout.status_code, 302) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].errors, { + 'username': ['This account is inactive.'], + }) - @override_settings(NOPASSWORD_POST_REDIRECT=False) - def test_login_with_get(self): - login = self.c.post('/accounts/login/?next=/secret/', {'username': self.user.username}) - self.assertEqual(login.status_code, 200) + def test_login_post(self): + login_code = LoginCode.objects.create(user=self.user, code='foobar', next='/private/') - login_url = '/accounts/login-code/%s/%s/' % ( - self.user.username, - LoginCode.objects.all()[0].code - ) - login_with_code = self.c.get(login_url) - self.assertEqual(login_with_code.status_code, 302) - self.assertIn(SESSION_KEY, self.c.session) + response = self.client.post('/accounts/login/code/', { + 'code': login_code.code, + }) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/private/') + self.assertEqual(response.wsgi_request.user, self.user) + 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') + + response = self.client.get('/accounts/login/code/', { + 'code': login_code.code, + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].cleaned_data['code'], login_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/') + + response = self.client.get('/accounts/login/code/', { + 'code': login_code.code, + }) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/private/') + self.assertEqual(response.wsgi_request.user, self.user) + self.assertFalse(LoginCode.objects.filter(pk=login_code.pk).exists()) + + def test_login_missing_code_post(self): + response = self.client.post('/accounts/login/code/') - @override_settings(NOPASSWORD_HIDE_USERNAME=True) - def test_hide_username(self): - response = self.c.get('/accounts/login/') self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].errors, { + 'code': ['This field is required.'], + }) + + def test_login_missing_code_get(self): + response = self.client.get('/accounts/login/code/') + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.context['form'].is_bound) + + def test_login_unknown_code(self): + response = self.client.post('/accounts/login/code/', { + 'code': 'unknown', + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['form'].errors, { + 'code': ['Login code is invalid. It might have expired.'], + }) + + def test_login_inactive_user(self): + self.user.is_active = False + self.user.save() + + login_code = LoginCode.objects.create(user=self.user, code='foobar') + + response = self.client.post('/accounts/login/code/', { + '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.'], + }) + + def test_logout_post(self): + login_code = LoginCode.objects.create(user=self.user, code='foobar') + + self.client.login(username=self.user.username, code=login_code.code) + + response = self.client.post('/accounts/logout/?next=/accounts/login/') + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/accounts/login/') + self.assertTrue(response.wsgi_request.user.is_anonymous) + + def test_logout_get(self): + login_code = LoginCode.objects.create(user=self.user, code='foobar') + + self.client.login(username=self.user.username, code=login_code.code) + + response = self.client.post('/accounts/logout/?next=/accounts/login/') - login = self.c.post('/accounts/login/?next=/secret/', {'username': self.user.username}) - self.assertEqual(login.status_code, 200) - - login_with_code = self.c.get('/accounts/login-code/%s/' % 'wrongcode') - self.assertEqual(login_with_code.status_code, 404) - - code_url = '/accounts/login-code/%s/' % LoginCode.objects.all()[0].code - login_with_code = self.c.get(code_url) - self.assertEqual(login_with_code.status_code, 200) - - login_post = self.c.post(code_url) - self.assertEqual(login_post.status_code, 302) - - logout = self.c.get('/accounts/logout/') - self.assertEqual(logout.status_code, 302) - - @patch.object(LoginCode, 'send_login_code') - def test_https_request(self, mock_send_login_code): - login = self.c.post('/accounts/login/?next=/secret/', - {'username': self.user.username}, - **{'wsgi.url_scheme': 'https'}) - self.assertEqual(login.status_code, 200) - mock_send_login_code.assert_called_with(secure=True, host='testserver:80') - - @patch.object(LoginCode, 'send_login_code') - def test_http_request(self, mock_send_login_code): - login = self.c.post('/accounts/login/?next=/secret/', - {'username': self.user.username}, - **{'wsgi.url_scheme': 'http'}) - self.assertEqual(login.status_code, 200) - mock_send_login_code.assert_called_with(secure=False, host='testserver') + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/accounts/login/') + self.assertTrue(response.wsgi_request.user.is_anonymous) diff --git a/tests/urls.py b/tests/urls.py index 143fbb5..eaf3fc3 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -2,5 +2,6 @@ from django.conf.urls import include, url urlpatterns = [ - url(r'^accounts/', include('nopassword.urls', namespace='nopassword')), + url(r'^accounts/', include('nopassword.urls')), + url(r'^accounts-rest/', include('nopassword.rest.urls')), ] diff --git a/tox.ini b/tox.ini index ded5420..2dbff9c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,9 @@ [tox] envlist = - flake8,isort - {py2,py3}-{django1_8,django1_9,django1_10,django1_11}, + flake8, + isort, + py2-{django1_11}, + py3-{django1_11,django2_0,django2_1}, coverage skipsdist = True @@ -15,10 +17,9 @@ commands = coverage run -p --source=nopassword runtests.py deps = -r{toxinidir}/requirements.txt - django1_8: Django>=1.8,<1.9 - django1_9: Django>=1.9,<1.10 - django1_10: Django>=1.10,<1.11 django1_11: Django>=1.11,<1.12 + django2_0: Django>=2.0,<2.1 + django2_1: Django>=2.1,<2.2 [testenv:flake8] basepython = python3