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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ 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_BASE_URL="https://mcid.microclub.info/api"
MCDI_SERVER_ID="900000000000000001"
MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/admin/auth/mcdi/callback"
MCDI_REDIRECT_URI_MOBILE="multai://auth/callback"
3 changes: 1 addition & 2 deletions .env.staging.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ 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_BASE_URL="https://mcid.microclub.info/api"
MCDI_SERVER_ID="900000000000000001"
MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/admin/auth/mcdi/callback"
MCDI_REDIRECT_URI_MOBILE="multai://auth/callback"
1 change: 0 additions & 1 deletion app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ def __init__(
session_querier=self.session_querier,
refresh_token_querier=self.refresh_token_querier,
face_embedding_service=self.face_embedding_service,
mcdi_service=self.mcdi_service,
)

self.staff_drive_service = StaffDriveService(
Expand Down
3 changes: 1 addition & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,9 @@ class Settings(BaseSettings):
# MCDI Configuration
MCDI_PROJECT_ID: str = ""
MCDI_API_KEY: str = ""
MCDI_BASE_URL: str = "https://mcdi.microclub.info/api"
MCDI_BASE_URL: str = "https://mcid.microclub.info/api"
MCDI_SERVER_ID: str = ""
MCDI_REDIRECT_URI_WEB: str = ""
MCDI_REDIRECT_URI_MOBILE: str = ""

model_config = SettingsConfigDict(
env_file=".env",
Expand Down
5 changes: 4 additions & 1 deletion app/deps/cookie_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ async def get_current_staff_user(
if not email:
raise AppException.unauthorized("Invalid MCDI payload: missing email")

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

Expand Down
28 changes: 2 additions & 26 deletions app/deps/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,13 @@ def _check_session_validity(
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:
if (now - cached.last_active).total_seconds() <= settings.SESSION_ACTIVITY_THROTTLE_SECONDS:
return

new_idle_expires_at = min(
Expand All @@ -73,7 +54,6 @@ async def _touch_session_activity(
blocked=cached.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=now,
mcdi_token=cached.mcdi_token,
)


Expand Down Expand Up @@ -106,10 +86,7 @@ async def get_current_mobile_user(
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)
)
await _touch_session_activity(container, redis, cached, datetime.now(timezone.utc))

return MobileUserSchema(
user_id=cached.user_id,
Expand Down Expand Up @@ -144,7 +121,6 @@ async def get_current_mobile_user(
blocked=user.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=session.last_active,
mcdi_token=None,
)

return MobileUserSchema(
Expand Down
30 changes: 0 additions & 30 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from app.deps.rate_limit import RateLimiter

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


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


@router.post(
"/refresh",
response_model=MobileAuthResponse,
Expand Down
34 changes: 26 additions & 8 deletions app/router/web/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Annotated
import logging
import secrets

from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi import APIRouter, Cookie, Depends, Query, Request, Response
from fastapi.responses import RedirectResponse

from app.container import Container, get_container
Expand All @@ -10,6 +12,8 @@
from app.schema.response.web.staff_user import StaffUserSchema
from db.generated.models import StaffUser

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth")


Expand Down Expand Up @@ -37,10 +41,25 @@ async def mcdi_login(
@router.get("/mcdi/callback")
async def mcdi_callback(
request: Request,
code: str,
state: str,
code: Annotated[str | None, Query()] = None,
state: Annotated[str | None, Query()] = None,
error: Annotated[str | None, Query()] = None,
error_description: Annotated[str | None, Query()] = None,
container: Container = Depends(get_container),
) -> RedirectResponse:
frontend_url = (
settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173"
)

if error:
logger.warning("mcdi_callback error=%s desc=%s", error, error_description)
redirect_res = RedirectResponse(url=f"{frontend_url}/auth/login?error={error}")
redirect_res.delete_cookie("mcdi_state")
return redirect_res

if not code or not state:
raise AppException.bad_request("Missing code or state parameter")

cookie_state = request.cookies.get("mcdi_state")
if not cookie_state or state != cookie_state:
raise AppException.unauthorized("Invalid state parameter")
Expand All @@ -55,18 +74,17 @@ async def mcdi_callback(
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)
normalized_email = email.strip().lower()
staff_user = await container.staff_user_querier.get_staff_user_by_email(
email=normalized_email
)
if not staff_user:
raise AppException.unauthorized("Not authorized for admin panel")

token = mcdi_data.get("token")
if not token:
raise AppException.internal_error("MCDI did not return a session token")

frontend_url = (
settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173"
)

redirect_res = RedirectResponse(url=f"{frontend_url}/admin")
redirect_res.set_cookie(
key="access_token",
Expand Down
29 changes: 0 additions & 29 deletions app/schema/request/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,33 +44,6 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object:
return stripped


class McdiExchangeRequest(BaseModel):
code: str
device_name: str = Field(
...,
min_length=1,
max_length=settings.MOBILE_AUTH_DEVICE_NAME_MAX_LEN,
)
device_type: str = Field(
...,
min_length=1,
max_length=settings.MOBILE_AUTH_DEVICE_TYPE_MAX_LEN,
)
physical_device_id: UUID

@field_validator("device_name", "device_type", mode="before")
@classmethod
def _strip_required_text(cls, value: object, info: ValidationInfo) -> object:
if not isinstance(value, str):
return value
stripped = value.strip()
if not stripped:
raise ValueError(f"{info.field_name} must not be empty")
if info.field_name == "device_type":
return stripped.lower()
return stripped


class MobileRegisterRequest(MobileAuthBaseRequest):
@field_validator("password")
@classmethod
Expand Down Expand Up @@ -108,8 +81,6 @@ def _normalize_email(cls, value: object) -> object:
if not isinstance(value, str):
return value
return value.strip().lower()


class RefreshTokenRequest(BaseModel):
refresh_token: str

Expand Down
3 changes: 0 additions & 3 deletions app/service/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class MobileSessionCache(BaseModel):
absolute_expires_at: datetime
blocked: bool
last_active: datetime
mcdi_token: str | None = None


class SessionService:
Expand All @@ -40,7 +39,6 @@ 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(
Expand All @@ -51,7 +49,6 @@ 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)
Expand Down
Loading
Loading