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
9 changes: 9 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ class Settings(BaseSettings):
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
MOBILE_SESSION_DAYS: int = 7
# Mobile auth validation defaults
MOBILE_AUTH_PASSWORD_MIN_LEN: int = 8
MOBILE_AUTH_PASSWORD_MAX_LEN: int = 128
MOBILE_AUTH_DEVICE_NAME_MAX_LEN: int = 64
MOBILE_AUTH_DEVICE_TYPE_MAX_LEN: int = 32
# Rate Limit Settings
RATE_LIMIT_LOGIN_MAX_ATTEMPTS: int = 5
RATE_LIMIT_LOGIN_WINDOW_SECONDS: int = 60
TRUST_PROXY_HEADERS: bool = True
# Admin list defaults
ADMIN_USERS_DEFAULT_LIMIT: int = 20
ADMIN_USERS_MAX_LIMIT: int = 100
Expand Down
16 changes: 15 additions & 1 deletion app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ def internal_error(detail: str = "Internal server error") -> HTTPException:
def conflict(detail: str = "Conflict") -> HTTPException:
return HTTPException(status_code=409, detail=detail)

@staticmethod
def too_many_requests(
detail: str = "Too many requests. Please try again later.",
retry_after: int | None = None,
) -> HTTPException:
headers = {"Retry-After": str(retry_after)} if retry_after is not None else None
return HTTPException(status_code=429, detail=detail, headers=headers)

@staticmethod
def storage_error(detail: str = "Storage operation failed") -> HTTPException:
return HTTPException(status_code=500, detail=detail)
Expand Down Expand Up @@ -104,11 +112,17 @@ class DBExceptionImpl(DBException):
@staticmethod
def handle_unique_violation(exc: Exception) -> HTTPException:
constraint = getattr(exc, "constraint_name", None)
if constraint == "staff_users_email_key":
err_msg = str(exc).lower()
if constraint == "staff_users_email_key" or "staff_users_email_key" in err_msg:
return HTTPException(
status_code=409,
detail="Staff user with this email already exists"
)
if constraint in ("users_email_key", "idx_users_email") or "idx_users_email" in err_msg or "users_email_key" in err_msg:
return HTTPException(
status_code=409,
detail="Email already in use; please login instead"
)
return HTTPException(status_code=409, detail="Resource already exists")

@staticmethod
Expand Down
28 changes: 12 additions & 16 deletions app/core/securite.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
import base64
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
import jwt
from passlib.context import CryptContext
from pydantic import BaseModel
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")
_BCRYPT_MAX_LEN = 72


def _normalize_password(password: str) -> bytes:
return password.encode("utf-8")[:_BCRYPT_MAX_LEN]


def hash_password(password: str) -> str:
normalized = _normalize_password(password)
logger.debug("hashing password (normalized %s bytes)", len(normalized))
return pwd_context.hash(normalized)
# Use SHA-256 pre-hashing to overcome bcrypt's 72-byte limit
pre_hashed = base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
logger.debug("hashing password (pre-hashed %s bytes)", len(pre_hashed))
return pwd_context.hash(pre_hashed)


def verify_password(password: str, hashed: str) -> bool:
normalized = _normalize_password(password)
result = pwd_context.verify(normalized, hashed)
# Verify using the SHA-256 pre-hashed format
pre_hashed = base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
result = pwd_context.verify(pre_hashed, hashed)
logger.debug("password verification result: %s", result)
return result

Expand Down Expand Up @@ -128,15 +126,13 @@ def generate_Acces_token_stuff(user_id: str, role: str) -> str:


class StaffJWTPayload(BaseModel):
model_config = ConfigDict(frozen=True)

sub: str
role: str
type: Literal["access", "refresh"]
exp: int

class Config:
frozen = True
# immutable class


def create_access_staff_token(staff_id: str, role: str) -> str:
"""
Expand Down
4 changes: 4 additions & 0 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ async def expire(self, key: RedisKey | str, seconds: int) -> bool:
result = await self._client.expire(key, seconds)
return int(cast(int, result)) == 1

async def incr(self, key: RedisKey | str) -> int:
result = await self._client.incr(key)
return int(cast(int, result))


async def sadd(self, key: RedisKey | str, *values: str) -> int:
result = self._client.sadd(key, *values)
Expand Down
61 changes: 52 additions & 9 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from typing import Optional

from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
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.token_auth import MobileUserSchema, get_current_mobile_user

from app.schema.request.mobile.auth import (
MobileAuthRequest,
MobileLoginRequest,
MobileRegisterRequest,
RefreshTokenRequest,
UpdateDeviceTokenRequest,
InactivateDeviceRequest,
Expand All @@ -18,17 +20,47 @@
router = APIRouter(prefix="/auth")


@router.post("/register-login", response_model=MobileAuthResponse)
async def mobile_register_login(
req: MobileAuthRequest,
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=MobileAuthResponse)
async def mobile_register(
req: MobileRegisterRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.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"},
)
return result


@router.post("/login", response_model=MobileAuthResponse)
async def mobile_login(
req: MobileLoginRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
result = await container.auth_service.mobile_register_login(container.redis, req)
event_type = AuditEventType.USER_SIGNUP if result.is_new_user else AuditEventType.USER_LOGIN
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=event_type,
event_type=AuditEventType.USER_LOGIN,
user_id=result.user_id,
metadata={"email": req.email},
metadata={"endpoint": "login"},
)
return result

Expand Down Expand Up @@ -64,6 +96,17 @@ async def revoke_device(
container: Container = Depends(get_container),
current_user: MobileUserSchema = Depends(get_current_mobile_user),
) -> dict[str, str]:
from app.core.constant import RedisKey

session = await container.session_service.session_querier.get_session_by_device(
device_id=device_id
)
if session:
await container.session_service.delete_session_cache(container.redis, session.id)

user_session_key = RedisKey.UserSessionByUser.value.format(user_id=current_user.user_id)
await container.redis.delete(user_session_key)

await container.device_service.revoke_device(
device_id=device_id,
user_id=current_user.user_id,
Expand Down
65 changes: 59 additions & 6 deletions app/schema/request/mobile/auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator
from uuid import UUID
from app.core.config import settings


class MobileAuthRequest(BaseModel):
email: EmailStr
password: str
device_name: str
device_type: str
class MobileAuthBaseRequest(BaseModel):
email: EmailStr = Field(..., max_length=255)
password: str = Field(
...,
min_length=settings.MOBILE_AUTH_PASSWORD_MIN_LEN,
max_length=settings.MOBILE_AUTH_PASSWORD_MAX_LEN,
)
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,
)
device_id: UUID

@field_validator("email", mode="before")
@classmethod
def _normalize_email(cls, value: object) -> object:
if not isinstance(value, str):
return value
return value.strip().lower()

@field_validator("password", "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 == "password":
return stripped
if info.field_name == "device_type":
return stripped.lower()
return stripped


class MobileRegisterRequest(MobileAuthBaseRequest):
@field_validator("password")
@classmethod
def _validate_password_complexity(cls, value: str) -> str:
if not any(c.isupper() for c in value):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in value):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in value):
raise ValueError("Password must contain at least one digit")
if not any(not c.isalnum() for c in value):
raise ValueError("Password must contain at least one special character")
return value


class MobileLoginRequest(MobileAuthBaseRequest):
pass




Expand Down
7 changes: 3 additions & 4 deletions app/schema/response/web/auth.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import uuid

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict


class WebAuthResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

access_token: str
user_id: uuid.UUID
role: str

class Config:
from_attributes = True



8 changes: 4 additions & 4 deletions app/service/photo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ async def decide(
async for a in self._approval_querier.get_photo_approvals_by_photo_id(photo_id=photo_id):
approvals.append(a)

pending = [a for a in approvals if a.decision == "pending"]
if pending:
return "pending"

rejected = [a for a in approvals if a.decision == "rejected"]
if rejected:
await self._photo_querier.update_photo_status(id=photo_id, status="rejected")
await self._delete_photo_storage(photo_id)
return "rejected"

pending = [a for a in approvals if a.decision == "pending"]
if pending:
return "pending"

await self._photo_querier.update_photo_status(id=photo_id, status="approved")
return "approved"

Expand Down
Loading