diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 55d2aa3..3bfb8db 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -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] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f207f62..5d45461 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 727257f..7e5e41b 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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: diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index 12e5a9f..63298bb 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -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) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index d57722c..ca88890 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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)