register and sign in pages
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user