Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
25e443c
fix(photo-approval): resolve group photo lifecycle gaps
wailbentafat May 31, 2026
a89ee73
test(e2e): add asynchronous AI pipeline E2E test
ademboukabes Jun 1, 2026
33c1ed9
test(ai): add E2E tests, edge cases, load testing, and enable GPU inf…
ademboukabes Jun 2, 2026
6aa4305
Merge develop into fix/photo-approval-lifecycle
ademboukabes Jun 2, 2026
e424ed2
test(photo-approval): add unit tests for group photo lifecycle gaps
ademboukabes Jun 2, 2026
9b3883c
style: fix trailing whitespaces in tests
ademboukabes Jun 2, 2026
ba4b5f2
fix(test): resolve mypy type check errors and unused imports
ademboukabes Jun 2, 2026
bb0d79b
Merge pull request #49 from MicroClub-USTHB/fix/photo-approval-lifecycle
ademboukabes Jun 2, 2026
4e7290c
fix: file type detection
Tyjfre-j Jun 4, 2026
dfe1e73
fix: read image by chunks and exit early on size limit
Tyjfre-j Jun 4, 2026
4426f6b
fix: sanitise filename, use sniffed MIME
Tyjfre-j Jun 4, 2026
d5ef8b3
fix: validate image dimensions to protect ML model from corrupt or ex…
Tyjfre-j Jun 4, 2026
155bff5
fix: add EnrollmentResponse schema
Tyjfre-j Jun 4, 2026
275f04c
fix: correct return type annotation to EnrollmentResponse
Tyjfre-j Jun 4, 2026
f834229
fix: add per-user enrollment rate limit
Tyjfre-j Jun 4, 2026
9c676ab
fix: catch unexpected errors and return clean 500 instead of raw trac…
Tyjfre-j Jun 4, 2026
3018a6a
chore: fix OpenAPI description for enroll endpoint
Tyjfre-j Jun 4, 2026
07e44ef
fix : handle mypy and ruff errors
Tyjfre-j Jun 4, 2026
f8dfb2b
fix: added missing dependencies
Tyjfre-j Jun 4, 2026
e8f84bc
fix(enrollment): add row lock protection
Tyjfre-j Jun 6, 2026
ae0cca6
fix(enrollment): stream validated uploads to embedding
Tyjfre-j Jun 6, 2026
fc2ddbc
fix(enrollment): add audit and lock constants
Tyjfre-j Jun 6, 2026
ea70fe0
fix(enrollment): add redis lock and audit events
Tyjfre-j Jun 6, 2026
7d45354
fix(enrollment): add header prechecks and audit helpers
Tyjfre-j Jun 6, 2026
8e02a8f
Merge pull request #53 from MicroClub-USTHB/fix/enroll
ademboukabes Jun 6, 2026
42365d2
feat(security): implement redis rate limiting on mobile photo endpoint
ademboukabes Jun 6, 2026
1f93320
test(security): fix auth security tests and resolve asyncio loop scop…
ademboukabes Jun 6, 2026
1b205f9
test(e2e): refactor e2e tests and add robust ai pipeline test coverage
ademboukabes Jun 6, 2026
5e05c52
fix(security): resolve test leak in rate limiting tests
ademboukabes Jun 6, 2026
649fd5b
perf(drive): use concurrent asyncio for faster google drive uploads
ademboukabes Jun 6, 2026
5d429fc
fix(core): address critical code review findings
ademboukabes Jun 9, 2026
bdecde7
style(core): apply linter autofixes and mypy type ignores
ademboukabes Jun 9, 2026
c18c8fc
Merge remote-tracking branch 'origin/main' into fix/code-review
ademboukabes Jun 9, 2026
7689b06
chore: remove tests from remote until finalized
ademboukabes Jun 9, 2026
e1b537e
Merge pull request #54 from MicroClub-USTHB/fix/code-review
ademboukabes Jun 9, 2026
fdb26b5
chore: save tests_backup locally
ademboukabes Jun 23, 2026
51910a9
fix(main): await task cancellation on shutdown
ademboukabes Jun 23, 2026
33bf418
perf(drive): limit max imports to 1000 and chunk asyncio tasks
ademboukabes Jun 23, 2026
f322700
feat(auth): Implement async Email OTP Verification for mobile registr…
ademboukabes Jun 24, 2026
2cfe500
fix(auth): add return type for email sender and fix test mock
ademboukabes Jun 24, 2026
381c291
Merge pull request #55 from MicroClub-USTHB/feat/email-otp-verification
ademboukabes Jun 24, 2026
deba687
feat(auth): implement resend-otp route
ademboukabes Jun 24, 2026
c9d0b73
Merge branch 'feat/email-otp-verification' into develop
ademboukabes Jun 24, 2026
06c5543
feat(security): apply robust rate limiting to auth endpoints
ademboukabes Jun 24, 2026
556623f
chore: merge develop into tests-backup
ademboukabes Jun 30, 2026
872de0f
test(core): consolidate test suite, fix E2E auth flow, and update AI …
ademboukabes Jul 1, 2026
ab6418e
style: auto-fix ruff linting errors
ademboukabes Jul 1, 2026
1ee569b
test(integration): fix mock for face embedding stream
ademboukabes Jul 1, 2026
7a00ffa
style: fix remaining manual ruff errors
ademboukabes Jul 1, 2026
f4f7ebd
chore: exclude tests and scripts from mypy
ademboukabes Jul 1, 2026
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]

# Firebase
FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json

# Resend Email Configuration
RESEND_API_KEY=
EMAIL_FROM=
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ db.txt

.venv
multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json

# Local Test & Debug Files
.coverage
dummy.json
test_output.log
*.log
10 changes: 8 additions & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class Settings(BaseSettings):
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432

PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7

# Mobile auth/session defaults
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
Expand All @@ -57,8 +59,8 @@ class Settings(BaseSettings):

# Face embedding model
FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l"
FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider"
FACE_EMBEDDING_CTX_ID: int = -1
FACE_EMBEDDING_PROVIDERS: str = "CUDAExecutionProvider,CPUExecutionProvider"
FACE_EMBEDDING_CTX_ID: int = 0
FACE_EMBEDDING_DET_WIDTH: int = 640
FACE_EMBEDDING_DET_HEIGHT: int = 640

Expand All @@ -73,6 +75,10 @@ class Settings(BaseSettings):
FACE_ENCRYPTION_KEY: str
FIREBASE_CREDENTIALS_PATH: str

# Resend Email Configuration
RESEND_API_KEY: str = ""
EMAIL_FROM: str = "onboarding@resend.dev"

model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
Expand Down
7 changes: 7 additions & 0 deletions app/core/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class AuditEventType(str, Enum):
USER_SIGNUP = "user.signup"
USER_LOGIN = "user.login"
USER_LOGOUT = "user.logout"
FACE_ENROLLMENT_ATTEMPT = "face_enrollment.attempt"
UPLOAD_REQUEST_CREATED = "upload_request.created"
UPLOAD_REQUEST_APPROVED = "upload_request.approved"
UPLOAD_REQUEST_REJECTED = "upload_request.rejected"
Expand Down Expand Up @@ -50,5 +51,11 @@ class AuditEventType(str, Enum):
GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}"

MAX_IMAGE_SIZE = 5 * 1024 * 1024
MIN_IMAGE_DIM = 64
MAX_IMAGE_DIM = 4096
MIN_ENROLL_IMAGES = 3
MAX_ENROLL_IMAGES = 5

ENROLL_RATE_LIMIT_MAX = 5
ENROLL_RATE_LIMIT_WINDOW = 3600
ENROLL_IN_PROGRESS_TTL_SECONDS = 300
36 changes: 36 additions & 0 deletions app/deps/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi import Request, HTTPException
from typing import Callable

from app.infra.redis import RedisClient
from app.core.config import settings

def _get_client_ip(request: Request) -> str:
if settings.TRUST_PROXY_HEADERS:
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
return forwarded_for.split(",", maxsplit=1)[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "127.0.0.1"


def RateLimiter(requests: int, window: int) -> Callable:
async def _rate_limit_dependency(request: Request) -> None:
client_ip = _get_client_ip(request)
# For simplicity, IP based rate limit on the endpoint
path = request.url.path
key = f"rate_limit:{path}:{client_ip}"

redis = RedisClient.get_instance()

# Increment request count
current = await redis.incr(key)
if current == 1:
# Set expiry for the window if it's the first request
await redis.expire(key, window)

if current > requests:
raise HTTPException(status_code=429, detail="Too Many Requests")

return _rate_limit_dependency
61 changes: 61 additions & 0 deletions app/infra/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import json
import urllib.request
import urllib.error
import asyncio
from app.core.config import settings
from app.core.logger import logger

class EmailSender:
@staticmethod
async def send_otp_email(to_email: str, otp: str) -> bool:
if not settings.RESEND_API_KEY:
logger.warning("RESEND_API_KEY is not set. Skipping email sending.")
# During development without an API key, just log the OTP
logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp)
return True

url = "https://api.resend.com/emails"
headers = {
"Authorization": f"Bearer {settings.RESEND_API_KEY}",
"Content-Type": "application/json",
"User-Agent": "multAI-Backend/1.0"
}

html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Bienvenue sur multAI !</h2>
<p>Voici votre code de vérification :</p>
<h1 style="background: #f4f4f4; padding: 10px; letter-spacing: 5px; text-align: center;">{otp}</h1>
<p>Ce code est valide pendant 10 minutes.</p>
</div>
"""

data = {
"from": settings.EMAIL_FROM,
"to": [to_email],
"subject": "Votre code de vérification multAI",
"html": html_content
}

req = urllib.request.Request(
url,
data=json.dumps(data).encode("utf-8"),
headers=headers,
method="POST"
)

def _send() -> bool:
try:
with urllib.request.urlopen(req, timeout=10) as response:
res_body = response.read()
logger.info("Email sent via Resend. Response: %s", res_body)
return True
except urllib.error.HTTPError as e:
err_body = e.read()
logger.error("Failed to send email via Resend: %s - %s", e.code, err_body)
return False
except Exception as e:
logger.error("Error sending email: %s", str(e))
return False

return await asyncio.to_thread(_send)
6 changes: 3 additions & 3 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ async def incr(self, key: RedisKey | str) -> int:


async def sadd(self, key: RedisKey | str, *values: str) -> int:
result = self._client.sadd(key, *values)
result = await self._client.sadd(key, *values) # type: ignore[misc]
return int(cast(int, result))

async def sismember(self, key: RedisKey | str, value: str) -> bool:
result = self._client.sismember(key, value)
result = await self._client.sismember(key, value) # type: ignore[misc]
return int(cast(int, result)) == 1

async def srem(self, key: RedisKey | str, *values: str) -> int:
result = self._client.srem(key, *values)
result = await self._client.srem(key, *values) # type: ignore[misc]
return int(cast(int, result))


Expand Down
17 changes: 17 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import settings
from app.infra.database import engine
from app.infra.minio import init_minio_client
from app.infra.nats import NatsClient
from app.infra.redis import RedisClient
Expand Down Expand Up @@ -48,6 +49,18 @@ async def dispatch(



async def _approval_expiry_loop() -> None:
while True:
await asyncio.sleep(3600)
try:
async with engine.begin() as conn:
from app.container import Container
container = Container(conn)
await container.photo_approval_service.expire_stale(settings.PHOTO_APPROVAL_TIMEOUT_DAYS)
except Exception as exc:
logger.warning("Approval expiry task failed: %s", exc)


MAX_RETRIES = 5
RETRY_DELAY = 2 # seconds
@asynccontextmanager
Expand Down Expand Up @@ -77,8 +90,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await NatsClient.connect()
get_face_embedding_service()

expiry_task = asyncio.create_task(_approval_expiry_loop())

yield

expiry_task.cancel()
await asyncio.gather(expiry_task, return_exceptions=True)
await RedisClient.get_instance().close()
await NatsClient.close()

Expand Down
35 changes: 30 additions & 5 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
from app.core.config import settings
from app.core.constant import AuditEventType
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
from app.deps.rate_limit import RateLimiter

from app.schema.request.mobile.auth import (
MobileLoginRequest,
MobileRegisterRequest,
RegisterVerifyRequest,
ResendOtpRequest,
RefreshTokenRequest,
UpdateDeviceTokenRequest,
InactivateDeviceRequest,
)
from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema
from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse

router = APIRouter(prefix="/auth")

Expand All @@ -33,23 +36,45 @@ def _get_client_ip(request: Request) -> str | None:
return request.client.host if request.client else None


@router.post("/register", response_model=MobileAuthResponse)
@router.post("/register", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_register(
req: MobileRegisterRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip)
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"},
metadata={"endpoint": "register_verify"},
)
return result


@router.post("/login", response_model=MobileAuthResponse)
@router.post("/login", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_login(
req: MobileLoginRequest,
request: Request,
Expand Down
Loading