Skip to content
Draft

Fix #68

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ce0dc12
feat(session): add EvictOverflowSessions and LockUserSessions queries
Tyjfre-j Jul 20, 2026
54cb075
chore(gen): regenerate session querier from updated SQL
Tyjfre-j Jul 20, 2026
660879f
feat(auth): session cap enforcement with SQL-native eviction
Tyjfre-j Jul 20, 2026
b94409d
test(integration): add concurrent login stress test
Tyjfre-j Jul 20, 2026
cb1254c
test(auth): update all fixtures/fakes for new eviction contract
Tyjfre-j Jul 20, 2026
9d78135
Move blocked-user re-check under row lock to close login race with bl…
Tyjfre-j Jul 24, 2026
f36fa11
Add tests for blocked-user race fix: unit branch coverage and real co…
Tyjfre-j Jul 24, 2026
d8c69bb
chore: removed unused test variables
Tyjfre-j Jul 24, 2026
b15d6ff
chore: seperated client ip check
Tyjfre-j Jul 25, 2026
eb7b2f2
chore: rewired client ip check
Tyjfre-j Jul 25, 2026
240cab2
chore: rewired client up check
Tyjfre-j Jul 25, 2026
7ba856c
fix: fixed ruff errors
Tyjfre-j Jul 25, 2026
e231855
feat: added mobile access and refresh token life time configs
Tyjfre-j Jul 25, 2026
bbec658
feat: added refresh token create and hash function + refresh cache pa…
Tyjfre-j Jul 25, 2026
24e5c65
refactor(container): constructor-inject SessionService, wire in refre…
Tyjfre-j Jul 26, 2026
e0c8dda
test: pass refresh_token_querier through auth_service fixture
Tyjfre-j Jul 26, 2026
6df9c61
test: wire refresh_token_querier into all AuthService construction si…
Tyjfre-j Jul 26, 2026
eeb2430
test: add refresh_token_querier fixture and wire into auth_service
Tyjfre-j Jul 26, 2026
95483e1
test: update fixtures for opaque refresh tokens, wire refresh_token_q…
Tyjfre-j Jul 26, 2026
849baf0
test: add refresh_token_querier to AuthService construction
Tyjfre-j Jul 26, 2026
6dd1b3a
test(auth): add coverage for rate-limit fail-open, block/delete locki…
Tyjfre-j Jul 26, 2026
f8fdb50
fix(auth): close session/token security gaps in refresh, block, and r…
Tyjfre-j Jul 26, 2026
6a1b02d
docs(env): add generation instructions for jwt_secret and encryption_key
Tyjfre-j Jul 26, 2026
fb453a3
feat(db): add refresh_token queries and generated querier
Tyjfre-j Jul 26, 2026
f7a8374
fix: switched encryption key to valide base 64 key
Tyjfre-j Jul 26, 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
4 changes: 4 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ class Settings(BaseSettings):
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 auth validation defaults
MOBILE_AUTH_PASSWORD_MIN_LEN: int = 8
MOBILE_AUTH_PASSWORD_MAX_LEN: int = 128
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
30 changes: 10 additions & 20 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from uuid import UUID

from app.container import get_container, Container
from app.core.config import settings
from app.core.constant import AuditEventType
from app.deps.client_ip import get_client_ip
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
from app.deps.rate_limit import RateLimiter

Expand All @@ -25,27 +25,13 @@

router = APIRouter(prefix="/auth")


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


@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),
) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
client_ip = get_client_ip(request)
result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip)
return result

Expand All @@ -56,7 +42,7 @@ async def mobile_register_resend_otp(
request: Request,
container: Container = Depends(get_container),
) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
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

Expand All @@ -67,7 +53,7 @@ async def mobile_register_verify(
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
client_ip = _get_client_ip(request)
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,
Expand All @@ -83,7 +69,7 @@ async def mobile_login(
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
client_ip = _get_client_ip(request)
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,
Expand All @@ -93,7 +79,11 @@ async def mobile_login(
return result


@router.post("/refresh", response_model=MobileAuthResponse)
@router.post(
"/refresh",
response_model=MobileAuthResponse,
dependencies=[Depends(RateLimiter(requests=10, window=60))],
)
async def refresh_token(
req: RefreshTokenRequest,
container: Container = Depends(get_container),
Expand Down
Loading