Skip to content
Open
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
129 changes: 129 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# ==============================================================================
# Python Togo API - Makefile
# Usage : make <cible> | make help pour la liste complete
# ==============================================================================

# --- Variables configurables (surchargeables : make dev PORT=9000) -----------
PYTHON ?= python3
VENV ?= venv
BIN := $(VENV)/bin
HOST ?= 0.0.0.0
PORT ?= 8000
WORKERS ?= 4
APP := app/main.py
ENV_FILE := app/.env
ENV_EXAMPLE := app/.env.example
API_PORT ?= 8080
COMPOSE ?= API_PORT=$(API_PORT) docker compose
SERVICE ?= api

.DEFAULT_GOAL := help
.PHONY: help venv install env migrate dev run start stop check-env freeze \
clean clean-pyc reset build up down restart logs ps shell db-shell \
redis-cli docker-migrate rebuild prune

# ==============================================================================
# Aide
# ==============================================================================
help: ## Affiche cette aide
@echo "Python Togo API - commandes disponibles :"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "Variables : PORT=$(PORT) API_PORT=$(API_PORT) HOST=$(HOST) VENV=$(VENV) SERVICE=$(SERVICE)"

# ==============================================================================
# Developpement local
# ==============================================================================
venv: ## Cree l'environnement virtuel Python
@test -d $(VENV) || $(PYTHON) -m venv $(VENV)
@$(BIN)/pip install --upgrade pip

install: venv ## Installe les dependances dans le venv
@$(BIN)/pip install -r requirements.txt

env: ## Cree app/.env a partir de app/.env.example (si absent)
@if [ -f $(ENV_FILE) ]; then \
echo "$(ENV_FILE) existe deja, aucune action."; \
else \
cp $(ENV_EXAMPLE) $(ENV_FILE); \
echo "$(ENV_FILE) cree. Pensez a remplir les variables."; \
fi

check-env: ## Verifie que app/.env est present
@if [ ! -f $(ENV_FILE) ]; then \
echo "Erreur : $(ENV_FILE) manquant. Lancez 'make env'."; \
exit 1; \
fi

migrate: check-env ## Execute les migrations de la base de donnees
@$(BIN)/python -m app.database.migrations

dev: check-env ## Demarre l'API en mode developpement (rechargement auto)
@$(BIN)/fastapi dev $(APP) --host $(HOST) --port $(PORT)

run: check-env ## Demarre l'API en mode production (multi-workers)
@$(BIN)/fastapi run $(APP) --host $(HOST) --port $(PORT) --workers $(WORKERS)

start: install env migrate dev ## Installation complete puis demarrage en dev

freeze: ## Fige les dependances installees dans requirements.lock.txt
@$(BIN)/pip freeze > requirements.lock.txt
@echo "requirements.lock.txt genere."

# ==============================================================================
# Docker
# ==============================================================================
build: ## Construit les images Docker
@$(COMPOSE) build

up: ## Demarre la stack Docker (api + postgres + redis) en arriere-plan
@$(COMPOSE) up -d
@echo "API disponible sur http://localhost:$(API_PORT)"

down: ## Arrete la stack Docker
@$(COMPOSE) down

stop: down ## Alias de 'down'

restart: ## Redemarre la stack Docker
@$(COMPOSE) restart

rebuild: ## Reconstruit sans cache puis redemarre la stack
@$(COMPOSE) build --no-cache
@$(COMPOSE) up -d --force-recreate

logs: ## Suit les logs du service api (SERVICE=db pour un autre)
@$(COMPOSE) logs -f $(SERVICE)

ps: ## Liste l'etat des conteneurs
@$(COMPOSE) ps

shell: ## Ouvre un shell dans le conteneur api
@$(COMPOSE) exec $(SERVICE) bash

db-shell: ## Ouvre psql dans le conteneur postgres
@$(COMPOSE) exec db psql -U $${DB_USER:-postgres} -d $${DB_NAME:-pythontogo_db}

redis-cli: ## Ouvre redis-cli dans le conteneur redis
@$(COMPOSE) exec redis redis-cli

docker-migrate: ## Execute les migrations dans le conteneur api
@$(COMPOSE) exec $(SERVICE) python -m app.database.migrations

prune: ## Arrete la stack et supprime les volumes (DONNEES PERDUES)
@$(COMPOSE) down -v

# ==============================================================================
# Nettoyage
# ==============================================================================
clean-pyc: ## Supprime les fichiers Python compiles
@find . -type d -name '__pycache__' -not -path './$(VENV)/*' -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name '*.py[co]' -not -path './$(VENV)/*' -delete

clean: clean-pyc ## Nettoie les caches (pycache, pytest, mypy)
@rm -rf .pytest_cache .mypy_cache .coverage htmlcov

reset: clean ## Supprime aussi l'environnement virtuel
@rm -rf $(VENV)
2 changes: 1 addition & 1 deletion app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ TICKETING_TEAM_EMAIL=
CONTACT_TEAM_EMAIL=
SPONSORSHIP_TEAM_EMAIL=

==========ADMIN notification service=========
# ========== ADMIN notification service ==========
ADMIN_SMTP_SERVER=
ADMIN_SMTP_PORT=
ADMIN_SMTP_USER=
Expand Down
71 changes: 71 additions & 0 deletions app/core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.settings import settings
from app.database.connection import get_db_connection
from app.database.orm import select

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v2/auth/login")

ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes
REFRESH_TOKEN_EXPIRE_DAYS = 7
SECRET_KEY = settings.secret_key
ALGORITHM = settings.algorithm


def hash_password(password: str) -> str:
return pwd_context.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)


def create_access_token(subject: str, expires_delta: Optional[timedelta] = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
payload = {"sub": str(subject), "type": "access", "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def create_refresh_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": str(user_id), "type": "refresh", "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)


async def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_db_connection)) -> dict:
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
users = await select(db, "users", filter={"id": user_id})
if not users:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
user = users[0]
if not user.get("is_active", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
return user


async def get_current_superuser(current_user: dict = Depends(get_current_user)) -> dict:
if not current_user.get("is_superuser"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
34 changes: 25 additions & 9 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPBasicCredentials
from typing import Annotated
from app.database.connection import get_db_connection, get_redis_client
from app.database.orm import select
from json import dumps, loads
from app.schemas.models import APIKeyResponse, APIKeyVerificationResponse
from app.core.settings import settings

security = HTTPBearer()

Expand All @@ -18,29 +19,44 @@ def generate_api_key():
return api_key


async def verify_api_key(credentials: Annotated[HTTPBasicCredentials, Depends(security)], db=Depends(get_db_connection), redis=Depends(get_redis_client)):
async def verify_api_key(request: Request, db=Depends(get_db_connection), redis=Depends(get_redis_client)):
api_key_value = request.headers.get("X-API-Key")

api_key_value = credentials.credentials
if not api_key_value.startswith("PYTOGO_SK_") or len(api_key_value) != 50:
if not api_key_value:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
api_key_value = auth_header[7:]

if not api_key_value or not isinstance(api_key_value, str) or not api_key_value.startswith("PYTOGO_SK_") or len(api_key_value) != 50:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
expected_api_key = await redis.get(f"PYTOGO_API_KEY:{credentials.credentials}")
expected_api_key = await redis.get(f"PYTOGO_API_KEY:{api_key_value}")
if not expected_api_key:
expected_api_key = await select(db, "api_keys", filter={"key_value": credentials.credentials})
expected_api_key = await select(db, "api_keys", filter={"key_value": api_key_value})
if not expected_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="API key not found")
expected_api_key = expected_api_key[0]
# Cache for 1 hour
api_key_data = {
"name": expected_api_key["name"],
"key_value": expected_api_key["key_value"],
}
await redis.set(f"PYTOGO_API_KEY:{credentials.credentials}", dumps(api_key_data), ex=3600)
await redis.set(f"PYTOGO_API_KEY:{api_key_value}", dumps(api_key_data), ex=3600)

expected_api_key_data = loads(expected_api_key) if isinstance(
expected_api_key, bytes) else expected_api_key
if expected_api_key_data["key_value"] != credentials.credentials:
if expected_api_key_data["key_value"] != api_key_value:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
return APIKeyVerificationResponse(is_valid=True, message="API key is valid")


async def require_admin_secret(request: Request):
if not settings.admin_api_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin secret not configured")
admin_secret = request.headers.get("X-Admin-Secret")
if admin_secret != settings.admin_api_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return True
4 changes: 2 additions & 2 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@
smtp_port=config("SMTP_PORT", default=587, cast=int),
smtp_user=config("SMTP_USER", default="user"),
smtp_password=config("SMTP_PASSWORD", default="password"),
# Admin SMTP settings for internal notification service
admin_api_key=config("ADMIN_API_KEY", default=""),
admin_smtp_server=config("ADMIN_SMTP_SERVER", default=None),
admin_smtp_port=config("ADMIN_SMTP_PORT", default=None, cast=int),
admin_smtp_port=config("ADMIN_SMTP_PORT", default=None),
admin_smtp_user=config("ADMIN_SMTP_USER", default=None),
admin_smtp_password=config("ADMIN_SMTP_PASSWORD", default=None),
paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY", default=None),
Expand Down
Loading