fix(security): Audit-Funde M-1,M-2,M-3,M-6,M-7,N-1..N-3 (Client-IP-Trust, AuditLog company_id, Token-Feeds, Mass-Assignment, Kiosk-Heartbeat)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3470a135c
commit
8672f92ee5
11 files changed
+109
-29
No files matched your search
@@ -15,6 +15,9 @@ from app.models.user import User, UserRole
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Liest die echte Client-IP auch hinter nginx-Proxy.
|
||||
|
||||
@@ -22,13 +25,15 @@ def get_client_ip(request: Request) -> str:
|
||||
Ohne diesen Header würde request.client.host hinter nginx immer
|
||||
127.0.0.1 zurückgeben, womit AuditLog-Einträge wertlos wären.
|
||||
"""
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
direct_ip = request.client.host if request.client else None
|
||||
if direct_ip in _TRUSTED_PROXY_IPS:
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return direct_ip or "unknown"
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
|
||||
@@ -10,13 +10,16 @@ import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.dependencies import get_client_ip, get_current_user
|
||||
from app.core.limiter import limiter
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.company import Company
|
||||
from app.models.time_entry import TimeEntry
|
||||
from app.models.user import User
|
||||
from app.services.time_service import time_service
|
||||
@@ -28,6 +31,24 @@ def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _company_active(user: User, db: AsyncSession) -> bool:
|
||||
if user.company_id is None:
|
||||
return True
|
||||
company = await db.get(Company, user.company_id)
|
||||
return company is not None and company.is_active
|
||||
|
||||
|
||||
def _audit(user: User, action: str, request: Request) -> AuditLog:
|
||||
return AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
|
||||
def _secs(t) -> int:
|
||||
return t.hour * 3600 + t.minute * 60 + t.second
|
||||
|
||||
@@ -38,9 +59,10 @@ def _elapsed_secs(start, now) -> int:
|
||||
|
||||
|
||||
@router.get("/time/ha/{token}")
|
||||
async def ha_status(token: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
@limiter.limit("60/minute")
|
||||
async def ha_status(request: Request, token: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
user = await db.scalar(select(User).where(User.ha_token_hash == _hash_token(token)))
|
||||
if user is None or not user.is_active:
|
||||
if user is None or not user.is_active or not await _company_active(user, db):
|
||||
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -94,11 +116,13 @@ async def ha_token_status(current_user: User = Depends(get_current_user)) -> dic
|
||||
|
||||
@router.post("/users/me/ha-token")
|
||||
async def create_or_rotate_ha_token(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
token = secrets.token_urlsafe(32)
|
||||
current_user.ha_token_hash = _hash_token(token)
|
||||
db.add(_audit(current_user, "ha_token_rotated", request))
|
||||
await db.commit()
|
||||
base = settings.frontend_url.rstrip("/")
|
||||
return {"enabled": True, "url": f"{base}/api/v1/time/ha/{token}"}
|
||||
@@ -106,9 +130,11 @@ async def create_or_rotate_ha_token(
|
||||
|
||||
@router.delete("/users/me/ha-token", status_code=204)
|
||||
async def delete_ha_token(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
current_user.ha_token_hash = None
|
||||
db.add(_audit(current_user, "ha_token_revoked", request))
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -17,14 +17,17 @@ import hashlib
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from icalendar import Calendar, Event
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.dependencies import get_client_ip, get_current_user
|
||||
from app.core.limiter import limiter
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.company import Company
|
||||
from app.models.absence import Absence, AbsenceStatus
|
||||
from app.models.absence_type import AbsenceType
|
||||
from app.models.user import User
|
||||
@@ -43,6 +46,24 @@ def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _company_active(user: User, db: AsyncSession) -> bool:
|
||||
if user.company_id is None:
|
||||
return True
|
||||
company = await db.get(Company, user.company_id)
|
||||
return company is not None and company.is_active
|
||||
|
||||
|
||||
def _audit(user: User, action: str, request: Request) -> AuditLog:
|
||||
return AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
|
||||
def _feed_url(token: str) -> str:
|
||||
base = settings.frontend_url.rstrip("/")
|
||||
return f"{base}/api/v1/absences/ical/{token}.ics"
|
||||
@@ -50,9 +71,10 @@ def _feed_url(token: str) -> str:
|
||||
|
||||
# ── Öffentlicher Feed (kein JWT) ──────────────────────────────────────────────
|
||||
@router.get("/absences/ical/{token}.ics")
|
||||
async def ical_feed(token: str, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
@limiter.limit("60/minute")
|
||||
async def ical_feed(request: Request, token: str, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
user = await db.scalar(select(User).where(User.ical_token_hash == _hash_token(token)))
|
||||
if user is None or not user.is_active:
|
||||
if user is None or not user.is_active or not await _company_active(user, db):
|
||||
raise HTTPException(status_code=404, detail="Kalender-Feed nicht gefunden.")
|
||||
|
||||
rows = (
|
||||
@@ -112,21 +134,25 @@ async def ical_token_status(current_user: User = Depends(get_current_user)) -> d
|
||||
|
||||
@router.post("/users/me/ical-token")
|
||||
async def create_or_rotate_ical_token(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL einmalig zurück."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
current_user.ical_token_hash = _hash_token(token)
|
||||
db.add(_audit(current_user, "ical_token_rotated", request))
|
||||
await db.commit()
|
||||
return {"enabled": True, "url": _feed_url(token)}
|
||||
|
||||
|
||||
@router.delete("/users/me/ical-token", status_code=204)
|
||||
async def delete_ical_token(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
current_user.ical_token_hash = None
|
||||
db.add(_audit(current_user, "ical_token_revoked", request))
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -289,6 +289,9 @@ class AbsenceService:
|
||||
if data.half_day_end is not None:
|
||||
absence.half_day_end = data.half_day_end
|
||||
if data.substitute_id is not None:
|
||||
substitute = await db.get(User, data.substitute_id)
|
||||
if substitute is None or substitute.company_id != current_user.company_id:
|
||||
raise HTTPException(status_code=404, detail="Vertretung nicht gefunden.")
|
||||
absence.substitute_id = data.substitute_id
|
||||
if data.note is not None:
|
||||
absence.note = data.note
|
||||
@@ -313,6 +316,7 @@ class AbsenceService:
|
||||
# Audit-Log
|
||||
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id,
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
entity_type="absence",
|
||||
|
||||
@@ -33,14 +33,8 @@ def _get_client_ip(request: "Request | None") -> str | None:
|
||||
"""Gibt die echte Client-IP zurück (berücksichtigt X-Real-IP / X-Forwarded-For hinter nginx-Proxy)."""
|
||||
if not request:
|
||||
return None
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
# Erstes Element = Original-Client-IP
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
from app.core.dependencies import get_client_ip
|
||||
return get_client_ip(request)
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.security import hash_token
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.models.user import User
|
||||
from app.schemas.kiosk import KioskBearerDeviceCreate, HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||||
|
||||
|
||||
@@ -162,7 +163,10 @@ class KioskService:
|
||||
device.offline_queue_size = data.queued_offline_entries
|
||||
|
||||
if data.current_user_id is not None and company.kiosk_track_current_user:
|
||||
device.current_user_id = data.current_user_id
|
||||
tracked = await db.get(User, data.current_user_id)
|
||||
device.current_user_id = (
|
||||
tracked.id if tracked is not None and tracked.company_id == device.company_id else None
|
||||
)
|
||||
elif not company.kiosk_track_current_user:
|
||||
# DSGVO-Opt-Out: aktuellen User nicht speichern
|
||||
device.current_user_id = None
|
||||
|
||||
@@ -50,7 +50,7 @@ class KioskSessionService:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Session konnte nicht erstellt werden: {exc}"
|
||||
detail="Session konnte nicht erstellt werden."
|
||||
)
|
||||
return session_token
|
||||
|
||||
@@ -63,7 +63,7 @@ class KioskSessionService:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Session-Lookup fehlgeschlagen: {exc}"
|
||||
detail="Session-Lookup fehlgeschlagen."
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
@@ -304,6 +304,9 @@ class LdapService:
|
||||
from ldap3 import SUBTREE
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
if not password:
|
||||
return False
|
||||
|
||||
conn = self._bind_connection(config)
|
||||
if conn is None:
|
||||
return False
|
||||
|
||||
@@ -38,7 +38,7 @@ class PublicStampSessionService:
|
||||
try:
|
||||
await redis.set(key, json.dumps(payload), ex=PUBLIC_STAMP_SESSION_TTL)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}")
|
||||
raise HTTPException(status_code=503, detail="Session konnte nicht erstellt werden.")
|
||||
return session_token
|
||||
|
||||
async def get_session(self, session_token: str) -> Optional[dict]:
|
||||
@@ -46,7 +46,7 @@ class PublicStampSessionService:
|
||||
try:
|
||||
data = await redis.get(SESSION_KEY_PREFIX + session_token)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}")
|
||||
raise HTTPException(status_code=503, detail="Session-Lookup fehlgeschlagen.")
|
||||
if data is None:
|
||||
return None
|
||||
return json.loads(data)
|
||||
|
||||
@@ -244,7 +244,7 @@ class TimeService:
|
||||
break_minutes=data.break_minutes,
|
||||
project_id=data.project_id,
|
||||
note=data.note,
|
||||
source=data.source,
|
||||
source=EntrySource.MANUAL,
|
||||
status=EntryStatus.PENDING,
|
||||
)
|
||||
db.add(entry)
|
||||
@@ -360,6 +360,9 @@ class TimeService:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
|
||||
|
||||
entry = await self._get_entry_or_404(entry_id, db)
|
||||
entry_user = await db.get(User, entry.user_id)
|
||||
if entry_user is None or entry_user.company_id != current_user.company_id:
|
||||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||
if entry.status != EntryStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
|
||||
|
||||
|
||||
@@ -310,6 +310,21 @@ class UserService:
|
||||
new_value={"role": new_role.value if hasattr(new_role, "value") else str(new_role)},
|
||||
))
|
||||
|
||||
if changes.get("is_active") and user.anonymized_at is not None:
|
||||
raise HTTPException(status_code=409, detail="Anonymisierte Konten können nicht reaktiviert werden.")
|
||||
if changes.get("is_active") is False and user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||
if changes.get("department_id") is not None:
|
||||
from app.models.department import Department
|
||||
dept = await db.get(Department, changes["department_id"])
|
||||
if dept is None or dept.company_id != user.company_id:
|
||||
raise HTTPException(status_code=404, detail="Abteilung nicht gefunden.")
|
||||
if changes.get("work_schedule_id") is not None:
|
||||
from app.models.work_schedule import WorkSchedule
|
||||
ws = await db.get(WorkSchedule, changes["work_schedule_id"])
|
||||
if ws is None or ws.company_id != user.company_id:
|
||||
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden.")
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
|
||||
Reference in new issue
Block a user