From e1f4edacfbce3c72fbe5b929dd05288d2457b372 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 6 Oct 2026 23:53:35 +0100 Subject: [PATCH 1/9] feat(auth): add MCDI environment variables and configuration --- .env.example | 9 ++++++++- .env.staging.example | 9 ++++++++- app/core/config.py | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) 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/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", From 17c6ab35c2300db47d112f1743bc407213d2ae0a Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 6 Oct 2026 23:59:45 +0100 Subject: [PATCH 2/9] feat(db): add discord_id to users table --- db/generated/models.py | 1 + db/generated/user.py | 63 ++++++++++++++----- db/queries/user.sql | 9 ++- .../sql/down/add_discord_id_to_users.sql | 2 + migrations/sql/up/add_discord_id_to_users.sql | 2 + .../27efe448f970_add_discord_id_to_users.py | 27 ++++++++ 6 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 migrations/sql/down/add_discord_id_to_users.sql create mode 100644 migrations/sql/up/add_discord_id_to_users.sql create mode 100644 migrations/versions/27efe448f970_add_discord_id_to_users.py 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..6fc8d7e 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,7 @@ 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 """ @@ -136,8 +143,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], discord_id: Optional[str]) -> 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 +158,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 +173,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 +206,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 +224,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 +242,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 +259,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 +285,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 +303,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 +326,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 +344,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 +362,5 @@ 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], ) diff --git a/db/queries/user.sql b/db/queries/user.sql index 61c7c59..e1d0b88 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,11 @@ SELECT * FROM users WHERE email = $1; +-- name: GetUserByDiscordId :one +SELECT * +FROM users +WHERE discord_id = $1; + -- 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") From 1b00176d4633dc97bed20c691ccf8b1494735e53 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:05:32 +0100 Subject: [PATCH 3/9] feat(mcdi): implement McdiService client --- app/container.py | 3 +++ app/service/mcdi.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 2 ++ 4 files changed, 61 insertions(+) create mode 100644 app/service/mcdi.py diff --git a/app/container.py b/app/container.py index 0024ea6..cb2ccb6 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 @@ -162,6 +163,8 @@ def __init__( querier=self.stats_querier, ) + self.mcdi_service = McdiService() + async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), diff --git a/app/service/mcdi.py b/app/service/mcdi.py new file mode 100644 index 0000000..43ad4c9 --- /dev/null +++ b/app/service/mcdi.py @@ -0,0 +1,55 @@ +import httpx +from typing import Any +from app.core.config import settings +from app.core.exceptions import AppException + + +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 + + async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: + """Exchanges an OAuth code for a session token and member info.""" + 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() as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + # MCDI standardizes errors + error_detail = response.json().get("message", "Failed to exchange MCDI code") + raise AppException.unauthorized(f"MCDI Error: {error_detail}") + return response.json() + except httpx.RequestError as 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.""" + 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() as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + raise AppException.unauthorized("Invalid or expired MCDI session") + return response.json() + except httpx.RequestError as e: + raise AppException.internal_error(f"MCDI service unavailable: {e}") 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/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" }, From 0ab5fd459ccbf5b76170c5cd8e93b3d4d0176855 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:08:15 +0100 Subject: [PATCH 4/9] refactor(auth-web): migrate staff login to MCDI SSO --- app/deps/cookie_auth.py | 40 ++++++++---------- app/router/web/auth.py | 87 ++++++++++++++++++++++++++++----------- app/service/staff_user.py | 21 ---------- 3 files changed, 81 insertions(+), 67 deletions(-) diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 456fe68..1a8eaf6 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: @@ -18,26 +15,23 @@ async def get_current_staff_user( ) -> 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 - ) - if staff_user is None: - raise AppException.not_found("Staff user not found") - - return staff_user + + # 1. Validate token with MCDI (live verification) + try: + mcdi_data = await container.mcdi_service.validate_token(token) + 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 def ensure_multi_team_lead_staff(current_staff_user: StaffUser) -> StaffUser: diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 76f945b..c9ef6ca 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,42 +1,80 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, Cookie from app.container import Container, get_container from fastapi import Response +from fastapi.responses import RedirectResponse from app.core.config import settings 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 +from app.core.exceptions import AppException +import urllib.parse +import secrets 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, +@router.get("/mcdi/login") +async def mcdi_login(r: Response) -> RedirectResponse: + state = secrets.token_urlsafe(16) + r.set_cookie( + "mcdi_state", + state, + httponly=True, + max_age=300, + secure=settings.environment != "dev", + samesite="lax" + ) + params = urllib.parse.urlencode({ + "client_id": settings.MCDI_PROJECT_ID, + "redirect_uri": settings.MCDI_REDIRECT_URI_WEB, + "state": state + }) + url = f"{settings.MCDI_BASE_URL.replace('/api', '')}/api/auth/sso/authorize?{params}" + return RedirectResponse(url) + + +@router.get("/mcdi/callback") +async def mcdi_callback( + request: Request, r: Response, + code: str, + state: str, container: Container = Depends(get_container), -) -> WebAuthResponse: +) -> RedirectResponse: + cookie_state = request.cookies.get("mcdi_state") + if not cookie_state or state != cookie_state: + raise AppException.unauthorized("Invalid state parameter") - authResponse = await container.staff_user_service.admin_login( - email=req.email, password=req.password - ) - r.set_cookie( + mcdi_data = await container.mcdi_service.exchange_code(code, settings.MCDI_REDIRECT_URI_WEB) + + # 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") + + # Let the frontend dashboard URL be determined by CORS origin or environment + 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 +93,14 @@ 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: + # We could also call MCDI logout endpoint here to kill the session globally or for this project + # But removing the cookie is the minimum required. r.delete_cookie( key="access_token", httponly=True, - secure=True, - samesite="strict", + secure=settings.environment != "dev", + samesite="lax", ) diff --git a/app/service/staff_user.py b/app/service/staff_user.py index b72cd89..ace9644 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -101,28 +101,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( From c42eca5d78135ebc22ed1924094b0ae20dce9748 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:12:54 +0100 Subject: [PATCH 5/9] refactor(auth-mobile): migrate mobile auth to MCDI SSO --- app/container.py | 5 +- app/deps/token_auth.py | 11 ++ app/router/mobile/auth.py | 94 +++-------- app/schema/request/mobile/auth.py | 53 +++--- app/service/session.py | 3 + app/service/users.py | 257 ++++++------------------------ 6 files changed, 106 insertions(+), 317 deletions(-) diff --git a/app/container.py b/app/container.py index cb2ccb6..726c376 100644 --- a/app/container.py +++ b/app/container.py @@ -87,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, @@ -98,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( @@ -163,8 +166,6 @@ def __init__( querier=self.stats_querier, ) - self.mcdi_service = McdiService() - async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 12c6718..b4a30d6 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -52,6 +52,15 @@ async def get_current_mobile_user( if cached.blocked: raise HTTPException(status_code=403, detail="User is blocked") + + # MCDI validation in fast path (async task or inline? We should inline for security if required, but for mobile we can do it inline since redis is fast) + # But wait, MCDI validate_token requires a token. + if cached.mcdi_token: + try: + await container.mcdi_service.validate_token(cached.mcdi_token) + except Exception: + raise HTTPException(status_code=401, detail="MCDI session invalid") + if ( now - cached.last_active ).total_seconds() > settings.SESSION_ACTIVITY_THROTTLE_SECONDS: @@ -73,6 +82,7 @@ async def get_current_mobile_user( blocked=cached.blocked, ttl=settings.MOBILE_SESSION_TTL_SECONDS, last_active=now, + mcdi_token=cached.mcdi_token, ) return MobileUserSchema( @@ -111,6 +121,7 @@ async def get_current_mobile_user( blocked=user.blocked, ttl=settings.MOBILE_SESSION_TTL_SECONDS, last_active=session.last_active, + mcdi_token=None, # MCDI token not available on slow path, will be re-fetched on login ) return MobileUserSchema( diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 81efcdb..c3b0572 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -13,10 +13,11 @@ from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( - MobileLoginRequest, - MobileRegisterRequest, - RegisterVerifyRequest, - ResendOtpRequest, + McdiExchangeRequest, + + + + RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, @@ -28,96 +29,39 @@ MobileAuthResponse, SessionSchema, UserSchema, - RegisterPendingResponse, ) router = APIRouter(prefix="/auth") @router.post( - "/register", - response_model=RegisterPendingResponse | MobileAuthResponse, + "/mcdi/exchange", + response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))], ) -async def mobile_register( - req: MobileRegisterRequest, +async def mcdi_exchange( + req: McdiExchangeRequest, request: Request, container: Container = Depends(get_container), -) -> RegisterPendingResponse | MobileAuthResponse: +) -> MobileAuthResponse: client_ip = get_client_ip(request) - result = await container.auth_service.mobile_register( + result = await container.auth_service.mcdi_exchange( container.redis, req, client_ip=client_ip ) - if isinstance(result, MobileAuthResponse): + if result.is_new_user: await container.audit_service.create_record( event_type=AuditEventType.USER_SIGNUP, user_id=result.user_id, - metadata={"endpoint": "register", "otp_activated": False}, + 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( - "/register/resend-otp", - response_model=RegisterPendingResponse, - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def mobile_register_resend_otp( - req: ResendOtpRequest, - request: Request, - container: Container = Depends(get_container), -) -> RegisterPendingResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.mobile_register_resend_otp( - container.redis, req.email, client_ip=client_ip - ) - return result - - -@router.post( - "/register/verify", - response_model=MobileAuthResponse, - dependencies=[Depends(RateLimiter(requests=10, window=60))], -) -async def mobile_register_verify( - req: RegisterVerifyRequest, - request: Request, - container: Container = Depends(get_container), -) -> MobileAuthResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.verify_mobile_register( - container.redis, req, client_ip=client_ip - ) - await container.audit_service.create_record( - event_type=AuditEventType.USER_SIGNUP, - user_id=result.user_id, - metadata={"endpoint": "register_verify"}, - ) - return result - - -@router.post( - "/login", - response_model=MobileAuthResponse, - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def mobile_login( - req: MobileLoginRequest, - request: Request, - container: Container = Depends(get_container), -) -> MobileAuthResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.mobile_login( - container.redis, req, client_ip=client_ip - ) - await container.audit_service.create_record( - event_type=AuditEventType.USER_LOGIN, - user_id=result.user_id, - metadata={"endpoint": "login"}, - ) - return result - - @router.post( "/refresh", response_model=MobileAuthResponse, diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 592b947..563bbe6 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -44,43 +44,34 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: return stripped -class MobileRegisterRequest(MobileAuthBaseRequest): - @field_validator("password") - @classmethod - def _validate_password_complexity(cls, value: str) -> str: - if not any(c.isupper() for c in value): - raise ValueError("Password must contain at least one uppercase letter") - if not any(c.islower() for c in value): - raise ValueError("Password must contain at least one lowercase letter") - if not any(c.isdigit() for c in value): - raise ValueError("Password must contain at least one digit") - if not any(not c.isalnum() for c in value): - raise ValueError("Password must contain at least one special character") - return value - - -class MobileLoginRequest(MobileAuthBaseRequest): - pass - - -class RegisterVerifyRequest(MobileAuthBaseRequest): - otp: str = Field( +class McdiExchangeRequest(BaseModel): + code: str + device_name: str = Field( ..., - min_length=6, - max_length=6, - description="The 6-digit OTP code sent via email", + 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 - -class ResendOtpRequest(BaseModel): - email: EmailStr = Field(..., max_length=255) - - @field_validator("email", mode="before") + @field_validator("device_name", "device_type", mode="before") @classmethod - def _normalize_email(cls, value: object) -> object: + def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: if not isinstance(value, str): return value - return value.strip().lower() + 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 RefreshTokenRequest(BaseModel): 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/users.py b/app/service/users.py index d534ff5..e076d28 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,15 +1,14 @@ from datetime import datetime, timedelta, timezone import uuid +import sqlalchemy from collections.abc import AsyncIterable -from typing import Optional +from typing import Optional, Any from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, - verify_password, create_acces_mobile_token, create_raw_refresh_token, hash_refresh_token, @@ -19,16 +18,11 @@ from app.core.config import settings from app.infra.redis import RedisClient from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.service.mcdi import McdiService from app.schema.request.mobile.auth import ( - MobileAuthBaseRequest, - MobileLoginRequest, - MobileRegisterRequest, - RegisterVerifyRequest, + McdiExchangeRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse -from app.infra.nats import NatsClient -import secrets -import json +from app.schema.response.mobile.auth import MobileAuthResponse 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 +50,19 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, + mcdi_service: McdiService, ): 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 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 @@ -98,222 +94,62 @@ async def _ensure_device_for_login( raise AppException.internal_error("Failed to create device") return device - async def mobile_login( + async def mcdi_exchange( self, redis: RedisClient, - req: MobileLoginRequest, + req: McdiExchangeRequest, client_ip: Optional[str] = None, ) -> MobileAuthResponse: - logger.info("mobile_login attempt") + 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, - ) - await self.check_rate_limit( - redis, - f"rate:email:{req.email}", - max_attempts, - window, - ) + await self.check_rate_limit(redis, f"rate:ip:{client_ip}", max_attempts, window) - existing_user = await self.user_querier.get_user_by_email(email=req.email) - if existing_user is None: - logger.warning("login attempt: user_not_found") - raise AppException.unauthorized( - "User not found; consider registering instead" - ) - if existing_user.blocked: - logger.warning("login attempt: user_blocked user_id=%s", existing_user.id) - raise AppException.forbidden("User is blocked") - if not verify_password(req.password, existing_user.hashed_password or ""): - logger.warning( - "login attempt: invalid_credentials user_id=%s", existing_user.id - ) - raise AppException.unauthorized("Invalid credentials") + mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) - locked_user = await self.user_querier.get_user_by_id_for_update( - id=existing_user.id - ) - if not locked_user: - raise AppException.unauthorized("User not found") - if locked_user.blocked: - logger.warning( - "login attempt: user_blocked_at_commit user_id=%s", locked_user.id - ) - raise AppException.forbidden("User is blocked") + email = mcdi_data.get("member", {}).get("email") + discord_id = mcdi_data.get("member", {}).get("discordId") + mcdi_token = mcdi_data.get("token") - logger.info("login success user_id=%s", locked_user.id) - return await self._create_mobile_session( - redis=redis, - user=locked_user, - req=req, - is_new_user=False, - ) + if not email or not discord_id or not mcdi_token: + raise AppException.unauthorized("Invalid MCDI payload") - async def mobile_register( - self, - redis: RedisClient, - req: MobileRegisterRequest, - client_ip: Optional[str] = None, - ) -> RegisterPendingResponse | MobileAuthResponse: - logger.info("mobile_register 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, - ) - await self.check_rate_limit( - redis, - f"rate:email:{req.email}", - max_attempts, - window, - ) - - existing_user = await self.user_querier.get_user_by_email(email=req.email) - if existing_user is not None: - logger.warning("register attempt: email_already_in_use") - raise AppException.conflict("Email already in use; please login instead") - - hashed = hash_password(req.password) - - if not settings.OTP_ACTIVATED: - logger.info("OTP deactivated, creating user directly") - user = await self.user_querier.create_user( - email=req.email, hashed_password=hashed - ) - if not user: - raise AppException.internal_error("Failed to create user") - return await self._create_mobile_session( - redis=redis, - user=user, - req=req, - is_new_user=True, - ) + await self.check_rate_limit(redis, f"rate:email:{email}", max_attempts, window) - pending_key = f"pending_user:{req.email}" - pending_data = { - "hashed_password": hashed, - } - - # Save in Redis for 10 minutes (600 seconds) - await redis.set(pending_key, json.dumps(pending_data), expire=600) - - 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.js_publish( - "email.send_otp", - json.dumps({"email": req.email, "otp": otp}).encode("utf-8"), - ) - - logger.info("register success, OTP sent") - return RegisterPendingResponse( - message="OTP sent to email", status="pending_verification", email=req.email - ) + user = await self.user_querier.get_user_by_discord_id(discord_id=discord_id) + is_new_user = False - async def mobile_register_resend_otp( - self, - redis: RedisClient, - email: str, - client_ip: Optional[str] = None, - ) -> RegisterPendingResponse: - logger.info("resend_otp attempt for %s", email) - 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, - ) - await self.check_rate_limit( - redis, - f"rate:email:{email}", - max_attempts, - window, - ) - - pending_key = f"pending_user:{email}" - raw_data = await redis.get(pending_key) - if not raw_data: - raise AppException.not_found("No pending registration found for this email") - - 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.js_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( - message="New OTP sent to email", status="pending_verification", email=email - ) - - async def verify_mobile_register( - self, - redis: RedisClient, - req: RegisterVerifyRequest, - client_ip: Optional[str] = None, - ) -> MobileAuthResponse: - otp_key = f"otp:{req.email}" - stored_otp = await redis.get(otp_key) - - if not stored_otp or stored_otp != req.otp: - raise AppException.unauthorized("Invalid or expired OTP") - - pending_key = f"pending_user:{req.email}" - raw_data = await redis.get(pending_key) - if not raw_data: - raise AppException.unauthorized("Registration session expired") - - data = json.loads(raw_data) + if not user: + user = await self.user_querier.get_user_by_email(email=email) + if user: + # Update with discord_id using direct DB execution since generated query doesn't exist + await self.user_querier._conn.execute( + sqlalchemy.text("UPDATE users SET discord_id = :d WHERE id = :id"), + {"d": discord_id, "id": user.id} + ) + 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 - try: - user = await self.user_querier.create_user( - email=req.email, hashed_password=data["hashed_password"] - ) - if not user: - raise AppException.internal_error("Failed to create user") - except SQLAlchemyError as exc: - logger.error("Failed to create user: %s", exc) - raise DBException.handle(exc) + if user.blocked: + raise AppException.forbidden("User is blocked") - # Clean up redis - await redis.delete(otp_key) - await redis.delete(pending_key) + 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") - logger.info("register verify success user_id=%s", user.id) return await self._create_mobile_session( redis=redis, - user=user, + user=locked_user, req=req, - is_new_user=True, + is_new_user=is_new_user, + mcdi_token=mcdi_token, ) async def _create_mobile_session( @@ -321,8 +157,9 @@ async def _create_mobile_session( *, 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 +213,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( @@ -582,6 +420,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") From 7faddb4aa43d60cfb2961503d166f3cfdb5987a1 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:37:32 +0100 Subject: [PATCH 6/9] refactor(mcdi): harden OAuth flow, Redis token caching, and spec alignment --- app/deps/cookie_auth.py | 10 +- app/deps/token_auth.py | 144 +++++++++++---------- app/router/web/auth.py | 48 +++---- app/schema/request/mobile/auth.py | 32 +++++ app/service/mcdi.py | 118 ++++++++++++++++-- app/service/staff_user.py | 3 +- app/service/users.py | 23 ++-- db/generated/user.py | 27 ++++ db/queries/user.sql | 7 ++ tests/unit/test_mcdi_auth.py | 201 ++++++++++++++++++++++++++++++ 10 files changed, 499 insertions(+), 114 deletions(-) create mode 100644 tests/unit/test_mcdi_auth.py diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 1a8eaf6..4fe9b1e 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -14,11 +14,15 @@ 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") + raise AppException.unauthorized("Authentication token required") - # 1. Validate token with MCDI (live verification) + # 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(token) + mcdi_data = await container.mcdi_service.validate_token_cached( + redis=container.redis, + token=token, + ttl_seconds=60, + ) except Exception: raise AppException.unauthorized("MCDI session invalid or expired") diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index b4a30d6..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,51 +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") - - - # MCDI validation in fast path (async task or inline? We should inline for security if required, but for mobile we can do it inline since redis is fast) - # But wait, MCDI validate_token requires a token. - if cached.mcdi_token: - try: - await container.mcdi_service.validate_token(cached.mcdi_token) - except Exception: - raise HTTPException(status_code=401, detail="MCDI session invalid") - - 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, - mcdi_token=cached.mcdi_token, - ) + _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, @@ -98,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, @@ -121,7 +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, # MCDI token not available on slow path, will be re-fetched on login + mcdi_token=None, ) return MobileUserSchema( @@ -135,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/web/auth.py b/app/router/web/auth.py index c9ef6ca..00f0c12 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,43 +1,42 @@ -from fastapi import APIRouter, Depends, Request, Cookie -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.schema.response.web.staff_user import StaffUserSchema from db.generated.models import StaffUser -from app.core.exceptions import AppException -import urllib.parse -import secrets router = APIRouter(prefix="/auth") @router.get("/mcdi/login") -async def mcdi_login(r: Response) -> RedirectResponse: +async def mcdi_login( + container: Container = Depends(get_container), +) -> RedirectResponse: state = secrets.token_urlsafe(16) - r.set_cookie( - "mcdi_state", - state, + 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" + samesite="lax", ) - params = urllib.parse.urlencode({ - "client_id": settings.MCDI_PROJECT_ID, - "redirect_uri": settings.MCDI_REDIRECT_URI_WEB, - "state": state - }) - url = f"{settings.MCDI_BASE_URL.replace('/api', '')}/api/auth/sso/authorize?{params}" - return RedirectResponse(url) + return response @router.get("/mcdi/callback") async def mcdi_callback( request: Request, - r: Response, code: str, state: str, container: Container = Depends(get_container), @@ -46,7 +45,10 @@ async def mcdi_callback( if not cookie_state or state != cookie_state: raise AppException.unauthorized("Invalid state parameter") - mcdi_data = await container.mcdi_service.exchange_code(code, settings.MCDI_REDIRECT_URI_WEB) + mcdi_data = await container.mcdi_service.exchange_code( + code=code, + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, + ) # Extract email and verify staff access email = mcdi_data.get("member", {}).get("email") @@ -61,7 +63,6 @@ async def mcdi_callback( if not token: raise AppException.internal_error("MCDI did not return a session token") - # Let the frontend dashboard URL be determined by CORS origin or environment frontend_url = settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173" redirect_res = RedirectResponse(url=f"{frontend_url}/dashboard") @@ -96,8 +97,9 @@ async def admin_logout( token: str | None = Cookie(default=None, alias="access_token"), container: Container = Depends(get_container), ) -> None: - # We could also call MCDI logout endpoint here to kill the session globally or for this project - # But removing the cookie is the minimum required. + if token: + await container.mcdi_service.logout(token, redis=container.redis) + r.delete_cookie( key="access_token", httponly=True, diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 563bbe6..5685770 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -69,11 +69,43 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: if info.field_name == "device_type": return stripped.lower() return stripped +class MobileRegisterRequest(MobileAuthBaseRequest): + @field_validator("password") + @classmethod + def _validate_password_complexity(cls, value: str) -> str: + if not any(c.isupper() for c in value): + raise ValueError("Password must contain at least one uppercase letter") + if not any(c.islower() for c in value): + raise ValueError("Password must contain at least one lowercase letter") + if not any(c.isdigit() for c in value): + raise ValueError("Password must contain at least one digit") + if not any(not c.isalnum() for c in value): + raise ValueError("Password must contain at least one special character") + return value + + +class MobileLoginRequest(MobileAuthBaseRequest): + pass +class RegisterVerifyRequest(MobileAuthBaseRequest): + otp: str = Field( + ..., + min_length=6, + max_length=6, + description="The 6-digit OTP code sent via email", + ) +class ResendOtpRequest(BaseModel): + email: EmailStr = Field(..., max_length=255) + @field_validator("email", mode="before") + @classmethod + 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 index 43ad4c9..e15cc6a 100644 --- a/app/service/mcdi.py +++ b/app/service/mcdi.py @@ -1,7 +1,16 @@ -import httpx +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: @@ -9,9 +18,39 @@ 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 code for a session token and member info.""" + """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, @@ -23,33 +62,88 @@ async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: "redirectUri": redirect_uri, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: try: response = await client.post(url, json=payload, headers=headers) if response.status_code != 200: - # MCDI standardizes errors - error_detail = response.json().get("message", "Failed to exchange MCDI code") - raise AppException.unauthorized(f"MCDI Error: {error_detail}") + 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.""" + """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 - } + payload = {"token": token} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: try: response = await client.post(url, json=payload, headers=headers) if response.status_code != 200: - raise AppException.unauthorized("Invalid or expired MCDI session") + 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/staff_user.py b/app/service/staff_user.py index ace9644..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 diff --git a/app/service/users.py b/app/service/users.py index e076d28..f57e688 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,6 +1,5 @@ from datetime import datetime, timedelta, timezone import uuid -import sqlalchemy from collections.abc import AsyncIterable from typing import Optional, Any @@ -50,14 +49,14 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, - mcdi_service: McdiService, + 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 + self.mcdi_service = mcdi_service or McdiService() async def _ensure_device_for_login( self, @@ -109,8 +108,9 @@ async def mcdi_exchange( mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) - email = mcdi_data.get("member", {}).get("email") - discord_id = mcdi_data.get("member", {}).get("discordId") + 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: @@ -124,11 +124,12 @@ async def mcdi_exchange( if not user: user = await self.user_querier.get_user_by_email(email=email) if user: - # Update with discord_id using direct DB execution since generated query doesn't exist - await self.user_querier._conn.execute( - sqlalchemy.text("UPDATE users SET discord_id = :d WHERE id = :id"), - {"d": discord_id, "id": user.id} + 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 @@ -346,6 +347,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) ) diff --git a/db/generated/user.py b/db/generated/user.py index 6fc8d7e..ea69682 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -139,6 +139,15 @@ class ListUsersWithEmbeddingRow: """ +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 +""" + + class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn @@ -364,3 +373,21 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid 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 e1d0b88..c6c3a17 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -24,6 +24,13 @@ 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/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py new file mode 100644 index 0000000..339bfe6 --- /dev/null +++ b/tests/unit/test_mcdi_auth.py @@ -0,0 +1,201 @@ +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", + ) From 5d459acabeefe0e048294ae5190352160f69c051 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:45:55 +0100 Subject: [PATCH 7/9] test(mcdi): expand unit test suite to 11 test cases and cover staff auth and account linking --- db/generated/user.py | 2 +- tests/unit/test_mcdi_auth.py | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/db/generated/user.py b/db/generated/user.py index ea69682..7fb2167 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -152,7 +152,7 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def create_user(self, *, email: str, hashed_password: Optional[str], discord_id: Optional[str]) -> Optional[models.User]: + 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 diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index 339bfe6..cb0033a 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -199,3 +199,139 @@ async def _empty_async_iter(*args, **kwargs): 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 + From e6588dd08e77bd36fad9d62e4b756923ad80bdf0 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:58:12 +0100 Subject: [PATCH 8/9] feat(auth): restore legacy mobile auth endpoints for backward compatibility This commit restores the legacy mobile email/password and OTP login/registration endpoints and service methods, ensuring backward compatibility alongside the new MCDI SSO flow. Also includes minor test file formatting. --- app/router/mobile/auth.py | 93 +++++++++++++- app/service/users.py | 229 ++++++++++++++++++++++++++++++++++- tests/unit/test_mcdi_auth.py | 2 +- 3 files changed, 318 insertions(+), 6 deletions(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index c3b0572..5e64b30 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -14,10 +14,10 @@ from app.schema.request.mobile.auth import ( McdiExchangeRequest, - - - - + MobileLoginRequest, + MobileRegisterRequest, + RegisterVerifyRequest, + ResendOtpRequest, RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, @@ -29,11 +29,96 @@ MobileAuthResponse, SessionSchema, UserSchema, + RegisterPendingResponse, ) router = APIRouter(prefix="/auth") +@router.post( + "/register", + response_model=RegisterPendingResponse | MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_register( + req: MobileRegisterRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse | MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_register( + container.redis, req, client_ip=client_ip + ) + if isinstance(result, MobileAuthResponse): + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + metadata={"endpoint": "register", "otp_activated": False}, + ) + return result + + +@router.post( + "/register/resend-otp", + response_model=RegisterPendingResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_register_resend_otp( + req: ResendOtpRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_register_resend_otp( + container.redis, req.email, client_ip=client_ip + ) + return result + + +@router.post( + "/register/verify", + response_model=MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=10, window=60))], +) +async def mobile_register_verify( + req: RegisterVerifyRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.verify_mobile_register( + container.redis, req, client_ip=client_ip + ) + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + metadata={"endpoint": "register_verify"}, + ) + return result + + +@router.post( + "/login", + response_model=MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_login( + req: MobileLoginRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_login( + container.redis, req, client_ip=client_ip + ) + await container.audit_service.create_record( + event_type=AuditEventType.USER_LOGIN, + user_id=result.user_id, + metadata={"endpoint": "login"}, + ) + return result + + @router.post( "/mcdi/exchange", response_model=MobileAuthResponse, diff --git a/app/service/users.py b/app/service/users.py index f57e688..44d6fe4 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -5,9 +5,12 @@ from fastapi import HTTPException +import json +import secrets from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, + verify_password, create_acces_mobile_token, create_raw_refresh_token, hash_refresh_token, @@ -16,12 +19,18 @@ ) 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 ( McdiExchangeRequest, + MobileLoginRequest, + MobileRegisterRequest, + RegisterVerifyRequest, + MobileAuthBaseRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse +from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse +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 @@ -93,6 +102,224 @@ async def _ensure_device_for_login( raise AppException.internal_error("Failed to create device") return device + async def mobile_login( + self, + redis: RedisClient, + req: MobileLoginRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + logger.info("mobile_login 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, + ) + await self.check_rate_limit( + redis, + f"rate:email:{req.email}", + max_attempts, + window, + ) + + existing_user = await self.user_querier.get_user_by_email(email=req.email) + if existing_user is None: + logger.warning("login attempt: user_not_found") + raise AppException.unauthorized( + "User not found; consider registering instead" + ) + if existing_user.blocked: + logger.warning("login attempt: user_blocked user_id=%s", existing_user.id) + raise AppException.forbidden("User is blocked") + if not verify_password(req.password, existing_user.hashed_password or ""): + logger.warning( + "login attempt: invalid_credentials user_id=%s", existing_user.id + ) + raise AppException.unauthorized("Invalid credentials") + + locked_user = await self.user_querier.get_user_by_id_for_update( + id=existing_user.id + ) + if not locked_user: + raise AppException.unauthorized("User not found") + if locked_user.blocked: + logger.warning( + "login attempt: user_blocked_at_commit user_id=%s", locked_user.id + ) + raise AppException.forbidden("User is blocked") + + logger.info("login success user_id=%s", locked_user.id) + return await self._create_mobile_session( + redis=redis, + user=locked_user, + req=req, + is_new_user=False, + ) + + async def mobile_register( + self, + redis: RedisClient, + req: MobileRegisterRequest, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse | MobileAuthResponse: + logger.info("mobile_register 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, + ) + await self.check_rate_limit( + redis, + f"rate:email:{req.email}", + max_attempts, + window, + ) + + existing_user = await self.user_querier.get_user_by_email(email=req.email) + if existing_user is not None: + logger.warning("register attempt: email_already_in_use") + raise AppException.conflict("Email already in use; please login instead") + + hashed = hash_password(req.password) + + if not settings.OTP_ACTIVATED: + logger.info("OTP deactivated, creating user directly") + user = await self.user_querier.create_user( + email=req.email, hashed_password=hashed + ) + if not user: + raise AppException.internal_error("Failed to create user") + return await self._create_mobile_session( + redis=redis, + user=user, + req=req, + is_new_user=True, + ) + + pending_key = f"pending_user:{req.email}" + pending_data = { + "hashed_password": hashed, + } + + # Save in Redis for 10 minutes (600 seconds) + await redis.set(pending_key, json.dumps(pending_data), expire=600) + + 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.js_publish( + "email.send_otp", + json.dumps({"email": req.email, "otp": otp}).encode("utf-8"), + ) + + logger.info("register success, OTP sent") + return RegisterPendingResponse( + message="OTP sent to email", status="pending_verification", email=req.email + ) + + async def mobile_register_resend_otp( + self, + redis: RedisClient, + email: str, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse: + logger.info("resend_otp attempt for %s", email) + 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, + ) + await self.check_rate_limit( + redis, + f"rate:email:{email}", + max_attempts, + window, + ) + + pending_key = f"pending_user:{email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.not_found("No pending registration found for this email") + + 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.js_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( + message="New OTP sent to email", status="pending_verification", email=email + ) + + async def verify_mobile_register( + self, + redis: RedisClient, + req: RegisterVerifyRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + otp_key = f"otp:{req.email}" + stored_otp = await redis.get(otp_key) + + if not stored_otp or stored_otp != req.otp: + raise AppException.unauthorized("Invalid or expired OTP") + + pending_key = f"pending_user:{req.email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.unauthorized("Registration session expired") + + data = json.loads(raw_data) + + try: + user = await self.user_querier.create_user( + email=req.email, hashed_password=data["hashed_password"] + ) + if not user: + raise AppException.internal_error("Failed to create user") + except SQLAlchemyError as exc: + logger.error("Failed to create user: %s", exc) + raise DBException.handle(exc) + + # Clean up redis + await redis.delete(otp_key) + await redis.delete(pending_key) + + logger.info("register verify success user_id=%s", user.id) + return await self._create_mobile_session( + redis=redis, + user=user, + req=req, + is_new_user=True, + ) + async def mcdi_exchange( self, redis: RedisClient, diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index cb0033a..2ced974 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -329,7 +329,7 @@ async def test_token_auth_validate_mcdi_session_revoked() -> None: 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 From a1b0e657533c60a9eb2032863893f205f6e1ca2e Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 01:15:16 +0100 Subject: [PATCH 9/9] fix(lint): remove unused import and trailing whitespace --- app/service/users.py | 1 - tests/unit/test_mcdi_auth.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 44d6fe4..20963f5 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -27,7 +27,6 @@ MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, - MobileAuthBaseRequest, ) from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse from sqlalchemy.exc import SQLAlchemyError diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index 2ced974..cb0033a 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -329,7 +329,7 @@ async def test_token_auth_validate_mcdi_session_revoked() -> None: 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