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()