Files
timemaster/backend/app/services/auth_service.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
7.8 KiB
Python

import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
generate_invite_token,
generate_reset_token,
hash_password,
hash_token,
verify_password,
)
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
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug[:80]
class AuthService:
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
base_slug = _slugify(data.company_name)
slug = base_slug
counter = 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base_slug}-{counter}"
counter += 1
company = Company(name=data.company_name, slug=slug)
db.add(company)
await db.flush()
user = User(
company_id=company.id,
email=data.email,
password_hash=hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.COMPANY_ADMIN,
)
db.add(user)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
tokens = await self._create_session(user, db)
await email_service.send_welcome(user, db)
return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
raise HTTPException(
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")
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",
)
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else:
if not user.password_hash or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled:
from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id))
return TokenResponse(
access_token="",
refresh_token="",
totp_required=True,
partial_token=partial,
)
user.last_login = datetime.now(timezone.utc)
return await self._create_session(user, db, request=request)
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
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")
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")
await db.delete(session)
return await self._create_session(user, db)
async def logout(self, raw_token: str, db: AsyncSession) -> None:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if session:
await db.delete(session)
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
"""
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
"""
from app.models.user import AuthProvider
user = await db.scalar(select(User).where(User.email == email))
if not user:
return None # Security: kein Hinweis ob E-Mail existiert
if user.auth_provider == AuthProvider.LDAP:
return "ldap"
old_resets = await db.scalars(
select(PasswordReset).where(
PasswordReset.user_id == user.id,
PasswordReset.used_at.is_(None),
)
)
for r in old_resets:
await db.delete(r)
raw, hashed = generate_reset_token()
reset = PasswordReset(
user_id=user.id,
token_hash=hashed,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
db.add(reset)
await email_service.send_password_reset(user, raw, db)
return None
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
token_hash = hash_token(token)
reset = await db.scalar(
select(PasswordReset).where(
PasswordReset.token_hash == token_hash,
PasswordReset.used_at.is_(None),
)
)
if not reset or reset.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user = await db.get(User, reset.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
user.password_hash = hash_password(new_password)
reset.used_at = datetime.now(timezone.utc)
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
for s in sessions:
await db.delete(s)
async def _create_session(
self,
user: User,
db: AsyncSession,
request: Request | None = None,
) -> TokenResponse:
raw_refresh, hashed_refresh = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=hashed_refresh,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
device=request.headers.get("User-Agent", "")[:255] if request else None,
ip=request.client.host if request and request.client else None,
)
db.add(session)
access_token = create_access_token(
str(user.id),
extra={"role": user.role, "company_id": str(user.company_id)},
)
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
auth_service = AuthService()