fix(security): Krankheitsdaten im Kalender maskieren, Art.-15-Export nur HR/Admin, Login-Enumeration, Session-Invalidierung bei Passwortwechsel
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-04 23:05:46 +01:00
1 parent 8672f92ee5
commit b6eda4e2d5
5 files changed
+42 -24

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ async def get_calendar(
db: AsyncSession = Depends(get_db),
):
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
entries = await absence_service.get_calendar(current_user.company_id, year, month, db, viewer=current_user)
return [CalendarEntry(**e) for e in entries]
+14 -8
View File
@@ -101,14 +101,11 @@ async def logout(request: Request, response: Response, data: RefreshRequest | No
@router.post("/password-reset", response_model=MessageResponse)
@limiter.limit("3/hour")
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.request_password_reset(data.email, db)
if result == "ldap":
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
)
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
await auth_service.request_password_reset(data.email, db)
return MessageResponse(
message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt. "
"Konten, die über LDAP verwaltet werden, setzen das Passwort beim LDAP-Administrator zurück."
)
@router.post("/password-reset/confirm", response_model=MessageResponse)
@@ -133,6 +130,7 @@ async def me(current_user: CurrentUser):
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
request: Request,
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
@@ -147,6 +145,14 @@ async def change_password(
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
from sqlalchemy import delete
from app.core.security import hash_token
from app.models.session import Session as UserSession
stale = delete(UserSession).where(UserSession.user_id == current_user.id)
keep = request.cookies.get(_COOKIE_NAME)
if keep:
stale = stale.where(UserSession.refresh_token_hash != hash_token(keep))
await db.execute(stale)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
+1 -1
View File
@@ -255,7 +255,7 @@ async def export_user_data(
"""DSGVO Art. 15 (Auskunftsrecht): strukturierter Export aller
personenbezogenen Daten des Users als JSON. Eigenes Konto immer erlaubt,
fremde Konten nur für HR/Admin/Manager der eigenen Firma."""
if user_id != current_user.id and current_user.role not in _hr_roles:
if user_id != current_user.id and current_user.role not in _admin_roles + (UserRole.HR,):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Konto")
try:
+15 -3
View File
@@ -563,6 +563,7 @@ class AbsenceService:
year: int,
month: int | None,
db: AsyncSession,
viewer: User | None = None,
) -> list[dict]:
q = (
select(Absence, User, AbsenceType)
@@ -587,14 +588,23 @@ class AbsenceService:
result = await db.execute(q.order_by(Absence.start_date))
rows = result.all()
privileged = viewer is None or viewer.role in (
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
)
calendar = []
for absence, user, atype in rows:
# Art. 9 DSGVO: Krankheitsart für normale Mitarbeiter nicht sichtbar
hide = (
not privileged
and atype.category == AbsenceCategory.SICK
and user.id != viewer.id
)
calendar.append({
"user_id": user.id,
"user_name": user.full_name,
"absence_id": absence.id,
"type_name": atype.name,
"type_color": atype.color,
"type_name": "Abwesend" if hide else atype.name,
"type_color": "#9CA3AF" if hide else atype.color,
"start_date": absence.start_date,
"end_date": absence.end_date,
"status": absence.status,
@@ -992,7 +1002,9 @@ class AbsenceService:
if user_id:
q = q.where(Absence.user_id == user_id)
# MANAGER sieht nur sein Department
if current_user.role == UserRole.MANAGER and current_user.department_id:
if current_user.role == UserRole.MANAGER:
if not current_user.department_id:
return []
q = q.where(User.department_id == current_user.department_id)
result = await db.execute(q)
+11 -11
View File
@@ -25,6 +25,7 @@ from app.services.email_service import email_service
logger = logging.getLogger(__name__)
# Login-Lockout-Konfiguration
_DUMMY_HASH = hash_password("timing-equalizer-Dummy1")
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
@@ -124,6 +125,7 @@ class AuthService:
if not user:
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
await self._record_login_failure(data.email, redis_client)
verify_password(data.password, _DUMMY_HASH) # gleiche Laufzeit wie bei bekanntem User
db.add(AuditLog(
company_id=None,
user_id=None,
@@ -138,23 +140,13 @@ class AuthService:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
# haben company_id IS NULL und sind davon nicht betroffen).
if user.company_id is not None:
company = await db.get(Company, user.company_id)
if company is not None and not company.is_active:
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
auth_ok = False
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
detail="Invalid email or password",
)
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
else:
@@ -177,6 +169,14 @@ class AuthService:
detail="Invalid email or password",
)
# Erst nach korrektem Passwort Kontostatus melden (keine Enumeration)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
if user.company_id is not None:
company = await db.get(Company, user.company_id)
if company is not None and not company.is_active:
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
# Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_login_failures(data.email, redis_client)