From 54de86fac87b8b63a4060dd2993d9c1596ec7831 Mon Sep 17 00:00:00 2001 From: DeOwl Date: Thu, 23 Apr 2026 20:19:39 +0300 Subject: [PATCH] added keycloak integration, user endpoints, env file --- .env.template | 4 + dockerfile => Dockerfile | 0 docker-compose.yaml | 23 ++++- requirements.txt | 7 ++ src/app.py | 151 +++++++++++++++++++---------- src/config.py | 24 +++++ src/connections/db.py | 41 ++++++++ src/connections/keycloak.py | 170 +++++++++++++++++++++++++++++++++ src/connections/rabbitmq.py | 0 src/crud/usercrud.py | 55 +++++++++++ src/request_response_models.py | 17 ---- src/rest_models/user_models.py | 35 +++++++ src/sql_models/models.py | 19 ++++ 13 files changed, 476 insertions(+), 70 deletions(-) create mode 100644 .env.template rename dockerfile => Dockerfile (100%) create mode 100644 src/config.py create mode 100644 src/connections/db.py create mode 100644 src/connections/keycloak.py create mode 100644 src/connections/rabbitmq.py create mode 100644 src/crud/usercrud.py delete mode 100755 src/request_response_models.py create mode 100755 src/rest_models/user_models.py create mode 100644 src/sql_models/models.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..8c67ab1 --- /dev/null +++ b/.env.template @@ -0,0 +1,4 @@ +KEYCLOAK_URL=http://auth.example.com +KEYCLOAK_REALM=quant_sim-realm +KEYCLOAK_CLIENT_ID=quantum-backend +KEYCLOAK_CLIENT_SECRET=!!!REPLACE_ME diff --git a/dockerfile b/Dockerfile similarity index 100% rename from dockerfile rename to Dockerfile diff --git a/docker-compose.yaml b/docker-compose.yaml index e29070b..9489b09 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,13 +4,34 @@ services: context: . environment: PORT: 1656 + extra_hosts: + - "auth.localhost:host-gateway" ports: - 1656:1656 depends_on: - - redis + db: + condition: service_healthy + redis: + condition: service_started restart: unless-stopped + env_file: .env + redis: image: "redis:alpine" ports: - "6379:6379" restart: unless-stopped + + db: + image: postgres:18.3 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: fastapi_db + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/requirements.txt b/requirements.txt index 213b3c4..d938298 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,10 @@ fastapi[all]==0.121.3 fastapi-cache2==0.2.2 redis==7.1.0 + +sqlalchemy==2.0.0 +asyncpg==0.31.0 + +python-keycloak==7.1.1 + +pydantic==2.13.1 diff --git a/src/app.py b/src/app.py index 182f21e..be69b0f 100755 --- a/src/app.py +++ b/src/app.py @@ -1,26 +1,42 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from connections.db import create_tables, engine, get_db +from connections.keycloak import ( + get_current_token_payload, + get_current_user, + get_keycloak_admin, +) +from crud.usercrud import get_or_create_user, update_user_profile +from fastapi import Depends, FastAPI from fastapi.exceptions import HTTPException from fastapi.middleware.cors import CORSMiddleware # from fastapi.requests import Request -from fastapi.responses import JSONResponse from fastapi_cache import FastAPICache from fastapi_cache.backends.redis import RedisBackend from fastapi_cache.decorator import cache -from logging_config import logger from redis import asyncio as aioredis -from request_response_models import BasicError, ConvertRequest, MolFileModel +from rest_models.user_models import ( + PublicUserResponse, + UserResponse, + UserUpdateRequest, +) +from sql_models.models import User +from sqlalchemy.ext.asyncio import AsyncSession @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: redis = aioredis.from_url("redis://redis:6379") FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") + + await create_tables() + yield + await engine.dispose() + app = FastAPI(lifespan=lifespan) @@ -39,54 +55,6 @@ app.add_middleware( ) -@app.post( - "/convert", - responses={ - 200: {"description": "Coversion Successful", "model": MolFileModel}, - 400: {"description": "Item created", "model": BasicError}, - }, -) -def convert_molecule(req: ConvertRequest): - try: - # Rad the string and format from request - mol = pybel.readstring(req.format, req.text) - - if req.add_hydrogen: - mol.addh() - if req.convert_3d: - mol.make3D() - if req.optimize_geometry: - mol.localopt() - - logger.info(f"Converting from format {req.format}") - - # To compute the Number of electrons and orbitals - atoms = [(atom.atomicnum, atom.coords) for atom in mol.atoms] - - mol_pyscf = gto.Mole() - mol_pyscf.atom = atoms - mol_pyscf.basis = "sto-3g" # simple basis - mol_pyscf.charge = mol.charge # default net charge - mol_pyscf.spin = mol.spin - 1 # multiplicity - 1 - mol_pyscf.build() - - # Export the resulting molecule - mol.OBMol.SetTitle( - f"Charge={mol.charge} Multiplicity={mol.spin} Electrons={mol_pyscf.nelectron} Orbitals={mol_pyscf.nao_nr()}" - ) - - mol2string = mol.write("xyz") - return JSONResponse({"molfile": mol2string}, 200) - except Exception as e: - raise HTTPException(status_code=400, detail={"error": str(e)}) - - -@app.get("/informats") -@cache() -async def get_informats(): - return pybel.informats - - @app.get( "/health", responses={ @@ -99,3 +67,82 @@ async def get_informats(): @cache() async def health_check(): return {"status": "healthy"} + + +# GET /me - Get current user (from JWT + local DB) +@app.get("/user", response_model=UserResponse) +async def get_me( + user: User = Depends(get_current_user), + payload: dict = Depends(get_current_token_payload), +) -> UserResponse: + """Get current user profile""" + + return UserResponse( + keycloak_id=user.keycloak_id, + email=payload.get("email", ""), + username=payload.get("preferred_username") or payload.get("username", ""), + profile_picture_path=user.profile_picture_path, + created_at=user.created_at, + ) + + +# PUT /me - Update user (local DB only) +@app.put("/user", response_model=UserResponse) +async def update_me( + update_data: UserUpdateRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> UserResponse: + """Update user profile (only profile_picture_path)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "permission denied") + + updated_user = await update_user_profile( + db, keycloak_id, update_data.profile_picture_path + ) + + return UserResponse( + keycloak_id=updated_user.keycloak_id, + email=payload.get("email", ""), + username=payload.get("preferred_username") or payload.get("username", ""), + profile_picture_path=updated_user.profile_picture_path, + created_at=updated_user.created_at, + ) + + +@app.get("/user/{email}", response_model=UserResponse) +async def get_user_by_email( + email: str, + db: AsyncSession = Depends(get_db), + keycloak_admin=Depends(get_keycloak_admin), + current_user: User = Depends(get_current_user), # Require auth +) -> UserResponse: + """ + Get public user profile by email. + Requires authentication to prevent email enumeration. + """ + + # Find user in Keycloak + keycloak_user = keycloak_admin.get_user_by_email(email) + + if not keycloak_user: + raise HTTPException(404, f"User with email '{email}' not found") + + keycloak_id = keycloak_user.get("id") + + local_user = await get_or_create_user(db, keycloak_id=keycloak_id) + + pfp = local_user.profile_picture_path + created_at = local_user.created_at + if not local_user: + raise HTTPException(404, f"Error getting user data") + + return UserResponse( + keycloak_id=keycloak_user.get("id"), + username=keycloak_user.get("username", ""), + email=keycloak_user.get("email", ""), + profile_picture_path=pfp, # Would need separate DB lookup + created_at=created_at, + ) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..8177e00 --- /dev/null +++ b/src/config.py @@ -0,0 +1,24 @@ +# config.py +import os +from functools import lru_cache + +from pydantic_settings import BaseSettings + +# Use asyncpg for async PostgreSQL +DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db" + + +class Settings(BaseSettings): + keycloak_server_url: str = os.environ["KEYCLOAK_URL"] + keycloak_realm: str = os.environ["KEYCLOAK_REALM"] + keycloak_client_id: str = os.environ["KEYCLOAK_CLIENT_ID"] + keycloak_client_secret: str = os.environ["KEYCLOAK_CLIENT_SECRET"] + + @property + def issuer(self) -> str: + return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/src/connections/db.py b/src/connections/db.py new file mode 100644 index 0000000..9f8f449 --- /dev/null +++ b/src/connections/db.py @@ -0,0 +1,41 @@ +from config import DATABASE_URL +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +# Create async engine +engine = create_async_engine( + DATABASE_URL, + echo=True, # Set to False in production + pool_size=5, + max_overflow=10, +) + +# Create async session factory +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + autocommit=False, +) + +Base = declarative_base() + + +# Create tables (async version) +async def create_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +# Dependency to get database session +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/src/connections/keycloak.py b/src/connections/keycloak.py new file mode 100644 index 0000000..e5bfd2e --- /dev/null +++ b/src/connections/keycloak.py @@ -0,0 +1,170 @@ +# services/keycloak.py +from typing import Dict, Optional + +from config import Settings, get_settings +from connections.db import get_db +from crud.usercrud import get_or_create_user +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer +from fastapi.security.http import HTTPAuthorizationCredentials +from keycloak.exceptions import KeycloakAuthenticationError, KeycloakGetError +from logging_config import logger +from rest_models.request_response_models import UserResponse +from sql_models.models import User +from sqlalchemy.ext.asyncio import AsyncSession + +from keycloak import KeycloakAdmin, KeycloakOpenID + + +class KeycloakOpenIDService: + """OpenID Connect operations - token validation, userinfo""" + + def __init__(self, settings: Settings): + self.keycloak_openid = KeycloakOpenID( + server_url=settings.keycloak_server_url, + client_id=settings.keycloak_client_id, + realm_name=settings.keycloak_realm, + client_secret_key=settings.keycloak_client_secret, + verify=True, + ) + self.settings = settings + + def get_well_known(self) -> dict: + """Get OpenID configuration""" + return self.keycloak_openid.well_known() + + def decode_token(self, token: str) -> dict: + """ + Decode and validate JWT token using Keycloak's public keys. + This does NOT call Keycloak - it validates locally using cached keys. + """ + try: + # Get public keys and decode + payload = self.keycloak_openid.decode_token(token) + return payload + except Exception as e: + logger.error(self.keycloak_openid.public_key()) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {str(e)}", + ) + + def get_userinfo(self, token: str) -> dict | bytes: + """Get user info from Keycloak using the access token""" + try: + return self.keycloak_openid.userinfo(token) + except KeycloakAuthenticationError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Failed to get user info: {str(e)}", + ) + + def introspect_token(self, token: str) -> dict: + """Introspect token (calls Keycloak) - use for revocation checks""" + try: + return self.keycloak_openid.introspect(token) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Token introspection failed: {str(e)}", + ) + + +class KeycloakAdminService: + """Admin operations - user lookup by email, etc.""" + + def __init__(self, settings: Settings): + self.settings = settings + self._admin: Optional[KeycloakAdmin] = None + + def _get_admin(self) -> KeycloakAdmin: + """Get or create admin client connection""" + if self._admin is None: + self._admin = KeycloakAdmin( + server_url=self.settings.keycloak_server_url, + realm_name=self.settings.keycloak_realm, + client_id=self.settings.keycloak_client_id, + client_secret_key=self.settings.keycloak_client_secret, + ) + return self._admin + + def get_user_by_email(self, email: str) -> Optional[Dict]: + """ + Find user by exact email match. + Uses the Keycloak Admin API. + """ + admin = self._get_admin() + try: + # Query users with email filter + # The get_users method accepts query parameters as a dict + users = admin.get_users(query={"email": email, "exact": True}) + return users[0] if users else None + except KeycloakGetError as e: + print(f"Keycloak admin error: {e}") + return None + + +security = HTTPBearer() + +# Singleton service instances +_keycloak_openid: Optional[KeycloakOpenIDService] = None +_keycloak_admin: Optional[KeycloakAdminService] = None + + +def get_keycloak_openid( + settings: Settings = Depends(get_settings), +) -> KeycloakOpenIDService: + """Dependency for OpenID service""" + global _keycloak_openid + if _keycloak_openid is None: + _keycloak_openid = KeycloakOpenIDService(settings) + return _keycloak_openid + + +def get_keycloak_admin( + settings: Settings = Depends(get_settings), +) -> KeycloakAdminService: + """Dependency for Admin service""" + global _keycloak_admin + if _keycloak_admin is None: + _keycloak_admin = KeycloakAdminService(settings) + return _keycloak_admin + + +async def get_current_token_payload( + credentials: HTTPAuthorizationCredentials = Depends(security), + keycloak_openid: KeycloakOpenIDService = Depends(get_keycloak_openid), +) -> Dict: + """ + Extract and validate JWT token. + Returns decoded token payload. + """ + token = credentials.credentials + return keycloak_openid.decode_token(token) + + +async def get_current_user( + db: AsyncSession = Depends(get_db), + payload: Dict = Depends(get_current_token_payload), +) -> UserResponse: + """ + Get current user from database (creates if doesn't exist). + This is the main dependency for authenticated endpoints. + """ + keycloak_id = payload.get("sub") + if not keycloak_id: + raise HTTPException(401, "Invalid token: missing sub claim") + + email = payload.get("email") + username = payload.get("preferred_username") or payload.get("username") + if not email or not username: + raise Exception("Error, user found but no email / username") + + user = await get_or_create_user(db, keycloak_id=keycloak_id) + return UserResponse( + keycloak_id=user.keycloak_id, + email=email, + username=username, + created_at=user.created_at, + profile_picture_path=user.profile_picture_path, + ) diff --git a/src/connections/rabbitmq.py b/src/connections/rabbitmq.py new file mode 100644 index 0000000..e69de29 diff --git a/src/crud/usercrud.py b/src/crud/usercrud.py new file mode 100644 index 0000000..44343c3 --- /dev/null +++ b/src/crud/usercrud.py @@ -0,0 +1,55 @@ +from typing import Optional + +from sql_models.models import User +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + + +async def get_or_create_user( + db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str] = None +) -> User: + + stmt = ( + insert(User) + .values( + keycloak_id=keycloak_id, + profile_picture_path=profile_picture_path, + is_deleted=False, + ) + .on_conflict_do_nothing(index_elements=["keycloak_id"]) + ) + + await db.execute(stmt) + await db.flush() + + # Now fetch (exists after this point) + result = await db.execute( + select(User).where(User.keycloak_id == keycloak_id, User.is_deleted == False) + ) + return result.scalar_one() + + +async def soft_delete_user(db: AsyncSession, keycloak_id: str) -> bool: + user = await get_or_create_user(db, keycloak_id) + + user.is_deleted = True + + await db.flush() + await db.refresh(user) + return user + + +async def update_user_profile( + db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str] +) -> User: + """Update only local DB fields""" + + user = await get_or_create_user(db, keycloak_id) + + if profile_picture_path is not None: + user.profile_picture_path = profile_picture_path + + await db.flush() + await db.refresh(user) + return user diff --git a/src/request_response_models.py b/src/request_response_models.py deleted file mode 100755 index c8eec84..0000000 --- a/src/request_response_models.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel - - -class ConvertRequest(BaseModel): - text: str - format: str # e.g. "mol", "smi", "sdf", "inchi", etc. - add_hydrogen: bool = False - convert_3d: bool = False - optimize_geometry: bool = False - - -class MolFileModel(BaseModel): - molfile: str - - -class BasicError(BaseModel): - error: str diff --git a/src/rest_models/user_models.py b/src/rest_models/user_models.py new file mode 100755 index 0000000..b3d5f5a --- /dev/null +++ b/src/rest_models/user_models.py @@ -0,0 +1,35 @@ +# schemas.py +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# GET /me - Complete user profile (read-only from Keycloak) +class UserResponse(BaseModel): + # From Keycloak (read-only) + keycloak_id: str + email: EmailStr + username: str + # From your database (writeable) + profile_picture_path: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# PUT /me - What user can actually change +class UserUpdateRequest(BaseModel): + profile_picture_path: Optional[str] = None + # That's it! No first_name, last_name, email, username + # Those are managed ONLY in Keycloak + + +# GET /user/{email} - Public profile +class PublicUserResponse(BaseModel): + keycloak_id: str + username: str + email: EmailStr + profile_picture_path: Optional[str] = None + # No email exposed, no internal IDs diff --git a/src/sql_models/models.py b/src/sql_models/models.py new file mode 100644 index 0000000..15a097c --- /dev/null +++ b/src/sql_models/models.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from connections.db import Base +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + + +class User(Base): + __tablename__ = "users" + + keycloak_id: Mapped[str] = mapped_column( + String(36), primary_key=True, index=True, nullable=False + ) + profile_picture_path: Mapped[str] = mapped_column( + String(500), nullable=True + ) # The only extra field + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Timestamps for auditing + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)