d89304845a
- 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
179 lines
5.0 KiB
Python
179 lines
5.0 KiB
Python
"""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
|