diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 690c0de..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 95c9dad..8229571 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ var/ *.egg .idea/ .vscode/* +.DS_Store/* +authors/.DS_Store # PyInstaller # Usually these files are written by a python script from a template @@ -40,6 +42,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .coverage +.coveragerc .coverage.* .cache nosetests.xml diff --git a/app.json b/app.json index 6883862..6eaa96c 100644 --- a/app.json +++ b/app.json @@ -22,6 +22,18 @@ }, "DB": { "required": true + }, + "EMAIL_HOST": { + "required": true + }, + "EMAIL_HOST_USER": { + "required": true + }, + "EMAIL_HOST_PASSWORD": { + "required": true + }, + "EMAIL_PORT": { + "required": true } }, "formation": {}, diff --git a/authors/.DS_Store b/authors/.DS_Store deleted file mode 100644 index d49293f..0000000 Binary files a/authors/.DS_Store and /dev/null differ diff --git a/authors/apps/.DS_Store b/authors/apps/.DS_Store deleted file mode 100644 index 68c17c6..0000000 Binary files a/authors/apps/.DS_Store and /dev/null differ diff --git a/authors/apps/authentication/backends.py b/authors/apps/authentication/backends.py index d719865..9213233 100644 --- a/authors/apps/authentication/backends.py +++ b/authors/apps/authentication/backends.py @@ -39,7 +39,7 @@ def authenticate_credentials(self, request, token): except BaseException: message = "The token provided can not be decoded." raise exceptions.AuthenticationFailed(message) - user = User.objects.get(email=payload['sub']['email']) + user = User.objects.get(username=payload['sub']) if not user: message = "User does not exist in the database." raise exceptions.AuthenticationFailed(message) diff --git a/authors/apps/authentication/migrations/0002_user_email_verified.py b/authors/apps/authentication/migrations/0002_user_email_verified.py new file mode 100644 index 0000000..caa8813 --- /dev/null +++ b/authors/apps/authentication/migrations/0002_user_email_verified.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2019-04-25 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email_verified', + field=models.BooleanField(default=False), + ), + ] diff --git a/authors/apps/authentication/models.py b/authors/apps/authentication/models.py index 1072865..ad15d8f 100644 --- a/authors/apps/authentication/models.py +++ b/authors/apps/authentication/models.py @@ -7,6 +7,7 @@ ) from django.db import models + class UserManager(BaseUserManager): """ Django requires that custom users define their own Manager class. By @@ -32,21 +33,20 @@ def create_user(self, username, email, password=None): return user def create_superuser(self, username, email, password): - """ - Create and return a `User` with superuser powers. - - Superuser powers means that this use is an admin that can do anything - they want. - """ - if password is None: - raise TypeError('Superusers must have a password.') + """ + Create and return a `User` with superuser powers. + Superuser powers means that this use is an admin that can do anything + they want. + """ + if password is None: + raise TypeError('Superusers must have a password.') - user = self.create_user(username, email, password) - user.is_superuser = True - user.is_staff = True - user.save() + user = self.create_user(username, email, password) + user.is_superuser = True + user.is_staff = True + user.save() - return user + return user class User(AbstractBaseUser, PermissionsMixin): @@ -80,6 +80,11 @@ class User(AbstractBaseUser, PermissionsMixin): # A timestamp reprensenting when this object was last updated. updated_at = models.DateTimeField(auto_now=True) + # The `email_verified` flag represents whether the registered user has a + # verified account. + # The user verifys their account through a link sent to their email. + email_verified = models.BooleanField(default=False) + # More fields required by Django when specifying a custom user model. # The `USERNAME_FIELD` property tells us which field we will use to log in. @@ -94,19 +99,18 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): """ Returns a string representation of this `User`. - This string is used when a `User` is printed in the console. """ return self.email @property def get_full_name(self): - """ - This method is required by Django for things like handling emails. - Typically, this would be the user's first and last name. Since we do - not store the user's real name, we return their username instead. - """ - return self.username + """ + This method is required by Django for things like handling emails. + Typically, this would be the user's first and last name. Since we do + not store the user's real name, we return their username instead. + """ + return self.username def get_short_name(self): """ diff --git a/authors/apps/authentication/renderers.py b/authors/apps/authentication/renderers.py index ce57216..82c7e8f 100644 --- a/authors/apps/authentication/renderers.py +++ b/authors/apps/authentication/renderers.py @@ -18,7 +18,6 @@ def render(self, data, media_type=None, renderer_context=None): # rendering errors. return super(UserJSONRenderer, self).render(data) - # Finally, we can render our data under the "user" namespace. return json.dumps({ 'user': data diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index dd1ffd9..e260a31 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -35,7 +35,6 @@ class LoginSerializer(serializers.Serializer): username = serializers.CharField(max_length=255, read_only=True) password = serializers.CharField(max_length=128, write_only=True) - def validate(self, data): # The `validate` method is where we make sure that the current # instance of `LoginSerializer` has "valid". In the case of logging a @@ -112,11 +111,10 @@ class Meta: # specifying the field with `read_only=True` like we did for password # above. The reason we want to use `read_only_fields` here is because # we don't need to specify anything else about the field. For the - # password field, we needed to specify the `min_length` and + # password field, we needed to specify the `min_length` and # `max_length` properties too, but that isn't the case for the token # field. - def update(self, instance, validated_data): """Performs an update on a User.""" diff --git a/authors/apps/authentication/tests/__init__.py b/authors/apps/authentication/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authors/apps/authentication/tests/test_authentication.py b/authors/apps/authentication/tests/test_authentication.py new file mode 100644 index 0000000..999be81 --- /dev/null +++ b/authors/apps/authentication/tests/test_authentication.py @@ -0,0 +1,141 @@ +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes + +from rest_framework import status +from rest_framework .test import APITestCase, APIClient + +from .user_data import UserTestData + + +class UserManagerTestCase(APITestCase): + """Usermanager TestCase.""" + + def setUp(self): + User = get_user_model() + self.client = APIClient() + self.user_test_data = UserTestData() + self.user = User.objects.create_user( + username='test1', email='test1@example.com', password='12345678' + ) + + def test_create_user(self): + """Test create user.""" + self.assertEqual(self.user.username, 'test1') + self.assertTrue(self.user.is_active) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) + + def test_user_model_returns_string_object(self): + """Test user object string representation is returned.""" + self.assertTrue(self.user.username, str(self.user)) + + def test_return_full_name(self): + """Test return get full name.""" + self.assertTrue(self.user.get_full_name) + + def test_return_short_name(self): + """Test return get short name.""" + self.assertEqual(self.user.get_short_name(), 'test1') + + def test_create_user_with_no_username(self): + """Test create user with no username.""" + User = get_user_model() + with self.assertRaisesMessage(TypeError, + 'Users must have a username.'): + User.objects.create_user( + username=None, email='test1@example.com', password='12345678' + ) + + def test_create_user_with_no_email(self): + """Test create user with no email.""" + User = get_user_model() + with self.assertRaisesMessage(TypeError, + 'Users must have an email address.'): + User.objects.create_user( + username='test1', email=None, password='12345678' + ) + + def test_create_superuser(self): + """Test create superuser.""" + User = get_user_model() + user = User.objects.create_superuser( + username='admin', email='admin@example.com', password='12345678' + ) + self.assertEqual(user.username, 'admin') + self.assertTrue(user.is_active) + self.assertTrue(user.is_staff) + self.assertTrue(user.is_superuser) + + def test_create_superuser_with_no_password(self): + """Test create superuser with no password.""" + User = get_user_model() + with self.assertRaisesMessage(TypeError, + 'Superusers must have a password.'): + User.objects.create_superuser( + username='admin2', email='admin2@example.com', password=None + ) + + def test_user_signup(self): + """Test user signup.""" + url = reverse('authentication:user_signup') + response = self.client.post(url, self.user_test_data.user_registration, + format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_user_login(self): + """Test user login.""" + registration_url = reverse('authentication:user_signup') + login_url = reverse('authentication:user_login') + self.client.post(registration_url, + self.user_test_data.user_registration, format="json") + response = self.client.post(login_url, self.user_test_data.user_login, + format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_activation_link(self): + """Test user can get activation link to activate account.""" + User = get_user_model() + user2 = User.objects.create_user( + username='test3', email='test3@example.com', password='12345678' + ) + uid = user2.username + kwargs = { + "uid": urlsafe_base64_encode(force_bytes(uid)).decode('utf-8') + } + activation_url = reverse('authentication:activation_link', + kwargs=kwargs) + response = self.client.get(activation_url, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_activation_link_invalid(self): + """Test user registration activation link is invalid.""" + User = get_user_model() + user3 = User.objects.create_user( + username='test3', email='test3@example.com', password='12345678' + ) + uid = user3.id + kwargs = { + "uid": urlsafe_base64_encode(force_bytes(uid)).decode('utf-8') + } + activation_url = reverse('authentication:activation_link', + kwargs=kwargs) + response = self.client.get(activation_url, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue(user3, None) + + def test_activation_link_user_not_found(self): + """Test user registration activation link is invalid.""" + User = get_user_model() + user3 = User.objects.create_user( + username='test3', email='test3@example.com', password='12345678' + ) + uid = user3.id + kwargs = { + "uid": urlsafe_base64_encode(force_bytes(uid)).decode('utf-8') + } + activation_url = reverse('authentication:activation_link', + kwargs=kwargs) + response = self.client.get(activation_url, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/authors/apps/authentication/tests/user_data.py b/authors/apps/authentication/tests/user_data.py new file mode 100644 index 0000000..4ded90f --- /dev/null +++ b/authors/apps/authentication/tests/user_data.py @@ -0,0 +1,18 @@ +class UserTestData: + """User Test Data.""" + + def __init__(self): + self.user_registration = { + "user": { + "username": "mwinel", + "email": "mwinel@live.com", + "password": "12345678" + } + } + + self.user_login = { + "user": { + "email": "mwinel@live.com", + "password": "12345678" + } + } diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index 7d62597..36c45a2 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -1,12 +1,15 @@ from django.urls import path from .views import ( - LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView + LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, + AccountActivationAPIView ) app_name = "authentication" urlpatterns = [ path('user/', UserRetrieveUpdateAPIView.as_view()), - path('users/', RegistrationAPIView.as_view()), - path('users/login/', LoginAPIView.as_view()), + path('users/', RegistrationAPIView.as_view(), name='user_signup'), + path('users/login/', LoginAPIView.as_view(), name='user_login'), + path('users//', AccountActivationAPIView.as_view(), + name='activation_link'), ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index 42e7da0..d146909 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -1,9 +1,17 @@ +from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import send_mail +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes + from rest_framework import status from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from authors.apps.authentication.models import User +from rest_framework import exceptions + +from .models import User from .renderers import UserJSONRenderer from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer @@ -26,14 +34,30 @@ def post(self, request): serializer = self.serializer_class(data=user) serializer.is_valid(raise_exception=True) serializer.save() - token = User.encode_auth_token(user).decode('utf-8') + + # Create a unique identifier + uid = urlsafe_base64_encode( + force_bytes(user['username'])).decode('utf-8') + current_site = get_current_site(request) + + activation_link = '{}/api/users/{}/'.format(current_site, uid) + + subject = 'Activate your account' + message = 'Click the link below to activate your account.\n{}'.format( + activation_link) + from_email = settings.EMAIL_HOST_USER + to_email = user['email'] + send_mail(subject, message, from_email, [to_email], + fail_silently=False) + response_data = { - 'username':user['username'], - 'email':user['email'], - 'token':token + 'username': user['username'], + 'email': user['email'], + 'message': 'Check your email address to confirm registration.', + 'activation_link': activation_link } - return Response(response_data, - status=status.HTTP_201_CREATED) + + return Response(response_data, status=status.HTTP_201_CREATED) class LoginAPIView(APIView): @@ -88,3 +112,32 @@ def update(self, request, *args, **kwargs): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) + + +class AccountActivationAPIView(APIView): + """ + Return the registration activation link. + """ + permission_classes = (AllowAny, ) + authentication_classes = () + serializer_class = UserSerializer + + def get(self, request, uid): + try: + username = urlsafe_base64_decode(uid).decode('utf-8') + user = User.objects.filter(username=username).first() + except User.DoesNotExist: + message = "User not found." + return exceptions.AuthenticationFailed(message) + if user is not None and not user.email_verified: + user.email_verified = True + user.save() + token = User.encode_auth_token(user.username).decode('utf-8') + response = { + "message": "Account successfully activated. Login now.", + "token": token + } + return Response(response, status=status.HTTP_200_OK) + return Response({ + "error": "Activation link is invalid.", + }, status=status.HTTP_400_BAD_REQUEST) diff --git a/authors/settings.py b/authors/settings.py index d3de668..34adf85 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -157,7 +157,8 @@ ) } -#heroku deploy settings +# Heroku deploy settings + PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) STATIC_ROOT = os.path.join(PROJECT_ROOT, "staticfiles") @@ -165,3 +166,12 @@ STATICFILES_DIRS = (os.path.join(PROJECT_ROOT, "static"),) STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +# Email settings + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('EMAIL_HOST') +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +EMAIL_PORT = os.getenv('EMAIL_PORT') +EMAIL_USE_TLS = True