From 8aecc74347c4e0cd55e3bf662c08c2e4744daec5 Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Sat, 15 Aug 2026 20:14:20 +0800 Subject: [PATCH 1/2] fix(server-gsm): require verified phone for SMS sends --- docs/api/openapi/gsm-sms.yaml | 12 ++++++ server/app/api/gsm.py | 32 +++++++++++++++- server/app/tests/test_gsm_proxy.py | 61 ++++++++++++++++++++++++++---- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi/gsm-sms.yaml b/docs/api/openapi/gsm-sms.yaml index 9656fe9b..e22a57d1 100644 --- a/docs/api/openapi/gsm-sms.yaml +++ b/docs/api/openapi/gsm-sms.yaml @@ -366,6 +366,12 @@ paths: schema: {} '404': description: Not Found + '403': + description: The sending account does not have a verified phone number. + content: + application/json: + schema: + $ref: '#/components/schemas/GsmFailureResponse' '422': description: Validation Error content: @@ -592,6 +598,12 @@ paths: - $ref: '#/components/schemas/GsmFailureResponse' - $ref: '#/components/schemas/GsmHealthUnavailableResponse' title: Response 503 Send Sms Gsm Sms Send Post + '403': + description: The sending account does not have a verified phone number. + content: + application/json: + schema: + $ref: '#/components/schemas/GsmFailureResponse' '422': description: Validation Error content: diff --git a/server/app/api/gsm.py b/server/app/api/gsm.py index e630c72a..5c760d9b 100644 --- a/server/app/api/gsm.py +++ b/server/app/api/gsm.py @@ -123,6 +123,18 @@ class GsmHealthUnavailableResponse(BaseModel): }, } +GSM_PHONE_VERIFICATION_ERROR_RESPONSES = { + 403: { + "model": GsmFailureResponse, + "description": "The sending account does not have a verified phone number.", + }, +} + +GSM_SMS_SEND_ERROR_RESPONSES = { + **GSM_SEND_ERROR_RESPONSES, + **GSM_PHONE_VERIFICATION_ERROR_RESPONSES, +} + GSM_HEALTH_ERROR_RESPONSES = { 503: { "model": GsmHealthResponse | GsmHealthUnavailableResponse, @@ -295,7 +307,19 @@ async def gsm_messages( response = await client.get("/sms/messages", params=params) return response.json() -@router.post("/sms/send", responses=GSM_SEND_ERROR_RESPONSES) + +def _require_verified_phone(user: User) -> None: + if not user.phone_is_verified: + raise HTTPException( + status_code=403, + detail={ + "reason": "PHONE_VERIFICATION_REQUIRED", + "message": "Verify your phone number before sending SMS.", + }, + ) + + +@router.post("/sms/send", responses=GSM_SMS_SEND_ERROR_RESPONSES) async def send_sms( current_user : Annotated[User, Depends(get_current_user)], user_id: UUID, @@ -306,6 +330,8 @@ async def send_sms( if current_user.banned: raise HTTPException(403) + _require_verified_phone(current_user) + target = session.get(User, user_id) if not target: @@ -769,7 +795,7 @@ async def MOCK_gsm_messages( """Admin only""" return "Admin only!" -@router.post("/mock/sms/send") +@router.post("/mock/sms/send", responses=GSM_PHONE_VERIFICATION_ERROR_RESPONSES) async def MOCK_send_sms( current_user : Annotated[User, Depends(get_current_user)], user_id: UUID, @@ -780,6 +806,8 @@ async def MOCK_send_sms( if current_user.banned: raise HTTPException(403) + _require_verified_phone(current_user) + target = session.get(User, user_id) if not target: diff --git a/server/app/tests/test_gsm_proxy.py b/server/app/tests/test_gsm_proxy.py index 65738769..35a1e2dd 100644 --- a/server/app/tests/test_gsm_proxy.py +++ b/server/app/tests/test_gsm_proxy.py @@ -5,7 +5,7 @@ from app.api import gsm from app.db_operations.token import get_current_user from app.main import app -from app.models.phone_verification import PhoneVerification, now_ms +from app.models.phone_verification import PhoneVerification, PhoneVerified, now_ms from app.models.users import User from sqlmodel import select @@ -65,8 +65,13 @@ async def post(self, path: str, json: dict, **kwargs): raise httpx.PoolTimeout("GSM proxy connection pool is full") -def _authenticated_user(session): - return session.exec(select(User)).first() +def _authenticated_user(session, *, phone_verified=False): + user = session.exec(select(User)).first() + if phone_verified: + session.add(PhoneVerified(user_id=user.id)) + session.commit() + session.refresh(user) + return user def test_proxy_capacity_and_timeouts_cover_gateway_contract(monkeypatch): @@ -111,7 +116,7 @@ async def post(self, path: str, **kwargs): def test_send_sms_preserves_queue_full_status(client, session, monkeypatch): - current_user = _authenticated_user(session) + current_user = _authenticated_user(session, phone_verified=True) target = session.exec(select(User).where(User.id != current_user.id)).first() monkeypatch.setattr(gsm, "_get_gsm_client", lambda: SaturatedGsmClient()) monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) @@ -126,7 +131,7 @@ def test_send_sms_preserves_queue_full_status(client, session, monkeypatch): def test_send_sms_preserves_service_stopping_status(client, session, monkeypatch): - current_user = _authenticated_user(session) + current_user = _authenticated_user(session, phone_verified=True) target = session.exec(select(User).where(User.id != current_user.id)).first() monkeypatch.setattr(gsm, "_get_gsm_client", lambda: StoppingGsmClient()) monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) @@ -191,7 +196,7 @@ def test_contact_unknown_user_preserves_queue_full_status(client, session, monke def test_send_sms_reports_unavailable_gateway(client, session, monkeypatch): - current_user = _authenticated_user(session) + current_user = _authenticated_user(session, phone_verified=True) target = session.exec(select(User).where(User.id != current_user.id)).first() monkeypatch.setattr(gsm, "_get_gsm_client", lambda: UnavailableGsmClient()) monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) @@ -211,7 +216,7 @@ def test_send_sms_reports_unavailable_gateway(client, session, monkeypatch): def test_send_sms_preserves_modem_not_ready_status(client, session, monkeypatch): - current_user = _authenticated_user(session) + current_user = _authenticated_user(session, phone_verified=True) target = session.exec(select(User).where(User.id != current_user.id)).first() monkeypatch.setattr(gsm, "_get_gsm_client", lambda: ModemNotReadyGsmClient()) monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) @@ -228,7 +233,7 @@ def test_send_sms_preserves_modem_not_ready_status(client, session, monkeypatch) def test_send_sms_rejects_proxy_pool_exhaustion_without_gateway_send( client, session, monkeypatch ): - current_user = _authenticated_user(session) + current_user = _authenticated_user(session, phone_verified=True) target = session.exec(select(User).where(User.id != current_user.id)).first() monkeypatch.setattr(gsm, "_get_gsm_client", lambda: PoolExhaustedGsmClient()) monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) @@ -245,3 +250,43 @@ def test_send_sms_rejects_proxy_pool_exhaustion_without_gateway_send( "reason": "GATEWAY_UNAVAILABLE", } } + + +def test_send_sms_rejects_unverified_sender_without_gateway_send( + client, session, monkeypatch +): + current_user = _authenticated_user(session) + target = session.exec(select(User).where(User.id != current_user.id)).first() + + def fail_if_gateway_client_is_requested(): + raise AssertionError("Unverified sender reached the GSM gateway") + + monkeypatch.setattr(gsm, "_get_gsm_client", fail_if_gateway_client_is_requested) + monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) + + response = client.post( + "/gsm/sms/send", + params={"user_id": str(target.id), "message": "Help is on the way"}, + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": { + "reason": "PHONE_VERIFICATION_REQUIRED", + "message": "Verify your phone number before sending SMS.", + } + } + + +def test_mock_send_sms_rejects_unverified_sender(client, session, monkeypatch): + current_user = _authenticated_user(session) + target = session.exec(select(User).where(User.id != current_user.id)).first() + monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user) + + response = client.post( + "/gsm/mock/sms/send", + params={"user_id": str(target.id), "message": "Help is on the way"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"]["reason"] == "PHONE_VERIFICATION_REQUIRED" From c8aba67dfa98885b5d258bbe9d9b0dd5ff193448 Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Sat, 15 Aug 2026 20:14:47 +0800 Subject: [PATCH 2/2] docs(gsm): document verified SMS sender policy --- docs/api/gsm-sms.md | 17 +++++++++++++++-- docs/features/sms-gateway/design.md | 2 +- docs/features/sms-gateway/requirements.md | 3 ++- mobile-app/sapot-mobile-app/docs/API.md | 13 ++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/api/gsm-sms.md b/docs/api/gsm-sms.md index 3d7d942d..abd96933 100644 --- a/docs/api/gsm-sms.md +++ b/docs/api/gsm-sms.md @@ -15,7 +15,7 @@ The GSM endpoints proxy SMS operations from the SAPOT server to the GSM module ( | GET | `/gsm/health` | JWT Bearer | Check GSM module availability. | | GET | `/gsm/health/detailed` | JWT Bearer | Detailed GSM module health/diagnostics. | | GET | `/gsm/sms/messages` | JWT Bearer | List recent SMS messages seen by the module. | -| POST | `/gsm/sms/send` | JWT Bearer | Send an SMS to a phone number. | +| POST | `/gsm/sms/send` | JWT Bearer + verified phone | Send an SMS to a phone number. | | POST | `/gsm/request` | None | Initiate an SMS OTP for a phone number (registration/verification). | | POST | `/gsm/verify` | None | Verify an SMS OTP code. | | POST | `/gsm/resend` | None | Resend an SMS OTP. | @@ -25,7 +25,7 @@ The GSM endpoints proxy SMS operations from the SAPOT server to the GSM module ( | GET | `/gsm/mock/health` | JWT Bearer | Mock variant of `/gsm/health`. | | GET | `/gsm/mock/health/detailed` | JWT Bearer | Mock variant of `/gsm/health/detailed`. | | GET | `/gsm/mock/sms/messages` | JWT Bearer | Mock variant of `/gsm/sms/messages`. | -| POST | `/gsm/mock/sms/send` | JWT Bearer | Mock variant of `/gsm/sms/send`. | +| POST | `/gsm/mock/sms/send` | JWT Bearer + verified phone | Mock variant of `/gsm/sms/send`. | | POST | `/gsm/mock/request` | None | Mock variant of `/gsm/request`. | | POST | `/gsm/mock/verify` | None | Mock variant of `/gsm/verify`. | | POST | `/gsm/mock/resend` | None | Mock variant of `/gsm/resend`. | @@ -43,6 +43,19 @@ Webhook endpoint the GSM hardware gateway calls when it receives an SMS. Protect The GSM module's own standalone hardware-facing API is documented in [`docs/deployment/gsm-module.md`](../deployment/gsm-module.md). Its `POST /sms/send` route requires the same `X-GSM-Secret` shared secret that protects the main server's inbound webhook. +## Outbound sender eligibility + +`POST /gsm/sms/send` and its mock variant require a `PhoneVerified` record for the authenticated account. The server checks this before looking up the recipient or contacting the GSM gateway. An unverified account receives HTTP 403: + +```json +{ + "detail": { + "reason": "PHONE_VERIFICATION_REQUIRED", + "message": "Verify your phone number before sending SMS." + } +} +``` + ## Gateway failure contract The main server preserves synchronous gateway failures for `/gsm/sms/send`, `/gsm/request`, `/gsm/resend`, and `/gsm/contact-unknown-user`. Queue saturation returns HTTP 503: diff --git a/docs/features/sms-gateway/design.md b/docs/features/sms-gateway/design.md index 3db8906e..20bb0222 100644 --- a/docs/features/sms-gateway/design.md +++ b/docs/features/sms-gateway/design.md @@ -33,7 +33,7 @@ sequenceDiagram GSM->>Main: POST /gsm/inbound with X-GSM-Secret ``` -The main server authenticates the user-facing `/gsm/sms/send` route with a JSON Web Token (JWT). It authenticates its direct gateway call with the same shared `GSM_SECRET` used for callbacks. The GSM service validates `X-GSM-Secret` before logging or queueing a send, which prevents another container on the internal network from occupying the serial modem. +The main server authenticates the user-facing `/gsm/sms/send` route with a JSON Web Token (JWT) and requires the sender to have a verified phone number. It rejects an unverified sender before contacting the gateway. The main server authenticates its direct gateway call with the same shared `GSM_SECRET` used for callbacks. The GSM service validates `X-GSM-Secret` before logging or queueing a send, which prevents another container on the internal network from occupying the serial modem. ## How does outbound admission work? diff --git a/docs/features/sms-gateway/requirements.md b/docs/features/sms-gateway/requirements.md index 61bdd05f..3aaabb60 100644 --- a/docs/features/sms-gateway/requirements.md +++ b/docs/features/sms-gateway/requirements.md @@ -119,7 +119,8 @@ Only one request may await a modem confirmation. A confirmation received before ### FR-SG-08: Main server integration -- The user-facing `/gsm/sms/send` route remains on the main server and requires its normal JWT authentication. +- The user-facing `/gsm/sms/send` route remains on the main server and requires its normal JWT authentication and a verified phone number for the sending account. +- An authenticated account without a verified phone number receives HTTP 403 with `reason: "PHONE_VERIFICATION_REQUIRED"` before the main server calls the GSM service. - The main server calls the direct gateway at `http://localhost:8001/sms/send` with `X-GSM-Secret`. - The direct gateway is a trusted local service and must not be exposed to untrusted networks. - The main server must preserve gateway HTTP 502 and 503 failures for user-facing send, verification, resend, and first-contact requests. diff --git a/mobile-app/sapot-mobile-app/docs/API.md b/mobile-app/sapot-mobile-app/docs/API.md index 79d59109..2a9b88a6 100644 --- a/mobile-app/sapot-mobile-app/docs/API.md +++ b/mobile-app/sapot-mobile-app/docs/API.md @@ -564,7 +564,8 @@ Failure is non-fatal — the client logs and retries on next login. --- ### `POST /user-utils/search-user` — Search Users -**Auth:** Required +**Auth:** Required; the authenticated account must have a verified phone number + **Query params:** `identifier_string=&limit=&offset=` **Response `200`:** @@ -864,6 +865,16 @@ independently — surface them separately rather than collapsing to one "offline { "msg_id": "string", "ok": "boolean", "to": "string" } ``` +**Response `403` when the sender's phone number is not verified:** +```json +{ + "detail": { + "reason": "PHONE_VERIFICATION_REQUIRED", + "message": "Verify your phone number before sending SMS." + } +} +``` + **Response `503` when the outbound queue is full:** ```json {