fix(security): Krankheitsdaten im Kalender maskieren, Art.-15-Export nur HR/Admin, Login-Enumeration, Session-Invalidierung bei Passwortwechsel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8672f92ee5
commit
b6eda4e2d5
5 files changed
+42
-24
No files matched your search
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user