diff --git a/.env.example b/.env.example index f9b58d7..4969b86 100644 --- a/.env.example +++ b/.env.example @@ -68,4 +68,11 @@ FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json # Resend Email Configuration RESEND_API_KEY= -EMAIL_FROM= \ No newline at end of file +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" diff --git a/.env.staging.example b/.env.staging.example index ff88733..a52da79 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -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 \ No newline at end of file +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" diff --git a/app/container.py b/app/container.py index 0024ea6..726c376 100644 --- a/app/container.py +++ b/app/container.py @@ -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 @@ -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, @@ -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( diff --git a/app/core/config.py b/app/core/config.py index bfa4656..dfc4548 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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", diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 456fe68..4fe9b1e 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -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: @@ -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: diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 12c6718..0845567 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -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 @@ -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) @@ -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, @@ -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, @@ -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( @@ -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 ) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 81efcdb..5e64b30 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -13,6 +13,7 @@ from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( + McdiExchangeRequest, MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, @@ -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, diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 76f945b..00f0c12 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,42 +1,81 @@ -from fastapi import APIRouter, Depends -from app.container import Container, get_container -from fastapi import Response +import secrets + +from fastapi import APIRouter, Cookie, Depends, Request, Response +from fastapi.responses import RedirectResponse +from app.container import Container, get_container from app.core.config import settings +from app.core.exceptions import AppException from app.deps.cookie_auth import get_current_staff_user -from app.deps.rate_limit import RateLimiter -from app.schema.request.web.auth import WebAuthRequest -from app.schema.response.web.auth import WebAuthResponse from app.schema.response.web.staff_user import StaffUserSchema from db.generated.models import StaffUser router = APIRouter(prefix="/auth") -@router.post( - "/login", - response_model=WebAuthResponse, - description="so here both the dahbsoard will authneticate from this endpoitn ", - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def admin_login( - req: WebAuthRequest, - r: Response, +@router.get("/mcdi/login") +async def mcdi_login( container: Container = Depends(get_container), -) -> WebAuthResponse: +) -> RedirectResponse: + state = secrets.token_urlsafe(16) + url = container.mcdi_service.get_sso_authorize_url( + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, + state=state, + ) + response = RedirectResponse(url=url) + response.set_cookie( + key="mcdi_state", + value=state, + httponly=True, + max_age=300, + secure=settings.environment != "dev", + samesite="lax", + ) + return response - authResponse = await container.staff_user_service.admin_login( - email=req.email, password=req.password + +@router.get("/mcdi/callback") +async def mcdi_callback( + request: Request, + code: str, + state: str, + container: Container = Depends(get_container), +) -> RedirectResponse: + cookie_state = request.cookies.get("mcdi_state") + if not cookie_state or state != cookie_state: + raise AppException.unauthorized("Invalid state parameter") + + mcdi_data = await container.mcdi_service.exchange_code( + code=code, + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, ) - r.set_cookie( + + # Extract email and verify staff access + email = mcdi_data.get("member", {}).get("email") + if not email: + raise AppException.unauthorized("No email provided by MCDI") + + staff_user = await container.staff_user_querier.get_staff_user_by_email(email=email) + if not staff_user: + raise AppException.unauthorized("Not authorized for admin panel") + + token = mcdi_data.get("token") + if not token: + raise AppException.internal_error("MCDI did not return a session token") + + frontend_url = settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173" + + redirect_res = RedirectResponse(url=f"{frontend_url}/dashboard") + redirect_res.set_cookie( key="access_token", - value=authResponse.access_token, + value=token, httponly=True, secure=settings.environment != "dev", - samesite="strict", + samesite="lax", max_age=60 * 60 * 24 * 7, ) - return authResponse + redirect_res.delete_cookie("mcdi_state") + return redirect_res @router.get("/me", response_model=StaffUserSchema) @@ -55,11 +94,15 @@ async def get_me_admin( @router.post("/logout", status_code=204) async def admin_logout( r: Response, - _: StaffUser = Depends(get_current_staff_user), + token: str | None = Cookie(default=None, alias="access_token"), + container: Container = Depends(get_container), ) -> None: + if token: + await container.mcdi_service.logout(token, redis=container.redis) + r.delete_cookie( key="access_token", httponly=True, - secure=True, - samesite="strict", + secure=settings.environment != "dev", + samesite="lax", ) diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 592b947..5685770 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -44,6 +44,31 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: return stripped +class McdiExchangeRequest(BaseModel): + code: str + device_name: str = Field( + ..., + min_length=1, + max_length=settings.MOBILE_AUTH_DEVICE_NAME_MAX_LEN, + ) + device_type: str = Field( + ..., + min_length=1, + max_length=settings.MOBILE_AUTH_DEVICE_TYPE_MAX_LEN, + ) + physical_device_id: UUID + + @field_validator("device_name", "device_type", mode="before") + @classmethod + def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: + if not isinstance(value, str): + return value + stripped = value.strip() + if not stripped: + raise ValueError(f"{info.field_name} must not be empty") + if info.field_name == "device_type": + return stripped.lower() + return stripped class MobileRegisterRequest(MobileAuthBaseRequest): @field_validator("password") @classmethod @@ -81,8 +106,6 @@ def _normalize_email(cls, value: object) -> object: if not isinstance(value, str): return value return value.strip().lower() - - class RefreshTokenRequest(BaseModel): refresh_token: str diff --git a/app/service/mcdi.py b/app/service/mcdi.py new file mode 100644 index 0000000..e15cc6a --- /dev/null +++ b/app/service/mcdi.py @@ -0,0 +1,149 @@ +import hashlib +import json +import logging +import urllib.parse +from typing import Any + +import httpx + +from app.core.config import settings +from app.core.exceptions import AppException +from app.infra.redis import RedisClient + +logger = logging.getLogger(__name__) + + +class McdiService: + def __init__(self) -> None: + self.base_url = settings.MCDI_BASE_URL.rstrip("/") + self.api_key = settings.MCDI_API_KEY + self.client_id = settings.MCDI_PROJECT_ID + self.server_id = settings.MCDI_SERVER_ID + self._timeout = httpx.Timeout(10.0, connect=5.0) + + def get_sso_authorize_url(self, redirect_uri: str, state: str) -> str: + """Constructs the MCDI SSO authorization URL matching MCDI's AuthorizeQueryDto.""" + params = urllib.parse.urlencode({ + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "server_id": self.server_id, + "state": state, + }) + # MCDI_BASE_URL ends with /api (e.g. https://mcdi.microclub.info/api) + # The authorize route is /api/auth/sso/authorize + return f"{self.base_url}/auth/sso/authorize?{params}" + + @staticmethod + def _parse_error_detail(response: httpx.Response, default: str) -> str: + try: + body = response.json() + msg = body.get("message") + if isinstance(msg, list): + return ", ".join(str(m) for m in msg) + if isinstance(msg, str) and msg: + return msg + err = body.get("error") + if isinstance(err, str) and err: + return err + except Exception: + pass + return default + + async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: + """Exchanges an OAuth callback code for a long-lived MCDI session token.""" + url = f"{self.base_url}/auth/token" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = { + "clientId": self.client_id, + "code": code, + "redirectUri": redirect_uri, + } + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + detail = self._parse_error_detail(response, "Failed to exchange MCDI code") + logger.warning("mcdi_exchange_failed status=%d detail=%s", response.status_code, detail) + raise AppException.unauthorized(f"MCDI Error: {detail}") + return response.json() + except httpx.RequestError as e: + logger.error("mcdi_network_error url=%s err=%s", url, e) + raise AppException.internal_error(f"MCDI service unavailable: {e}") + + async def validate_token(self, token: str) -> dict[str, Any]: + """Validates an MCDI session token and fetches live roles directly from MCDI.""" + url = f"{self.base_url}/auth/validate" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = {"token": token} + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + detail = self._parse_error_detail(response, "Invalid or expired MCDI session") + logger.warning("mcdi_validation_failed status=%d detail=%s", response.status_code, detail) + raise AppException.unauthorized(f"MCDI validation failed: {detail}") + return response.json() + except httpx.RequestError as e: + logger.error("mcdi_network_error url=%s err=%s", url, e) + raise AppException.internal_error(f"MCDI service unavailable: {e}") + + async def validate_token_cached( + self, + redis: RedisClient, + token: str, + ttl_seconds: int = 60, + ) -> dict[str, Any]: + """ + Validates token against Redis cache to prevent latency and avoid hitting MCDI rate limits + (MCDI limits /auth/validate to 60 req/min). Falls back to remote validation on cache miss. + """ + token_hash = hashlib.sha256(token.encode()).hexdigest() + cache_key = f"mcdi:session:{token_hash}" + + cached = await redis.get(cache_key) + if cached: + try: + return json.loads(cached) + except Exception: + pass + + # Cache miss: validate against MCDI + data = await self.validate_token(token) + try: + await redis.set(cache_key, json.dumps(data), expire=ttl_seconds) + except Exception as e: + logger.warning("mcdi_cache_write_failed err=%s", e) + + return data + + async def logout(self, token: str, redis: RedisClient | None = None) -> bool: + """Kills the project-scoped session on MCDI and purges local Redis validation cache.""" + token_hash = hashlib.sha256(token.encode()).hexdigest() + if redis: + try: + await redis.delete(f"mcdi:session:{token_hash}") + except Exception as e: + logger.warning("mcdi_cache_delete_failed err=%s", e) + + url = f"{self.base_url}/auth/logout" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = {"token": token} + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + return response.status_code == 200 + except Exception as e: + logger.warning("mcdi_logout_failed err=%s", e) + return False diff --git a/app/service/session.py b/app/service/session.py index 97b980f..c082a68 100644 --- a/app/service/session.py +++ b/app/service/session.py @@ -17,6 +17,7 @@ class MobileSessionCache(BaseModel): absolute_expires_at: datetime blocked: bool last_active: datetime + mcdi_token: str | None = None class SessionService: @@ -39,6 +40,7 @@ async def cache_session_for_auth( blocked: bool, ttl: int, last_active: datetime, + mcdi_token: str | None = None, ) -> None: key = RedisKey.MobileSessionCache.value.format(session_id=session_id) payload = MobileSessionCache( @@ -49,6 +51,7 @@ async def cache_session_for_auth( absolute_expires_at=absolute_expires_at, blocked=blocked, last_active=last_active, + mcdi_token=mcdi_token, ) try: await redis.set(key=key, value=payload.model_dump_json(), expire=ttl) diff --git a/app/service/staff_user.py b/app/service/staff_user.py index b72cd89..fcf5ac6 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -5,8 +5,7 @@ import uuid from app.core.exceptions import AppException, DBException, DBExceptionImpl -from app.core.securite import create_access_staff_token, hash_password, verify_password -from app.schema.response.web.auth import WebAuthResponse +from app.core.securite import hash_password from db.generated import staff_user as staff_queries from db.generated.staff_user import ListStaffUsersParams from db.generated.models import StaffUser, StaffRole @@ -101,28 +100,7 @@ async def list_staff_users( logger.error("Failed to list staff users: %s", exc) raise DBException.handle(exc) - async def admin_login( - self, - email: str, - password: str, - ) -> WebAuthResponse: - normalized_email = email.strip().lower() - staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email( - email=normalized_email - ) - if staff is None or not verify_password(password, staff.password): - logger.info("admin login failed for email %s", normalized_email) - raise AppException.unauthorized("Invalid email or password") - access_token = create_access_staff_token( - staff_id=str(staff.id), role=staff.role - ) - - return WebAuthResponse( - access_token=access_token, - user_id=staff.id, - role=staff.role, - ) async def get_staff_user(self, stuff_id: uuid.UUID) -> StaffUser: stuff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_id( diff --git a/app/service/users.py b/app/service/users.py index d534ff5..20963f5 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,11 +1,12 @@ from datetime import datetime, timedelta, timezone import uuid from collections.abc import AsyncIterable -from typing import Optional +from typing import Optional, Any from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError +import json +import secrets from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, @@ -18,17 +19,17 @@ ) from app.core.config import settings from app.infra.redis import RedisClient +from app.infra.nats import NatsClient from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.service.mcdi import McdiService from app.schema.request.mobile.auth import ( - MobileAuthBaseRequest, + McdiExchangeRequest, MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, ) from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse -from app.infra.nats import NatsClient -import secrets -import json +from sqlalchemy.exc import SQLAlchemyError from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries @@ -56,17 +57,19 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, + mcdi_service: McdiService | None = None, ): self.user_querier = user_querier self.device_querier = device_querier self.session_querier = session_querier self.refresh_token_querier = refresh_token_querier self.face_embedding_service = face_embedding_service + self.mcdi_service = mcdi_service or McdiService() async def _ensure_device_for_login( self, user_id: uuid.UUID, - req: MobileAuthBaseRequest, + req: Any, ) -> UserDevice: existing_device = await self.device_querier.get_device_by_physical_id( user_id=user_id, physical_device_id=req.physical_device_id @@ -316,13 +319,74 @@ async def verify_mobile_register( is_new_user=True, ) + async def mcdi_exchange( + self, + redis: RedisClient, + req: McdiExchangeRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + logger.info("mcdi_exchange attempt") + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit(redis, f"rate:ip:{client_ip}", max_attempts, window) + + mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) + + member = mcdi_data.get("member", {}) + email = member.get("email") + discord_id = member.get("id") or member.get("discordId") + mcdi_token = mcdi_data.get("token") + + if not email or not discord_id or not mcdi_token: + raise AppException.unauthorized("Invalid MCDI payload") + + await self.check_rate_limit(redis, f"rate:email:{email}", max_attempts, window) + + user = await self.user_querier.get_user_by_discord_id(discord_id=discord_id) + is_new_user = False + + if not user: + user = await self.user_querier.get_user_by_email(email=email) + if user: + user = await self.user_querier.update_user_discord_id( + discord_id=discord_id, + id=user.id, + ) + if not user: + raise AppException.internal_error("Failed to link Discord account") + else: + user = await self.user_querier.create_user( + email=email, hashed_password=None, discord_id=discord_id + ) + if not user: + raise AppException.internal_error("Failed to create user") + is_new_user = True + + if user.blocked: + raise AppException.forbidden("User is blocked") + + locked_user = await self.user_querier.get_user_by_id_for_update(id=user.id) + if not locked_user or locked_user.blocked: + raise AppException.forbidden("User is blocked") + + return await self._create_mobile_session( + redis=redis, + user=locked_user, + req=req, + is_new_user=is_new_user, + mcdi_token=mcdi_token, + ) + async def _create_mobile_session( self, *, redis: RedisClient, user: User, - req: MobileAuthBaseRequest, + req: Any, is_new_user: bool, + mcdi_token: str | None = None, ) -> MobileAuthResponse: user_id: uuid.UUID = user.id @@ -376,6 +440,7 @@ async def _create_mobile_session( blocked=user.blocked, ttl=AuthService.REDIS_SESSION_TTL, last_active=session.last_active, + mcdi_token=mcdi_token, ) return MobileAuthResponse( @@ -508,6 +573,10 @@ async def logout( session_id: str, ) -> dict[str, str]: sid = uuid.UUID(session_id) + cached = await SessionService.get_cached_session(redis, sid) + if cached and cached.mcdi_token: + await self.mcdi_service.logout(cached.mcdi_token, redis=redis) + await self.session_querier.delete_session_by_id( id=sid, user_id=uuid.UUID(user_id) ) @@ -582,6 +651,7 @@ async def create_user( user = await self.user_querier.create_user( email=email, hashed_password=hashed, + discord_id=None, ) if not user: raise AppException.internal_error("Failed to create user") diff --git a/db/generated/models.py b/db/generated/models.py index adf1f28..eac8094 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -266,6 +266,7 @@ class User: deleted_at: Optional[datetime.datetime] blocked: bool avatar_key: Optional[str] + discord_id: Optional[str] @dataclasses.dataclass() diff --git a/db/generated/user.py b/db/generated/user.py index e922f86..7fb2167 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -13,9 +13,9 @@ CREATE_USER = """-- name: create_user \\:one -INSERT INTO users (email, hashed_password) -VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +INSERT INTO users (email, hashed_password, discord_id) +VALUES (:p1, :p2, :p3) +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -41,22 +41,29 @@ class FindClosestUserByEmbeddingRow: distance: Optional[Any] +GET_USER_BY_DISCORD_ID = """-- name: get_user_by_discord_id \\:one +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id +FROM users +WHERE discord_id = :p1 +""" + + GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE id = :p1 """ GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE id = :p1 FOR UPDATE @@ -64,7 +71,7 @@ class FindClosestUserByEmbeddingRow: LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -90,7 +97,7 @@ class ListUsersWithEmbeddingRow: SET blocked = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -99,7 +106,7 @@ class ListUsersWithEmbeddingRow: SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -110,7 +117,7 @@ class ListUsersWithEmbeddingRow: blocked = COALESCE(:p3, blocked), updated_at = NOW() WHERE id = :p4 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -119,7 +126,7 @@ class ListUsersWithEmbeddingRow: SET avatar_key = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -128,7 +135,16 @@ class ListUsersWithEmbeddingRow: SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id +""" + + +UPDATE_USER_DISCORD_ID = """-- name: update_user_discord_id \\:one +UPDATE users +SET discord_id = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -136,8 +152,8 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password})).first() + async def create_user(self, *, email: str, hashed_password: Optional[str] = None, discord_id: Optional[str] = None) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password, "p3": discord_id})).first() if row is None: return None return models.User( @@ -151,6 +167,7 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -165,6 +182,24 @@ async def find_closest_user_by_embedding(self, *, dollar_1: Any) -> Optional[Fin distance=row[1], ) + async def get_user_by_discord_id(self, *, discord_id: Optional[str]) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_DISCORD_ID), {"p1": discord_id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + avatar_key=row[9], + discord_id=row[10], + ) + async def get_user_by_email(self, *, email: str) -> Optional[models.User]: row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_EMAIL), {"p1": email})).first() if row is None: @@ -180,6 +215,7 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -197,6 +233,7 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -214,6 +251,7 @@ async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.U deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -230,6 +268,7 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def list_users_with_embedding(self) -> AsyncIterator[ListUsersWithEmbeddingRow]: @@ -255,6 +294,7 @@ async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[mo deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: @@ -272,6 +312,7 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: @@ -294,6 +335,7 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -311,6 +353,7 @@ async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -328,4 +371,23 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], + ) + + async def update_user_discord_id(self, *, discord_id: str, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER_DISCORD_ID), {"p1": discord_id, "p2": id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + avatar_key=row[9], + discord_id=row[10], ) diff --git a/db/queries/user.sql b/db/queries/user.sql index 61c7c59..c6c3a17 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -1,6 +1,6 @@ -- name: CreateUser :one -INSERT INTO users (email, hashed_password) -VALUES ($1, $2) +INSERT INTO users (email, hashed_password, discord_id) +VALUES ($1, $2, $3) RETURNING *; -- name: GetUserById :one @@ -19,6 +19,18 @@ SELECT * FROM users WHERE email = $1; +-- name: GetUserByDiscordId :one +SELECT * +FROM users +WHERE discord_id = $1; + +-- name: UpdateUserDiscordId :one +UPDATE users +SET discord_id = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: UpdateUserPassword :one UPDATE users SET hashed_password = $1, diff --git a/migrations/sql/down/add_discord_id_to_users.sql b/migrations/sql/down/add_discord_id_to_users.sql new file mode 100644 index 0000000..6592b63 --- /dev/null +++ b/migrations/sql/down/add_discord_id_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN IF EXISTS discord_id; +ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL; diff --git a/migrations/sql/up/add_discord_id_to_users.sql b/migrations/sql/up/add_discord_id_to_users.sql new file mode 100644 index 0000000..e39d1e2 --- /dev/null +++ b/migrations/sql/up/add_discord_id_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN discord_id VARCHAR(255) UNIQUE; +ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL; diff --git a/migrations/versions/27efe448f970_add_discord_id_to_users.py b/migrations/versions/27efe448f970_add_discord_id_to_users.py new file mode 100644 index 0000000..c3bf277 --- /dev/null +++ b/migrations/versions/27efe448f970_add_discord_id_to_users.py @@ -0,0 +1,27 @@ +"""add_discord_id_to_users + +Revision ID: 27efe448f970 +Revises: 7eb80a1e711a +Create Date: 2026-10-06 23:57:24.958872 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from migrations.helper import run_sql_up, run_sql_down + + +# revision identifiers, used by Alembic. +revision: str = '27efe448f970' +down_revision: Union[str, Sequence[str], None] = '7eb80a1e711a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_discord_id_to_users") + + +def downgrade() -> None: + run_sql_down("add_discord_id_to_users") diff --git a/pyproject.toml b/pyproject.toml index 89d4ad8..9ceac35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "pillow-heif>=1.3.0", "sqlalchemy>=2.0.47", "setuptools>=83.0.0", + "httpx>=0.28.1", ] [tool.ruff] diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py new file mode 100644 index 0000000..cb0033a --- /dev/null +++ b/tests/unit/test_mcdi_auth.py @@ -0,0 +1,337 @@ +import json +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from app.core.config import settings +from app.schema.request.mobile.auth import McdiExchangeRequest +from app.service.mcdi import McdiService +from app.service.users import AuthService + + +@pytest.fixture +def mcdi_service() -> McdiService: + return McdiService() + + +def test_get_sso_authorize_url(mcdi_service: McdiService) -> None: + redirect_uri = "https://app.example.com/callback" + state = "random-state-123" + url = mcdi_service.get_sso_authorize_url(redirect_uri, state) + + assert "/auth/sso/authorize?" in url + assert f"client_id={settings.MCDI_PROJECT_ID}" in url + assert f"server_id={settings.MCDI_SERVER_ID}" in url + assert "state=random-state-123" in url + assert "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" in url + + +@pytest.mark.asyncio +async def test_exchange_code_success(mcdi_service: McdiService) -> None: + expected_response = { + "token": "sess_123456", + "member": { + "id": "discord_999", + "username": "alice", + "email": "alice@microclub.info", + "isClubMember": True, + }, + "roles": [{"roleId": "r1", "roleName": "Bureau"}], + } + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = expected_response + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + res = await mcdi_service.exchange_code("code_abc", "https://redirect.uri") + + assert res["token"] == "sess_123456" + assert res["member"]["id"] == "discord_999" + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert kwargs["json"]["code"] == "code_abc" + assert kwargs["json"]["clientId"] == settings.MCDI_PROJECT_ID + + +@pytest.mark.asyncio +async def test_exchange_code_failure(mcdi_service: McdiService) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 400 + mock_resp.json.return_value = {"message": "Invalid callback code"} + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + with pytest.raises(HTTPException) as exc_info: + await mcdi_service.exchange_code("bad_code", "https://redirect.uri") + assert "Invalid callback code" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_validate_token_cached_hit(mcdi_service: McdiService) -> None: + redis = AsyncMock() + cached_payload = { + "member": {"id": "123", "email": "alice@club.net"}, + "roles": [], + } + redis.get.return_value = json.dumps(cached_payload) + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + data = await mcdi_service.validate_token_cached(redis, "token_xyz", ttl_seconds=60) + assert data == cached_payload + mock_post.assert_not_called() + + +@pytest.mark.asyncio +async def test_validate_token_cached_miss_populates_cache(mcdi_service: McdiService) -> None: + redis = AsyncMock() + redis.get.return_value = None + + mock_resp = MagicMock() + mock_resp.status_code = 200 + remote_payload = { + "member": {"id": "123", "email": "alice@club.net"}, + "roles": [], + } + mock_resp.json.return_value = remote_payload + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + data = await mcdi_service.validate_token_cached(redis, "token_xyz", ttl_seconds=60) + + assert data == remote_payload + mock_post.assert_called_once() + redis.set.assert_called_once() + call_args = redis.set.call_args + assert call_args[1]["expire"] == 60 + + +@pytest.mark.asyncio +async def test_logout_invalidates_cache_and_calls_mcdi(mcdi_service: McdiService) -> None: + redis = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + ok = await mcdi_service.logout("token_to_kill", redis=redis) + + assert ok is True + redis.delete.assert_called_once() + mock_post.assert_called_once() + assert "/auth/logout" in mock_post.call_args[0][0] + + +@pytest.mark.asyncio +async def test_users_service_mcdi_exchange_creates_new_user() -> None: + user_querier = AsyncMock() + device_querier = AsyncMock() + session_querier = AsyncMock() + refresh_token_querier = AsyncMock() + face_service = MagicMock() + mock_mcdi = AsyncMock() + redis = AsyncMock() + redis.incr.return_value = 1 + + auth_service = AuthService( + user_querier=user_querier, + device_querier=device_querier, + session_querier=session_querier, + refresh_token_querier=refresh_token_querier, + face_embedding_service=face_service, + mcdi_service=mock_mcdi, + ) + + mock_mcdi.exchange_code.return_value = { + "token": "mcdi_sess_tok", + "member": { + "id": "discord_12345", + "email": "bob@microclub.info", + "username": "bob", + }, + } + + user_querier.get_user_by_discord_id.return_value = None + user_querier.get_user_by_email.return_value = None + + new_user_mock = MagicMock() + new_user_mock.id = uuid.uuid4() + new_user_mock.email = "bob@microclub.info" + new_user_mock.blocked = False + user_querier.create_user.return_value = new_user_mock + user_querier.get_user_by_id_for_update.return_value = new_user_mock + + device_mock = MagicMock() + device_mock.id = uuid.uuid4() + device_querier.get_device_by_physical_id.return_value = None + device_querier.create_device.return_value = device_mock + + session_mock = MagicMock() + session_mock.id = uuid.uuid4() + session_mock.user_id = new_user_mock.id + session_mock.idle_expires_at = MagicMock() + session_mock.absolute_expires_at = MagicMock() + session_mock.last_active = MagicMock() + session_querier.upsert_session.return_value = session_mock + + async def _empty_async_iter(*args, **kwargs): + if False: + yield + + session_querier.evict_overflow_sessions = MagicMock(side_effect=_empty_async_iter) + + req = McdiExchangeRequest( + code="code_from_discord", + device_name="iPhone 15", + device_type="ios", + physical_device_id=uuid.uuid4(), + ) + + res = await auth_service.mcdi_exchange(redis, req) + + assert res.is_new_user is True + assert res.user_id == new_user_mock.id + user_querier.create_user.assert_called_once_with( + email="bob@microclub.info", + hashed_password=None, + discord_id="discord_12345", + ) + + +@pytest.mark.asyncio +async def test_users_service_mcdi_exchange_links_existing_user() -> None: + user_querier = AsyncMock() + device_querier = AsyncMock() + session_querier = AsyncMock() + refresh_token_querier = AsyncMock() + face_service = MagicMock() + mock_mcdi = AsyncMock() + redis = AsyncMock() + redis.incr.return_value = 1 + + auth_service = AuthService( + user_querier=user_querier, + device_querier=device_querier, + session_querier=session_querier, + refresh_token_querier=refresh_token_querier, + face_embedding_service=face_service, + mcdi_service=mock_mcdi, + ) + + mock_mcdi.exchange_code.return_value = { + "token": "mcdi_sess_tok_2", + "member": { + "id": "discord_existing_123", + "email": "existing@microclub.info", + "username": "existing_user", + }, + } + + existing_user = MagicMock() + existing_user.id = uuid.uuid4() + existing_user.email = "existing@microclub.info" + existing_user.blocked = False + + user_querier.get_user_by_discord_id.return_value = None + user_querier.get_user_by_email.return_value = existing_user + user_querier.update_user_discord_id.return_value = existing_user + user_querier.get_user_by_id_for_update.return_value = existing_user + + device_mock = MagicMock() + device_mock.id = uuid.uuid4() + device_querier.get_device_by_physical_id.return_value = None + device_querier.create_device.return_value = device_mock + + session_mock = MagicMock() + session_mock.id = uuid.uuid4() + session_mock.user_id = existing_user.id + session_mock.idle_expires_at = MagicMock() + session_mock.absolute_expires_at = MagicMock() + session_mock.last_active = MagicMock() + session_querier.upsert_session.return_value = session_mock + + async def _empty_async_iter(*args, **kwargs): + if False: + yield + + session_querier.evict_overflow_sessions = MagicMock(side_effect=_empty_async_iter) + + req = McdiExchangeRequest( + code="code_existing", + device_name="Pixel 8", + device_type="android", + physical_device_id=uuid.uuid4(), + ) + + res = await auth_service.mcdi_exchange(redis, req) + + assert res.is_new_user is False + assert res.user_id == existing_user.id + user_querier.update_user_discord_id.assert_called_once_with( + discord_id="discord_existing_123", + id=existing_user.id, + ) + user_querier.create_user.assert_not_called() + + +@pytest.mark.asyncio +async def test_cookie_auth_get_current_staff_user_success() -> None: + from app.deps.cookie_auth import get_current_staff_user + + container = MagicMock() + container.redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.staff_user_querier = AsyncMock() + + container.mcdi_service.validate_token_cached.return_value = { + "member": {"email": "admin@microclub.info", "id": "111"}, + "roles": [{"roleName": "Admin"}], + } + + mock_staff = MagicMock() + mock_staff.email = "admin@microclub.info" + container.staff_user_querier.get_staff_user_by_email.return_value = mock_staff + + staff = await get_current_staff_user(container=container, token="valid_staff_tok") + assert staff == mock_staff + + +@pytest.mark.asyncio +async def test_cookie_auth_get_current_staff_user_unauthorized_if_not_staff() -> None: + from app.deps.cookie_auth import get_current_staff_user + + container = MagicMock() + container.redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.staff_user_querier = AsyncMock() + + container.mcdi_service.validate_token_cached.return_value = { + "member": {"email": "stranger@discord.com", "id": "999"}, + "roles": [], + } + + container.staff_user_querier.get_staff_user_by_email.return_value = None + + with pytest.raises(HTTPException) as exc_info: + await get_current_staff_user(container=container, token="valid_mcdi_tok") + assert exc_info.value.status_code == 401 + assert "not authorized" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_token_auth_validate_mcdi_session_revoked() -> None: + from app.deps.token_auth import _validate_mcdi_session + + container = MagicMock() + redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.mcdi_service.validate_token_cached.side_effect = Exception("revoked") + + with pytest.raises(HTTPException) as exc_info: + await _validate_mcdi_session(container, redis, "revoked_tok") + assert exc_info.value.status_code == 401 + assert "MCDI session invalid" in exc_info.value.detail + diff --git a/uv.lock b/uv.lock index b403b75..835c8cf 100644 --- a/uv.lock +++ b/uv.lock @@ -308,6 +308,7 @@ dependencies = [ { name = "filetype" }, { name = "firebase-admin" }, { name = "greenlet" }, + { name = "httpx" }, { name = "insightface" }, { name = "miniopy-async" }, { name = "nats-py" }, @@ -347,6 +348,7 @@ requires-dist = [ { name = "filetype", specifier = ">=1.2.0" }, { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "insightface", specifier = ">=0.7.3" }, { name = "miniopy-async", specifier = ">=1.23.4" }, { name = "nats-py", specifier = ">=2.14.0" },