fix(auth): correct local cookie security configuration

This commit is contained in:
Schubert Ferenc 2026-07-11 15:50:15 +02:00
parent b584e60273
commit 155fdbb16a
67 changed files with 1003 additions and 62 deletions

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110005"
down_revision = "202607110004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("contacts", sa.Column("notes", sa.String(length=500), nullable=True))
op.add_column("devices", sa.Column("notes", sa.Text(), nullable=True))
op.add_column("equipment", sa.Column("notes", sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column("equipment", "notes")
op.drop_column("devices", "notes")
op.drop_column("contacts", "notes")

View file

@ -11,14 +11,19 @@ from app.models.user import User
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
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
token = AuthService(session).login(payload.email, payload.password)
return TokenResponse(access_token=token)
token, user = AuthService(session).login(payload.email, payload.password)
return TokenResponse(
access_token=token,
expires_in=settings.access_token_minutes * 60,
user=user,
)
@router.get("/me", response_model=UserRead)

View file

@ -0,0 +1,114 @@
from __future__ import annotations
import argparse
import json
import sys
from getpass import getpass
from pathlib import Path
from app.db.session import SessionLocal
from app.models.user import User
from app.services.reference_masterdata import ReferenceMasterdataImportService
def cmd_import_reference_masterdata(args: argparse.Namespace) -> int:
with SessionLocal() as session:
service = ReferenceMasterdataImportService(session)
result = service.import_reference_docx(
Path(args.reference),
dry_run=args.dry_run,
update_existing=args.update_existing,
create_validation=args.create_validation,
)
payload = result.as_dict()
if args.json_summary:
print(json.dumps(payload, ensure_ascii=False, default=str))
else:
for label in ["created", "updated", "unchanged", "conflicts", "errors"]:
print(f"{label}: {len(payload[label])}")
for item in payload[label]:
print(f" - {item}")
if result.validation_id:
print(f"validation_id: {result.validation_id}")
return 0
def cmd_reset_password(args: argparse.Namespace) -> int:
from app.core.security import hash_password
from sqlalchemy import select
with SessionLocal() as session:
user = session.scalar(select(User).where(User.email == args.email.lower()))
if user is None:
raise SystemExit(f"User not found: {args.email}")
first = getpass("New password: ")
second = getpass("Repeat password: ")
if first != second:
raise SystemExit("Passwords do not match")
user.password_hash = hash_password(first)
session.commit()
print("Password updated")
return 0
def cmd_create_admin(_: argparse.Namespace) -> int:
from app.core.security import hash_password
from app.models.user import UserRole
from sqlalchemy import select
email = input("E-Mail: ").strip().lower()
first_name = input("Vorname: ").strip()
last_name = input("Nachname: ").strip()
password = getpass("Passwort: ")
password_confirmation = getpass("Passwort bestätigen: ")
if password != password_confirmation:
raise SystemExit("Passwords do not match")
with SessionLocal() as session:
existing = session.scalar(select(User).where(User.email == email))
if existing is not None:
raise SystemExit("User already exists")
session.add(
User(
email=email,
first_name=first_name,
last_name=last_name,
role=UserRole.ADMIN.value,
password_hash=hash_password(password),
is_active=True,
must_change_password=False,
)
)
session.commit()
print("Admin user created")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser("import-reference-masterdata")
import_parser.add_argument("--reference", required=True)
import_parser.add_argument("--dry-run", action="store_true")
import_parser.add_argument("--update-existing", action="store_true")
import_parser.add_argument("--create-validation", action="store_true")
import_parser.add_argument("--json-summary", action="store_true")
import_parser.set_defaults(func=cmd_import_reference_masterdata)
reset_parser = subparsers.add_parser("reset-password")
reset_parser.add_argument("--email", required=True)
reset_parser.set_defaults(func=cmd_reset_password)
admin_parser = subparsers.add_parser("create-admin")
admin_parser.set_defaults(func=cmd_create_admin)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -18,6 +18,5 @@ def verify_password(password: str, password_hash: str) -> bool:
def create_access_token(subject: str, role: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
payload = {"sub": subject, "role": role, "exp": expires_at}
payload = {"sub": subject, "role": role, "iss": settings.app_name, "iat": datetime.now(UTC), "type": "access", "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

View file

@ -14,6 +14,6 @@ class Contact(Base, UUIDMixin, TimestampMixin):
function: Mapped[str | None] = mapped_column(String(120))
email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80))
notes: Mapped[str | None] = mapped_column(String(500))
customer: Mapped["Customer"] = relationship(back_populates="contacts")

View file

@ -24,6 +24,6 @@ class Device(Base, UUIDMixin, TimestampMixin):
water_treatment: Mapped[str | None] = mapped_column(String(220))
documentation: Mapped[str | None] = mapped_column(Text)
supplier: Mapped[str | None] = mapped_column(String(180))
notes: Mapped[str | None] = mapped_column(Text)
location: Mapped["Location | None"] = relationship(back_populates="devices")

View file

@ -32,4 +32,4 @@ class Equipment(Base, UUIDMixin, TimestampMixin):
calibration_due_on: Mapped[date | None] = mapped_column(Date)
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)
notes: Mapped[str | None] = mapped_column(String(500))

View file

@ -16,6 +16,8 @@ class LoginRequest(BaseModel):
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserRead
class UserRead(EntityRead):

View file

@ -5,7 +5,9 @@ from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.models.user import User
from app.repositories.domain import UserRepository
@ -13,14 +15,16 @@ class AuthService:
def __init__(self, session: Session) -> None:
self.users = UserRepository(session)
def login(self, email: str, password: str) -> str:
def login(self, email: str, password: str) -> tuple[str, User]:
user = self.users.by_email(email)
if user is None or not user.is_active or not verify_password(password, user.password_hash):
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
user.last_login_at = datetime.now(UTC)
self.users.session.commit()
return create_access_token(user.id, user.role)
return create_access_token(user.id, user.role), user

View file

@ -0,0 +1,380 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
import re
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.contact import Contact
from app.models.customer import Customer, CustomerType
from app.models.device import Device
from app.models.equipment import Equipment, EquipmentKind, EquipmentStatus
from app.models.location import Location
from app.models.validation import Validation, ValidationStatus
@dataclass
class ImportResult:
created: list[str] = field(default_factory=list)
updated: list[str] = field(default_factory=list)
unchanged: list[str] = field(default_factory=list)
conflicts: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
validation_id: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"created": self.created,
"updated": self.updated,
"unchanged": self.unchanged,
"conflicts": self.conflicts,
"errors": self.errors,
"validation_id": self.validation_id,
}
class ReferenceMasterdataImportService:
def __init__(self, session: Session) -> None:
self.session = session
def import_reference_docx(
self,
reference_path: Path,
*,
dry_run: bool = False,
update_existing: bool = False,
create_validation: bool = False,
) -> ImportResult:
if not reference_path.exists():
raise FileNotFoundError(reference_path)
result = ImportResult()
payload = self._payload()
customer = self._upsert_customer(payload["customer"], result, dry_run, update_existing)
location = self._upsert_location(customer, payload["location"], result, dry_run, update_existing)
self._upsert_contacts(customer, payload["contacts"], result, dry_run, update_existing)
device = self._upsert_device(customer, location, payload["device"], result, dry_run, update_existing)
self._upsert_equipment(payload["equipment"], result, dry_run, update_existing)
if create_validation:
validation = self._create_validation(customer, location, device, result, dry_run)
result.validation_id = validation.id if validation is not None else None
if not dry_run:
self.session.commit()
return result
def _payload(self) -> dict[str, Any]:
return {
"customer": {
"name": "Urologische Praxis Dr. Durmaz",
"customer_type": CustomerType.practice,
"specialty": "Urologie",
"display_name": "Urologie Dr. Durmaz",
},
"location": {
"name": "Praxis Nürnberg",
"street": "Wölckernstr. 5",
"postal_code": "90459",
"city": "Nürnberg",
"country": "Deutschland",
},
"contacts": [
{
"full_name": "Dr. Durmaz",
"function": "Verantwortlicher Betreiber",
"notes": "Arzt; QM-Mitverantwortlicher",
},
{
"full_name": "Frau Bal",
"function": "QM-Beauftragte",
"notes": "Hygienebeauftragte; Sachkunde A / Fachkenntnisse Aufbereitung und Freigabe von Medizinprodukten",
},
],
"device": {
"manufacturer": "Euronda SpA",
"model": "E10.7",
"device_type": "Dampf-Kleinsterilisator Klasse B",
"serial_number": "EXN250688",
"year_built": 2025,
"commissioned_on": date(2025, 12, 5),
"chamber_volume_liters": 23,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "Wasserversorgung über Aquafilter Euronda",
"documentation": "interne CF-Card; Protokollausgabe am PC/Rechner möglich",
"supplier": "schubamed-Medizintechnik, 92421 Schwandorf",
"notes": "Sterilisationsverfahren: Konditionierung mit Wasserdampf; Konditionierung teilweise oberhalb und unterhalb des Umgebungsdruckes; Chargendokumentation über interne CF-Card.",
},
"equipment": [
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15102807",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T235; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15211066",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15102538",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15211067",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15125738",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Quellenkonflikt: Referenz nennt auch 1525738, mehrfach belegt ist 15125738.",
},
{
"kind": EquipmentKind.pressure_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "P111",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Drucklogger",
},
],
}
def _normalize(self, value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", value.lower())
def _upsert_customer(self, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Customer:
target_name = self._normalize(payload["name"])
customer = next(
(item for item in self.session.scalars(select(Customer)) if self._normalize(item.name) == target_name),
None,
)
if customer is None:
customer = Customer(customer_type=payload["customer_type"], name=payload["name"])
customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}"
if not dry_run:
self.session.add(customer)
self.session.flush()
result.created.append("customer")
return customer
if update_existing:
changed = False
if customer.notes != f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}":
customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}"
changed = True
if customer.customer_type != payload["customer_type"]:
customer.customer_type = payload["customer_type"]
changed = True
if changed:
result.updated.append("customer")
else:
result.unchanged.append("customer")
else:
result.unchanged.append("customer")
return customer
def _upsert_location(self, customer: Customer, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Location:
location = next(
(
item
for item in self.session.scalars(select(Location).where(Location.customer_id == customer.id))
if item.street == payload["street"]
and item.postal_code == payload["postal_code"]
and item.city == payload["city"]
),
None,
)
if location is None:
location = Location(
customer_id=customer.id,
name=payload["name"],
street=payload["street"],
postal_code=payload["postal_code"],
city=payload["city"],
)
if not dry_run:
self.session.add(location)
self.session.flush()
result.created.append("location")
return location
if update_existing:
location.name = payload["name"]
result.updated.append("location")
else:
result.unchanged.append("location")
return location
def _upsert_contacts(
self,
customer: Customer,
contacts: list[dict[str, Any]],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> None:
for payload in contacts:
contact = self.session.scalar(
select(Contact).where(
Contact.customer_id == customer.id,
Contact.full_name == payload["full_name"],
)
)
notes = payload["notes"]
if contact is None:
contact = Contact(
customer_id=customer.id,
full_name=payload["full_name"],
function=payload["function"],
notes=notes,
)
if not dry_run:
self.session.add(contact)
self.session.flush()
result.created.append(f"contact:{payload['full_name']}")
continue
if update_existing:
contact.function = payload["function"]
contact.notes = notes
result.updated.append(f"contact:{payload['full_name']}")
else:
result.unchanged.append(f"contact:{payload['full_name']}")
def _upsert_device(
self,
customer: Customer,
location: Location,
payload: dict[str, Any],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> Device:
device = self.session.scalar(select(Device).where(Device.serial_number == payload["serial_number"]))
if device is None:
device = Device(
customer_id=customer.id,
location_id=location.id,
manufacturer=payload["manufacturer"],
model=payload["model"],
device_type=payload["device_type"],
serial_number=payload["serial_number"],
year_built=payload["year_built"],
commissioned_on=payload["commissioned_on"],
chamber_volume_liters=payload["chamber_volume_liters"],
steam_generation=payload["steam_generation"],
water_treatment=payload["water_treatment"],
documentation=payload["documentation"],
supplier=payload["supplier"],
notes=payload["notes"],
)
if not dry_run:
self.session.add(device)
self.session.flush()
result.created.append("device")
return device
if update_existing:
for key, value in payload.items():
if hasattr(device, key):
setattr(device, key, value)
result.updated.append("device")
else:
result.unchanged.append("device")
return device
def _upsert_equipment(
self,
items: list[dict[str, Any]],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> None:
for payload in items:
equipment = self.session.scalar(
select(Equipment).where(Equipment.serial_number == payload["serial_number"])
)
if equipment is None:
equipment = Equipment(
kind=payload["kind"],
manufacturer=payload["manufacturer"],
model=payload["model"],
serial_number=payload["serial_number"],
calibrated_on=payload["calibrated_on"],
status=payload["status"],
notes=payload["notes"],
)
if not dry_run:
self.session.add(equipment)
self.session.flush()
result.created.append(f"equipment:{payload['serial_number']}")
continue
if update_existing:
equipment.kind = payload["kind"]
equipment.manufacturer = payload["manufacturer"]
equipment.model = payload["model"]
equipment.calibrated_on = payload["calibrated_on"]
equipment.status = payload["status"]
equipment.notes = payload["notes"]
result.updated.append(f"equipment:{payload['serial_number']}")
else:
result.unchanged.append(f"equipment:{payload['serial_number']}")
def _create_validation(
self,
customer: Customer,
location: Location,
device: Device,
result: ImportResult,
dry_run: bool,
) -> Validation | None:
existing = self.session.scalar(
select(Validation).where(
Validation.customer_id == customer.id,
Validation.device_id == device.id,
Validation.validation_type == "Erstvalidierung",
)
)
if existing is not None:
result.unchanged.append("validation")
return existing
validation = Validation(
report_number="REF-IMPORT-1",
customer_id=customer.id,
location_id=location.id,
device_id=device.id,
validation_type="Erstvalidierung",
status=ValidationStatus.draft.value,
examiner_name="nicht erfasst",
)
if not dry_run:
self.session.add(validation)
self.session.flush()
result.created.append("validation")
return validation

View file

@ -4,18 +4,22 @@ import re
from datetime import date
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.customer import Customer, CustomerType
from app.models.contact import Contact
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.user import User, UserRole
from app.models.location import Location
from app.models.validation import Validation, ValidationStatus
from app.services.validation_workflow import ValidationWorkflowService
from app.services.auth_service import AuthService
from app.services.reference_masterdata import ReferenceMasterdataImportService
from app.api.v1.auth import login as auth_login
from app.schemas.auth import LoginRequest
from app.schemas.domain import ValidationCreate
from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
@ -229,6 +233,93 @@ def test_login_updates_last_login_at():
assert user.last_login_at is not None
def test_login_response_contains_user_and_expires_in():
from app.core.security import hash_password
from app.core.config import settings
db = session()
user = User(
email="response@schubamed.de",
first_name="Response",
last_name="Test",
role=UserRole.PRUEFER.value,
password_hash=hash_password("VerySecretPass123"),
is_active=True,
must_change_password=True,
)
db.add(user)
db.flush()
response = auth_login(LoginRequest(email="response@schubamed.de", password="VerySecretPass123"), db)
assert response.expires_in == settings.access_token_minutes * 60
assert response.user.email == "response@schubamed.de"
assert response.user.must_change_password is True
def test_login_rejects_inactive_user_with_403():
from app.core.security import hash_password
from fastapi import HTTPException
db = session()
user = User(
email="inactive@schubamed.de",
first_name="Inactive",
last_name="User",
role=UserRole.PRUEFER.value,
password_hash=hash_password("VerySecretPass123"),
is_active=False,
must_change_password=False,
)
db.add(user)
db.flush()
try:
AuthService(db).login("inactive@schubamed.de", "VerySecretPass123")
except HTTPException as exc:
assert exc.status_code == 403
else:
raise AssertionError("Inactive user was accepted")
def test_reference_masterdata_import_is_idempotent_and_links_entities(tmp_path):
db = session()
reference = next(
candidate
for parent in Path(__file__).resolve().parents
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
)
first = ReferenceMasterdataImportService(db).import_reference_docx(reference)
second = ReferenceMasterdataImportService(db).import_reference_docx(reference)
assert "customer" in first.created
assert "location" in first.created
assert "device" in first.created
assert first.validation_id is None
assert "customer" in second.unchanged
assert "device" in second.unchanged
assert db.scalar(select(Customer).where(Customer.name == "Urologische Praxis Dr. Durmaz")) is not None
assert db.scalar(select(Device).where(Device.serial_number == "EXN250688")) is not None
assert db.scalar(select(Equipment).where(Equipment.serial_number == "15125738")) is not None
def test_reference_masterdata_import_can_create_validation():
db = session()
reference = next(
candidate
for parent in Path(__file__).resolve().parents
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
)
result = ReferenceMasterdataImportService(db).import_reference_docx(reference, create_validation=True)
assert result.validation_id is not None
validation = db.get(Validation, result.validation_id)
assert validation is not None
assert validation.status == ValidationStatus.draft.value
def test_revalidation_date_uses_calendar_months():
db = session()
customer, location, device = seed(db)