From f244a3b31a3b693ef30e9e3b4ea618f9783d1115 Mon Sep 17 00:00:00 2001 From: wailbentafat Date: Mon, 24 Aug 2026 23:58:58 +0100 Subject: [PATCH] feat: gate mobile endpoints behind face enrollment, fix jsonb round-trip - Add require_onboarded_mobile_user dependency: every mobile endpoint except /auth/* and /enroll now returns 403 until the user completes face enrollment (users.face_embedding is set). - Add is_onboarded to GET /user/auth/me and the avatar upload response so clients can branch on a single field instead of parsing 403s. - Fix asyncpg/SQLAlchemy jsonb handling: register a jsonb type codec on connect so dict params bind correctly and jsonb columns decode back to dict (was crashing audit_events writes, and silently mismatched on notifications/staff_notifications reads). - Add a dev-env fixed OTP (DEV_OTP_BYPASS_CODE) for registration so local/mobile testing doesn't require a real inbox or NATS email flow. --- app/core/config.py | 3 +++ app/deps/token_auth.py | 21 ++++++++++++++++++++ app/infra/database.py | 26 ++++++++++++++++++++++++- app/router/mobile/audit.py | 4 ++-- app/router/mobile/auth.py | 2 ++ app/router/mobile/event.py | 6 +++--- app/router/mobile/notifications.py | 6 +++--- app/router/mobile/photo_approval.py | 6 +++--- app/router/mobile/photos.py | 8 ++++---- app/schema/response/mobile/auth.py | 1 + app/service/user_notification.py | 3 +-- app/service/users.py | 30 ++++++++++++++++++----------- 12 files changed, 87 insertions(+), 29 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index dea2f69a..35ec0daf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -54,6 +54,9 @@ class Settings(BaseSettings): # Rate Limit Settings RATE_LIMIT_LOGIN_MAX_ATTEMPTS: int = 5 RATE_LIMIT_LOGIN_WINDOW_SECONDS: int = 60 + # In dev env, registration OTPs are fixed to this value and the email/NATS + # send is skipped, so mobile devs can verify without a real inbox. + DEV_OTP_BYPASS_CODE: str = "000000" TRUST_PROXY_HEADERS: bool = True # Admin list defaults ADMIN_USERS_DEFAULT_LIMIT: int = 20 diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index a14de6c4..7c778309 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -114,3 +114,24 @@ async def get_current_mobile_user( email=user.email or "", session_id=session.id, ) + + +async def require_onboarded_mobile_user( + current_user: Annotated[MobileUserSchema, Depends(get_current_mobile_user)], + container: Annotated[Container, Depends(get_container)], +) -> MobileUserSchema: + """Gate for endpoints that require a completed face enrollment. + Auth (login/me/devices/etc.), /enroll, and /event/join stay reachable + via plain get_current_mobile_user so a new user can always finish + onboarding; everything else (photos, notifications, audits, /event/me) + depends on this instead. + """ + user = await container.auth_service.user_querier.get_user_by_id(id=current_user.user_id) + if user is None: + raise HTTPException(status_code=401, detail="User not found") + if user.face_embedding is None: + raise HTTPException( + status_code=403, + detail="Complete face enrollment before accessing this resource", + ) + return current_user diff --git a/app/infra/database.py b/app/infra/database.py index 956662bd..930b3368 100644 --- a/app/infra/database.py +++ b/app/infra/database.py @@ -1,5 +1,8 @@ -from typing import AsyncGenerator +import json +from typing import Any, AsyncGenerator + import sqlalchemy.ext.asyncio +from sqlalchemy import event from app.core.config import settings @@ -13,6 +16,27 @@ ) +@event.listens_for(engine.sync_engine, "connect") +def _register_jsonb_codec(dbapi_connection: Any, connection_record: Any) -> None: + # SQLAlchemy's asyncpg dialect doesn't forward connect_args={"init": ...} + # to asyncpg (it raises TypeError: unexpected keyword argument 'init'), + # so codec registration has to go through the wrapped connection's + # run_async bridge instead. Without this, asyncpg neither accepts a + # Python dict as a jsonb bind param (raises DataError) nor decodes a + # jsonb column back into one (returns raw JSON text) — every jsonb + # column in the schema (audit metadata, notification payloads) needs + # both directions to work. + dbapi_connection.run_async( + lambda conn: conn.set_type_codec( + "jsonb", + encoder=json.dumps, + decoder=json.loads, + schema="pg_catalog", + format="text", + ) + ) + + async def get_db() -> AsyncGenerator[sqlalchemy.ext.asyncio.AsyncConnection, None]: async with engine.begin() as conn: yield conn diff --git a/app/router/mobile/audit.py b/app/router/mobile/audit.py index fe3226a5..14cba0af 100644 --- a/app/router/mobile/audit.py +++ b/app/router/mobile/audit.py @@ -7,7 +7,7 @@ from app.container import Container, get_container from app.core.constant import AuditEventType -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user from app.schema.response.mobile.audit import AuditEventListResponse, AuditEventSchema router = APIRouter(prefix="/audits", tags=["audits"]) @@ -22,7 +22,7 @@ async def list_audits( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), container: Container = Depends(get_container), - _: MobileUserSchema = Depends(get_current_mobile_user), + _: MobileUserSchema = Depends(require_onboarded_mobile_user), ) -> AuditEventListResponse: events = await container.audit_service.list_audit_events( event_type=event_type, diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index ce444c00..a7e4dddd 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -206,6 +206,7 @@ async def get_me( email=user.email, name=user.display_name, avatar_url="/user/auth/me/avatar/image" if user.avatar_key else None, + is_onboarded=user.face_embedding is not None, ), devices=device_list, sessions=session_schema, @@ -239,6 +240,7 @@ async def upload_avatar( email=user.email, name=user.display_name, avatar_url="/user/auth/me/avatar/image", + is_onboarded=user.face_embedding is not None, ) diff --git a/app/router/mobile/event.py b/app/router/mobile/event.py index adada86a..33548e75 100644 --- a/app/router/mobile/event.py +++ b/app/router/mobile/event.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user from app.schema.request.web.event import JoinEventRequest from app.schema.response.web.event import JoinEventResponse, UserEventResponse @@ -13,7 +13,7 @@ async def join_event( req: JoinEventRequest, container: Container = Depends(get_container), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), )-> JoinEventResponse: return await container.event_service.join_event_by_code( user_id=current_user.user_id, @@ -24,6 +24,6 @@ async def join_event( @router.get("/me", response_model=List[UserEventResponse]) async def get_my_joined_events( container: Container = Depends(get_container), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), )-> List[UserEventResponse]: return await container.event_service.get_my_events(current_user.user_id) diff --git a/app/router/mobile/notifications.py b/app/router/mobile/notifications.py index f8d4a569..ca973fcb 100644 --- a/app/router/mobile/notifications.py +++ b/app/router/mobile/notifications.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user from app.schema.request.mobile.notifications import MarkUserNotificationsReadRequest from app.schema.response.mobile.notifications import UserNotificationListResponse @@ -12,7 +12,7 @@ @router.get("", response_model=UserNotificationListResponse) async def get_all_notifications( container: Container = Depends(get_container), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), ) -> UserNotificationListResponse: notifications = await container.user_notifications_service.get_all_notifications( user_id=current_user.user_id, @@ -24,7 +24,7 @@ async def get_all_notifications( async def mark_as_read( req: MarkUserNotificationsReadRequest, container: Container = Depends(get_container), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), ) -> UserNotificationListResponse: notifications = await container.user_notifications_service.mark_notifications_as_read( notification_ids=req.notification_ids, diff --git a/app/router/mobile/photo_approval.py b/app/router/mobile/photo_approval.py index 3aead0d3..9f5f97f0 100644 --- a/app/router/mobile/photo_approval.py +++ b/app/router/mobile/photo_approval.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user from app.schema.request.mobile.photo_approval import PhotoApprovalRequest router = APIRouter(prefix="/photos") @@ -15,7 +15,7 @@ async def list_my_approvals( status: Literal["pending", "approved", "rejected"] | None = Query(default=None), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), container: Container = Depends(get_container), ) -> list[dict[str, object]]: approvals: list[dict[str, object]] = [] @@ -39,7 +39,7 @@ async def list_my_approvals( async def decide_photo_approval( photo_id: UUID, req: PhotoApprovalRequest, - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), container: Container = Depends(get_container), ) -> dict[str, str]: photo_status = await container.photo_approval_service.decide( diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py index 00113f97..a9f6036c 100644 --- a/app/router/mobile/photos.py +++ b/app/router/mobile/photos.py @@ -5,7 +5,7 @@ from fastapi.responses import Response from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user from app.deps.rate_limit import RateLimiter router = APIRouter(prefix="/photos") @@ -17,7 +17,7 @@ async def list_my_photos( sort: Literal["asc", "desc"] = Query(default="desc"), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), container: Container = Depends(get_container), ) -> list[dict[str, object]]: photos = await container.user_photo_service.list_photos( @@ -47,7 +47,7 @@ async def list_event_photos( sort: Literal["asc", "desc"] = Query(default="desc"), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), container: Container = Depends(get_container), ) -> dict[str, object]: photos = await container.user_photo_service.list_event_photos( @@ -81,7 +81,7 @@ async def list_event_photos( @router.get("/{photo_id}/image") async def get_photo_image( photo_id: UUID, - current_user: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(require_onboarded_mobile_user), container: Container = Depends(get_container), ) -> Response: data, filename, content_type = await container.user_photo_service.get_photo_bytes( diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index 67bf1398..d9949374 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -26,6 +26,7 @@ class UserSchema(BaseModel): email: str name: str | None avatar_url: str | None + is_onboarded: bool class MeResponse(BaseModel): user: UserSchema diff --git a/app/service/user_notification.py b/app/service/user_notification.py index f8181bc0..a269ff17 100644 --- a/app/service/user_notification.py +++ b/app/service/user_notification.py @@ -1,4 +1,3 @@ -import json from typing import Any import uuid @@ -42,7 +41,7 @@ async def create_notification( notification_record = await self.notification_querier.create_notification( user_id=user_id, type=type, - payload=json.dumps(payload), + payload=payload, ) if notification_record is None: raise AppException.internal_error("Failed to create user notification") diff --git a/app/service/users.py b/app/service/users.py index 3864757b..5682b826 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -176,7 +176,6 @@ async def mobile_register( raise AppException.conflict("Email already in use; please login instead") hashed = hash_password(req.password) - otp = "".join(secrets.choice("0123456789") for _ in range(6)) pending_key = f"pending_user:{req.email}" pending_data = { @@ -185,10 +184,16 @@ async def mobile_register( # Save in Redis for 10 minutes (600 seconds) await redis.set(pending_key, json.dumps(pending_data), expire=600) - await redis.set(f"otp:{req.email}", otp, expire=600) - # Send to NATS - await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8")) + if settings.environment == "dev": + otp = settings.DEV_OTP_BYPASS_CODE + await redis.set(f"otp:{req.email}", otp, expire=600) + logger.info("dev OTP bypass active, otp=%s email=%s", otp, req.email) + else: + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + await redis.set(f"otp:{req.email}", otp, expire=600) + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8")) logger.info("register success, OTP sent") return RegisterPendingResponse( @@ -226,13 +231,16 @@ async def mobile_register_resend_otp( if not raw_data: raise AppException.not_found("No pending registration found for this email") - otp = "".join(secrets.choice("0123456789") for _ in range(6)) - - # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL - await redis.set(f"otp:{email}", otp, expire=600) - - # Send to NATS - await NatsClient.publish("email.send_otp", json.dumps({"email": email, "otp": otp}).encode("utf-8")) + if settings.environment == "dev": + otp = settings.DEV_OTP_BYPASS_CODE + await redis.set(f"otp:{email}", otp, expire=600) + logger.info("dev OTP bypass active, otp=%s email=%s", otp, email) + else: + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL + await redis.set(f"otp:{email}", otp, expire=600) + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": email, "otp": otp}).encode("utf-8")) logger.info("resend_otp success, new OTP sent to %s", email) return RegisterPendingResponse(