diff --git a/.gitignore b/.gitignore index 8a04b95..85446ba 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage_html # Translations *.mo diff --git a/.travis.yml b/.travis.yml index 53c2a44..f07f4a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,14 @@ language: python python: - - "2.6" - "2.7" env: - - DJANGO=1.5.12 - - DJANGO=1.6.11 - - DJANGO=1.7.7 - - DJANGO=1.8 -matrix: - exclude: - - python: "2.6" - env: DJANGO=1.7.7 - - python: "2.6" - env: 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 - - pip install -r test_requirements.pip + - pip install -r rest_auth/tests/requirements.pip script: - coverage run --source=rest_auth setup.py test after_success: diff --git a/README.rst b/README.rst index 1e670d7..9014187 100644 --- a/README.rst +++ b/README.rst @@ -9,10 +9,6 @@ Welcome to django-rest-auth :target: https://coveralls.io/r/Tivix/django-rest-auth?branch=master -.. image:: https://requires.io/github/Tivix/django-rest-auth/requirements.png?branch=master - :target: https://requires.io/github/Tivix/django-rest-auth/requirements/?branch=master - - .. image:: https://readthedocs.org/projects/django-rest-auth/badge/?version=latest :target: https://readthedocs.org/projects/django-rest-auth/?badge=latest @@ -29,3 +25,7 @@ Source code ----------- https://github.com/Tivix/django-rest-auth + +Stack Overflow +----------- +http://stackoverflow.com/questions/tagged/django-rest-auth \ No newline at end of file diff --git a/demo/demo/settings.py b/demo/demo/settings.py index 50669e1..cd2d2f0 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -29,8 +29,10 @@ ALLOWED_HOSTS = [] TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.auth.context_processors.auth', "django.core.context_processors.request", - "allauth.account.context_processors.account", - "allauth.socialaccount.context_processors.socialaccount", + + # Disabling due to alluth>=0.21.0 changes + # "allauth.account.context_processors.account", + # "allauth.socialaccount.context_processors.socialaccount", ) # Application definition @@ -51,6 +53,8 @@ INSTALLED_APPS = ( 'allauth', 'allauth.account', 'rest_auth.registration', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.facebook', ) MIDDLEWARE_CLASSES = ( @@ -102,9 +106,9 @@ TEMPLATE_DIRS = [os.path.join(BASE_DIR, 'templates')] REST_SESSION_LOGIN = False EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' SITE_ID = 1 -ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_AUTHENTICATION_METHOD = 'email' -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_EMAIL_REQUIRED = False +ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_EMAIL_VERIFICATION = 'optional' REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( diff --git a/demo/demo/urls.py b/demo/demo/urls.py index b2d7676..3813e5a 100644 --- a/demo/demo/urls.py +++ b/demo/demo/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import patterns, include, url +from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView, RedirectView -urlpatterns = patterns('', +urlpatterns = [ url(r'^$', TemplateView.as_view(template_name="home.html"), name='home'), url(r'^signup/$', TemplateView.as_view(template_name="signup.html"), name='signup'), @@ -11,6 +11,8 @@ urlpatterns = patterns('', 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'), @@ -35,5 +37,5 @@ urlpatterns = patterns('', url(r'^rest-auth/registration/', include('rest_auth.registration.urls')), url(r'^account/', include('allauth.urls')), url(r'^admin/', include(admin.site.urls)), - url(r'^accounts/profile/$', RedirectView.as_view(url='/'), name='profile-redirect'), -) + url(r'^accounts/profile/$', RedirectView.as_view(url='/', permanent=True), name='profile-redirect'), +] diff --git a/demo/requirements.pip b/demo/requirements.pip index 2583a95..f1c5057 100644 --- a/demo/requirements.pip +++ b/demo/requirements.pip @@ -1,4 +1,4 @@ -django>=1.5.0 -django-rest-auth==0.4.0 -django-allauth==0.19.1 +django>=1.7.0 +django-rest-auth==0.6.0 +django-allauth==0.24.1 six==1.9.0 diff --git a/demo/templates/base.html b/demo/templates/base.html index 8a0b0ed..03a2b73 100644 --- a/demo/templates/base.html +++ b/demo/templates/base.html @@ -40,6 +40,7 @@
  • User details
  • +
  • Logout
  • Password change
  • diff --git a/demo/templates/fragments/email_verification_form.html b/demo/templates/fragments/email_verification_form.html index 2298d0e..a718e0b 100644 --- a/demo/templates/fragments/email_verification_form.html +++ b/demo/templates/fragments/email_verification_form.html @@ -1,5 +1,5 @@ -
    +{% csrf_token %}
    diff --git a/demo/templates/fragments/login_form.html b/demo/templates/fragments/login_form.html index ee02b53..46aba62 100644 --- a/demo/templates/fragments/login_form.html +++ b/demo/templates/fragments/login_form.html @@ -1,5 +1,5 @@ - +{% csrf_token %}
    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/fragments/password_change_form.html b/demo/templates/fragments/password_change_form.html index 9e64ea4..c8dfda5 100644 --- a/demo/templates/fragments/password_change_form.html +++ b/demo/templates/fragments/password_change_form.html @@ -1,6 +1,5 @@ -
    - +{% csrf_token %}
    diff --git a/demo/templates/fragments/password_reset_confirm_form.html b/demo/templates/fragments/password_reset_confirm_form.html index 973b05d..5a9c395 100644 --- a/demo/templates/fragments/password_reset_confirm_form.html +++ b/demo/templates/fragments/password_reset_confirm_form.html @@ -1,5 +1,5 @@ - +{% csrf_token %}
    diff --git a/demo/templates/fragments/password_reset_form.html b/demo/templates/fragments/password_reset_form.html index 6840193..0f61344 100644 --- a/demo/templates/fragments/password_reset_form.html +++ b/demo/templates/fragments/password_reset_form.html @@ -1,5 +1,5 @@ - +{% csrf_token %}
    diff --git a/demo/templates/fragments/signup_form.html b/demo/templates/fragments/signup_form.html index 9a7e43e..d60b99b 100644 --- a/demo/templates/fragments/signup_form.html +++ b/demo/templates/fragments/signup_form.html @@ -1,5 +1,5 @@ - +{% csrf_token %}
    diff --git a/demo/templates/fragments/user_details_form.html b/demo/templates/fragments/user_details_form.html index 405403b..7fafc0a 100644 --- a/demo/templates/fragments/user_details_form.html +++ b/demo/templates/fragments/user_details_form.html @@ -1,5 +1,5 @@ - +{% csrf_token %}
    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 40287d2..7f22efd 100644 --- a/docs/api_endpoints.rst +++ b/docs/api_endpoints.rst @@ -7,11 +7,14 @@ Basic - /rest-auth/login/ (POST) - username (string) + - email (string) - password (string) - /rest-auth/logout/ (POST) + - token + - /rest-auth/password/reset/ (POST) - email @@ -30,9 +33,10 @@ Basic - new_password1 - new_password2 - old_password - - + + .. note:: ``OLD_PASSWORD_FIELD_ENABLED = True`` to use old_password. + .. note:: ``LOGOUT_ON_PASSWORD_CHANGE = False`` to keep the user logged in after password change - /rest-auth/user/ (GET) @@ -54,16 +58,6 @@ Registration - password2 - email - .. note:: This endpoint is based on ``allauth.account.views.SignupView`` and uses the same form as in this view. To override fields you have to create custom Signup Form and define it in django settings: - - .. code-block:: python - - ACCOUNT_FORMS = { - 'signup': 'path.to.custom.SignupForm' - } - - See allauth documentation for more details. - - /rest-auth/registration/verify-email/ (POST) - key diff --git a/docs/changelog.rst b/docs/changelog.rst index cbe9962..735b936 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,23 @@ Changelog ========= +0.6.0 +----- +- dropped support for Python 2.6 +- dropped support for Django 1.6 +- fixed demo code +- added better validation support for serializers +- added optional logout after password change +- compatibility fixes +- bugfixes + +0.5.0 +----- +- replaced request.DATA with request.data for compatibility with DRF 3.2 +- authorization codes for social login +- view classes rename (appended "View" to all of them) +- bugfixes + 0.4.0 ----- - Django 1.8 compatiblity fixes diff --git a/docs/configuration.rst b/docs/configuration.rst index ed0d785..3746234 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -29,8 +29,19 @@ Configuration ... } +- **REST_AUTH_REGISTRATION_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) - - **OLD_PASSWORD_FIELD_ENABLED** - set it to True if you want to have old password verification on password change enpoint (default: False) + +- **LOGOUT_ON_PASSWORD_CHANGE** - set to False if you want to keep the current user logged in after a password change diff --git a/docs/demo.rst b/docs/demo.rst index ed74750..877ca33 100644 --- a/docs/demo.rst +++ b/docs/demo.rst @@ -11,7 +11,7 @@ Do these steps to make it running (ideally in virtualenv). git clone https://github.com/Tivix/django-rest-auth.git cd django-rest-auth/demo/ pip install -r requirements.pip - python manage.py syncdb --settings=demo.settings --noinput + python manage.py migrate --settings=demo.settings --noinput python manage.py runserver --settings=demo.settings Now, go to ``http://127.0.0.1:8000/`` in your browser. 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 989bcb8..92251dc 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -38,7 +38,7 @@ You're good to go now! Registration (optional) ----------------------- -1. If you want to enable standard registration process you will need to install ``django-allauth`` - see this doc for installation http://django-allauth.readthedocs.org/en/latest/installation.html. +1. If you want to enable standard registration process you will need to install ``django-allauth`` by using ``pip install django-rest-auth[extras]`` or ``pip install django-rest-auth[with_social]``. 2. Add ``allauth``, ``allauth.account`` and ``rest_auth.registration`` apps to INSTALLED_APPS in your django settings.py: @@ -57,8 +57,8 @@ Registration (optional) urlpatterns = patterns('', ..., - (r'^rest-auth/', include('rest_auth.urls')), - (r'^rest-auth/registration/', include('rest_auth.registration.urls')) + url(r'^rest-auth/', include('rest_auth.urls')), + url(r'^rest-auth/registration/', include('rest_auth.registration.urls')) ) @@ -87,11 +87,9 @@ Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creati 'allauth.socialaccount.providers.facebook', ) -2. Add ``allauth.socialaccount.context_processors.socialaccount`` to TEMPLATE_CONTEXT_PROCESSORS in django settings +2. Add Social Application in django admin panel -3. Add Social Application in django admin panel - -4. Create new view as a subclass of ``rest_auth.registration.views.SocialLoginView`` with ``FacebookOAuth2Adapter`` adapter as an attribute: +3. Create new view as a subclass of ``rest_auth.registration.views.SocialLoginView`` with ``FacebookOAuth2Adapter`` adapter as an attribute: .. code-block:: python @@ -101,7 +99,7 @@ Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creati class FacebookLogin(SocialLoginView): adapter_class = FacebookOAuth2Adapter -5. Create url for FacebookLogin view: +4. Create url for FacebookLogin view: .. code-block:: python @@ -109,3 +107,5 @@ Using ``django-allauth``, ``django-rest-auth`` provides helpful class for creati ..., url(r'^rest-auth/facebook/$', FacebookLogin.as_view(), name='fb_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. diff --git a/rest_auth/app_settings.py b/rest_auth/app_settings.py index e0340b7..b77d1d2 100644 --- a/rest_auth/app_settings.py +++ b/rest_auth/app_settings.py @@ -7,8 +7,10 @@ from rest_auth.serializers import ( PasswordResetSerializer as DefaultPasswordResetSerializer, PasswordResetConfirmSerializer as DefaultPasswordResetConfirmSerializer, PasswordChangeSerializer as DefaultPasswordChangeSerializer) -from .utils import import_callable +from .utils import import_callable, default_create_token +create_token = import_callable( + getattr(settings, 'REST_AUTH_TOKEN_CREATOR', default_create_token)) serializers = getattr(settings, 'REST_AUTH_SERIALIZERS', {}) 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/app_settings.py b/rest_auth/registration/app_settings.py new file mode 100644 index 0000000..227b45b --- /dev/null +++ b/rest_auth/registration/app_settings.py @@ -0,0 +1,11 @@ +from django.conf import settings + +from rest_auth.registration.serializers import ( + RegisterSerializer as DefaultRegisterSerializer) +from ..utils import import_callable + + +serializers = getattr(settings, 'REST_AUTH_REGISTER_SERIALIZERS', {}) + +RegisterSerializer = import_callable( + serializers.get('REGISTER_SERIALIZER', DefaultRegisterSerializer)) diff --git a/rest_auth/registration/serializers.py b/rest_auth/registration/serializers.py index f5c444e..2390b05 100644 --- a/rest_auth/registration/serializers.py +++ b/rest_auth/registration/serializers.py @@ -1,17 +1,31 @@ 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 + from allauth.utils import (email_address_exists, + get_username_max_length) + from allauth.account.adapter import get_adapter + from allauth.account.utils import setup_user_email +except ImportError: + raise ImportError('allauth needs to be added to INSTALLED_APPS.') + 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 + +if 'allauth.socialaccount' in settings.INSTALLED_APPS: + try: + from allauth.socialaccount.helpers import complete_social_login + except ImportError: + pass class SocialLoginSerializer(serializers.Serializer): - access_token = serializers.CharField(required=False) - code = serializers.CharField(required=False) + access_token = serializers.CharField(required=False, allow_blank=True) + code = serializers.CharField(required=False, allow_blank=True) def _get_request(self): request = self.context.get('request') @@ -39,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) @@ -63,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') @@ -87,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 @@ -96,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() @@ -104,3 +118,57 @@ class SocialLoginSerializer(serializers.Serializer): attrs['user'] = login.account.user return attrs + + +class RegisterSerializer(serializers.Serializer): + username = serializers.CharField( + max_length=get_username_max_length(), + min_length=allauth_settings.USERNAME_MIN_LENGTH, + required=allauth_settings.USERNAME_REQUIRED + ) + email = serializers.EmailField(required=allauth_settings.EMAIL_REQUIRED) + password1 = serializers.CharField(required=True, write_only=True) + password2 = serializers.CharField(required=True, write_only=True) + + def validate_username(self, username): + username = get_adapter().clean_username(username) + return username + + def validate_email(self, email): + email = get_adapter().clean_email(email) + 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.")) + return email + + def validate_password1(self, password): + return get_adapter().clean_password(password) + + def validate(self, data): + if data['password1'] != data['password2']: + raise serializers.ValidationError(_("The two password fields didn't match.")) + return data + + def custom_signup(self, request, user): + pass + + def get_cleaned_data(self): + return { + 'username': self.validated_data.get('username', ''), + 'password1': self.validated_data.get('password1', ''), + 'email': self.validated_data.get('email', '') + } + + def save(self, request): + adapter = get_adapter() + user = adapter.new_user(request) + self.cleaned_data = self.get_cleaned_data() + adapter.save_user(request, user, self) + self.custom_signup(request, user) + setup_user_email(request, user, []) + return user + + +class VerifyEmailSerializer(serializers.Serializer): + key = serializers.CharField() diff --git a/rest_auth/registration/urls.py b/rest_auth/registration/urls.py index abdd8b5..9e56c3b 100644 --- a/rest_auth/registration/urls.py +++ b/rest_auth/registration/urls.py @@ -1,10 +1,9 @@ from django.views.generic import TemplateView -from django.conf.urls import patterns, url +from django.conf.urls import url from .views import RegisterView, VerifyEmailView -urlpatterns = patterns( - '', +urlpatterns = [ url(r'^$', RegisterView.as_view(), name='rest_register'), url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'), @@ -21,4 +20,4 @@ urlpatterns = patterns( # djang-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'), -) +] diff --git a/rest_auth/registration/views.py b/rest_auth/registration/views.py index 1895267..1f9da3c 100644 --- a/rest_auth/registration/views.py +++ b/rest_auth/registration/views.py @@ -1,72 +1,52 @@ -from django.http import HttpRequest +from django.utils.translation import ugettext_lazy as _ + 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 SignupView, ConfirmEmailView +from allauth.account.views import ConfirmEmailView from allauth.account.utils import complete_signup -from allauth.account import app_settings +from allauth.account import app_settings as allauth_settings -from rest_auth.app_settings import TokenSerializer -from rest_auth.registration.serializers import SocialLoginSerializer +from rest_auth.app_settings import (TokenSerializer, + create_token) +from rest_auth.registration.serializers import (SocialLoginSerializer, + VerifyEmailSerializer) from rest_auth.views import LoginView +from rest_auth.models import TokenModel +from .app_settings import RegisterSerializer -class RegisterView(APIView, SignupView): - """ - Accepts the credentials and creates a new user - if user does not exist already - Return the REST Token if the credentials are valid and authenticated. - Calls allauth complete_signup method +class RegisterView(CreateAPIView): + serializer_class = RegisterSerializer + permission_classes = (AllowAny, ) + token_model = TokenModel - Accept the following POST parameters: username, email, password - Return the REST Framework Token Object's key. - """ + def get_response_data(self, user): + if allauth_settings.EMAIL_VERIFICATION == \ + allauth_settings.EmailVerificationMethod.MANDATORY: + return {} - permission_classes = (AllowAny,) - allowed_methods = ('POST', 'OPTIONS', 'HEAD') - token_model = Token - serializer_class = TokenSerializer + return TokenSerializer(user.auth_token).data - def get(self, *args, **kwargs): - return Response({}, status=status.HTTP_405_METHOD_NOT_ALLOWED) + 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) - def put(self, *args, **kwargs): - return Response({}, status=status.HTTP_405_METHOD_NOT_ALLOWED) + return Response(self.get_response_data(user), status=status.HTTP_201_CREATED, headers=headers) - def form_valid(self, form): - self.user = form.save(self.request) - self.token, created = self.token_model.objects.get_or_create( - user=self.user - ) - if isinstance(self.request, HttpRequest): - request = self.request - else: - request = self.request._request - return complete_signup(request, self.user, - app_settings.EMAIL_VERIFICATION, - self.get_success_url()) - - def post(self, request, *args, **kwargs): - self.initial = {} - self.request.POST = self.request.data.copy() - form_class = self.get_form_class() - self.form = self.get_form(form_class) - if self.form.is_valid(): - self.form_valid(self.form) - return self.get_response() - else: - return self.get_response_with_errors() - - def get_response(self): - # serializer = self.user_serializer_class(instance=self.user) - serializer = self.serializer_class(instance=self.token) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def get_response_with_errors(self): - return Response(self.form.errors, status=status.HTTP_400_BAD_REQUEST) + def perform_create(self, serializer): + user = serializer.save(self.request) + create_token(self.token_model, user, serializer) + complete_signup(self.request._request, user, + allauth_settings.EMAIL_VERIFICATION, + None) + return user class VerifyEmailView(APIView, ConfirmEmailView): @@ -75,13 +55,15 @@ class VerifyEmailView(APIView, ConfirmEmailView): allowed_methods = ('POST', 'OPTIONS', 'HEAD') def get(self, *args, **kwargs): - return Response({}, status=status.HTTP_405_METHOD_NOT_ALLOWED) + raise MethodNotAllowed('GET') def post(self, request, *args, **kwargs): - self.kwargs['key'] = self.request.data.get('key', '') + serializer = VerifyEmailSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + 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 9cfb6b9..86f9007 100644 --- a/rest_auth/serializers.py +++ b/rest_auth/serializers.py @@ -1,61 +1,92 @@ from django.contrib.auth import get_user_model, authenticate from django.conf import settings from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm -try: - from django.utils.http import urlsafe_base64_decode as uid_decoder -except: - # make compatible with django 1.5 - from django.utils.http import base36_to_int as uid_decoder from django.contrib.auth.tokens import default_token_generator +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 +UserModel = get_user_model() + class LoginSerializer(serializers.Serializer): - username = serializers.CharField(required=False) - email = serializers.EmailField(required=False) + username = serializers.CharField(required=False, allow_blank=True) + email = serializers.EmailField(required=False, allow_blank=True) password = serializers.CharField(style={'input_type': 'password'}) + def _validate_email(self, email, password): + user = None + + if email and password: + user = authenticate(email=email, password=password) + else: + msg = _('Must include "email" and "password".') + raise exceptions.ValidationError(msg) + + return user + + def _validate_username(self, username, password): + user = None + + if username and password: + user = authenticate(username=username, password=password) + else: + msg = _('Must include "username" and "password".') + raise exceptions.ValidationError(msg) + + return user + + def _validate_username_email(self, username, email, password): + user = None + + if email and password: + user = authenticate(email=email, password=password) + elif username and password: + user = authenticate(username=username, password=password) + else: + msg = _('Must include either "username" or "email" and "password".') + raise exceptions.ValidationError(msg) + + return user + def validate(self, attrs): username = attrs.get('username') email = attrs.get('email') password = attrs.get('password') + user = None + if 'allauth' in settings.INSTALLED_APPS: from allauth.account import app_settings + # Authentication through email if app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL: - if email and password: - user = authenticate(email=email, password=password) - else: - msg = _('Must include "email" and "password".') - raise exceptions.ValidationError(msg) + user = self._validate_email(email, password) + # Authentication through username - elif app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME: - if username and password: - user = authenticate(username=username, password=password) - else: - msg = _('Must include "username" and "password".') - raise exceptions.ValidationError(msg) + if app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME: + user = self._validate_username(username, password) + # Authentication through either username or email else: - if email and password: - user = authenticate(email=email, password=password) - elif username and password: - user = authenticate(username=username, password=password) - else: - msg = _('Must include either "username" or "email" and "password".') - raise exceptions.ValidationError(msg) - - elif username and password: - user = authenticate(username=username, password=password) + user = self._validate_username_email(username, email, password) else: - msg = _('Must include "username" and "password".') - raise exceptions.ValidationError(msg) + # Authentication without using allauth + if email: + try: + username = UserModel.objects.get(email__iexact=email).get_username() + except UserModel.DoesNotExist: + pass + + if username: + user = self._validate_username_email(username, '', password) # Did we get back an active user? if user: @@ -72,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 @@ -84,7 +115,7 @@ class TokenSerializer(serializers.ModelSerializer): """ class Meta: - model = Token + model = TokenModel fields = ('key',) @@ -94,7 +125,7 @@ class UserDetailsSerializer(serializers.ModelSerializer): User model w/o password """ class Meta: - model = get_user_model() + model = UserModel fields = ('username', 'email', 'first_name', 'last_name') read_only_fields = ('email', ) @@ -109,11 +140,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') + raise serializers.ValidationError(_('Error')) + return value def save(self): @@ -124,6 +161,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) @@ -145,11 +184,10 @@ class PasswordResetConfirmSerializer(serializers.Serializer): def validate(self, attrs): self._errors = {} - # Get the UserModel - UserModel = get_user_model() + # Decode the uidb64 to uid to get User object try: - uid = uid_decoder(attrs['uid']) + uid = force_text(uid_decoder(attrs['uid'])) self.user = UserModel._default_manager.get(pk=uid) except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist): raise ValidationError({'uid': ['Invalid value']}) @@ -182,6 +220,9 @@ class PasswordChangeSerializer(serializers.Serializer): self.old_password_field_enabled = getattr( settings, 'OLD_PASSWORD_FIELD_ENABLED', False ) + self.logout_on_password_change = getattr( + settings, 'LOGOUT_ON_PASSWORD_CHANGE', False + ) super(PasswordChangeSerializer, self).__init__(*args, **kwargs) if not self.old_password_field_enabled: @@ -212,3 +253,6 @@ class PasswordChangeSerializer(serializers.Serializer): def save(self): self.set_password_form.save() + if not self.logout_on_password_change: + from django.contrib.auth import update_session_auth_hash + update_session_auth_hash(self.request, self.user) diff --git a/rest_auth/tests/__init__.py b/rest_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rest_auth/django_test_urls.py b/rest_auth/tests/django_urls.py similarity index 100% rename from rest_auth/django_test_urls.py rename to rest_auth/tests/django_urls.py diff --git a/test_requirements.pip b/rest_auth/tests/requirements.pip similarity index 100% rename from test_requirements.pip rename to rest_auth/tests/requirements.pip diff --git a/test_settings.py b/rest_auth/tests/settings.py similarity index 86% rename from test_settings.py rename to rest_auth/tests/settings.py index 2fcc1e3..b09b496 100644 --- a/test_settings.py +++ b/rest_auth/tests/settings.py @@ -1,8 +1,8 @@ -import django import os import sys PROJECT_ROOT = os.path.abspath(os.path.split(os.path.split(__file__)[0])[0]) + ROOT_URLCONF = 'urls' STATIC_URL = '/static/' STATIC_ROOT = '%s/staticserve' % PROJECT_ROOT @@ -18,15 +18,12 @@ IS_STAGING = False IS_PROD = False IS_TEST = 'test' in sys.argv or 'test_coverage' in sys.argv -if django.VERSION[:2] >= (1, 3): - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', } -else: - DATABASE_ENGINE = 'sqlite3' +} MIDDLEWARE_CLASSES = [ 'django.middleware.common.CommonMiddleware', @@ -73,7 +70,3 @@ INSTALLED_APPS = [ SECRET_KEY = "38dh*skf8sjfhs287dh&^hd8&3hdg*j2&sd" ACCOUNT_ACTIVATION_DAYS = 1 SITE_ID = 1 - -MIGRATION_MODULES = { - 'authtoken': 'authtoken.migrations', -} diff --git a/rest_auth/tests.py b/rest_auth/tests/test_api.py similarity index 53% rename from rest_auth/tests.py rename to rest_auth/tests/test_api.py index b137313..f6cc839 100644 --- a/rest_auth/tests.py +++ b/rest_auth/tests/test_api.py @@ -1,122 +1,14 @@ -import json - -from django.conf import settings from django.core.urlresolvers import reverse -from django.test.client import Client, MULTIPART_CONTENT from django.test import TestCase from django.contrib.auth import get_user_model from django.core import mail +from django.conf import settings from django.test.utils import override_settings -from django.contrib.sites.models import Site - -from allauth.socialaccount.models import SocialApp -from allauth.socialaccount.providers.facebook.provider import GRAPH_API_URL -import responses +from django.utils.encoding import force_text from rest_framework import status - -class APIClient(Client): - - def patch(self, path, data='', content_type=MULTIPART_CONTENT, follow=False, **extra): - return self.generic('PATCH', path, data, content_type, **extra) - - def options(self, path, data='', content_type=MULTIPART_CONTENT, follow=False, **extra): - return self.generic('OPTIONS', path, data, content_type, **extra) - - -class BaseAPITestCase(object): - - """ - base for API tests: - * easy request calls, f.e.: self.post(url, data), self.get(url) - * easy status check, f.e.: self.post(url, data, status_code=200) - """ - def send_request(self, request_method, *args, **kwargs): - request_func = getattr(self.client, request_method) - status_code = None - if 'content_type' not in kwargs and request_method != 'get': - kwargs['content_type'] = 'application/json' - if 'data' in kwargs and request_method != 'get' and kwargs['content_type'] == 'application/json': - data = kwargs.get('data', '') - kwargs['data'] = json.dumps(data) # , cls=CustomJSONEncoder - if 'status_code' in kwargs: - status_code = kwargs.pop('status_code') - - # check_headers = kwargs.pop('check_headers', True) - if hasattr(self, 'token'): - kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % self.token - - self.response = request_func(*args, **kwargs) - is_json = bool( - filter(lambda x: 'json' in x, self.response._headers['content-type'])) - if is_json and self.response.content: - self.response.json = json.loads(self.response.content) - else: - self.response.json = {} - if status_code: - self.assertEqual(self.response.status_code, status_code) - return self.response - - def post(self, *args, **kwargs): - return self.send_request('post', *args, **kwargs) - - def get(self, *args, **kwargs): - return self.send_request('get', *args, **kwargs) - - def patch(self, *args, **kwargs): - return self.send_request('patch', *args, **kwargs) - - # def put(self, *args, **kwargs): - # return self.send_request('put', *args, **kwargs) - - # def delete(self, *args, **kwargs): - # return self.send_request('delete', *args, **kwargs) - - # def options(self, *args, **kwargs): - # return self.send_request('options', *args, **kwargs) - - # def post_file(self, *args, **kwargs): - # kwargs['content_type'] = MULTIPART_CONTENT - # return self.send_request('post', *args, **kwargs) - - # def get_file(self, *args, **kwargs): - # content_type = None - # if 'content_type' in kwargs: - # content_type = kwargs.pop('content_type') - # response = self.send_request('get', *args, **kwargs) - # if content_type: - # self.assertEqual( - # bool(filter(lambda x: content_type in x, response._headers['content-type'])), True) - # return response - - def init(self): - settings.DEBUG = True - self.client = APIClient() - - self.login_url = reverse('rest_login') - self.logout_url = reverse('rest_logout') - self.password_change_url = reverse('rest_password_change') - self.register_url = reverse('rest_register') - self.password_reset_url = reverse('rest_password_reset') - self.user_url = reverse('rest_user_details') - self.veirfy_email_url = reverse('rest_verify_email') - self.fb_login_url = reverse('fb_login') - - def _login(self): - payload = { - "username": self.USERNAME, - "password": self.PASS - } - self.post(self.login_url, data=payload, status_code=status.HTTP_200_OK) - - def _logout(self): - self.post(self.logout_url, status=status.HTTP_200_OK) - - -# ----------------------- -# T E S T H E R E -# ----------------------- +from .test_base import BaseAPITestCase class APITestCase1(TestCase, BaseAPITestCase): @@ -126,7 +18,7 @@ class APITestCase1(TestCase, BaseAPITestCase): - custom registration: backend defined """ - urls = 'rest_auth.test_urls' + urls = 'tests.urls' USERNAME = 'person' PASS = 'person' @@ -159,13 +51,9 @@ class APITestCase1(TestCase, BaseAPITestCase): result = {} from django.utils.encoding import force_bytes from django.contrib.auth.tokens import default_token_generator - from django import VERSION - if VERSION[1] == 5: - from django.utils.http import int_to_base36 - result['uid'] = int_to_base36(user.pk) - else: - from django.utils.http import urlsafe_base64_encode - result['uid'] = urlsafe_base64_encode(force_bytes(user.pk)) + from django.utils.http import urlsafe_base64_encode + + result['uid'] = urlsafe_base64_encode(force_bytes(user.pk)) result['token'] = default_token_generator.make_token(user) return result @@ -203,6 +91,51 @@ class APITestCase1(TestCase, BaseAPITestCase): # test empty payload self.post(self.login_url, data={}, status_code=400) + def test_login_by_email(self): + # starting test without allauth app + settings.INSTALLED_APPS.remove('allauth') + + payload = { + "email": self.EMAIL.lower(), + "password": self.PASS + } + # there is no users in db so it should throw error (400) + self.post(self.login_url, data=payload, status_code=400) + + self.post(self.password_change_url, status_code=403) + + # create user + user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) + + # test auth by email + self.post(self.login_url, data=payload, status_code=200) + self.assertEqual('key' in self.response.json.keys(), True) + self.token = self.response.json['key'] + + # test auth by email in different case + payload = { + "email": self.EMAIL.upper(), + "password": self.PASS + } + self.post(self.login_url, data=payload, status_code=200) + self.assertEqual('key' in self.response.json.keys(), True) + self.token = self.response.json['key'] + + # test inactive user + user.is_active = False + user.save() + self.post(self.login_url, data=payload, status_code=400) + + # test wrong email/password + payload = { + "email": 't' + self.EMAIL, + "password": self.PASS + } + self.post(self.login_url, data=payload, status_code=400) + + # test empty payload + self.post(self.login_url, data={}, status_code=400) + def test_password_change(self): login_payload = { "username": self.USERNAME, @@ -298,7 +231,7 @@ class APITestCase1(TestCase, BaseAPITestCase): data = { 'new_password1': self.NEW_PASS, 'new_password2': self.NEW_PASS, - 'uid': url_kwargs['uid'], + 'uid': force_text(url_kwargs['uid']), 'token': '-wrong-token-' } self.post(url, data=data, status_code=400) @@ -325,7 +258,7 @@ class APITestCase1(TestCase, BaseAPITestCase): data = { 'new_password1': self.NEW_PASS, 'new_password2': self.NEW_PASS, - 'uid': url_kwargs['uid'], + 'uid': force_text(url_kwargs['uid']), 'token': url_kwargs['token'] } url = reverse('rest_password_reset_confirm') @@ -337,6 +270,27 @@ class APITestCase1(TestCase, BaseAPITestCase): } self.post(self.login_url, data=payload, status_code=200) + def test_password_reset_with_email_in_different_case(self): + get_user_model().objects.create_user(self.USERNAME, self.EMAIL.lower(), self.PASS) + + # call password reset in upper case + mail_count = len(mail.outbox) + payload = {'email': self.EMAIL.upper()} + self.post(self.password_reset_url, data=payload, status_code=200) + 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=200) + self.assertEqual(len(mail.outbox), mail_count) + def test_user_details(self): user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) payload = { @@ -359,14 +313,22 @@ class APITestCase1(TestCase, BaseAPITestCase): # 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() + def test_registration_with_invalid_password(self): + data = self.REGISTRATION_DATA.copy() + data['password2'] = 'foobar' + + self.post(self.register_url, data=data, status_code=400) + @override_settings( ACCOUNT_EMAIL_VERIFICATION='mandatory', ACCOUNT_EMAIL_REQUIRED=True @@ -382,11 +344,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') @@ -415,119 +378,3 @@ class APITestCase1(TestCase, BaseAPITestCase): # try to login again self._login() self._logout() - - -class TestSocialAuth(TestCase, BaseAPITestCase): - - urls = 'rest_auth.test_urls' - - USERNAME = 'person' - PASS = 'person' - EMAIL = "person1@world.com" - REGISTRATION_DATA = { - "username": USERNAME, - "password1": PASS, - "password2": PASS, - "email": EMAIL - } - - def setUp(self): - self.init() - - social_app = SocialApp.objects.create( - provider='facebook', - name='Facebook', - client_id='123123123', - secret='321321321', - ) - site = Site.objects.get_current() - social_app.sites.add(site) - self.graph_api_url = GRAPH_API_URL + '/me' - - @responses.activate - def test_failed_social_auth(self): - # fake response - responses.add( - responses.GET, - self.graph_api_url, - body='', - status=400, - content_type='application/json' - ) - - payload = { - 'access_token': 'abc123' - } - self.post(self.fb_login_url, data=payload, status_code=400) - - @responses.activate - def test_social_auth(self): - # fake response for facebook call - 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('key', self.response.json.keys()) - self.assertEqual(get_user_model().objects.all().count(), users_count + 1) - - # make sure that second request will not create a new user - self.post(self.fb_login_url, data=payload, status_code=200) - self.assertIn('key', self.response.json.keys()) - self.assertEqual(get_user_model().objects.all().count(), users_count + 1) - - @responses.activate - @override_settings( - ACCOUNT_EMAIL_VERIFICATION='mandatory', - ACCOUNT_EMAIL_REQUIRED=True, - REST_SESSION_LOGIN=False - ) - def test_edge_case(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,"email":"%s"}' # noqa - responses.add( - responses.GET, - self.graph_api_url, - body=resp_body % self.EMAIL, - status=200, - content_type='application/json' - ) - - # test empty payload - self.post(self.register_url, data={}, status_code=400) - - self.post( - self.register_url, - data=self.REGISTRATION_DATA, - status_code=201 - ) - new_user = get_user_model().objects.latest('id') - self.assertEqual(new_user.username, self.REGISTRATION_DATA['username']) - - # verify email - email_confirmation = new_user.emailaddress_set.get(email=self.EMAIL)\ - .emailconfirmation_set.order_by('-created')[0] - self.post( - self.veirfy_email_url, - data={"key": email_confirmation.key}, - status_code=status.HTTP_200_OK - ) - - self._login() - self._logout() - - payload = { - 'access_token': 'abc123' - } - - self.post(self.fb_login_url, data=payload, status_code=200) - self.assertIn('key', self.response.json.keys()) diff --git a/rest_auth/tests/test_base.py b/rest_auth/tests/test_base.py new file mode 100644 index 0000000..ed8ffeb --- /dev/null +++ b/rest_auth/tests/test_base.py @@ -0,0 +1,106 @@ +import json + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.client import Client, MULTIPART_CONTENT +from django.utils.encoding import force_text + +from rest_framework import status + + +class APIClient(Client): + + def patch(self, path, data='', content_type=MULTIPART_CONTENT, follow=False, **extra): + return self.generic('PATCH', path, data, content_type, **extra) + + def options(self, path, data='', content_type=MULTIPART_CONTENT, follow=False, **extra): + return self.generic('OPTIONS', path, data, content_type, **extra) + + +class BaseAPITestCase(object): + + """ + base for API tests: + * easy request calls, f.e.: self.post(url, data), self.get(url) + * easy status check, f.e.: self.post(url, data, status_code=200) + """ + def send_request(self, request_method, *args, **kwargs): + request_func = getattr(self.client, request_method) + status_code = None + if 'content_type' not in kwargs and request_method != 'get': + kwargs['content_type'] = 'application/json' + if 'data' in kwargs and request_method != 'get' and kwargs['content_type'] == 'application/json': + data = kwargs.get('data', '') + kwargs['data'] = json.dumps(data) # , cls=CustomJSONEncoder + if 'status_code' in kwargs: + status_code = kwargs.pop('status_code') + + # check_headers = kwargs.pop('check_headers', True) + if hasattr(self, 'token'): + kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % self.token + + self.response = request_func(*args, **kwargs) + is_json = bool( + [x for x in self.response._headers['content-type'] if 'json' in x]) + if is_json and self.response.content: + self.response.json = json.loads(force_text(self.response.content)) + else: + self.response.json = {} + if status_code: + self.assertEqual(self.response.status_code, status_code) + return self.response + + def post(self, *args, **kwargs): + return self.send_request('post', *args, **kwargs) + + def get(self, *args, **kwargs): + return self.send_request('get', *args, **kwargs) + + def patch(self, *args, **kwargs): + return self.send_request('patch', *args, **kwargs) + + # def put(self, *args, **kwargs): + # return self.send_request('put', *args, **kwargs) + + # def delete(self, *args, **kwargs): + # return self.send_request('delete', *args, **kwargs) + + # def options(self, *args, **kwargs): + # return self.send_request('options', *args, **kwargs) + + # def post_file(self, *args, **kwargs): + # kwargs['content_type'] = MULTIPART_CONTENT + # return self.send_request('post', *args, **kwargs) + + # def get_file(self, *args, **kwargs): + # content_type = None + # if 'content_type' in kwargs: + # content_type = kwargs.pop('content_type') + # response = self.send_request('get', *args, **kwargs) + # if content_type: + # self.assertEqual( + # bool(filter(lambda x: content_type in x, response._headers['content-type'])), True) + # return response + + def init(self): + settings.DEBUG = True + self.client = APIClient() + + self.login_url = reverse('rest_login') + self.logout_url = reverse('rest_logout') + self.password_change_url = reverse('rest_password_change') + self.register_url = reverse('rest_register') + self.password_reset_url = reverse('rest_password_reset') + self.user_url = reverse('rest_user_details') + self.veirfy_email_url = reverse('rest_verify_email') + self.fb_login_url = reverse('fb_login') + + def _login(self): + payload = { + "username": self.USERNAME, + "password": self.PASS + } + self.post(self.login_url, data=payload, status_code=status.HTTP_200_OK) + + def _logout(self): + self.post(self.logout_url, status=status.HTTP_200_OK) diff --git a/rest_auth/tests/test_social.py b/rest_auth/tests/test_social.py new file mode 100644 index 0000000..19509ef --- /dev/null +++ b/rest_auth/tests/test_social.py @@ -0,0 +1,127 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.test.utils import override_settings +from django.contrib.sites.models import Site + +from allauth.socialaccount.models import SocialApp +from allauth.socialaccount.providers.facebook.provider import GRAPH_API_URL +import responses + +from rest_framework import status + +from .test_base import BaseAPITestCase + + +class TestSocialAuth(TestCase, BaseAPITestCase): + + urls = 'tests.urls' + + USERNAME = 'person' + PASS = 'person' + EMAIL = "person1@world.com" + REGISTRATION_DATA = { + "username": USERNAME, + "password1": PASS, + "password2": PASS, + "email": EMAIL + } + + def setUp(self): + self.init() + + social_app = SocialApp.objects.create( + provider='facebook', + name='Facebook', + client_id='123123123', + secret='321321321', + ) + site = Site.objects.get_current() + social_app.sites.add(site) + self.graph_api_url = GRAPH_API_URL + '/me' + + @responses.activate + def test_failed_social_auth(self): + # fake response + responses.add( + responses.GET, + self.graph_api_url, + body='', + status=400, + content_type='application/json' + ) + + payload = { + 'access_token': 'abc123' + } + self.post(self.fb_login_url, data=payload, status_code=400) + + @responses.activate + def test_social_auth(self): + # fake response for facebook call + 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('key', self.response.json.keys()) + self.assertEqual(get_user_model().objects.all().count(), users_count + 1) + + # make sure that second request will not create a new user + self.post(self.fb_login_url, data=payload, status_code=200) + self.assertIn('key', self.response.json.keys()) + self.assertEqual(get_user_model().objects.all().count(), users_count + 1) + + @responses.activate + @override_settings( + ACCOUNT_EMAIL_VERIFICATION='mandatory', + ACCOUNT_EMAIL_REQUIRED=True, + REST_SESSION_LOGIN=False + ) + def test_edge_case(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,"email":"%s"}' # noqa + responses.add( + responses.GET, + self.graph_api_url, + body=resp_body % self.EMAIL, + status=200, + content_type='application/json' + ) + + # test empty payload + self.post(self.register_url, data={}, status_code=400) + self.post( + self.register_url, + data=self.REGISTRATION_DATA, + status_code=201 + ) + new_user = get_user_model().objects.latest('id') + self.assertEqual(new_user.username, self.REGISTRATION_DATA['username']) + + # verify email + email_confirmation = new_user.emailaddress_set.get(email=self.EMAIL)\ + .emailconfirmation_set.order_by('-created')[0] + self.post( + self.veirfy_email_url, + data={"key": email_confirmation.key}, + status_code=status.HTTP_200_OK + ) + + self._login() + self._logout() + + payload = { + 'access_token': 'abc123' + } + + self.post(self.fb_login_url, data=payload, status_code=200) + self.assertIn('key', self.response.json.keys()) diff --git a/rest_auth/test_urls.py b/rest_auth/tests/urls.py similarity index 80% rename from rest_auth/test_urls.py rename to rest_auth/tests/urls.py index ae5ef1d..d922f7f 100644 --- a/rest_auth/test_urls.py +++ b/rest_auth/tests/urls.py @@ -1,6 +1,6 @@ -from django.conf.urls import patterns, url, include +from django.conf.urls import url, include from django.views.generic import TemplateView -import rest_auth.django_test_urls +from . import django_urls from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter @@ -11,14 +11,13 @@ from rest_auth.registration.views import SocialLoginView class FacebookLogin(SocialLoginView): adapter_class = FacebookOAuth2Adapter -urlpatterns += patterns( - '', +urlpatterns += [ url(r'^rest-registration/', include('rest_auth.registration.urls')), - url(r'^test-admin/', include(rest_auth.django_test_urls)), + url(r'^test-admin/', include(django_urls)), url(r'^account-email-verification-sent/$', TemplateView.as_view(), name='account_email_verification_sent'), url(r'^account-confirm-email/(?P\w+)/$', TemplateView.as_view(), name='account_confirm_email'), url(r'^social-login/facebook/$', FacebookLogin.as_view(), name='fb_login'), url(r'^accounts/', include('allauth.socialaccount.urls')) -) +] diff --git a/rest_auth/urls.py b/rest_auth/urls.py index d753c44..7a35e9b 100644 --- a/rest_auth/urls.py +++ b/rest_auth/urls.py @@ -1,12 +1,11 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from rest_auth.views import ( LoginView, LogoutView, UserDetailsView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView ) -urlpatterns = patterns( - '', +urlpatterns = [ # URLs that do not require a session or valid token url(r'^password/reset/$', PasswordResetView.as_view(), name='rest_password_reset'), @@ -18,4 +17,4 @@ urlpatterns = patterns( url(r'^user/$', UserDetailsView.as_view(), name='rest_user_details'), url(r'^password/change/$', PasswordChangeView.as_view(), name='rest_password_change'), -) +] diff --git a/rest_auth/utils.py b/rest_auth/utils.py index 7dc4a42..e224f26 100644 --- a/rest_auth/utils.py +++ b/rest_auth/utils.py @@ -1,9 +1,5 @@ from six import string_types -import sys -if sys.version_info < (2, 7): - from django.utils.importlib import import_module -else: - from importlib import import_module +from importlib import import_module def import_callable(path_or_callable): @@ -13,3 +9,8 @@ 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 diff --git a/rest_auth/views.py b/rest_auth/views.py index d789ac4..b369456 100644 --- a/rest_auth/views.py +++ b/rest_auth/views.py @@ -1,19 +1,21 @@ 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 .app_settings import ( TokenSerializer, UserDetailsSerializer, LoginSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, - PasswordChangeSerializer + PasswordChangeSerializer, create_token ) +from .models import TokenModel class LoginView(GenericAPIView): @@ -29,13 +31,12 @@ class LoginView(GenericAPIView): """ permission_classes = (AllowAny,) serializer_class = LoginSerializer - token_model = Token + token_model = TokenModel response_serializer = TokenSerializer def login(self): self.user = self.serializer.validated_data['user'] - self.token, created = self.token_model.objects.get_or_create( - user=self.user) + self.token = create_token(self.token_model, self.user, self.serializer) if getattr(settings, 'REST_SESSION_LOGIN', True): login(self.request, self.user) @@ -44,15 +45,9 @@ class LoginView(GenericAPIView): self.response_serializer(self.token).data, status=status.HTTP_200_OK ) - def get_error_response(self): - return Response( - self.serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - def post(self, request, *args, **kwargs): self.serializer = self.get_serializer(data=self.request.data) - if not self.serializer.is_valid(): - return self.get_error_response() + self.serializer.is_valid(raise_exception=True) self.login() return self.get_response() @@ -70,17 +65,16 @@ class LogoutView(APIView): def post(self, request): try: request.user.auth_token.delete() - except: + except (AttributeError, ObjectDoesNotExist): pass 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,20 +106,17 @@ class PasswordResetView(GenericAPIView): def post(self, request, *args, **kwargs): # Create a serializer with request.data serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) - if not serializer.is_valid(): - return Response(serializer.errors, - status=status.HTTP_400_BAD_REQUEST) 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. @@ -139,16 +130,12 @@ class PasswordResetConfirmView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + 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. @@ -161,9 +148,6 @@ class PasswordChangeView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + serializer.is_valid(raise_exception=True) serializer.save() - return Response({"success": "New password has been saved."}) + return Response({"success": _("New password has been saved.")}) diff --git a/runtests.py b/runtests.py index a59b2bb..8b7ede2 100644 --- a/runtests.py +++ b/runtests.py @@ -3,8 +3,8 @@ import os import sys -os.environ['DJANGO_SETTINGS_MODULE'] = 'test_settings' -test_dir = os.path.dirname(__file__) +os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' +test_dir = os.path.join(os.path.dirname(__file__), 'rest_auth') sys.path.insert(0, test_dir) import django diff --git a/setup.py b/setup.py index e3400d6..d85b2d2 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ f.close() setup( name='django-rest-auth', - version='0.4.0', + version='0.6.0', author='Sumit Chachra', author_email='chachra@tivix.com', url='http://github.com/Tivix/django-rest-auth', @@ -28,10 +28,17 @@ setup( keywords='django rest auth registration rest-framework django-registration api', zip_safe=False, install_requires=[ - 'Django>=1.5.0', - 'djangorestframework>=3.0', + 'Django>=1.7.0', + 'djangorestframework>=3.1.0', 'six>=1.9.0', ], + extras_require={ + 'with_social': ['django-allauth>=0.24.1'], + }, + tests_require=[ + 'responses>=0.5.0', + 'django-allauth>=0.24.1', + ], test_suite='runtests.runtests', include_package_data=True, # cmdclass={},