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,54 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import model_validator
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# App
|
||||
app_name: str = "TimeMaster"
|
||||
app_env: str = "development"
|
||||
secret_key: str = "change-me-in-production"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
allowed_hosts: list[str] = []
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://timemaster:secret@localhost:5432/timemaster_db"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
access_token_expire_minutes: int = 30
|
||||
refresh_token_expire_days: int = 30
|
||||
algorithm: str = "HS256"
|
||||
|
||||
# Email
|
||||
resend_api_key: str = ""
|
||||
email_from: str = "noreply@timemaster.app"
|
||||
email_from_name: str = "TimeMaster"
|
||||
|
||||
# First superadmin
|
||||
first_superadmin_email: str = ""
|
||||
first_superadmin_password: str = ""
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_secret_key(self):
|
||||
if self.app_env == 'production' and self.secret_key == 'change-me-in-production':
|
||||
raise ValueError('SECRET_KEY must be changed in production! Set SECRET_KEY env variable.')
|
||||
if len(self.secret_key) < 32:
|
||||
raise ValueError('SECRET_KEY must be at least 32 characters long.')
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.app_env == "development",
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = await db.get(User, UUID(user_id))
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_role(*roles: UserRole):
|
||||
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
|
||||
async def checker(current_user: CurrentUser) -> User:
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions",
|
||||
)
|
||||
return current_user
|
||||
return Depends(checker)
|
||||
|
||||
|
||||
def require_same_company(target_company_id: UUID, current_user: User) -> None:
|
||||
"""Raise 403 if user tries to access another company's data."""
|
||||
if (
|
||||
current_user.role != UserRole.SUPER_ADMIN
|
||||
and current_user.company_id != target_company_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access to this resource is not allowed",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
# ── Password ────────────────────────────────────────────────────────────────
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 hash for storing tokens (refresh, invite, reset) in DB."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
# ── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.access_token_expire_minutes
|
||||
)
|
||||
payload = {"sub": subject, "exp": expire, "type": "access"}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def create_refresh_token() -> tuple[str, str]:
|
||||
"""Returns (raw_token, hashed_token). Store hash in DB, send raw to client."""
|
||||
raw = secrets.token_urlsafe(64)
|
||||
return raw, hash_token(raw)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""Raises JWTError on invalid/expired token."""
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("type") != "access":
|
||||
raise JWTError("Invalid token type")
|
||||
return payload
|
||||
|
||||
|
||||
# ── Partial token (TOTP pending) ─────────────────────────────────────────────
|
||||
|
||||
def create_partial_token(user_id: str) -> str:
|
||||
"""Short-lived token issued after password-OK but before TOTP verification. Valid 5 min."""
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
payload = {"sub": user_id, "exp": expire, "type": "partial"}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def decode_partial_token(token: str) -> str:
|
||||
"""Returns user_id (sub). Raises JWTError on invalid/expired/wrong-type token."""
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("type") != "partial":
|
||||
raise JWTError("Invalid token type")
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
# ── One-time tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
def generate_invite_token() -> tuple[str, str]:
|
||||
"""Returns (raw, hashed). Invite valid for 7 days."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, hash_token(raw)
|
||||
|
||||
|
||||
def generate_reset_token() -> tuple[str, str]:
|
||||
"""Returns (raw, hashed). Reset valid for 1 hour."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, hash_token(raw)
|
||||
Reference in New Issue
Block a user