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,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
|
||||
Reference in New Issue
Block a user