1fedd683e0
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>
212 lines
7.8 KiB
Python
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()
|