Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.schemas.auth import (
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
TotpConfirmRequest,
TotpDisableRequest,
TotpLoginRequest,
TotpSetupResponse,
)
from app.schemas.user import InviteAccept, UserOut
from app.services.auth_service import auth_service
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Create a new company + admin account."""
return await auth_service.register(data, db)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse)
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
return MessageResponse(message="Logged out successfully")
@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.")
@router.post("/password-reset/confirm", response_model=MessageResponse)
@limiter.limit("5/hour")
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
await auth_service.confirm_password_reset(data.token, data.new_password, db)
return MessageResponse(message="Password updated successfully")
@router.post("/invite/accept", response_model=UserOut)
@limiter.limit("10/hour")
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
from app.services.user_service import user_service
user = await user_service.accept_invite(data, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
import re
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
raise HTTPException(
status_code=400,
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
import pyotp
secret = pyotp.random_base32()
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/setup/save", response_model=MessageResponse)
async def totp_setup_save(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
await db.commit()
return MessageResponse(message="Secret gespeichert")
@router.post("/totp/confirm", response_model=MessageResponse)
async def totp_confirm(
data: TotpConfirmRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@router.post("/totp/disable", response_model=MessageResponse)
async def totp_disable(
data: TotpDisableRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
import pyotp
if not verify_password(data.password, current_user.password_hash or ""):
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
from uuid import UUID
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
try:
user_id = decode_partial_token(data.partial_token)
except JWTError:
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
user = await db.get(User, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(401, "Benutzer nicht gefunden")
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)