feat(users): add user administration and password management

This commit is contained in:
Schubert Ferenc 2026-07-11 14:04:30 +02:00
parent 47f54d3461
commit b584e60273
16 changed files with 720 additions and 22 deletions

View file

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_session
from app.models.user import User
from app.models.user import User, UserRole
bearer = HTTPBearer()
@ -30,3 +30,17 @@ def current_user(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user
def require_role(*roles: UserRole):
def dependency(user: User = Depends(current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user
return dependency
def current_admin(user: User = Depends(current_user)) -> User:
if user.role != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user

View file

@ -1,12 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
from app.core.security import hash_password, verify_password
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@ -22,3 +25,24 @@ def login(payload: LoginRequest, session: Session = Depends(get_session)) -> Tok
def me(user: User = Depends(current_user)) -> User:
return user
@router.post("/change-password")
def change_password(
payload: ChangePasswordRequest,
user: User = Depends(current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das aktuelle Passwort ist falsch.")
if payload.new_password != payload.new_password_confirmation:
raise HTTPException(status_code=422, detail="Die neuen Passwoerter stimmen nicht ueberein.")
if len(payload.new_password) < 12:
raise HTTPException(status_code=422, detail="Das neue Passwort muss mindestens 12 Zeichen haben.")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das neue Passwort darf nicht identisch zum alten sein.")
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
user.password_changed_at = datetime.now(UTC)
session.commit()
return {"status": "ok"}

View file

@ -9,18 +9,20 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.api.dependencies import current_admin, current_user
from app.db.session import get_session
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.user import User, UserRole
from app.models.validation import Validation
from app.modules.helios.service import HeliosImportService
from app.modules.orion.service import OrionReportService
@ -50,6 +52,10 @@ from app.schemas.domain import (
ValidationRead,
ValidationReview,
ValidationUpdate,
UserCreate,
UserRead,
UserUpdate,
UserPasswordResetRequest,
)
from app.services.domain_service import CrudService, DomainServices
from app.services.validation_workflow import ValidationWorkflowService
@ -546,3 +552,155 @@ def validation_report_pdf(item_id: str, session: Session = Depends(get_session))
media_type="application/pdf",
filename=report_path.name,
)
def _user_payload(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at,
"updated_at": user.updated_at,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role,
"is_active": user.is_active,
"must_change_password": user.must_change_password,
"last_login_at": user.last_login_at,
"password_changed_at": user.password_changed_at,
}
@router.get("/users", response_model=PaginatedResponse[UserRead])
def list_users(
session: Session = Depends(get_session),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(default=None),
role: str | None = Query(default=None),
active: bool | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_order: str = Query(default="desc"),
_: User = Depends(current_admin),
):
query = select(User)
if search:
term = f"%{search.strip()}%"
query = query.where(
(User.first_name.ilike(term))
| (User.last_name.ilike(term))
| (User.email.ilike(term))
)
if role:
query = query.where(User.role == role)
if active is not None:
query = query.where(User.is_active.is_(active))
sort_columns = {
"name": User.last_name,
"email": User.email,
"role": User.role,
"is_active": User.is_active,
"last_login_at": User.last_login_at,
"password_changed_at": User.password_changed_at,
"created_at": User.created_at,
"updated_at": User.updated_at,
}
sort_column = sort_columns.get(sort_by, User.created_at)
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.post("/users", status_code=201)
def create_user(payload: UserCreate, session: Session = Depends(get_session), _: User = Depends(current_admin)):
from app.core.security import hash_password
import secrets
from app.models.user import User
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
user = User(
email=payload.email.lower(),
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role.value,
password_hash=hash_password(temporary_password),
is_active=payload.is_active,
must_change_password=payload.must_change_password,
)
session.add(user)
session.commit()
session.refresh(user)
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201)
@router.get("/users/{item_id}", response_model=UserRead)
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
return user
@router.put("/users/{item_id}", response_model=UserRead)
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id and not payload.is_active:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
user.first_name = payload.first_name
user.last_name = payload.last_name
user.email = payload.email.lower()
user.role = payload.role.value
user.is_active = payload.is_active
user.must_change_password = payload.must_change_password
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/deactivate", response_model=UserRead)
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1:
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.")
user.is_active = False
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/activate", response_model=UserRead)
def activate_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
user.is_active = True
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/reset-password")
def reset_user_password(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
import secrets
from app.core.security import hash_password
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
temporary_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(temporary_password)
user.must_change_password = True
session.commit()
return JSONResponse(content={"temporary_password": temporary_password})

View file

@ -16,10 +16,12 @@ def seed_admin() -> None:
session.add(
User(
email=settings.admin_email.lower(),
full_name="Validation Suite Administrator",
role=UserRole.admin,
first_name="Validation Suite",
last_name="Administrator",
role=UserRole.ADMIN.value,
password_hash=hash_password(settings.admin_password),
is_active=True,
must_change_password=False,
)
)
session.commit()
@ -27,4 +29,3 @@ def seed_admin() -> None:
if __name__ == "__main__":
seed_admin()

View file

@ -1,25 +1,34 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, Enum, String
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class UserRole(str, enum.Enum):
admin = "admin"
employee = "employee"
auditor = "auditor"
ADMIN = "admin"
PRUEFER = "pruefer"
MITARBEITER = "mitarbeiter"
LESER = "leser"
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
full_name: Mapped[str] = mapped_column(String(160))
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
first_name: Mapped[str] = mapped_column(String(80))
last_name: Mapped[str] = mapped_column(String(80))
role: Mapped[str] = mapped_column(String(40), default=UserRole.MITARBEITER.value)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, EmailStr
from app.models.user import UserRole
@ -18,7 +20,16 @@ class TokenResponse(BaseModel):
class UserRead(EntityRead):
email: EmailStr
full_name: str
first_name: str
last_name: str
role: UserRole
is_active: bool
must_change_password: bool
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
new_password_confirmation: str

View file

@ -1,12 +1,14 @@
from __future__ import annotations
from datetime import date
from datetime import datetime
from uuid import UUID
from pydantic import EmailStr, Field, field_validator
from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus
from app.models.user import UserRole
from app.models.validation import ValidationStatus
from app.schemas.common import EntityRead, ORMModel
@ -241,3 +243,29 @@ class MeasurementImportValueConfirm(ORMModel):
class MeasurementImportConfirmRequest(ORMModel):
values: list[MeasurementImportValueConfirm]
class UserBase(ORMModel):
first_name: str
last_name: str
email: EmailStr
role: UserRole
is_active: bool = True
must_change_password: bool = True
class UserCreate(UserBase):
temporary_password: str
class UserUpdate(UserBase):
pass
class UserRead(UserBase, EntityRead):
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
class UserPasswordResetRequest(ORMModel):
temporary_password: str | None = None

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
@ -19,5 +21,6 @@ class AuthService:
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return create_access_token(user.id, user.role.value)
user.last_login_at = datetime.now(UTC)
self.users.session.commit()
return create_access_token(user.id, user.role)

View file

@ -12,6 +12,7 @@ from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.user import User, UserRole
from app.models.validation import Validation
from app.repositories.base import Repository
from app.repositories.domain import (
@ -20,6 +21,7 @@ from app.repositories.domain import (
DeviceRepository,
EquipmentRepository,
LocationRepository,
UserRepository,
ValidationRepository,
)
@ -78,6 +80,7 @@ class CrudService:
class DomainServices:
def __init__(self, session: Session) -> None:
self.users: CrudService[User] = CrudService(UserRepository(session))
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
self.locations: CrudService[Location] = CrudService(LocationRepository(session))
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))