Skip to content

Commit

Permalink
merge quality of life changes (#141)
Browse files Browse the repository at this point in the history
  • Loading branch information
merwok authored Jan 13, 2021
2 parents 3191eae + 9f8da38 commit c34db7f
Show file tree
Hide file tree
Showing 15 changed files with 166 additions and 31 deletions.
7 changes: 3 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions demo/demo/accounts/app.py
Original file line number Diff line number Diff line change
@@ -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,
})
8 changes: 0 additions & 8 deletions demo/demo/accounts/authentication.py

This file was deleted.

3 changes: 0 additions & 3 deletions demo/demo/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,3 @@ class EmailConfirmation(BaseEmailConfirmation, TimeStampedModel):

class APIToken(BaseAPIToken, models.Model):
created = AutoCreatedField(_('created'))

def revoke(self):
self.delete()
4 changes: 1 addition & 3 deletions demo/demo/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions rest_auth_toolkit/authentication.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions rest_auth_toolkit/fields.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 10 additions & 1 deletion rest_auth_toolkit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
15 changes: 12 additions & 3 deletions rest_auth_toolkit/serializers.py
Original file line number Diff line number Diff line change
@@ -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 _

Expand All @@ -12,6 +13,8 @@
except ImportError:
facepy = None

from .fields import CustomEmailField


User = get_user_model()

Expand All @@ -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 = {}
Expand All @@ -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):
Expand All @@ -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}


Expand All @@ -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}
2 changes: 1 addition & 1 deletion rest_auth_toolkit/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 10 additions & 1 deletion tests/functional/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from demo.accounts.models import User, APIToken
from demo.accounts.models import User, EmailConfirmation, APIToken


@pytest.fixture
Expand All @@ -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(
Expand Down
62 changes: 55 additions & 7 deletions tests/functional/test_email.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]", "password": "correct battery horse staple"}
django_app.post_json(reverse("auth:signup"), params=params, status=201)
@pytest.mark.parametrize("email", [
"[email protected]",
"[email protected]",
])
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": "[email protected]"}
user = User.objects.last()
assert user.email == "[email protected]"
assert user.has_usable_password()
Expand All @@ -22,25 +31,64 @@ 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": "[email protected]"}
resp = django_app.post_json(reverse("auth:signup"), params=params, status=400)

assert "email" in resp.json


def test_signup_already_exists(db, django_app, user0):
params = {"email": "[email protected]", "password": "pass123"}
@pytest.mark.parametrize("email", [
"[email protected]",
"[email protected]",
])
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": "[email protected]", "password": "pass123"}
def test_signup_different_address(db, django_app, user0):
params = {"email": "[email protected]", "password": "goodpass"}
resp = django_app.post_json(reverse("auth:signup"), params=params, status=201)

assert resp.json == {"email": "[email protected]"}


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", [
"[email protected]",
"[email protected]",
])
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):
Expand Down
5 changes: 5 additions & 0 deletions tests/functional/test_facebook.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from django.urls import reverse

from pretend import stub
Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions tests/unit/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down

0 comments on commit c34db7f

Please sign in to comment.