feat(auth): enterprise authentication and user management

This commit is contained in:
Schubert Ferenc 2026-07-02 22:19:12 +02:00
parent 4bc8b20a21
commit 86a32a942c
36 changed files with 2239 additions and 324 deletions

View file

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.core.security import (
create_access_token,
get_access_token_expire_seconds,
verify_password,
)
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import LoginRequest
from app.schemas.user import LoginRequest, LoginResponse
router = APIRouter(
prefix="/auth",
@ -12,32 +16,30 @@ router = APIRouter(
)
@router.post("/login")
@router.post("/login", response_model=LoginResponse)
def login(
login: LoginRequest,
response: Response,
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == login.username).first()
if not user:
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
if not verify_password(login.password, user.password_hash):
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
token = create_access_token(user.username)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
)
return {
"message": "Login erfolgreich",
"user": user.username,
}
"access_token": token,
"token_type": "bearer",
"expires_in": get_access_token_expire_seconds(),
"user": user,
}

View file

@ -1,24 +1,191 @@
from fastapi import APIRouter, Depends
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import verify_access_token
from app.db.database import get_db
from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserResponse
from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/users",
tags=["Users"]
tags=["Users"],
)
bearer_scheme = HTTPBearer(auto_error=False)
@router.get("/", response_model=list[UserResponse])
def get_users(db: Session = Depends(get_db)):
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
username = verify_access_token(credentials.credentials)
user = UserRepository.get_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(*allowed_roles: str):
def dependency(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency
def get_user_or_404(db: Session, user_id: int) -> User:
db_user = UserRepository.get_by_id(db, user_id)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Benutzer nicht gefunden",
)
return db_user
def raise_conflict(field: str) -> None:
if field == "username":
detail = "Benutzername ist bereits vergeben"
else:
detail = "E-Mail ist bereits vergeben"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
@router.get("", response_model=list[UserResponse])
@router.get("/", response_model=list[UserResponse], include_in_schema=False)
def get_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
logger.info("users.list", extra={"actor_user_id": current_user.id})
return UserRepository.get_all(db)
@router.post("/", response_model=UserResponse)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
logger.info(
"users.detail",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return get_user_or_404(db, user_id)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
include_in_schema=False,
)
def create_user(
user: UserCreate,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return UserRepository.create(db, user)
conflict = UserRepository.find_conflict(
db,
username=user.username,
email=str(user.email),
)
if conflict is not None:
raise_conflict(conflict[0])
logger.info("users.create", extra={"actor_user_id": current_user.id})
return UserRepository.create(db, user)
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_user = get_user_or_404(db, user_id)
conflict = UserRepository.find_conflict(
db,
username=user.username,
email=str(user.email),
exclude_user_id=user_id,
)
if conflict is not None:
raise_conflict(conflict[0])
logger.info(
"users.update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update(db, db_user, user)
@router.put("/{user_id}/password", response_model=UserResponse)
def update_user_password(
user_id: int,
password_update: UserPasswordUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_user = get_user_or_404(db, user_id)
logger.info(
"users.password_update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update_password(db, db_user, password_update)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.id == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Der aktuell angemeldete Benutzer darf sich nicht selbst löschen",
)
db_user = get_user_or_404(db, user_id)
logger.info(
"users.delete",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
UserRepository.delete(db, db_user)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -7,6 +7,8 @@ class Settings(BaseSettings):
app_name: str = "Hermes API"
app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta
from jose import jwt
from fastapi import HTTPException, status
from jose import JWTError, jwt
from pwdlib import PasswordHash
from app.core.config import settings
@ -8,7 +9,11 @@ from app.core.config import settings
password_hash = PasswordHash.recommended()
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
ACCESS_TOKEN_TYPE = "access"
def get_access_token_expire_seconds() -> int:
return settings.access_token_expire_minutes * 60
def hash_password(password: str) -> str:
@ -20,11 +25,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_access_token(subject: str) -> str:
expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
issued_at = datetime.now(UTC)
expire = issued_at + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": subject,
"iat": issued_at,
"exp": expire,
"iss": settings.jwt_issuer,
"type": ACCESS_TOKEN_TYPE,
}
return jwt.encode(
@ -32,3 +41,31 @@ def create_access_token(subject: str) -> str:
settings.secret_key,
algorithm=ALGORITHM,
)
def verify_access_token(token: str) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[ALGORITHM],
issuer=settings.jwt_issuer,
)
subject = payload.get("sub")
token_type = payload.get("type")
except JWTError as exc:
raise credentials_exception from exc
if not isinstance(subject, str) or not subject:
raise credentials_exception
if token_type != ACCESS_TOKEN_TYPE:
raise credentials_exception
return subject

View file

@ -1,5 +1,12 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi import HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from starlette import status
from starlette.requests import Request
from app.api.auth import router as auth_router
from app.api.users import router as users_router
@ -11,21 +18,52 @@ app = FastAPI(
description="Backend von Olympus",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://crm.funktechnik-schubert.de",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(users_router)
logger = logging.getLogger(__name__)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.warning(
"http_exception",
extra={
"path": request.url.path,
"method": request.method,
"status_code": exc.status_code,
},
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.warning(
"validation_exception",
extra={"path": request.url.path, "method": request.method},
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception(
"unhandled_exception",
extra={"path": request.url.path, "method": request.method},
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Interner Serverfehler"},
)
@app.get("/")
def root():
@ -40,4 +78,4 @@ def health():
return {
"status": "healthy",
"database": check_database(),
}
}

View file

@ -1,4 +1,9 @@
from datetime import datetime
from sqlalchemy import Boolean
from sqlalchemy import DateTime
from sqlalchemy import String
from sqlalchemy import func
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
@ -10,6 +15,18 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
first_name: Mapped[str] = mapped_column(
String(100),
default="",
server_default=""
)
last_name: Mapped[str] = mapped_column(
String(100),
default="",
server_default=""
)
username: Mapped[str] = mapped_column(
String(50),
unique=True,
@ -23,4 +40,27 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(
String(255)
)
)
role: Mapped[str] = mapped_column(
String(50),
default="user",
server_default="user"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
server_default="true"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)

View file

@ -1,27 +1,98 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
class UserRepository:
@staticmethod
def get_all(db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.created_at.desc())))
@staticmethod
def get_all(db: Session):
return db.query(User).all()
def get_by_id(db: Session, user_id: int) -> User | None:
return db.get(User, user_id)
@staticmethod
def create(db: Session, user: UserCreate):
def get_by_username(db: Session, username: str) -> User | None:
return db.scalar(select(User).where(User.username == username))
@staticmethod
def find_conflict(
db: Session,
*,
username: str,
email: str,
exclude_user_id: int | None = None,
) -> tuple[str, User] | None:
username_query = select(User).where(User.username == username)
email_query = select(User).where(User.email == email)
if exclude_user_id is not None:
username_query = username_query.where(User.id != exclude_user_id)
email_query = email_query.where(User.id != exclude_user_id)
username_user = db.scalar(username_query)
if username_user is not None:
return ("username", username_user)
email_user = db.scalar(email_query)
if email_user is not None:
return ("email", email_user)
return None
@staticmethod
def create(db: Session, user: UserCreate) -> User:
db_user = User(
username=user.username,
email=user.email,
password_hash=hash_password(user.password),
)
first_name=user.first_name,
last_name=user.last_name,
username=user.username,
email=str(user.email),
role=user.role,
is_active=user.is_active,
password_hash=hash_password(user.password),
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
return db_user
@staticmethod
def update(db: Session, db_user: User, user: UserUpdate) -> User:
db_user.first_name = user.first_name
db_user.last_name = user.last_name
db_user.username = user.username
db_user.email = str(user.email)
db_user.role = user.role
db_user.is_active = user.is_active
if user.password:
db_user.password_hash = hash_password(user.password)
db.commit()
db.refresh(db_user)
return db_user
@staticmethod
def update_password(
db: Session,
db_user: User,
password_update: UserPasswordUpdate,
) -> User:
db_user.password_hash = hash_password(password_update.password)
db.commit()
db.refresh(db_user)
return db_user
@staticmethod
def delete(db: Session, db_user: User) -> None:
db.delete(db_user)
db.commit()

View file

@ -1,21 +1,79 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["admin", "manager", "user"]
class UserCreate(BaseModel):
username: str
class UserBase(BaseModel):
first_name: str = Field(default="", max_length=100)
last_name: str = Field(default="", max_length=100)
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str
role: UserRole = "user"
is_active: bool = True
@field_validator("first_name", "last_name", "username", mode="before")
@classmethod
def normalize_text(cls, value: object) -> str:
if value is None:
return ""
return str(value).strip()
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
allowed = value.replace(".", "").replace("_", "").replace("-", "")
if not allowed.isalnum():
raise ValueError(
"Benutzername darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten"
)
return value
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=128)
class UserUpdate(UserBase):
password: str | None = Field(default=None, max_length=128)
@field_validator("password")
@classmethod
def validate_optional_password(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
if len(value) < 8:
raise ValueError("Passwort muss mindestens 8 Zeichen lang sein")
return value
class UserPasswordUpdate(BaseModel):
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
username: str
password: str
password: str
class UserResponse(BaseModel):
id: int
first_name: str
last_name: str
username: str
email: EmailStr
role: UserRole
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {
"from_attributes": True
}
model_config = ConfigDict(from_attributes=True)
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse