Files
2026-09-22 00:50:30 +05:00

182 lines
5.7 KiB
Markdown

# 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.