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:
@@ -0,0 +1,211 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user