diff --git a/.gitignore b/.gitignore index 525a299..03b4ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,14 @@ -*.pyc -__pycache__ +__pycache__/ /demo/db.sqlite3 /demo/.env # Test artifacts /.pytest_cache/ /.coverage +/htmlcov/ /.tox/ /venv/ -# Packaging litter -/*.egg-info +# Packaging artifacts /build/ /dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e85905..0cf20f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Require Django 2.2 (LTS release) or 3.1 and Python 3.8 or 3.9 #115 +A number of improvements make it easier to integrate in your +project (#135): + +New class `TokenAuthentication` can be directly added to +django-rest-framework settings for API auth. + +The `BaseAPIToken` class provides a default `revoke` method +needed by the logout view. + +Email addresses can be normalized by serializers using the +new `CustomEmailField` class. Default behaviour does not +normalize before checking uniqueness or saving data. + +Login now sends `user_logged_in` signal. If you have +`django.contrib.auth` in `INSTALLED_APPS` and your user model +has a `last_login` field, it will be automatically updated. + ### Upgrade notes `BaseUserEmail.natural_key` now returns a 1-element tuple diff --git a/demo/demo/accounts/app.py b/demo/demo/accounts/app.py index 2ea0cdd..f3d47f0 100644 --- a/demo/demo/accounts/app.py +++ b/demo/demo/accounts/app.py @@ -1,6 +1,16 @@ from django.apps import AppConfig +from django.db import models + +from rest_framework.serializers import ModelSerializer class AccountsConfig(AppConfig): name = 'demo.accounts' label = 'accounts' + + def ready(self): + from rest_auth_toolkit.fields import CustomEmailField + + ModelSerializer.serializer_field_mapping.update({ + models.EmailField: CustomEmailField, + }) diff --git a/demo/demo/accounts/authentication.py b/demo/demo/accounts/authentication.py deleted file mode 100644 index 7b8ecdf..0000000 --- a/demo/demo/accounts/authentication.py +++ /dev/null @@ -1,8 +0,0 @@ -from rest_framework.authentication import TokenAuthentication - -from .models import APIToken - - -class APITokenAuthentication(TokenAuthentication): - keyword = 'Bearer' - model = APIToken diff --git a/demo/demo/accounts/models.py b/demo/demo/accounts/models.py index c5efef4..460029f 100644 --- a/demo/demo/accounts/models.py +++ b/demo/demo/accounts/models.py @@ -53,6 +53,3 @@ class EmailConfirmation(BaseEmailConfirmation, TimeStampedModel): class APIToken(BaseAPIToken, models.Model): created = AutoCreatedField(_('created')) - - def revoke(self): - self.delete() diff --git a/demo/demo/settings.py b/demo/demo/settings.py index a2f4b33..b1ceb25 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -102,13 +102,11 @@ STATIC_URL = '/static/' -# STATIC_ROOT = os.path.join(BASE_DIR, 'static') REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - # This is the real authn policy for API clients - 'demo.accounts.authentication.APITokenAuthentication', + 'rest_auth_toolkit.authentication.TokenAuthentication', ], 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', diff --git a/rest_auth_toolkit/authentication.py b/rest_auth_toolkit/authentication.py new file mode 100644 index 0000000..db3c02b --- /dev/null +++ b/rest_auth_toolkit/authentication.py @@ -0,0 +1,21 @@ +from rest_framework.authentication import TokenAuthentication + +from .utils import get_object_from_setting + + +Token = get_object_from_setting("api_token_class") + + +class TokenAuthentication(TokenAuthentication): + """DRF authentication backend for API requests. + + Parses headers like "Authorization: Bearer user-api-token". + + Override authenticate_credentials in a subclass if you need + special behaviour to get a Token object from a string value. + Default behaviour is to fetch a token via "key" field and + check that token.user.is_active is true. + """ + + keyword = "Bearer" + model = Token diff --git a/rest_auth_toolkit/fields.py b/rest_auth_toolkit/fields.py new file mode 100644 index 0000000..6ada77e --- /dev/null +++ b/rest_auth_toolkit/fields.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model + +from rest_framework.fields import EmailField, empty + + +User = get_user_model() + + +class CustomEmailField(EmailField): + """Subclass of EmailField that adds normalization.""" + + def run_validation(self, data=empty): + if data != empty: + data = User.objects.normalize_email(data) + return super().run_validation(data) diff --git a/rest_auth_toolkit/models.py b/rest_auth_toolkit/models.py index 35363a4..9a3f79c 100644 --- a/rest_auth_toolkit/models.py +++ b/rest_auth_toolkit/models.py @@ -46,7 +46,7 @@ def natural_key(self): return (self.email,) -class BaseEmailConfirmation(models.Model): # pragma: no cover +class BaseEmailConfirmation(models.Model): """Abstract model for email confirmations. Subclass in your project to customize to your needs and make @@ -90,6 +90,12 @@ class BaseAPIToken(models.Model): You can override generate_key in your concrete class to change the way the keys are created. You can also redefine the key field. + The default logout view calls the revoke method, which by default + deletes the row, and can be overriden to customize behaviour. + For example, you could implement soft deletes with a custom model + field (boolean or datetime) and a matching custom authentication + class that checks the custom field. + Adapted from rest_framework.authtoken. """ key = models.CharField(_('key'), max_length=40, primary_key=True) @@ -112,3 +118,6 @@ def save(self, *args, **kwargs): def generate_key(self): return binascii.hexlify(os.urandom(20)).decode() + + def revoke(self): + self.delete() diff --git a/rest_auth_toolkit/serializers.py b/rest_auth_toolkit/serializers.py index ad61067..dc7dfbe 100644 --- a/rest_auth_toolkit/serializers.py +++ b/rest_auth_toolkit/serializers.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import password_validation +from django.contrib.auth.signals import user_logged_in from django.core import exceptions from django.utils.translation import gettext as _ @@ -12,6 +13,8 @@ except ImportError: facepy = None +from .fields import CustomEmailField + User = get_user_model() @@ -23,13 +26,13 @@ class Meta: model = User fields = ('email', 'password') extra_kwargs = { - 'password': {'style': {'input_type': 'password'}}, + 'password': {'write_only': True, 'style': {'input_type': 'password'}}, } def validate(self, data): password = data['password'] - # Create user object without saving it to get extra checks by validators + # Instantiate user object without saving it to get extra checks by validators user = User(**data) errors = {} @@ -54,7 +57,7 @@ def create(self, validated_data): class LoginDeserializer(serializers.Serializer): """Deserializer to find a user from credentials.""" - email = serializers.EmailField() + email = CustomEmailField() password = serializers.CharField(style={'input_type': 'password'}) def validate(self, data): @@ -64,6 +67,9 @@ def validate(self, data): msg = _('Invalid email or password') raise ValidationError({'errors': [msg]}) + request = self.context.get("request") + user_logged_in.send(sender=user.__class__, request=request, user=user) + return {'user': user} @@ -89,4 +95,7 @@ def validate(self, data): user = User.objects.get_or_create_facebook_user(data, extended_token)[0] + request = self.context.get("request") + user_logged_in.send(sender=user.__class__, request=request, user=user) + return {'user': user} diff --git a/rest_auth_toolkit/views.py b/rest_auth_toolkit/views.py index e6d43a3..011b433 100644 --- a/rest_auth_toolkit/views.py +++ b/rest_auth_toolkit/views.py @@ -58,7 +58,7 @@ def post(self, request): email_field = user.get_email_field_name() send_email(request, user, getattr(user, email_field), confirmation) - return Response(status=status.HTTP_201_CREATED) + return Response(deserializer.data, status=status.HTTP_201_CREATED) class LoginView(generics.GenericAPIView): diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index c01f68f..f877a71 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -1,6 +1,6 @@ import pytest -from demo.accounts.models import User, APIToken +from demo.accounts.models import User, EmailConfirmation, APIToken @pytest.fixture @@ -27,6 +27,15 @@ def userfb0(db): ) +@pytest.fixture +def emailconfirmation0(user0): + user0.is_active = False + user0.save() + return EmailConfirmation.objects.create( + user=user0, + ) + + @pytest.fixture def token0(user0): return APIToken.objects.create_token( diff --git a/tests/functional/test_email.py b/tests/functional/test_email.py index 713a84f..b5fe5b3 100644 --- a/tests/functional/test_email.py +++ b/tests/functional/test_email.py @@ -1,12 +1,21 @@ +from datetime import datetime + from django.urls import reverse +import pytest + from demo.accounts.models import User, APIToken -def test_signup(db, django_app, mailoutbox): - params = {"email": "zack@example.com", "password": "correct battery horse staple"} - django_app.post_json(reverse("auth:signup"), params=params, status=201) +@pytest.mark.parametrize("email", [ + "zack@example.com", + "zack@EXAMPLE.COM", +]) +def test_signup(db, django_app, mailoutbox, email): + params = {"email": email, "password": "correct battery horse staple"} + resp = django_app.post_json(reverse("auth:signup"), params=params, status=201) + assert resp.json == {"email": "zack@example.com"} user = User.objects.last() assert user.email == "zack@example.com" assert user.has_usable_password() @@ -22,6 +31,13 @@ def test_signup_same_as_password(db, django_app): assert "password" in resp.json +def test_signup_missing_email(db, django_app): + params = {"password": "little bobby passwords"} + resp = django_app.post_json(reverse("auth:signup"), params=params, status=400) + + assert "email" in resp.json + + def test_signup_invalid_email(db, django_app): params = {"email": "bobby", "password": "bobby@example.com"} resp = django_app.post_json(reverse("auth:signup"), params=params, status=400) @@ -29,18 +45,50 @@ def test_signup_invalid_email(db, django_app): assert "email" in resp.json -def test_signup_already_exists(db, django_app, user0): - params = {"email": "bob@example.com", "password": "pass123"} +@pytest.mark.parametrize("email", [ + "bob@example.com", + "bob@example.COM", +]) +def test_signup_already_exists(db, django_app, user0, email): + params = {"email": email, "password": "pass123"} resp = django_app.post_json(reverse("auth:signup"), params=params, status=400) assert "email" in resp.json -def test_login(db, django_app, user0): - params = {"email": "bob@example.com", "password": "pass123"} +def test_signup_different_address(db, django_app, user0): + params = {"email": "ZACK@EXAMPLE.com", "password": "goodpass"} + resp = django_app.post_json(reverse("auth:signup"), params=params, status=201) + + assert resp.json == {"email": "ZACK@example.com"} + + +def test_confirm_email(db, django_app, user0, emailconfirmation0): + assert not user0.is_active + assert emailconfirmation0.confirmed is None + + django_app.get(reverse("app-auth:email-confirmation", + kwargs={"external_id": emailconfirmation0.external_id})) + + user0.refresh_from_db() + emailconfirmation0.refresh_from_db() + assert user0.is_active + assert isinstance(emailconfirmation0.confirmed, datetime) + + +@pytest.mark.parametrize("email", [ + "bob@example.com", + "bob@example.COM", +]) +def test_login(db, django_app, user0, email): + assert user0.last_login is None + + params = {"email": email, "password": "pass123"} resp = django_app.post_json(reverse("auth:login"), params=params, status=200) assert "token" in resp.json + user0.refresh_from_db() + assert isinstance(user0.last_login, datetime) def test_login_unknown_user(db, django_app): diff --git a/tests/functional/test_facebook.py b/tests/functional/test_facebook.py index 90476e6..77d6720 100644 --- a/tests/functional/test_facebook.py +++ b/tests/functional/test_facebook.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.urls import reverse from pretend import stub @@ -28,9 +30,12 @@ def test_facebook_login(django_app, monkeypatch, userfb0, token1): monkeypatch.setattr("facepy.GraphAPI.get", fake_graph_api_get) monkeypatch.setattr("facepy.SignedRequest", fake_signed_request) monkeypatch.setattr("facepy.get_extended_access_token", fake_get_access_token) + assert userfb0.last_login is None params = {"signed_request": "abcd_signed_request"} resp = django_app.post_json(reverse("auth:fb-login"), params=params, status=200) assert resp.json.keys() == {"token"} assert resp.json["token"] != token1.key + userfb0.refresh_from_db() + assert isinstance(userfb0.last_login, datetime) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 05dfaf9..6d0454b 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -93,6 +93,12 @@ def test_apitoken_custom_generate_key(monkeypatch, user0): assert token.key == '2345bcde' +def test_apitoken_revoke(token0): + token0.revoke() + + assert list(APIToken.objects.all()) == [] + + def test_apitoken_manager_create_token(user0): token = APIToken.objects.create_token(user=user0)