Skip to content
Closed
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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,11 @@ FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json

# Resend Email Configuration
RESEND_API_KEY=
EMAIL_FROM=
EMAIL_FROM=
# MCDI Configuration
MCDI_PROJECT_ID="your_mcdi_project_id"
MCDI_API_KEY="your_mcdi_api_key"
MCDI_BASE_URL="https://mcdi.microclub.info/api"
MCDI_SERVER_ID="900000000000000001"
MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/api/v1/auth/mcdi/callback"
MCDI_REDIRECT_URI_MOBILE="multai://auth/callback"
9 changes: 8 additions & 1 deletion .env.staging.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,11 @@ GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email

FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key

FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json
FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json
# MCDI Configuration
MCDI_PROJECT_ID="your_mcdi_project_id"
MCDI_API_KEY="your_mcdi_api_key"
MCDI_BASE_URL="https://mcdi.microclub.info/api"
MCDI_SERVER_ID="900000000000000001"
MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/api/v1/auth/mcdi/callback"
MCDI_REDIRECT_URI_MOBILE="multai://auth/callback"
4 changes: 4 additions & 0 deletions app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from app.service.upload_requests import UploadRequestsService
from app.service.users import AuthService
from app.service.user_notification import UserNotificationService
from app.service.mcdi import McdiService
from db.generated import devices as device_queries
from db.generated import photo_approvals as photo_approval_queries
from db.generated import processing_jobs as processing_job_queries
Expand Down Expand Up @@ -86,6 +87,8 @@ def __init__(
redis=self.redis,
)

self.mcdi_service = McdiService()

self.device_service = DeviceService()
self.device_service.init(
device_querier=self.device_querier,
Expand All @@ -97,6 +100,7 @@ def __init__(
session_querier=self.session_querier,
refresh_token_querier=self.refresh_token_querier,
face_embedding_service=self.face_embedding_service,
mcdi_service=self.mcdi_service,
)

self.staff_drive_service = StaffDriveService(
Expand Down
8 changes: 8 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ class Settings(BaseSettings):
RESEND_API_KEY: str = ""
EMAIL_FROM: str = "onboarding@resend.dev"

# MCDI Configuration
MCDI_PROJECT_ID: str = ""
MCDI_API_KEY: str = ""
MCDI_BASE_URL: str = "https://mcdi.microclub.info/api"
MCDI_SERVER_ID: str = ""
MCDI_REDIRECT_URI_WEB: str = ""
MCDI_REDIRECT_URI_MOBILE: str = ""

model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
Expand Down
42 changes: 20 additions & 22 deletions app/deps/cookie_auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from typing import Annotated
import uuid

from fastapi import Cookie, Depends
from app.container import Container, get_container
from app.core.exceptions import AppException
from db.generated.models import StaffRole, StaffUser
from app.core.securite import decode_staff_token


def _role_value(role: object) -> str:
Expand All @@ -17,27 +14,28 @@ async def get_current_staff_user(
token: Annotated[str | None, Cookie(alias="access_token")] = None,
) -> StaffUser:
if token is None:
raise AppException.unauthorized("token doestn exist")
else:
payload = decode_staff_token(token)
staff_id_str = payload.sub

if not staff_id_str:
raise AppException.unauthorized("Token missing subject")

# 2. Convert to UUID and fetch user from DB
try:
staff_id = uuid.UUID(staff_id_str)
except ValueError:
raise AppException.unauthorized("Invalid staff ID in token")

staff_user = await container.staff_user_querier.get_staff_user_by_id(
id=staff_id
raise AppException.unauthorized("Authentication token required")

# 1. Validate token with MCDI (cached in Redis for sub-ms latency and rate-limit protection)
try:
mcdi_data = await container.mcdi_service.validate_token_cached(
redis=container.redis,
token=token,
ttl_seconds=60,
)
if staff_user is None:
raise AppException.not_found("Staff user not found")
except Exception:
raise AppException.unauthorized("MCDI session invalid or expired")

email = mcdi_data.get("member", {}).get("email")
if not email:
raise AppException.unauthorized("Invalid MCDI payload: missing email")

# 2. Get local StaffUser by email
staff_user = await container.staff_user_querier.get_staff_user_by_email(email=email)
if staff_user is None:
raise AppException.unauthorized("Staff user not found or not authorized")

return staff_user
return staff_user


def ensure_multi_team_lead_staff(current_staff_user: StaffUser) -> StaffUser:
Expand Down
133 changes: 79 additions & 54 deletions app/deps/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.container import get_container, Container
from app.container import Container, get_container
from app.core.config import settings
from app.core.securite import decode_access_mobile_token
from app.infra.redis import RedisClient
Expand All @@ -15,21 +15,74 @@
security = HTTPBearer()


def _check_session_validity(
idle_expires_at: datetime,
absolute_expires_at: datetime,
blocked: bool,
) -> None:
now = datetime.now(timezone.utc)
if idle_expires_at < now or absolute_expires_at < now:
raise HTTPException(status_code=401, detail="Session expired")
if blocked:
raise HTTPException(status_code=403, detail="User is blocked")


async def _validate_mcdi_session(
container: Container,
redis: RedisClient,
mcdi_token: str | None,
) -> None:
if not mcdi_token:
return
try:
await container.mcdi_service.validate_token_cached(
redis=redis,
token=mcdi_token,
ttl_seconds=300,
)
except Exception:
raise HTTPException(status_code=401, detail="MCDI session invalid or expired")


async def _touch_session_activity(
container: Container,
redis: RedisClient,
cached: MobileSessionCache,
now: datetime,
) -> None:
if (now - cached.last_active).total_seconds() <= settings.SESSION_ACTIVITY_THROTTLE_SECONDS:
return

new_idle_expires_at = min(
now + timedelta(days=settings.MOBILE_SESSION_DAYS),
cached.absolute_expires_at,
)
await container.session_service.session_querier.update_session_activity(
id=cached.session_id,
idle_expires_at=new_idle_expires_at,
)
await SessionService.cache_session_for_auth(
redis=redis,
session_id=cached.session_id,
user_id=cached.user_id,
email=cached.email,
idle_expires_at=new_idle_expires_at,
absolute_expires_at=cached.absolute_expires_at,
blocked=cached.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=now,
mcdi_token=cached.mcdi_token,
)


async def get_current_mobile_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
container: Annotated[Container, Depends(get_container)],
) -> MobileUserSchema:
"""
Dependency to get the current logged-in mobile user.
Fast path: Redis cache hit. Usually 0 DB queries; occasionally 1 cheap,
throttled UPDATE to last_active (see SESSION_ACTIVITY_THROTTLE_SECONDS) —
this is not a full round trip through the slow path, just a single
indexed write on the request's existing connection.
Slow path: Postgres fallback (2 DB queries) with cache re-population.

idle_expires_at slides forward on each throttled activity refresh, up to
MOBILE_SESSION_DAYS from now, but is capped so it never exceeds
absolute_expires_at — the hard ceiling set at login that never moves.
Fast path: Redis cache hit. Usually 0 DB queries; throttled touch to last_active.
Slow path: Postgres fallback with cache re-population.
"""
token = credentials.credentials
payload = decode_access_mobile_token(token)
Expand All @@ -39,41 +92,20 @@ async def get_current_mobile_user(
raise HTTPException(status_code=401, detail="Invalid token")

session_id = uuid.UUID(session_id_str)
redis = RedisClient.get_instance()

# --- Fast path: Redis cache ---
redis = RedisClient.get_instance()
cached: MobileSessionCache | None = await SessionService.get_cached_session(
redis, session_id
)
if cached is not None:
now = datetime.now(timezone.utc)
if cached.idle_expires_at < now or cached.absolute_expires_at < now:
raise HTTPException(status_code=401, detail="Session expired")
if cached.blocked:
raise HTTPException(status_code=403, detail="User is blocked")

if (
now - cached.last_active
).total_seconds() > settings.SESSION_ACTIVITY_THROTTLE_SECONDS:
new_idle_expires_at = min(
now + timedelta(days=settings.MOBILE_SESSION_DAYS),
cached.absolute_expires_at,
)
await container.session_service.session_querier.update_session_activity(
id=cached.session_id,
idle_expires_at=new_idle_expires_at,
)
await SessionService.cache_session_for_auth(
redis=redis,
session_id=cached.session_id,
user_id=cached.user_id,
email=cached.email,
idle_expires_at=new_idle_expires_at,
absolute_expires_at=cached.absolute_expires_at,
blocked=cached.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=now,
)
_check_session_validity(
cached.idle_expires_at,
cached.absolute_expires_at,
cached.blocked,
)
await _validate_mcdi_session(container, redis, cached.mcdi_token)
await _touch_session_activity(container, redis, cached, datetime.now(timezone.utc))

return MobileUserSchema(
user_id=cached.user_id,
Expand All @@ -88,19 +120,16 @@ async def get_current_mobile_user(
if not session:
raise HTTPException(status_code=401, detail="Session not found")

now = datetime.now(timezone.utc)
if session.idle_expires_at < now or session.absolute_expires_at < now:
raise HTTPException(status_code=401, detail="Session expired")

user = await container.auth_service.user_querier.get_user_by_id(id=session.user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
if user.blocked:
raise HTTPException(status_code=403, detail="User is blocked")

# Re-populate cache so next request hits Redis. The session row was just
# fetched fresh from Postgres, so its last_active is already accurate —
# no extra write needed here, only cache population.
_check_session_validity(
session.idle_expires_at,
session.absolute_expires_at,
user.blocked,
)

await SessionService.cache_session_for_auth(
redis=redis,
session_id=session.id,
Expand All @@ -111,6 +140,7 @@ async def get_current_mobile_user(
blocked=user.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=session.last_active,
mcdi_token=None,
)

return MobileUserSchema(
Expand All @@ -124,12 +154,7 @@ 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.
"""
"""Gate for endpoints that require a completed face enrollment."""
user = await container.auth_service.user_querier.get_user_by_id(
id=current_user.user_id
)
Expand Down
29 changes: 29 additions & 0 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from app.deps.rate_limit import RateLimiter

from app.schema.request.mobile.auth import (
McdiExchangeRequest,
MobileLoginRequest,
MobileRegisterRequest,
RegisterVerifyRequest,
Expand Down Expand Up @@ -118,6 +119,34 @@ async def mobile_login(
return result


@router.post(
"/mcdi/exchange",
response_model=MobileAuthResponse,
dependencies=[Depends(RateLimiter(requests=5, window=60))],
)
async def mcdi_exchange(
req: McdiExchangeRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
client_ip = get_client_ip(request)
result = await container.auth_service.mcdi_exchange(
container.redis, req, client_ip=client_ip
)
if result.is_new_user:
await container.audit_service.create_record(
event_type=AuditEventType.USER_SIGNUP,
user_id=result.user_id,
metadata={"endpoint": "mcdi_exchange"},
)
else:
await container.audit_service.create_record(
event_type=AuditEventType.USER_LOGIN,
user_id=result.user_id,
metadata={"endpoint": "mcdi_exchange"},
)
return result

@router.post(
"/refresh",
response_model=MobileAuthResponse,
Expand Down
Loading
Loading