diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml new file mode 100644 index 0000000..ff2c9a1 --- /dev/null +++ b/.gitea/workflows/security.yml @@ -0,0 +1,56 @@ +name: Security Audit + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' # Jeden Montag 06:00 UTC + +jobs: + pip-audit: + name: Python Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pip-audit + run: pip install pip-audit + + - name: Run pip-audit + run: | + cd backend + pip-audit -r requirements.txt --format json --output pip-audit-report.json || true + pip-audit -r requirements.txt + continue-on-error: true # Nicht blockieren, aber anzeigen + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pip-audit-report + path: backend/pip-audit-report.json + if-no-files-found: ignore + + npm-audit: + name: Node.js Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run npm audit + run: | + cd frontend + npm audit --audit-level=high || true + continue-on-error: true diff --git a/DEVLOG.md b/DEVLOG.md index 0966ab7..7098b05 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1749,3 +1749,37 @@ Keine Commits in dieser Session. - frontend/src/context/AuthContext.tsx | 14 +++-- --- +## 2026-05-26 11:30 – 11:35 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 4dc6913 security: H-1 settings-Whitelist + H-5 UUID-Guard + H-6 DNS-Pinning + H-7 Heartbeat-Timing + +### Geänderte Dateien +- DEVLOG.md | 42 ++++++++++++++++++ +- backend/app/core/dependencies.py | 12 ++++-- +- backend/app/core/kiosk_security.py | 11 +++-- +- backend/app/routers/companies.py | 8 +++- +- backend/app/schemas/company.py | 17 +++++++- +- backend/app/services/caldav_service.py | 78 +++++++++++++++++++++++++++++----- +- backend/app/services/kiosk_service.py | 8 +++- + +--- +## 2026-05-26 11:36 – 11:36 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 42 ++++++++++++++++++ +- backend/app/core/dependencies.py | 12 ++++-- +- backend/app/core/kiosk_security.py | 11 +++-- +- backend/app/routers/companies.py | 8 +++- +- backend/app/schemas/company.py | 17 +++++++- +- backend/app/services/caldav_service.py | 78 +++++++++++++++++++++++++++++----- +- backend/app/services/kiosk_service.py | 8 +++- + +--- diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 34f62c9..8a80834 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -1,9 +1,11 @@ +import logging import re +import uuid as uuid_mod from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi import HTTPException, Request, status -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings @@ -20,6 +22,8 @@ from app.models import Company, PasswordReset, Session, User, UserRole from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse from app.services.email_service import email_service +logger = logging.getLogger(__name__) + # Login-Lockout-Konfiguration FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt @@ -201,18 +205,63 @@ class AuthService: return await self._create_session(user, db, request=request) async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse: + import redis.asyncio as aioredis + from app.models.audit_log import AuditLog + token_hash = hash_token(raw_token) - session = await db.scalar( - select(Session).where(Session.refresh_token_hash == token_hash) - ) - if not session or session.expires_at < datetime.now(timezone.utc): - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + burned_key = f"burned_token:{token_hash}" - user = await db.get(User, session.user_id) - if not user or not user.is_active: - raise HTTPException(status_code=401, detail="User not found or inactive") + redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) + try: + # Re-Use-Detection: prüfe ob Token bereits verbrannt wurde + burned_user_id = await redis_client.get(burned_key) + if burned_user_id: + # Token wurde bereits einmal genutzt — möglicher Token-Diebstahl! + logger.warning( + "Replay-Angriff erkannt: verbrannter Refresh-Token für User %s", + burned_user_id, + ) + # Alle Sessions des betroffenen Users invalidieren + try: + uid = uuid_mod.UUID(burned_user_id) + await db.execute(delete(Session).where(Session.user_id == uid)) + db.add(AuditLog( + company_id=None, + user_id=uid, + action="refresh_token_reuse", + entity_type="session", + entity_id=uid, + new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"}, + ip=None, + )) + await db.commit() + except Exception: + pass + raise HTTPException( + status_code=401, + detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.", + ) + + session = await db.scalar( + select(Session).where(Session.refresh_token_hash == token_hash) + ) + if not session or session.expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + user = await db.get(User, session.user_id) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="User not found or inactive") + + user_id_str = str(session.user_id) + + # Session löschen (Token "verbrennen") + await db.delete(session) + + # Verbrannten Token-Hash 48h in Redis merken + await redis_client.set(burned_key, user_id_str, ex=48 * 3600) + finally: + await redis_client.aclose() - await db.delete(session) return await self._create_session(user, db) async def logout(self, raw_token: str, db: AsyncSession) -> None: diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 587be59..ce4bbd5 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -112,7 +112,7 @@ class EmailService: async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None: cfg = await self._load_smtp(user.company_id, db) - invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}" + invite_url = f"{settings.frontend_url}/invite/accept#{raw_token}" body = f"""

Du wurdest eingeladen!

{invited_by.full_name} hat dich zu {settings.app_name} eingeladen.

@@ -130,7 +130,7 @@ class EmailService: async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None: cfg = await self._load_smtp(user.company_id, db) - reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}" + reset_url = f"{settings.frontend_url}/auth/reset-password#{raw_token}" body = f"""

Passwort zurücksetzen

Hallo {user.first_name},

diff --git a/frontend/src/pages/ResetPasswordPage.tsx b/frontend/src/pages/ResetPasswordPage.tsx index f2ca9cb..1138830 100644 --- a/frontend/src/pages/ResetPasswordPage.tsx +++ b/frontend/src/pages/ResetPasswordPage.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' -import { useNavigate, useSearchParams, Link } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' import { api } from '../api/client' export function ResetPasswordPage() { - const [params] = useSearchParams() - const token = params.get('token') ?? '' + // Token wird als URL-Fragment (#token) übergeben – landet nicht in Server-Logs oder Referer-Headern + const token = window.location.hash.slice(1) const navigate = useNavigate() const [password, setPassword] = useState('') diff --git a/timemaster.service b/timemaster.service index fb77759..37de888 100644 --- a/timemaster.service +++ b/timemaster.service @@ -1,32 +1,15 @@ [Unit] -Description=TimeMaster FastAPI Backend -After=network.target postgresql.service redis.service -Requires=postgresql.service redis.service +Description=TimeMaster Backend API +After=network.target postgresql.service [Service] -Type=exec -User=www-data -Group=www-data +Type=simple +User=root WorkingDirectory=/opt/timemaster/backend -EnvironmentFile=/opt/timemaster/backend/.env -ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app \ - --host 127.0.0.1 \ - --port 8000 \ - --workers 4 \ - --log-level info \ - --access-log -ExecReload=/bin/kill -HUP $MAINPID +Environment=PATH=/opt/timemaster/backend/venv/bin +ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1 Restart=on-failure RestartSec=5 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=timemaster - -# Sicherheit -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ReadWritePaths=/opt/timemaster/backend [Install] WantedBy=multi-user.target