register and sign in pages

This commit is contained in:
2026-09-22 00:50:30 +05:00
parent f313867653
commit 78d04f75d1
96 changed files with 6383 additions and 109 deletions
+45
View File
@@ -0,0 +1,45 @@
# =============================================================================
# Django
# =============================================================================
SECRET_KEY="change-me"
DJANGO_DEBUG=True
# Comma-separated. Include the backend service name for the compose network.
ALLOWED_HOSTS="localhost,127.0.0.1,backend"
# Comma-separated absolute origins the browser calls the API from.
CORS_ALLOWED_ORIGINS="http://localhost:5173"
CSRF_TRUSTED_ORIGINS="http://localhost:5173"
# --- only read when DJANGO_DEBUG=False ---
SECURE_SSL_REDIRECT=False
SECURE_HSTS_SECONDS=0
# =============================================================================
# Database (PostgreSQL) - matches the `database` service in compose.yml
# =============================================================================
POSTGRES_DATABASE=sarlink
POSTGRES_USER=sarlink
POSTGRES_PASSWORD=changeme
POSTGRES_HOST=database
POSTGRES_PORT=5432
# =============================================================================
# SMS gateway - login/registration OTP
# Leave empty in dev: codes are written to the log instead of being sent.
# =============================================================================
SMS_API_URL=""
SMS_API_KEY=""
SMS_SENDER="SARLink"
# =============================================================================
# OTP policy
# =============================================================================
OTP_TTL_SECONDS=300
OTP_MAX_ATTEMPTS=5
OTP_RESEND_COOLDOWN_SECONDS=60
# How long a number stays verified for the registration form (30 min).
REGISTRATION_TICKET_TTL_SECONDS=1800
# =============================================================================
# Public frontend base URL (used in SMS links)
# =============================================================================
FRONTEND_URL=http://localhost:5173
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.venv/
venv/
.env
db.sqlite3
/staticfiles/
/media/
.ruff_cache/
+20
View File
@@ -0,0 +1,20 @@
# Development image. Production is built from .build/prod/api.Dockerfile.
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# The source is bind-mounted over this in compose.
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+181
View File
@@ -0,0 +1,181 @@
# backend
Django 5.2 + DRF API for the SAR Link portal.
| | |
|---|---|
| Auth | knox tokens, two-step login (mobile -> password or SMS OTP) |
| Database | PostgreSQL 16 |
| Background tasks | procrastinate (postgres-backed, no broker) |
| Serving | gunicorn + WhiteNoise for `/static/` |
## Layout
```
apibase/ settings, urls, procrastinate app
core/ healthcheck, pagination, unified error shape
users/ custom User (mobile is the identifier), OtpCode, auth endpoints
```
## Run it
From the repo root (`docker compose up` starts backend + database + frontend):
```sh
cp backend/.env.example backend/.env
docker compose up --build
docker compose exec backend python manage.py createsuperuser # asks for a mobile number
```
The API is on `http://localhost:8000`, the admin on `http://localhost:8000/admin/`,
and Swagger (DEBUG only) on `http://localhost:8000/api/docs/`.
Without Docker:
```sh
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
POSTGRES_HOST=localhost python manage.py migrate
POSTGRES_HOST=localhost python manage.py runserver
```
## Tests
`apibase/settings_test.py` turns off throttling and uses a fast password hasher.
It still runs on postgres, because procrastinate's migrations are postgres-only.
```sh
python manage.py test --settings=apibase.settings_test
```
## Authentication
Everything starts with the mobile number. One request decides which second box
the SPA renders:
```
POST /api/auth/start/ {"mobile": "7712345"}
-> {"next": "password", "mobile": "+9607712345"}
the account signs in with a password
-> {"next": "otp", "mobile": ..., "expires_at": ...,
"resend_available_at": ..., "code_length": 6}
a code was sent by SMS
```
**`start` never says whether a number has an account.** Every number that gets
a code gets the same response, so the endpoint can't be used to enumerate
members. That answer comes only after the code is confirmed:
```
POST /api/auth/login/password/ {"mobile", "password"}
-> {"next": "dashboard", "token", "expiry", "user"}
POST /api/auth/verify/ {"mobile", "code"}
-> {"next": "dashboard", "token", "expiry", "user"} the account signs in
-> {"next": "register", "registration_token", "mobile", "expires_at"}
no account: go register
POST /api/auth/otp/resend/ {"mobile"}
GET /api/auth/me/ Authorization: Token <token>
POST /api/auth/logout/ Authorization: Token <token>
```
A disabled account is also only reported at `verify/`, for the same reason.
Which method an account uses is `User.auth_method` (`otp` by default, or
`password`). An account set to `password` with no usable password falls back to
OTP, so nobody gets locked out — see `User.effective_auth_method`.
Numbers are normalised to E.164 (`+960XXXXXXX`) at the serializer, so
`7712345`, `960 771 2345` and `+9607712345` are all the same account.
Codes are 6 digits, stored only as a hash, single-use, valid for
`OTP_TTL_SECONDS` (5 min), at most `OTP_MAX_ATTEMPTS` (5) guesses, with a
`OTP_RESEND_COOLDOWN_SECONDS` (60s) resend cooldown on top of per-IP throttles.
Issuing a new code invalidates the outstanding one.
### SMS
`users/sms.py` posts to the SAR Link gateway:
```
POST {SMS_API_URL} # https://smsapi.sarlink.net/api/sms/send
X-API-Key: {SMS_API_KEY}
{"to": "+9607712345", "text": "..."}
```
With `SMS_API_KEY` empty the message is written to the log instead of being
sent, so every flow works in dev — the code is in the backend log. The real key
belongs in `backend/.env` (gitignored), never in `.env.example`.
### Errors
Every error has the same shape, and the SPA branches on `code`:
```json
{"detail": "That code is not correct.", "code": "invalid_code", "attempts_left": 4}
```
## Registration
`verify/` returns a `registration_token` when the number has no account: proof
that the number was confirmed by SMS, good for
`REGISTRATION_TICKET_TTL_SECONDS` (30 min) and redeemable once. The form then
posts it back:
```
POST /api/auth/register/
{
"registration_token": "...",
"full_name": "Mariyam Ibrahim",
"idnumber": "A123456",
"date_of_birth": "1998-02-11",
"atoll": 1,
"island": 1,
"terms_accepted": true,
"policy_accepted": true
}
-> 201 {"status": "pending", "mobile", "full_name", "detail"}
```
The mobile number is **not** read from the form - it comes from the ticket, so
the account always gets a number the applicant proved they control and the
prefilled field can't be tampered with. Both agreements must be `true`, and the
island must belong to the chosen atoll.
Registering does **not** sign anyone in and does **not** produce a usable
account: the row is created with `status = "pending"`, no password, and
`auth_method = "otp"`. The applicant can sign in with an SMS code to watch the
status, but nothing is provisioned until an admin approves.
### Approving
In the Django admin, filter `status = pending`, review, then use the
**Approve selected registrations** / **Reject selected registrations** actions.
Both record `reviewed_at`/`reviewed_by` and text the applicant. In code:
`user.approve(reviewer=admin)` / `user.reject(reviewer=admin, reason="...")`.
## Locations
The registration form's atoll/island dropdowns come from the database:
```
GET /api/locations/atolls/ # public, islands nested
```
Seed data lives in `locations/seed.py` (currently just Faafu ->
Dharanboodhoo) and is applied by migration `locations/0002_seed_locations`, so
a fresh database has it. To re-apply after editing:
```sh
python manage.py seed_locations # idempotent
```
Anything else is managed in the admin.
## Not built yet
Devices, billing, password self-service, and the RADIUS access-control
integration.
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for apibase project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings')
application = get_asgi_application()
+218
View File
@@ -0,0 +1,218 @@
"""Django settings for the SAR Link portal API."""
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
DJANGO_DEBUG=(bool, True),
ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1", "backend"]),
CORS_ALLOWED_ORIGINS=(list, ["http://localhost:5173"]),
CSRF_TRUSTED_ORIGINS=(list, []),
SECURE_SSL_REDIRECT=(bool, False),
SECURE_HSTS_SECONDS=(int, 0),
SECRET_KEY=(str, "insecure-dev-key-change-me"),
POSTGRES_DATABASE=(str, "sarlink"),
POSTGRES_USER=(str, "sarlink"),
POSTGRES_PASSWORD=(str, "changeme"),
POSTGRES_HOST=(str, "database"),
POSTGRES_PORT=(int, 5432),
SMS_API_URL=(str, "https://smsapi.sarlink.net/api/sms/send"),
SMS_API_KEY=(str, ""),
OTP_TTL_SECONDS=(int, 300),
OTP_MAX_ATTEMPTS=(int, 5),
OTP_RESEND_COOLDOWN_SECONDS=(int, 60),
REGISTRATION_TICKET_TTL_SECONDS=(int, 1800),
FRONTEND_URL=(str, "http://localhost:5173"),
)
environ.Env.read_env(BASE_DIR / ".env")
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
# third party
"rest_framework",
"knox",
"django_filters",
"corsheaders",
"procrastinate.contrib.django",
# applications
"core",
"locations",
"users",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "apibase.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "apibase.wsgi.application"
ASGI_APPLICATION = "apibase.asgi.application"
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("POSTGRES_DATABASE"),
"USER": env("POSTGRES_USER"),
"PASSWORD": env("POSTGRES_PASSWORD"),
"HOST": env("POSTGRES_HOST"),
"PORT": env("POSTGRES_PORT"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# -----------------------------------------------------------------------------
# Auth
# -----------------------------------------------------------------------------
AUTH_USER_MODEL = "users.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Knox: the token itself is only ever returned once, at login.
REST_KNOX = {
"TOKEN_TTL": None, # tokens live until logout
"TOKEN_LIMIT_PER_USER": None,
"AUTO_REFRESH": False,
}
# -----------------------------------------------------------------------------
# DRF
# -----------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ["knox.auth.TokenAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_PAGINATION_CLASS": "core.pagination.DefaultPagination",
"PAGE_SIZE": 25,
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
"DEFAULT_THROTTLE_RATES": {
# Unauthenticated auth endpoints, keyed per IP.
"auth_start": "30/hour",
"auth_login": "20/hour",
"otp_request": "10/hour",
"register": "10/hour",
},
"EXCEPTION_HANDLER": "core.exceptions.exception_handler",
}
if DEBUG:
INSTALLED_APPS.append("drf_spectacular")
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
SPECTACULAR_SETTINGS = {
"TITLE": "SAR Link Portal API",
"VERSION": "2.0.0",
"SERVE_INCLUDE_SCHEMA": False,
}
# -----------------------------------------------------------------------------
# CORS / CSRF - the SPA is served from a different origin in dev
# -----------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = False
CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS")
# -----------------------------------------------------------------------------
# i18n / tz
# -----------------------------------------------------------------------------
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Indian/Maldives"
USE_I18N = True
USE_TZ = True
# -----------------------------------------------------------------------------
# Static / media
# -----------------------------------------------------------------------------
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
},
}
# -----------------------------------------------------------------------------
# Background tasks (procrastinate)
# -----------------------------------------------------------------------------
PROCRASTINATE_APP = "apibase.tasks.app"
# -----------------------------------------------------------------------------
# SMS / OTP
# -----------------------------------------------------------------------------
SMS_API_URL = env("SMS_API_URL")
SMS_API_KEY = env("SMS_API_KEY")
OTP_TTL_SECONDS = env("OTP_TTL_SECONDS")
OTP_MAX_ATTEMPTS = env("OTP_MAX_ATTEMPTS")
OTP_RESEND_COOLDOWN_SECONDS = env("OTP_RESEND_COOLDOWN_SECONDS")
# How long a verified number stays redeemable for the registration form.
REGISTRATION_TICKET_TTL_SECONDS = env("REGISTRATION_TICKET_TTL_SECONDS")
FRONTEND_URL = env("FRONTEND_URL")
# -----------------------------------------------------------------------------
# Production hardening
# -----------------------------------------------------------------------------
if not DEBUG:
SECURE_SSL_REDIRECT = env("SECURE_SSL_REDIRECT")
SECURE_HSTS_SECONDS = env("SECURE_HSTS_SECONDS")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {"format": "{levelname} {asctime} {name} {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "simple"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
+19
View File
@@ -0,0 +1,19 @@
"""Test settings.
Same postgres engine as production (procrastinate's migrations are
postgres-only), with throttling off and a cheap password hasher so the auth
tests aren't dominated by bcrypt.
python manage.py test --settings=apibase.settings_test
"""
from .settings import * # noqa: F401,F403
from .settings import DATABASES, REST_FRAMEWORK, env
DATABASES["default"]["HOST"] = env("POSTGRES_HOST", default="localhost")
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = dict.fromkeys(
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"], None
)
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
+5
View File
@@ -0,0 +1,5 @@
"""Procrastinate app - postgres-backed background tasks, no broker."""
from procrastinate.contrib.django import app
__all__ = ["app"]
+24
View File
@@ -0,0 +1,24 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("core.urls")),
path("api/auth/", include("users.urls")),
path("api/locations/", include("locations.urls")),
]
if settings.DEBUG:
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns += [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for apibase project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings')
application = get_wsgi_application()
+58
View File
@@ -0,0 +1,58 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
hostname: backend
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/app
ports:
- "8000:8000"
env_file:
- path: .env
required: false
environment:
POSTGRES_HOST: database
depends_on:
database:
condition: service_healthy
# Postgres-backed background tasks. Same image, different entrypoint.
worker:
build:
context: .
dockerfile: Dockerfile
command: python manage.py procrastinate worker
volumes:
- .:/app
env_file:
- path: .env
required: false
environment:
POSTGRES_HOST: database
depends_on:
backend:
condition: service_started
database:
image: postgres:16
hostname: database
environment:
POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink}
POSTGRES_USER: ${POSTGRES_USER:-sarlink}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"
+32
View File
@@ -0,0 +1,32 @@
"""A single response shape for every error the API returns.
{"detail": "...", "code": "...", "errors": {"field": ["..."]}}
`errors` is only present for validation failures. The SPA reads `code` to
branch (e.g. `registration_required`) and `detail` to show a message.
"""
from rest_framework import exceptions
from rest_framework.views import exception_handler as drf_exception_handler
def exception_handler(exc, context):
response = drf_exception_handler(exc, context)
if response is None:
return None
code = getattr(exc, "default_code", "error")
data = response.data
if isinstance(exc, exceptions.ValidationError):
detail = "The submitted data was invalid."
if isinstance(data, dict):
non_field = data.get("detail") or data.get("non_field_errors")
if non_field:
detail = non_field[0] if isinstance(non_field, list) else str(non_field)
response.data = {"detail": str(detail), "code": code, "errors": data}
return response
detail = data.get("detail") if isinstance(data, dict) else data
response.data = {"detail": str(detail), "code": code}
return response
+6
View File
@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class DefaultPagination(PageNumberPagination):
page_size_query_param = "page_size"
max_page_size = 100
View File
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import healthcheck
urlpatterns = [
path("health/", healthcheck, name="healthcheck"),
]
+17
View File
@@ -0,0 +1,17 @@
from django.db import connection
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.response import Response
@api_view(["GET"])
@authentication_classes([])
@permission_classes([])
def healthcheck(request):
"""Liveness + database reachability, for compose/nginx health probes."""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
database = "up"
except Exception: # pragma: no cover - reported, not raised
database = "down"
return Response({"status": "ok", "database": database})
+26
View File
@@ -0,0 +1,26 @@
default:
just --list
# --- dev ---
run:
python manage.py runserver 0.0.0.0:8000
worker:
python manage.py procrastinate worker
shell:
python manage.py shell
# --- database ---
migrate:
python manage.py migrate
migrations:
python manage.py makemigrations
superuser:
python manage.py createsuperuser
# --- quality ---
test:
python manage.py test --settings=apibase.settings_test
lint:
ruff check .
fmt:
ruff format .
View File
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Atoll, Island
class IslandInline(admin.TabularInline):
model = Island
extra = 0
@admin.register(Atoll)
class AtollAdmin(admin.ModelAdmin):
list_display = ["name", "code", "is_active"]
search_fields = ["name", "code"]
inlines = [IslandInline]
@admin.register(Island)
class IslandAdmin(admin.ModelAdmin):
list_display = ["name", "atoll", "is_active"]
list_filter = ["atoll", "is_active"]
search_fields = ["name"]
+17
View File
@@ -0,0 +1,17 @@
from rest_framework.generics import ListAPIView
from rest_framework.permissions import AllowAny
from .models import Atoll
from .serializers import AtollSerializer
class AtollListView(ListAPIView):
"""Public: the registration form needs this before anyone has a token."""
authentication_classes = []
permission_classes = [AllowAny]
serializer_class = AtollSerializer
pagination_class = None
def get_queryset(self):
return Atoll.objects.filter(is_active=True).prefetch_related("islands")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "locations"
@@ -0,0 +1,16 @@
from django.core.management.base import BaseCommand
from locations.models import Atoll, Island
from locations.seed import seed
class Command(BaseCommand):
help = "Create the atolls and islands SAR Link serves (idempotent)."
def handle(self, *args, **options):
atolls, islands = seed(Atoll, Island)
self.stdout.write(
self.style.SUCCESS(
f"Locations seeded: {atolls} atoll(s), {islands} island(s) created."
)
)
@@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2026-09-21 19:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Atoll',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('code', models.CharField(blank=True, max_length=8)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Island',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('atoll', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='islands', to='locations.atoll')),
],
options={
'ordering': ['name'],
'constraints': [models.UniqueConstraint(fields=('atoll', 'name'), name='unique_island_name_per_atoll')],
},
),
]
@@ -0,0 +1,32 @@
from django.db import migrations
from locations.seed import seed
def seed_locations(apps, schema_editor):
seed(apps.get_model("locations", "Atoll"), apps.get_model("locations", "Island"))
def unseed_locations(apps, schema_editor):
"""Only removes rows nothing references."""
Atoll = apps.get_model("locations", "Atoll")
Island = apps.get_model("locations", "Island")
from locations.seed import ATOLLS
for entry in ATOLLS:
Island.objects.filter(
atoll__name=entry["name"], name__in=entry["islands"], users__isnull=True
).delete()
Atoll.objects.filter(
name=entry["name"], islands__isnull=True, users__isnull=True
).delete()
class Migration(migrations.Migration):
dependencies = [
("locations", "0001_initial"),
# Islands/atolls are referenced by users; keep the tables in step.
("users", "0001_initial"),
]
operations = [migrations.RunPython(seed_locations, unseed_locations)]
+31
View File
@@ -0,0 +1,31 @@
from django.db import models
class Atoll(models.Model):
name = models.CharField(max_length=100, unique=True)
# Maldivian administrative code, e.g. "F" for Faafu.
code = models.CharField(max_length=8, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Island(models.Model):
atoll = models.ForeignKey(Atoll, on_delete=models.PROTECT, related_name="islands")
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["atoll", "name"], name="unique_island_name_per_atoll"
)
]
def __str__(self):
return f"{self.name}, {self.atoll.name}"
+37
View File
@@ -0,0 +1,37 @@
"""Seed data for atolls and islands.
Only the areas SAR Link actually serves are listed. Add more here (or in the
admin) as coverage grows; `seed()` is idempotent, so re-running is safe.
"""
ATOLLS = [
{
"name": "Faafu",
"code": "F",
"islands": ["Dharanboodhoo"],
},
]
def seed(atoll_model, island_model) -> tuple[int, int]:
"""Create any missing atolls/islands. Returns (atolls, islands) created.
Takes the models as arguments so both the management command and the data
migration can call it, the latter with historical models.
"""
atolls_created = 0
islands_created = 0
for entry in ATOLLS:
atoll, created = atoll_model.objects.get_or_create(
name=entry["name"], defaults={"code": entry.get("code", "")}
)
atolls_created += int(created)
for island_name in entry["islands"]:
_, created = island_model.objects.get_or_create(
atoll=atoll, name=island_name
)
islands_created += int(created)
return atolls_created, islands_created
+23
View File
@@ -0,0 +1,23 @@
from rest_framework import serializers
from .models import Atoll, Island
class IslandSerializer(serializers.ModelSerializer):
class Meta:
model = Island
fields = ["id", "name", "atoll"]
class AtollSerializer(serializers.ModelSerializer):
"""Atolls with their islands nested - the registration form needs both."""
islands = serializers.SerializerMethodField()
class Meta:
model = Atoll
fields = ["id", "name", "code", "islands"]
def get_islands(self, obj) -> list[dict]:
islands = [island for island in obj.islands.all() if island.is_active]
return IslandSerializer(islands, many=True).data
View File
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .api import AtollListView
urlpatterns = [
path("atolls/", AtollListView.as_view(), name="atoll-list"),
]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+11
View File
@@ -0,0 +1,11 @@
[tool.ruff]
line-length = 90
target-version = "py312"
exclude = [".venv", "*/migrations/*"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "DJ"]
ignore = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["apibase", "core", "users"]
+26
View File
@@ -0,0 +1,26 @@
# --- core ---
django==5.2.7
djangorestframework==3.16.1
django-rest-knox==5.0.2
django-filter==25.2
django-cors-headers==4.9.0
django-environ==0.12.0
# --- database ---
psycopg[binary]==3.2.10
# --- background tasks (postgres-backed, no broker) ---
procrastinate[django]==3.6.0
# --- serving / static ---
gunicorn==23.0.0
whitenoise==6.11.0
# --- integrations ---
requests==2.32.5
# --- api docs (DEBUG only) ---
drf-spectacular==0.28.0
# --- dev tooling ---
ruff==0.14.0
View File
+141
View File
@@ -0,0 +1,141 @@
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import OtpCode, RegistrationTicket, User
from .sms import send_registration_approved, send_registration_rejected
@admin.register(User)
class UserAdmin(BaseUserAdmin):
ordering = ["-date_joined"]
list_display = [
"mobile",
"full_name",
"status",
"idnumber",
"island",
"auth_method",
"date_joined",
]
list_filter = ["status", "auth_method", "atoll", "is_staff", "is_active"]
search_fields = ["mobile", "full_name", "email", "idnumber"]
readonly_fields = [
"date_joined",
"updated_at",
"last_login",
"terms_accepted_at",
"policy_accepted_at",
"reviewed_at",
"reviewed_by",
]
autocomplete_fields = ["atoll", "island"]
actions = ["approve_registrations", "reject_registrations"]
fieldsets = [
(None, {"fields": ["mobile", "password"]}),
(
"Applicant",
{
"fields": [
"full_name",
"email",
"idnumber",
"date_of_birth",
"atoll",
"island",
]
},
),
(
"Registration review",
{
"fields": [
"status",
"rejection_reason",
"terms_accepted_at",
"policy_accepted_at",
"reviewed_at",
"reviewed_by",
]
},
),
("Portal", {"fields": ["auth_method", "mobile_verified"]}),
(
"Permissions",
{
"fields": [
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
]
},
),
("Dates", {"fields": ["last_login", "date_joined", "updated_at"]}),
]
add_fieldsets = [
(
None,
{
"classes": ["wide"],
"fields": [
"mobile",
"full_name",
"auth_method",
"password1",
"password2",
],
},
),
]
@admin.action(description="Approve selected registrations")
def approve_registrations(self, request, queryset):
pending = queryset.exclude(status=User.Status.APPROVED)
for user in pending:
user.approve(reviewer=request.user)
send_registration_approved(user.mobile)
self.message_user(
request, f"Approved {pending.count()} registration(s).", messages.SUCCESS
)
@admin.action(description="Reject selected registrations")
def reject_registrations(self, request, queryset):
pending = queryset.exclude(status=User.Status.REJECTED)
for user in pending:
user.reject(reviewer=request.user)
send_registration_rejected(user.mobile, user.rejection_reason)
self.message_user(
request, f"Rejected {pending.count()} registration(s).", messages.WARNING
)
@admin.register(OtpCode)
class OtpCodeAdmin(admin.ModelAdmin):
list_display = [
"mobile",
"purpose",
"attempts",
"created_at",
"expires_at",
"consumed_at",
]
list_filter = ["purpose"]
search_fields = ["mobile"]
readonly_fields = [
"user",
"mobile",
"purpose",
"code_hash",
"attempts",
"created_at",
"expires_at",
"consumed_at",
]
@admin.register(RegistrationTicket)
class RegistrationTicketAdmin(admin.ModelAdmin):
list_display = ["mobile", "created_at", "expires_at", "consumed_at"]
search_fields = ["mobile"]
readonly_fields = ["key", "mobile", "created_at", "expires_at", "consumed_at"]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
+38
View File
@@ -0,0 +1,38 @@
from django.contrib.auth.models import BaseUserManager
from .mobile import normalize_mobile
class UserManager(BaseUserManager):
"""Users are identified by mobile number, not username."""
use_in_migrations = True
def _create_user(self, mobile, password=None, **extra_fields):
if not mobile:
raise ValueError("A mobile number is required.")
user = self.model(mobile=normalize_mobile(mobile), **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_user(self, mobile, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(mobile, password, **extra_fields)
def create_superuser(self, mobile, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("status", self.model.Status.APPROVED)
extra_fields.setdefault("auth_method", self.model.AuthMethod.PASSWORD)
if not extra_fields["is_staff"] or not extra_fields["is_superuser"]:
raise ValueError("Superusers must have is_staff and is_superuser set.")
return self._create_user(mobile, password, **extra_fields)
def get_by_natural_key(self, username):
return self.get(mobile=normalize_mobile(username))
+89
View File
@@ -0,0 +1,89 @@
# Generated by Django 5.2.7 on 2026-09-21 19:05
import django.db.models.deletion
import django.utils.timezone
import users.managers
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('locations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='RegistrationTicket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=64, unique=True)),
('mobile', models.CharField(db_index=True, max_length=16)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField()),
('consumed_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('mobile', models.CharField(db_index=True, max_length=16, unique=True)),
('full_name', models.CharField(blank=True, max_length=255)),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
('document_type', models.CharField(choices=[('id_card', 'ID card'), ('passport', 'Passport'), ('work_permit', 'Work permit')], default='id_card', max_length=16)),
('document_number', models.CharField(blank=True, db_index=True, max_length=32)),
('date_of_birth', models.DateField(blank=True, null=True)),
('auth_method', models.CharField(choices=[('otp', 'SMS one-time code'), ('password', 'Password')], default='otp', max_length=16)),
('mobile_verified', models.BooleanField(default=False)),
('status', models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
('terms_accepted_at', models.DateTimeField(blank=True, null=True)),
('policy_accepted_at', models.DateTimeField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('rejection_reason', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
('updated_at', models.DateTimeField(auto_now=True)),
('atoll', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.atoll')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('island', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.island')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_users', to=settings.AUTH_USER_MODEL)),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'ordering': ['-date_joined'],
},
managers=[
('objects', users.managers.UserManager()),
],
),
migrations.CreateModel(
name='OtpCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mobile', models.CharField(db_index=True, max_length=16)),
('purpose', models.CharField(choices=[('login', 'Login'), ('registration', 'Registration')], default='login', max_length=16)),
('code_hash', models.CharField(max_length=128)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField()),
('consumed_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='otp_codes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['mobile', 'purpose', '-created_at'], name='users_otpco_mobile_0ef94c_idx')],
},
),
]
+14
View File
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial"),
]
operations = [
migrations.RemoveField(model_name="user", name="document_type"),
migrations.RenameField(
model_name="user", old_name="document_number", new_name="idnumber"
),
]
+29
View File
@@ -0,0 +1,29 @@
"""Maldives mobile number normalisation.
Everything past the serializer layer deals in E.164 (`+960XXXXXXX`) so a
number is stored and looked up exactly one way.
"""
import re
from django.core.exceptions import ValidationError
COUNTRY_CODE = "960"
LOCAL_LENGTH = 7
# Maldives mobile prefixes are 7xx and 9xx.
LOCAL_RE = re.compile(r"^[79]\d{6}$")
def normalize_mobile(value: str) -> str:
"""Return `value` as +960XXXXXXX, or raise ValidationError."""
digits = re.sub(r"[\s()-]", "", str(value or "")).lstrip("+")
if digits.startswith("00" + COUNTRY_CODE):
digits = digits[len("00" + COUNTRY_CODE) :]
elif digits.startswith(COUNTRY_CODE) and len(digits) > LOCAL_LENGTH:
digits = digits[len(COUNTRY_CODE) :]
if not LOCAL_RE.match(digits):
raise ValidationError("Enter a valid Maldives mobile number.")
return f"+{COUNTRY_CODE}{digits}"
+279
View File
@@ -0,0 +1,279 @@
import secrets
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from django.utils import timezone
from .managers import UserManager
from .mobile import normalize_mobile
class User(AbstractBaseUser, PermissionsMixin):
"""Portal account. The mobile number is the login identifier.
A self-registered account starts at `status = PENDING` and only becomes
usable once an admin approves it - see `approve()` / `reject()`.
"""
class AuthMethod(models.TextChoices):
OTP = "otp", "SMS one-time code"
PASSWORD = "password", "Password"
class Status(models.TextChoices):
PENDING = "pending", "Pending approval"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
mobile = models.CharField(max_length=16, unique=True, db_index=True)
full_name = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True)
# ID card, passport or work permit number.
idnumber = models.CharField(max_length=32, blank=True, db_index=True)
date_of_birth = models.DateField(null=True, blank=True)
# Address
atoll = models.ForeignKey(
"locations.Atoll",
on_delete=models.PROTECT,
related_name="users",
null=True,
blank=True,
)
island = models.ForeignKey(
"locations.Island",
on_delete=models.PROTECT,
related_name="users",
null=True,
blank=True,
)
# Which second step /auth/start/ asks the SPA to render.
auth_method = models.CharField(
max_length=16, choices=AuthMethod.choices, default=AuthMethod.OTP
)
mobile_verified = models.BooleanField(default=False)
# Registration review
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
)
terms_accepted_at = models.DateTimeField(null=True, blank=True)
policy_accepted_at = models.DateTimeField(null=True, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="reviewed_users",
null=True,
blank=True,
)
rejection_reason = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
date_joined = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "mobile"
REQUIRED_FIELDS = []
class Meta:
ordering = ["-date_joined"]
def __str__(self):
return f"{self.full_name or 'Unnamed'} ({self.mobile})"
def save(self, *args, **kwargs):
self.mobile = normalize_mobile(self.mobile)
super().save(*args, **kwargs)
@property
def is_admin_user(self) -> bool:
"""Admin surface gate: staff or superuser."""
return self.is_staff or self.is_superuser
@property
def is_approved(self) -> bool:
return self.status == self.Status.APPROVED
def can_use_password_login(self) -> bool:
return (
self.auth_method == self.AuthMethod.PASSWORD and self.has_usable_password()
)
@property
def effective_auth_method(self) -> str:
"""`auth_method`, falling back to OTP if no password is actually set."""
if self.can_use_password_login():
return self.AuthMethod.PASSWORD
return self.AuthMethod.OTP
@property
def address(self) -> str:
parts = [part for part in [self.island_id and self.island.name, self.atoll_id and self.atoll.name] if part]
return ", ".join(parts)
def approve(self, reviewer=None) -> None:
self.status = self.Status.APPROVED
self.rejection_reason = ""
self.reviewed_at = timezone.now()
self.reviewed_by = reviewer
self.save(
update_fields=[
"status",
"rejection_reason",
"reviewed_at",
"reviewed_by",
"updated_at",
]
)
def reject(self, reviewer=None, reason: str = "") -> None:
self.status = self.Status.REJECTED
self.rejection_reason = reason
self.reviewed_at = timezone.now()
self.reviewed_by = reviewer
self.save(
update_fields=[
"status",
"rejection_reason",
"reviewed_at",
"reviewed_by",
"updated_at",
]
)
def _generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
class OtpCodeQuerySet(models.QuerySet):
def active(self):
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
class OtpCode(models.Model):
"""A single-use SMS code. Only the hash of the code is stored."""
class Purpose(models.TextChoices):
LOGIN = "login", "Login"
REGISTRATION = "registration", "Registration"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="otp_codes",
null=True,
blank=True,
)
mobile = models.CharField(max_length=16, db_index=True)
purpose = models.CharField(
max_length=16, choices=Purpose.choices, default=Purpose.LOGIN
)
code_hash = models.CharField(max_length=128)
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
consumed_at = models.DateTimeField(null=True, blank=True)
objects = OtpCodeQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["mobile", "purpose", "-created_at"])]
def __str__(self):
return f"{self.purpose} code for {self.mobile}"
@classmethod
def issue(cls, mobile: str, purpose: str, user=None) -> tuple["OtpCode", str]:
"""Invalidate any outstanding codes and return (row, plaintext code)."""
cls.objects.filter(
mobile=mobile, purpose=purpose, consumed_at__isnull=True
).update(consumed_at=timezone.now())
code = _generate_code()
otp = cls.objects.create(
user=user,
mobile=mobile,
purpose=purpose,
code_hash=make_password(code),
expires_at=timezone.now() + timedelta(seconds=settings.OTP_TTL_SECONDS),
)
return otp, code
@property
def is_expired(self) -> bool:
return self.expires_at <= timezone.now()
@property
def is_exhausted(self) -> bool:
return self.attempts >= settings.OTP_MAX_ATTEMPTS
@property
def resend_available_at(self):
return self.created_at + timedelta(
seconds=settings.OTP_RESEND_COOLDOWN_SECONDS
)
def verify(self, code: str) -> bool:
"""Check `code`, counting the attempt. Consumes the row on success."""
self.attempts += 1
if check_password(str(code), self.code_hash):
self.consumed_at = timezone.now()
self.save(update_fields=["attempts", "consumed_at"])
return True
self.save(update_fields=["attempts"])
return False
class RegistrationTicketQuerySet(models.QuerySet):
def active(self):
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
class RegistrationTicket(models.Model):
"""Proof that a mobile number was verified by SMS, redeemable once.
Issued when a registration code is confirmed and required by the
registration submit, so the form can't be posted for a number the caller
never proved they control.
"""
key = models.CharField(max_length=64, unique=True, db_index=True)
mobile = models.CharField(max_length=16, db_index=True)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
consumed_at = models.DateTimeField(null=True, blank=True)
objects = RegistrationTicketQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"registration ticket for {self.mobile}"
@classmethod
def issue(cls, mobile: str) -> "RegistrationTicket":
cls.objects.filter(mobile=mobile, consumed_at__isnull=True).update(
consumed_at=timezone.now()
)
return cls.objects.create(
key=secrets.token_urlsafe(32),
mobile=mobile,
expires_at=timezone.now()
+ timedelta(seconds=settings.REGISTRATION_TICKET_TTL_SECONDS),
)
def consume(self) -> None:
self.consumed_at = timezone.now()
self.save(update_fields=["consumed_at"])
+170
View File
@@ -0,0 +1,170 @@
from datetime import date
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
from locations.models import Atoll, Island
from .mobile import normalize_mobile
from .models import RegistrationTicket, User
MAX_AGE_YEARS = 120
class MobileField(serializers.CharField):
"""Accepts 7712345 / 9607712345 / +960 771 2345 and stores +9607712345."""
def to_internal_value(self, data):
value = super().to_internal_value(data)
try:
return normalize_mobile(value)
except DjangoValidationError as exc:
raise serializers.ValidationError(exc.messages) from exc
class AuthStartSerializer(serializers.Serializer):
mobile = MobileField()
class PasswordLoginSerializer(serializers.Serializer):
mobile = MobileField()
password = serializers.CharField(trim_whitespace=False, write_only=True)
class OtpVerifySerializer(serializers.Serializer):
mobile = MobileField()
code = serializers.RegexField(r"^\d{6}$", write_only=True)
class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.BooleanField(source="is_admin_user", read_only=True)
has_password = serializers.SerializerMethodField()
atoll_name = serializers.CharField(source="atoll.name", default=None, read_only=True)
island_name = serializers.CharField(
source="island.name", default=None, read_only=True
)
class Meta:
model = User
fields = [
"id",
"mobile",
"full_name",
"email",
"idnumber",
"date_of_birth",
"atoll",
"atoll_name",
"island",
"island_name",
"auth_method",
"status",
"rejection_reason",
"mobile_verified",
"is_admin",
"has_password",
"date_joined",
]
read_only_fields = fields
def get_has_password(self, obj) -> bool:
return obj.has_usable_password()
class RegistrationSerializer(serializers.Serializer):
"""The registration form.
The mobile number is not accepted from the client: it comes from the
`registration_token`, which is only issued after that number confirmed an
SMS code. So the number on the account is always one the applicant proved
they control, and the form's prefilled field can't be tampered with.
"""
registration_token = serializers.CharField(write_only=True)
full_name = serializers.CharField(max_length=255)
idnumber = serializers.CharField(max_length=32)
date_of_birth = serializers.DateField()
atoll = serializers.PrimaryKeyRelatedField(
queryset=Atoll.objects.filter(is_active=True)
)
island = serializers.PrimaryKeyRelatedField(
queryset=Island.objects.filter(is_active=True)
)
terms_accepted = serializers.BooleanField()
policy_accepted = serializers.BooleanField()
def validate_registration_token(self, value):
ticket = RegistrationTicket.objects.active().filter(key=value).first()
if ticket is None:
raise serializers.ValidationError(
"Your number needs to be verified again."
)
return ticket
def validate_full_name(self, value):
name = " ".join(value.split())
if len(name) < 3:
raise serializers.ValidationError("Enter your full name.")
return name
def validate_idnumber(self, value):
return value.strip().upper()
def validate_date_of_birth(self, value):
today = date.today()
if value > today:
raise serializers.ValidationError("Date of birth can't be in the future.")
if value.year < today.year - MAX_AGE_YEARS:
raise serializers.ValidationError("Enter a valid date of birth.")
return value
def validate_terms_accepted(self, value):
if not value:
raise serializers.ValidationError(
"You must agree to the terms and conditions."
)
return value
def validate_policy_accepted(self, value):
if not value:
raise serializers.ValidationError(
"You must confirm you understand the privacy policy."
)
return value
def validate(self, attrs):
island = attrs["island"]
if island.atoll_id != attrs["atoll"].pk:
raise serializers.ValidationError(
{"island": "That island isn't in the selected atoll."}
)
ticket = attrs["registration_token"]
if User.objects.filter(mobile=ticket.mobile).exists():
raise serializers.ValidationError(
{"mobile": "An account already exists for this number."}
)
return attrs
@transaction.atomic
def create(self, validated_data):
ticket = validated_data["registration_token"]
now = timezone.now()
user = User.objects.create_user(
mobile=ticket.mobile,
full_name=validated_data["full_name"],
idnumber=validated_data["idnumber"],
date_of_birth=validated_data["date_of_birth"],
atoll=validated_data["atoll"],
island=validated_data["island"],
auth_method=User.AuthMethod.OTP,
status=User.Status.PENDING,
mobile_verified=True,
terms_accepted_at=now,
policy_accepted_at=now,
)
ticket.consume()
return user
+73
View File
@@ -0,0 +1,73 @@
"""SMS delivery via the SAR Link SMS gateway.
POST {SMS_API_URL}
X-API-Key: {SMS_API_KEY}
{"to": "+9607712345", "text": "..."}
With `SMS_API_URL`/`SMS_API_KEY` unset (dev) nothing is sent and the message is
logged instead, so the OTP flows stay usable without the gateway.
"""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def send_sms(mobile: str, text: str) -> bool:
if not settings.SMS_API_URL or not settings.SMS_API_KEY:
logger.warning("SMS not configured; would send to %s: %s", mobile, text)
return False
try:
response = requests.post(
settings.SMS_API_URL,
json={"to": mobile, "text": text},
headers={
"X-API-Key": settings.SMS_API_KEY,
"Content-Type": "application/json",
},
timeout=TIMEOUT_SECONDS,
)
response.raise_for_status()
except requests.RequestException:
logger.exception("Failed to send SMS to %s", mobile)
return False
logger.info("Sent SMS to %s", mobile)
return True
def send_otp(mobile: str, code: str, purpose: str) -> bool:
minutes = max(1, settings.OTP_TTL_SECONDS // 60)
what = "registration" if purpose == "registration" else "login"
return send_sms(
mobile,
f"{code} is your SAR Link {what} code. It expires in {minutes} minutes.",
)
def send_registration_submitted(mobile: str) -> bool:
return send_sms(
mobile,
"Thanks for registering with SAR Link. Your application is being "
"reviewed and we'll text you once it's approved.",
)
def send_registration_approved(mobile: str) -> bool:
return send_sms(
mobile,
f"Your SAR Link registration is approved. Sign in at {settings.FRONTEND_URL}",
)
def send_registration_rejected(mobile: str, reason: str = "") -> bool:
tail = f" Reason: {reason}" if reason else ""
return send_sms(
mobile, f"Your SAR Link registration could not be approved.{tail}"
)
View File
+185
View File
@@ -0,0 +1,185 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from users.models import OtpCode, User
class AuthFlowTests(TestCase):
def setUp(self):
self.client = APIClient()
self.otp_user = User.objects.create_user(mobile="7712345", full_name="Otp User")
self.password_user = User.objects.create_user(
mobile="7798765",
full_name="Password User",
password="correct-horse-battery",
auth_method=User.AuthMethod.PASSWORD,
)
# --- step 1 ---------------------------------------------------------
def test_start_reports_password_method(self):
response = self.client.post(reverse("auth-start"), {"mobile": "7798765"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "password")
self.assertFalse(OtpCode.objects.exists())
def test_start_sends_a_code_for_otp_accounts(self):
response = self.client.post(reverse("auth-start"), {"mobile": "771 2345"})
self.assertEqual(response.data["next"], "otp")
self.assertEqual(response.data["mobile"], "+9607712345")
self.assertEqual(OtpCode.objects.filter(user=self.otp_user).count(), 1)
def test_start_does_not_say_whether_a_number_has_an_account(self):
known = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
unknown = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(unknown.status_code, known.status_code)
self.assertEqual(set(unknown.data), set(known.data))
self.assertEqual(unknown.data["next"], "otp")
def test_start_never_returns_a_masked_number(self):
for mobile in ["7798765", "7712345", "7700000"]:
with self.subTest(mobile=mobile):
response = self.client.post(reverse("auth-start"), {"mobile": mobile})
self.assertNotIn("mobile_masked", response.data)
def test_start_falls_back_to_a_code_when_no_password_is_set(self):
self.otp_user.auth_method = User.AuthMethod.PASSWORD
self.otp_user.save(update_fields=["auth_method"])
response = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
self.assertEqual(response.data["next"], "otp")
def test_start_rejects_a_malformed_number(self):
response = self.client.post(reverse("auth-start"), {"mobile": "123"})
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid")
self.assertIn("mobile", response.data["errors"])
# --- step 2: password ----------------------------------------------
def test_password_login_returns_a_token(self):
response = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "correct-horse-battery"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertIn("token", response.data)
self.assertEqual(response.data["user"]["mobile"], "+9607798765")
def test_password_login_rejects_a_wrong_password(self):
response = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "nope"},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_credentials")
# --- step 2: code ---------------------------------------------------
def _issue_code(self, user=None):
target = user or self.otp_user
return OtpCode.issue(
mobile=target.mobile, purpose=OtpCode.Purpose.LOGIN, user=target
)
def test_verifying_a_code_signs_the_account_in(self):
_, code = self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertIn("token", response.data)
self.otp_user.refresh_from_db()
self.assertTrue(self.otp_user.mobile_verified)
def test_a_code_works_only_once(self):
_, code = self._issue_code()
self.client.post(reverse("auth-verify"), {"mobile": "7712345", "code": code})
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "code_expired")
def test_wrong_code_counts_attempts(self):
self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_code")
self.assertEqual(response.data["attempts_left"], 4)
def test_code_is_exhausted_after_max_attempts(self):
self._issue_code()
for _ in range(5):
self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
self.assertEqual(response.status_code, 429)
self.assertEqual(response.data["code"], "code_exhausted")
def test_issuing_a_new_code_invalidates_the_previous_one(self):
_, first = self._issue_code()
_, second = self._issue_code()
self.assertEqual(OtpCode.objects.active().count(), 1)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": first}
)
self.assertEqual(response.status_code, 400)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": second}
)
self.assertEqual(response.status_code, 200)
def test_a_disabled_account_is_told_only_after_verifying(self):
self.otp_user.is_active = False
self.otp_user.save(update_fields=["is_active"])
start = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
self.assertEqual(start.data["next"], "otp")
_, code = self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["code"], "account_disabled")
# --- resend ----------------------------------------------------------
def test_resend_is_rate_limited_by_the_cooldown(self):
self.client.post(reverse("auth-start"), {"mobile": "7712345"})
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7712345"})
self.assertEqual(response.status_code, 429)
self.assertEqual(response.data["code"], "resend_cooldown")
def test_resend_works_for_a_number_without_an_account(self):
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7700000"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertTrue(OtpCode.objects.filter(mobile="+9607700000").exists())
def test_resend_does_not_reveal_that_an_account_uses_a_password(self):
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7798765"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertFalse(OtpCode.objects.exists())
# --- session --------------------------------------------------------
def test_me_requires_a_token(self):
self.assertEqual(self.client.get(reverse("auth-me")).status_code, 401)
def test_me_returns_the_signed_in_account(self):
login = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "correct-horse-battery"},
)
self.client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}")
response = self.client.get(reverse("auth-me"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["mobile"], "+9607798765")
self.assertFalse(response.data["is_admin"])
+17
View File
@@ -0,0 +1,17 @@
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from users.mobile import normalize_mobile
class NormalizeMobileTests(SimpleTestCase):
def test_accepts_every_way_a_number_is_typed(self):
for value in ["7712345", "+9607712345", "9607712345", "960 771 2345", "009607712345", "771-2345"]:
with self.subTest(value=value):
self.assertEqual(normalize_mobile(value), "+9607712345")
def test_rejects_invalid_numbers(self):
for value in ["", "123", "1712345", "77123456", "abcdefg", None]:
with self.subTest(value=value):
with self.assertRaises(ValidationError):
normalize_mobile(value)
+250
View File
@@ -0,0 +1,250 @@
from datetime import date, timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from locations.models import Atoll, Island
from users.models import OtpCode, RegistrationTicket, User
class RegistrationFlowTests(TestCase):
def setUp(self):
self.client = APIClient()
self.atoll = Atoll.objects.get(name="Faafu")
self.island = Island.objects.get(name="Dharanboodhoo")
self.other_atoll = Atoll.objects.create(name="Kaafu", code="K")
self.other_island = Island.objects.create(atoll=self.other_atoll, name="Male")
# --- verifying the number -------------------------------------------
def test_unknown_number_gets_a_code_without_being_told_anything(self):
response = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertEqual(response.data["mobile"], "+9607700000")
otp = OtpCode.objects.get(mobile="+9607700000")
self.assertEqual(otp.purpose, OtpCode.Purpose.REGISTRATION)
self.assertIsNone(otp.user)
def test_verifying_an_unknown_number_hands_back_a_ticket(self):
code = self._send_registration_code("7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "register")
self.assertTrue(response.data["registration_token"])
self.assertEqual(response.data["mobile"], "+9607700000")
self.assertNotIn("token", response.data)
def test_verify_rejects_a_wrong_code(self):
self._send_registration_code("7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": "000000"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_code")
self.assertFalse(RegistrationTicket.objects.exists())
def test_a_code_signs_in_an_account_created_in_the_meantime(self):
code = self._send_registration_code("7700000")
User.objects.create_user(mobile="7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertFalse(RegistrationTicket.objects.exists())
# --- the form -------------------------------------------------------
def test_registration_creates_a_pending_account(self):
token = self._verified_token("7700000")
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["status"], "pending")
# No token: registering does not sign anyone in.
self.assertNotIn("token", response.data)
user = User.objects.get(mobile="+9607700000")
self.assertEqual(user.status, User.Status.PENDING)
self.assertEqual(user.full_name, "Ahmed Ali")
self.assertEqual(user.idnumber, "WP123456")
self.assertEqual(user.date_of_birth, date(1995, 4, 17))
self.assertEqual(user.island, self.island)
self.assertTrue(user.mobile_verified)
self.assertFalse(user.has_usable_password())
self.assertIsNotNone(user.terms_accepted_at)
self.assertIsNotNone(user.policy_accepted_at)
def test_the_number_comes_from_the_token_not_the_form(self):
token = self._verified_token("7700000")
payload = self._form(token) | {"mobile": "7711111"}
self.client.post(reverse("auth-register"), payload)
self.assertTrue(User.objects.filter(mobile="+9607700000").exists())
self.assertFalse(User.objects.filter(mobile="+9607711111").exists())
def test_a_token_works_only_once(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
User.objects.all().delete()
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_an_expired_token_is_refused(self):
token = self._verified_token("7700000")
RegistrationTicket.objects.filter(key=token).update(
expires_at=timezone.now() - timedelta(minutes=1)
)
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_registration_requires_a_token(self):
payload = self._form("nope")
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_both_agreements_are_required(self):
for field in ["terms_accepted", "policy_accepted"]:
with self.subTest(field=field):
token = self._verified_token("7700000")
payload = self._form(token) | {field: False}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn(field, response.data["errors"])
self.assertFalse(User.objects.exists())
def test_island_must_belong_to_the_selected_atoll(self):
token = self._verified_token("7700000")
payload = self._form(token) | {"island": self.other_island.pk}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("island", response.data["errors"])
def test_required_fields(self):
token = self._verified_token("7700000")
response = self.client.post(
reverse("auth-register"), {"registration_token": token}
)
self.assertEqual(response.status_code, 400)
for field in [
"full_name",
"idnumber",
"date_of_birth",
"atoll",
"island",
]:
self.assertIn(field, response.data["errors"])
def test_future_date_of_birth_is_refused(self):
token = self._verified_token("7700000")
payload = self._form(token) | {
"date_of_birth": (date.today() + timedelta(days=1)).isoformat()
}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("date_of_birth", response.data["errors"])
def test_cannot_register_a_number_that_already_has_an_account(self):
token = self._verified_token("7700000")
User.objects.create_user(mobile="7700000")
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("mobile", response.data["errors"])
# --- after submitting ------------------------------------------------
def test_a_pending_applicant_can_sign_in_and_see_their_status(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
start = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(start.data["next"], "otp")
otp = OtpCode.objects.active().filter(mobile="+9607700000").first()
self.assertIsNotNone(otp)
self.assertEqual(otp.purpose, OtpCode.Purpose.LOGIN)
_, code = OtpCode.issue(
mobile="+9607700000",
purpose=OtpCode.Purpose.LOGIN,
user=User.objects.get(mobile="+9607700000"),
)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertEqual(response.data["user"]["status"], "pending")
def test_approval_flips_the_status_and_records_the_reviewer(self):
admin = User.objects.create_superuser(mobile="7711111", password="x")
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
user = User.objects.get(mobile="+9607700000")
user.approve(reviewer=admin)
user.refresh_from_db()
self.assertEqual(user.status, User.Status.APPROVED)
self.assertTrue(user.is_approved)
self.assertEqual(user.reviewed_by, admin)
self.assertIsNotNone(user.reviewed_at)
def test_rejection_records_the_reason(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
user = User.objects.get(mobile="+9607700000")
user.reject(reason="Document unreadable")
user.refresh_from_db()
self.assertEqual(user.status, User.Status.REJECTED)
self.assertEqual(user.rejection_reason, "Document unreadable")
# --- helpers ---------------------------------------------------------
def _send_registration_code(self, mobile: str) -> str:
otp, code = OtpCode.issue(
mobile=f"+960{mobile}", purpose=OtpCode.Purpose.REGISTRATION
)
self.assertIsNotNone(otp)
return code
def _verified_token(self, mobile: str) -> str:
code = self._send_registration_code(mobile)
response = self.client.post(
reverse("auth-verify"), {"mobile": mobile, "code": code}
)
self.assertEqual(response.status_code, 200, response.data)
return response.data["registration_token"]
def _form(self, token: str) -> dict:
return {
"registration_token": token,
"full_name": "Ahmed Ali",
"idnumber": "wp123456",
"date_of_birth": "1995-04-17",
"atoll": self.atoll.pk,
"island": self.island.pk,
"terms_accepted": True,
"policy_accepted": True,
}
class LocationsApiTests(TestCase):
def test_atolls_are_public_and_include_islands(self):
response = APIClient().get(reverse("atoll-list"))
self.assertEqual(response.status_code, 200)
names = {atoll["name"]: atoll for atoll in response.data}
self.assertIn("Faafu", names)
self.assertEqual(
[island["name"] for island in names["Faafu"]["islands"]],
["Dharanboodhoo"],
)
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from knox import views as knox_views
from .views import (
AuthStartView,
MeView,
OtpResendView,
PasswordLoginView,
RegisterView,
VerifyCodeView,
)
urlpatterns = [
path("start/", AuthStartView.as_view(), name="auth-start"),
path("login/password/", PasswordLoginView.as_view(), name="auth-login-password"),
path("verify/", VerifyCodeView.as_view(), name="auth-verify"),
path("otp/resend/", OtpResendView.as_view(), name="auth-otp-resend"),
path("register/", RegisterView.as_view(), name="auth-register"),
path("me/", MeView.as_view(), name="auth-me"),
path("logout/", knox_views.LogoutView.as_view(), name="auth-logout"),
path("logout-all/", knox_views.LogoutAllView.as_view(), name="auth-logout-all"),
]
+262
View File
@@ -0,0 +1,262 @@
"""Authentication and registration.
1. POST /api/auth/start/ {mobile}
-> {"next": "password"} the account signs in with a password
-> {"next": "otp", ...} a code was sent by SMS
2. POST /api/auth/login/password/ {mobile, password}
-> {"next": "dashboard", "token", "expiry", "user"}
POST /api/auth/verify/ {mobile, code}
-> {"next": "dashboard", "token", "expiry", "user"}
-> {"next": "register", "registration_token", "mobile", "expires_at"}
3. POST /api/auth/register/ {registration_token, ...form}
-> 201 {"status": "pending", ...}
Whether a number has an account is only answered once its owner has confirmed
a code, so `start` looks the same for every valid number.
"""
import logging
from django.conf import settings
from django.contrib.auth import authenticate
from django.utils import timezone
from knox.views import LoginView as KnoxLoginView
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import OtpCode, RegistrationTicket, User
from .serializers import (
AuthStartSerializer,
OtpVerifySerializer,
PasswordLoginSerializer,
RegistrationSerializer,
UserSerializer,
)
from .sms import send_otp, send_registration_submitted
logger = logging.getLogger(__name__)
CODE_LENGTH = 6
def _issue_code(mobile: str, user: User | None) -> OtpCode:
purpose = OtpCode.Purpose.LOGIN if user else OtpCode.Purpose.REGISTRATION
otp, code = OtpCode.issue(mobile=mobile, purpose=purpose, user=user)
send_otp(mobile, code, purpose)
return otp
def _code_payload(mobile: str, otp: OtpCode | None = None) -> dict:
payload = {"next": "otp", "mobile": mobile, "code_length": CODE_LENGTH}
if otp is not None:
payload["expires_at"] = otp.expires_at
payload["resend_available_at"] = otp.resend_available_at
return payload
class AuthStartView(APIView):
"""Step 1: password box, or a code sent by SMS."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "auth_start"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response({"next": "password", "mobile": mobile})
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class OtpResendView(APIView):
"""Send a fresh code, honouring the cooldown."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "otp_request"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response(_code_payload(mobile))
latest = OtpCode.objects.active().filter(mobile=mobile).first()
if latest and latest.resend_available_at > timezone.now():
return Response(
{
"detail": "A code was just sent. Try again shortly.",
"code": "resend_cooldown",
"resend_available_at": latest.resend_available_at,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class BaseLoginView(KnoxLoginView):
"""Issues a knox token, with the account serialised alongside it."""
authentication_classes = []
permission_classes = [AllowAny]
def issue_token(self, request, user):
request.user = user
return super().post(request, format=None)
def get_post_response_data(self, request, token, instance):
data = super().get_post_response_data(request, token, instance)
data["next"] = "dashboard"
data["user"] = UserSerializer(request.user).data
return data
class PasswordLoginView(BaseLoginView):
"""Step 2, password."""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = PasswordLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["mobile"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{
"detail": "Incorrect mobile number or password.",
"code": "invalid_credentials",
},
status=status.HTTP_400_BAD_REQUEST,
)
return self.issue_token(request, user)
class VerifyCodeView(BaseLoginView):
"""Step 2, SMS code.
A confirmed code either signs the account in or, when the number has no
account, hands back the ticket the registration form needs.
"""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = OtpVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
otp = (
OtpCode.objects.active()
.filter(mobile=mobile)
.select_related("user")
.first()
)
if otp is None:
return Response(
{
"detail": "That code has expired. Request a new one.",
"code": "code_expired",
},
status=status.HTTP_400_BAD_REQUEST,
)
if otp.is_exhausted:
return Response(
{
"detail": "Too many incorrect attempts. Request a new code.",
"code": "code_exhausted",
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not otp.verify(serializer.validated_data["code"]):
return Response(
{
"detail": "That code is not correct.",
"code": "invalid_code",
"attempts_left": max(0, settings.OTP_MAX_ATTEMPTS - otp.attempts),
},
status=status.HTTP_400_BAD_REQUEST,
)
user = otp.user or User.objects.filter(mobile=mobile).first()
if user is None:
ticket = RegistrationTicket.issue(mobile)
return Response(
{
"next": "register",
"registration_token": ticket.key,
"mobile": mobile,
"expires_at": ticket.expires_at,
}
)
if not user.is_active:
return Response(
{"detail": "This account is disabled.", "code": "account_disabled"},
status=status.HTTP_403_FORBIDDEN,
)
if not user.mobile_verified:
user.mobile_verified = True
user.save(update_fields=["mobile_verified", "updated_at"])
return self.issue_token(request, user)
class RegisterView(APIView):
"""Submit the registration form. Creates a pending account - no token."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "register"
def post(self, request):
serializer = RegistrationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
send_registration_submitted(user.mobile)
logger.info("Registration submitted for %s (user %s)", user.mobile, user.pk)
return Response(
{
"status": user.status,
"mobile": user.mobile,
"full_name": user.full_name,
"detail": (
"Your registration is pending approval. We'll text you "
"once it has been reviewed."
),
},
status=status.HTTP_201_CREATED,
)
class MeView(APIView):
"""The signed-in account, for the SPA to hydrate its session."""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)