feat(T01): auth + user management + RBAC + base frontend + i18n
- Backend: FastAPI, JWT auth (HS256), bcrypt, RBAC middleware - User CRUD (admin-only), soft-delete, pagination - Pydantic BaseSettings config, async SQLAlchemy 2.0 - 50 backend tests, 88% coverage - Frontend: Next.js 14 App Router, Tailwind, design tokens - Login page, auth context, API client with auto-refresh - i18n: next-intl, DE/EN (31 keys each) - 6 base UI components (Button, Input, Card, Table, Modal, Toast) - 12 frontend tests, npm build success
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""Application configuration using Pydantic BaseSettings.
|
||||
|
||||
All secrets and environment-dependent values are loaded from environment
|
||||
variables or a local .env file. No hardcoded secrets in code.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Central application settings loaded from env vars or .env file."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
DATABASE_URL: str = (
|
||||
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||
)
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
JWT_SECRET: str = "change-me-to-a-secure-random-string"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TTL_MINUTES: int = 15
|
||||
JWT_REFRESH_TTL_DAYS: int = 7
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
|
||||
UPLOAD_DIR: str = "/tmp/uploads"
|
||||
APP_NAME: str = "ERP Nutzfahrzeuge"
|
||||
APP_ENV: str = "development"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS comma-separated string into a list."""
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def access_token_ttl_seconds(self) -> int:
|
||||
return self.JWT_ACCESS_TTL_MINUTES * 60
|
||||
|
||||
@property
|
||||
def refresh_token_ttl_seconds(self) -> int:
|
||||
return self.JWT_REFRESH_TTL_DAYS * 86400
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Async SQLAlchemy database engine, session factory, and base model."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all SQLAlchemy models."""
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=(settings.APP_ENV == "development"),
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create all tables. Used for testing and first-run setup."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db():
|
||||
"""Drop all tables. Used in test teardown."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.auth_service import get_user_by_id
|
||||
from app.utils.jwt import verify_access_token
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> dict:
|
||||
"""Common pagination parameters."""
|
||||
return {"page": page, "page_size": page_size}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Extract and verify the JWT bearer token, returning the current user.
|
||||
|
||||
Raises 401 if token is missing, invalid, or the user is not found / inactive.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = verify_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}},
|
||||
)
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}},
|
||||
)
|
||||
|
||||
try:
|
||||
user_uuid = uuid.UUID(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_uuid)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(allowed_roles: list[str]):
|
||||
"""RBAC dependency factory: enforce that the current user has one of the allowed roles.
|
||||
|
||||
Usage:
|
||||
@router.get("/", dependencies=[Depends(require_role(["admin"]))])
|
||||
or:
|
||||
async def handler(user: User = Depends(require_role(["admin"]))):
|
||||
"""
|
||||
|
||||
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
||||
user_role = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
if user_role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": f"Role '{user_role}' is not permitted. Required: {allowed_roles}",
|
||||
}
|
||||
},
|
||||
)
|
||||
return user
|
||||
|
||||
return role_checker
|
||||
@@ -0,0 +1,57 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Configures CORS, registers routers under /api/v1, exposes health endpoint.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application startup and shutdown lifecycle."""
|
||||
# Startup: could run alembic migrations here in production
|
||||
yield
|
||||
# Shutdown: cleanup resources
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="ERP Nutzfahrzeuge Backend API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS configuration from settings
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API v1 router
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(auth.router)
|
||||
api_v1_router.include_router(users.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root redirect info."""
|
||||
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SQLAlchemy User model with UUID primary key."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""User roles for RBAC."""
|
||||
admin = "admin"
|
||||
verkaeufer = "verkaeufer"
|
||||
buchhaltung = "buchhaltung"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User entity for authentication and authorization."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
)
|
||||
full_name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False,
|
||||
)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
Enum(UserRole, name="user_role"),
|
||||
nullable=False,
|
||||
default=UserRole.verkaeufer,
|
||||
)
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(5), nullable=False, default="de",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email} role={self.role}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize user for API responses (excludes password_hash)."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"full_name": self.full_name,
|
||||
"role": self.role.value if isinstance(self.role, UserRole) else self.role,
|
||||
"language": self.language,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Auth router: login, token refresh, current user profile."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
authenticate_user,
|
||||
generate_token_pair,
|
||||
refresh_access_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Authenticate user and return JWT access + refresh tokens."""
|
||||
user = await authenticate_user(db, body.email, body.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_CREDENTIALS",
|
||||
"message": "Invalid email or password",
|
||||
}
|
||||
},
|
||||
)
|
||||
tokens = generate_token_pair(user)
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def refresh_token(
|
||||
body: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Exchange a valid refresh token for a new access + refresh token pair."""
|
||||
tokens = await refresh_access_token(db, body.refresh_token)
|
||||
if tokens is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_REFRESH_TOKEN",
|
||||
"message": "Invalid or expired refresh token",
|
||||
}
|
||||
},
|
||||
)
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the currently authenticated user's profile."""
|
||||
return UserResponse.model_validate(current_user)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Users router: admin-only user management (list, create, update, deactivate)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
UserCreate,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
create_user as svc_create_user,
|
||||
deactivate_user as svc_deactivate_user,
|
||||
get_user_by_id,
|
||||
list_users as svc_list_users,
|
||||
update_user as svc_update_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""List all users with pagination. Admin only."""
|
||||
users, total = await svc_list_users(
|
||||
db, page=pagination["page"], page_size=pagination["page_size"]
|
||||
)
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Create a new user. Admin only."""
|
||||
try:
|
||||
user = await svc_create_user(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
language=body.language,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_EMAIL", "message": str(exc)}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Update a user's fields. Admin only."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
user = await svc_update_user(db, user_id, **updates)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Deactivate a user (soft delete). Admin only."""
|
||||
user = await svc_deactivate_user(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pydantic schemas for user-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""POST /api/v1/auth/login request body."""
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""JWT token pair returned after login or refresh."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(..., description="Access token TTL in seconds")
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""POST /api/v1/auth/refresh request body."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user fields shared across schemas."""
|
||||
email: EmailStr
|
||||
full_name: str = Field(..., min_length=1, max_length=200)
|
||||
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
|
||||
language: str = Field(default="de", max_length=5)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""POST /api/v1/users request body."""
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""PUT /api/v1/users/:id request body (all fields optional)."""
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
|
||||
language: Optional[str] = Field(None, max_length=5)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response schema (never exposes password_hash)."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
role: str
|
||||
language: str
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Paginated user list response."""
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response format."""
|
||||
error: dict
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Authentication service: password hashing, login, token refresh, user lookup."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_refresh_token,
|
||||
)
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a plaintext password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
return pwd_context.verify(plaintext, hashed)
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""Fetch a user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""Fetch a user by UUID."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user by email and password."""
|
||||
user = await get_user_by_email(db, email)
|
||||
if user is None:
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def generate_token_pair(user: User) -> dict:
|
||||
"""Create access + refresh JWT tokens for a given user."""
|
||||
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
access_token = create_access_token(
|
||||
user_id=str(user.id),
|
||||
role=role_val,
|
||||
email=user.email,
|
||||
lang=user.language,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=str(user.id),
|
||||
role=role_val,
|
||||
email=user.email,
|
||||
lang=user.language,
|
||||
)
|
||||
from app.config import settings
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.access_token_ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
async def refresh_access_token(db: AsyncSession, refresh_token: str) -> Optional[dict]:
|
||||
"""Verify a refresh token and issue a new token pair."""
|
||||
payload = verify_refresh_token(refresh_token)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_uuid = uuid.UUID(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, user_uuid)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
|
||||
return generate_token_pair(user)
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str,
|
||||
role: str = "verkaeufer",
|
||||
language: str = "de",
|
||||
) -> User:
|
||||
"""Create a new user with a bcrypt-hashed password."""
|
||||
existing = await get_user_by_email(db, email)
|
||||
if existing is not None:
|
||||
raise ValueError(f"User with email {email} already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
full_name=full_name,
|
||||
role=UserRole(role),
|
||||
language=language,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_users(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[User], int]:
|
||||
"""List users with pagination. Returns (users, total_count)."""
|
||||
count_result = await db.execute(select(func.count(User.id)))
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
users = list(result.scalars().all())
|
||||
return users, total
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
**updates,
|
||||
) -> Optional[User]:
|
||||
"""Update user fields. Returns updated user or None if not found."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
for field, value in updates.items():
|
||||
if field == "role" and value is not None:
|
||||
value = UserRole(value)
|
||||
if field == "password":
|
||||
user.password_hash = hash_password(value)
|
||||
elif hasattr(user, field) and value is not None:
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def deactivate_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""Soft-delete a user by setting is_active=False. Returns user or None."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
user.is_active = False
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,83 @@
|
||||
"""JWT utility functions for token creation and verification."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
email: str,
|
||||
lang: str = "de",
|
||||
) -> str:
|
||||
"""Create a short-lived access JWT."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.access_token_ttl_seconds)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"email": email,
|
||||
"lang": lang,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
email: str,
|
||||
lang: str = "de",
|
||||
) -> str:
|
||||
"""Create a long-lived refresh JWT."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.refresh_token_ttl_seconds)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"email": email,
|
||||
"lang": lang,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and verify a JWT token. Returns payload or None."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_access_token(token: str) -> Optional[dict]:
|
||||
"""Verify an access token specifically (checks type claim)."""
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[dict]:
|
||||
"""Verify a refresh token specifically (checks type claim)."""
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
return payload
|
||||
Reference in New Issue
Block a user