Auth API Tech Spec Django 5.x DRF 3.x SimpleJWT PostgreSQL

Technical Specification — Auth Backend (Django REST) for a Sharetribe‑like Marketplace

0) Context & Goal

Backend API to power marketplace authentication and account creation for two primary roles: customer and business. This spec covers only the Auth surface required by the Vue SPA’s Login & Sign‑up flows, with production‑grade security, validation, throttling, email verification, and observability.

1) Stack & Versions

2) Project Structure

backend/
  manage.py
  pyproject.toml
  /config
    __init__.py
    settings.py
    urls.py
    asgi.py
  /apps
    /accounts
      __init__.py
      apps.py
      admin.py
      models.py
      validators.py
      serializers.py
      views.py
      urls.py
      signals.py
      tokens.py
      tasks.py
      tests/
        test_auth_api.py
        test_models.py

3) Data Model

3.1 User

# apps/accounts/models.py (excerpt)
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    username = None  # use email as unique ID
    email = models.EmailField(unique=True, db_index=True)

    class Role(models.TextChoices):
        CUSTOMER = "customer", "Customer"
        BUSINESS = "business", "Business"

    role = models.CharField(max_length=16, choices=Role.choices, default=Role.CUSTOMER)
    email_verified = models.BooleanField(default=False)
    marketing_opt_in = models.BooleanField(default=False)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS: list[str] = []

3.2 BusinessProfile (conditional)

class BusinessProfile(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user = models.OneToOneField("accounts.User", on_delete=models.CASCADE, related_name="business")
    business_name = models.CharField(max_length=120)
    website = models.URLField(blank=True)
    phone_e164 = models.CharField(max_length=20, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Signal on user creation (role=business) to auto‑create BusinessProfile.

4) API Endpoints

Base path: /api/v1. JSON only. All times UTC ISO‑8601.

4.1 POST /auth/register

Request
{
  "role": "customer" | "business",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "email": "ada@example.com",
  "password": "S3cure!Pass",
  "business": {"businessName": "Ada Services", "website": "https://ada.services", "phone": "+12125551212"},
  "acceptTos": true,
  "marketingOptIn": false
}

201 Response
{
  "user": {
    "id": "uuid",
    "role": "customer",
    "email": "ada@example.com",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "emailVerified": false
  },
  "tokens": {"access":"<jwt>","refresh":"<jwt>"},
  "requiresEmailVerification": true
}

Errors: EMAIL_IN_USE, WEAK_PASSWORD, VALIDATION_ERROR

4.2 POST /auth/login

Request
{ "email": "user@example.com", "password": "••••••••" }

200 Response
{ "user": { ... }, "tokens": {"access":"<jwt>","refresh":"<jwt>"} }

401 INVALID_CREDENTIALS (generic)

4.3 POST /auth/token/refresh

{ "refresh": "<jwt>" } → { "access": "<jwt>" }

4.4 POST /auth/logout

{} → 204  (server blacklists/rotates refresh token)

4.5 POST /auth/forgot-password

{ "email": "user@example.com" } → 204 (always)

4.6 POST /auth/reset-password

{ "token": "<opaque>", "password": "NewPass!23" } → 204

Errors: TOKEN_INVALID, WEAK_PASSWORD

4.7 GET /auth/verify-email

Query: ?token=<opaque>  → 302 to success/failure page (for web) or 200 JSON (for SPA fetch)

4.8 GET /me

Auth: Bearer access token → 200 { user profile, role, emailVerified, business? }

4.9 PATCH /me

Partial updates (firstName, lastName, marketingOptIn, business fields if role=business)

Note: Cookie‑based refresh is supported as an alternative deployment (httpOnly, SameSite=Lax). In that mode /auth/token/refresh reads refresh from cookie and returns only access.

5) Security

6) Validation & Business Rules

7) Email & Verification

# apps/accounts/tokens.py (concept)
from itsdangerous import URLSafeTimedSerializer

ts = URLSafeTimedSerializer(settings.SECRET_KEY, salt="email-verify")

# generate: ts.dumps({"uid": str(user.id), "jti": uuid4().hex})
# verify: payload = ts.loads(token, max_age=86400)

8) Rate Limits & Abuse Controls

9) Settings (snippets)

# config/settings.py (excerpts)
AUTH_USER_MODEL = "accounts.User"

REST_FRAMEWORK = {
  "DEFAULT_AUTHENTICATION_CLASSES": (
    "rest_framework_simplejwt.authentication.JWTAuthentication",
  ),
  "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
  "DEFAULT_THROTTLE_CLASSES": [
    "rest_framework.throttling.UserRateThrottle",
    "rest_framework.throttling.AnonRateThrottle",
  ],
  "DEFAULT_THROTTLE_RATES": {
    "user": "1000/day",
    "anon": "200/day",
    "login": "10/min",
  },
}

SIMPLE_JWT = {
  "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
  "REFRESH_TOKEN_LIFETIME": timedelta(days=21),
  "ROTATE_REFRESH_TOKENS": True,
  "BLACKLIST_AFTER_ROTATION": True,
  "AUTH_HEADER_TYPES": ("Bearer",),
  "SIGNING_KEY": env("JWT_SIGNING_KEY"),
}

CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

10) Observability

11) Testing

# apps/accounts/tests/test_auth_api.py (sketch)
def test_register_customer(api_client):
    res = api_client.post("/api/v1/auth/register", { ... }, format="json")
    assert res.status_code == 201
    assert res.data["requiresEmailVerification"] is True

12) Acceptance Criteria

13) Out of Scope (now)

Appendix A — Serializers (sketch)

# apps/accounts/serializers.py (excerpts)
class RegisterSerializer(serializers.Serializer):
    role = serializers.ChoiceField(choices=["customer","business"], default="customer")
    firstName = serializers.CharField(max_length=80)
    lastName = serializers.CharField(max_length=80)
    email = serializers.EmailField()
    password = serializers.CharField(write_only=True)
    acceptTos = serializers.BooleanField()
    marketingOptIn = serializers.BooleanField(required=False, default=False)
    business = BusinessSerializer(required=False)

    def validate_email(self, email):
        if User.objects.filter(email__iexact=email).exists():
            raise serializers.ValidationError("EMAIL_IN_USE")
        return email

    def validate(self, attrs):
        validate_password(attrs["password"])  # uses AUTH_PASSWORD_VALIDATORS
        if not attrs.get("acceptTos"):
            raise serializers.ValidationError({"acceptTos": "REQUIRED"})
        return attrs

    def create(self, data):
        role = data.pop("role")
        business_data = data.pop("business", None)
        user = User.objects.create_user(
            email=data["email"],
            password=data["password"],
            first_name=data["firstName"],
            last_name=data["lastName"],
            role=role,
            marketing_opt_in=data.get("marketingOptIn", False),
        )
        if role == User.Role.BUSINESS and business_data:
            BusinessProfile.objects.create(
                user=user,
                business_name=business_data["businessName"],
                website=business_data.get("website",""),
                phone_e164=business_data.get("phone",""),
            )
        return user

Appendix B — URLs (sketch)

# apps/accounts/urls.py
from django.urls import path
from . import views

urlpatterns = [
  path("auth/register", views.RegisterView.as_view()),
  path("auth/login", views.LoginView.as_view()),  # can proxy SimpleJWT token obtain pair
  path("auth/token/refresh", views.TokenRefreshView.as_view()),
  path("auth/logout", views.LogoutView.as_view()),
  path("auth/forgot-password", views.ForgotPasswordView.as_view()),
  path("auth/reset-password", views.ResetPasswordView.as_view()),
  path("auth/verify-email", views.VerifyEmailView.as_view()),
  path("me", views.MeView.as_view()),
]