Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/deps/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 25 additions & 1 deletion app/infra/database.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
4 changes: 2 additions & 2 deletions app/router/mobile/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)


Expand Down
6 changes: 3 additions & 3 deletions app/router/mobile/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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)
6 changes: 3 additions & 3 deletions app/router/mobile/notifications.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/router/mobile/photo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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]] = []
Expand All @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions app/router/mobile/photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions app/schema/response/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class UserSchema(BaseModel):
email: str
name: str | None
avatar_url: str | None
is_onboarded: bool

class MeResponse(BaseModel):
user: UserSchema
Expand Down
3 changes: 1 addition & 2 deletions app/service/user_notification.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
from typing import Any
import uuid

Expand Down Expand Up @@ -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")
Expand Down
30 changes: 19 additions & 11 deletions app/service/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading