Просмотр исходного кода

Füge Authentifizierungs- und Berechtigungsmanagement hinzu

- Erstelle Migration für die Tabelle 'Rollen' und füge Standardrollen hinzu.
- Füge die Spalte 'Abteilung' zur Tabelle 'Benutzer_Rechte' hinzu, falls sie nicht existiert.
- Erstelle einen Index für die Tabelle 'Benutzer_Rechte', um die Abfrageleistung zu verbessern.
- Aktualisiere die Abfrage-Templates, um Platzhalter für Parameter zu verwenden, anstelle von direkten URL-Parametern.
- Implementiere CSRF-Schutz in den Formularen und aktualisiere die Logout-Logik.
- Füge Tests für Authentifizierung und Berechtigungen hinzu, um die Funktionalität zu überprüfen.
- Aktualisiere die Abhängigkeiten in der pyproject.toml und uv.lock, um die neuesten Versionen zu verwenden.
- Ändere den Startbefehl für den Uvicorn-Server, um Proxy-Header zu deaktivieren und die IP-Adresse zu ändern.
gc-server3 1 месяц назад
Родитель
Сommit
6cf42fec89

+ 20 - 0
.env.example

@@ -0,0 +1,20 @@
+ENVIRONMENT=production
+DATABASE_URL=mssql+pyodbc://server/database?driver=ODBC+Driver+18+for+SQL+Server&trusted_connection=yes&TrustServerCertificate=no
+
+AUTH_PROXY_HEADER=X-Remote-User
+TRUSTED_PROXY_IPS=127.0.0.1,::1
+JWT_SECRET=replace-with-at-least-32-random-bytes
+JWT_ISSUER=gcepic
+JWT_AUDIENCE=gcepic-web
+JWT_IDLE_MINUTES=30
+JWT_ABSOLUTE_HOURS=8
+COOKIE_SECURE=true
+
+LDAP_URL=ldaps://domain-controller.example.local:636
+LDAP_BASE_DN=DC=example,DC=local
+LDAP_BIND_DN=CN=gcepic-service,OU=Service Accounts,DC=example,DC=local
+LDAP_BIND_PASSWORD=replace-me
+LDAP_UPN_SUFFIX=example.local
+LDAP_START_TLS=false
+
+LOCAL_STORAGE=C:\\Data\\GCEPIC

+ 2 - 1
.gitignore

@@ -8,5 +8,6 @@ wheels/
 
 # Virtual environments
 .venv
+.uv-cache/
 .env
-logs/
+logs/

+ 25 - 4
README.md

@@ -31,10 +31,10 @@ Das übernimmt `uv`
 ```cmd
 cd C:\Projekte\Reisacher-Forderung
 uv sync
-echo DATABASE_URL=mssql+pyodbc://user:pass@server:port/database?driver=SQL+Server+Native+Client+11.0 > .env
+copy .env.example .env
 ```
 
-Dann muss noch die Datenbankanbindung definiert werden. Dazu muss die Datei `.env` bearbeitet werden und die Platzhalter mit der tatsächlichen Datenbank-Anmeldung eingetragen werden.
+Danach müssen in `.env` die Datenbank-, LDAP-, Proxy- und JWT-Platzhalter ersetzt werden.
 
 Danach kann der Webserver gestartet werden:
 
@@ -61,6 +61,27 @@ Das Repository ist nun bereit für die Nutzung.
 
 Öffne <http://127.0.0.1:8000> im Browser.
 
-## Deployment
+## Authentifizierung und Deployment
 
-TODO
+Die Anwendung läuft im Produktivbetrieb ausschließlich hinter dem IIS-Reverse-Proxy auf
+`127.0.0.1:8093`. IIS entfernt einen eventuell vom Client gelieferten Header
+`X-Remote-User` und setzt ihn nur für erfolgreich per Windows Authentication bestätigte
+Benutzer neu. Fehlt der Header, bietet die Anwendung die Anmeldung gegen Active Directory
+über LDAPS an.
+
+1. `migrations/001_authentication.sql` auf der Anwendungsdatenbank ausführen.
+2. `.env.example` nach `.env` kopieren und alle Platzhalter ersetzen.
+3. Ein eigenes zufälliges `JWT_SECRET` mit mindestens 32 Byte konfigurieren.
+4. Das LDAPS-Zertifikat des Domain Controllers auf dem Server als vertrauenswürdig installieren.
+5. Die IIS-Proxyadresse in `TRUSTED_PROXY_IPS` eintragen und den FastAPI-Port nicht extern freigeben.
+6. Den Windows-Dienst mit `webservice/install_service.bat` installieren.
+
+Die Rollenprioritäten sind: Buchhaltung (400), Standortleitung (300),
+Abteilungsleitung (200) und Mitarbeiter (100). Berechtigungen werden bei jedem Request
+aus `dbo.Benutzer_Rechte` gelesen; leere Benutzerkennungen erteilen keine Rechte.
+
+Tests werden ohne zusätzliche Testabhängigkeiten ausgeführt:
+
+```cmd
+.venv\Scripts\python.exe -m unittest discover -v
+```

+ 153 - 0
app/auth.py

@@ -0,0 +1,153 @@
+import logging
+import secrets
+from urllib.parse import parse_qs, quote
+
+from fastapi import HTTPException, Request, status
+from fastapi.responses import JSONResponse, RedirectResponse, Response
+from starlette.middleware.base import BaseHTTPMiddleware
+
+from .config import settings
+from .db import SessionLocal
+from .ldap import DirectoryUnavailable, lookup_directory_user
+from .oauth_token import (
+    InvalidSessionToken,
+    create_access_token,
+    decode_access_token,
+    renew_access_token,
+    token_needs_renewal,
+)
+from .permissions import Principal, highest_role, load_entitlements
+
+logger = logging.getLogger(__name__)
+
+PUBLIC_PREFIXES = ("/static/",)
+PUBLIC_PATHS = {"/login", "/health"}
+UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
+
+
+def set_session_cookie(response, token: str) -> None:
+    response.set_cookie(
+        settings.SESSION_COOKIE_NAME,
+        token,
+        secure=settings.COOKIE_SECURE,
+        httponly=True,
+        samesite="lax",
+        path="/",
+    )
+
+
+def clear_session_cookie(response) -> None:
+    response.delete_cookie(
+        settings.SESSION_COOKIE_NAME,
+        secure=settings.COOKIE_SECURE,
+        httponly=True,
+        samesite="lax",
+        path="/",
+    )
+
+
+def safe_next_url(value: str | None, default: str = "/select") -> str:
+    if not value or not value.startswith("/") or value.startswith("//") or "\\" in value:
+        return default
+    return value
+
+
+def _principal(payload: dict, entitlements) -> Principal:
+    return Principal(
+        username=payload["sub"],
+        name=payload["name"],
+        email=payload["email"],
+        role=highest_role(entitlements),
+        auth_source=payload["auth_source"],
+        csrf=payload["csrf"],
+        entitlements=entitlements,
+    )
+
+
+async def _submitted_csrf(request: Request) -> str:
+    header = request.headers.get("X-CSRF-Token", "")
+    if header:
+        return header
+    content_type = request.headers.get("content-type", "")
+    if content_type.startswith("application/x-www-form-urlencoded"):
+        body = await request.body()
+        return parse_qs(body.decode("utf-8", errors="replace")).get("_csrf", [""])[0]
+    return ""
+
+
+class AuthenticationMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        is_public = request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES)
+        token = request.cookies.get(settings.SESSION_COOKIE_NAME)
+        payload = None
+        new_token = None
+
+        if token:
+            try:
+                payload = decode_access_token(token)
+            except InvalidSessionToken:
+                payload = None
+
+        proxy_user = request.headers.get(settings.AUTH_PROXY_HEADER)
+        proxy_host = request.client.host if request.client else None
+        trusted_proxy_identity = proxy_user if settings.is_trusted_proxy(proxy_host) else None
+        if proxy_user and not trusted_proxy_identity:
+            logger.warning("Nicht vertrauenswürdiger Proxy-Identitätsheader von %s ignoriert", proxy_host)
+
+        try:
+            with SessionLocal() as db:
+                if payload:
+                    entitlements = load_entitlements(db, payload["sub"])
+                    principal = _principal(payload, entitlements)
+                    request.state.principal = principal
+                    if token_needs_renewal(payload, principal.role):
+                        new_token = renew_access_token(payload, principal.role)
+                elif trusted_proxy_identity:
+                    directory_user = lookup_directory_user(trusted_proxy_identity)
+                    if not directory_user or directory_user.disabled:
+                        return JSONResponse({"detail": "Windows-Benutzer ist nicht gültig"}, status_code=401)
+                    entitlements = load_entitlements(db, directory_user.username)
+                    role = highest_role(entitlements)
+                    new_token = create_access_token(
+                        username=directory_user.username,
+                        name=directory_user.name,
+                        email=directory_user.email,
+                        role=role,
+                        auth_source="iis",
+                    )
+                    payload = decode_access_token(new_token)
+                    request.state.principal = _principal(payload, entitlements)
+        except HTTPException as exc:
+            return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
+        except ValueError:
+            return JSONResponse({"detail": "Windows-Benutzer ist nicht gültig"}, status_code=401)
+        except DirectoryUnavailable:
+            logger.exception("IIS-Benutzer konnte nicht im AD aufgelöst werden")
+            return JSONResponse({"detail": "Anmeldedienst ist derzeit nicht verfügbar"}, status_code=503)
+        except Exception:
+            logger.exception("Berechtigungen konnten nicht geladen werden")
+            return JSONResponse({"detail": "Berechtigungssystem ist derzeit nicht verfügbar"}, status_code=503)
+
+        principal = getattr(request.state, "principal", None)
+        if not principal and not is_public:
+            next_url = request.url.path
+            if request.url.query:
+                next_url += "?" + request.url.query
+            login_url = f"/login?next={quote(next_url, safe='')}"
+            if request.headers.get("HX-Request", "").lower() == "true":
+                response = Response(status_code=200, headers={"HX-Redirect": login_url})
+            else:
+                response = RedirectResponse(login_url, status_code=303)
+            if token:
+                clear_session_cookie(response)
+            return response
+
+        if principal and request.method in UNSAFE_METHODS and request.url.path != "/login":
+            submitted = await _submitted_csrf(request)
+            if not submitted or not secrets.compare_digest(submitted, principal.csrf):
+                return JSONResponse({"detail": "Ungültiger CSRF-Token"}, status_code=status.HTTP_403_FORBIDDEN)
+
+        response = await call_next(request)
+        if new_token and not getattr(request.state, "suppress_session_refresh", False):
+            set_session_cookie(response, new_token)
+        return response

+ 5 - 14
app/auth2.py

@@ -1,17 +1,8 @@
-from fastapi import Depends, HTTPException, status
-from fastapi.security import OAuth2PasswordBearer
-from .oauth_token import verify_token
-from .schemas import TokenData
+from fastapi import Request
 
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+from .permissions import Principal, current_principal
 
 
-def get_current_user(token: str = Depends(oauth2_scheme)):
-    payload = verify_token(token)
-    username: str = payload.get("sub")
-    if username is None:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
-        )
-    return TokenData(username=username)
+def get_current_user(request: Request) -> Principal:
+    """Compatibility wrapper for older imports; cookie auth is handled centrally."""
+    return current_principal(request)

+ 74 - 13
app/config.py

@@ -1,20 +1,81 @@
-from pydantic_settings import BaseSettings
+from functools import lru_cache
+from ipaddress import ip_address, ip_network
+from typing import Annotated
+
+from pydantic import AliasChoices, BeforeValidator, Field, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+DEVELOPMENT_JWT_SECRET = "development-only-change-me-32-bytes"
+
+def _split_csv(value: object) -> list[str]:
+    if isinstance(value, str):
+        return [item.strip() for item in value.split(",") if item.strip()]
+    return list(value or [])
+
+
+CsvList = Annotated[list[str], BeforeValidator(_split_csv)]
 
 
 class Settings(BaseSettings):
-    # auth
-    SECRET_KEY: str = "my_secret_key"
-    ALGORITHM: str = "HS256"
-    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
-    # ldap
-    LDAP_URL: str
-    LDAP_USER_DN: str
-    LDAP_BASE_DN: str
-    # database
+    model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
+
+    ENVIRONMENT: str = "development"
     DATABASE_URL: str = "sqlite:///./dev.db"
 
-    class Config:
-        env_file = ".env"
+    AUTH_PROXY_HEADER: str = "X-Remote-User"
+    TRUSTED_PROXY_IPS: CsvList = Field(default_factory=lambda: ["127.0.0.1", "::1"])
+
+    JWT_SECRET: str = DEVELOPMENT_JWT_SECRET
+    JWT_ISSUER: str = "gcepic"
+    JWT_AUDIENCE: str = "gcepic-web"
+    JWT_IDLE_MINUTES: int = 30
+    JWT_ABSOLUTE_HOURS: int = 8
+    JWT_RENEW_BEFORE_MINUTES: int = 5
+    COOKIE_SECURE: bool = False
+    SESSION_COOKIE_NAME: str = "gcepic_session"
+    LOGIN_CSRF_COOKIE_NAME: str = "gcepic_login_csrf"
+
+    LDAP_URL: str | None = None
+    LDAP_BASE_DN: str | None = None
+    LDAP_BIND_DN: str | None = Field(
+        default=None,
+        validation_alias=AliasChoices("LDAP_BIND_DN", "LDAP_USER_DN"),
+    )
+    LDAP_BIND_PASSWORD: str | None = None
+    LDAP_UPN_SUFFIX: str | None = None
+    LDAP_START_TLS: bool = False
+    LOCAL_STORAGE: str | None = None
+
+    @model_validator(mode="after")
+    def validate_security_settings(self) -> "Settings":
+        if self.ENVIRONMENT.lower() == "production":
+            if len(self.JWT_SECRET.encode("utf-8")) < 32 or self.JWT_SECRET == DEVELOPMENT_JWT_SECRET:
+                raise ValueError("A unique JWT_SECRET with at least 32 bytes is required in production")
+            if not self.COOKIE_SECURE:
+                raise ValueError("COOKIE_SECURE must be enabled in production")
+        if self.JWT_IDLE_MINUTES <= 0 or self.JWT_ABSOLUTE_HOURS <= 0:
+            raise ValueError("JWT session lifetimes must be positive")
+        return self
+
+    def is_trusted_proxy(self, host: str | None) -> bool:
+        if not host:
+            return False
+        try:
+            address = ip_address(host)
+        except ValueError:
+            return host in self.TRUSTED_PROXY_IPS and self.ENVIRONMENT.lower() != "production"
+        for entry in self.TRUSTED_PROXY_IPS:
+            try:
+                if address in ip_network(entry, strict=False):
+                    return True
+            except ValueError:
+                continue
+        return False
+
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()
 
 
-settings = Settings()
+settings = get_settings()

+ 3 - 7
app/db.py

@@ -1,15 +1,11 @@
-import os
-
-from dotenv import load_dotenv
 from sqlalchemy import create_engine
 from sqlalchemy.orm import declarative_base, sessionmaker
 
-load_dotenv()
-
-DATABASE_URL = os.environ.get("DATABASE_URL") or "sqlite:///./dev.db"
+from .config import settings
 
 engine = create_engine(
-    DATABASE_URL, connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
+    settings.DATABASE_URL,
+    connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {},
 )
 SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
 Base = declarative_base()

+ 17 - 9
app/file_storage.py

@@ -1,22 +1,30 @@
-import os
 from pathlib import Path
 from urllib.parse import unquote
 
-from dotenv import load_dotenv
-
-load_dotenv()
+from .config import settings
 
 
 def get_files(primary_key: dict[str, str]):
-    fileserver = os.environ.get("LOCAL_STORAGE")
+    fileserver = settings.LOCAL_STORAGE
+    if not fileserver:
+        return []
+    root = Path(fileserver).resolve()
+    folder = (root / primary_key["Client_DB"] / primary_key["Beleg_Nr"]).resolve()
+    if root not in folder.parents:
+        return []
     files = [
-        f.relative_to(fileserver)
-        for f in Path(f"{fileserver}\\{primary_key['Client_DB']}\\{primary_key['Beleg_Nr']}").glob("*")
+        f.relative_to(root)
+        for f in folder.glob("*")
+        if f.is_file()
     ]
     return files
 
 
 def get_file(name: str):
-    fileserver = os.environ.get("LOCAL_STORAGE")
-    filename = Path(f"{fileserver}\\{unquote(name)}")
+    if not settings.LOCAL_STORAGE:
+        raise FileNotFoundError("LOCAL_STORAGE is not configured")
+    root = Path(settings.LOCAL_STORAGE).resolve()
+    filename = (root / unquote(name)).resolve()
+    if root not in filename.parents or not filename.is_file():
+        raise FileNotFoundError(name)
     return filename

+ 106 - 19
app/ldap.py

@@ -1,26 +1,113 @@
-import os
-from typing import Optional
+import logging
+import re
+import ssl
+from dataclasses import dataclass
+from urllib.parse import urlparse
 
-from dotenv import load_dotenv
-from ldap3 import ALL, Connection, Server
+from ldap3 import AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND, SUBTREE, Connection, Server, Tls
+from ldap3.core.exceptions import LDAPException
+from ldap3.utils.conv import escape_filter_chars
 
-load_dotenv()
+from .config import settings
 
+logger = logging.getLogger(__name__)
 
-def ldap_authenticate(username: str, password: str) -> Optional[dict]:
-    ldap_url = os.environ.get("LDAP_URL")
-    ldap_user_dn = os.environ.get("LDAP_USER_DN")
-    ldap_base = os.environ.get("LDAP_BASE_DN")
-    if not ldap_url:
-        # Fallback mock for local development: accept any user with password 'password'
-        if password == "password":
-            return {"username": username, "groups": ["local"]}
-        return None
 
+class DirectoryUnavailable(RuntimeError):
+    pass
+
+
+@dataclass(frozen=True)
+class DirectoryUser:
+    username: str
+    distinguished_name: str
+    name: str
+    email: str
+    disabled: bool = False
+
+
+def normalize_username(value: str) -> str:
+    username = value.strip()
+    if "\\" in username:
+        username = username.rsplit("\\", 1)[1]
+    if "@" in username:
+        username = username.split("@", 1)[0]
+    username = username.strip().lower()
+    if not username or not re.fullmatch(r"[a-z0-9._-]+", username):
+        raise ValueError("Ungültige Benutzerkennung")
+    return username
+
+
+def _server() -> tuple[Server, bool]:
+    if not settings.LDAP_URL:
+        raise DirectoryUnavailable("LDAP_URL ist nicht konfiguriert")
+    parsed = urlparse(settings.LDAP_URL if "://" in settings.LDAP_URL else f"ldaps://{settings.LDAP_URL}")
+    use_ssl = parsed.scheme.lower() == "ldaps"
+    use_start_tls = parsed.scheme.lower() == "ldap" and settings.LDAP_START_TLS
+    if not use_ssl and not use_start_tls:
+        raise DirectoryUnavailable("LDAP muss LDAPS oder validiertes StartTLS verwenden")
+    tls = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT)
+    server = Server(parsed.hostname, port=parsed.port or (636 if use_ssl else 389), use_ssl=use_ssl, tls=tls)
+    return server, use_start_tls
+
+
+def _service_connection() -> Connection:
+    if not settings.LDAP_BIND_DN or not settings.LDAP_BIND_PASSWORD or not settings.LDAP_BASE_DN:
+        raise DirectoryUnavailable("LDAP-Servicekonto ist nicht vollständig konfiguriert")
+    server, start_tls = _server()
+    return Connection(
+        server,
+        user=settings.LDAP_BIND_DN,
+        password=settings.LDAP_BIND_PASSWORD,
+        auto_bind=AUTO_BIND_TLS_BEFORE_BIND if start_tls else AUTO_BIND_NO_TLS,
+        raise_exceptions=True,
+    )
+
+
+def lookup_directory_user(value: str) -> DirectoryUser | None:
+    username = normalize_username(value)
+    try:
+        with _service_connection() as connection:
+            connection.search(
+                settings.LDAP_BASE_DN,
+                f"(&(objectCategory=person)(objectClass=user)(sAMAccountName={escape_filter_chars(username)}))",
+                search_scope=SUBTREE,
+                attributes=["sAMAccountName", "displayName", "mail", "userAccountControl"],
+                size_limit=2,
+            )
+            if len(connection.entries) != 1:
+                return None
+            entry = connection.entries[0]
+            control = int(entry.userAccountControl.value or 0)
+            return DirectoryUser(
+                username=normalize_username(str(entry.sAMAccountName.value)),
+                distinguished_name=entry.entry_dn,
+                name=str(entry.displayName.value or entry.sAMAccountName.value),
+                email=str(entry.mail.value or ""),
+                disabled=bool(control & 2),
+            )
+    except (LDAPException, OSError) as exc:
+        logger.exception("AD-Benutzersuche fehlgeschlagen")
+        raise DirectoryUnavailable("Active Directory ist nicht erreichbar") from exc
+
+
+def ldap_authenticate(username: str, password: str) -> DirectoryUser | None:
+    if not password:
+        return None
+    user = lookup_directory_user(username)
+    if not user or user.disabled:
+        return None
     try:
-        server = Server(ldap_url, get_info=ALL)
-        conn = Connection(server, user=username, password=password, auto_bind=True)
-        # Simple success -> return username; group resolution left for future
-        return {"username": username, "groups": []}
-    except Exception:
+        server, start_tls = _server()
+        connection = Connection(
+            server,
+            user=user.distinguished_name,
+            password=password,
+            auto_bind=AUTO_BIND_TLS_BEFORE_BIND if start_tls else AUTO_BIND_NO_TLS,
+            raise_exceptions=True,
+        )
+        connection.unbind()
+        return user
+    except LDAPException:
+        logger.info("LDAP-Anmeldung für Benutzer %s fehlgeschlagen", user.username)
         return None

+ 2 - 6
app/main.py

@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
 from fastapi import FastAPI
 from fastapi.staticfiles import StaticFiles
 
-# from .db import Base, engine
+from .auth import AuthenticationMiddleware
 from .routes import router
 
 
@@ -16,11 +16,7 @@ async def lifespan(_: FastAPI):
 
 app = FastAPI(title="GCOutline", lifespan=lifespan)
 
+app.add_middleware(AuthenticationMiddleware)
 app.include_router(router)
 
 app.mount("/static", StaticFiles(directory="static"), name="static")
-
-
-@app.get("/")
-async def root():
-    return {"message": "Hello Bigger Applications!"}

+ 2 - 49
app/main2.py

@@ -1,50 +1,3 @@
-from fastapi import FastAPI, Depends, HTTPException, status
-from sqlalchemy.orm import Session
-from fastapi.security import OAuth2PasswordRequestForm
-from .oauth_token import create_access_token
-from .schemas import User, Token
-from .auth2 import get_current_user
-from .users import UserModel, get_password_hash, authenticate_user
-from .db import get_session
+"""Compatibility entry point. The application now has one authentication stack."""
 
-app = FastAPI()
-
-
-@app.post("/token", response_model=Token)
-def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_session)):
-    user = authenticate_user(db, form_data.username, form_data.password)
-    print(user)
-    if not user:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Incorrect username or password",
-            headers={"WWW-Authenticate": "Bearer"},
-        )
-    access_token = create_access_token(data={"sub": user.username})
-    return {"access_token": access_token, "token_type": "bearer"}
-
-
-@app.get("/users/me")
-def read_users_me(current_user: User = Depends(get_current_user)):
-    return current_user
-
-
-@app.post("/users/createUsers")
-def create_user(user: User, db: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
-    # Check if the username already exists
-    existing_user = db.query(UserModel).filter(UserModel.username == user.username).first()
-    if existing_user:
-        raise HTTPException(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            detail="Username already exists",
-        )
-
-    hashed_password = get_password_hash(user.hashed_password)
-    # Create a new user object
-    new_user = UserModel(username=user.username, email=user.email, hashed_password=hashed_password)
-    # Add the new user to the database
-    db.add(new_user)
-    db.commit()
-    db.refresh(new_user)
-
-    return {"message": "User created successfully", "user_id": new_user.id}
+from .main import app

+ 2 - 93
app/main3.py

@@ -1,94 +1,3 @@
-from typing import Annotated
+"""Compatibility entry point. The application now has one authentication stack."""
 
-from fastapi import Depends, FastAPI, HTTPException, status
-from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
-from pydantic import BaseModel
-
-fake_users_db = {
-    "johndoe": {
-        "username": "johndoe",
-        "full_name": "John Doe",
-        "email": "johndoe@example.com",
-        "hashed_password": "fakehashedsecret",
-        "disabled": False,
-    },
-    "alice": {
-        "username": "alice",
-        "full_name": "Alice Wonderson",
-        "email": "alice@example.com",
-        "hashed_password": "fakehashedsecret2",
-        "disabled": True,
-    },
-}
-
-app = FastAPI()
-
-
-def fake_hash_password(password: str):
-    return "fakehashed" + password
-
-
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
-
-
-class User(BaseModel):
-    username: str
-    email: str | None = None
-    full_name: str | None = None
-    disabled: bool | None = None
-
-
-class UserInDB(User):
-    hashed_password: str
-
-
-def get_user(db, username: str):
-    if username in db:
-        user_dict = db[username]
-        return UserInDB(**user_dict)
-
-
-def fake_decode_token(token):
-    # This doesn't provide any security at all
-    # Check the next version
-    user = get_user(fake_users_db, token)
-    return user
-
-
-async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
-    user = fake_decode_token(token)
-    if not user:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Not authenticated",
-            headers={"WWW-Authenticate": "Bearer"},
-        )
-    return user
-
-
-async def get_current_active_user(
-    current_user: Annotated[User, Depends(get_current_user)],
-):
-    if current_user.disabled:
-        raise HTTPException(status_code=400, detail="Inactive user")
-    return current_user
-
-
-@app.post("/token")
-async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
-    user_dict = fake_users_db.get(form_data.username)
-    if not user_dict:
-        raise HTTPException(status_code=400, detail="Incorrect username or password")
-    user = UserInDB(**user_dict)
-    hashed_password = fake_hash_password(form_data.password)
-    if not hashed_password == user.hashed_password:
-        raise HTTPException(status_code=400, detail="Incorrect username or password")
-
-    return {"access_token": user.username, "token_type": "bearer"}
-
-
-@app.get("/users/me")
-async def read_users_me(
-    current_user: Annotated[User, Depends(get_current_active_user)],
-):
-    return current_user
+from .main import app

+ 87 - 19
app/oauth_token.py

@@ -1,30 +1,98 @@
 from datetime import datetime, timedelta, timezone
+from secrets import token_urlsafe
+from uuid import uuid4
+
 from jose import JWTError, jwt
-from fastapi import HTTPException, status
-from fastapi.security import OAuth2PasswordBearer
+
 from .config import settings
 
-SECRET_KEY = settings.SECRET_KEY
-ALGORITHM = settings.ALGORITHM
-ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
 
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+class InvalidSessionToken(ValueError):
+    pass
+
 
+def utcnow() -> datetime:
+    return datetime.now(timezone.utc)
 
-def create_access_token(data: dict):
-    to_encode = data.copy()
-    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
-    to_encode.update({"exp": expire})
-    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
 
+def create_access_token(
+    *,
+    username: str,
+    name: str,
+    email: str,
+    role: str,
+    auth_source: str,
+    session_started: datetime | None = None,
+    csrf: str | None = None,
+    now: datetime | None = None,
+) -> str:
+    now = now or utcnow()
+    session_started = session_started or now
+    absolute_expiry = session_started + timedelta(hours=settings.JWT_ABSOLUTE_HOURS)
+    expiry = min(now + timedelta(minutes=settings.JWT_IDLE_MINUTES), absolute_expiry)
+    payload = {
+        "sub": username,
+        "name": name,
+        "email": email,
+        "role": role,
+        "auth_source": auth_source,
+        "iss": settings.JWT_ISSUER,
+        "aud": settings.JWT_AUDIENCE,
+        "iat": int(now.timestamp()),
+        "nbf": int(now.timestamp()),
+        "exp": int(expiry.timestamp()),
+        "session_exp": int(absolute_expiry.timestamp()),
+        "jti": str(uuid4()),
+        "csrf": csrf or token_urlsafe(32),
+    }
+    return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
 
-def verify_token(token: str):
+
+def decode_access_token(token: str) -> dict:
     try:
-        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-        return payload
-    except JWTError:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid or expired token",
-            headers={"WWW-Authenticate": "Bearer"},
+        payload = jwt.decode(
+            token,
+            settings.JWT_SECRET,
+            algorithms=["HS256"],
+            audience=settings.JWT_AUDIENCE,
+            issuer=settings.JWT_ISSUER,
         )
+    except JWTError as exc:
+        raise InvalidSessionToken("Invalid or expired session token") from exc
+
+    required = {"sub", "name", "email", "role", "auth_source", "exp", "session_exp", "csrf"}
+    if not required.issubset(payload):
+        raise InvalidSessionToken("Session token is missing required claims")
+    if payload["auth_source"] not in {"iis", "ldap"}:
+        raise InvalidSessionToken("Unknown authentication source")
+    if int(payload["session_exp"]) <= int(utcnow().timestamp()):
+        raise InvalidSessionToken("Absolute session lifetime exceeded")
+    return payload
+
+
+def token_needs_renewal(payload: dict, role: str, now: datetime | None = None) -> bool:
+    now = now or utcnow()
+    renew_at = datetime.fromtimestamp(int(payload["exp"]), timezone.utc) - timedelta(
+        minutes=settings.JWT_RENEW_BEFORE_MINUTES
+    )
+    return role != payload["role"] or now >= renew_at
+
+
+def renew_access_token(payload: dict, role: str, now: datetime | None = None) -> str:
+    started = datetime.fromtimestamp(int(payload["session_exp"]), timezone.utc) - timedelta(
+        hours=settings.JWT_ABSOLUTE_HOURS
+    )
+    return create_access_token(
+        username=payload["sub"],
+        name=payload["name"],
+        email=payload["email"],
+        role=role,
+        auth_source=payload["auth_source"],
+        session_started=started,
+        csrf=payload["csrf"],
+        now=now,
+    )
+
+
+def verify_token(token: str) -> dict:
+    return decode_access_token(token)

+ 97 - 0
app/permissions.py

@@ -0,0 +1,97 @@
+from dataclasses import dataclass
+
+from fastapi import HTTPException, status
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+
+@dataclass(frozen=True)
+class Entitlement:
+    hauptbetrieb_id: str
+    standort_id: str
+    abteilung: str | None
+    role: str
+    priority: int
+
+
+@dataclass(frozen=True)
+class Principal:
+    username: str
+    name: str
+    email: str
+    role: str
+    auth_source: str
+    csrf: str
+    entitlements: tuple[Entitlement, ...]
+
+
+RIGHTS_SQL = text(
+    """
+    SELECT BR.Hauptbetrieb_ID,
+           BR.Standort_ID,
+           BR.Abteilung,
+           BR.Rolle,
+           R.Prioritaet
+    FROM dbo.Benutzer_Rechte AS BR
+    INNER JOIN dbo.Rollen AS R ON R.Rolle = BR.Rolle AND R.Aktiv = 1
+    WHERE LOWER(LTRIM(RTRIM(BR.Benutzer_ID))) = :username
+      AND NULLIF(LTRIM(RTRIM(BR.Benutzer_ID)), '') IS NOT NULL
+    ORDER BY R.Prioritaet DESC, BR.Rolle ASC
+    """
+)
+
+
+def load_entitlements(db: Session, username: str) -> tuple[Entitlement, ...]:
+    rows = db.execute(RIGHTS_SQL, {"username": username}).mappings().all()
+    return tuple(
+        Entitlement(
+            hauptbetrieb_id=str(row["Hauptbetrieb_ID"]),
+            standort_id=str(row["Standort_ID"]),
+            abteilung=row["Abteilung"],
+            role=row["Rolle"],
+            priority=int(row["Prioritaet"]),
+        )
+        for row in rows
+    )
+
+
+def highest_role(entitlements: tuple[Entitlement, ...]) -> str:
+    if not entitlements:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung für diese Anwendung")
+    return sorted(entitlements, key=lambda item: (-item.priority, item.role))[0].role
+
+
+FORDERUNG_ACCESS_SQL = text(
+    """
+    SELECT TOP 1 1
+    FROM dbo.Forderungen_Kopf AS F
+    WHERE F.Client_DB = :client_db
+      AND F.Beleg_Nr = :beleg_nr
+      AND EXISTS (
+          SELECT 1
+          FROM dbo.Benutzer_Rechte AS BR
+          INNER JOIN dbo.Rollen AS R ON R.Rolle = BR.Rolle AND R.Aktiv = 1
+          WHERE LOWER(LTRIM(RTRIM(BR.Benutzer_ID))) = :auth_username
+            AND NULLIF(LTRIM(RTRIM(BR.Benutzer_ID)), '') IS NOT NULL
+            AND BR.Hauptbetrieb_ID = F.Hauptbetrieb_ID
+            AND BR.Standort_ID = F.Standort_ID
+            AND (BR.Abteilung IS NULL OR BR.Abteilung = F.Abteilung)
+      )
+    """
+)
+
+
+def ensure_forderung_access(db: Session, principal: Principal, client_db: str, beleg_nr: str) -> None:
+    allowed = db.execute(
+        FORDERUNG_ACCESS_SQL,
+        {"client_db": client_db, "beleg_nr": beleg_nr, "auth_username": principal.username},
+    ).first()
+    if not allowed:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Forderung nicht gefunden")
+
+
+def current_principal(request) -> Principal:
+    principal = getattr(request.state, "principal", None)
+    if principal is None:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet")
+    return principal

+ 211 - 143
app/routes.py

@@ -1,15 +1,15 @@
 import io
+import logging
 import re
+import secrets
 from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Annotated
 from urllib.parse import unquote, urlencode
 
 import pandas as pd
-import requests
-from docx import Document
 from docxtpl import DocxTemplate
-from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
+from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
 from fastapi.responses import (
     FileResponse,
     HTMLResponse,
@@ -20,25 +20,50 @@ from sqlalchemy import text
 from sqlalchemy.orm import Session
 
 from app.file_storage import get_file, get_files
-from app.query_filter import filter_type_option, import_filter_config
+from app.query_filter import import_filter_config
 
+from .auth import clear_session_cookie, safe_next_url, set_session_cookie
+from .config import settings
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
 from .jinja_templates import templates
-from .ldap import ldap_authenticate
-from .models import Forderung
+from .ldap import DirectoryUnavailable, ldap_authenticate
+from .oauth_token import create_access_token
+from .permissions import Principal, current_principal, ensure_forderung_access, highest_role, load_entitlements
 
 router = APIRouter()
+logger = logging.getLogger(__name__)
 
 
 @router.get("/", response_class=HTMLResponse)
 def index(request: Request):
-    return RedirectResponse(url="/login")
+    return RedirectResponse(url="/select")
+
+
+@router.get("/health")
+def health():
+    return {"status": "ok"}
 
 
 @router.get("/login", response_class=HTMLResponse)
-def login_get(request: Request):
-    return templates.TemplateResponse(request, "base/login.html", {"request": request})
+def login_get(request: Request, next: str | None = None):
+    if getattr(request.state, "principal", None):
+        return RedirectResponse(safe_next_url(next), status_code=303)
+    csrf = secrets.token_urlsafe(32)
+    response = templates.TemplateResponse(
+        request,
+        "base/login.html",
+        {"request": request, "csrf": csrf, "next": safe_next_url(next), "error": None},
+    )
+    response.set_cookie(
+        settings.LOGIN_CSRF_COOKIE_NAME,
+        csrf,
+        secure=settings.COOKIE_SECURE,
+        httponly=True,
+        samesite="lax",
+        path="/login",
+    )
+    return response
 
 
 @router.get("/select", response_class=HTMLResponse)
@@ -47,22 +72,77 @@ def select_get(request: Request):
 
 
 @router.post("/login")
-def login_post(username: str = Form(...), password: str = Form(...)):
-    user = ldap_authenticate(username, password)
+def login_post(
+    request: Request,
+    username: Annotated[str, Form()],
+    password: Annotated[str, Form()],
+    csrf: Annotated[str, Form(alias="_csrf")],
+    next: Annotated[str | None, Form()] = None,
+    db: Session = Depends(get_session),
+):
+    if getattr(request.state, "principal", None):
+        return RedirectResponse(safe_next_url(next), status_code=303)
+    expected_csrf = request.cookies.get(settings.LOGIN_CSRF_COOKIE_NAME, "")
+    if not expected_csrf or not secrets.compare_digest(expected_csrf, csrf):
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ungültiger CSRF-Token")
+
+    try:
+        user = ldap_authenticate(username, password)
+    except DirectoryUnavailable:
+        logger.exception("LDAP-Anmeldedienst nicht verfügbar")
+        return templates.TemplateResponse(
+            request,
+            "base/login.html",
+            {
+                "request": request,
+                "csrf": csrf,
+                "next": safe_next_url(next),
+                "error": "Anmeldedienst ist derzeit nicht verfügbar",
+            },
+            status_code=503,
+        )
+    except ValueError:
+        user = None
     if not user:
-        raise HTTPException(status_code=401, detail="Invalid credentials")
-    # For a simple demo we set a cookie (not secure) -- replace with real session in production
-    response = RedirectResponse(url="/forderungen", status_code=302)
-    response.set_cookie("user", user["username"])
+        response = templates.TemplateResponse(
+            request,
+            "base/login.html",
+            {
+                "request": request,
+                "csrf": csrf,
+                "next": safe_next_url(next),
+                "error": "Anmeldung fehlgeschlagen",
+            },
+            status_code=401,
+        )
+        return response
+
+    entitlements = load_entitlements(db, user.username)
+    role = highest_role(entitlements)
+    token = create_access_token(
+        username=user.username,
+        name=user.name,
+        email=user.email,
+        role=role,
+        auth_source="ldap",
+    )
+    response = RedirectResponse(url=safe_next_url(next), status_code=303)
+    set_session_cookie(response, token)
+    response.delete_cookie(settings.LOGIN_CSRF_COOKIE_NAME, path="/login")
     return response
 
 
-def single_quote(text: str):
-    return "'" + unquote(text) + "'"
+@router.post("/logout")
+def logout(request: Request):
+    request.state.suppress_session_refresh = True
+    response = templates.TemplateResponse(request, "base/logout.html", {"request": request})
+    clear_session_cookie(response)
+    return response
 
 
 @router.get("/app/forderungen/liste", response_class=HTMLResponse)
 def forderungen_liste(request: Request, db: Session = Depends(get_session), limit: int = 100, page: int = 0):
+    principal = current_principal(request)
     forderung_filter_config = import_filter_config("forderungen")
 
     params = {
@@ -73,24 +153,27 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
     if page == 0 and len(params) < len(request.query_params):
         return RedirectResponse(url="/app/forderungen/liste?" + urlencode(params))
 
-    context = {}
+    query_clauses = []
+    query_values = {"auth_username": principal.username}
     for f in forderung_filter_config:
-        context[f.name] = {filter_type_option[f.filter_type]: single_quote(params.get(f.name, f.default_value))}
-        if f.name in params:
-            f.current_value = params[f.name]
+        f.current_value = unquote(params.get(f.name, f.default_value))
+        if f.filter_type == "hidden" or not f.current_value:
+            continue
+        parameter = f"filter_{f.name.lower()}"
+        if f.filter_type == "date":
+            operator = "<=" if "bis" in f.name.lower() else ">="
+            query_clauses.append(f"F.[{f.key_column}] {operator} :{parameter}")
+            query_values[parameter] = f.current_value
+        else:
+            query_clauses.append(f"F.[{f.key_column}] LIKE :{parameter}")
+            query_values[parameter] = f"%{f.current_value}%"
 
-    query = templates.TemplateResponse(request, "forderungen/liste/queries/forderungen_liste.sql", context).body.decode(
+    query = templates.TemplateResponse(request, "forderungen/liste/queries/forderungen_liste.sql", {}).body.decode(
         "utf-8"
     )
-    query_params = [
-        f.query_param for f in forderung_filter_config if f.query_param is not None and f.filter_type != "hidden"
-    ]
-    if query_params:
-        query = query.replace("1 = 1", " AND ".join(query_params))
-    print(query)
-    print(query_params)
-    # q = db.execute(text("SELECT * FROM [dbo].[Forderungen]"))
-    q_res = db.execute(text(query))
+    if query_clauses:
+        query = query.replace("AND 1 = 1", "AND 1 = 1 AND " + " AND ".join(query_clauses))
+    q_res = db.execute(text(query), query_values)
     col_names = list(q_res.keys())
     q = q_res.fetchall()
 
@@ -107,10 +190,8 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             for row in q
         ]
         if filter_temp:
-            print(filter_temp[0])
             filters[f.name] = {item[1]: item[2] for item in sorted(list(set(filter_temp))) if item[1]}
         else:
-            print(f"Filter {f.name} has no options, skipping")
             filters[f.name] = {}
         f.options = filters[f.name]
 
@@ -150,27 +231,14 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
 
 @router.get("/app/forderungen/details/{primary_key}", response_class=HTMLResponse)
 def forderungen_details(request: Request, primary_key: str, db: Session = Depends(get_session)):
-
+    principal = current_principal(request)
     pkey = convert_primary_key("forderungen", primary_key)
-
+    ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
     template_context = get_template_context(request, pkey, db)
     return templates.TemplateResponse(request, "forderungen/details/details.html", template_context)
 
 
 def get_template_context(request: Request, pkey: dict[str, str], db: Session) -> dict:
-    context = {
-        "appsmith": {
-            "URL": {
-                "queryParams": {
-                    "Client_DB": "'" + pkey["Client_DB"] + "'",
-                    "Beleg_Nr": "'" + pkey["Beleg_Nr"] + "'",
-                }
-            }
-        },
-        "BelegeFilter": {"selectedOptionValue": "D"},
-        "BenutzerSelect": {"selectedOptionValue": "winter"},
-    }
-
     template_context = {}
     for filename in [
         "auftraege_positionen",
@@ -180,17 +248,11 @@ def get_template_context(request: Request, pkey: dict[str, str], db: Session) ->
         "forderungen_mahnungen",
         "versicherungen",
     ]:
-        query = templates.TemplateResponse(request, f"forderungen/details/queries/{filename}.sql", context).body.decode(
+        query = templates.TemplateResponse(request, f"forderungen/details/queries/{filename}.sql", {}).body.decode(
             "utf-8"
         )
-        template_context[filename] = db.execute(text(query)).fetchall()
-    template_context["forderung"] = {
-        "id": "12345",
-        "rechnungsnummer": "WERE123445",
-        "kunde": "Burghard",
-        "betrag": "120,00",
-        "faelligkeit": "23.12.1983",
-    }
+        values = {"client_db": pkey["Client_DB"], "beleg_nr": pkey["Beleg_Nr"], "belege_filter": "D"}
+        template_context[filename] = db.execute(text(query), values).fetchall()
     template_context["files"] = get_files(pkey)
     template_context["add7days"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
     return template_context
@@ -204,14 +266,16 @@ async def post_forderungen_details(
     redirect: Annotated[str | None, Form()] = None,
     db: Session = Depends(get_session),
 ):
+    principal = current_principal(request)
     pkey = convert_primary_key("forderungen", primary_key)
+    ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
 
     if source == "comments":
         data = {
             "Client_DB": "",
             "Beleg_Nr": "",
             "Timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
-            "Benutzer_ID": "winter",
+            "Benutzer_ID": principal.username,
             "Kommentar": "",
         }
     else:
@@ -247,13 +311,14 @@ async def post_forderungen_details(
                     v = v.replace(",", ".")
                 data[k] = v
 
+    if source not in {"comments", "details"}:
+        raise HTTPException(status_code=400, detail="Ungültige Datenquelle")
     query = templates.TemplateResponse(
         request, f"forderungen/details/queries/post/post_{source}.sql", context={"data": data}
     ).body.decode("utf-8")
-    print(query)
     for subquery in query.split(";"):
-        q = db.execute(text(subquery))
-        print(q.rowcount)
+        if subquery.strip():
+            db.execute(text(subquery), data)
     db.commit()
     template_context = get_template_context(request, pkey, db)
 
@@ -265,32 +330,24 @@ async def post_forderungen_details(
 
 
 def convert_primary_key(area: str, primary_key: str) -> dict[str, str]:
-    if area == "forderungen":
-        pkey = ["Client_DB", "Beleg_Nr"]
-    pkey_values = primary_key.split("_")
-    return dict(zip(pkey, pkey_values))
+    if area != "forderungen" or "_" not in primary_key:
+        raise HTTPException(status_code=400, detail="Ungültiger Primärschlüssel")
+    client_db, beleg_nr = primary_key.split("_", 1)
+    if not client_db or not beleg_nr or len(client_db) > 20 or len(beleg_nr) > 100:
+        raise HTTPException(status_code=400, detail="Ungültiger Primärschlüssel")
+    return {"Client_DB": client_db, "Beleg_Nr": beleg_nr}
 
 
 @router.get("/app/forderungen/dashboard", response_class=HTMLResponse)
 def forderungen_dashboard(request: Request, db: Session = Depends(get_session)):
+    current_principal(request)
     context = {"summary": False}
     return templates.TemplateResponse(request, "forderungen/dashboard/dashboard.html", context)
 
 
 @router.get("/export/csv")
-def export_csv(db: Session = Depends(get_session)):
-    q = db.query(Forderung).all()
-    rows = []
-    for f in q:
-        rows.append(
-            {
-                "id": f.id,
-                "rechnungsnummer": f.rechnungsnummer,
-                "kunde": f.kunde.name if f.kunde else "",
-                "betrag": float(f.betrag),
-                "faelligkeit": f.faelligkeit.isoformat() if f.faelligkeit else "",
-            }
-        )
+def export_csv(request: Request, db: Session = Depends(get_session)):
+    rows = _export_rows(db, current_principal(request))
     df = pd.DataFrame(rows)
     buf = io.StringIO()
     df.to_csv(buf, index=False)
@@ -303,19 +360,8 @@ def export_csv(db: Session = Depends(get_session)):
 
 
 @router.get("/export/xlsx")
-def export_xlsx(db: Session = Depends(get_session)):
-    q = db.query(Forderung).all()
-    rows = []
-    for f in q:
-        rows.append(
-            {
-                "id": f.id,
-                "rechnungsnummer": f.rechnungsnummer,
-                "kunde": f.kunde.name if f.kunde else "",
-                "betrag": float(f.betrag),
-                "faelligkeit": f.faelligkeit.isoformat() if f.faelligkeit else "",
-            }
-        )
+def export_xlsx(request: Request, db: Session = Depends(get_session)):
+    rows = _export_rows(db, current_principal(request))
     df = pd.DataFrame(rows)
     buf = io.BytesIO()
     with pd.ExcelWriter(buf, engine="openpyxl") as writer:
@@ -328,61 +374,74 @@ def export_xlsx(db: Session = Depends(get_session)):
     )
 
 
-@router.get("/detail/{id}/export/docx")
-def export_docx(id: int, db: Session = Depends(get_session)):
-    f = db.query(Forderung).filter(Forderung.id == id).first()
-    if not f:
-        raise HTTPException(status_code=404)
-    doc = Document()
-    doc.add_heading(f"Rechnung {f.rechnungsnummer}", level=1)
-    doc.add_paragraph(f'Kunde: {f.kunde.name if f.kunde else ""}')
-    doc.add_paragraph(f"Betrag: {float(f.betrag)}")
-    doc.add_paragraph(f"Fälligkeit: {f.faelligkeit}")
-    buf = io.BytesIO()
-    doc.save(buf)
-    buf.seek(0)
-    return StreamingResponse(
-        buf,
-        media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-        headers={"Content-Disposition": f"attachment; filename=rechnung_{f.rechnungsnummer}.docx"},
+def _export_rows(db: Session, principal: Principal) -> list[dict]:
+    query = text(
+        """
+        SELECT F.Client_DB,
+               F.Beleg_Nr AS Rechnungsnummer,
+               F.Kunde_Text AS Kunde,
+               F.Beleg_offen AS Betrag_offen,
+               F.Beleg_Faellig_Datum AS Faelligkeit,
+               F.Hauptbetrieb_ID,
+               F.Standort_ID,
+               F.Abteilung
+        FROM dbo.Forderungen_Kopf AS F
+        WHERE EXISTS (
+            SELECT 1
+            FROM dbo.Benutzer_Rechte AS BR
+            INNER JOIN dbo.Rollen AS R ON R.Rolle = BR.Rolle AND R.Aktiv = 1
+            WHERE LOWER(LTRIM(RTRIM(BR.Benutzer_ID))) = :auth_username
+              AND NULLIF(LTRIM(RTRIM(BR.Benutzer_ID)), '') IS NOT NULL
+              AND BR.Hauptbetrieb_ID = F.Hauptbetrieb_ID
+              AND BR.Standort_ID = F.Standort_ID
+              AND (BR.Abteilung IS NULL OR BR.Abteilung = F.Abteilung)
+        )
+        ORDER BY F.Beleg_Faellig_Datum, F.Beleg_Nr
+        """
     )
+    return [dict(row) for row in db.execute(query, {"auth_username": principal.username}).mappings().all()]
+
+
+@router.get("/detail/{id}/export/docx")
+def export_docx_legacy(id: int):
+    raise HTTPException(status_code=410, detail="Veralteter Export; bitte die Forderungsdetailseite verwenden")
 
 
 # TODO
 @router.get("/app/forderungen/export/{client_db}_{document_no}/")
 def export_docx2(request: Request, client_db: str, document_no: str, db: Session = Depends(get_session)):
-    context = {
-        "appsmith": {
-            "URL": {
-                "queryParams": {
-                    "Client_DB": "'" + client_db + "'",
-                    "Document_No": "'" + document_no + "'",
-                }
-            }
-        },
-    }
-    query = templates.TemplateResponse(request, "forderungen/details/queries/forderung_kopf.sql", context).body.decode(
-        "utf-8"
-    )
-    q = db.execute(text(query)).fetchone()
-
-    doc = DocxTemplate("templates\\forderungen\\docs\\Mahnung_AHR.docx")
-    filename = Path(
-        f"C:\\Projekte\\Reisacher-Fileserver\\{client_db}\\{document_no}\\Mahnung_{client_db}_{document_no}.docx"
-    )
+    principal = current_principal(request)
+    ensure_forderung_access(db, principal, client_db, document_no)
+    q = db.execute(
+        text(
+            """SELECT TOP 1 F.*, K.Kunde_Name, K.Kunde_Adresse, K.Kunde_PLZ, K.Kunde_Ort
+               FROM dbo.Forderungen_Kopf AS F
+               LEFT JOIN dbo.Kunden AS K ON K.Client_DB = F.Client_DB AND K.Kunde_Nr = F.Kunde_Nr
+               WHERE F.Client_DB = :client_db AND F.Beleg_Nr = :beleg_nr"""
+        ),
+        {"client_db": client_db, "beleg_nr": document_no},
+    ).mappings().first()
+    if not q or not settings.LOCAL_STORAGE:
+        raise HTTPException(status_code=503, detail="Dokumentablage ist nicht konfiguriert")
+
+    doc = DocxTemplate("templates\\forderungen\\details\\docs\\Mahnung_AHR.docx")
+    storage_root = Path(settings.LOCAL_STORAGE).resolve()
+    filename = (storage_root / client_db / document_no / f"Mahnung_{client_db}_{document_no}.docx").resolve()
+    if storage_root not in filename.parents:
+        raise HTTPException(status_code=400, detail="Ungültiger Dokumentpfad")
     filename.parent.mkdir(parents=True, exist_ok=True)
 
     context = {
-        "Kunde_Name": q.Kunde,
-        "Kunde_Adresse": "Im Waldhof 14a",
-        "Kunde_PLZ": "61476",
-        "Kunde_Ort": "Kronberg",
+        "Kunde_Name": q["Kunde_Name"] or "",
+        "Kunde_Adresse": q["Kunde_Adresse"] or "",
+        "Kunde_PLZ": q["Kunde_PLZ"] or "",
+        "Kunde_Ort": q["Kunde_Ort"] or "",
         "Datum_heute": datetime.now().strftime("%d.%m.%Y"),
-        "Kunde_Nr": q.Kunde.split("-")[-1].strip(),
-        "Fahrzeug_Kennzeichen": "OF-RB 512",
-        "Rechnung_Nr": q.Document_No,
-        "Betrag_SB": "250,00",
-        "Betrag_USt": "380,00",
+        "Kunde_Nr": q["Kunde_Nr"] or "",
+        "Fahrzeug_Kennzeichen": q.get("Fahrzeug_Kennzeichen", "") or "",
+        "Rechnung_Nr": q["Beleg_Nr"],
+        "Betrag_SB": q.get("Selbstbeteiligung_Betrag", "") or "",
+        "Betrag_USt": q.get("Mwst_Betrag", "") or "",
     }
     doc.render(context)
     doc.save(filename)
@@ -391,22 +450,31 @@ def export_docx2(request: Request, client_db: str, document_no: str, db: Session
 
 
 @router.get("/files/")
-def get_files2(request: Request):
-    filename = get_file(request.query_params.get("name"))
+def get_files2(request: Request, db: Session = Depends(get_session)):
+    principal = current_principal(request)
+    name = request.query_params.get("name", "")
+    parts = Path(unquote(name)).parts
+    if len(parts) < 3:
+        raise HTTPException(status_code=404, detail="Datei nicht gefunden")
+    ensure_forderung_access(db, principal, parts[0], parts[1])
+    try:
+        filename = get_file(name)
+    except FileNotFoundError:
+        raise HTTPException(status_code=404, detail="Datei nicht gefunden")
     return FileResponse(filename, media_type="application/octet-stream", filename=filename.name)
 
 
 @router.get("/app/forderungen/files/{primary_key}", response_class=StreamingResponse)
 def forderungen_files(request: Request, primary_key: str, db: Session = Depends(get_session)):
+    principal = current_principal(request)
     pkey = convert_primary_key("forderungen", primary_key)
-    filename = "http://localhost:8093/static/assets/pdf/invoice.pdf"
-    filestream = requests.get(filename, stream=True).raw
+    ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
     try:
         file_content = get_invoice_pdf_by_document_no(client_db=pkey["Client_DB"], document_no=pkey["Beleg_Nr"])
         filestream = io.BytesIO(file_content)
-
-    except Exception as e:
-        print(f"Error fetching invoice PDF: {e}")
+    except Exception:
+        logger.exception("Rechnung konnte nicht aus DocuWare geladen werden")
+        filestream = io.BytesIO(Path("static/assets/pdf/invoice.pdf").read_bytes())
 
     return StreamingResponse(
         filestream,

+ 60 - 0
migrations/001_authentication.sql

@@ -0,0 +1,60 @@
+SET XACT_ABORT ON;
+BEGIN TRANSACTION;
+
+IF COL_LENGTH('dbo.Benutzer_Rechte', 'Abteilung') IS NULL
+BEGIN
+    DECLARE @type nvarchar(128), @length smallint, @collation sysname, @sql nvarchar(max);
+    SELECT @type = TYPE_NAME(system_type_id), @length = max_length, @collation = collation_name
+    FROM sys.columns
+    WHERE object_id = OBJECT_ID('dbo.Forderungen_Kopf') AND name = 'Abteilung';
+
+    IF @type IS NULL
+        THROW 50000, 'Forderungen_Kopf.Abteilung wurde nicht gefunden.', 1;
+
+    IF @type NOT IN ('varchar', 'nvarchar', 'char', 'nchar')
+        THROW 50001, 'Forderungen_Kopf.Abteilung hat einen unerwarteten Datentyp.', 1;
+
+    SET @sql = N'ALTER TABLE dbo.Benutzer_Rechte ADD Abteilung ' + QUOTENAME(@type)
+        + N'(' + CASE WHEN @length = -1 THEN N'MAX'
+                     WHEN @type IN ('nvarchar', 'nchar') THEN CONVERT(nvarchar(10), @length / 2)
+                     ELSE CONVERT(nvarchar(10), @length) END + N')'
+        + CASE WHEN @collation IS NOT NULL THEN N' COLLATE ' + QUOTENAME(@collation) ELSE N'' END
+        + N' NULL;';
+    EXEC sys.sp_executesql @sql;
+END;
+
+IF OBJECT_ID('dbo.Rollen', 'U') IS NULL
+BEGIN
+    CREATE TABLE dbo.Rollen
+    (
+        Rolle nvarchar(50) NOT NULL CONSTRAINT PK_Rollen PRIMARY KEY,
+        Prioritaet int NOT NULL,
+        Aktiv bit NOT NULL CONSTRAINT DF_Rollen_Aktiv DEFAULT (1)
+    );
+END;
+
+MERGE dbo.Rollen AS target
+USING (VALUES
+    (N'Buchhaltung', 400, 1),
+    (N'Standortleitung', 300, 1),
+    (N'Abteilungsleitung', 200, 1),
+    (N'Mitarbeiter', 100, 1)
+) AS source (Rolle, Prioritaet, Aktiv)
+ON target.Rolle = source.Rolle
+WHEN MATCHED THEN UPDATE SET Prioritaet = source.Prioritaet, Aktiv = source.Aktiv
+WHEN NOT MATCHED THEN INSERT (Rolle, Prioritaet, Aktiv)
+VALUES (source.Rolle, source.Prioritaet, source.Aktiv);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('dbo.Benutzer_Rechte') AND name = 'IX_Benutzer_Rechte_Scope')
+BEGIN
+    CREATE INDEX IX_Benutzer_Rechte_Scope
+        ON dbo.Benutzer_Rechte (Benutzer_ID, Hauptbetrieb_ID, Standort_ID, Abteilung)
+        INCLUDE (Rolle);
+END;
+
+-- Leere Benutzer_IDs bleiben zur manuellen Prüfung erhalten, werden von der Anwendung aber nicht mehr ausgewertet.
+SELECT COUNT(*) AS Zu_pruefende_leere_Berechtigungen
+FROM dbo.Benutzer_Rechte
+WHERE NULLIF(LTRIM(RTRIM(Benutzer_ID)), '') IS NULL;
+
+COMMIT TRANSACTION;

+ 1 - 0
pyproject.toml

@@ -18,6 +18,7 @@ dependencies = [
 	"pyodbc>=4.0",
 	"aiofiles>=23.1",
 	"python-dotenv>=1.2.1",
+	"pydantic-settings>=2.0",
 	"docxtpl>=0.20.2",
 	"imap-tools>=1.12.1",
 	"passlib>=1.7.4",

+ 8 - 1
server.py

@@ -1,4 +1,11 @@
 import uvicorn
 
 if __name__ == "__main__":
-    uvicorn.run("app.main:app", host="0.0.0.0", port=8093, reload=True, log_level="info")
+    uvicorn.run(
+        "app.main:app",
+        host="127.0.0.1",
+        port=8093,
+        reload=False,
+        proxy_headers=False,
+        log_level="info",
+    )

+ 11 - 4
templates/base/base.html

@@ -34,7 +34,7 @@
   <link rel="icon" href="/static/assets/images/favicon.ico" type="image/x-icon">
 </head>
 
-<body class="container" hx-boost="true">
+<body class="container" hx-boost="true" hx-headers='{"X-CSRF-Token": "{{ request.state.principal.csrf }}"}'>
   <!--<nav class="navbar navbar-expand-lg bg-primary">
     <div class="container-fluid">
         <a class="navbar-brand" href="#">Navbar</a>
@@ -76,8 +76,15 @@
               <i class="icon icon-lg cil-home"></i></a></li>
           <li class="nav-item"><a class="nav-link" href="/config">
               <i class="icon icon-lg cil-cog"></i></a></li>
-          <li class="nav-item"><a class="nav-link" href="/logout">
-              <i class="icon icon-lg cil-user"></i></a></li>
+          <li class="nav-item d-flex align-items-center">
+            <span class="me-2">{{ request.state.principal.name }} ({{ request.state.principal.role }})</span>
+            <form method="post" action="/logout" class="m-0">
+              <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}">
+              <button type="submit" class="nav-link border-0 bg-transparent" title="Abmelden">
+                <i class="icon icon-lg cil-account-logout"></i>
+              </button>
+            </form>
+          </li>
         </ul>
         <!--
         <ul class="header-nav">
@@ -183,4 +190,4 @@
 
 </body>
 
-</html>
+</html>

+ 2 - 1
templates/base/chat_container.html

@@ -421,6 +421,7 @@
     <div class="input-area">
         <div class="toolbar">
             <form>
+                <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}" />
                 <button class="btn btn-primary">
                     <i class="icon icon-lg cil-paperclip"></i>
                 </button>
@@ -475,4 +476,4 @@
         let element = document.getElementById("chat-end");
         element.scrollIntoView({ behavior: "smooth", block: "end" });
     }, 1000);
-</script>
+</script>

+ 11 - 4
templates/base/login.html

@@ -43,7 +43,13 @@
 					<div class="card-body">
 						<h2 class="mb-4">Reisacher<br> <span class="text-c-blue">Forderungsmanagement 2.0</span></h2>
 						<p>Aus Freude am Zahlen.</p>
+						{% if error %}
+						<div class="alert alert-danger" role="alert">{{ error }}</div>
+						{% endif %}
 						<div class="toggle-block">
+							<form method="post" action="/login">
+							<input type="hidden" name="_csrf" value="{{ csrf }}">
+							<input type="hidden" name="next" value="{{ next }}">
 							<ol class="position-relative carousel-indicators justify-content-start">
 								<li class=""></li>
 								<li class="active"></li>
@@ -52,13 +58,13 @@
 								<div class="input-group-prepend">
 									<span class="input-group-text"><i class="feather icon-user"></i></span>
 								</div>
-								<input type="email" class="form-control" placeholder="Benutzername">
+								<input type="text" class="form-control" name="username" autocomplete="username" placeholder="Benutzername" required autofocus>
 							</div>
 							<div class="input-group mb-4">
 								<div class="input-group-prepend">
 									<span class="input-group-text"><i class="feather icon-lock"></i></span>
 								</div>
-								<input type="password" class="form-control" placeholder="Passwort">
+								<input type="password" class="form-control" name="password" autocomplete="current-password" placeholder="Passwort" required>
 							</div>
 							<!--
 							<div class="form-group text-left">
@@ -67,11 +73,12 @@
 									<label for="checkbox-fill-a1" class="cr"> Anmeldung merken</label>
 								</div>
 							</div> -->
-							<a href="/select"><button class="btn btn-primary mb-4">Anmelden</button></a>
+							<button type="submit" class="btn btn-primary mb-4">Anmelden</button>
+							</form>
 							
 							
 						</div>
-						<div class="toggle-block collapse">
+						<div class="toggle-block collapse" hidden>
 							<ol class="position-relative carousel-indicators justify-content-start">
 								<li class="active"></li>
 								<li class=""></li>

+ 19 - 0
templates/base/logout.html

@@ -0,0 +1,19 @@
+<!doctype html>
+<html lang="de">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Abgemeldet – Global Cube</title>
+  <link href="/static/assets/plugins/bootstrap/css/bootstrap.min.css" rel="stylesheet">
+</head>
+<body class="container py-5">
+  <div class="card mx-auto" style="max-width: 36rem">
+    <div class="card-body">
+      <h1 class="h3">Abgemeldet</h1>
+      <p>Die Anwendungssitzung wurde beendet.</p>
+      <p class="text-muted">Bei aktivierter Windows-Anmeldung kann IIS Sie beim nächsten Aufruf automatisch erneut anmelden.</p>
+      <a class="btn btn-primary" href="/">Anwendung öffnen</a>
+    </div>
+  </div>
+</body>
+</html>

+ 2 - 1
templates/forderungen/details/details_formular.html

@@ -1,6 +1,7 @@
 {% set row = forderungen_details[0] %}
 
 <form class="row g-3" id="formular">
+  <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}" />
   <input type="hidden" name="source" value="details" />
   <div class="col-12">
     <div class="form-check" data-coreui-toggle="collapse" data-coreui-target="#VersicherungCollapse"
@@ -215,4 +216,4 @@
   </div>
 
 
-</form>
+</form>

+ 3 - 3
templates/forderungen/details/queries/auftraege_positionen.sql

@@ -3,6 +3,6 @@ SELECT TOP 100 [Auftrag_Nr]
      , [Betrag]
 FROM [dbo].[Auftraege_Positionen]
 WHERE (
-	Client_DB = {{ appsmith.URL.queryParams.Client_DB }}
-AND [Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }}
-) OR 1 != 1
+	Client_DB = :client_db
+AND [Beleg_Nr] = :beleg_nr
+) OR 1 != 1

+ 4 - 4
templates/forderungen/details/queries/forderungen_buchungen.sql

@@ -11,12 +11,12 @@ SELECT TOP 1000 [Client_DB]
      , [Beleg_Art]
 FROM [dbo].[Forderungen_Buchungen]
 WHERE (
-	[Client_DB] = {{ appsmith.URL.queryParams.Client_DB }}
-AND [Kunde_Nr] IN (SELECT [Kunde_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }} )
+    [Client_DB] = :client_db
+AND [Kunde_Nr] IN (SELECT [Kunde_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Client_DB] = :client_db AND [Beleg_Nr] = :beleg_nr)
 AND (
-	('D' = '{{ BelegeFilter.selectedOptionValue }}')
+	('D' = :belege_filter)
 	OR
-	('F' = '{{ BelegeFilter.selectedOptionValue }}' AND [Fahrzeug_Nr] IN (SELECT [Fahrzeug_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }} )) 
+	('F' = :belege_filter AND [Fahrzeug_Nr] IN (SELECT [Fahrzeug_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Client_DB] = :client_db AND [Beleg_Nr] = :beleg_nr))
 
 )
 	

+ 2 - 2
templates/forderungen/details/queries/forderungen_details.sql

@@ -70,5 +70,5 @@ FROM [dbo].[Forderungen_Kopf] [F]
          LEFT JOIN [dbo].[Kunden] [K2] ON [FZ].[Fahrzeug_Kunde_Nr] = [K2].[Kunde_Nr] AND [FZ].[Client_DB] = [K2].[Client_DB]
          LEFT JOIN [dbo].[Forderungen_Details] [FD] ON [F].[Beleg_Nr] = [FD].[Beleg_Nr] AND [F].[Client_DB] = [FD].[Client_DB] 
          LEFT JOIN [dbo].[Versicherungen] [V] ON [FD].[Vers_Adresse_ID] = [V].[Vers_Adresse_ID]
-WHERE [F].[Client_DB] = {{ appsmith.URL.queryParams.Client_DB }}
-AND [F].[Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }}
+WHERE [F].[Client_DB] = :client_db
+AND [F].[Beleg_Nr] = :beleg_nr

+ 2 - 2
templates/forderungen/details/queries/forderungen_kommentare.sql

@@ -5,5 +5,5 @@ SELECT [Timestamp],
  [Kommentar]
  
 FROM [dbo].[Forderungen_Kommentar_Benutzer]
-WHERE [Client_DB] = {{ appsmith.URL.queryParams.Client_DB }}
-AND [Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }}
+WHERE [Client_DB] = :client_db
+AND [Beleg_Nr] = :beleg_nr

+ 3 - 3
templates/forderungen/details/queries/forderungen_mahnungen.sql

@@ -9,7 +9,7 @@ SELECT TOP 100 [Client_DB]
      , 'N' as [Bezahlt]
 FROM [dbo].[Forderungen_Mahnungen]
 WHERE (
-	[Client_DB] = {{ appsmith.URL.queryParams.Client_DB }}
-AND [Kunde_Nr] IN (SELECT [Kunde_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Beleg_Nr] = {{ appsmith.URL.queryParams.Beleg_Nr }} )
+	[Client_DB] = :client_db
+AND [Kunde_Nr] IN (SELECT [Kunde_Nr] FROM [dbo].[Forderungen_Mahnungen] WHERE [Client_DB] = :client_db AND [Beleg_Nr] = :beleg_nr)
 ) OR 1 != 1
-ORDER BY [Mahnung_Datum], [Beleg_Nr]
+ORDER BY [Mahnung_Datum], [Beleg_Nr]

+ 1 - 1
templates/forderungen/details/queries/post/post_comments.sql

@@ -1,2 +1,2 @@
 INSERT INTO [dbo].[Forderungen_Kommentar] ([Client_DB], [Beleg_Nr], [Timestamp], [Benutzer_ID], [Kommentar])
-VALUES ('{{data.Client_DB}}', '{{data.Beleg_Nr}}', '{{data.Timestamp}}', '{{data.Benutzer_ID}}', '{{data.Kommentar}}')
+VALUES (:Client_DB, :Beleg_Nr, :Timestamp, :Benutzer_ID, :Kommentar)

+ 21 - 21
templates/forderungen/details/queries/post/post_details.sql

@@ -1,30 +1,30 @@
 IF NOT exists (SELECT 1
                FROM [dbo].[Forderungen_Details]
-               WHERE [Client_DB] = '{{data.Client_DB}}'
-                 AND [Beleg_Nr] = '{{data.Beleg_Nr}}')
+               WHERE [Client_DB] = :Client_DB
+                 AND [Beleg_Nr] = :Beleg_Nr)
     BEGIN
         INSERT INTO [dbo].[Forderungen_Details] ( [Client_DB], [Beleg_Nr])
-        VALUES ('{{data.Client_DB}}', '{{data.Beleg_Nr}}')
+        VALUES (:Client_DB, :Beleg_Nr)
     END;
 
 
 UPDATE [dbo].[Forderungen_Details]
 SET [Bearbeitet] = 'J'
-  , [Versicherung] = '{{data.Versicherung}}'
-  , [Rechtsanwalt] = '{{data.Rechtsanwalt}}'
-  , [Reklamation] = '{{data.Reklamation}}'
-  , [Mahnen_aussetzen] = '{{data.Mahnen_aussetzen}}'
-  , [Selbstbeteiligung] = '{{data.Selbstbeteiligung}}'
-  , [Selbstbeteiligung_Betrag] = '{{data.Selbstbeteiligung_Betrag}}'
-  , [Mwst] = '{{data.Mwst}}'
-  , [Mwst_Betrag] = '{{data.Mwst_Betrag}}'
-  , [Wiedervorlage] = '{{data.Wiedervorlage}}'
-  , [Vers_Adresse_ID] = '{{data.Vers_Adresse_ID}}'
-  , [Versicherung_Typ] = '{{data.Versicherung_Typ}}'
-  , [Reklamation_Begruendung] = '{{data.Reklamation_Begruendung}}'
-  , [Rechtsanwalt_Begruendung] = '{{data.Rechtsanwalt_Begruendung}}'
-  , [Rechtsanwalt_Aktenzeichen] = '{{data.Rechtsanwalt_Aktenzeichen}}'
-  , [Rechtsanwalt_Stand] = '{{data.Rechtsanwalt_Stand}}'
-  , [Mahnen_Begruendung] = '{{data.Mahnen_Begruendung}}'
-WHERE [Client_DB] = '{{data.Client_DB}}'
-  AND [Beleg_Nr] = '{{data.Beleg_Nr}}'
+  , [Versicherung] = :Versicherung
+  , [Rechtsanwalt] = :Rechtsanwalt
+  , [Reklamation] = :Reklamation
+  , [Mahnen_aussetzen] = :Mahnen_aussetzen
+  , [Selbstbeteiligung] = :Selbstbeteiligung
+  , [Selbstbeteiligung_Betrag] = :Selbstbeteiligung_Betrag
+  , [Mwst] = :Mwst
+  , [Mwst_Betrag] = :Mwst_Betrag
+  , [Wiedervorlage] = NULLIF(:Wiedervorlage, '')
+  , [Vers_Adresse_ID] = :Vers_Adresse_ID
+  , [Versicherung_Typ] = :Versicherung_Typ
+  , [Reklamation_Begruendung] = :Reklamation_Begruendung
+  , [Rechtsanwalt_Begruendung] = :Rechtsanwalt_Begruendung
+  , [Rechtsanwalt_Aktenzeichen] = :Rechtsanwalt_Aktenzeichen
+  , [Rechtsanwalt_Stand] = :Rechtsanwalt_Stand
+  , [Mahnen_Begruendung] = :Mahnen_Begruendung
+WHERE [Client_DB] = :Client_DB
+  AND [Beleg_Nr] = :Beleg_Nr

+ 0 - 2
templates/forderungen/liste/config/liste_filter.csv

@@ -17,5 +17,3 @@ Bearbeitet;select;2;;;;;
 Abwarten;select;2;;;;;
 WiedervorlageVon;date;2;Wiedervorlage von;Wiedervorlage;;;
 WiedervorlageBis;date;2;Wiedervorlage bis;Wiedervorlage;;;
-
-BenutzerSelect;hidden;2;Benutzer;;;;winter

+ 12 - 7
templates/forderungen/liste/queries/forderungen_liste.sql

@@ -59,10 +59,15 @@ FROM [dbo].[Forderungen_Kopf] [F]
          LEFT JOIN [dbo].[Forderungen_Kommentar_letzte] [FK] ON [F].[Client_DB] = [FK].[Client_DB] AND [F].[Beleg_Nr] = [FK].[Beleg_Nr]
          LEFT JOIN [Garantie2] [G] ON [F].[Client_DB] = [G].[Client_DB] AND [F].[Beleg_Nr] = [G].[Beleg_Nr]
 ) AS F
-INNER JOIN [dbo].[Benutzer_Rechte] [BR] ON ([BR].[Benutzer_ID] = {{ BenutzerSelect.selectedOptionValue }} OR BR.Benutzer_ID = '')
-AND [F].[Hauptbetrieb_ID] = [BR].[Hauptbetrieb_ID] 
-AND [F].[Standort_ID] = [BR].[Standort_ID] 
-AND ([BR].[Rolle] = 'Buchhaltung' OR [BR].[Benutzer_ID] = [F].[Verursacher_Benutzer_ID])
-
-WHERE 1 = 1
-ORDER BY F.[Wiedervorlage] ASC, F.[Beleg_Tage_offen] DESC
+WHERE EXISTS (
+    SELECT 1
+    FROM [dbo].[Benutzer_Rechte] AS [BR]
+    INNER JOIN [dbo].[Rollen] AS [R] ON [R].[Rolle] = [BR].[Rolle] AND [R].[Aktiv] = 1
+    WHERE LOWER(LTRIM(RTRIM([BR].[Benutzer_ID]))) = :auth_username
+      AND NULLIF(LTRIM(RTRIM([BR].[Benutzer_ID])), '') IS NOT NULL
+      AND [F].[Hauptbetrieb_ID] = [BR].[Hauptbetrieb_ID]
+      AND [F].[Standort_ID] = [BR].[Standort_ID]
+      AND ([BR].[Abteilung] IS NULL OR [F].[Abteilung] = [BR].[Abteilung])
+)
+AND 1 = 1
+ORDER BY F.[Wiedervorlage] ASC, F.[Beleg_Tage_offen] DESC

+ 0 - 0
tests/__init__.py


+ 189 - 0
tests/test_auth.py

@@ -0,0 +1,189 @@
+import unittest
+from datetime import timedelta
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+
+from app.config import settings
+from app.ldap import DirectoryUser, normalize_username
+from app.main import app
+from app.oauth_token import InvalidSessionToken, create_access_token, decode_access_token, token_needs_renewal, utcnow
+from app.permissions import Entitlement, highest_role
+
+
+def rights(role: str = "Mitarbeiter", priority: int = 100):
+    return (
+        Entitlement("1", "10", None, role, priority),
+        Entitlement("1", "20", "Service", "Mitarbeiter", 100),
+    )
+
+
+class UsernameTests(unittest.TestCase):
+    def test_supported_ad_name_formats_are_normalized(self):
+        self.assertEqual(normalize_username(r"DOMAIN\Max.Mustermann"), "max.mustermann")
+        self.assertEqual(normalize_username("Max.Mustermann@example.local"), "max.mustermann")
+        self.assertEqual(normalize_username("Max.Mustermann"), "max.mustermann")
+
+    def test_invalid_username_is_rejected(self):
+        with self.assertRaises(ValueError):
+            normalize_username("../../admin")
+
+
+class TokenTests(unittest.TestCase):
+    def test_token_contains_required_identity_and_session_claims(self):
+        token = create_access_token(
+            username="max.mustermann",
+            name="Max Mustermann",
+            email="max@example.local",
+            role="Mitarbeiter",
+            auth_source="ldap",
+        )
+        payload = decode_access_token(token)
+        self.assertEqual(payload["sub"], "max.mustermann")
+        self.assertEqual(payload["role"], "Mitarbeiter")
+        self.assertEqual(payload["auth_source"], "ldap")
+        self.assertLessEqual(payload["exp"], payload["session_exp"])
+        self.assertTrue(payload["csrf"])
+
+    def test_role_change_requires_immediate_renewal(self):
+        token = create_access_token(
+            username="max.mustermann",
+            name="Max Mustermann",
+            email="",
+            role="Mitarbeiter",
+            auth_source="iis",
+        )
+        self.assertTrue(token_needs_renewal(decode_access_token(token), "Buchhaltung"))
+
+    def test_active_token_is_not_renewed_too_early(self):
+        now = utcnow()
+        token = create_access_token(
+            username="max.mustermann",
+            name="Max Mustermann",
+            email="",
+            role="Mitarbeiter",
+            auth_source="iis",
+            now=now,
+        )
+        self.assertFalse(token_needs_renewal(decode_access_token(token), "Mitarbeiter", now + timedelta(minutes=1)))
+
+    def test_absolute_session_expiry_is_enforced(self):
+        token = create_access_token(
+            username="max.mustermann",
+            name="Max Mustermann",
+            email="",
+            role="Mitarbeiter",
+            auth_source="iis",
+            now=utcnow() - timedelta(hours=9),
+        )
+        with self.assertRaises(InvalidSessionToken):
+            decode_access_token(token)
+
+
+class PermissionTests(unittest.TestCase):
+    def test_highest_role_uses_configured_priority(self):
+        entitlements = (
+            Entitlement("1", "10", None, "Mitarbeiter", 100),
+            Entitlement("1", "20", None, "Buchhaltung", 400),
+            Entitlement("1", "30", None, "Standortleitung", 300),
+        )
+        self.assertEqual(highest_role(entitlements), "Buchhaltung")
+
+    def test_no_explicit_rights_are_rejected(self):
+        with self.assertRaises(Exception) as context:
+            highest_role(())
+        self.assertEqual(context.exception.status_code, 403)
+
+
+class AuthenticationFlowTests(unittest.TestCase):
+    def setUp(self):
+        self.old_proxies = list(settings.TRUSTED_PROXY_IPS)
+        self.old_secure = settings.COOKIE_SECURE
+        settings.TRUSTED_PROXY_IPS = ["testclient"]
+        settings.COOKIE_SECURE = True
+        self.directory_user = DirectoryUser(
+            username="max.mustermann",
+            distinguished_name="CN=Max Mustermann,DC=example,DC=local",
+            name="Max Mustermann",
+            email="max@example.local",
+        )
+
+    def tearDown(self):
+        settings.TRUSTED_PROXY_IPS = self.old_proxies
+        settings.COOKIE_SECURE = self.old_secure
+
+    def test_missing_identity_redirects_to_login(self):
+        with TestClient(app, base_url="https://testserver") as client:
+            response = client.get("/select?from=test", follow_redirects=False)
+        self.assertEqual(response.status_code, 303)
+        self.assertIn("/login?next=", response.headers["location"])
+
+    def test_htmx_request_receives_full_page_redirect_header(self):
+        with TestClient(app, base_url="https://testserver") as client:
+            response = client.get("/select", headers={"HX-Request": "true"}, follow_redirects=False)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("/login?next=", response.headers["HX-Redirect"])
+
+    def test_untrusted_identity_header_is_ignored(self):
+        settings.TRUSTED_PROXY_IPS = ["127.0.0.1"]
+        with TestClient(app, base_url="https://testserver") as client:
+            response = client.get(
+                "/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"}, follow_redirects=False
+            )
+        self.assertEqual(response.status_code, 303)
+
+    @patch("app.auth.load_entitlements", return_value=rights())
+    @patch("app.auth.lookup_directory_user")
+    def test_trusted_iis_identity_creates_secure_session(self, lookup, _load):
+        lookup.return_value = self.directory_user
+        with TestClient(app, base_url="https://testserver") as client:
+            response = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
+        self.assertEqual(response.status_code, 200)
+        cookie = response.headers.get("set-cookie", "")
+        self.assertIn("gcepic_session=", cookie)
+        self.assertIn("HttpOnly", cookie)
+        self.assertIn("Secure", cookie)
+        self.assertIn("SameSite=lax", cookie)
+
+    @patch("app.auth.load_entitlements", return_value=())
+    @patch("app.auth.lookup_directory_user")
+    def test_iis_user_without_explicit_rights_is_forbidden(self, lookup, _load):
+        lookup.return_value = self.directory_user
+        with TestClient(app, base_url="https://testserver") as client:
+            response = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
+        self.assertEqual(response.status_code, 403)
+
+    @patch("app.routes.load_entitlements", return_value=rights())
+    @patch("app.routes.ldap_authenticate")
+    def test_ldap_fallback_creates_session(self, authenticate, _load):
+        authenticate.return_value = self.directory_user
+        with TestClient(app, base_url="https://testserver") as client:
+            login_page = client.get("/login")
+            csrf = client.cookies[settings.LOGIN_CSRF_COOKIE_NAME]
+            response = client.post(
+                "/login",
+                data={"username": "max.mustermann", "password": "secret", "_csrf": csrf, "next": "/select"},
+                follow_redirects=False,
+            )
+        self.assertEqual(login_page.status_code, 200)
+        self.assertEqual(response.status_code, 303)
+        self.assertEqual(response.headers["location"], "/select")
+        self.assertIn("gcepic_session=", response.headers.get("set-cookie", ""))
+
+    @patch("app.auth.load_entitlements", return_value=rights())
+    @patch("app.auth.lookup_directory_user")
+    def test_logout_requires_csrf_and_deletes_session(self, lookup, _load):
+        lookup.return_value = self.directory_user
+        with TestClient(app, base_url="https://testserver") as client:
+            login = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
+            token = login.cookies[settings.SESSION_COOKIE_NAME]
+            csrf = decode_access_token(token)["csrf"]
+            rejected = client.post("/logout")
+            accepted = client.post("/logout", data={"_csrf": csrf})
+        self.assertEqual(rejected.status_code, 403)
+        self.assertEqual(accepted.status_code, 200)
+        self.assertIn("Max-Age=0", accepted.headers.get("set-cookie", ""))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 2 - 0
uv.lock

@@ -358,6 +358,7 @@ dependencies = [
     { name = "openpyxl" },
     { name = "pandas" },
     { name = "passlib" },
+    { name = "pydantic-settings" },
     { name = "pyodbc" },
     { name = "python-docx" },
     { name = "python-dotenv" },
@@ -381,6 +382,7 @@ requires-dist = [
     { name = "openpyxl", specifier = ">=3.1" },
     { name = "pandas", specifier = ">=2.0" },
     { name = "passlib", specifier = ">=1.7.4" },
+    { name = "pydantic-settings", specifier = ">=2.0" },
     { name = "pyodbc", specifier = ">=4.0" },
     { name = "python-docx", specifier = ">=0.8" },
     { name = "python-dotenv", specifier = ">=1.2.1" },

+ 1 - 1
webservice/webservice.bat

@@ -1,5 +1,5 @@
 cd %~dp0..
 call .venv\Scripts\activate.bat
 
-uvicorn app.main:app --reload --host 0.0.0.0 --port 8093 > logs\webservice.log 2> logs\errors.log
+uvicorn app.main:app --host 127.0.0.1 --port 8093 --no-proxy-headers > logs\webservice.log 2> logs\errors.log
 rem pause