Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
4ede13a
feat(db): migration add_physical_device_id to user_devices
Tyjfre-j Jul 17, 2026
c17a040
feat(db): migration create_refresh_tokens table
Tyjfre-j Jul 17, 2026
348aa7b
feat(db): add GetDeviceByPhysicalId, GetAnyDeviceByPhysicalId, regene…
Tyjfre-j Jul 17, 2026
f9b87be
feat(auth): rewrite _ensure_device_for_login, fix FK and session cap …
Tyjfre-j Jul 17, 2026
ed0055e
refactor(auth): delete dead create_device, remove unused import
Tyjfre-j Jul 17, 2026
c5e8e96
refactor(api): rename device_id to physical_device_id in MobileAuthBa…
Tyjfre-j Jul 17, 2026
3206d40
test(auth): update fakes and mocks for physical_device_id
Tyjfre-j Jul 17, 2026
61c63d4
fix: fixed ruff errors
Tyjfre-j Jul 17, 2026
75193ae
feat(session): add SESSION_ACTIVITY_THROTTLE_SECONDS setting (default…
Tyjfre-j Jul 18, 2026
99f54f2
fix(security): neutralize '..' path traversal sequences in sanitise_f…
Tyjfre-j Jul 18, 2026
bb03d10
feat(session): throttled last_active update on fast path; honest docs…
Tyjfre-j Jul 18, 2026
3d3d103
feat(session): add last_active to MobileSessionCache; update cache_se…
Tyjfre-j Jul 18, 2026
f3ba2da
feat(session): replace hard-reject session cap with LRU eviction
Tyjfre-j Jul 18, 2026
963ea92
test(image): update enrollment security tests for app.core.image_vali…
Tyjfre-j Jul 18, 2026
6d0774a
test(session): rewrite TestSessionLimit to assert LRU eviction behavior
Tyjfre-j Jul 18, 2026
756801f
test(auth): add regression tests for FK bug, cap-vs-replace, device r…
Tyjfre-j Jul 18, 2026
b687c82
test(integration): add real-DB tests for session replace and cascade …
Tyjfre-j Jul 18, 2026
c6e6d4e
fix: added missing last active field
Tyjfre-j Jul 18, 2026
ad20628
Merge pull request #67 from MicroClub-USTHB/fix/session-and-device-ma…
ademboukabes Jul 18, 2026
120a391
feat(session): add EvictOverflowSessions and LockUserSessions queries
Tyjfre-j Jul 20, 2026
a60d5f2
chore(gen): regenerate session querier from updated SQL
Tyjfre-j Jul 20, 2026
97cd4c7
feat(auth): session cap enforcement with SQL-native eviction
Tyjfre-j Jul 20, 2026
b553671
test(integration): add concurrent login stress test
Tyjfre-j Jul 20, 2026
05fa44a
test(auth): update all fixtures/fakes for new eviction contract
Tyjfre-j Jul 20, 2026
68e46ec
Move blocked-user re-check under row lock to close login race with bl…
Tyjfre-j Jul 24, 2026
801955c
Add tests for blocked-user race fix: unit branch coverage and real co…
Tyjfre-j Jul 24, 2026
b73bafd
chore: removed unused test variables
Tyjfre-j Jul 24, 2026
6f9c509
chore: seperated client ip check
Tyjfre-j Jul 25, 2026
28803eb
chore: rewired client ip check
Tyjfre-j Jul 25, 2026
37c2772
chore: rewired client up check
Tyjfre-j Jul 25, 2026
ffb348d
fix: fixed ruff errors
Tyjfre-j Jul 25, 2026
f62be14
feat: added mobile access and refresh token life time configs
Tyjfre-j Jul 25, 2026
1d31bf5
feat: added refresh token create and hash function + refresh cache pa…
Tyjfre-j Jul 25, 2026
89d60ca
refactor(container): constructor-inject SessionService, wire in refre…
Tyjfre-j Jul 26, 2026
47f90a6
test: pass refresh_token_querier through auth_service fixture
Tyjfre-j Jul 26, 2026
0b9e515
test: wire refresh_token_querier into all AuthService construction si…
Tyjfre-j Jul 26, 2026
d97e07d
test: add refresh_token_querier fixture and wire into auth_service
Tyjfre-j Jul 26, 2026
aa8d530
test: update fixtures for opaque refresh tokens, wire refresh_token_q…
Tyjfre-j Jul 26, 2026
7c46082
test: add refresh_token_querier to AuthService construction
Tyjfre-j Jul 26, 2026
6e5b414
test(auth): add coverage for rate-limit fail-open, block/delete locki…
Tyjfre-j Jul 26, 2026
9ed7ceb
fix(auth): close session/token security gaps in refresh, block, and r…
Tyjfre-j Jul 26, 2026
05b3f62
docs(env): add generation instructions for jwt_secret and encryption_key
Tyjfre-j Jul 26, 2026
f5cddd4
feat(db): add refresh_token queries and generated querier
Tyjfre-j Jul 26, 2026
e625bb6
fix: switched encryption key to valide base 64 key
Tyjfre-j Jul 26, 2026
1a717bd
feat(config): add MOBILE_SESSION_ABSOLUTE_DAYS setting
Tyjfre-j Jul 28, 2026
1632c41
feat(schema): split session expiry into idle and absolute fields
Tyjfre-j Jul 28, 2026
77f1b43
feat(session): update cache model and service for dual expiry
Tyjfre-j Jul 28, 2026
160602d
feat(auth): apply idle/absolute expiry checks in login, refresh, and …
Tyjfre-j Jul 28, 2026
9014a0d
feat(auth): update dependency to check both expiry timers and slide i…
Tyjfre-j Jul 28, 2026
ad1bfb8
feat(router): return dual expiry fields in session response
Tyjfre-j Jul 28, 2026
a16a3d6
feat(db): update session queries for idle_expires_at and absolute_exp…
Tyjfre-j Jul 28, 2026
059e3ff
chore(gen): regenerate session querier with dual expiry support
Tyjfre-j Jul 28, 2026
ad11fbd
chore(gen): regenerate UserSession model with dual expiry fields
Tyjfre-j Jul 28, 2026
046036b
test(integration): update session device tests for dual expiry
Tyjfre-j Jul 28, 2026
7cee926
test(security): update auth security tests for dual expiry
Tyjfre-j Jul 28, 2026
4b40959
test(unit): update fake queriers for dual expiry fields
Tyjfre-j Jul 28, 2026
7c54e0f
feat(migration): add forward migration for idle/absolute expiry columns
Tyjfre-j Jul 28, 2026
d4234b3
feat(migration): add rollback migration for idle/absolute expiry columns
Tyjfre-j Jul 28, 2026
69a9a7f
feat(migration): add alembic revision for session idle/absolute expiry
Tyjfre-j Jul 28, 2026
b753d45
test(unit): update auth service tests and add dual expiry behavior co…
Tyjfre-j Jul 28, 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
21 changes: 18 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,31 @@ REDIS_PASSWORD=
# =========================
PGADMIN_PORT=5050

jwt_secret=super_secret_jwt_key
# Secret used to sign mobile access tokens and staff JWTs (HS256).
# Must be high-entropy, minimum 32 bytes to satisfy PyJWT's recommended
# HMAC key length for SHA-256 (RFC 7518 §3.2).
jwt_secret=
jwt_algorithm=HS256
encryption_key=super_secret_encryption_key

# AES-256-GCM key used to encrypt the refresh-token grace-window replay
# cache before it's stored in Redis (see app/core/securite.py:
# encrypt_refresh_cache_payload / decrypt_refresh_cache_payload).
# Must be a base64-encoded 32-byte (256-bit) key.
encryption_key=

totp_issuer=MultiAI

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback
GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile
FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib

# Key for the (currently dormant/commented-out) EmbeddingCrypto class in
# app/core/securite.py. Same format requirement as encryption_key above —
# base64-encoded 32-byte key — if this class is ever re-enabled, a weak
# placeholder value here will fail the same way an under-length
# encryption_key did.
FACE_ENCRYPTION_KEY=

# CORS Configuration
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
MINIO_ROOT_PASSWORD: dummy
MINIO_HOST: localhost
jwt_secret: test_secret
encryption_key: test_encryption_key
encryption_key: MPCSXH0IYfkp8JTpUNH0vUVyDlUeP6OKI8kz5iK54mw=
FACE_ENCRYPTION_KEY: test_face_encryption_key
FIREBASE_CREDENTIALS_PATH: dummy.json
services:
Expand Down
10 changes: 5 additions & 5 deletions app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from db.generated import upload_request_photos as upload_request_photo_queries
from db.generated import upload_requests as upload_request_queries
from db.generated import user as user_queries

from db.generated import refresh_token as refresh_token_queries
from db.generated import events as event_queries
from db.generated import event_participant as participant_queries
from db.generated import notifications as notification_queries
Expand Down Expand Up @@ -72,11 +72,10 @@ def __init__(
self.event_querier = event_queries.AsyncQuerier(conn)
self.participant_querier = participant_queries.AsyncQuerier(conn)
self.stats_querier = stats_queries.AsyncQuerier(conn)
self.refresh_token_querier = refresh_token_queries.AsyncQuerier(conn)

# services
self.session_service = SessionService()
self.session_service.init(
session=self.session_querier,
self.session_service = SessionService(
session_querier=self.session_querier,
redis=self.redis,
)

Expand All @@ -90,6 +89,7 @@ def __init__(
user_querier=self.user_querier,
device_querier=self.device_querier,
session_querier=self.session_querier,
refresh_token_querier=self.refresh_token_querier,
face_embedding_service=self.face_embedding_service,
)

Expand Down
7 changes: 7 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ class Settings(BaseSettings):
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
MOBILE_SESSION_DAYS: int = 7
SESSION_ACTIVITY_THROTTLE_SECONDS: int = 60

# Mobile access/refresh token lifetimes
MOBILE_ACCESS_TOKEN_TTL_SECONDS: int = 900
MOBILE_REFRESH_TOKEN_REUSE_GRACE_SECONDS: int = 30
MOBILE_SESSION_ABSOLUTE_DAYS: int = 30

# Mobile auth validation defaults
MOBILE_AUTH_PASSWORD_MIN_LEN: int = 8
MOBILE_AUTH_PASSWORD_MAX_LEN: int = 128
Expand Down
1 change: 1 addition & 0 deletions app/core/image_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def sanitise_filename(raw: str | None, extension: str) -> str:
if not raw:
return f"{prefix}.{extension}"
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw)
name = name.replace("..", "_")
name = name.lstrip(".")[:128]
return f"{prefix}_{name}"

Expand Down
45 changes: 27 additions & 18 deletions app/core/securite.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import base64
import hashlib
import os
from datetime import datetime, timedelta, timezone
import secrets
from typing import Any, Literal
import jwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from passlib.context import CryptContext
from pydantic import BaseModel, ConfigDict
import pyotp
from app.core.config import settings
from app.core.exceptions import AppException
from app.core.logger import logger

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


Expand Down Expand Up @@ -56,25 +58,12 @@ def decode_access_mobile_token(token: str) -> dict[str, Any]:
raise AppException.unauthorized("Invalid token")


def create_refresh_mobile_token(session_id: str) -> str:
payload: dict[str, Any] = {
"session_id": session_id,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time() * 4)).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)

def create_raw_refresh_token() -> str:
return secrets.token_urlsafe(32)

def decode_refresh_mobile_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, key=settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except jwt.ExpiredSignatureError:
raise AppException.unauthorized("Token has expired")
except jwt.InvalidTokenError:
raise AppException.unauthorized("Invalid token")

def hash_refresh_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()

def create_totp_secret() -> str:
return pyotp.random_base32()
Expand All @@ -100,6 +89,26 @@ def generate_Acces_token_stuff(user_id: str, role: str) -> str:
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)

def _get_refresh_cache_aesgcm() -> AESGCM:
key = base64.b64decode(settings.encryption_key)
return AESGCM(key)

def encrypt_refresh_cache_payload(plaintext: str) -> str:
"""Encrypt a JSON string for storage in Redis. Returns a base64 string
safe to store directly (nonce + ciphertext packed together)."""
aes = _get_refresh_cache_aesgcm()
nonce = os.urandom(12)
ciphertext = aes.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ciphertext).decode("utf-8")

def decrypt_refresh_cache_payload(encoded: str) -> str:
"""Reverse of encrypt_refresh_cache_payload. Raises on tampering or
wrong key — treat any exception as 'cache miss'."""
aes = _get_refresh_cache_aesgcm()
raw = base64.b64decode(encoded)
nonce, ciphertext = raw[:12], raw[12:]
plaintext = aes.decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")


# class EmbeddingCrypto:
Expand Down
15 changes: 15 additions & 0 deletions app/deps/client_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import Request
from app.core.config import settings


def get_client_ip(request: Request) -> str | None:
if settings.TRUST_PROXY_HEADERS:
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
return forwarded_for.split(",", maxsplit=1)[0].strip() or None

real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip() or None

return request.client.host if request.client else None
31 changes: 12 additions & 19 deletions app/deps/rate_limit.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,27 @@
from fastapi import Request, HTTPException
from typing import Callable

from app.deps.client_ip import get_client_ip
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"

from app.core.logger import logger

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
client_ip = get_client_ip(request) or "127.0.0.1"
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)
try:
current = await redis.incr(key)
if current == 1:
await redis.expire(key, window)
except HTTPException:
raise
except Exception:
logger.warning("rate_limit: redis unavailable, failing open for key=%s", key)
return

if current > requests:
raise HTTPException(status_code=429, detail="Too Many Requests")
Expand Down
56 changes: 42 additions & 14 deletions app/deps/token_auth.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Annotated
import uuid

from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel

from app.container import get_container, Container
from app.core.config import settings
from app.core.securite import decode_access_mobile_token
from app.infra.redis import RedisClient
from app.schema.response.mobile.auth import MobileUserSchema
from app.service.session import MobileSessionCache, SessionService

security = HTTPBearer()


class MobileUserSchema(BaseModel):
user_id: uuid.UUID
email: str
session_id: uuid.UUID


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 (0 DB queries).
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.
"""
token = credentials.credentials
payload = decode_access_mobile_token(token)
Expand All @@ -45,10 +46,33 @@ async def get_current_mobile_user(
redis, session_id
)
if cached is not None:
if cached.expires_at < datetime.now(timezone.utc):
now = datetime.now(timezone.utc)
if cached.idle_expires_at < now or cached.absolute_expires_at < now:
raise HTTPException(status_code=401, detail="Session expired")
if cached.blocked:
raise HTTPException(status_code=403, detail="User is blocked")

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

return MobileUserSchema(
user_id=cached.user_id,
email=cached.email,
Expand All @@ -60,8 +84,8 @@ async def get_current_mobile_user(
if not session:
raise HTTPException(status_code=401, detail="Session not found")

exp_ts = payload.get("exp")
if exp_ts and session.expires_at.timestamp() < exp_ts:
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)
Expand All @@ -70,15 +94,19 @@ async def get_current_mobile_user(
if user.blocked:
raise HTTPException(status_code=403, detail="User is blocked")

# Re-populate cache so next request hits Redis
# 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.
await SessionService.cache_session_for_auth(
redis=redis,
session_id=session.id,
user_id=session.user_id,
email=user.email or "",
expires_at=session.expires_at,
idle_expires_at=session.idle_expires_at,
absolute_expires_at=session.absolute_expires_at,
blocked=user.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=session.last_active,
)

return MobileUserSchema(
Expand Down
Loading