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 %} - - 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 %} + + + + + + + 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 %} + + + + + + + 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 %} - - - -