added keycloak integration, user endpoints, env file

This commit is contained in:
2026-04-23 20:19:39 +03:00
parent 86eee49c43
commit 54de86fac8
13 changed files with 476 additions and 70 deletions

4
.env.template Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

24
src/config.py Normal file
View File

@@ -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()

41
src/connections/db.py Normal file
View File

@@ -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()

170
src/connections/keycloak.py Normal file
View File

@@ -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,
)

View File

55
src/crud/usercrud.py Normal file
View File

@@ -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

View File

@@ -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

35
src/rest_models/user_models.py Executable file
View File

@@ -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

19
src/sql_models/models.py Normal file
View File

@@ -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)