Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b4987f3
Merge pull request #66 from MicroClub-USTHB/develop
Tyjfre-j Jul 16, 2026
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
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
2 changes: 2 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ 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 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
27 changes: 25 additions & 2 deletions app/deps/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ async def get_current_mobile_user(
) -> 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.
"""
token = credentials.credentials
Expand All @@ -49,6 +52,23 @@ async def get_current_mobile_user(
raise HTTPException(status_code=401, detail="Session expired")
if cached.blocked:
raise HTTPException(status_code=403, detail="User is blocked")

now = datetime.now(timezone.utc)
if (now - cached.last_active).total_seconds() > settings.SESSION_ACTIVITY_THROTTLE_SECONDS:
await container.session_service.session_querier.update_session_activity(
id=cached.session_id
)
await SessionService.cache_session_for_auth(
redis=redis,
session_id=cached.session_id,
user_id=cached.user_id,
email=cached.email,
expires_at=cached.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 @@ -70,7 +90,9 @@ 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,
Expand All @@ -79,6 +101,7 @@ async def get_current_mobile_user(
expires_at=session.expires_at,
blocked=user.blocked,
ttl=settings.MOBILE_SESSION_TTL_SECONDS,
last_active=session.last_active,
)

return MobileUserSchema(
Expand Down
2 changes: 1 addition & 1 deletion app/schema/request/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class MobileAuthBaseRequest(BaseModel):
min_length=1,
max_length=settings.MOBILE_AUTH_DEVICE_TYPE_MAX_LEN,
)
device_id: UUID
physical_device_id: UUID

@field_validator("email", mode="before")
@classmethod
Expand Down
25 changes: 0 additions & 25 deletions app/service/device.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from db.generated import devices as device_queries
from app.core.securite import create_totp_secret
import uuid
from app.core.exceptions import DBException,AppException, DBExceptionImpl
from db.generated.models import UserDevice
Expand All @@ -11,30 +10,6 @@ class DeviceService:
def init(self: "DeviceService", device_querier: device_queries.AsyncQuerier) -> None:
self.device_querier = device_querier

async def create_device(
self: "DeviceService",
user_id: uuid.UUID,
device_name: str,
device_type: str,
id: uuid.UUID | None = None,
) -> UserDevice | None:
try :
DeviceCount = await self.count_devices(user_id=user_id)
if DeviceCount >=3:
raise AppException.bad_request("You can only have 3 devices")
return await self.device_querier.create_device(
arg=device_queries.CreateDeviceParams(
column_1=id,
user_id=user_id,
device_name=device_name,
device_type=device_type,
totp_secret=create_totp_secret(),
)

)
except Exception as e :
raise DBException.handle(e)

async def activate_device(
self: "DeviceService",
device_id: uuid.UUID,
Expand Down
3 changes: 3 additions & 0 deletions app/service/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class MobileSessionCache(BaseModel):
email: str
expires_at: datetime
blocked: bool
last_active: datetime


class SessionService:
Expand All @@ -35,6 +36,7 @@ async def cache_session_for_auth(
expires_at: datetime,
blocked: bool,
ttl: int,
last_active: datetime
) -> None:
key = RedisKey.MobileSessionCache.value.format(session_id=session_id)
payload = MobileSessionCache(
Expand All @@ -43,6 +45,7 @@ async def cache_session_for_auth(
email=email,
expires_at=expires_at,
blocked=blocked,
last_active=last_active
)
await redis.set(key=key, value=payload.model_dump_json(), expire=ttl)

Expand Down
48 changes: 30 additions & 18 deletions app/service/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from db.generated import user as user_queries
from db.generated import devices as device_queries
from db.generated import session as session_queries
from db.generated.models import User, UserDevice
from db.generated.models import User, UserDevice, UserSession
from app.core.logger import logger
from app.service.face_embedding import FaceImagePayload, FaceEmbeddingService
from app.schema.internal.single_face_match import ClosestUserMatch
Expand Down Expand Up @@ -62,26 +62,28 @@ async def _ensure_device_for_login(
user_id: uuid.UUID,
req: MobileAuthBaseRequest,
) -> UserDevice:
existing_device = await self.device_querier.get_device_by_id_any(id=req.device_id)
existing_device = await self.device_querier.get_device_by_physical_id(
user_id=user_id,
physical_device_id=req.physical_device_id
)

if existing_device:
if existing_device.user_id != user_id:
raise AppException.forbidden("Device already registered to another user")
if existing_device.is_invalid_token:
raise AppException.forbidden(
"Device push token is invalid. Update the token before logging in."
)
if not existing_device.is_active:
await self.device_querier.activate_device(id=req.device_id, user_id=user_id)
await self.device_querier.activate_device(id=existing_device.id, user_id=user_id)
return existing_device

device = await self.device_querier.create_device(
arg=device_queries.CreateDeviceParams(
column_1=req.device_id,
column_1=None,
user_id=user_id,
device_name=req.device_name,
device_type=req.device_type,
totp_secret=None,
physical_device_id=req.physical_device_id
)
)
if not device:
Expand Down Expand Up @@ -274,25 +276,34 @@ async def _create_mobile_session(
) -> MobileAuthResponse:
user_id: uuid.UUID = user.id

session_count = await self.session_querier.count_user_sessions(user_id=user_id)
if session_count and session_count >= AuthService.SESSION_LIMIT:
logger.warning(
"session_limit_reached user_id=%s limit=%s",
user_id,
AuthService.SESSION_LIMIT,
)
raise AppException.forbidden("Maximum session limit reached")
device = await self._ensure_device_for_login(user_id, req)

existing_session = await self.session_querier.get_session_by_device_for_user(
device_id=device.id,
user_id=user_id,
)

if existing_session is None:
sessions: list[UserSession] = []
async for s in self.session_querier.list_sessions_by_user(user_id=user_id):
sessions.append(s)

if len(sessions) >= AuthService.SESSION_LIMIT:
oldest = min(sessions, key=lambda s: (s.last_active, s.created_at))
await SessionService.delete_session_cache(redis, oldest.id)
await self.session_querier.delete_session_by_id(id=oldest.id, user_id=user_id)
logger.warning(
"session_evicted user_id=%s evicted_session_id=%s",
user_id, oldest.id,
)

device_id = req.device_id
expires_at = datetime.now(timezone.utc) + timedelta(
days=settings.MOBILE_SESSION_DAYS
)

await self._ensure_device_for_login(user_id, req)

session = await self.session_querier.upsert_session(
user_id=user_id,
device_id=device_id,
device_id=device.id,
expires_at=expires_at,
)

Expand All @@ -312,6 +323,7 @@ async def _create_mobile_session(
expires_at=session.expires_at,
blocked=user.blocked,
ttl=AuthService.REDIS_SESSION_TTL,
last_active=session.last_active
)

return MobileAuthResponse(
Expand Down
71 changes: 64 additions & 7 deletions db/generated/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
user_id,
device_name,
device_type,
totp_secret
totp_secret,
physical_device_id
) VALUES (
COALESCE(:p1, uuid_generate_v4()), :p2, :p3, :p4, :p5
COALESCE(:p1, uuid_generate_v4()), :p2, :p3, :p4, :p5, :p6
)
RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token
RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id
"""


Expand All @@ -48,6 +49,7 @@ class CreateDeviceParams:
device_name: Optional[str]
device_type: Optional[str]
totp_secret: Optional[str]
physical_device_id: uuid.UUID


DEACTIVATE_DEVICE = """-- name: deactivate_device \\:exec
Expand All @@ -67,21 +69,33 @@ class CreateDeviceParams:
"""


GET_ANY_DEVICE_BY_PHYSICAL_ID = """-- name: get_any_device_by_physical_id \\:many
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id FROM user_devices
WHERE physical_device_id = :p1
"""


GET_DEVICE_BY_ID = """-- name: get_device_by_id \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id from user_devices
WHERE id = :p1
AND user_id = :p2
"""


GET_DEVICE_BY_ID_ANY = """-- name: get_device_by_id_any \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id from user_devices
WHERE id = :p1
"""


GET_DEVICE_BY_PHYSICAL_ID = """-- name: get_device_by_physical_id \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id FROM user_devices
WHERE user_id = :p1 AND physical_device_id = :p2
"""


LIST_USER_DEVICES = """-- name: list_user_devices \\:many
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id
FROM user_devices
WHERE user_id = :p1
ORDER BY last_active DESC
Expand Down Expand Up @@ -119,7 +133,7 @@ class CreateDeviceParams:
is_invalid_token = FALSE
WHERE id = :p1
AND user_id = :p3
RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token
RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token, physical_device_id
"""


Expand All @@ -143,6 +157,7 @@ async def create_device(self, arg: CreateDeviceParams) -> Optional[models.UserDe
"p3": arg.device_name,
"p4": arg.device_type,
"p5": arg.totp_secret,
"p6": arg.physical_device_id,
})).first()
if row is None:
return None
Expand All @@ -158,6 +173,7 @@ async def create_device(self, arg: CreateDeviceParams) -> Optional[models.UserDe
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
Expand All @@ -166,6 +182,24 @@ async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
async def enable_device2_fa(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(ENABLE_DEVICE2_FA), {"p1": id, "p2": user_id})

async def get_any_device_by_physical_id(self, *, physical_device_id: uuid.UUID) -> AsyncIterator[models.UserDevice]:
result = await self._conn.stream(sqlalchemy.text(GET_ANY_DEVICE_BY_PHYSICAL_ID), {"p1": physical_device_id})
async for row in result:
yield models.UserDevice(
id=row[0],
user_id=row[1],
device_name=row[2],
device_type=row[3],
totp_secret=row[4],
is_2fa_enabled=row[5],
last_active=row[6],
created_at=row[7],
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def get_device_by_id(self, *, id: uuid.UUID, user_id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID), {"p1": id, "p2": user_id})).first()
if row is None:
Expand All @@ -182,6 +216,7 @@ async def get_device_by_id(self, *, id: uuid.UUID, user_id: uuid.UUID) -> Option
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def get_device_by_id_any(self, *, id: uuid.UUID) -> Optional[models.UserDevice]:
Expand All @@ -200,6 +235,26 @@ async def get_device_by_id_any(self, *, id: uuid.UUID) -> Optional[models.UserDe
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def get_device_by_physical_id(self, *, user_id: uuid.UUID, physical_device_id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_PHYSICAL_ID), {"p1": user_id, "p2": physical_device_id})).first()
if row is None:
return None
return models.UserDevice(
id=row[0],
user_id=row[1],
device_name=row[2],
device_type=row[3],
totp_secret=row[4],
is_2fa_enabled=row[5],
last_active=row[6],
created_at=row[7],
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models.UserDevice]:
Expand All @@ -217,6 +272,7 @@ async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)

async def mark_device_token_invalid(self, *, push_token: Optional[str]) -> None:
Expand Down Expand Up @@ -244,4 +300,5 @@ async def update_device_push_token(self, *, id: uuid.UUID, push_token: Optional[
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
physical_device_id=row[11],
)
Loading