Skip to content
Merged
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
38 changes: 0 additions & 38 deletions .env.old

This file was deleted.

2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ COPY . .

RUN mkdir -p /app/static/images && chmod 777 /app/static/images

EXPOSE 8080
EXPOSE 8001
9 changes: 8 additions & 1 deletion backend/app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@ BACKEND_PORT=8001
LOG_LEVEL=INFO
IMAGE_DIR=/app/static/images
CORS_ORIGINS=http://localhost:3000
FRONTEND_URL=http://localhost:3000
DATABASE_URL=postgresql://user:dev_pass_user1@db:5432/appdb_dev_user1
SECRET_KEY = "9a6f88adad60f8dc9cae15ea76b9e363a21a051020268f1b2158004ddd5160f4"
SESSION_SECRET_KEY=sG8LhNXrRNLb-JOrEo5XAcVtqTxhCaAYaZ0QqiDXQgQ
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_INTERNAL_URL=http://keycloak:8080
KEYCLOAK_EXTERNAL_URL=http://localhost:8080
KEYCLOAK_REALM=usecase-management-dev
KEYCLOAK_CLIENT_ID=usecase-management-dev-client
KEYCLOAK_CLIENT_SECRET=aKfLgYd2mWo2fEQTA5yqEKc8CGf6bSbnsFN9yA5pKOLrFTYnDlIyJipnzup2udc3TU5uvndpsngxEADe7LGGBc
8 changes: 7 additions & 1 deletion backend/app/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ LOG_LEVEL=INFO
IMAGE_DIR=/app/static/images
CORS_ORIGINS=http://localhost:3000
DATABASE_URL=postgresql://user:dev_pass_user1@db:5432/appdb_dev_user1
SECRET_KEY = "changeme"
SESSION_SECRET_KEY=
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_INTERNAL_URL=http://centralidp.staging.construct-x.net/auth/
KEYCLOAK_EXTERNAL_URL=http://centralidp.staging.construct-x.net/auth/
KEYCLOAK_REALM=
KEYCLOAK_CLIENT_ID=usecase-management
KEYCLOAK_CLIENT_SECRET=
9 changes: 8 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ class Settings(BaseSettings):
log_level: str = "INFO"
cors_origins: str = "*"
database_url: str = ""
secret_key: str = ""
frontend_url: str
session_secret_key: str
keycloak_server_url: str
keycloak_internal_url: str # es werden zwei URLs benötigt, da der Test mit einer Remote-VM sonst nicht funktioniert
keycloak_external_url: str # es werden zwei URLs benötigt, da der Test mit einer Remote-VM sonst nicht funktioniert
keycloak_realm: str
keycloak_client_id: str
keycloak_client_secret: str

model_config = SettingsConfigDict(
env_file_encoding="utf-8",
Expand Down
132 changes: 34 additions & 98 deletions backend/app/crud/auth.py
Original file line number Diff line number Diff line change
@@ -1,121 +1,57 @@
from datetime import datetime, timedelta
from typing import Optional, Union
from fastapi import HTTPException, Depends, status, Security
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
from jose import JWTError, jwt
import bcrypt
from typing import Union
from fastapi import HTTPException, Depends, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from sqlalchemy.orm import Session
import secrets
from ..db import get_db
from ..models.users import User, APIKey, RoleEnum
from ..schemas.users import TokenData
from ..models.users import User, RoleEnum
from .keycloak import decode_keycloak_token
from ..config import get_settings

SECRET_KEY = get_settings().secret_key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
settings = get_settings()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl=f"{settings.keycloak_external_url}/realms/{settings.keycloak_realm}/protocol/openid-connect/auth",
tokenUrl=f"{settings.keycloak_external_url}/realms/{settings.keycloak_realm}/protocol/openid-connect/token",
scopes={"openid": "openid", "profile": "profile", "email": "email"},
auto_error=False,
)

def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)

def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt

def generate_api_key() -> str:
return secrets.token_urlsafe(32)

def get_user_by_username(db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()

def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()

def authenticate_user(db: Session, username: str, password: str) -> Union[User, bool]:
user = get_user_by_username(db, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user

async def get_current_user_from_token(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
async def get_current_user_from_token(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültige Authentifizierung",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
payload = await decode_keycloak_token(token)
except Exception:
raise credentials_exception

user = get_user_by_username(db, username=token_data.username)
if user is None:
username = payload.get("preferred_username")
if username is None:
raise credentials_exception
return user

async def get_user_from_api_key(
api_key: Optional[str] = Security(api_key_header),
db: Session = Depends(get_db)
) -> Optional[dict]:
if not api_key:
return None

key_obj = db.query(APIKey).filter(
APIKey.key == api_key,
APIKey.is_active.is_(True)
).first()

if not key_obj:
return None

if key_obj.expires_at and key_obj.expires_at < datetime.utcnow():
return None

return {"role": key_obj.role, "auth_type": "api_key"}

async def get_current_user(
token_user: Optional[User] = Depends(get_current_user_from_token),
api_key_user: Optional[User] = Depends(get_user_from_api_key),
) -> Union[User, dict]:
# Priorisiere Token-Auth, falls vorhanden
if token_user:
if not token_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return token_user

# Fallback auf API-Key
if api_key_user:
return api_key_user
user = db.query(User).filter(User.username == username).first()
if user is None:
# Just-in-Time Provisioning: User existiert in Keycloak, aber noch nicht lokal
roles = payload.get("realm_access", {}).get("roles", [])
role = RoleEnum.WRITE if "write" in roles else RoleEnum.READ
user = User(
username=username,
email=payload.get("email", ""),
role=role,
hashed_password="", # nicht mehr genutzt, Login läuft über Keycloak
)
db.add(user)
db.commit()
db.refresh(user)
return user

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert"
)

def require_role(required_role: RoleEnum):
"""Decorator-Funktion für Rollenprüfung"""
async def role_checker(current_user: Union[User, dict] = Depends(get_current_user)):
async def role_checker(current_user: Union[User, dict] = Depends(get_current_user_from_token)):
if isinstance(current_user, User):
user_role = current_user.role
else:
Expand Down
43 changes: 43 additions & 0 deletions backend/app/crud/keycloak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from authlib.integrations.starlette_client import OAuth
from jose import jwt
import httpx
from ..config import get_settings

settings = get_settings()

oauth = OAuth()
oauth.register(
name="keycloak",
server_metadata_url=(
f"{settings.keycloak_internal_url}/realms/{settings.keycloak_realm}" # hier interne URL
"/.well-known/openid-configuration"
),
client_id=settings.keycloak_client_id,
client_secret=settings.keycloak_client_secret,
client_kwargs={"scope": "openid profile email"},
)

_jwks_cache: dict | None = None

async def _get_jwks() -> dict:
global _jwks_cache
if _jwks_cache is None:
metadata = await oauth.keycloak.load_server_metadata()
async with httpx.AsyncClient() as client:
resp = await client.get(metadata["jwks_uri"])
resp.raise_for_status()
_jwks_cache = resp.json()
return _jwks_cache

async def decode_keycloak_token(token: str) -> dict:
jwks = await _get_jwks()
header = jwt.get_unverified_header(token)
key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
payload = jwt.decode(
token,
key,
algorithms=[header["alg"]],
audience="account",
options={"verify_aud": False},
)
return payload
16 changes: 13 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from .routers import useCases, subUseCases, roles, transactions, properties, propertyGroups, users, ontology, standards
from starlette.middleware.sessions import SessionMiddleware
from .routers import auth, useCases, subUseCases, roles, transactions, properties, propertyGroups, users, ontology, standards
from .config import IMAGE_DIR, get_settings
from .exceptions import VersionConflictError

Expand All @@ -13,6 +14,11 @@
description="Manage use cases and related information.",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
swagger_ui_init_oauth={
"clientId": "usecase-management-dev-swagger",
"usePkceWithAuthorizationCodeGrant": True,
"scopes": "openid profile email",
},
)

@app.exception_handler(VersionConflictError)
Expand All @@ -36,6 +42,11 @@ async def version_conflict_handler(request: Request, exc: VersionConflictError):
allow_headers=["*"],
)

app.add_middleware(
SessionMiddleware,
secret_key=get_settings().session_secret_key,
)

api = APIRouter(prefix="/api")

@app.get("/health")
Expand All @@ -44,9 +55,8 @@ def health() -> Response:

app.mount("/static/images", StaticFiles(directory=IMAGE_DIR), name="images")

api.include_router(users.auth_router)
api.include_router(auth.auth_router)
api.include_router(users.user_router)
api.include_router(users.api_router)
api.include_router(useCases.router)
api.include_router(subUseCases.router)
api.include_router(roles.router)
Expand Down
39 changes: 39 additions & 0 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from fastapi import Request, APIRouter
from fastapi.responses import RedirectResponse
from ..crud.keycloak import oauth
from ..config import get_settings
from urllib.parse import urlencode


settings = get_settings()

auth_router = APIRouter(tags=["Auth"])

@auth_router.get("/auth/login")
async def login(request: Request):
redirect_uri = str(request.url_for("auth_callback"))
return await oauth.keycloak.authorize_redirect(request, redirect_uri)

@auth_router.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request):
token = await oauth.keycloak.authorize_access_token(request)
access_token = token["access_token"]
refresh_token = token.get("refresh_token", "")
id_token = token.get("id_token", "")
return RedirectResponse(
f"{settings.frontend_url}"
f"#access_token={access_token}&refresh_token={refresh_token}&id_token={id_token}"
)

@auth_router.get("/auth/logout")
async def logout(id_token: str | None = None):
metadata = await oauth.keycloak.load_server_metadata()
end_session_endpoint = metadata["end_session_endpoint"]

params = {"post_logout_redirect_uri": settings.frontend_url}
if id_token:
params["id_token_hint"] = id_token
else:
params["client_id"] = settings.keycloak_client_id

return RedirectResponse(f"{end_session_endpoint}?{urlencode(params)}")
Loading
Loading