Files
timemaster/backend/app/routers/auth.py
T
sysops 1fedd683e0 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>
2026-05-23 20:03:27 +02:00

212 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)