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
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
109 changes: 85 additions & 24 deletions app/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,50 @@
REFERENCES events(id)
ON DELETE CASCADE
);""",

]
"""
CREATE TABLE IF NOT EXISTS feedbacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sex VARCHAR(64),
age VARCHAR(32),
profession VARCHAR(255),
country VARCHAR(120),
python_level VARCHAR(120),
heard TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
overall TEXT,
favorite TEXT,
improvements TEXT,
comments TEXT,
is_resolved BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);""",
"""
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
hashed_password TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);""",
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_refresh_tokens_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);""",

]

CREATE_INDEX_QUERIES = [
"CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);",
Expand All @@ -539,33 +581,52 @@

ALTER_TABLE_QUERIES = [
"ALTER TABLE sponsors_partners ADD COLUMN IF NOT EXISTS package_tier package_tier_enum;",
"ALTER TABLE feedbacks DROP COLUMN IF EXISTS event_code;",
"ALTER TABLE feedbacks DROP COLUMN IF EXISTS name;",
"ALTER TABLE feedbacks DROP COLUMN IF EXISTS email;",
"ALTER TABLE feedbacks DROP COLUMN IF EXISTS subject;",
"ALTER TABLE feedbacks DROP COLUMN IF EXISTS message;",
"ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS days JSONB DEFAULT '[]'::jsonb;",
]


def create_tables():
"""Return SQL queries in execution order for creating the schema."""
conn = connect(settings.db_url)
with conn.cursor() as cur:
cur.execute(CREATE_EXTENSIONS_QUERY)
cur.execute(CREATE_TYPES_QUERY)
for query in CREATE_TABLE_QUERIES:
cur.execute(query)
for query in ALTER_TABLE_QUERIES:
cur.execute(query)
for query in CREATE_INDEX_QUERIES:
cur.execute(query)
conn.commit()
return (
CREATE_EXTENSIONS_QUERY
+ "\n"
+ CREATE_TYPES_QUERY
+ "\n"
+ "\n".join(CREATE_TABLE_QUERIES)
+ "\n"
+ "\n".join(ALTER_TABLE_QUERIES)
+ "\n"
+ "\n".join(CREATE_INDEX_QUERIES)
)
import time
max_retries = 10
retry_delay = 2
last_error = None
for attempt in range(1, max_retries + 1):
try:
conn = connect(settings.db_url)
with conn.cursor() as cur:
cur.execute(CREATE_EXTENSIONS_QUERY)
cur.execute(CREATE_TYPES_QUERY)
for query in CREATE_TABLE_QUERIES:
cur.execute(query)
for query in ALTER_TABLE_QUERIES:
cur.execute(query)
for query in CREATE_INDEX_QUERIES:
cur.execute(query)
conn.commit()
return (
CREATE_EXTENSIONS_QUERY
+ "\n"
+ CREATE_TYPES_QUERY
+ "\n"
+ "\n".join(CREATE_TABLE_QUERIES)
+ "\n"
+ "\n".join(ALTER_TABLE_QUERIES)
+ "\n"
+ "\n".join(CREATE_INDEX_QUERIES)
)
except Exception as exc:
last_error = exc
if attempt < max_retries:
print(f"Tentative {attempt}/{max_retries} : base non prête, attente {retry_delay}s...")
time.sleep(retry_delay)
else:
raise last_error


def run_migrations():
Expand Down
22 changes: 6 additions & 16 deletions app/database/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ async def select(db: Connection, table, columns=None, filter=None):
result = await cur.fetchall()
return result
except Exception as e:

logger.error(f"Error executing select query on {table}: {str(e)}")
# TODO: sent email to admin about error during select query execution
raise


async def select_with_join(db: Connection, table, join_table, join_condition, columns=None, filter=None):
Expand All @@ -36,26 +35,23 @@ async def select_with_join(db: Connection, table, join_table, join_condition, co

return result
except Exception as e:

logger.error(
f"Error executing select with join query on {table} and {join_table}: {str(e)}")
# TODO: sent email to admin about error during select with join query execution
raise


async def select_with_multiple_joins(db: Connection, table, joins, columns=None, filter=None):
try:

query, values = generate_multiple_joins_query(
table, joins, columns, filter)
async with db.cursor(row_factory=dict_row) as cur:
await cur.execute(query, values)
result = await cur.fetchall()
return result
except Exception as e:

logger.error(
f"Error executing select with multiple joins query on {table}: {str(e)}")
# TODO: sent email to admin about error during select with multiple joins query execution
raise


async def insert(db: Connection, table, data):
Expand All @@ -69,24 +65,20 @@ async def insert(db: Connection, table, data):
await cur.execute(query, values)
await db.commit()
except Exception as e:

logger.error(f"Error inserting record into {table}: {str(e)}")
# TODO: sent email to admin about error during insert query execution
raise


async def update(db: Connection, table, data, filter):
try:
data = remove_null_values(data)
query, values = generate_update_query(table, data, filter)
async with db.cursor() as cur:

await cur.execute(query, values)
await db.commit()
except Exception as e:

logger.error(f"Error updating record in {table}: {str(e)}")
# TODO: Log the error can be done here
# TODO: sent email to admin about error during update query execution
raise


async def delete(db: Connection, table, filter):
Expand All @@ -96,7 +88,5 @@ async def delete(db: Connection, table, filter):
await cur.execute(query, values)
await db.commit()
except Exception as e:

logger.error(f"Error deleting record from {table}: {str(e)}")
# TODO: Log the error can be done here
# TODO: sent email to admin about error during delete query execution
raise
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import redis.asyncio as redis
from app.core.settings import settings
from app.routers.api import api_routers
from app.routers.auth import api_router as auth_router
from app.routers.notifications import api_router as notifications_router
from app.webhooks.payments_callback import api_router as payments_callback_router
from app.routers.feedback_public import api_router as feedback_public_router
from app.core.settings import logger, settings
from pathlib import Path
from datetime import datetime, timezone
Expand Down Expand Up @@ -147,6 +149,8 @@ async def favicon():
return FileResponse(BASE_DIR / "static" / "favicon.ico")


app.include_router(auth_router, prefix="/api/v2")
app.include_router(api_routers)
app.include_router(payments_callback_router)
app.include_router(notifications_router)
app.include_router(feedback_public_router)
2 changes: 2 additions & 0 deletions app/routers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from app.routers.vauchers import api_router as vauchers_router
from app.routers.teams import api_router as teams_router
from app.routers.access_grant import api_router as access_grant_router
from app.routers.feedbacks import api_router as feedbacks_router
from fastapi import APIRouter
from app.core.security import verify_api_key

Expand All @@ -40,3 +41,4 @@
api_routers.include_router(vauchers_router)
api_routers.include_router(teams_router)
api_routers.include_router(access_grant_router)
api_routers.include_router(feedbacks_router)
Loading