diff --git a/.travis.yml b/.travis.yml index 5152e3c..f07f4a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,9 @@ language: python python: - "2.7" env: - - DJANGO=1.7.7 - - DJANGO=1.8 + - DJANGO=1.7.11 + - DJANGO=1.8.9 + - DJANGO=1.9.2 install: - pip install -q Django==$DJANGO --use-mirrors - pip install coveralls diff --git a/demo/demo/urls.py b/demo/demo/urls.py index 5b075e6..61a7c77 100644 --- a/demo/demo/urls.py +++ b/demo/demo/urls.py @@ -11,6 +11,8 @@ urlpatterns = [ name='email-verification'), url(r'^login/$', TemplateView.as_view(template_name="login.html"), name='login'), + url(r'^logout/$', TemplateView.as_view(template_name="logout.html"), + name='logout'), url(r'^password-reset/$', TemplateView.as_view(template_name="password_reset.html"), name='password-reset'), diff --git a/demo/requirements.pip b/demo/requirements.pip index f1c5057..4949275 100644 --- a/demo/requirements.pip +++ b/demo/requirements.pip @@ -1,4 +1,4 @@ django>=1.7.0 -django-rest-auth==0.6.0 +django-rest-auth==0.7.0 django-allauth==0.24.1 six==1.9.0 diff --git a/demo/templates/base.html b/demo/templates/base.html index 9d513c4..c9b65f1 100644 --- a/demo/templates/base.html +++ b/demo/templates/base.html @@ -41,6 +41,7 @@
  • User details
  • +
  • Logout
  • Password change
  • diff --git a/demo/templates/fragments/logout_form.html b/demo/templates/fragments/logout_form.html new file mode 100644 index 0000000..7fd281d --- /dev/null +++ b/demo/templates/fragments/logout_form.html @@ -0,0 +1,20 @@ +{% block content %} + +
    {% csrf_token %} +
    + +
    + +

    Token received after login

    +
    +
    + +
    +
    + +
    +
    + +
    +
    +{% endblock %} diff --git a/demo/templates/logout.html b/demo/templates/logout.html new file mode 100644 index 0000000..2ae28e2 --- /dev/null +++ b/demo/templates/logout.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Logout


    + {% include "fragments/logout_form.html" %} +
    +{% endblock %} diff --git a/docs/api_endpoints.rst b/docs/api_endpoints.rst index 1f9660f..05c2196 100644 --- a/docs/api_endpoints.rst +++ b/docs/api_endpoints.rst @@ -11,7 +11,11 @@ Basic - password (string) -- /rest-auth/logout/ (POST) +- /rest-auth/logout/ (POST, GET) + + .. note:: ``ACCOUNT_LOGOUT_ON_GET = True`` to allow logout using GET (this is the exact same conf from allauth) + + - token - /rest-auth/password/reset/ (POST) @@ -70,3 +74,8 @@ Basing on example from installation section :doc:`Installation ` - access_token - code + +- /rest-auth/twitter/ (POST) + + - access_token + - token_secret diff --git a/docs/changelog.rst b/docs/changelog.rst index 735b936..a24d7f5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,17 @@ Changelog ========= +0.8.0 +----- +- added support for django-rest-framework-jwt + +0.7.0 +----- +- Wrapped API returned strings in ugettext_lazy +- Fixed not using ``get_username`` which caused issues when using custom user model without username field +- Django 1.9 support +- Added ``TwitterLoginSerializer`` + 0.6.0 ----- - dropped support for Python 2.6 diff --git a/docs/configuration.rst b/docs/configuration.rst index 079c710..0728e11 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -10,6 +10,8 @@ Configuration - TOKEN_SERIALIZER - response for successful authentication in ``rest_auth.views.LoginView``, default value ``rest_auth.serializers.TokenSerializer`` + - JWT_SERIALIZER - (Using REST_USE_JWT=True) response for successful authentication in ``rest_auth.views.LoginView``, default value ``rest_auth.serializers.JWTSerializer`` + - USER_DETAILS_SERIALIZER - serializer class in ``rest_auth.views.UserDetailsView``, default value ``rest_auth.serializers.UserDetailsSerializer`` - PASSWORD_RESET_SERIALIZER - serializer class in ``rest_auth.views.PasswordResetView``, default value ``rest_auth.serializers.PasswordResetSerializer`` @@ -29,17 +31,20 @@ Configuration ... } -- **REST_AUTH_REGISTRATION_SERIALIZERS** +- **REST_AUTH_REGISTER_SERIALIZERS** You can define your custom serializers for registration endpoint. Possible key values: - REGISTER_SERIALIZER - serializer class in ``rest_auth.register.views.RegisterView``, default value ``rest_auth.register.serializers.RegisterSerializer`` +- **REST_AUTH_TOKEN_MODEL** - model class for tokens, default value ``rest_framework.authtoken.models`` +- **REST_AUTH_TOKEN_CREATOR** - callable to create tokens, default value ``rest_auth.utils.default_create_token``. - **REST_SESSION_LOGIN** - Enable session login in Login API view (default: True) +- **REST_USE_JWT** - Enable JWT Authentication instead of Token/Session based. This is built on top of django-rest-framework-jwt http://getblimp.github.io/django-rest-framework-jwt/ , which much also be installed. (default: False) - **OLD_PASSWORD_FIELD_ENABLED** - set it to True if you want to have old password verification on password change enpoint (default: False) diff --git a/docs/faq.rst b/docs/faq.rst index 5faeee6..ff04c13 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -17,7 +17,12 @@ FAQ djang-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L190 -2. How can I update UserProfile assigned to User model? +2. I get an error: Reverse for 'password_reset_confirm' not found. + + You need to add `password_reset_confirm` url into your ``urls.py`` (at the top of any other included urls). Please check the ``urls.py`` module inside demo app example for more details. + + +3. How can I update UserProfile assigned to User model? Assuming you already have UserProfile model defined like this diff --git a/docs/index.rst b/docs/index.rst index 2ad4a05..dc25e83 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to django-rest-auth's documentation! ============================================ -.. warning:: Updating django-rest-auth to version **0.3.4** is highly recommended because of a security issue in PasswordResetConfirmation validation method. +.. warning:: Updating django-rest-auth from version **0.3.3** is highly recommended because of a security issue in PasswordResetConfirmation validation method. .. note:: django-rest-auth from v0.3.3 supports django-rest-framework v3.0 diff --git a/docs/installation.rst b/docs/installation.rst index 92251dc..9e47a47 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -65,11 +65,11 @@ Registration (optional) Social Authentication (optional) -------------------------------- -Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creating social media authentication view. Below is an example with Facebook authentication. +Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creating social media authentication view. -.. note:: Points 1, 2 and 3 are related with ``django-allauth`` configuration, so if you have already configured social authentication, then please go to step 4. See ``django-allauth`` documentation for more details. +.. note:: Points 1 and 2 are related to ``django-allauth`` configuration, so if you have already configured social authentication, then please go to step 3. See ``django-allauth`` documentation for more details. -1. Add ``allauth.socialaccount`` and ``allauth.socialaccount.providers.facebook`` apps to INSTALLED_APPS in your django settings.py: +1. Add ``allauth.socialaccount`` and ``allauth.socialaccount.providers.facebook`` or ``allauth.socialaccount.providers.twitter`` apps to INSTALLED_APPS in your django settings.py: .. code-block:: python @@ -85,10 +85,15 @@ Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creati ..., 'allauth.socialaccount', 'allauth.socialaccount.providers.facebook', + 'allauth.socialaccount.providers.twitter', + ) 2. Add Social Application in django admin panel +Facebook +######## + 3. Create new view as a subclass of ``rest_auth.registration.views.SocialLoginView`` with ``FacebookOAuth2Adapter`` adapter as an attribute: .. code-block:: python @@ -108,4 +113,45 @@ Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creati url(r'^rest-auth/facebook/$', FacebookLogin.as_view(), name='fb_login') ) + +Twitter +####### + +If you are using Twitter for your social authentication, it is a bit different since Twitter uses OAuth 1.0. + +3. Create new view as a subclass of ``rest_auth.views.LoginView`` with ``TwitterOAuthAdapter`` adapter and ``TwitterLoginSerializer`` as an attribute: + +.. code-block:: python + + from allauth.socialaccount.providers.twitter.views import TwitterOAuthAdapter + from rest_auth.views import LoginView + from rest_auth.social_serializers import TwitterLoginSerializer + + class TwitterLogin(LoginView): + serializer_class = TwitterLoginSerializer + adapter_class = TwitterOAuthAdapter + +4. Create url for TwitterLogin view: + +.. code-block:: python + + urlpatterns += pattern('', + ..., + url(r'^rest-auth/twitter/$', TwitterLogin.as_view(), name='twitter_login') + ) .. note:: Starting from v0.21.0, django-allauth has dropped support for context processors. Check out http://django-allauth.readthedocs.org/en/latest/changelog.html#from-0-21-0 for more details. + + +JWT Support (optional) +---------------------- + +By default, ``django-rest-auth`` uses Django's Token-based authentication. If you want to use JWT authentication, you need to install the following: + +1. Install ``django-rest-framework-jwt`` http://getblimp.github.io/django-rest-framework-jwt/ . Right now this is the only supported JWT library. + +2. Add the following to your settings + +.. code-block:: python + + REST_USE_JWT = True + diff --git a/rest_auth/app_settings.py b/rest_auth/app_settings.py index cd6571a..f6d97a5 100644 --- a/rest_auth/app_settings.py +++ b/rest_auth/app_settings.py @@ -2,6 +2,7 @@ from django.conf import settings from rest_auth.serializers import ( TokenSerializer as DefaultTokenSerializer, + JWTSerializer as DefaultJWTSerializer, UserDetailsSerializer as DefaultUserDetailsSerializer, LoginSerializer as DefaultLoginSerializer, PasswordResetSerializer as DefaultPasswordResetSerializer, @@ -10,12 +11,17 @@ from rest_auth.serializers import ( EmailChangeSerializer as DefaultEmailChangeSerializer) from .utils import import_callable +create_token = import_callable( + getattr(settings, 'REST_AUTH_TOKEN_CREATOR', default_create_token)) serializers = getattr(settings, 'REST_AUTH_SERIALIZERS', {}) TokenSerializer = import_callable( serializers.get('TOKEN_SERIALIZER', DefaultTokenSerializer)) +JWTSerializer = import_callable( + serializers.get('JWT_SERIALIZER', DefaultJWTSerializer)) + UserDetailsSerializer = import_callable( serializers.get('USER_DETAILS_SERIALIZER', DefaultUserDetailsSerializer) ) diff --git a/rest_auth/locale/de/LC_MESSAGES/django.po b/rest_auth/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..3ad22d0 --- /dev/null +++ b/rest_auth/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,99 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-02-02 14:11+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: registration/serializers.py:54 +msgid "View is not defined, pass it as a context variable" +msgstr "\"View\" ist nicht definiert, übergib es als Contextvariable" + +#: registration/serializers.py:59 +msgid "Define adapter_class in view" +msgstr "Definier \"adapter_class\" in view" + +#: registration/serializers.py:78 +msgid "Define callback_url in view" +msgstr "Definier \"callback_url\" in view" + +#: registration/serializers.py:82 +msgid "Define client_class in view" +msgstr "Definier \"client_class\" in view" + +#: registration/serializers.py:102 +msgid "Incorrect input. access_token or code is required." +msgstr "Falsche Eingabe. \"access_token\" oder \"code\" erforderlich." + +#: registration/serializers.py:111 +msgid "Incorrect value" +msgstr "Falscher Wert." + +#: registration/serializers.py:140 +msgid "A user is already registered with this e-mail address." +msgstr "Ein User mit dieser E-Mail Adresse ist schon registriert." + +#: registration/serializers.py:148 +msgid "The two password fields didn't match." +msgstr "Die beiden Passwörter sind nicht identisch." + +#: registration/views.py:64 +msgid "ok" +msgstr "Ok" + +#: serializers.py:29 +msgid "Must include \"email\" and \"password\"." +msgstr "Muss \"email\" und \"password\" enthalten." + +#: serializers.py:40 +msgid "Must include \"username\" and \"password\"." +msgstr "Muss \"username\" und \"password\" enthalten." + +#: serializers.py:53 +msgid "Must include either \"username\" or \"email\" and \"password\"." +msgstr "Muss entweder \"username\" oder \"email\" und password \"password\"" + +#: serializers.py:94 +msgid "User account is disabled." +msgstr "Der Useraccount ist deaktiviert." + +#: serializers.py:97 +msgid "Unable to log in with provided credentials." +msgstr "Kann nicht mit den angegeben Zugangsdaten anmelden." + +#: serializers.py:106 +msgid "E-mail is not verified." +msgstr "E-Mail Adresse ist nicht verifiziert." + +#: serializers.py:152 +msgid "Error" +msgstr "Fehler" + +#: views.py:71 +msgid "Successfully logged out." +msgstr "Erfolgreich ausgeloggt." + +#: views.py:111 +msgid "Password reset e-mail has been sent." +msgstr "Die E-Mail zum Zurücksetzen des Passwortes wurde verschickt." + +#: views.py:132 +msgid "Password has been reset with the new password." +msgstr "Das Passwort wurde mit dem neuen Passwort ersetzt." + +#: views.py:150 +msgid "New password has been saved." +msgstr "Das neue Passwort wurde gespeichert." diff --git a/rest_auth/models.py b/rest_auth/models.py index e703865..a132f9c 100644 --- a/rest_auth/models.py +++ b/rest_auth/models.py @@ -1,3 +1,10 @@ -# from django.db import models +from django.conf import settings + +from rest_framework.authtoken.models import Token as DefaultTokenModel + +from .utils import import_callable # Register your models here. + +TokenModel = import_callable( + getattr(settings, 'REST_AUTH_TOKEN_MODEL', DefaultTokenModel)) diff --git a/rest_auth/registration/serializers.py b/rest_auth/registration/serializers.py index b27d7bd..2390b05 100644 --- a/rest_auth/registration/serializers.py +++ b/rest_auth/registration/serializers.py @@ -1,5 +1,6 @@ from django.http import HttpRequest from django.conf import settings +from django.utils.translation import ugettext_lazy as _ try: from allauth.account import app_settings as allauth_settings @@ -14,13 +15,12 @@ from rest_framework import serializers from requests.exceptions import HTTPError # Import is needed only if we are using social login, in which # case the allauth.socialaccount will be declared -try: - from allauth.socialaccount.helpers import complete_social_login -except ImportError: - raise ImportError('allauth.socialaccount needs to be installed.') -if 'allauth.socialaccount' not in settings.INSTALLED_APPS: - raise ImportError('allauth.socialaccount needs to be added to INSTALLED_APPS.') +if 'allauth.socialaccount' in settings.INSTALLED_APPS: + try: + from allauth.socialaccount.helpers import complete_social_login + except ImportError: + pass class SocialLoginSerializer(serializers.Serializer): @@ -53,12 +53,12 @@ class SocialLoginSerializer(serializers.Serializer): if not view: raise serializers.ValidationError( - 'View is not defined, pass it as a context variable' + _('View is not defined, pass it as a context variable') ) adapter_class = getattr(view, 'adapter_class', None) if not adapter_class: - raise serializers.ValidationError('Define adapter_class in view') + raise serializers.ValidationError(_('Define adapter_class in view')) adapter = adapter_class() app = adapter.get_provider().get_app(request) @@ -77,11 +77,11 @@ class SocialLoginSerializer(serializers.Serializer): if not self.callback_url: raise serializers.ValidationError( - 'Define callback_url in view' + _('Define callback_url in view') ) if not self.client_class: raise serializers.ValidationError( - 'Define client_class in view' + _('Define client_class in view') ) code = attrs.get('code') @@ -101,7 +101,7 @@ class SocialLoginSerializer(serializers.Serializer): access_token = token['access_token'] else: - raise serializers.ValidationError('Incorrect input. access_token or code is required.') + raise serializers.ValidationError(_('Incorrect input. access_token or code is required.')) token = adapter.parse_token({'access_token': access_token}) token.app = app @@ -110,7 +110,7 @@ class SocialLoginSerializer(serializers.Serializer): login = self.get_social_login(adapter, app, token, access_token) complete_social_login(request, login) except HTTPError: - raise serializers.ValidationError('Incorrect value') + raise serializers.ValidationError(_('Incorrect value')) if not login.is_existing: login.lookup() @@ -139,7 +139,7 @@ class RegisterSerializer(serializers.Serializer): if allauth_settings.UNIQUE_EMAIL: if email and email_address_exists(email): raise serializers.ValidationError( - "A user is already registered with this e-mail address.") + _("A user is already registered with this e-mail address.")) return email def validate_password1(self, password): @@ -147,7 +147,7 @@ class RegisterSerializer(serializers.Serializer): def validate(self, data): if data['password1'] != data['password2']: - raise serializers.ValidationError("The two password fields didn't match.") + raise serializers.ValidationError(_("The two password fields didn't match.")) return data def custom_signup(self, request, user): diff --git a/rest_auth/registration/urls.py b/rest_auth/registration/urls.py index 3206c9e..b23fc6c 100644 --- a/rest_auth/registration/urls.py +++ b/rest_auth/registration/urls.py @@ -17,7 +17,7 @@ urlpatterns = [ # with proper key. # If you don't want to use API on that step, then just use ConfirmEmailView # view from: - # djang-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L190 + # django-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L190 url(r'^account-confirm-email/(?P\w+)/$', TemplateView.as_view(), name='account_confirm_email'), url(r'^change-email/$', VerifyEmailView.as_view(), name='rest_email_change'), diff --git a/rest_auth/registration/views.py b/rest_auth/registration/views.py index 81fd951..d1d8d37 100644 --- a/rest_auth/registration/views.py +++ b/rest_auth/registration/views.py @@ -1,38 +1,61 @@ +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework.generics import CreateAPIView from rest_framework import status -from rest_framework.authtoken.models import Token from rest_framework.exceptions import MethodNotAllowed from allauth.account.views import ConfirmEmailView from allauth.account.utils import complete_signup from allauth.account import app_settings as allauth_settings -from rest_auth.app_settings import TokenSerializer +from rest_auth.app_settings import (TokenSerializer, + JWTSerializer, + create_token) from rest_auth.registration.serializers import (SocialLoginSerializer, VerifyEmailSerializer) -from .app_settings import RegisterSerializer from rest_auth.views import LoginView +from rest_auth.models import TokenModel +from .app_settings import RegisterSerializer +from rest_auth.utils import jwt_encode class RegisterView(CreateAPIView): serializer_class = RegisterSerializer permission_classes = (AllowAny, ) + token_model = TokenModel + + def get_response_data(self, user): + if allauth_settings.EMAIL_VERIFICATION == \ + allauth_settings.EmailVerificationMethod.MANDATORY: + return {} + + if getattr(settings, 'REST_USE_JWT', False): + data = { + 'user': user, + 'token': self.token + } + return JWTSerializer(data).data + else: + return TokenSerializer(user.auth_token).data def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(TokenSerializer(user.auth_token).data, - status=status.HTTP_201_CREATED, - headers=headers) + + return Response(self.get_response_data(user), status=status.HTTP_201_CREATED, headers=headers) def perform_create(self, serializer): user = serializer.save(self.request) - Token.objects.get_or_create(user=user) + if getattr(settings, 'REST_USE_JWT', False): + self.token = jwt_encode(user) + else: + create_token(self.token_model, user, serializer) complete_signup(self.request._request, user, allauth_settings.EMAIL_VERIFICATION, None) @@ -53,7 +76,7 @@ class VerifyEmailView(APIView, ConfirmEmailView): self.kwargs['key'] = serializer.validated_data['key'] confirmation = self.get_object() confirmation.confirm(self.request) - return Response({'message': 'ok'}, status=status.HTTP_200_OK) + return Response({'message': _('ok')}, status=status.HTTP_200_OK) class SocialLoginView(LoginView): diff --git a/rest_auth/serializers.py b/rest_auth/serializers.py index 80fb254..7b14182 100644 --- a/rest_auth/serializers.py +++ b/rest_auth/serializers.py @@ -6,8 +6,9 @@ from django.utils.http import urlsafe_base64_decode as uid_decoder from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import force_text +from .models import TokenModel + from rest_framework import serializers, exceptions -from rest_framework.authtoken.models import Token from rest_framework.exceptions import ValidationError # Get the UserModel @@ -80,7 +81,7 @@ class LoginSerializer(serializers.Serializer): # Authentication without using allauth if email: try: - username = UserModel.objects.get(email__iexact=email).username + username = UserModel.objects.get(email__iexact=email).get_username() except UserModel.DoesNotExist: pass @@ -102,7 +103,7 @@ class LoginSerializer(serializers.Serializer): if app_settings.EMAIL_VERIFICATION == app_settings.EmailVerificationMethod.MANDATORY: email_address = user.emailaddress_set.get(email=user.email) if not email_address.verified: - raise serializers.ValidationError('E-mail is not verified.') + raise serializers.ValidationError(_('E-mail is not verified.')) attrs['user'] = user return attrs @@ -114,8 +115,9 @@ class TokenSerializer(serializers.ModelSerializer): """ class Meta: - model = Token - fields = ('key') + + model = TokenModel + fields = ('key',) class UserDetailsSerializer(serializers.ModelSerializer): @@ -127,6 +129,12 @@ class UserDetailsSerializer(serializers.ModelSerializer): model = UserModel fields = ('username', 'email', 'first_name', 'last_name') +class JWTSerializer(serializers.Serializer): + """ + Serializer for JWT authentication. + """ + token = serializers.CharField() + user = UserDetailsSerializer() class PasswordResetSerializer(serializers.Serializer): @@ -138,15 +146,17 @@ class PasswordResetSerializer(serializers.Serializer): password_reset_form_class = PasswordResetForm + def get_email_options(self): + """ Override this method to change default e-mail options + """ + return {} + def validate_email(self, value): # Create PasswordResetForm with the serializer self.reset_form = self.password_reset_form_class(data=self.initial_data) if not self.reset_form.is_valid(): raise serializers.ValidationError(_('Error')) - if not UserModel.objects.filter(email__iexact=value).exists(): - raise serializers.ValidationError(_('Invalid e-mail address')) - return value def save(self): @@ -157,6 +167,8 @@ class PasswordResetSerializer(serializers.Serializer): 'from_email': getattr(settings, 'DEFAULT_FROM_EMAIL'), 'request': request, } + + opts.update(self.get_email_options()) self.reset_form.save(**opts) diff --git a/rest_auth/social_serializers.py b/rest_auth/social_serializers.py new file mode 100644 index 0000000..087e6e1 --- /dev/null +++ b/rest_auth/social_serializers.py @@ -0,0 +1,78 @@ +from django.http import HttpRequest +from rest_framework import serializers +from requests.exceptions import HTTPError +# Import is needed only if we are using social login, in which +# case the allauth.socialaccount will be declared +try: + from allauth.socialaccount.helpers import complete_social_login +except ImportError: + pass + +from allauth.socialaccount.models import SocialToken + + +class TwitterLoginSerializer(serializers.Serializer): + access_token = serializers.CharField(required=True) + token_secret = serializers.CharField(required=True) + + def _get_request(self): + request = self.context.get('request') + if not isinstance(request, HttpRequest): + request = request._request + return request + + def get_social_login(self, adapter, app, token, response): + """ + + :param adapter: allauth.socialaccount Adapter subclass. Usually OAuthAdapter or Auth2Adapter + :param app: `allauth.socialaccount.SocialApp` instance + :param token: `allauth.socialaccount.SocialToken` instance + :param response: Provider's response for OAuth1. Not used in the + :return: :return: A populated instance of the `allauth.socialaccount.SocialLoginView` instance + """ + request = self._get_request() + social_login = adapter.complete_login(request, app, token, response=response) + social_login.token = token + return social_login + + def validate(self, attrs): + view = self.context.get('view') + request = self._get_request() + + if not view: + raise serializers.ValidationError( + 'View is not defined, pass it as a context variable' + ) + + adapter_class = getattr(view, 'adapter_class', None) + if not adapter_class: + raise serializers.ValidationError('Define adapter_class in view') + + adapter = adapter_class() + app = adapter.get_provider().get_app(request) + + if('access_token' in attrs) and ('token_secret' in attrs): + access_token = attrs.get('access_token') + token_secret = attrs.get('token_secret') + else: + raise serializers.ValidationError('Incorrect input. access_token and token_secret are required.') + + request.session['oauth_api.twitter.com_access_token'] = { + 'oauth_token': access_token, + 'oauth_token_secret': token_secret, + } + token = SocialToken(token=access_token, token_secret=token_secret) + token.app = app + + try: + login = self.get_social_login(adapter, app, token, access_token) + complete_social_login(request, login) + except HTTPError: + raise serializers.ValidationError('Incorrect value') + + if not login.is_existing: + login.lookup() + login.save(request, connect=True) + attrs['user'] = login.account.user + + return attrs diff --git a/rest_auth/tests/requirements.pip b/rest_auth/tests/requirements.pip index bb8d844..de66892 100644 --- a/rest_auth/tests/requirements.pip +++ b/rest_auth/tests/requirements.pip @@ -1,3 +1,4 @@ django-allauth>=0.19.1 responses>=0.3.0 flake8==2.4.0 +djangorestframework-jwt>=1.7.2 diff --git a/rest_auth/tests/settings.py b/rest_auth/tests/settings.py index b09b496..aab38d3 100644 --- a/rest_auth/tests/settings.py +++ b/rest_auth/tests/settings.py @@ -45,6 +45,13 @@ TEMPLATE_CONTEXT_PROCESSORS = [ "allauth.socialaccount.context_processors.socialaccount", ] +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', + ) +} + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -64,7 +71,9 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'rest_auth', - 'rest_auth.registration' + 'rest_auth.registration', + + 'rest_framework_jwt' ] SECRET_KEY = "38dh*skf8sjfhs287dh&^hd8&3hdg*j2&sd" diff --git a/rest_auth/tests/test_api.py b/rest_auth/tests/test_api.py index 222b3a7..f75322c 100644 --- a/rest_auth/tests/test_api.py +++ b/rest_auth/tests/test_api.py @@ -91,6 +91,19 @@ class APITestCase1(TestCase, BaseAPITestCase): # test empty payload self.post(self.login_url, data={}, status_code=400) + @override_settings(REST_USE_JWT=True) + def test_login_jwt(self): + payload = { + "username": self.USERNAME, + "password": self.PASS + } + user = get_user_model().objects.create_user(self.USERNAME, '', self.PASS) + + self.post(self.login_url, data=payload, status_code=200) + self.assertEqual('token' in self.response.json.keys(), True) + self.token = self.response.json['token'] + + def test_login_by_email(self): # starting test without allauth app settings.INSTALLED_APPS.remove('allauth') @@ -280,12 +293,15 @@ class APITestCase1(TestCase, BaseAPITestCase): self.assertEqual(len(mail.outbox), mail_count + 1) def test_password_reset_with_invalid_email(self): + """ + Invalid email should not raise error, as this would leak users + """ get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) # call password reset mail_count = len(mail.outbox) payload = {'email': 'nonexisting@email.com'} - self.post(self.password_reset_url, data=payload, status_code=400) + self.post(self.password_reset_url, data=payload, status_code=200) self.assertEqual(len(mail.outbox), mail_count) def test_user_details(self): @@ -304,20 +320,52 @@ class APITestCase1(TestCase, BaseAPITestCase): self.assertEqual(user.last_name, self.response.json['last_name']) self.assertEqual(user.email, self.response.json['email']) + @override_settings(REST_USE_JWT=True) + def test_user_details_using_jwt(self): + user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) + payload = { + "username": self.USERNAME, + "password": self.PASS + } + self.post(self.login_url, data=payload, status_code=200) + self.token = self.response.json['token'] + self.get(self.user_url, status_code=200) + + self.patch(self.user_url, data=self.BASIC_USER_DATA, status_code=200) + user = get_user_model().objects.get(pk=user.pk) + self.assertEqual(user.email, self.response.json['email']) + + def test_registration(self): user_count = get_user_model().objects.all().count() # test empty payload self.post(self.register_url, data={}, status_code=400) - self.post(self.register_url, data=self.REGISTRATION_DATA, status_code=201) + result = self.post(self.register_url, data=self.REGISTRATION_DATA, status_code=201) + self.assertIn('key', result.data) self.assertEqual(get_user_model().objects.all().count(), user_count + 1) + new_user = get_user_model().objects.latest('id') self.assertEqual(new_user.username, self.REGISTRATION_DATA['username']) self._login() self._logout() + @override_settings(REST_USE_JWT=True) + def test_registration_with_jwt(self): + user_count = get_user_model().objects.all().count() + + self.post(self.register_url, data={}, status_code=400) + + result = self.post(self.register_url, data=self.REGISTRATION_DATA, status_code=201) + self.assertIn('token', result.data) + self.assertEqual(get_user_model().objects.all().count(), user_count + 1) + + self._login() + self._logout() + + def test_registration_with_invalid_password(self): data = self.REGISTRATION_DATA.copy() data['password2'] = 'foobar' @@ -339,11 +387,12 @@ class APITestCase1(TestCase, BaseAPITestCase): status_code=status.HTTP_400_BAD_REQUEST ) - self.post( + result = self.post( self.register_url, data=self.REGISTRATION_DATA_WITH_EMAIL, status_code=status.HTTP_201_CREATED ) + self.assertNotIn('key', result.data) self.assertEqual(get_user_model().objects.all().count(), user_count + 1) self.assertEqual(len(mail.outbox), mail_count + 1) new_user = get_user_model().objects.latest('id') @@ -372,3 +421,29 @@ class APITestCase1(TestCase, BaseAPITestCase): # try to login again self._login() self._logout() + + @override_settings(ACCOUNT_LOGOUT_ON_GET=True) + def test_logout_on_get(self): + payload = { + "username": self.USERNAME, + "password": self.PASS + } + + # create user + user = get_user_model().objects.create_user(self.USERNAME, '', self.PASS) + + self.post(self.login_url, data=payload, status_code=200) + self.get(self.logout_url, status=status.HTTP_200_OK) + + @override_settings(ACCOUNT_LOGOUT_ON_GET=False) + def test_logout_on_post_only(self): + payload = { + "username": self.USERNAME, + "password": self.PASS + } + + # create user + user = get_user_model().objects.create_user(self.USERNAME, '', self.PASS) + + self.post(self.login_url, data=payload, status_code=status.HTTP_200_OK) + self.get(self.logout_url, status_code=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/rest_auth/tests/test_base.py b/rest_auth/tests/test_base.py index ed8ffeb..97dfa31 100644 --- a/rest_auth/tests/test_base.py +++ b/rest_auth/tests/test_base.py @@ -37,7 +37,10 @@ class BaseAPITestCase(object): # check_headers = kwargs.pop('check_headers', True) if hasattr(self, 'token'): - kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % self.token + if getattr(settings, 'REST_USE_JWT', False): + kwargs['HTTP_AUTHORIZATION'] = 'JWT %s' % self.token + else: + kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % self.token self.response = request_func(*args, **kwargs) is_json = bool( diff --git a/rest_auth/tests/test_social.py b/rest_auth/tests/test_social.py index 19509ef..34c912e 100644 --- a/rest_auth/tests/test_social.py +++ b/rest_auth/tests/test_social.py @@ -125,3 +125,29 @@ class TestSocialAuth(TestCase, BaseAPITestCase): self.post(self.fb_login_url, data=payload, status_code=200) self.assertIn('key', self.response.json.keys()) + + @responses.activate + @override_settings( + REST_USE_JWT=True + ) + def test_jwt(self): + resp_body = '{"id":"123123123123","first_name":"John","gender":"male","last_name":"Smith","link":"https:\\/\\/www.facebook.com\\/john.smith","locale":"en_US","name":"John Smith","timezone":2,"updated_time":"2014-08-13T10:14:38+0000","username":"john.smith","verified":true}' # noqa + responses.add( + responses.GET, + self.graph_api_url, + body=resp_body, + status=200, + content_type='application/json' + ) + + users_count = get_user_model().objects.all().count() + payload = { + 'access_token': 'abc123' + } + + self.post(self.fb_login_url, data=payload, status_code=200) + self.assertIn('token', self.response.json.keys()) + self.assertIn('user', self.response.json.keys()) + + self.assertEqual(get_user_model().objects.all().count(), users_count + 1) + diff --git a/rest_auth/utils.py b/rest_auth/utils.py index a32da60..5cb94d7 100644 --- a/rest_auth/utils.py +++ b/rest_auth/utils.py @@ -9,3 +9,20 @@ def import_callable(path_or_callable): assert isinstance(path_or_callable, string_types) package, attr = path_or_callable.rsplit('.', 1) return getattr(import_module(package), attr) + + +def default_create_token(token_model, user, serializer): + token, _ = token_model.objects.get_or_create(user=user) + return token + +def jwt_encode(user): + try: + from rest_framework_jwt.settings import api_settings + except ImportError: + raise ImportError('rest_framework_jwt needs to be installed') + + jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER + jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + + payload = jwt_payload_handler(user) + return jwt_encode_handler(payload) diff --git a/rest_auth/views.py b/rest_auth/views.py index 52718eb..6946ef2 100644 --- a/rest_auth/views.py +++ b/rest_auth/views.py @@ -1,20 +1,25 @@ from django.contrib.auth import login, logout from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated, AllowAny -from rest_framework.authtoken.models import Token from rest_framework.generics import RetrieveUpdateAPIView +from allauth.account import app_settings as allauth_settings + from .app_settings import ( TokenSerializer, UserDetailsSerializer, LoginSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, - PasswordChangeSerializer + PasswordChangeSerializer, JWTSerializer, create_token ) +from .models import TokenModel + +from .utils import jwt_encode class LoginView(GenericAPIView): @@ -30,20 +35,40 @@ class LoginView(GenericAPIView): """ permission_classes = (AllowAny,) serializer_class = LoginSerializer - token_model = Token - response_serializer = TokenSerializer + token_model = TokenModel + + def get_response_serializer(self): + if getattr(settings, 'REST_USE_JWT', False): + response_serializer = JWTSerializer + else: + response_serializer = TokenSerializer + return response_serializer 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_USE_JWT', False): + self.token = jwt_encode(self.user) + else: + self.token = create_token(self.token_model, self.user, self.serializer) + if getattr(settings, 'REST_SESSION_LOGIN', True): login(self.request, self.user) + def get_response(self): - return Response( - self.response_serializer(self.token).data, status=status.HTTP_200_OK - ) + serializer_class = self.get_response_serializer() + + if getattr(settings, 'REST_USE_JWT', False): + data = { + 'user': self.user, + 'token': self.token + } + serializer = serializer_class(instance=data) + else: + serializer = serializer_class(instance=self.token) + + return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, *args, **kwargs): self.serializer = self.get_serializer(data=self.request.data) @@ -62,7 +87,23 @@ class LogoutView(APIView): """ permission_classes = (AllowAny,) + def get(self, request, *args, **kwargs): + try: + if allauth_settings.LOGOUT_ON_GET: + response = self.logout(request) + else: + response = self.http_method_not_allowed(request, *args, **kwargs) + except Exception as exc: + response = self.handle_exception(exc) + + return self.finalize_response(request, response, *args, **kwargs) + self.response = self.finalize_response(request, response, *args, **kwargs) + return self.response + def post(self, request): + return self.logout(request) + + def logout(self, request): try: request.user.auth_token.delete() except (AttributeError, ObjectDoesNotExist): @@ -70,12 +111,11 @@ class LogoutView(APIView): logout(request) - return Response({"success": "Successfully logged out."}, + return Response({"success": _("Successfully logged out.")}, status=status.HTTP_200_OK) class UserDetailsView(RetrieveUpdateAPIView): - """ Returns User's details in JSON format. @@ -112,13 +152,12 @@ class PasswordResetView(GenericAPIView): serializer.save() # Return the success message with OK HTTP status return Response( - {"success": "Password reset e-mail has been sent."}, + {"success": _("Password reset e-mail has been sent.")}, status=status.HTTP_200_OK ) class PasswordResetConfirmView(GenericAPIView): - """ Password reset e-mail link is confirmed, therefore this resets the user's password. @@ -134,11 +173,10 @@ class PasswordResetConfirmView(GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - return Response({"success": "Password has been reset with the new password."}) + return Response({"success": _("Password has been reset with the new password.")}) class PasswordChangeView(GenericAPIView): - """ Calls Django Auth SetPasswordForm save method. @@ -171,4 +209,4 @@ class PasswordChangeView(GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - return Response({"success": "New Email has been saved."}) + return Response({"success": _("New password has been saved.")}) diff --git a/setup.py b/setup.py index d85b2d2..9b33b7f 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ f.close() setup( name='django-rest-auth', - version='0.6.0', + version='0.8.0', author='Sumit Chachra', author_email='chachra@tivix.com', url='http://github.com/Tivix/django-rest-auth',