Skip to content

Commit

Permalink
Merge pull request #3 from snnbotchway/feature/authentication
Browse files Browse the repository at this point in the history
Redo authentication
  • Loading branch information
snnbotchway authored Mar 11, 2023
2 parents e0d9eae + dca213d commit f1a4702
Show file tree
Hide file tree
Showing 18 changed files with 286 additions and 47 deletions.
1 change: 1 addition & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
runs-on: ubuntu-latest
needs: check-hooks
env:
DATABASE_URL: postgresql://devuser:changeme@db:5432/devdb
DEBUG: True
SECRET_KEY: change_me

Expand Down
1 change: 1 addition & 0 deletions b2b/account/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The account app."""
9 changes: 9 additions & 0 deletions b2b/account/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Account app configuration."""
from django.apps import AppConfig


class AccountConfig(AppConfig):
"""Account app config class."""

default_auto_field = "django.db.models.BigAutoField"
name = "account"
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Generated by Django 4.1.7 on 2023-03-11 10:27
# Generated by Django 4.1.7 on 2023-03-11 22:32

import django.contrib.auth.models
import django.contrib.auth.validators
import account.models
import django.utils.timezone
from django.db import migrations, models

Expand Down Expand Up @@ -41,39 +40,6 @@ class Migration(migrations.Migration):
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
Expand All @@ -96,6 +62,13 @@ class Migration(migrations.Migration):
default=django.utils.timezone.now, verbose_name="date joined"
),
),
("name", models.CharField(max_length=100)),
(
"email",
models.EmailField(
max_length=254, unique=True, verbose_name="email address"
),
),
(
"groups",
models.ManyToManyField(
Expand Down Expand Up @@ -125,7 +98,7 @@ class Migration(migrations.Migration):
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
("objects", account.models.UserManager()),
],
),
]
File renamed without changes.
56 changes: 56 additions & 0 deletions b2b/account/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Account app models."""
from typing import List

from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _


class UserManager(BaseUserManager):
"""Custom user manager."""

use_in_migrations = True

def _create_user(self, email, password, **extra_fields):
"""Create and return a user."""
if not email:
raise ValueError("Users require an email field")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user

def create_user(self, email, password=None, **extra_fields):
"""Set defaults and call _create_user."""
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)

def create_superuser(self, email, password, **extra_fields):
"""Create and return a superuser."""
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)

if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")

return self._create_user(email, password, **extra_fields)


class User(AbstractUser):
"""Custom user model."""

username = None
first_name = None
last_name = None
name = models.CharField(max_length=100)
email = models.EmailField(_("email address"), unique=True)

objects = UserManager()

USERNAME_FIELD = "email"
REQUIRED_FIELDS: List[str] = []
37 changes: 37 additions & 0 deletions b2b/account/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Serializers for the account app views."""

from django.contrib.auth import authenticate
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers


class UserTokenSerializer(serializers.Serializer):
"""Serializer for authentication token."""

email = serializers.EmailField(label=_("Email"), write_only=True)
password = serializers.CharField(
label=_("Password"),
style={"input_type": "password"},
trim_whitespace=False,
write_only=True,
)
token = serializers.CharField(label=_("Token"), read_only=True)

def validate(self, attrs):
"""Validate email and password."""
email = attrs.get("email")
password = attrs.get("password")

if email and password:
user = authenticate(
request=self.context.get("request"), email=email, password=password
)
if not user:
msg = _("Unable to log in with provided credentials.")
raise serializers.ValidationError(msg, code="authorization")
else:
msg = _('Must include "email" and "password".')
raise serializers.ValidationError(msg, code="authorization")

attrs["user"] = user
return attrs
1 change: 1 addition & 0 deletions b2b/account/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the account app."""
21 changes: 21 additions & 0 deletions b2b/account/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Common fixtures for the account app tests."""
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.fixture
def user_payload():
"""Return sample user information as a payload."""
return {
"email": "user@example.com",
"password": "test_pass123",
"name": "Test User",
}


@pytest.fixture
def sample_user(user_payload):
"""Create and return a sample user."""
return User.objects.create_user(**user_payload)
62 changes: 62 additions & 0 deletions b2b/account/tests/test_account_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest
from django.urls import reverse
from rest_framework import status

ACCOUNT_LOGIN_URL = reverse("account:login")


@pytest.mark.django_db
class TestUserToken:
def test_generate_token_with_valid_credentials_returns_200(
self, sample_user, api_client
):
"""Test a token is returned with valid credentials"""
payload = {
"email": sample_user.email,
"password": "test_pass123",
}

response = api_client.post(ACCOUNT_LOGIN_URL, payload)

assert response.status_code == status.HTTP_200_OK
assert "token" in response.data

def test_generate_token_with_invalid_email_returns_400(
self, sample_user, api_client
):
"""Test a token is not returned with invalid email"""
payload = {
"email": "other@example.com",
"password": "testPass123",
}

response = api_client.post(ACCOUNT_LOGIN_URL, payload)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "token" not in response.data

def test_generate_token_with_invalid_password_returns_400(
self, sample_user, api_client
):
"""Test a token is not returned with invalid password"""
payload = {
"email": sample_user.email,
"password": "badPass123",
}

response = api_client.post(ACCOUNT_LOGIN_URL, payload)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "token" not in response.data

def test_generate_token_without_password_returns_400(self, api_client, sample_user):
"""Test a token is not returned with blank password"""
payload = {
"email": "user@example.com",
"password": "",
}

response = api_client.post(ACCOUNT_LOGIN_URL, payload)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "token" not in response.data
38 changes: 38 additions & 0 deletions b2b/account/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.mark.django_db
class TestUserManager:
def test_create_user(self, user_payload):
user = User.objects.create_user(**user_payload)
assert user.email == user_payload.get("email")
assert user.name == user_payload.get("name")
assert user.check_password(user_payload.get("password"))
assert not user.is_staff
assert not user.is_superuser

def test_create_user_missing_email(self):
with pytest.raises(ValueError) as excinfo:
User.objects.create_user(email=None, password="password")
assert str(excinfo.value) == "Users require an email field"

def test_create_superuser(self):
email = "testsuperuser@example.com"
password = "password"
user = User.objects.create_superuser(email=email, password=password)
assert user.email == email
assert user.is_staff
assert user.is_superuser

def test_create_superuser_missing_is_staff(self, user_payload):
with pytest.raises(ValueError) as excinfo:
User.objects.create_superuser(**user_payload, is_staff=False)
assert str(excinfo.value) == "Superuser must have is_staff=True."

def test_create_superuser_missing_is_superuser(self, user_payload):
with pytest.raises(ValueError) as excinfo:
User.objects.create_superuser(**user_payload, is_superuser=False)
assert str(excinfo.value) == "Superuser must have is_superuser=True."
10 changes: 10 additions & 0 deletions b2b/account/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Account app urls."""
from django.urls import path

from .views import CreateUserTokenView

app_name = "account"

urlpatterns = [
path("login/", CreateUserTokenView.as_view(), name="login"),
]
17 changes: 17 additions & 0 deletions b2b/account/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Account app views."""
from django.contrib.auth import get_user_model
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.settings import api_settings

from .serializers import UserTokenSerializer

User = get_user_model()


class CreateUserTokenView(ObtainAuthToken):
"""Get token for valid user email and password."""

serializer_class = UserTokenSerializer

# To get the browsable API;
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
12 changes: 11 additions & 1 deletion b2b/b2b/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
"django.contrib.messages",
"django.contrib.staticfiles",
# Local apps
"account.apps.AccountConfig",
"core.apps.CoreConfig",
# Third-party apps
"rest_framework",
"rest_framework.authtoken",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -131,4 +135,10 @@

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

AUTH_USER_MODEL = "core.User"
AUTH_USER_MODEL = "account.User"

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.TokenAuthentication",
),
}
3 changes: 2 additions & 1 deletion b2b/b2b/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import include, path

urlpatterns = [
path("admin/", admin.site.urls),
path("account/", include("account.urls")),
]
9 changes: 9 additions & 0 deletions b2b/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Global project fixtures."""
import pytest
from rest_framework.test import APIClient


@pytest.fixture
def api_client():
"""Return API client."""
return APIClient()
8 changes: 0 additions & 8 deletions b2b/core/models.py

This file was deleted.

Loading

0 comments on commit f1a4702

Please sign in to comment.