182 lines
5.7 KiB
Markdown
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.
|