[extensions-web/wip/api/v1] Implemented token authentication and REST account registration



commit 6d8cc6ab0b44db048bbd89627e619c8a253a1a91
Author: Yuri Konotopov <ykonotopov gnome org>
Date:   Sun Sep 11 16:27:39 2022 +0400

    Implemented token authentication and REST account registration

 requirements.in                                    |  3 +-
 requirements.txt                                   | 99 ++++++++++++++++++++--
 sweettooth/api/v1/urls.py                          |  4 +-
 sweettooth/api/v1/views.py                         |  6 +-
 sweettooth/auth/authentication.py                  | 53 ++++++++++++
 sweettooth/auth/backends.py                        | 29 -------
 sweettooth/auth/context_processors.py              |  8 --
 sweettooth/auth/forms.py                           | 69 ---------------
 sweettooth/auth/serializers.py                     | 46 ++++++++++
 .../django_registration/activation_complete.html   |  4 -
 .../django_registration/activation_email_body.txt  |  9 --
 .../activation_email_subject.txt                   |  1 -
 .../django_registration/activation_failed.html     |  4 -
 .../django_registration/registration_complete.html |  4 -
 .../django_registration/registration_form.html     | 13 ---
 sweettooth/auth/templates/profile/profile.html     | 58 -------------
 sweettooth/auth/templates/profile/settings.html    |  7 --
 sweettooth/auth/templates/registration/login.html  | 39 ---------
 .../templates/registration/login_popup_form.html   | 23 -----
 .../registration/password_change_done.html         |  4 -
 .../registration/password_change_form.html         | 13 ---
 .../registration/password_reset_complete.html      |  5 --
 .../registration/password_reset_confirm.html       | 17 ----
 .../registration/password_reset_done.html          |  4 -
 .../registration/password_reset_email.txt          |  8 --
 .../registration/password_reset_form.html          | 15 ----
 sweettooth/auth/tests.py                           | 91 ++++++++++----------
 sweettooth/auth/urls.py                            | 58 -------------
 sweettooth/auth/views.py                           | 59 ++-----------
 sweettooth/settings.py                             | 45 ++++++++--
 sweettooth/templates/base.html                     | 16 ----
 sweettooth/testutils.py                            | 12 ++-
 sweettooth/urls.py                                 |  3 -
 sweettooth/users/admin.py                          | 17 +++-
 .../users/migrations/0004_auto_20220830_2007.py    | 27 ++++++
 sweettooth/users/models.py                         |  5 +-
 36 files changed, 340 insertions(+), 538 deletions(-)
---
diff --git a/requirements.in b/requirements.in
index 3c43a31..041aff5 100644
--- a/requirements.in
+++ b/requirements.in
@@ -4,7 +4,8 @@ django-autoslug==1.9.8
 django-contrib-comments==2.1.0
 django-opensearch-dsl==0.4.1
 django-filter==21.1
-django-registration==3.2
+django-rest-registration==0.7.2
+django-rest-knox==4.2.0
 drf-spectacular[sidecar]==0.22.1
 Pygments==2.11.2
 Pillow==9.0.1
diff --git a/requirements.txt b/requirements.txt
index 70b8f63..4dd4aba 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,6 +18,58 @@ certifi==2021.10.8 \
     # via
     #   opensearch-py
     #   requests
+cffi==1.15.0 \
+    --hash=sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3 \
+    --hash=sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2 \
+    --hash=sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636 \
+    --hash=sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20 \
+    --hash=sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728 \
+    --hash=sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27 \
+    --hash=sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66 \
+    --hash=sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443 \
+    --hash=sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0 \
+    --hash=sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7 \
+    --hash=sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39 \
+    --hash=sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605 \
+    --hash=sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a \
+    --hash=sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37 \
+    --hash=sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029 \
+    --hash=sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139 \
+    --hash=sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc \
+    --hash=sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df \
+    --hash=sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14 \
+    --hash=sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880 \
+    --hash=sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2 \
+    --hash=sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a \
+    --hash=sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e \
+    --hash=sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474 \
+    --hash=sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024 \
+    --hash=sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8 \
+    --hash=sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0 \
+    --hash=sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e \
+    --hash=sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a \
+    --hash=sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e \
+    --hash=sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032 \
+    --hash=sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6 \
+    --hash=sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e \
+    --hash=sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b \
+    --hash=sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e \
+    --hash=sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954 \
+    --hash=sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962 \
+    --hash=sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c \
+    --hash=sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4 \
+    --hash=sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55 \
+    --hash=sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962 \
+    --hash=sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023 \
+    --hash=sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c \
+    --hash=sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6 \
+    --hash=sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8 \
+    --hash=sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382 \
+    --hash=sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7 \
+    --hash=sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc \
+    --hash=sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997 \
+    --hash=sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796
+    # via cryptography
 chardet==4.0.0 \
     --hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa \
     --hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5
@@ -26,10 +78,30 @@ charset-normalizer==2.1.1 \
     --hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \
     --hash=sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f
     # via requests
-confusable-homoglyphs==3.2.0 \
-    --hash=sha256:3b4a0d9fa510669498820c91a0bfc0c327568cecec90648cf3819d4a6fc6a751 \
-    --hash=sha256:e3ce611028d882b74a5faa69e3cbb5bd4dcd9f69936da6e73d33eda42c917944
-    # via django-registration
+cryptography==37.0.2 \
+    --hash=sha256:093cb351031656d3ee2f4fa1be579a8c69c754cf874206be1d4cf3b542042804 \
+    --hash=sha256:0cc20f655157d4cfc7bada909dc5cc228211b075ba8407c46467f63597c78178 \
+    --hash=sha256:1b9362d34363f2c71b7853f6251219298124aa4cc2075ae2932e64c91a3e2717 \
+    --hash=sha256:1f3bfbd611db5cb58ca82f3deb35e83af34bb8cf06043fa61500157d50a70982 \
+    --hash=sha256:2bd1096476aaac820426239ab534b636c77d71af66c547b9ddcd76eb9c79e004 \
+    --hash=sha256:31fe38d14d2e5f787e0aecef831457da6cec68e0bb09a35835b0b44ae8b988fe \
+    --hash=sha256:3b8398b3d0efc420e777c40c16764d6870bcef2eb383df9c6dbb9ffe12c64452 \
+    --hash=sha256:3c81599befb4d4f3d7648ed3217e00d21a9341a9a688ecdd615ff72ffbed7336 \
+    --hash=sha256:419c57d7b63f5ec38b1199a9521d77d7d1754eb97827bbb773162073ccd8c8d4 \
+    --hash=sha256:46f4c544f6557a2fefa7ac8ac7d1b17bf9b647bd20b16decc8fbcab7117fbc15 \
+    --hash=sha256:471e0d70201c069f74c837983189949aa0d24bb2d751b57e26e3761f2f782b8d \
+    --hash=sha256:59b281eab51e1b6b6afa525af2bd93c16d49358404f814fe2c2410058623928c \
+    --hash=sha256:731c8abd27693323b348518ed0e0705713a36d79fdbd969ad968fbef0979a7e0 \
+    --hash=sha256:95e590dd70642eb2079d280420a888190aa040ad20f19ec8c6e097e38aa29e06 \
+    --hash=sha256:a68254dd88021f24a68b613d8c51d5c5e74d735878b9e32cc0adf19d1f10aaf9 \
+    --hash=sha256:a7d5137e556cc0ea418dca6186deabe9129cee318618eb1ffecbd35bee55ddc1 \
+    --hash=sha256:aeaba7b5e756ea52c8861c133c596afe93dd716cbcacae23b80bc238202dc023 \
+    --hash=sha256:dc26bb134452081859aa21d4990474ddb7e863aa39e60d1592800a8865a702de \
+    --hash=sha256:e53258e69874a306fcecb88b7534d61820db8a98655662a3dd2ec7f1afd9132f \
+    --hash=sha256:ef15c2df7656763b4ff20a9bc4381d8352e6640cfeb95c2972c38ef508e75181 \
+    --hash=sha256:f224ad253cc9cea7568f49077007d2263efa57396a2f2f78114066fd54b5c68e \
+    --hash=sha256:f8ec91983e638a9bcd75b39f1396e5c0dc2330cbd9ce4accefe68717e6779e0a
+    # via django-rest-knox
 dateutils==0.6.12 \
     --hash=sha256:03dd90bcb21541bd4eb4b013637e4f1b5f944881c46cc6e4b67a6059e370e3f1 \
     --hash=sha256:f33b6ab430fa4166e7e9cb8b21ee9f6c9843c48df1a964466f52c79b2a8d53b3
@@ -49,7 +121,8 @@ django==3.2.15 \
     #   -r requirements.in
     #   django-contrib-comments
     #   django-filter
-    #   django-registration
+    #   django-rest-knox
+    #   django-rest-registration
     #   djangorestframework
     #   drf-spectacular
     #   drf-spectacular-sidecar
@@ -68,15 +141,21 @@ django-filter==21.1 \
 django-opensearch-dsl==0.4.1 \
     --hash=sha256:7c3cb4ca4cf8679a8cfef6f4073ab1679b71ee423627974ec42060a455b1d694
     # via -r requirements.in
-django-registration==3.2 \
-    --hash=sha256:2ea8c7d89a8760ccde41dfd335aa28ba89073d09aab5a0f5f3d7c8c148fcc518 \
-    --hash=sha256:e79fdbfa22bfaf4182efccb6604391391a7de19438d2669c5b9520a7708efbd2
+django-rest-knox==4.2.0 \
+    --hash=sha256:4595f1dc23d6e41af7939e5f2d8fdaf6ade0a74a656218e7b56683db5566fcc9 \
+    --hash=sha256:62b8e374a44cd4e9617eaefe27c915b301bf224fa6550633d3013d3f9f415113
+    # via -r requirements.in
+django-rest-registration==0.7.2 \
+    --hash=sha256:79a7caca716b12a3bb616ac94321b778ff7027d481bed12752edc5ccfeba34e3 \
+    --hash=sha256:83996992658fe1f6ceb1fcd12ebad3a4deb11c6a8b993c5901c573b6c6f70aa4
     # via -r requirements.in
 djangorestframework==3.13.1 \
     --hash=sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee \
     --hash=sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa
     # via
     #   -r requirements.in
+    #   django-rest-knox
+    #   django-rest-registration
     #   drf-spectacular
 drf-spectacular[sidecar]==0.22.1 \
     --hash=sha256:17ac5e31e5d6150dd5fa10843b429202f4f38069202acc44394cc5a771de63d9 \
@@ -147,6 +226,10 @@ polib==1.1.1 \
     --hash=sha256:d3ee85e0c6788f789353416b1612c6c92d75fe6ccfac0029711974d6abd0f86d \
     --hash=sha256:e02c355ae5e054912e3b0d16febc56510eff7e49d60bf22aecb463bd2f2a2dfa
     # via -r requirements.in
+pycparser==2.21 \
+    --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \
+    --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206
+    # via cffi
 pygments==2.11.2 \
     --hash=sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65 \
     --hash=sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a
diff --git a/sweettooth/api/v1/urls.py b/sweettooth/api/v1/urls.py
index 819b189..353a032 100644
--- a/sweettooth/api/v1/urls.py
+++ b/sweettooth/api/v1/urls.py
@@ -8,7 +8,7 @@
     (at your option) any later version.
 """
 
-from django.urls import path
+from django.urls import include, path
 
 from rest_framework.routers import SimpleRouter
 from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
@@ -32,6 +32,8 @@ router.register(
 
 urlpatterns = router.urls
 urlpatterns += [
+    path('v1/accounts/', include('rest_registration.api.urls')),
+
     path('v1/hello/', HelloView.as_view()),
     path('v1/profile/<int:pk>/',
          UserProfileDetailView.as_view(),
diff --git a/sweettooth/api/v1/views.py b/sweettooth/api/v1/views.py
index 3619419..805aff8 100644
--- a/sweettooth/api/v1/views.py
+++ b/sweettooth/api/v1/views.py
@@ -7,14 +7,13 @@
     the Free Software Foundation, either version 3 of the License, or
     (at your option) any later version.
 """
-
 from rest_framework.response import Response
 from rest_framework.views import APIView
 
-from sweettooth.auth import forms
 from sweettooth.auth import serializers
 from sweettooth.utils import gravatar_url
 
+
 class HelloView(APIView):
     def get(self, request, format=None):
         user = request.user
@@ -26,7 +25,4 @@ class HelloView(APIView):
 
         return Response({
             'user': serializers.UserSerializer(user).data,
-            'forms': {
-                'login_popup_form': forms.InlineAuthenticationForm().as_plain()
-            }
         })
diff --git a/sweettooth/auth/authentication.py b/sweettooth/auth/authentication.py
new file mode 100644
index 0000000..b2bab20
--- /dev/null
+++ b/sweettooth/auth/authentication.py
@@ -0,0 +1,53 @@
+from typing import TYPE_CHECKING, Optional, Sequence, Type
+
+from django.http import HttpRequest
+
+from drf_spectacular.authentication import TokenScheme
+from drf_spectacular.plumbing import build_bearer_security_scheme_object
+from knox.auth import AuthToken, TokenAuthentication
+from knox.settings import knox_settings
+from rest_framework.authentication import BaseAuthentication
+from rest_registration.auth_token_managers import AbstractAuthTokenManager, AuthToken as AuthTokenType
+from .views import LoginView
+
+if TYPE_CHECKING:
+    from django.contrib.auth.base_user import AbstractBaseUser
+
+
+class KnoxTokenScheme(TokenScheme):
+    target_class = 'knox.auth.TokenAuthentication'
+
+    def get_security_definition(self, auto_schema):
+        return build_bearer_security_scheme_object(
+            header_name='Authorization',
+            token_prefix=knox_settings.AUTH_HEADER_PREFIX,
+        )
+
+
+class KnoxAuthTokenManager(AbstractAuthTokenManager):
+
+    def get_authentication_class(self) -> Type[BaseAuthentication]:
+        return TokenAuthentication
+
+    def get_app_names(self) -> Sequence[str]:
+        return [
+            'knox',
+        ]
+
+    def provide_token(self, user: 'AbstractBaseUser') -> AuthTokenType:
+        request = HttpRequest()
+        request.method = "POST"
+        request.user = user
+        request._force_auth_user = user
+
+        token = LoginView.as_view()(request).data
+
+        return AuthTokenType(token)
+
+    def revoke_token(
+            self, user: 'AbstractBaseUser', *,
+            token: Optional[AuthTokenType] = None) -> None:
+        if token:
+            AuthToken.objects.get(user=user, digest=token).delete()
+        else:
+            user.auth_token_set.all().delete()
diff --git a/sweettooth/auth/serializers.py b/sweettooth/auth/serializers.py
index 89221aa..4f7c240 100644
--- a/sweettooth/auth/serializers.py
+++ b/sweettooth/auth/serializers.py
@@ -9,12 +9,58 @@
 """
 
 from django.contrib.auth import get_user_model
+from django.utils.translation import gettext_lazy as _
+
 from rest_framework import serializers
+from rest_registration.api.serializers import DefaultRegisterUserSerializer
+from rest_registration.utils.users import get_user_email_field_name
+
+
+User = get_user_model()
+
+
+def user_with_email_exists(email: str) -> bool:
+    email_field_name = get_user_email_field_name()
+
+    if not email_field_name:
+        return True
+
+    queryset = User.objects.filter(**{f"{email_field_name}__iexact": email})
+    return queryset.exists()
+
+
+def user_with_username_exists(username: str) -> bool:
+    return User.objects.filter(username__iexact=username).exists()
+
 
 class UserSerializer(serializers.ModelSerializer):
     avatar = serializers.CharField(read_only=True)
+    is_authenticated = serializers.SerializerMethodField()
+
+    def get_is_authenticated(self, data):
+        return data.is_authenticated
 
     class Meta:
         model = get_user_model()
         fields = '__all__'
         extra_kwargs = {'password': {'write_only': True}}
+
+
+class RegisterUserSerializer(DefaultRegisterUserSerializer):
+    def validate_email(self, value):
+        if user_with_email_exists(value):
+            raise serializers.ValidationError(_('This email is already registered'))
+
+        return value
+
+    def validate_username(self, value):
+        if user_with_username_exists(value):
+            raise serializers.ValidationError(_('This username is already registered'))
+
+        return value
+
+    def validate(self, attrs):
+        if attrs['username'].lower() == attrs['email'].lower():
+            raise serializers.ValidationError(_("You should not use email as username"))
+
+        return super().validate(attrs)
diff --git a/sweettooth/auth/tests.py b/sweettooth/auth/tests.py
index 251e765..dcaba84 100644
--- a/sweettooth/auth/tests.py
+++ b/sweettooth/auth/tests.py
@@ -10,18 +10,21 @@
 
 import re
 
-from django_registration import validators
-
 from django.contrib.auth import get_user_model
 from django.contrib.auth.tokens import PasswordResetTokenGenerator
-from django.test.testcases import TestCase
-from .forms import AutoFocusRegistrationForm, RegistrationForm
-from .urls import PASSWORD_RESET_TOKEN_PATTERN
+from django.urls import reverse
+
+from rest_framework import status
+from rest_framework.test import APIRequestFactory, APITestCase
+
+from rest_registration.api.views import login, register
+
+from .serializers import RegisterUserSerializer
 
 User = get_user_model()
 
 
-class RegistrationDataTest(TestCase):
+class RegistrationDataTest(APITestCase):
     registration_data = {
         User.USERNAME_FIELD: 'bob',
         'email': 'bob example com',
@@ -30,8 +33,9 @@ class RegistrationDataTest(TestCase):
     valid_data = {
         User.USERNAME_FIELD: 'alice',
         'email': 'alice example com',
-        'password1': 'swordfish',
-        'password2': 'swordfish',
+        'password': 'swordfish',
+        'password_confirm': 'swordfish',
+        'display_name': 'Alice',
     }
 
     @classmethod
@@ -43,67 +47,64 @@ class RegistrationDataTest(TestCase):
             email=cls.registration_data['email'],
             password=cls.registration_data['password']
         )
+        cls.factory = APIRequestFactory()
 
 
 # registration/tests/test_forms.py
 class AuthTests(RegistrationDataTest):
     def test_email_uniqueness(self):
+        url = reverse('rest_registration:register')
+
         data = self.valid_data.copy()
         data.update(email=self.registration_data['email'])
-        form = AutoFocusRegistrationForm(
-            data=data
-        )
-        self.assertFalse(form.is_valid())
-        self.assertEqual(
-            form.errors['email'],
-            [str(validators.DUPLICATE_EMAIL)]
-        )
 
-        form = AutoFocusRegistrationForm(
-            data=self.valid_data.copy()
-        )
-        self.assertTrue(form.is_valid())
+        request = self.factory.post(url, data)
+        response = register(request)
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('email', response.data.keys())
 
+    # TODO: make email DB field unique and enable email login
     def test_auth_username_email(self):
-        self.assertTrue(self.client.login(
-            username=self.registration_data[User.USERNAME_FIELD],
-            password=self.registration_data['password']))
+        url = reverse('rest_registration:login')
 
-        self.assertTrue(self.client.login(
-            username=self.registration_data['email'],
-            password=self.registration_data['password']))
+        response = login(self.factory.post(url, {
+            'login': self.registration_data[User.USERNAME_FIELD],
+            'password': self.registration_data['password']
+        }))
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
 
-        self.assertFalse(self.client.login(
-            username=self.registration_data[User.USERNAME_FIELD],
-            password=self.valid_data['password1']))
+        response = login(self.factory.post(url, {
+            'login': self.registration_data['email'],
+            'password': self.registration_data['password']
+        }))
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
-        self.assertFalse(self.client.login(
-            username=self.registration_data['email'],
-            password=self.valid_data['password1']))
+        response = login(self.factory.post(url, {
+            'login': self.registration_data[User.USERNAME_FIELD],
+            'password': self.valid_data['password']
+        }))
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
 class RegistrationTests(RegistrationDataTest):
     def test_username_email(self):
-        form = RegistrationForm(data=self.valid_data)
-        self.assertTrue(form.is_valid())
+        serializer = RegisterUserSerializer(data=self.valid_data.copy())
+        self.assertTrue(serializer.is_valid())
 
         data = self.valid_data.copy()
         data[User.USERNAME_FIELD] = data['email']
-        form = RegistrationForm(data=data)
-        self.assertFalse(form.is_valid())
+        serializer = RegisterUserSerializer(data=data)
+        self.assertFalse(serializer.is_valid())
 
     def test_username_case(self):
+        url = reverse('rest_registration:register')
+
         data = self.valid_data.copy()
         data[User.USERNAME_FIELD] = self.registration_data[User.USERNAME_FIELD].swapcase()
         self.assertTrue(data[User.USERNAME_FIELD] != self.registration_data[User.USERNAME_FIELD])
 
-        form = RegistrationForm(data=data)
-        self.assertFalse(form.is_valid())
-
-
-class PasswordResetTests(RegistrationDataTest):
-    def test_reset_token_pattern(self):
-        token = PasswordResetTokenGenerator().make_token(self.registered_user)
-        pattern = re.compile(f'^{PASSWORD_RESET_TOKEN_PATTERN}$')
+        request = self.factory.post(url, data)
+        response = register(request)
 
-        self.assertTrue(pattern.match(token))
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/sweettooth/auth/views.py b/sweettooth/auth/views.py
index 9a30d3d..d01d58a 100644
--- a/sweettooth/auth/views.py
+++ b/sweettooth/auth/views.py
@@ -1,56 +1,9 @@
-from django.contrib.auth import get_user_model
-from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseForbidden
-from django.shortcuts import get_object_or_404, redirect, render
-from django.views.decorators.http import require_POST
+from rest_framework import permissions
+from knox.views import LoginView as KnoxLoginView
 
-from sweettooth.review.models import CodeReview
-from sweettooth.extensions.models import Extension, ExtensionVersion
 
-from sweettooth.decorators import ajax_view
+class LoginView(KnoxLoginView):
+    permission_classes = (permissions.AllowAny,)
 
-def profile(request, user):
-    userobj = get_object_or_404(get_user_model(), username=user)
-
-    is_editable = (request.user == userobj) or request.user.has_perm('review.can-review-extensions')
-
-    display_name = userobj.get_full_name() or userobj.username
-    extensions = Extension.objects.visible().filter(creator=userobj).order_by('name')
-
-    if is_editable:
-        unreviewed = ExtensionVersion.objects.unreviewed().filter(extension__creator=userobj)
-        waiting = ExtensionVersion.objects.waiting().filter(extension__creator=userobj)
-    else:
-        unreviewed = []
-        waiting = []
-
-    return render(request,
-                  'profile/profile.html',
-                  dict(user=userobj,
-                       display_name=display_name,
-                       extensions=extensions,
-                       unreviewed=unreviewed,
-                       waiting=waiting,
-                       is_editable=is_editable))
-
-@ajax_view
-@require_POST
-@login_required
-def ajax_change_display_name(request, pk):
-    if request.POST['id'] != 'new_display_name':
-        return HttpResponseForbidden()
-
-    userobj = get_object_or_404(get_user_model(), pk=pk)
-    is_editable = (request.user == userobj) or request.user.has_perm('review.can-review-extensions')
-
-    if not is_editable:
-        return HttpResponseForbidden()
-
-    # display name is "%s %s" % (first_name, last_name). Change the first name.
-    userobj.first_name = request.POST['value']
-    userobj.save()
-    return userobj.first_name
-
-@login_required
-def profile_redirect(request):
-    return redirect('auth-profile', user=request.user.username)
+    def post(self, request, format=None):
+        return super(LoginView, self).post(request, format=None)
diff --git a/sweettooth/settings.py b/sweettooth/settings.py
index 91d5b01..178492b 100644
--- a/sweettooth/settings.py
+++ b/sweettooth/settings.py
@@ -6,7 +6,9 @@ https://docs.djangoproject.com/en/stable/ref/settings/
 """
 
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+import datetime
 import os
+from urllib.parse import urljoin
 import dj_database_url
 
 SITE_ROOT = os.path.dirname(os.path.abspath(__file__))
@@ -32,8 +34,6 @@ ALLOWED_HOSTS = [os.getenv('EGO_ALLOWED_HOST') or "extensions.gnome.org"]
 INSTALLED_APPS = [
     'django.contrib.auth',
 
-    'django_registration',
-
     # 'ratings' goes before django's comments
     # app so it will find our templates
     'sweettooth.ratings',
@@ -48,6 +48,8 @@ INSTALLED_APPS = [
 
     'rest_framework',
     'django_filters',
+    'knox',
+    'rest_registration',
 
     'django_opensearch_dsl',
 
@@ -82,12 +84,20 @@ if 'EGO_CORS_ORIGINS' in os.environ:
     CORS_ORIGIN_WHITELIST = list(map(str.strip, os.environ['EGO_CORS_ORIGINS'].split(",")))
 
 AUTH_USER_MODEL = 'users.User'
-AUTHENTICATION_BACKENDS = ['sweettooth.auth.backends.LoginEmailAuthentication']
 
 SECURE_BROWSER_XSS_FILTER = True
 SECURE_CONTENT_TYPE_NOSNIFF = True
 X_FRAME_OPTIONS = 'DENY'
 
+TOKEN_TTL_DAYS = 3
+REST_KNOX = {
+    'TOKEN_TTL': datetime.timedelta(days=TOKEN_TTL_DAYS),
+    'TOKEN_LIMIT_PER_USER': TOKEN_TTL_DAYS * 15,
+    'AUTO_REFRESH': True,
+}
+
+BASE_URL = os.getenv('EGO_BASE_URL', 'https://extensions.gnome.org')
+
 ROOT_URLCONF = 'sweettooth.urls'
 
 TEMPLATES = [
@@ -104,7 +114,6 @@ TEMPLATES = [
                 "django.template.context_processors.media",
                 "django.template.context_processors.request",
                 "sweettooth.review.context_processors.n_unreviewed_extensions",
-                "sweettooth.auth.context_processors.login_form",
                 "sweettooth.context_processors.navigation",
             ],
             'debug': DEBUG,
@@ -181,13 +190,14 @@ LOGIN_URL = '/accounts/login/'
 COMMENTS_APP = 'sweettooth.ratings'
 
 REST_FRAMEWORK = {
-    'DEFAULT_AUTHENTICATION_CLASSES': [
-        'rest_framework.authentication.BasicAuthentication',
-        'rest_framework.authentication.SessionAuthentication',
-    ],
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'knox.auth.TokenAuthentication',
+    ),
     'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
     'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
     'PAGE_SIZE': 10,
+
+    'TEST_REQUEST_DEFAULT_FORMAT': 'json',
 }
 
 SPECTACULAR_SETTINGS = {
@@ -224,6 +234,25 @@ if os.getenv('EGO_EMAIL_URL'):
 NO_SECURE_SETTINGS = True if os.getenv('EGO_NO_SECURE_SETTINGS') else False
 NO_STATICFILES_SETTINGS = False
 
+REST_REGISTRATION = {
+    'REGISTER_VERIFICATION_URL': urljoin(BASE_URL, '/verify-user'),
+    'RESET_PASSWORD_VERIFICATION_URL': urljoin(BASE_URL, '/reset-password'),
+    'REGISTER_EMAIL_VERIFICATION_URL': urljoin(BASE_URL, '/verify-email'),
+
+    'VERIFICATION_FROM_EMAIL': DEFAULT_FROM_EMAIL,
+
+    'USER_LOGIN_FIELDS': ('username',),
+
+    'REGISTER_SERIALIZER_CLASS': 'sweettooth.auth.serializers.RegisterUserSerializer',
+
+    'REGISTER_VERIFICATION_PERIOD': datetime.timedelta(days=5),
+    'REGISTER_VERIFICATION_ONE_TIME_USE': True,
+    'REGISTER_VERIFICATION_AUTO_LOGIN': True,
+
+    'AUTH_TOKEN_MANAGER_CLASS': 'sweettooth.auth.authentication.KnoxAuthTokenManager',
+    'LOGIN_RETRIEVE_TOKEN': True,
+}
+
 try:
     from local_settings import *
 except ImportError:
diff --git a/sweettooth/templates/base.html b/sweettooth/templates/base.html
index 4b18372..7649387 100644
--- a/sweettooth/templates/base.html
+++ b/sweettooth/templates/base.html
@@ -63,22 +63,6 @@
                         </li>
                     </ul>
                 </div>
-                <ul class="nav navbar-nav navbar-right no-padding">
-                    <li id="userDropdownMenu" class="dropdown">
-                    {% spaceless %}
-                    {% if request.user.is_authenticated %}
-                        <a href="{% url 'auth-profile' user=request.user.username %}" class="dropdown-toggle 
hidden-xs avatar" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><img 
src="{% gravatar_url request request.user.email %}"></a>
-                    {% else %}
-                        <a href="{% url 'auth-login' %}" class="dropdown-toggle hidden-xs" 
data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">{% trans "Log in" %} <span 
class="caret"></span></a>
-                    {% endif %}
-                    {% endspaceless %}
-                        <ul class="dropdown-menu login_popup_form">
-                            <li>
-                                {% include "usermenu.html" %}
-                            </li>
-                        </ul>
-                    </li>
-                </ul>
                 {% if n_unreviewed_extensions %}
                 <div class="nav navbar-nav navbar-right hidden-xs">
                     <li>
diff --git a/sweettooth/testutils.py b/sweettooth/testutils.py
index 0e81382..fae63d3 100644
--- a/sweettooth/testutils.py
+++ b/sweettooth/testutils.py
@@ -1,12 +1,18 @@
-
 from django.contrib.auth import get_user_model
 
-class BasicUserTestCase(object):
+from rest_framework.test import APITestCase
+
+from knox.models import AuthToken
+
+
+class BasicUserTestCase(APITestCase):
     def setUp(self):
         super().setUp()
+
         self.username = 'TestUser1'
         self.email = 'non-existant non-existant tld'
         self.password = 'a random password'
         self.user = get_user_model().objects.create_user(self.username, self.email, self.password)
 
-        self.client.login(username=self.username, password=self.password)
+        _, token = AuthToken.objects.create(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION=f'Token {token}')
diff --git a/sweettooth/urls.py b/sweettooth/urls.py
index 2b6bb7d..c5fc02f 100644
--- a/sweettooth/urls.py
+++ b/sweettooth/urls.py
@@ -17,9 +17,6 @@ admin.autodiscover()
 urlpatterns = [
     path('api/', include('sweettooth.api.v1.urls')),
 
-    # 'login' and 'register'
-    re_path(r'^accounts/', include('sweettooth.auth.urls')),
-
     re_path(r'^', include('sweettooth.extensions.urls'), name='index'),
 
     re_path(r'^review/', include('sweettooth.review.urls')),
diff --git a/sweettooth/users/admin.py b/sweettooth/users/admin.py
index f91be8f..4482403 100644
--- a/sweettooth/users/admin.py
+++ b/sweettooth/users/admin.py
@@ -1,5 +1,18 @@
+from django.utils.translation import gettext_lazy as _
 from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
 from .models import User
 
-admin.site.register(User, UserAdmin)
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+    fieldsets = (
+        (None, {'fields': ('username', 'password')}),
+        (_('Personal info'), {'fields': ('display_name', 'email')}),
+        (_('Permissions'), {
+            'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
+        }),
+        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
+    )
+    list_display = ('username', 'email', 'display_name', 'is_staff')
+    search_fields = ('username', 'display_name', 'email')
diff --git a/sweettooth/users/migrations/0004_auto_20220830_2007.py 
b/sweettooth/users/migrations/0004_auto_20220830_2007.py
new file mode 100644
index 0000000..c4e6087
--- /dev/null
+++ b/sweettooth/users/migrations/0004_auto_20220830_2007.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.15 on 2022-08-30 20:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0003_rename_users_table'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='user',
+            old_name='first_name',
+            new_name='display_name',
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='display_name',
+            field=models.CharField(blank=True, max_length=150),
+        ),
+        migrations.RemoveField(
+            model_name='user',
+            name='last_name',
+        ),
+    ]
diff --git a/sweettooth/users/models.py b/sweettooth/users/models.py
index 61487eb..068e4ed 100644
--- a/sweettooth/users/models.py
+++ b/sweettooth/users/models.py
@@ -9,7 +9,10 @@
 """
 
 from django.contrib.auth.models import AbstractUser
+from django.db import models
 
 
 class User(AbstractUser):
-    pass
+    first_name = None
+    last_name = None
+    display_name = models.CharField(max_length=150, blank=True)


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]