added keycloak integration, user endpoints, env file
This commit is contained in:
4
.env.template
Normal file
4
.env.template
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
151
src/app.py
151
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,
|
||||
)
|
||||
|
||||
24
src/config.py
Normal file
24
src/config.py
Normal 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
41
src/connections/db.py
Normal 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
170
src/connections/keycloak.py
Normal 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,
|
||||
)
|
||||
0
src/connections/rabbitmq.py
Normal file
0
src/connections/rabbitmq.py
Normal file
55
src/crud/usercrud.py
Normal file
55
src/crud/usercrud.py
Normal 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
|
||||
@@ -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
35
src/rest_models/user_models.py
Executable 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
19
src/sql_models/models.py
Normal 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)
|
||||
Reference in New Issue
Block a user