From 266a1249d73dbaef7a8a0723a17408c27f21408a Mon Sep 17 00:00:00 2001 From: Matteo Rosati Date: Tue, 10 Feb 2026 20:24:02 +0100 Subject: [PATCH] implement auth (register, login, logout) --- api/serializers.py | 75 ++++++++++++++++++++++++++++++++++++++++ api/urls.py | 9 +++++ api/views.py | 68 ++++++++++++++++++++++++++++++++++-- db.sqlite3 | Bin 135168 -> 172032 bytes dronewars/settings.py | 30 ++++++++++++++++ dronewars/urls.py | 6 ++-- pyproject.toml | 2 ++ specs/authentication.md | 68 ++++++++++++++++++++++++++++++++++++ uv.lock | 39 +++++++++++++++++++++ 9 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 api/serializers.py create mode 100644 api/urls.py create mode 100644 specs/authentication.md diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..81f89ee --- /dev/null +++ b/api/serializers.py @@ -0,0 +1,75 @@ +from django.contrib.auth import authenticate, get_user_model +from django.core.validators import validate_email +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + + +class RegistrationSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(min_length=8, write_only=True) + + def validate_email(self, value): + validate_email(value) + if User.objects.filter(email__iexact=value).exists(): + raise serializers.ValidationError(_("Email is already registered.")) + return value + + def create(self, validated_data): + email = validated_data["email"].lower() + password = validated_data["password"] + user = User.objects.create_user( + username=email, + email=email, + password=password, + ) + return user + + +class LoginSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + email = attrs.get("email", "").lower() + password = attrs.get("password") + if not email or not password: + raise serializers.ValidationError(_("Email and password are required.")) + + user = authenticate( + request=self.context.get("request"), + username=email, + password=password, + ) + if user is None: + raise serializers.ValidationError(_("Invalid email or password.")) + if not user.is_active: + raise serializers.ValidationError(_("User account is disabled.")) + + attrs["user"] = user + return attrs + + def create(self, validated_data): + user = validated_data["user"] + refresh = RefreshToken.for_user(user) + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + + +class LogoutSerializer(serializers.Serializer): + refresh = serializers.CharField() + + def validate(self, attrs): + refresh = attrs.get("refresh") + if not refresh: + raise serializers.ValidationError(_("Refresh token is required.")) + return attrs + + def create(self, validated_data): + refresh = RefreshToken(validated_data["refresh"]) + refresh.blacklist() + return {} diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..a1dd9e5 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import LoginView, LogoutView, RegisterView + +urlpatterns = [ + path("auth/register", RegisterView.as_view(), name="auth-register"), + path("auth/login", LoginView.as_view(), name="auth-login"), + path("auth/logout", LogoutView.as_view(), name="auth-logout"), +] diff --git a/api/views.py b/api/views.py index 91ea44a..18f0fbe 100644 --- a/api/views.py +++ b/api/views.py @@ -1,3 +1,67 @@ -from django.shortcuts import render +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView -# Create your views here. +from .serializers import LoginSerializer, LogoutSerializer, RegistrationSerializer + + +class RegisterView(APIView): + permission_classes = [permissions.AllowAny] + throttle_scope = "auth" + + def post(self, request): + serializer = RegistrationSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + user = serializer.save() + login_serializer = LoginSerializer( + data={ + "email": user.email, + "password": request.data.get("password"), + }, + context={"request": request}, + ) + login_serializer.is_valid(raise_exception=True) + tokens = login_serializer.save() + return Response( + { + "user": { + "id": user.id, + "email": user.email, + }, + "tokens": tokens, + }, + status=status.HTTP_201_CREATED, + ) + + +class LoginView(APIView): + permission_classes = [permissions.AllowAny] + throttle_scope = "auth" + + def post(self, request): + serializer = LoginSerializer(data=request.data, context={"request": request}) + if not serializer.is_valid(): + return Response( + {"errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + tokens = serializer.save() + return Response({"tokens": tokens}, status=status.HTTP_200_OK) + + +class LogoutView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + serializer = LogoutSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response({"detail": "Logged out."}, status=status.HTTP_200_OK) diff --git a/db.sqlite3 b/db.sqlite3 index 76596f89e4c6298de8ebd7f30436f95898a2c609..9116127227e4fa7b67b86769a2be1ada22940519 100644 GIT binary patch delta 7545 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 delta 606 zcmXw0ZAepL6n@Y9Uhm!Ryq6mwgOACQ=|GPF=!fe`%|t}ooR)ZtgTU(29q znr+_fjs7V9i6DR2B`#rs>`zca7WT9BN6-ftQBjmg-36Y*IUJtDbDjrIYDrGbS~8^$ zA0Z@P+tHW})|U9l-Wz`kObBo@zi{KGQnoNOh7NB>q|<96B`(G6E}K`A?0a;d_nWMH zOWzAl5l0Aq!c|Uv@GNS{18L^5-ujg#01m+E3MM@OthL48RJc1Xy2wACVkFT#ac-*X(iZGlu#ZOew07R`>ppa zZ!J#ov+1Fi>g*(2qA5tzX7Zi1W)He~=L~(oC%b|3({Xq#Hdc%7h@bmZ*uk6QP|izL zsO8n?;g*w466f&%5VcRN5%U%Z-|T_=6.0.2", + "djangorestframework>=3.16.1", + "djangorestframework-simplejwt>=5.5.0", "pydantic>=2.12.5", ] diff --git a/specs/authentication.md b/specs/authentication.md new file mode 100644 index 0000000..6afc30b --- /dev/null +++ b/specs/authentication.md @@ -0,0 +1,68 @@ +# Authentication + +- Write a fully functional API based authentication system for this application. +- There must be two available endpoints, one for the registration, one for the login. Both only speak JSON and both are POST only. +- The authentication must be token based. When a user authenticates, the response returns a token that uniquely identifies the user, and can be used for the next API calls. +- The implementation must be secure, solid, and be production ready. +- If necessary, add python / django packages to simplify the implementation. + +You must interview the user on this specs to gather as many information as you think it's necessary to make this implementation solid as rock, then update this file with an accurate implementation plan. + +## Registration + +The registration accepts two fields in the payload: + +- email: mandatory, valid email. +- password: mandatory, 8 characters minimum. + +The endpoint must validate the data and return meaningful JSON error messages. When it runs without errors, it creates a new user. + +## Login + +The login endpoint accepts two fields in the payload: + +- email: mandatory, valid email. +- password: mandatory + +If the user is found in the database, the API returns a token for that user. + +## Logout + +Provide also a logout mechanism that invalidates or deletes the current user token. + +## Implementation Plan + +Assumptions (defaulted since only routes were confirmed): +- Use Django REST Framework + SimpleJWT. +- Access token lifetime: 15 minutes; refresh token lifetime: 7 days. +- Enable token blacklist app to invalidate refresh tokens on logout. +- Keep default Django `User` model; store email as the unique identifier, set `username = email` on registration. +- Add throttling for login/registration to reduce abuse. +- Routes: `/api/auth/register`, `/api/auth/login`, `/api/auth/logout` (JSON-only, POST). + +Steps: +1) Add packages + - Add `djangorestframework`, `djangorestframework-simplejwt`, and `djangorestframework-simplejwt[token_blacklist]` to dependencies. +2) Configure settings + - Add `rest_framework`, `rest_framework_simplejwt`, and `rest_framework_simplejwt.token_blacklist` to `INSTALLED_APPS`. + - Configure `REST_FRAMEWORK` defaults for JSON-only responses, authentication classes (JWT), and throttling classes/rates. + - Configure `SIMPLE_JWT` lifetimes and blacklist settings. +3) Add API routes + - Create `api/urls.py` and include under `dronewars/urls.py` at `path("api/", include(...))`. + - Add routes: + - POST `/api/auth/register` + - POST `/api/auth/login` + - POST `/api/auth/logout` +4) Implement serializers + - `RegistrationSerializer`: validate email format, enforce minimum password length (>= 8), ensure email uniqueness, create user with `set_password`, set `username=email`. + - `LoginSerializer`: validate credentials using `authenticate`, return SimpleJWT token pair. +5) Implement views + - `RegisterView`: create user; return token pair on success. + - `LoginView`: return token pair for valid credentials; return JSON errors on failure. + - `LogoutView`: accept refresh token, blacklist it, return success JSON. +6) Error handling + - Return consistent JSON errors with field-level messages for validation failures and authentication errors. +7) Tests (if present or required) + - Add basic tests for registration, login, and logout success/failure paths. + +If any assumption should change (token lifetimes, user model, blacklist behavior, throttling), update this plan before implementation. diff --git a/uv.lock b/uv.lock index 24e7829..68446ae 100644 --- a/uv.lock +++ b/uv.lock @@ -34,18 +34,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/ba/a6e2992bc5b8c688249c00ea48cb1b7a9bc09839328c81dc603671460928/django-6.0.2-py3-none-any.whl", hash = "sha256:610dd3b13d15ec3f1e1d257caedd751db8033c5ad8ea0e2d1219a8acf446ecc6", size = 8339381, upload-time = "2026-02-03T13:50:15.501Z" }, ] +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + [[package]] name = "dronewars" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, { name = "pydantic" }, ] [package.metadata] requires-dist = [ { name = "django", specifier = ">=6.0.2" }, + { name = "djangorestframework", specifier = ">=3.16.1" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.0" }, { name = "pydantic", specifier = ">=2.12.5" }, ] @@ -117,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5"