Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc8763d57 |
+71
-1
@@ -3,6 +3,11 @@
|
||||
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||
und liefert den Benutzer – oder 401.
|
||||
|
||||
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
||||
ansprach, kam daran vorbei.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
@@ -11,6 +16,8 @@ from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .database import get_db
|
||||
from .models import Role, User
|
||||
from .schemas import UserOut
|
||||
from .services import policy as policy_service
|
||||
from .services import sessions
|
||||
|
||||
_credentials_exc = HTTPException(
|
||||
@@ -18,15 +25,71 @@ _credentials_exc = HTTPException(
|
||||
detail="Nicht angemeldet oder Sitzung abgelaufen",
|
||||
)
|
||||
|
||||
# Pfade, die trotz offener Pflicht erreichbar bleiben müssen – sonst könnte
|
||||
# niemand die Pflicht je erfüllen. Präfix-Vergleich, ohne führendes /api.
|
||||
_ALWAYS_ALLOWED = (
|
||||
"/health",
|
||||
"/version",
|
||||
"/auth/me",
|
||||
"/auth/logout",
|
||||
"/auth/login",
|
||||
"/auth/change-password",
|
||||
"/auth/2fa",
|
||||
)
|
||||
# Die Richtlinie darf gelesen werden (die Checkliste braucht sie), aber ein
|
||||
# Admin mit offener Pflicht soll sie nicht erst einmal entschärfen können.
|
||||
_READ_ONLY_ALLOWED = ("/policy",)
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
|
||||
def _session_user(request: Request, db: Session) -> User | None:
|
||||
"""Validiert die Sitzung genau einmal pro Anfrage und merkt sie sich.
|
||||
|
||||
``enforce_account_state`` läuft vor den Endpunkt-Abhängigkeiten; ohne diesen
|
||||
Zwischenspeicher würde ``sessions.validate`` (und damit der Schreibzugriff
|
||||
auf ``last_seen_at``) pro Anfrage zweimal laufen."""
|
||||
if hasattr(request.state, "session_user"):
|
||||
return request.state.session_user
|
||||
token = request.cookies.get(get_settings().cookie_name, "")
|
||||
user = sessions.validate(db, token)
|
||||
request.state.session_user = user
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = _session_user(request, db)
|
||||
if user is None:
|
||||
raise _credentials_exc
|
||||
return user
|
||||
|
||||
|
||||
def pending_reason(db: Session, user: User) -> str | None:
|
||||
"""Was der Benutzer erledigen muss, bevor er die App benutzen darf."""
|
||||
if user.must_change_password:
|
||||
return "password_change_required"
|
||||
if policy_service.load(db).mfa_required and not user.totp_enabled:
|
||||
return "mfa_setup_required"
|
||||
return None
|
||||
|
||||
|
||||
def enforce_account_state(request: Request, db: Session = Depends(get_db)) -> None:
|
||||
"""Router-weite Sperre für Konten mit offener Pflicht (403, sonst Durchlass).
|
||||
|
||||
Nicht angemeldete Anfragen laufen hier ungehindert durch – um die kümmert
|
||||
sich ``get_current_user`` am jeweiligen Endpunkt."""
|
||||
pfad = request.url.path
|
||||
rest = pfad[4:] if pfad.startswith("/api") else pfad
|
||||
if rest.startswith(_ALWAYS_ALLOWED):
|
||||
return
|
||||
if request.method == "GET" and rest.startswith(_READ_ONLY_ALLOWED):
|
||||
return
|
||||
user = _session_user(request, db)
|
||||
if user is None:
|
||||
return
|
||||
grund = pending_reason(db, user)
|
||||
if grund is not None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != Role.admin:
|
||||
raise HTTPException(
|
||||
@@ -36,6 +99,13 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
|
||||
def user_out(db: Session, user: User) -> UserOut:
|
||||
"""``UserOut`` inklusive des berechneten ``mfa_setup_required``."""
|
||||
out = UserOut.model_validate(user)
|
||||
out.mfa_setup_required = policy_service.load(db).mfa_required and not user.totp_enabled
|
||||
return out
|
||||
|
||||
|
||||
def get_owned(db: Session, model, obj_id: int, user: User):
|
||||
"""Holt eine Zeile und stellt sicher, dass sie dem Nutzer gehört – sonst 404.
|
||||
|
||||
|
||||
+8
-2
@@ -9,7 +9,7 @@ import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from .branding import APP_NAME, VERSION, git_sha
|
||||
from .config import get_settings
|
||||
from .database import SessionLocal
|
||||
from .deps import enforce_account_state
|
||||
from .migrate import upgrade_to_head
|
||||
from .routers import (
|
||||
accounts,
|
||||
@@ -27,6 +28,7 @@ from .routers import (
|
||||
dashboard,
|
||||
documents,
|
||||
imports,
|
||||
policy,
|
||||
rates,
|
||||
settings as settings_router,
|
||||
transactions,
|
||||
@@ -85,7 +87,10 @@ if settings.cors_origins.strip():
|
||||
)
|
||||
|
||||
# --- API -------------------------------------------------------------------
|
||||
api = APIRouter(prefix="/api")
|
||||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||||
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
||||
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
|
||||
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
|
||||
|
||||
|
||||
@api.get("/health", tags=["meta"])
|
||||
@@ -101,6 +106,7 @@ def version() -> dict:
|
||||
api.include_router(auth.router)
|
||||
api.include_router(users.router)
|
||||
api.include_router(settings_router.router)
|
||||
api.include_router(policy.router)
|
||||
api.include_router(accounts.router)
|
||||
api.include_router(categories.router)
|
||||
api.include_router(transactions.router)
|
||||
|
||||
@@ -119,14 +119,33 @@ class User(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
# Vom Admin mit Temporär-Passwort angelegt: beim ersten Login ändern lassen.
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# Warum der Wechsel fällig ist: "temp" (Admin-Passwort) oder "policy"
|
||||
# (Richtlinie wurde verschärft). Steuert nur den Hinweistext.
|
||||
must_change_password_reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
# --- Zwei-Faktor (TOTP) ---
|
||||
# Das Secret liegt im Klartext. Verschlüsseln brächte hier nichts: der
|
||||
# Schlüssel läge auf derselben Maschine wie die Datenbank. Wer die DB-Datei
|
||||
# hat, hätte dann auch den Schlüssel. Schutz kommt aus Dateirechten.
|
||||
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
totp_confirmed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Zuletzt verbrauchtes 30-Sekunden-Zeitfenster – verhindert, dass derselbe
|
||||
# Code innerhalb seiner Gültigkeit ein zweites Mal benutzt wird.
|
||||
totp_last_step: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
sessions: Mapped[list[Session]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
settings: Mapped[list[UserSetting]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
recovery_codes: Mapped[list[RecoveryCode]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
@@ -143,10 +162,46 @@ class Session(Base):
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Passwort stimmt, der zweite Faktor fehlt noch. Solche Sitzungen gelten
|
||||
# nirgends als angemeldet (siehe services/sessions.validate) und laufen
|
||||
# nach wenigen Minuten ab.
|
||||
pending_2fa: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
failed_2fa: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class RecoveryCode(Base):
|
||||
"""Einmal-Wiederherstellungscode für den Fall eines verlorenen Authenticators.
|
||||
|
||||
Gespeichert wird nur der SHA-256-Hash. Bewusst nicht Argon2: die Codes sind
|
||||
zufällig und hochentrop, und pro Prüfung stünden bis zu zehn Kandidaten an.
|
||||
"""
|
||||
|
||||
__tablename__ = "recovery_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="recovery_codes")
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Globale, vom Admin gepflegte Einstellungen – im Gegensatz zu
|
||||
``user_settings`` bewusst ohne ``owner_id``. Beherbergt heute die
|
||||
Passwortrichtlinie (siehe ``services/policy``)."""
|
||||
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class UserSetting(Base):
|
||||
"""Freie Schlüssel-Wert-Einstellungen je Benutzer.
|
||||
|
||||
|
||||
+206
-14
@@ -1,4 +1,12 @@
|
||||
"""Anmeldung über server-seitige Sitzung im Cookie."""
|
||||
"""Anmeldung über server-seitige Sitzung im Cookie, inklusive Zwei-Faktor.
|
||||
|
||||
Ablauf mit aktivem TOTP: ``/login`` prüft das Passwort und legt eine Sitzung an,
|
||||
die als ``pending_2fa`` markiert ist – die gilt nirgends als angemeldet. Erst
|
||||
``/2fa/verify`` tauscht sie gegen eine echte Sitzung (neues Cookie, damit kein
|
||||
vorab untergeschobenes Token weiterlebt).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
@@ -6,21 +14,32 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..deps import get_current_user, user_out
|
||||
from ..models import User
|
||||
from ..schemas import PasswordChange, UserOut
|
||||
from ..security import hash_password, verify_password
|
||||
from ..services import sessions
|
||||
from ..schemas import (
|
||||
LoginOut,
|
||||
PasswordChange,
|
||||
RecoveryCodesOut,
|
||||
TwoFactorActivate,
|
||||
TwoFactorPassword,
|
||||
TwoFactorSetupOut,
|
||||
TwoFactorStatusOut,
|
||||
TwoFactorVerify,
|
||||
UserOut,
|
||||
)
|
||||
from ..security import hash_password, needs_rehash, verify_password
|
||||
from ..services import policy as policy_service
|
||||
from ..services import sessions, twofactor
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, raw_token: str) -> None:
|
||||
def _set_session_cookie(response: Response, raw_token: str, max_age: int | None = None) -> None:
|
||||
settings = get_settings()
|
||||
response.set_cookie(
|
||||
key=settings.cookie_name,
|
||||
value=raw_token,
|
||||
max_age=settings.session_ttl_days * 86400,
|
||||
max_age=max_age if max_age is not None else settings.session_ttl_days * 86400,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite=settings.cookie_samesite,
|
||||
@@ -28,13 +47,13 @@ def _set_session_cookie(response: Response, raw_token: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
@router.post("/login", response_model=LoginOut)
|
||||
def login(
|
||||
response: Response,
|
||||
request: Request,
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
) -> LoginOut:
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
if user is None or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
@@ -46,9 +65,27 @@ def login(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Dieses Konto ist deaktiviert",
|
||||
)
|
||||
|
||||
# Einziger Moment, in dem das Passwort im Klartext vorliegt: hier lässt sich
|
||||
# prüfen, ob es die (womöglich verschärfte) Richtlinie noch erfüllt. Über den
|
||||
# gespeicherten Hash ginge das nicht.
|
||||
if policy_service.violations(form.password, policy_service.load(db)):
|
||||
user.must_change_password = True
|
||||
user.must_change_password_reason = "policy"
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(form.password)
|
||||
db.commit()
|
||||
|
||||
if user.totp_enabled:
|
||||
raw = sessions.create_session(
|
||||
db, user, request.headers.get("user-agent"), pending_2fa=True
|
||||
)
|
||||
_set_session_cookie(response, raw, max_age=sessions.PENDING_TTL_MINUTES * 60)
|
||||
return LoginOut(status="2fa_required", user=None)
|
||||
|
||||
raw = sessions.create_session(db, user, request.headers.get("user-agent"))
|
||||
_set_session_cookie(response, raw)
|
||||
return user
|
||||
return LoginOut(status="ok", user=user_out(db, user))
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -60,23 +97,178 @@ def logout(request: Request, response: Response, db: Session = Depends(get_db))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
def me(
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> UserOut:
|
||||
return user_out(db, current_user)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=UserOut)
|
||||
def change_password(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
) -> UserOut:
|
||||
if not verify_password(payload.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Aktuelles Passwort ist falsch",
|
||||
)
|
||||
if payload.new_password == payload.current_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Das neue Passwort muss sich vom bisherigen unterscheiden",
|
||||
)
|
||||
policy_service.assert_ok(db, payload.new_password)
|
||||
|
||||
current_user.password_hash = hash_password(payload.new_password)
|
||||
current_user.must_change_password = False
|
||||
current_user.must_change_password_reason = None
|
||||
db.commit()
|
||||
|
||||
# Ein Passwortwechsel soll alle übrigen Sitzungen beenden – wer das Passwort
|
||||
# kannte, soll danach draussen sein. Der aufrufende Client bekommt sofort
|
||||
# eine frische Sitzung, damit er nicht selbst herausfliegt.
|
||||
sessions.revoke_all(db, current_user)
|
||||
raw = sessions.create_session(db, current_user, request.headers.get("user-agent"))
|
||||
_set_session_cookie(response, raw)
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
return user_out(db, current_user)
|
||||
|
||||
|
||||
# --- Zwei-Faktor ------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/2fa/verify", response_model=LoginOut)
|
||||
def verify_two_factor(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: TwoFactorVerify,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginOut:
|
||||
"""Zweiter Schritt der Anmeldung: TOTP-Code oder Wiederherstellungscode."""
|
||||
token = request.cookies.get(get_settings().cookie_name, "")
|
||||
offen = sessions.validate_pending(db, token)
|
||||
if offen is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Anmeldung abgelaufen – bitte neu anmelden",
|
||||
)
|
||||
entry, user = offen
|
||||
|
||||
ok = (
|
||||
twofactor.consume_recovery_code(db, user, payload.code)
|
||||
if payload.recovery
|
||||
else twofactor.consume_code(db, user, payload.code)
|
||||
)
|
||||
if not ok:
|
||||
verbraucht = sessions.note_failed_2fa(db, entry)
|
||||
if verbraucht:
|
||||
response.delete_cookie(get_settings().cookie_name, path="/")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Zu viele Fehlversuche – bitte neu anmelden",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Code stimmt nicht"
|
||||
)
|
||||
|
||||
# Cookie tauschen statt weiterverwenden: die Pending-Sitzung verschwindet,
|
||||
# der Client bekommt ein frisches Token.
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
raw = sessions.create_session(db, user, request.headers.get("user-agent"))
|
||||
_set_session_cookie(response, raw)
|
||||
return LoginOut(status="ok", user=user_out(db, user))
|
||||
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFactorStatusOut)
|
||||
def two_factor_status(
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> TwoFactorStatusOut:
|
||||
return TwoFactorStatusOut(
|
||||
enabled=current_user.totp_enabled,
|
||||
recovery_codes_left=twofactor.codes_left(db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetupOut)
|
||||
def setup_two_factor(
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> TwoFactorSetupOut:
|
||||
"""Legt ein neues, noch nicht aktives Secret an und liefert den QR-Code."""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zwei-Faktor ist bereits aktiv",
|
||||
)
|
||||
secret = twofactor.new_secret()
|
||||
current_user.totp_secret = secret
|
||||
current_user.totp_last_step = None
|
||||
db.commit()
|
||||
uri = twofactor.otpauth_uri(current_user, secret)
|
||||
return TwoFactorSetupOut(secret=secret, otpauth_uri=uri, qr_png=twofactor.qr_data_uri(uri))
|
||||
|
||||
|
||||
@router.post("/2fa/activate", response_model=RecoveryCodesOut)
|
||||
def activate_two_factor(
|
||||
payload: TwoFactorActivate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RecoveryCodesOut:
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zwei-Faktor ist bereits aktiv",
|
||||
)
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zuerst die Einrichtung starten",
|
||||
)
|
||||
if not twofactor.consume_code(db, current_user, payload.code):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Code stimmt nicht")
|
||||
|
||||
current_user.totp_enabled = True
|
||||
current_user.totp_confirmed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
|
||||
|
||||
|
||||
@router.post("/2fa/recovery-codes", response_model=RecoveryCodesOut)
|
||||
def new_recovery_codes(
|
||||
payload: TwoFactorPassword,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RecoveryCodesOut:
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
|
||||
)
|
||||
if not verify_password(payload.password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
||||
)
|
||||
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
|
||||
|
||||
|
||||
@router.post("/2fa/disable", response_model=UserOut)
|
||||
def disable_two_factor(
|
||||
payload: TwoFactorPassword,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserOut:
|
||||
if not verify_password(payload.password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
||||
)
|
||||
if policy_service.load(db).mfa_required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Die Richtlinie verlangt Zwei-Faktor – Abschalten ist nicht möglich",
|
||||
)
|
||||
twofactor.disable(db, current_user)
|
||||
db.refresh(current_user)
|
||||
return user_out(db, current_user)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Backup & Export: kompletter DB-Download (Admin) und Datenexport je Nutzer."""
|
||||
"""Backup & Export: kompletter DB-Download (Admin), Datenexport je Nutzer und
|
||||
das wiederherstellbare Voll-Backup als JSON (siehe ``services/backup_data``)."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
@@ -6,8 +7,9 @@ import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTask
|
||||
@@ -23,17 +25,49 @@ from ..models import (
|
||||
CategoryGroup,
|
||||
DashboardLayout,
|
||||
PlannedIncomeLine,
|
||||
Role,
|
||||
Transaction,
|
||||
User,
|
||||
)
|
||||
from ..services import backup_data, sessions
|
||||
|
||||
router = APIRouter(tags=["backup"])
|
||||
|
||||
# Ein Backup ist reiner Text; alles darüber ist mit Sicherheit keins mehr.
|
||||
MAX_BACKUP_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def _sqlite_path() -> str | None:
|
||||
url = get_settings().effective_database_url
|
||||
return url.replace("sqlite:///", "", 1) if url.startswith("sqlite:///") else None
|
||||
|
||||
|
||||
def _snapshot_before_restore() -> str | None:
|
||||
"""Legt vor einem ersetzenden Restore eine Kopie der Datenbank ab.
|
||||
|
||||
Dieselbe Absicherung, die ``deploy/update.sh`` vor einem Update macht: wer
|
||||
versehentlich das falsche Backup einspielt, hat noch einen Weg zurück."""
|
||||
db_path = _sqlite_path()
|
||||
if db_path is None or not os.path.exists(db_path):
|
||||
return None
|
||||
ziel_ordner = Path(get_settings().data_dir) / "backups"
|
||||
ziel_ordner.mkdir(parents=True, exist_ok=True)
|
||||
ziel = ziel_ordner / f"pre-restore-{_ts()}.db"
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(str(ziel))
|
||||
try:
|
||||
with dst:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
return str(ziel)
|
||||
|
||||
|
||||
def _money(cents: int | None) -> str:
|
||||
return f"{(cents or 0) / 100:.2f}"
|
||||
|
||||
@@ -43,10 +77,9 @@ def _money(cents: int | None) -> str:
|
||||
|
||||
@router.get("/backup/db")
|
||||
def backup_db(_: User = Depends(require_admin)):
|
||||
url = get_settings().effective_database_url
|
||||
if not url.startswith("sqlite:///"):
|
||||
db_path = _sqlite_path()
|
||||
if db_path is None:
|
||||
raise HTTPException(status_code=400, detail="DB-Backup ist nur für SQLite verfügbar.")
|
||||
db_path = url.replace("sqlite:///", "", 1)
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="cf-backup-", suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
# Online-Backup-API: konsistente Kopie auch bei laufendem Betrieb.
|
||||
@@ -198,3 +231,131 @@ def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_curr
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'},
|
||||
)
|
||||
|
||||
|
||||
# --- Voll-Backup (wiederherstellbar) ---------------------------------------
|
||||
|
||||
|
||||
def _download(obj: dict, name: str) -> Response:
|
||||
return Response(
|
||||
content=backup_data.to_bytes(obj),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}-{_ts()}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/backup.json")
|
||||
def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Alle eigenen Daten – vollständig und wieder einspielbar."""
|
||||
return _download(backup_data.export_user(db, user), "certain-finance-backup")
|
||||
|
||||
|
||||
@router.get("/export/system-backup.json")
|
||||
def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
||||
"""Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und
|
||||
TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln."""
|
||||
return _download(backup_data.export_system(db), "certain-finance-system-backup")
|
||||
|
||||
|
||||
async def _read_upload(file: UploadFile) -> bytes:
|
||||
roh = await file.read()
|
||||
if len(roh) > MAX_BACKUP_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Die Datei ist zu gross für ein Backup.")
|
||||
return roh
|
||||
|
||||
|
||||
@router.post("/backup/restore/preview")
|
||||
async def restore_preview(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Prüft die Datei und meldet, was darin steckt. Schreibt nichts."""
|
||||
try:
|
||||
data = backup_data.parse(await _read_upload(file))
|
||||
except backup_data.BackupError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
scope = data["scope"]
|
||||
if scope == "system" and user.role != Role.admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Ein systemweites Backup darf nur ein Administrator einspielen.",
|
||||
)
|
||||
hinweise: list[str] = []
|
||||
if scope == "system":
|
||||
hinweise.append(
|
||||
"Systemweite Wiederherstellung: alle Benutzer und deren Daten werden "
|
||||
"ersetzt. Danach sind alle Sitzungen beendet und du musst dich neu anmelden."
|
||||
)
|
||||
if not any(u.get("role") == "admin" for u in data.get("users") or []):
|
||||
hinweise.append("Achtung: im Backup ist kein Administrator enthalten.")
|
||||
return {
|
||||
"scope": scope,
|
||||
"exported_at": data.get("exported_at"),
|
||||
"app_version": data.get("app_version"),
|
||||
"username": data.get("username"),
|
||||
"user_count": len(data.get("users") or []) if scope == "system" else 1,
|
||||
"tables": backup_data.summarize(data),
|
||||
"notes": hinweise,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backup/restore")
|
||||
async def restore(
|
||||
file: UploadFile = File(...),
|
||||
mode: str = Form("replace"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Spielt ein Backup ein – ``replace`` (ersetzen) oder ``merge`` (ergänzen).
|
||||
|
||||
Alles läuft in einer Transaktion: entweder es geht komplett durch oder es
|
||||
wird gar nichts geschrieben."""
|
||||
if mode not in ("replace", "merge"):
|
||||
raise HTTPException(status_code=400, detail="Unbekannter Modus.")
|
||||
try:
|
||||
data = backup_data.parse(await _read_upload(file))
|
||||
except backup_data.BackupError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
scope = data["scope"]
|
||||
if scope == "system" and user.role != Role.admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Ein systemweites Backup darf nur ein Administrator einspielen.",
|
||||
)
|
||||
if scope == "system" and mode != "replace":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Ein systemweites Backup kann nur ersetzend eingespielt werden.",
|
||||
)
|
||||
|
||||
schnappschuss = _snapshot_before_restore() if mode == "replace" else None
|
||||
try:
|
||||
if scope == "system":
|
||||
bilanz = backup_data.restore_system(db, data)
|
||||
else:
|
||||
bilanz = backup_data.restore_user_data(
|
||||
db, user, data.get("data") or {}, merge=(mode == "merge")
|
||||
)
|
||||
db.commit()
|
||||
except backup_data.BackupError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # pragma: no cover - Schutznetz gegen halbe Zustände
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Wiederherstellung fehlgeschlagen: {exc}"
|
||||
) from exc
|
||||
|
||||
if scope == "system":
|
||||
# Die eigene Sitzung wurde mitgelöscht; alle übrigen ebenfalls beenden.
|
||||
sessions.revoke_all_everywhere(db)
|
||||
return {
|
||||
"scope": scope,
|
||||
"mode": mode,
|
||||
"restored": bilanz,
|
||||
"snapshot": os.path.basename(schnappschuss) if schnappschuss else None,
|
||||
"logout_required": scope == "system",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Globale Passwortrichtlinie – lesen für jeden, ändern nur für Admins.
|
||||
|
||||
Lesbar muss sie für alle sein, weil die Anforderungs-Checkliste beim
|
||||
Passwortsetzen dieselben Werte braucht.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import User
|
||||
from ..schemas import PolicyIn, PolicyOut
|
||||
from ..services import policy as policy_service
|
||||
|
||||
router = APIRouter(prefix="/policy", tags=["policy"])
|
||||
|
||||
|
||||
@router.get("", response_model=PolicyOut)
|
||||
def get_policy(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> PolicyOut:
|
||||
return PolicyOut(**policy_service.load(db).as_dict())
|
||||
|
||||
|
||||
@router.put("", response_model=PolicyOut)
|
||||
def set_policy(
|
||||
payload: PolicyIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> PolicyOut:
|
||||
saved = policy_service.save(db, policy_service.Policy(**payload.model_dump()))
|
||||
return PolicyOut(**saved.as_dict())
|
||||
@@ -4,24 +4,28 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import require_admin
|
||||
from ..deps import require_admin, user_out
|
||||
from ..models import Role, User
|
||||
from ..schemas import UserCreate, UserOut, UserUpdate
|
||||
from ..security import hash_password
|
||||
from ..seed import ensure_starter_categories
|
||||
from ..services import policy as policy_service
|
||||
from ..services import sessions, twofactor
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||
return db.query(User).order_by(User.username).all()
|
||||
def list_users(db: Session = Depends(get_db)) -> list[UserOut]:
|
||||
rows = db.query(User).order_by(User.username).all()
|
||||
return [user_out(db, u) for u in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> UserOut:
|
||||
if db.query(User).filter(User.username == payload.username).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||
policy_service.assert_ok(db, payload.password)
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
@@ -30,24 +34,29 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
is_active=True,
|
||||
# Vom Admin gesetztes Temporär-Passwort: beim ersten Login ändern lassen.
|
||||
must_change_password=True,
|
||||
must_change_password_reason="temp",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Neuen Nutzern die Startkategorien mitgeben.
|
||||
ensure_starter_categories(db, user)
|
||||
return user
|
||||
return user_out(db, user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)) -> User:
|
||||
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)) -> UserOut:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
if payload.password is not None:
|
||||
policy_service.assert_ok(db, payload.password)
|
||||
user.password_hash = hash_password(payload.password)
|
||||
# Vom Admin gesetztes Passwort gilt als temporär.
|
||||
user.must_change_password = True
|
||||
user.must_change_password_reason = "temp"
|
||||
# Ein zurückgesetztes Passwort soll bestehende Sitzungen beenden.
|
||||
sessions.revoke_all(db, user)
|
||||
if payload.role is not None:
|
||||
user.role = payload.role
|
||||
if payload.display_name is not None:
|
||||
@@ -56,7 +65,22 @@ def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
|
||||
user.is_active = payload.is_active
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
return user_out(db, user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/2fa/reset", response_model=UserOut)
|
||||
def reset_two_factor(user_id: int, db: Session = Depends(get_db)) -> UserOut:
|
||||
"""Notausgang, wenn jemand seinen Authenticator und alle Codes verloren hat.
|
||||
|
||||
Verlangt die Richtlinie MFA, muss der Benutzer beim nächsten Anmelden sofort
|
||||
neu einrichten – dafür sorgt ``enforce_account_state``."""
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
twofactor.disable(db, user)
|
||||
sessions.revoke_all(db, user)
|
||||
db.refresh(user)
|
||||
return user_out(db, user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -34,7 +34,13 @@ class UserOut(BaseModel):
|
||||
display_name: str | None = None
|
||||
is_active: bool
|
||||
must_change_password: bool
|
||||
must_change_password_reason: str | None = None
|
||||
totp_enabled: bool = False
|
||||
created_at: datetime
|
||||
# Kein Datenbankfeld: „Richtlinie verlangt MFA und dieser Benutzer hat
|
||||
# keins". Wird beim Ausliefern gesetzt (siehe deps.user_out), damit das
|
||||
# Frontend ohne zweite Abfrage weiss, wohin es umleiten muss.
|
||||
mfa_setup_required: bool = False
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
@@ -61,6 +67,68 @@ class SettingOut(BaseModel):
|
||||
value: str | None = None
|
||||
|
||||
|
||||
# --- Passwortrichtlinie -----------------------------------------------------
|
||||
|
||||
|
||||
class PolicyOut(BaseModel):
|
||||
min_length: int
|
||||
require_upper: bool
|
||||
require_lower: bool
|
||||
require_digit: bool
|
||||
require_special: bool
|
||||
mfa_required: bool
|
||||
|
||||
|
||||
class PolicyIn(BaseModel):
|
||||
min_length: int = Field(ge=8, le=128)
|
||||
require_upper: bool = False
|
||||
require_lower: bool = False
|
||||
require_digit: bool = False
|
||||
require_special: bool = False
|
||||
mfa_required: bool = False
|
||||
|
||||
|
||||
# --- Anmeldung / Zwei-Faktor ------------------------------------------------
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
"""Antwort auf ``POST /auth/login``.
|
||||
|
||||
``status`` ist ``"ok"`` (angemeldet, ``user`` gesetzt) oder ``"2fa_required"``
|
||||
(Passwort stimmt, es fehlt noch der Code – ``user`` bleibt leer)."""
|
||||
|
||||
status: str = "ok"
|
||||
user: UserOut | None = None
|
||||
|
||||
|
||||
class TwoFactorVerify(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
recovery: bool = False
|
||||
|
||||
|
||||
class TwoFactorSetupOut(BaseModel):
|
||||
secret: str
|
||||
otpauth_uri: str
|
||||
qr_png: str # data:-URI, direkt in <img src=...> verwendbar
|
||||
|
||||
|
||||
class TwoFactorActivate(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class TwoFactorPassword(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class RecoveryCodesOut(BaseModel):
|
||||
codes: list[str]
|
||||
|
||||
|
||||
class TwoFactorStatusOut(BaseModel):
|
||||
enabled: bool
|
||||
recovery_codes_left: int
|
||||
|
||||
|
||||
# --- Konten ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Voll-Backup als JSON: Export und Wiederherstellung.
|
||||
|
||||
Warum ein eigenes Format neben ``/export/all.json``: jener Export ist zum
|
||||
Ansehen gedacht und referenziert alles über Namen. Kategorienamen sind aber
|
||||
nicht eindeutig, mehrere Tabellen fehlen ganz, und eine Version steht auch nicht
|
||||
drin – zurückladen lässt sich das nicht verlustfrei.
|
||||
|
||||
Hier wird stattdessen tabellengetrieben gearbeitet: jede Zeile bringt ihre
|
||||
Quell-ID als ``local_id`` mit, Fremdschlüssel bleiben IDs. Beim Einspielen wird
|
||||
eine Abbildung ``local_id -> neue ID`` aufgebaut und jeder Verweis übersetzt.
|
||||
Das ist unabhängig von Namen und überlebt auch doppelte Bezeichnungen.
|
||||
|
||||
Zwei Reichweiten:
|
||||
|
||||
``user`` – die Daten eines Benutzers. Jeder kann sein eigenes Backup ziehen.
|
||||
``system`` – alle Benutzer inklusive Passwort-Hash und TOTP-Secret, dazu die
|
||||
globalen Tabellen. Nur für Admins; die Datei ist wie ein Passwort
|
||||
zu behandeln.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date as date_t, datetime, time as time_t
|
||||
from decimal import Decimal
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Numeric, Time
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..branding import VERSION
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountIdentifier,
|
||||
AppSetting,
|
||||
BudgetPeriod,
|
||||
BudgetPeriodLine,
|
||||
BudgetTemplateLine,
|
||||
Category,
|
||||
CategoryGroup,
|
||||
CategorizationRule,
|
||||
DashboardLayout,
|
||||
ExchangeRate,
|
||||
ImportBatch,
|
||||
ImportTemplate,
|
||||
PlannedIncomeLine,
|
||||
RecoveryCode,
|
||||
Transaction,
|
||||
User,
|
||||
UserSetting,
|
||||
)
|
||||
|
||||
FORMAT_NAME = "certain-finance-backup"
|
||||
SCHEMA_VERSION = 2
|
||||
ALEMBIC_REVISION = "0008"
|
||||
|
||||
|
||||
class BackupError(ValueError):
|
||||
"""Fachlicher Fehler beim Lesen oder Einspielen eines Backups."""
|
||||
|
||||
|
||||
# --- Tabellenbeschreibung ---------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableSpec:
|
||||
key: str # Name im JSON
|
||||
model: type # ORM-Klasse
|
||||
scope: str # owner | user | account | period | global
|
||||
refs: dict[str, str] = field(default_factory=dict) # Spalte -> Tabellen-key
|
||||
has_id: bool = True # False bei Tabellen mit natürlichem Schlüssel
|
||||
label: str = "" # Anzeigename für die Prüfansicht
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.label:
|
||||
self.label = self.key
|
||||
|
||||
|
||||
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
|
||||
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
|
||||
USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("accounts", Account, "owner", label="Konten"),
|
||||
TableSpec("account_identifiers", AccountIdentifier, "account",
|
||||
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
||||
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
|
||||
TableSpec("categories", Category, "owner",
|
||||
{"group_id": "category_groups"}, label="Kategorien"),
|
||||
TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
|
||||
TableSpec("import_batches", ImportBatch, "owner",
|
||||
{"account_id": "accounts", "template_id": "import_templates"},
|
||||
label="Import-Läufe"),
|
||||
TableSpec("transactions", Transaction, "owner", {
|
||||
"account_id": "accounts",
|
||||
"to_account_id": "accounts",
|
||||
"category_id": "categories",
|
||||
"budget_category_id": "categories",
|
||||
"import_batch_id": "import_batches",
|
||||
}, label="Buchungen"),
|
||||
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
||||
{"category_id": "categories"}, label="Budget-Vorlage"),
|
||||
TableSpec("planned_income_lines", PlannedIncomeLine, "owner",
|
||||
{"category_id": "categories", "target_account_id": "accounts"},
|
||||
label="Geplante Einnahmen"),
|
||||
TableSpec("budget_periods", BudgetPeriod, "owner", label="Budget-Monate"),
|
||||
TableSpec("budget_period_lines", BudgetPeriodLine, "period",
|
||||
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
||||
label="Budget-Monatswerte"),
|
||||
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
|
||||
TableSpec("categorization_rules", CategorizationRule, "owner",
|
||||
{"category_id": "categories"}, label="Schlagwortregeln"),
|
||||
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
||||
label="Persönliche Einstellungen"),
|
||||
]
|
||||
|
||||
# Nur im systemweiten Backup, je Benutzer.
|
||||
SYSTEM_USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
|
||||
]
|
||||
|
||||
# Globale Tabellen (nicht benutzergebunden), nur im systemweiten Backup.
|
||||
GLOBAL_TABLES: list[TableSpec] = [
|
||||
TableSpec("app_settings", AppSetting, "global", has_id=False, label="Globale Einstellungen"),
|
||||
TableSpec("exchange_rates", ExchangeRate, "global", has_id=False, label="Wechselkurse"),
|
||||
]
|
||||
|
||||
# Spalten, die nie exportiert werden: sie werden beim Einspielen neu gesetzt.
|
||||
_SKIP_COLUMNS = {"id", "owner_id", "user_id"}
|
||||
|
||||
|
||||
# --- Werte umwandeln --------------------------------------------------------
|
||||
|
||||
|
||||
def _dump_value(col, value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(col.type, SAEnum):
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(col.type, Date) or isinstance(value, date_t):
|
||||
return value.isoformat()
|
||||
if isinstance(col.type, Time) or isinstance(value, time_t):
|
||||
return value.isoformat()
|
||||
if isinstance(col.type, Numeric) or isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(col.type, Boolean):
|
||||
return bool(value)
|
||||
return value
|
||||
|
||||
|
||||
def _load_value(col, value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
||||
return col.type.enum_class(value)
|
||||
if isinstance(col.type, DateTime):
|
||||
return datetime.fromisoformat(value)
|
||||
if isinstance(col.type, Date):
|
||||
return date_t.fromisoformat(value)
|
||||
if isinstance(col.type, Time):
|
||||
return time_t.fromisoformat(value)
|
||||
if isinstance(col.type, Numeric):
|
||||
return Decimal(str(value))
|
||||
if isinstance(col.type, Boolean):
|
||||
return bool(value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise BackupError(f"Unlesbarer Wert für '{col.key}': {value!r}") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _columns(spec: TableSpec):
|
||||
"""Zu sichernde Spalten – ohne technische IDs, die neu vergeben werden."""
|
||||
return [c for c in sa_inspect(spec.model).columns if c.key not in _SKIP_COLUMNS]
|
||||
|
||||
|
||||
# --- Export -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _query(db: Session, spec: TableSpec, user: User | None):
|
||||
q = db.query(spec.model)
|
||||
if spec.scope == "owner":
|
||||
q = q.filter(spec.model.owner_id == user.id)
|
||||
elif spec.scope == "user":
|
||||
q = q.filter(spec.model.user_id == user.id)
|
||||
elif spec.scope == "account":
|
||||
q = q.join(Account, spec.model.account_id == Account.id).filter(
|
||||
Account.owner_id == user.id
|
||||
)
|
||||
elif spec.scope == "period":
|
||||
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
|
||||
BudgetPeriod.owner_id == user.id
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def _dump_table(db: Session, spec: TableSpec, user: User | None) -> list[dict]:
|
||||
cols = _columns(spec)
|
||||
rows = []
|
||||
for obj in _query(db, spec, user).all():
|
||||
row = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
||||
if spec.has_id:
|
||||
row["local_id"] = obj.id
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _dump_user_data(db: Session, user: User, specs: list[TableSpec]) -> dict:
|
||||
return {spec.key: _dump_table(db, spec, user) for spec in specs}
|
||||
|
||||
|
||||
def _envelope(scope: str, payload: dict) -> dict:
|
||||
return {
|
||||
"format": FORMAT_NAME,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"app_version": VERSION,
|
||||
"alembic_revision": ALEMBIC_REVISION,
|
||||
"exported_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"scope": scope,
|
||||
**payload,
|
||||
}
|
||||
|
||||
|
||||
def export_user(db: Session, user: User) -> dict:
|
||||
"""Backup der Daten eines Benutzers."""
|
||||
return _envelope("user", {
|
||||
"username": user.username,
|
||||
"data": _dump_user_data(db, user, USER_TABLES),
|
||||
})
|
||||
|
||||
|
||||
def export_system(db: Session) -> dict:
|
||||
"""Backup der ganzen Instanz inklusive Anmeldegeheimnissen (nur Admin)."""
|
||||
user_cols = _columns(TableSpec("users", User, "global"))
|
||||
benutzer = []
|
||||
for u in db.query(User).order_by(User.id).all():
|
||||
satz = {c.key: _dump_value(c, getattr(u, c.key)) for c in user_cols}
|
||||
satz["local_id"] = u.id
|
||||
satz["data"] = _dump_user_data(db, u, USER_TABLES + SYSTEM_USER_TABLES)
|
||||
benutzer.append(satz)
|
||||
return _envelope("system", {
|
||||
"users": benutzer,
|
||||
"global": {spec.key: _dump_table(db, spec, None) for spec in GLOBAL_TABLES},
|
||||
})
|
||||
|
||||
|
||||
def to_bytes(obj: dict) -> bytes:
|
||||
return json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
# --- Umschlag prüfen --------------------------------------------------------
|
||||
|
||||
|
||||
def parse(raw: bytes) -> dict:
|
||||
"""Liest eine hochgeladene Datei und prüft den Umschlag."""
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackupError("Die Datei ist kein lesbares JSON.") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise BackupError("Unerwarteter Dateiinhalt – erwartet wird ein JSON-Objekt.")
|
||||
if data.get("format") != FORMAT_NAME:
|
||||
# Der alte, nur zum Ansehen gedachte Export – klar benennen statt
|
||||
# halbherzig zu raten.
|
||||
if "accounts" in data and "category_groups" in data:
|
||||
raise BackupError(
|
||||
"Das ist der Ansichts-Export (all.json). Er lässt sich nicht "
|
||||
"wiederherstellen – bitte ein Backup über „Voll-Backup“ erzeugen."
|
||||
)
|
||||
raise BackupError("Das ist kein Backup von Certain Finance.")
|
||||
version = data.get("schema_version")
|
||||
if version != SCHEMA_VERSION:
|
||||
raise BackupError(
|
||||
f"Backup-Version {version} wird nicht unterstützt "
|
||||
f"(diese Fassung liest Version {SCHEMA_VERSION})."
|
||||
)
|
||||
if data.get("scope") not in ("user", "system"):
|
||||
raise BackupError("Unbekannte Reichweite im Backup.")
|
||||
return data
|
||||
|
||||
|
||||
def summarize(data: dict) -> list[dict]:
|
||||
"""Zählt je Tabelle, was in der Datei steckt – für die Prüfansicht."""
|
||||
if data["scope"] == "user":
|
||||
haeufen = [data.get("data", {})]
|
||||
else:
|
||||
haeufen = [u.get("data", {}) for u in data.get("users", [])]
|
||||
|
||||
zeilen = []
|
||||
for spec in USER_TABLES + SYSTEM_USER_TABLES:
|
||||
anzahl = sum(len(h.get(spec.key) or []) for h in haeufen)
|
||||
if anzahl:
|
||||
zeilen.append({"key": spec.key, "label": spec.label, "count": anzahl})
|
||||
if data["scope"] == "system":
|
||||
zeilen.insert(0, {"key": "users", "label": "Benutzer", "count": len(data.get("users", []))})
|
||||
for spec in GLOBAL_TABLES:
|
||||
anzahl = len(data.get("global", {}).get(spec.key) or [])
|
||||
if anzahl:
|
||||
zeilen.append({"key": spec.key, "label": spec.label, "count": anzahl})
|
||||
return zeilen
|
||||
|
||||
|
||||
# --- Einspielen -------------------------------------------------------------
|
||||
|
||||
# Wie im Modus „Ergänzen" entschieden wird, ob eine Zeile schon da ist.
|
||||
# Rückgabe: Schlüssel, unter dem verglichen wird – oder None für „immer neu".
|
||||
_MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
|
||||
"accounts": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||
# Ohne diesen Eintrag würde ein zweiter Ergänzen-Lauf die Eindeutigkeit
|
||||
# (account_id, kind, value_norm) verletzen.
|
||||
"account_identifiers": lambda r: ("ident", r.get("account_id"), r.get("kind"),
|
||||
r.get("value_norm")),
|
||||
"category_groups": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||
"categories": lambda r: ("group", r.get("group_id"), (r.get("name") or "").strip().lower()),
|
||||
"import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||
"import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"),
|
||||
r.get("created_at")),
|
||||
"transactions": lambda r: (
|
||||
("hash", r["import_hash"]) if r.get("import_hash")
|
||||
else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"),
|
||||
(r.get("payee") or "").strip().lower())
|
||||
),
|
||||
}
|
||||
|
||||
# Tabellen, die im Modus „Ergänzen" nur dann übernommen werden, wenn der
|
||||
# Benutzer davon noch gar nichts hat. Zeilenweise mischen ergäbe hier Unsinn
|
||||
# (halbe Budgets, doppelte Layouts).
|
||||
_ALL_OR_NOTHING = {
|
||||
"budget_template_lines",
|
||||
"planned_income_lines",
|
||||
"budget_periods",
|
||||
"budget_period_lines",
|
||||
"dashboard_layouts",
|
||||
"categorization_rules",
|
||||
"user_settings",
|
||||
}
|
||||
|
||||
|
||||
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int]:
|
||||
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID."""
|
||||
schluessel = _MERGE_KEYS.get(spec.key)
|
||||
if schluessel is None:
|
||||
return {}
|
||||
cols = _columns(spec)
|
||||
treffer: dict[tuple, int] = {}
|
||||
for obj in _query(db, spec, user).all():
|
||||
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
||||
k = schluessel(roh)
|
||||
if k is not None:
|
||||
treffer.setdefault(k, obj.id)
|
||||
return treffer
|
||||
|
||||
|
||||
def _delete_user_data(db: Session, user: User, specs: list[TableSpec]) -> None:
|
||||
"""Löscht die Daten eines Benutzers in fremdschlüsselsicherer Reihenfolge."""
|
||||
for spec in reversed(specs):
|
||||
for obj in _query(db, spec, user).all():
|
||||
db.delete(obj)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _insert_rows(
|
||||
db: Session, spec: TableSpec, user: User, rows: list[dict], maps: dict[str, dict[int, int]],
|
||||
merge: bool,
|
||||
) -> int:
|
||||
cols = {c.key: c for c in _columns(spec)}
|
||||
id_map = maps.setdefault(spec.key, {})
|
||||
vorhanden = _existing_keys(db, spec, user) if merge else {}
|
||||
schluessel = _MERGE_KEYS.get(spec.key)
|
||||
neu = 0
|
||||
|
||||
for row in rows:
|
||||
# Verweise zuerst übersetzen – sie fließen auch in den Vergleichsschlüssel ein.
|
||||
aufgeloest = dict(row)
|
||||
for spalte, ziel in spec.refs.items():
|
||||
alt = row.get(spalte)
|
||||
aufgeloest[spalte] = maps.get(ziel, {}).get(alt) if alt is not None else None
|
||||
|
||||
if merge and schluessel is not None:
|
||||
k = schluessel(aufgeloest)
|
||||
if k is not None and k in vorhanden:
|
||||
# Nicht anlegen, aber den Verweis auf die bestehende Zeile merken.
|
||||
if spec.has_id and row.get("local_id") is not None:
|
||||
id_map[row["local_id"]] = vorhanden[k]
|
||||
continue
|
||||
|
||||
werte = {}
|
||||
for name, col in cols.items():
|
||||
if name in spec.refs:
|
||||
werte[name] = aufgeloest[name]
|
||||
else:
|
||||
werte[name] = _load_value(col, row.get(name))
|
||||
if spec.scope in ("owner",):
|
||||
werte["owner_id"] = user.id
|
||||
elif spec.scope == "user":
|
||||
werte["user_id"] = user.id
|
||||
|
||||
obj = spec.model(**werte)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
if spec.has_id and row.get("local_id") is not None:
|
||||
id_map[row["local_id"]] = obj.id
|
||||
if merge and schluessel is not None:
|
||||
k = schluessel(aufgeloest)
|
||||
if k is not None:
|
||||
vorhanden[k] = obj.id
|
||||
neu += 1
|
||||
return neu
|
||||
|
||||
|
||||
def restore_user_data(
|
||||
db: Session, user: User, data: dict, merge: bool, specs: list[TableSpec] | None = None
|
||||
) -> dict[str, int]:
|
||||
"""Spielt den Datenblock eines Benutzers ein. Ruft **kein** commit."""
|
||||
specs = specs or USER_TABLES
|
||||
if not merge:
|
||||
_delete_user_data(db, user, specs)
|
||||
|
||||
maps: dict[str, dict[int, int]] = {}
|
||||
bilanz: dict[str, int] = {}
|
||||
for spec in specs:
|
||||
rows = data.get(spec.key) or []
|
||||
if merge and spec.key in _ALL_OR_NOTHING:
|
||||
# Nur übernehmen, wenn dazu noch nichts existiert.
|
||||
if _query(db, spec, user).first() is not None:
|
||||
bilanz[spec.key] = 0
|
||||
continue
|
||||
bilanz[spec.key] = _insert_rows(db, spec, user, rows, maps, merge)
|
||||
return bilanz
|
||||
|
||||
|
||||
def restore_system(db: Session, data: dict) -> dict[str, int]:
|
||||
"""Baut die ganze Instanz aus einem systemweiten Backup neu auf.
|
||||
|
||||
Immer ersetzend – ein teilweises Verschmelzen fremder Benutzerkonten wäre
|
||||
nicht sinnvoll deutbar. Ruft **kein** commit; das übernimmt der Aufrufer,
|
||||
damit im Fehlerfall nichts halb eingespielt zurückbleibt."""
|
||||
benutzer = data.get("users") or []
|
||||
if not any(u.get("role") == "admin" for u in benutzer):
|
||||
raise BackupError(
|
||||
"Das Backup enthält keinen Administrator – damit käme niemand mehr hinein."
|
||||
)
|
||||
|
||||
# Benutzer weg: alles Benutzergebundene hängt per ON DELETE CASCADE daran.
|
||||
for u in db.query(User).all():
|
||||
db.delete(u)
|
||||
db.flush()
|
||||
for spec in GLOBAL_TABLES:
|
||||
for obj in db.query(spec.model).all():
|
||||
db.delete(obj)
|
||||
db.flush()
|
||||
|
||||
user_cols = {c.key: c for c in _columns(TableSpec("users", User, "global"))}
|
||||
bilanz: dict[str, int] = {"users": 0}
|
||||
for satz in benutzer:
|
||||
werte = {k: _load_value(col, satz.get(k)) for k, col in user_cols.items()}
|
||||
neuer = User(**werte)
|
||||
db.add(neuer)
|
||||
db.flush()
|
||||
bilanz["users"] += 1
|
||||
teil = restore_user_data(
|
||||
db, neuer, satz.get("data") or {}, merge=False,
|
||||
specs=USER_TABLES + SYSTEM_USER_TABLES,
|
||||
)
|
||||
for key, anzahl in teil.items():
|
||||
bilanz[key] = bilanz.get(key, 0) + anzahl
|
||||
|
||||
global_data = data.get("global") or {}
|
||||
for spec in GLOBAL_TABLES:
|
||||
cols = {c.key: c for c in _columns(spec)}
|
||||
rows = global_data.get(spec.key) or []
|
||||
for row in rows:
|
||||
db.add(spec.model(**{k: _load_value(col, row.get(k)) for k, col in cols.items()}))
|
||||
bilanz[spec.key] = len(rows)
|
||||
db.flush()
|
||||
return bilanz
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Passwortrichtlinie: global vom Admin einstellbar, serverseitig durchgesetzt.
|
||||
|
||||
Die Werte liegen in ``app_settings`` (nicht in ``user_settings`` – die Richtlinie
|
||||
gilt für die ganze Instanz). Das Frontend wertet dieselben Regeln nochmal lokal
|
||||
aus, um beim Tippen eine Checkliste abzuhaken; verbindlich ist ausschließlich
|
||||
diese Datei.
|
||||
|
||||
Zur wiederkehrenden Frage „kann man bestehende Passwörter gegen eine neue
|
||||
Richtlinie prüfen?": nicht über den Hash – der gibt das Passwort nicht her. Wohl
|
||||
aber beim Anmelden, denn dort liegt das Klartext-Passwort für einen Moment vor.
|
||||
Genau das macht ``routers/auth.login``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import AppSetting
|
||||
|
||||
# Grenzen für die Mindestlänge – nach unten, damit die Richtlinie nicht sinnlos
|
||||
# wird, nach oben, damit sich niemand selbst aussperrt.
|
||||
MIN_LENGTH_FLOOR = 8
|
||||
MIN_LENGTH_CEILING = 128
|
||||
|
||||
|
||||
def is_special(c: str) -> bool:
|
||||
"""Sonderzeichen = alles, was weder Buchstabe/Ziffer noch Leerraum ist.
|
||||
|
||||
Bewusst nicht über eine feste ASCII-Liste: „§" oder „€" sollen zählen,
|
||||
Umlaute wie „ü" dagegen nicht (die sind Buchstaben)."""
|
||||
return not c.isalnum() and not c.isspace()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Policy:
|
||||
min_length: int = 10
|
||||
require_upper: bool = True
|
||||
require_lower: bool = True
|
||||
require_digit: bool = True
|
||||
require_special: bool = False
|
||||
mfa_required: bool = False
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# Schlüssel in app_settings <-> Feldname im Datenobjekt.
|
||||
_KEYS = {
|
||||
"password_min_length": "min_length",
|
||||
"password_require_upper": "require_upper",
|
||||
"password_require_lower": "require_lower",
|
||||
"password_require_digit": "require_digit",
|
||||
"password_require_special": "require_special",
|
||||
"mfa_required": "mfa_required",
|
||||
}
|
||||
|
||||
# Menschenlesbare Begründung je Regel – wird bei Verstößen zusammengesetzt.
|
||||
RULE_TEXTS = {
|
||||
"length": "mindestens {n} Zeichen",
|
||||
"upper": "mindestens einen Grossbuchstaben",
|
||||
"lower": "mindestens einen Kleinbuchstaben",
|
||||
"digit": "mindestens eine Ziffer",
|
||||
"special": "mindestens ein Sonderzeichen",
|
||||
}
|
||||
|
||||
|
||||
def _clamp_length(value: int) -> int:
|
||||
return max(MIN_LENGTH_FLOOR, min(MIN_LENGTH_CEILING, value))
|
||||
|
||||
|
||||
def load(db: Session) -> Policy:
|
||||
"""Liest die Richtlinie; fehlende Schlüssel fallen auf die Vorgaben zurück."""
|
||||
stored = {row.key: row.value for row in db.query(AppSetting).all()}
|
||||
pol = Policy()
|
||||
for key, field in _KEYS.items():
|
||||
raw = stored.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
if field == "min_length":
|
||||
try:
|
||||
pol.min_length = _clamp_length(int(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
setattr(pol, field, raw == "true")
|
||||
return pol
|
||||
|
||||
|
||||
def save(db: Session, pol: Policy) -> Policy:
|
||||
"""Schreibt die Richtlinie; die Mindestlänge wird auf den erlaubten Bereich begrenzt."""
|
||||
pol.min_length = _clamp_length(pol.min_length)
|
||||
values = pol.as_dict()
|
||||
for key, field in _KEYS.items():
|
||||
value = values[field]
|
||||
text = str(value) if field == "min_length" else ("true" if value else "false")
|
||||
row = db.get(AppSetting, key)
|
||||
if row is None:
|
||||
db.add(AppSetting(key=key, value=text))
|
||||
else:
|
||||
row.value = text
|
||||
db.commit()
|
||||
return pol
|
||||
|
||||
|
||||
def violations(password: str, pol: Policy) -> list[str]:
|
||||
"""Gibt die Kennungen der *nicht* erfüllten Regeln zurück (leer = in Ordnung)."""
|
||||
offen: list[str] = []
|
||||
if len(password) < pol.min_length:
|
||||
offen.append("length")
|
||||
if pol.require_upper and not any(c.isupper() for c in password):
|
||||
offen.append("upper")
|
||||
if pol.require_lower and not any(c.islower() for c in password):
|
||||
offen.append("lower")
|
||||
if pol.require_digit and not any(c.isdigit() for c in password):
|
||||
offen.append("digit")
|
||||
if pol.require_special and not any(is_special(c) for c in password):
|
||||
offen.append("special")
|
||||
return offen
|
||||
|
||||
|
||||
def assert_ok(db: Session, password: str) -> None:
|
||||
"""Wirft 400 mit einer Klartext-Begründung, wenn das Passwort nicht genügt."""
|
||||
pol = load(db)
|
||||
offen = violations(password, pol)
|
||||
if not offen:
|
||||
return
|
||||
teile = [RULE_TEXTS[r].format(n=pol.min_length) for r in offen]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Das Passwort erfüllt die Richtlinie nicht: " + ", ".join(teile) + ".",
|
||||
)
|
||||
@@ -23,24 +23,39 @@ def _aware(dt: datetime) -> datetime:
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def create_session(db: DBSession, user: User, user_agent: str | None = None) -> str:
|
||||
# Wie lange der Zwischenzustand „Passwort ok, Code fehlt" gilt. Kurz, weil in
|
||||
# dieser Zeit nur noch der zweite Faktor eingegeben werden soll.
|
||||
PENDING_TTL_MINUTES = 10
|
||||
# So viele Fehlversuche verträgt eine Pending-Sitzung, dann ist sie verbraucht.
|
||||
MAX_2FA_ATTEMPTS = 5
|
||||
|
||||
|
||||
def create_session(
|
||||
db: DBSession, user: User, user_agent: str | None = None, pending_2fa: bool = False
|
||||
) -> str:
|
||||
"""Legt eine Sitzung an und gibt das Klartext-Token zurück (nur hier sichtbar)."""
|
||||
settings = get_settings()
|
||||
gueltig_bis = (
|
||||
_now() + timedelta(minutes=PENDING_TTL_MINUTES)
|
||||
if pending_2fa
|
||||
else _now() + timedelta(days=settings.session_ttl_days)
|
||||
)
|
||||
raw = generate_session_token()
|
||||
entry = UserSession(
|
||||
user_id=user.id,
|
||||
token_hash=hash_session_token(raw),
|
||||
expires_at=_now() + timedelta(days=settings.session_ttl_days),
|
||||
expires_at=gueltig_bis,
|
||||
last_seen_at=_now(),
|
||||
user_agent=(user_agent or "")[:255] or None,
|
||||
pending_2fa=pending_2fa,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def validate(db: DBSession, raw_token: str) -> User | None:
|
||||
"""Gibt den Benutzer zurück, wenn das Token gültig und nicht abgelaufen ist."""
|
||||
def _lookup(db: DBSession, raw_token: str) -> UserSession | None:
|
||||
"""Sucht die Sitzung und räumt sie weg, falls sie abgelaufen ist."""
|
||||
if not raw_token:
|
||||
return None
|
||||
entry = (
|
||||
@@ -54,6 +69,18 @@ def validate(db: DBSession, raw_token: str) -> User | None:
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def validate(db: DBSession, raw_token: str) -> User | None:
|
||||
"""Gibt den Benutzer zurück, wenn das Token gültig und nicht abgelaufen ist.
|
||||
|
||||
Sitzungen, bei denen der zweite Faktor noch aussteht, gelten hier bewusst
|
||||
als *nicht* angemeldet – sonst wäre das Zwischen-Cookie ein vollwertiger
|
||||
Zugang."""
|
||||
entry = _lookup(db, raw_token)
|
||||
if entry is None or entry.pending_2fa:
|
||||
return None
|
||||
user = db.get(User, entry.user_id)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
@@ -62,6 +89,27 @@ def validate(db: DBSession, raw_token: str) -> User | None:
|
||||
return user
|
||||
|
||||
|
||||
def validate_pending(db: DBSession, raw_token: str) -> tuple[UserSession, User] | None:
|
||||
"""Gegenstück zu ``validate`` für den Zwei-Faktor-Schritt."""
|
||||
entry = _lookup(db, raw_token)
|
||||
if entry is None or not entry.pending_2fa:
|
||||
return None
|
||||
user = db.get(User, entry.user_id)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return entry, user
|
||||
|
||||
|
||||
def note_failed_2fa(db: DBSession, entry: UserSession) -> bool:
|
||||
"""Zählt einen Fehlversuch. Gibt ``True`` zurück, wenn die Sitzung verfällt."""
|
||||
entry.failed_2fa += 1
|
||||
verbraucht = entry.failed_2fa >= MAX_2FA_ATTEMPTS
|
||||
if verbraucht:
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return verbraucht
|
||||
|
||||
|
||||
def revoke(db: DBSession, raw_token: str) -> None:
|
||||
entry = (
|
||||
db.query(UserSession)
|
||||
@@ -76,3 +124,10 @@ def revoke(db: DBSession, raw_token: str) -> None:
|
||||
def revoke_all(db: DBSession, user: User) -> None:
|
||||
db.query(UserSession).filter(UserSession.user_id == user.id).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
def revoke_all_everywhere(db: DBSession) -> None:
|
||||
"""Beendet sämtliche Sitzungen – nach einer systemweiten Wiederherstellung,
|
||||
bei der die Benutzertabelle komplett ausgetauscht wurde."""
|
||||
db.query(UserSession).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Zwei-Faktor per TOTP (Authenticator-App) plus Einmal-Wiederherstellungscodes.
|
||||
|
||||
Der QR-Code wird hier serverseitig als PNG-Data-URI erzeugt. Das spart eine
|
||||
JavaScript-Abhängigkeit im Frontend und erspart es, fremdes SVG ins DOM zu
|
||||
schreiben – das Bild landet einfach in einem ``<img src=…>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..branding import APP_NAME
|
||||
from ..models import RecoveryCode, User
|
||||
from ..security import hash_session_token
|
||||
|
||||
STEP_SECONDS = 30
|
||||
# Ein Schritt Toleranz nach vorn und hinten – deckt übliche Uhrendrift ab,
|
||||
# ohne das Zeitfenster unnötig aufzuweiten.
|
||||
VALID_WINDOW = 1
|
||||
|
||||
RECOVERY_CODE_COUNT = 10
|
||||
RECOVERY_CODE_LENGTH = 10
|
||||
# Ohne 0/O/1/I/L – abgeschriebene Codes sollen nicht an Verwechslungen scheitern.
|
||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def otpauth_uri(user: User, secret: str) -> str:
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=user.username, issuer_name=APP_NAME)
|
||||
|
||||
|
||||
def qr_data_uri(uri: str) -> str:
|
||||
"""QR-Code als ``data:image/png;base64,…``."""
|
||||
img = qrcode.make(uri)
|
||||
puffer = io.BytesIO()
|
||||
img.save(puffer, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(puffer.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def matching_step(secret: str, code: str) -> int | None:
|
||||
"""Zu welchem 30-Sekunden-Fenster gehört dieser Code? ``None`` = zu keinem.
|
||||
|
||||
Nicht ``TOTP.verify`` benutzt, weil das nur ja/nein sagt. Für den
|
||||
Wiederverwendungsschutz muss aber feststehen, *welches* Fenster verbraucht
|
||||
wurde – sonst liesse sich ein Code aus dem nächsten Fenster im nächsten
|
||||
Moment erneut einlösen."""
|
||||
code = (code or "").strip().replace(" ", "")
|
||||
if not code.isdigit():
|
||||
return None
|
||||
totp = pyotp.TOTP(secret)
|
||||
jetzt = int(time.time()) // STEP_SECONDS
|
||||
for versatz in range(-VALID_WINDOW, VALID_WINDOW + 1):
|
||||
schritt = jetzt + versatz
|
||||
if secrets.compare_digest(totp.at(schritt * STEP_SECONDS), code):
|
||||
return schritt
|
||||
return None
|
||||
|
||||
|
||||
def consume_code(db: Session, user: User, code: str) -> bool:
|
||||
"""Prüft den Code und verbraucht sein Zeitfenster.
|
||||
|
||||
Ein einmal benutzter Code bleibt zwar bis zu 30 Sekunden rechnerisch gültig,
|
||||
darf aber kein zweites Mal zählen – sonst könnte ein abgefangener Code sofort
|
||||
nachgespielt werden."""
|
||||
if not user.totp_secret:
|
||||
return False
|
||||
schritt = matching_step(user.totp_secret, code)
|
||||
if schritt is None:
|
||||
return False
|
||||
if user.totp_last_step is not None and schritt <= user.totp_last_step:
|
||||
return False
|
||||
user.totp_last_step = schritt
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def _format_code(raw: str) -> str:
|
||||
"""Fünfergruppen mit Bindestrich – deutlich leichter abzuschreiben."""
|
||||
return f"{raw[:5]}-{raw[5:]}"
|
||||
|
||||
|
||||
def generate_recovery_codes(db: Session, user: User) -> list[str]:
|
||||
"""Erzeugt einen frischen Satz Codes; alte werden dabei ungültig.
|
||||
|
||||
Die Klartext-Codes gibt es nur dieses eine Mal – in der Datenbank steht
|
||||
ausschliesslich ihr Hash."""
|
||||
db.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).delete()
|
||||
codes: list[str] = []
|
||||
for _ in range(RECOVERY_CODE_COUNT):
|
||||
roh = "".join(secrets.choice(_ALPHABET) for _ in range(RECOVERY_CODE_LENGTH))
|
||||
codes.append(_format_code(roh))
|
||||
db.add(RecoveryCode(user_id=user.id, code_hash=hash_session_token(roh)))
|
||||
db.commit()
|
||||
return codes
|
||||
|
||||
|
||||
def consume_recovery_code(db: Session, user: User, code: str) -> bool:
|
||||
"""Setzt einen Wiederherstellungscode ein. Jeder Code zählt genau einmal."""
|
||||
roh = (code or "").strip().upper().replace("-", "").replace(" ", "")
|
||||
if not roh:
|
||||
return False
|
||||
treffer = (
|
||||
db.query(RecoveryCode)
|
||||
.filter(
|
||||
RecoveryCode.user_id == user.id,
|
||||
RecoveryCode.code_hash == hash_session_token(roh),
|
||||
RecoveryCode.used_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if treffer is None:
|
||||
return False
|
||||
treffer.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def codes_left(db: Session, user: User) -> int:
|
||||
return (
|
||||
db.query(RecoveryCode)
|
||||
.filter(RecoveryCode.user_id == user.id, RecoveryCode.used_at.is_(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def disable(db: Session, user: User) -> None:
|
||||
"""Schaltet 2FA ab und räumt Secret sowie Wiederherstellungscodes weg."""
|
||||
db.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).delete()
|
||||
user.totp_secret = None
|
||||
user.totp_enabled = False
|
||||
user.totp_confirmed_at = None
|
||||
user.totp_last_step = None
|
||||
db.commit()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Passwortrichtlinie und Zwei-Faktor-Authentisierung.
|
||||
|
||||
Bringt die globale Einstellungstabelle (``app_settings``), die TOTP-Felder am
|
||||
Benutzer, die Wiederherstellungscodes und den Zwischenzustand „Passwort ok,
|
||||
zweiter Faktor fehlt noch" an der Sitzung.
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-08-20
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"app_settings",
|
||||
sa.Column("key", sa.String(length=64), primary_key=True),
|
||||
sa.Column("value", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"recovery_codes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("code_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_recovery_codes_user_id", "recovery_codes", ["user_id"])
|
||||
op.create_index("ix_recovery_codes_code_hash", "recovery_codes", ["code_hash"])
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(sa.Column("must_change_password_reason", sa.String(length=16), nullable=True))
|
||||
batch.add_column(sa.Column("totp_secret", sa.String(length=64), nullable=True))
|
||||
batch.add_column(
|
||||
sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default=sa.text("0"))
|
||||
)
|
||||
batch.add_column(sa.Column("totp_confirmed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch.add_column(sa.Column("totp_last_step", sa.Integer(), nullable=True))
|
||||
|
||||
with op.batch_alter_table("sessions") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("pending_2fa", sa.Boolean(), nullable=False, server_default=sa.text("0"))
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("failed_2fa", sa.Integer(), nullable=False, server_default=sa.text("0"))
|
||||
)
|
||||
|
||||
# Bestehende Nutzer, die bereits ein Temporär-Passwort haben, bekommen den
|
||||
# passenden Grund nachgetragen – sonst stünde dort der Richtlinien-Hinweis.
|
||||
op.execute(
|
||||
"UPDATE users SET must_change_password_reason = 'temp' WHERE must_change_password = 1"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("sessions") as batch:
|
||||
batch.drop_column("failed_2fa")
|
||||
batch.drop_column("pending_2fa")
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_column("totp_last_step")
|
||||
batch.drop_column("totp_confirmed_at")
|
||||
batch.drop_column("totp_enabled")
|
||||
batch.drop_column("totp_secret")
|
||||
batch.drop_column("must_change_password_reason")
|
||||
|
||||
op.drop_index("ix_recovery_codes_code_hash", table_name="recovery_codes")
|
||||
op.drop_index("ix_recovery_codes_user_id", table_name="recovery_codes")
|
||||
op.drop_table("recovery_codes")
|
||||
op.drop_table("app_settings")
|
||||
@@ -5,6 +5,10 @@ alembic==1.14.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
argon2-cffi==23.1.0
|
||||
# Zwei-Faktor: TOTP-Codes (pyotp) und der QR-Code zur Einrichtung (qrcode).
|
||||
# qrcode nutzt Pillow, das unten ohnehin schon für die OCR dabei ist.
|
||||
pyotp==2.9.0
|
||||
qrcode==8.0
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -8,12 +8,16 @@ import tempfile
|
||||
os.environ.setdefault("APP_DATA_DIR", tempfile.mkdtemp(prefix="cf-test-"))
|
||||
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
|
||||
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
|
||||
os.environ.setdefault("APP_ADMIN_PASSWORD", "adminpass")
|
||||
# Muss die Standard-Passwortrichtlinie erfüllen (Länge 10, Gross/Klein/Ziffer),
|
||||
# sonst verlangt der erste Login sofort einen Wechsel und alle Tests scheitern.
|
||||
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
|
||||
os.environ.setdefault("APP_SESSION_SECRET", "test-secret")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ADMIN_PASSWORD = "AdminPass1"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
@@ -25,15 +29,67 @@ def client():
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(client):
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": "adminpass"})
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 200
|
||||
return client
|
||||
|
||||
|
||||
def new_password() -> str:
|
||||
"""Zufälliges, richtlinienkonformes Testpasswort."""
|
||||
return "Pw1_" + secrets.token_hex(4)
|
||||
|
||||
|
||||
def new_user(admin_client) -> tuple[str, str]:
|
||||
"""Legt (als Admin) einen frischen Nutzer an und gibt (username, passwort) zurück."""
|
||||
username = "u_" + secrets.token_hex(4)
|
||||
password = "pw_" + secrets.token_hex(4)
|
||||
password = new_password()
|
||||
r = admin_client.post("/api/users", json={"username": username, "password": password, "role": "user"})
|
||||
assert r.status_code == 201, r.text
|
||||
return username, password
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def policy_admin(admin):
|
||||
"""Admin-Client für Tests, die die Passwortrichtlinie verändern.
|
||||
|
||||
Die Testdatenbank lebt über den ganzen Testlauf und die Richtlinie ist
|
||||
global – ohne Zurücksetzen würde eine Verschärfung alle folgenden Tests
|
||||
kippen.
|
||||
|
||||
Zurückgesetzt wird bewusst direkt über die Dienstschicht, nicht über die
|
||||
API: wer ``mfa_required`` einschaltet, sperrt sich damit selbst aus
|
||||
``PUT /policy`` aus, bis er einen zweiten Faktor eingerichtet hat. Über die
|
||||
API bliebe die Verschärfung also stehen."""
|
||||
from app.database import SessionLocal
|
||||
from app.services import policy as policy_service
|
||||
|
||||
vorher = admin.get("/api/policy").json()
|
||||
try:
|
||||
yield admin
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
policy_service.save(db, policy_service.Policy(**vorher))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def login_ready(username: str, password: str) -> tuple[TestClient, str]:
|
||||
"""Meldet einen frisch angelegten Nutzer an und erledigt den Pflichtwechsel.
|
||||
|
||||
Vom Admin angelegte Konten tragen ``must_change_password``; solange das
|
||||
offen ist, sperrt der Server alle fachlichen Endpunkte. Für Tests, die den
|
||||
Nutzer einfach benutzen wollen, wird der Wechsel hier gleich mit erledigt.
|
||||
Gibt den angemeldeten Client und das nun gültige Passwort zurück."""
|
||||
from app.main import app
|
||||
|
||||
c = TestClient(app)
|
||||
r = c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert r.status_code == 200, r.text
|
||||
neu = new_password()
|
||||
r = c.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": password, "new_password": neu},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return c, neu
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Anmeldung, Passwortrichtlinie und die serverseitige Sperre offener Pflichten."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from conftest import ADMIN_PASSWORD, login_ready, new_password, new_user
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": "falsch"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_unknown_user(client):
|
||||
r = client.post("/api/auth/login", data={"username": "gibtsnicht", "password": "egal"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_returns_status_ok(client):
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["user"]["username"] == "admin"
|
||||
|
||||
|
||||
def test_inactive_account_cannot_log_in(admin):
|
||||
username, password = new_user(admin)
|
||||
liste = admin.get("/api/users").json()
|
||||
u = next(x for x in liste if x["username"] == username)
|
||||
assert admin.patch(f"/api/users/{u['id']}", json={"is_active": False}).status_code == 200
|
||||
|
||||
r = _client().post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_fresh_user_is_blocked_until_password_changed(admin):
|
||||
"""Vom Admin angelegte Konten kommen nur an die Wechsel-Endpunkte."""
|
||||
username, password = new_user(admin)
|
||||
c = _client()
|
||||
assert c.post("/api/auth/login", data={"username": username, "password": password}).status_code == 200
|
||||
|
||||
# Eigene Daten und der Wechsel selbst bleiben erreichbar …
|
||||
me = c.get("/api/auth/me").json()
|
||||
assert me["must_change_password"] is True
|
||||
assert me["must_change_password_reason"] == "temp"
|
||||
assert c.get("/api/policy").status_code == 200
|
||||
|
||||
# … alles Fachliche nicht.
|
||||
r = c.get("/api/accounts")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "password_change_required"
|
||||
|
||||
neu = new_password()
|
||||
assert c.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": password, "new_password": neu},
|
||||
).status_code == 200
|
||||
assert c.get("/api/accounts").status_code == 200
|
||||
|
||||
|
||||
def test_change_password_checks_current(admin):
|
||||
username, password = new_user(admin)
|
||||
c, _gueltig = login_ready(username, password)
|
||||
r = c.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "stimmt-nicht", "new_password": new_password()},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_change_password_rejects_same_password(admin):
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
r = c.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": gueltig, "new_password": gueltig},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_change_password_enforces_policy(admin):
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
# Zu kurz, keine Grossbuchstaben, keine Ziffer.
|
||||
r = c.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": gueltig, "new_password": "kurz"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "Richtlinie" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_create_user_enforces_policy(admin):
|
||||
r = admin.post("/api/users", json={"username": "schwach_x", "password": "abc", "role": "user"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_policy_is_admin_only(admin):
|
||||
other, _ = login_ready(*new_user(admin))
|
||||
assert other.get("/api/policy").status_code == 200
|
||||
assert other.put("/api/policy", json={"min_length": 8}).status_code == 403
|
||||
|
||||
|
||||
def test_tightened_policy_forces_change_on_next_login(policy_admin):
|
||||
"""Kernpunkt: der Hash gibt nichts her, aber beim Anmelden liegt das
|
||||
Passwort im Klartext vor – dort wird die Richtlinie geprüft."""
|
||||
username, password = new_user(policy_admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
assert c.get("/api/auth/me").json()["must_change_password"] is False
|
||||
|
||||
# Richtlinie verschärfen: das bisherige Passwort ist nun zu kurz.
|
||||
assert policy_admin.put("/api/policy", json={
|
||||
"min_length": 40, "require_upper": True, "require_lower": True,
|
||||
"require_digit": True, "require_special": False, "mfa_required": False,
|
||||
}).status_code == 200
|
||||
|
||||
zweiter = _client()
|
||||
assert zweiter.post(
|
||||
"/api/auth/login", data={"username": username, "password": gueltig}
|
||||
).status_code == 200
|
||||
me = zweiter.get("/api/auth/me").json()
|
||||
assert me["must_change_password"] is True
|
||||
assert me["must_change_password_reason"] == "policy"
|
||||
assert zweiter.get("/api/accounts").status_code == 403
|
||||
|
||||
|
||||
def test_policy_min_length_unter_untergrenze_wird_abgelehnt(policy_admin):
|
||||
vorher = policy_admin.get("/api/policy").json()
|
||||
assert policy_admin.put("/api/policy", json={"min_length": 4}).status_code == 422
|
||||
# Die gespeicherte Richtlinie bleibt unverändert.
|
||||
assert policy_admin.get("/api/policy").json() == vorher
|
||||
|
||||
|
||||
def test_change_password_revokes_other_sessions(admin):
|
||||
username, password = new_user(admin)
|
||||
erste, gueltig = login_ready(username, password)
|
||||
zweite = _client()
|
||||
assert zweite.post(
|
||||
"/api/auth/login", data={"username": username, "password": gueltig}
|
||||
).status_code == 200
|
||||
|
||||
neu = new_password()
|
||||
assert erste.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": gueltig, "new_password": neu},
|
||||
).status_code == 200
|
||||
|
||||
# Die andere Sitzung ist beendet, die eigene läuft weiter.
|
||||
assert zweite.get("/api/auth/me").status_code == 401
|
||||
assert erste.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_logout_ends_session(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
assert c.post("/api/auth/logout").status_code == 204
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
import secrets
|
||||
from datetime import date
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def _setup(admin):
|
||||
acc = admin.post("/api/accounts", json={"name": "Exp", "currency": "EUR"}).json()
|
||||
@@ -41,19 +42,13 @@ def test_export_all_json(admin):
|
||||
|
||||
|
||||
def test_db_backup_admin_only(admin):
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
# Admin darf.
|
||||
r = admin.get("/api/backup/db")
|
||||
assert r.status_code == 200
|
||||
assert r.content[:16].startswith(b"SQLite format 3")
|
||||
|
||||
# Normaler Nutzer darf nicht.
|
||||
u, p = "u_" + secrets.token_hex(4), "pw"
|
||||
admin.post("/api/users", json={"username": u, "password": p, "role": "user"})
|
||||
other = TestClient(app)
|
||||
other.post("/api/auth/login", data={"username": u, "password": p})
|
||||
other, _ = login_ready(*new_user(admin))
|
||||
assert other.get("/api/backup/db").status_code == 403
|
||||
# ...aber seinen eigenen Export schon (leer).
|
||||
assert other.get("/api/export/all.json").status_code == 200
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import secrets
|
||||
from datetime import date
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def test_users_are_isolated(admin):
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
# Admin legt ein Konto an.
|
||||
acc = admin.post("/api/accounts", json={"name": "AdminKonto", "currency": "EUR"}).json()
|
||||
|
||||
# Frischen Nutzer anlegen und getrennt einloggen.
|
||||
username = "iso_" + secrets.token_hex(4)
|
||||
password = "pw_" + secrets.token_hex(4)
|
||||
assert admin.post("/api/users", json={"username": username, "password": password, "role": "user"}).status_code == 201
|
||||
|
||||
other = TestClient(app)
|
||||
assert other.post("/api/auth/login", data={"username": username, "password": password}).status_code == 200
|
||||
username, password = new_user(admin)
|
||||
other, _ = login_ready(username, password)
|
||||
|
||||
# Sieht keine fremden Konten/Buchungen.
|
||||
assert other.get("/api/accounts").json() == []
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Voll-Backup: Export, Prüfansicht und Wiederherstellung."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def _datensatz(c):
|
||||
"""Legt ein Konto mit zwei Buchungen an und gibt das Konto zurück."""
|
||||
acc = c.post("/api/accounts", json={"name": "Restore-Konto", "currency": "EUR"}).json()
|
||||
for betrag, wer in ((1234, "Laden A"), (5678, "Laden B")):
|
||||
c.post("/api/transactions", json={
|
||||
"type": "expense", "date": str(date.today()), "account_id": acc["id"],
|
||||
"amount_cents": betrag, "payee": wer,
|
||||
})
|
||||
return acc
|
||||
|
||||
|
||||
def _backup(c) -> dict:
|
||||
r = c.get("/api/export/backup.json")
|
||||
assert r.status_code == 200, r.text
|
||||
return json.loads(r.content)
|
||||
|
||||
|
||||
def _hochladen(c, daten: dict, pfad: str, mode: str | None = None):
|
||||
dateien = {"file": ("backup.json", json.dumps(daten).encode("utf-8"), "application/json")}
|
||||
if mode is None:
|
||||
return c.post(pfad, files=dateien)
|
||||
return c.post(pfad, files=dateien, data={"mode": mode})
|
||||
|
||||
|
||||
def test_backup_hat_umschlag_und_alle_tabellen(admin):
|
||||
_datensatz(admin)
|
||||
daten = _backup(admin)
|
||||
assert daten["format"] == "certain-finance-backup"
|
||||
assert daten["schema_version"] == 2
|
||||
assert daten["scope"] == "user"
|
||||
assert daten["username"] == "admin"
|
||||
# Beziehungen laufen über lokale IDs, nicht über Namen.
|
||||
konto = daten["data"]["accounts"][0]
|
||||
assert "local_id" in konto
|
||||
buchung = daten["data"]["transactions"][0]
|
||||
assert buchung["account_id"] in {a["local_id"] for a in daten["data"]["accounts"]}
|
||||
# Felder, die der alte Ansichts-Export verlor.
|
||||
assert "import_hash" in buchung
|
||||
assert "fx_rate" in buchung
|
||||
assert "budget_treatment" in buchung
|
||||
|
||||
|
||||
def test_pruefansicht_schreibt_nichts(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
_datensatz(c)
|
||||
daten = _backup(c)
|
||||
vorher = len(c.get("/api/accounts").json())
|
||||
|
||||
r = _hochladen(c, daten, "/api/backup/restore/preview")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["scope"] == "user"
|
||||
tabellen = {z["key"]: z["count"] for z in body["tables"]}
|
||||
assert tabellen["accounts"] >= 1
|
||||
assert tabellen["transactions"] == 2
|
||||
assert len(c.get("/api/accounts").json()) == vorher
|
||||
|
||||
|
||||
def test_replace_stellt_geloeschte_daten_wieder_her(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
acc = _datensatz(c)
|
||||
daten = _backup(c)
|
||||
konten_vorher = c.get("/api/accounts").json()
|
||||
buchungen_vorher = c.get("/api/transactions").json()
|
||||
|
||||
# Alles wegwerfen …
|
||||
assert c.delete(f"/api/accounts/{acc['id']}").status_code in (200, 204)
|
||||
assert c.get("/api/accounts").json() == []
|
||||
|
||||
# … und aus dem Backup zurückholen.
|
||||
r = _hochladen(c, daten, "/api/backup/restore", "replace")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["mode"] == "replace"
|
||||
|
||||
konten = c.get("/api/accounts").json()
|
||||
buchungen = c.get("/api/transactions").json()
|
||||
assert len(konten) == len(konten_vorher)
|
||||
assert konten[0]["name"] == "Restore-Konto"
|
||||
assert len(buchungen) == len(buchungen_vorher)
|
||||
assert {b["payee"] for b in buchungen} == {"Laden A", "Laden B"}
|
||||
# Die Buchungen hängen wieder am wiederhergestellten Konto.
|
||||
assert {b["account_id"] for b in buchungen} == {konten[0]["id"]}
|
||||
|
||||
|
||||
def test_replace_ersetzt_statt_zu_ergaenzen(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
_datensatz(c)
|
||||
daten = _backup(c)
|
||||
|
||||
# Zwischendurch etwas anlegen, das im Backup nicht vorkommt.
|
||||
c.post("/api/accounts", json={"name": "Danach angelegt", "currency": "EUR"})
|
||||
assert len(c.get("/api/accounts").json()) == 2
|
||||
|
||||
assert _hochladen(c, daten, "/api/backup/restore", "replace").status_code == 200
|
||||
namen = [a["name"] for a in c.get("/api/accounts").json()]
|
||||
assert namen == ["Restore-Konto"]
|
||||
|
||||
|
||||
def test_merge_erzeugt_keine_duplikate(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
_datensatz(c)
|
||||
daten = _backup(c)
|
||||
|
||||
assert _hochladen(c, daten, "/api/backup/restore", "merge").status_code == 200
|
||||
assert len(c.get("/api/accounts").json()) == 1
|
||||
assert len(c.get("/api/transactions").json()) == 2
|
||||
|
||||
# Zweiter Durchlauf ändert weiterhin nichts.
|
||||
assert _hochladen(c, daten, "/api/backup/restore", "merge").status_code == 200
|
||||
assert len(c.get("/api/accounts").json()) == 1
|
||||
assert len(c.get("/api/transactions").json()) == 2
|
||||
|
||||
|
||||
def test_merge_ergaenzt_fehlendes(admin):
|
||||
geber, _ = login_ready(*new_user(admin))
|
||||
_datensatz(geber)
|
||||
daten = _backup(geber)
|
||||
|
||||
nehmer, _ = login_ready(*new_user(admin))
|
||||
nehmer.post("/api/accounts", json={"name": "Eigenes Konto", "currency": "EUR"})
|
||||
|
||||
assert _hochladen(nehmer, daten, "/api/backup/restore", "merge").status_code == 200
|
||||
namen = sorted(a["name"] for a in nehmer.get("/api/accounts").json())
|
||||
assert namen == ["Eigenes Konto", "Restore-Konto"]
|
||||
assert len(nehmer.get("/api/transactions").json()) == 2
|
||||
|
||||
|
||||
def test_fremde_version_wird_abgelehnt(admin):
|
||||
daten = _backup(admin)
|
||||
daten["schema_version"] = 99
|
||||
r = _hochladen(admin, daten, "/api/backup/restore/preview")
|
||||
assert r.status_code == 400
|
||||
assert "99" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_ansichts_export_wird_erkannt(admin):
|
||||
_datensatz(admin)
|
||||
alt = json.loads(admin.get("/api/export/all.json").content)
|
||||
r = _hochladen(admin, alt, "/api/backup/restore/preview")
|
||||
assert r.status_code == 400
|
||||
assert "all.json" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_fremde_datei_wird_abgelehnt(admin):
|
||||
r = _hochladen(admin, {"irgendwas": 1}, "/api/backup/restore/preview")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_systembackup_nur_fuer_admin(admin):
|
||||
assert admin.get("/api/export/system-backup.json").status_code == 200
|
||||
other, _ = login_ready(*new_user(admin))
|
||||
assert other.get("/api/export/system-backup.json").status_code == 403
|
||||
|
||||
|
||||
def test_systembackup_enthaelt_alle_benutzer(admin):
|
||||
daten = json.loads(admin.get("/api/export/system-backup.json").content)
|
||||
assert daten["scope"] == "system"
|
||||
namen = {u["username"] for u in daten["users"]}
|
||||
assert "admin" in namen
|
||||
admin_satz = next(u for u in daten["users"] if u["username"] == "admin")
|
||||
# Für einen echten Wiederaufbau muss der Hash mit – deshalb der Hinweis
|
||||
# in der Oberfläche, die Datei wie ein Passwort zu behandeln.
|
||||
assert admin_satz["password_hash"].startswith("$argon2")
|
||||
assert "data" in admin_satz
|
||||
|
||||
|
||||
def test_systembackup_darf_kein_normaler_nutzer_einspielen(admin):
|
||||
daten = json.loads(admin.get("/api/export/system-backup.json").content)
|
||||
other, _ = login_ready(*new_user(admin))
|
||||
r = _hochladen(other, daten, "/api/backup/restore/preview")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_systembackup_ohne_admin_wird_abgelehnt(admin):
|
||||
daten = json.loads(admin.get("/api/export/system-backup.json").content)
|
||||
daten["users"] = [u for u in daten["users"] if u["role"] != "admin"]
|
||||
r = _hochladen(admin, daten, "/api/backup/restore", "replace")
|
||||
assert r.status_code == 400
|
||||
assert "Administrator" in r.json()["detail"]
|
||||
# Nichts halb eingespielt: der Admin ist noch da.
|
||||
assert admin.get("/api/auth/me").status_code == 200
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Zwei-Faktor: Einrichtung, zweistufige Anmeldung, Wiederherstellungscodes."""
|
||||
|
||||
import time
|
||||
|
||||
import pyotp
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _code(secret: str, versatz: int = 0) -> str:
|
||||
"""Code für das aktuelle (oder ein verschobenes) Zeitfenster.
|
||||
|
||||
Jeder Code verbraucht sein Fenster dauerhaft, deshalb muss ein Test, der
|
||||
mehrfach anmeldet, jeweils ein späteres Fenster nehmen."""
|
||||
schritt = int(time.time()) // 30 + versatz
|
||||
return pyotp.TOTP(secret).at(schritt * 30)
|
||||
|
||||
|
||||
def _mit_2fa(admin) -> tuple[str, str, str]:
|
||||
"""Legt einen Nutzer an, richtet 2FA ein und gibt (name, passwort, secret) zurück.
|
||||
|
||||
Aktiviert wird mit dem Code des aktuellen Fensters – spätere Anmeldungen im
|
||||
Test brauchen deshalb ``versatz >= 1``."""
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
setup = c.post("/api/auth/2fa/setup").json()
|
||||
r = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])})
|
||||
assert r.status_code == 200, r.text
|
||||
return username, gueltig, setup["secret"]
|
||||
|
||||
|
||||
def test_setup_liefert_qr_und_secret(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
r = c.post("/api/auth/2fa/setup")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["qr_png"].startswith("data:image/png;base64,")
|
||||
assert body["otpauth_uri"].startswith("otpauth://totp/")
|
||||
assert len(body["secret"]) >= 16
|
||||
# Noch nicht aktiv, solange kein Code bestätigt wurde.
|
||||
assert c.get("/api/auth/2fa/status").json()["enabled"] is False
|
||||
|
||||
|
||||
def test_activate_liefert_wiederherstellungscodes(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
setup = c.post("/api/auth/2fa/setup").json()
|
||||
r = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])})
|
||||
assert r.status_code == 200
|
||||
codes = r.json()["codes"]
|
||||
assert len(codes) == 10
|
||||
assert len(set(codes)) == 10
|
||||
status = c.get("/api/auth/2fa/status").json()
|
||||
assert status["enabled"] is True
|
||||
assert status["recovery_codes_left"] == 10
|
||||
|
||||
|
||||
def test_activate_mit_falschem_code(admin):
|
||||
c, _ = login_ready(*new_user(admin))
|
||||
c.post("/api/auth/2fa/setup")
|
||||
assert c.post("/api/auth/2fa/activate", json={"code": "000000"}).status_code == 400
|
||||
|
||||
|
||||
def test_zweistufige_anmeldung(admin):
|
||||
username, password, secret = _mit_2fa(admin)
|
||||
|
||||
c = _client()
|
||||
r = c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "2fa_required"
|
||||
assert r.json()["user"] is None
|
||||
|
||||
# Das Zwischen-Cookie ist kein Zugang.
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
assert c.get("/api/accounts").status_code == 401
|
||||
|
||||
r = c.post("/api/auth/2fa/verify", json={"code": _code(secret, 1)})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "ok"
|
||||
assert r.json()["user"]["username"] == username
|
||||
assert c.get("/api/accounts").status_code == 200
|
||||
|
||||
|
||||
def test_code_gilt_nur_einmal(admin):
|
||||
username, password, secret = _mit_2fa(admin)
|
||||
code = _code(secret, 1)
|
||||
|
||||
c = _client()
|
||||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert c.post("/api/auth/2fa/verify", json={"code": code}).status_code == 200
|
||||
|
||||
# Derselbe Code ein zweites Mal: das Fenster ist verbraucht.
|
||||
zweiter = _client()
|
||||
zweiter.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert zweiter.post("/api/auth/2fa/verify", json={"code": code}).status_code == 401
|
||||
|
||||
|
||||
def test_bereits_verbrauchtes_fenster_wird_abgelehnt(admin):
|
||||
"""Der Code, mit dem aktiviert wurde, taugt nicht mehr zum Anmelden."""
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
setup = c.post("/api/auth/2fa/setup").json()
|
||||
aktivierungscode = _code(setup["secret"])
|
||||
assert c.post("/api/auth/2fa/activate", json={"code": aktivierungscode}).status_code == 200
|
||||
|
||||
zweiter = _client()
|
||||
zweiter.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||||
assert zweiter.post(
|
||||
"/api/auth/2fa/verify", json={"code": aktivierungscode}
|
||||
).status_code == 401
|
||||
|
||||
|
||||
def test_falscher_code_wird_abgelehnt(admin):
|
||||
username, password, _ = _mit_2fa(admin)
|
||||
c = _client()
|
||||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert c.post("/api/auth/2fa/verify", json={"code": "000000"}).status_code == 401
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_zu_viele_fehlversuche_beenden_den_versuch(admin):
|
||||
username, password, secret = _mit_2fa(admin)
|
||||
c = _client()
|
||||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
for _ in range(5):
|
||||
c.post("/api/auth/2fa/verify", json={"code": "000000"})
|
||||
# Die Pending-Sitzung ist verbraucht – auch ein richtiger Code hilft nicht mehr.
|
||||
r = c.post("/api/auth/2fa/verify", json={"code": _code(secret, 1)})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_wiederherstellungscode_funktioniert_einmal(admin):
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
setup = c.post("/api/auth/2fa/setup").json()
|
||||
codes = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])}).json()["codes"]
|
||||
|
||||
erster = _client()
|
||||
erster.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||||
r = erster.post("/api/auth/2fa/verify", json={"code": codes[0], "recovery": True})
|
||||
assert r.status_code == 200, r.text
|
||||
assert erster.get("/api/auth/2fa/status").json()["recovery_codes_left"] == 9
|
||||
|
||||
zweiter = _client()
|
||||
zweiter.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||||
r = zweiter.post("/api/auth/2fa/verify", json={"code": codes[0], "recovery": True})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_abschalten_verlangt_das_richtige_passwort(admin):
|
||||
username, password = new_user(admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
setup = c.post("/api/auth/2fa/setup").json()
|
||||
assert c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])}).status_code == 200
|
||||
|
||||
assert c.post("/api/auth/2fa/disable", json={"password": "falsch"}).status_code == 400
|
||||
r = c.post("/api/auth/2fa/disable", json={"password": gueltig})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["totp_enabled"] is False
|
||||
assert c.get("/api/auth/2fa/status").json()["recovery_codes_left"] == 0
|
||||
|
||||
|
||||
def test_admin_kann_zwei_faktor_zuruecksetzen(admin):
|
||||
username, password, secret = _mit_2fa(admin)
|
||||
u = next(x for x in admin.get("/api/users").json() if x["username"] == username)
|
||||
assert u["totp_enabled"] is True
|
||||
|
||||
r = admin.post(f"/api/users/{u['id']}/2fa/reset")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["totp_enabled"] is False
|
||||
|
||||
# Danach genügt wieder das Passwort allein.
|
||||
c = _client()
|
||||
r = c.post("/api/auth/login", data={"username": username, "password": password})
|
||||
assert r.json()["status"] == "ok"
|
||||
assert secret # das alte Secret ist damit wertlos
|
||||
|
||||
|
||||
def test_mfa_pflicht_sperrt_bis_zur_einrichtung(policy_admin):
|
||||
username, password = new_user(policy_admin)
|
||||
c, gueltig = login_ready(username, password)
|
||||
|
||||
assert policy_admin.put("/api/policy", json={
|
||||
"min_length": 10, "require_upper": True, "require_lower": True,
|
||||
"require_digit": True, "require_special": False, "mfa_required": True,
|
||||
}).status_code == 200
|
||||
|
||||
zweiter = _client()
|
||||
assert zweiter.post(
|
||||
"/api/auth/login", data={"username": username, "password": gueltig}
|
||||
).status_code == 200
|
||||
me = zweiter.get("/api/auth/me").json()
|
||||
assert me["mfa_setup_required"] is True
|
||||
|
||||
r = zweiter.get("/api/accounts")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "mfa_setup_required"
|
||||
|
||||
# Einrichten ist erlaubt und hebt die Sperre auf.
|
||||
setup = zweiter.post("/api/auth/2fa/setup").json()
|
||||
assert zweiter.post(
|
||||
"/api/auth/2fa/activate", json={"code": _code(setup["secret"])}
|
||||
).status_code == 200
|
||||
assert zweiter.get("/api/accounts").status_code == 200
|
||||
|
||||
# Abschalten bleibt verwehrt, solange die Pflicht gilt.
|
||||
assert zweiter.post("/api/auth/2fa/disable", json={"password": gueltig}).status_code == 400
|
||||
@@ -22,6 +22,9 @@ APP_SESSION_SECRET=change-me-please
|
||||
APP_COOKIE_SECURE=false
|
||||
|
||||
# Erster Admin – wird NUR beim allerersten Start angelegt (wenn noch kein Nutzer da ist).
|
||||
# Erfüllt das Passwort die Passwortrichtlinie nicht (Vorgabe: 10 Zeichen, Gross,
|
||||
# Klein, Ziffer), verlangt die App beim ersten Anmelden sofort ein neues. Genau
|
||||
# so ist "changeme" hier auch gemeint.
|
||||
APP_ADMIN_USERNAME=admin
|
||||
APP_ADMIN_PASSWORD=changeme
|
||||
|
||||
|
||||
+37
-2
@@ -4,11 +4,46 @@
|
||||
|
||||
**Einstellungen → Backup & Export:**
|
||||
|
||||
- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — exportiert die *eigenen*
|
||||
Daten des angemeldeten Nutzers (owner-gescoped) zum Archivieren/Weiterverarbeiten.
|
||||
- **Voll-Backup (JSON)** — alle eigenen Daten in einem versionierten Format, das
|
||||
sich in der App wieder einspielen lässt (siehe unten): Konten, Kategorien,
|
||||
Buchungen mit allen Feldern, Budget-Vorlage und Monatswerte, Schlagwortregeln,
|
||||
Import-Vorlagen, Dashboard-Layout und die persönlichen Einstellungen.
|
||||
- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — Ansichts-Exporte zum
|
||||
Archivieren und Weiterverarbeiten, etwa in einer Tabellenkalkulation.
|
||||
Referenzen laufen dort über Namen und mehrere Tabellen fehlen: zum
|
||||
Wiederherstellen taugen sie **nicht**. Die App weist eine solche Datei beim
|
||||
Einspielen ausdrücklich ab, statt halb zu raten.
|
||||
- **Komplettes DB-Backup** (nur Admin) — lädt eine konsistente Kopie der gesamten
|
||||
SQLite-Datenbank herunter (alle Nutzer). Erzeugt über die SQLite-Online-Backup-API,
|
||||
also auch bei laufendem Betrieb gefahrlos.
|
||||
- **Systemweites Voll-Backup (JSON)** (nur Admin) — dasselbe JSON-Format über
|
||||
alle Benutzer hinweg, inklusive Benutzerkonten. Die Datei enthält
|
||||
**Passwort-Hashes und Zwei-Faktor-Geheimnisse** und ist wie ein Passwort zu
|
||||
behandeln.
|
||||
|
||||
## Wiederherstellen (JSON, in der App)
|
||||
|
||||
**Einstellungen → Backup zurückladen.** Die Datei wird zuerst nur geprüft; die
|
||||
Prüfansicht zeigt, was darin steckt. Geschrieben wird erst nach Bestätigung.
|
||||
|
||||
Zwei Vorgehen:
|
||||
|
||||
- **Ersetzen** — die vorhandenen Daten werden gelöscht und durch das Backup
|
||||
ersetzt. Vorher legt der Server automatisch einen Schnappschuss der Datenbank
|
||||
unter `/var/lib/certain-finance/backups/pre-restore-*.db` an.
|
||||
- **Ergänzen** — nichts wird gelöscht, nur Fehlendes kommt hinzu. Konten und
|
||||
Kategorien werden über den Namen abgeglichen, Buchungen über ihren
|
||||
Import-Hash beziehungsweise über Datum, Konto, Betrag und Empfänger. Budget,
|
||||
Regeln und Einstellungen bleiben unangetastet, wenn dazu schon etwas
|
||||
vorhanden ist — halb zusammengeführte Budgets wären nicht sinnvoll deutbar.
|
||||
|
||||
Ein **systemweites** Backup kann nur ein Admin einspielen, und nur ersetzend.
|
||||
Da dabei die Benutzertabelle ausgetauscht wird, enden anschliessend alle
|
||||
Sitzungen und man muss sich neu anmelden. Enthält die Datei keinen
|
||||
Administrator, wird sie abgelehnt — sonst käme danach niemand mehr hinein.
|
||||
|
||||
Das Einspielen läuft in einer einzigen Transaktion: entweder geht alles durch,
|
||||
oder es wird nichts geschrieben.
|
||||
|
||||
## Wiederherstellen (DB-Backup)
|
||||
|
||||
|
||||
+7
-1
@@ -14,6 +14,7 @@ import Budget from "./pages/Budget";
|
||||
import Import from "./pages/Import";
|
||||
import Users from "./pages/Users";
|
||||
import Settings from "./pages/Settings";
|
||||
import TwoFactor from "./pages/TwoFactor";
|
||||
|
||||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||
|
||||
@@ -98,10 +99,14 @@ function Protected({ children, adminOnly = false, wide = false }) {
|
||||
if (loading) return <div className="center muted" style={{ padding: 60 }}>{t("common.loading")}</div>;
|
||||
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
// Vom Admin gesetztes Temporär-Passwort: erst ändern, dann weiter.
|
||||
// Offene Pflichten der Reihe nach – dieselbe Reihenfolge erzwingt der Server
|
||||
// (backend/app/deps.py, enforce_account_state), sonst liefe man in 403er.
|
||||
if (user.must_change_password && pathname !== "/passwort-aendern") {
|
||||
return <Navigate to="/passwort-aendern" replace />;
|
||||
}
|
||||
if (user.mfa_setup_required && pathname !== "/zwei-faktor") {
|
||||
return <Navigate to="/zwei-faktor" replace />;
|
||||
}
|
||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
@@ -135,6 +140,7 @@ export default function App() {
|
||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||
<Route path="/zwei-faktor" element={<Protected><TwoFactor /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -114,11 +114,29 @@ export const api = {
|
||||
request("/auth/change-password", { method: "POST", body: { current_password, new_password } }),
|
||||
version: () => request("/version"),
|
||||
|
||||
// Zwei-Faktor. verifyTwoFactor ist der zweite Anmeldeschritt und läuft noch
|
||||
// über das kurzlebige Zwischen-Cookie – dort ist ein 401 normal, also nicht
|
||||
// vom globalen Abmelde-Griff behandeln lassen.
|
||||
verifyTwoFactor: (code, recovery = false) =>
|
||||
request("/auth/2fa/verify", { method: "POST", body: { code, recovery }, skipAuthHandler: true }),
|
||||
twoFactorStatus: () => request("/auth/2fa/status"),
|
||||
twoFactorSetup: () => request("/auth/2fa/setup", { method: "POST" }),
|
||||
twoFactorActivate: (code) => request("/auth/2fa/activate", { method: "POST", body: { code } }),
|
||||
twoFactorDisable: (password) =>
|
||||
request("/auth/2fa/disable", { method: "POST", body: { password } }),
|
||||
newRecoveryCodes: (password) =>
|
||||
request("/auth/2fa/recovery-codes", { method: "POST", body: { password } }),
|
||||
|
||||
// Passwortrichtlinie (lesen: alle, ändern: Admin)
|
||||
getPolicy: () => request("/policy"),
|
||||
setPolicy: (body) => request("/policy", { method: "PUT", body }),
|
||||
|
||||
// Benutzer (Admin)
|
||||
listUsers: () => request("/users"),
|
||||
createUser: (body) => request("/users", { method: "POST", body }),
|
||||
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||
resetUserTwoFactor: (id) => request(`/users/${id}/2fa/reset`, { method: "POST" }),
|
||||
|
||||
// Einstellungen (je Benutzer)
|
||||
listSettings: () => request("/settings"),
|
||||
@@ -218,4 +236,19 @@ export const api = {
|
||||
exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`),
|
||||
exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`),
|
||||
exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`),
|
||||
exportBackupJson: () =>
|
||||
downloadFile("/export/backup.json", `certain-finance-backup_${zeitstempel()}.json`),
|
||||
exportSystemBackupJson: () =>
|
||||
downloadFile("/export/system-backup.json", `certain-finance-system-backup_${zeitstempel()}.json`),
|
||||
restorePreview: (file) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return request("/backup/restore/preview", { method: "POST", formData: fd });
|
||||
},
|
||||
restoreBackup: (file, mode) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("mode", mode);
|
||||
return request("/backup/restore", { method: "POST", formData: fd });
|
||||
},
|
||||
};
|
||||
|
||||
+21
-3
@@ -44,12 +44,29 @@ export function AuthProvider({ children }) {
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Erster Anmeldeschritt. Liefert `{ status, user }`:
|
||||
* `"ok"` heisst angemeldet, `"2fa_required"` heisst, es fehlt noch der Code
|
||||
* aus der Authenticator-App – dann ist `user` leer und der Aufrufer muss
|
||||
* `verifyTwoFactor` nachlegen.
|
||||
*/
|
||||
async function login(username, password) {
|
||||
const me = await api.login(username, password);
|
||||
setUser(me);
|
||||
const res = await api.login(username, password);
|
||||
if (res.status === "ok") {
|
||||
setUser(res.user);
|
||||
setAbgelaufen(false);
|
||||
setStartFehler(null);
|
||||
return me;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Zweiter Anmeldeschritt: TOTP-Code oder Wiederherstellungscode. */
|
||||
async function verifyTwoFactor(code, recovery = false) {
|
||||
const res = await api.verifyTwoFactor(code, recovery);
|
||||
setUser(res.user);
|
||||
setAbgelaufen(false);
|
||||
setStartFehler(null);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -81,6 +98,7 @@ export function AuthProvider({ children }) {
|
||||
isAdmin,
|
||||
loading,
|
||||
login,
|
||||
verifyTwoFactor,
|
||||
logout,
|
||||
aktualisieren,
|
||||
abgelaufen,
|
||||
|
||||
@@ -110,6 +110,31 @@ const PATHS = {
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</>
|
||||
),
|
||||
smartphone: (
|
||||
<>
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</>
|
||||
),
|
||||
copy: (
|
||||
<>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</>
|
||||
),
|
||||
upload: (
|
||||
<>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m17 8-5-5-5 5" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</>
|
||||
),
|
||||
|
||||
// --- Finance-Grundausstattung ------------------------------------------
|
||||
wallet: (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "./Icon";
|
||||
|
||||
/**
|
||||
* Passwortfeld mit Augensymbol zum Ein-/Ausblenden.
|
||||
*
|
||||
* Bewusst ein echtes ``type="password"``, solange nichts eingeblendet ist –
|
||||
* nur so funktionieren Passwortmanager und die Autofill-Erkennung des Browsers
|
||||
* zuverlässig.
|
||||
*/
|
||||
export default function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
autoComplete = "current-password",
|
||||
required = false,
|
||||
autoFocus = false,
|
||||
id,
|
||||
...rest
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [sichtbar, setSichtbar] = useState(false);
|
||||
const eigeneId = useId();
|
||||
const feldId = id || eigeneId;
|
||||
const beschriftung = sichtbar ? t("password.hide") : t("password.show");
|
||||
|
||||
return (
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
id={feldId}
|
||||
type={sichtbar ? "text" : "password"}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setSichtbar((v) => !v)}
|
||||
title={beschriftung}
|
||||
aria-label={beschriftung}
|
||||
aria-pressed={sichtbar}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon name={sichtbar ? "eye-off" : "eye"} size={17} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "./Icon";
|
||||
import { ruleList } from "../passwordPolicy";
|
||||
|
||||
/**
|
||||
* Anforderungsliste unter einem Passwortfeld, die sich beim Tippen abhakt.
|
||||
*
|
||||
* Solange nichts eingegeben ist, bleibt sie neutral – ein Feld, das man noch
|
||||
* gar nicht angefasst hat, soll nicht wie ein Fehler aussehen.
|
||||
*/
|
||||
export default function PasswordRules({ policy, value }) {
|
||||
const { t } = useTranslation();
|
||||
const regeln = ruleList(policy, value, t);
|
||||
const unberuehrt = !value;
|
||||
|
||||
return (
|
||||
<ul className="pw-rules" aria-live="polite">
|
||||
{regeln.map((r) => (
|
||||
<li key={r.id} className={r.ok ? "pw-rule erfuellt" : "pw-rule"}>
|
||||
<span className="pw-rule-mark" aria-hidden="true">
|
||||
{r.ok ? <Icon name="check" size={13} strokeWidth={3} /> : <span className="pw-rule-dot" />}
|
||||
</span>
|
||||
<span className={unberuehrt ? "muted" : undefined}>{r.text}</span>
|
||||
<span className="sr-only">{r.ok ? t("password.met") : t("password.unmet")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
+13
-3
@@ -19,6 +19,9 @@ import Icon from "./components/Icon";
|
||||
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
|
||||
*
|
||||
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
|
||||
*
|
||||
* `inputType: "password"` blendet die Eingabe aus (Passwortabfrage). Dort wird
|
||||
* bewusst nicht getrimmt – Leerzeichen dürfen Teil eines Passworts sein.
|
||||
*/
|
||||
const ConfirmContext = createContext(async () => false);
|
||||
|
||||
@@ -43,10 +46,15 @@ export function ConfirmProvider({ children }) {
|
||||
antwort.current = null;
|
||||
}
|
||||
|
||||
/** Passwörter unverändert lassen, sonst Rand-Leerzeichen wegschneiden. */
|
||||
function wert() {
|
||||
return frage?.inputType === "password" ? eingabe : eingabe.trim();
|
||||
}
|
||||
|
||||
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
|
||||
function bestaetigen() {
|
||||
if (!frage?.prompt) { schliessen(true); return; }
|
||||
const text = eingabe.trim();
|
||||
const text = wert();
|
||||
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
|
||||
schliessen(text);
|
||||
}
|
||||
@@ -67,7 +75,9 @@ export function ConfirmProvider({ children }) {
|
||||
{frage.prompt && (
|
||||
<div className="modal-body">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
|
||||
<input autoFocus type={frage.inputType || "text"}
|
||||
autoComplete={frage.inputType === "password" ? "current-password" : undefined}
|
||||
value={eingabe} onChange={(e) => setEingabe(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") bestaetigen();
|
||||
if (e.key === "Escape") schliessen(null);
|
||||
@@ -81,7 +91,7 @@ export function ConfirmProvider({ children }) {
|
||||
</button>
|
||||
<button type="button"
|
||||
className={frage.danger ? "btn danger-solid" : "btn primary"}
|
||||
disabled={frage.prompt && !eingabe.trim()}
|
||||
disabled={frage.prompt && !wert()}
|
||||
onClick={bestaetigen}>
|
||||
{frage.confirmLabel || "Fortfahren"}
|
||||
</button>
|
||||
|
||||
@@ -812,3 +812,136 @@ tr.row-dup {
|
||||
.kw-flag-word {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Passwort: Anforderungsliste, Zwei-Faktor ---------- */
|
||||
|
||||
/* Nur für Screenreader – optisch weg, aber vorlesbar. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Hakt sich beim Tippen ab. Farben ausschliesslich aus Tokens, sonst bricht
|
||||
das dunkle Thema. */
|
||||
.pw-rules {
|
||||
list-style: none;
|
||||
margin: var(--sp-2) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pw-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--muted);
|
||||
transition: color 0.16s ease;
|
||||
}
|
||||
.pw-rule.erfuellt {
|
||||
color: var(--ok);
|
||||
}
|
||||
.pw-rule-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Offener Punkt, solange die Regel nicht erfüllt ist. */
|
||||
.pw-rule-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Kurzer Impuls, wenn das Häkchen erscheint. */
|
||||
.pw-rule.erfuellt .pw-rule-mark {
|
||||
animation: pw-rule-pop 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pw-rule-pop {
|
||||
from { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.18); opacity: 1; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* styles.css hat keinen globalen Bewegungs-Reset, nur eine Aufzählung –
|
||||
deshalb hier noch einmal ausdrücklich für die neuen Bewegungen. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pw-rule { transition: none; }
|
||||
.pw-rule.erfuellt .pw-rule-mark { animation: none; }
|
||||
}
|
||||
|
||||
/* Zwei-Faktor: QR-Code, Secret und Wiederherstellungscodes. */
|
||||
.tfa-qr {
|
||||
display: block;
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
/* Der QR-Code braucht weissen Grund, damit er auch im dunklen Thema
|
||||
zuverlässig gescannt wird. */
|
||||
background: #fff;
|
||||
padding: var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.tfa-secret {
|
||||
display: inline-block;
|
||||
padding: 6px var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.06em;
|
||||
/* styles.css setzt body { user-select: none } – ohne das hier liesse sich
|
||||
das Secret nicht markieren und kopieren. */
|
||||
user-select: text;
|
||||
}
|
||||
.tfa-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--sp-2);
|
||||
margin: var(--sp-3) 0;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
user-select: text;
|
||||
}
|
||||
.tfa-codes .verbraucht {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Zusammenfassung der Prüfansicht beim Wiederherstellen. */
|
||||
.restore-summary {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.restore-summary td {
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.restore-summary td:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
.restore-summary tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
+93
-3
@@ -40,7 +40,8 @@
|
||||
"repeat": "Neues Passwort wiederholen",
|
||||
"submit": "Passwort speichern",
|
||||
"mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"saved": "Passwort geändert."
|
||||
"saved": "Passwort geändert.",
|
||||
"policyHint": "Die Passwortrichtlinie wurde geändert. Dein bisheriges Passwort erfüllt sie nicht mehr – bitte lege ein neues fest."
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzer",
|
||||
@@ -56,7 +57,12 @@
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"deleteConfirm": "Diesen Benutzer wirklich löschen?",
|
||||
"created": "Benutzer angelegt.",
|
||||
"you": "(du)"
|
||||
"you": "(du)",
|
||||
"twoFactor": "Zwei-Faktor",
|
||||
"resetTwoFactor": "Zwei-Faktor zurücksetzen",
|
||||
"resetTwoFactorConfirm": "Zwei-Faktor für „{{name}}“ abschalten? Nur nötig, wenn Gerät und Wiederherstellungscodes verloren sind.",
|
||||
"twoFactorReset": "Zwei-Faktor zurückgesetzt.",
|
||||
"generatePassword": "Passwort erzeugen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -84,7 +90,14 @@
|
||||
"keywordReviewHint": "Diese Schlagwörter werden auffällig oft wieder wegkorrigiert – stimmen sie vielleicht nicht mehr?",
|
||||
"keywordCorrected": "{{overridden}} von {{applied}} Buchungen korrigiert",
|
||||
"keep": "Behalten",
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"security": "Sicherheit",
|
||||
"securityHint": "Zwei-Faktor-Authentisierung für dein eigenes Konto.",
|
||||
"twoFactor": "Zwei-Faktor verwalten",
|
||||
"backupHint": "Das Voll-Backup enthält alle deine Daten und lässt sich unten wieder einspielen.",
|
||||
"exportBackup": "Voll-Backup (JSON)",
|
||||
"systemBackup": "Systemweites Voll-Backup (JSON)",
|
||||
"systemBackupWarning": "Das systemweite Backup enthält Passwort-Hashes und Zwei-Faktor-Geheimnisse aller Benutzer. Behandle die Datei wie ein Passwort."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
@@ -280,5 +293,82 @@
|
||||
"undone": "rückgängig gemacht",
|
||||
"needFile": "Bitte zuerst eine CSV-Datei wählen.",
|
||||
"needFileAccount": "Bitte Konto und Datei wählen."
|
||||
},
|
||||
"password": {
|
||||
"show": "Passwort anzeigen",
|
||||
"hide": "Passwort verbergen",
|
||||
"met": "(erfüllt)",
|
||||
"unmet": "(offen)",
|
||||
"ruleLength": "Mindestens {{n}} Zeichen",
|
||||
"ruleUpper": "Mindestens ein Grossbuchstabe",
|
||||
"ruleLower": "Mindestens ein Kleinbuchstabe",
|
||||
"ruleDigit": "Mindestens eine Ziffer",
|
||||
"ruleSpecial": "Mindestens ein Sonderzeichen"
|
||||
},
|
||||
"twofactor": {
|
||||
"title": "Zwei-Faktor-Authentisierung",
|
||||
"intro": "Zusätzlich zum Passwort ein Einmalcode aus einer Authenticator-App. Wer nur dein Passwort kennt, kommt damit nicht mehr hinein.",
|
||||
"on": "Aktiv",
|
||||
"off": "Nicht eingerichtet",
|
||||
"start": "Einrichten",
|
||||
"scan": "Scanne den Code mit deiner Authenticator-App (z. B. Aegis, 1Password, Google Authenticator).",
|
||||
"qrAlt": "QR-Code zur Einrichtung der Authenticator-App",
|
||||
"manual": "Geht der Scan nicht, gib diesen Schlüssel von Hand ein:",
|
||||
"code": "Code aus der App",
|
||||
"codeHint": "Gib den sechsstelligen Code aus deiner Authenticator-App ein.",
|
||||
"activate": "Aktivieren",
|
||||
"verify": "Bestätigen",
|
||||
"useRecovery": "Wiederherstellungscode verwenden",
|
||||
"useApp": "Doch Code aus der App",
|
||||
"recoveryCode": "Wiederherstellungscode",
|
||||
"recoveryHint": "Gib einen deiner Wiederherstellungscodes ein. Jeder funktioniert nur einmal.",
|
||||
"codesTitle": "Wiederherstellungscodes",
|
||||
"codesHint": "Bewahre diese Codes sicher auf – zum Beispiel ausgedruckt. Sie sind der einzige Weg hinein, wenn dein Gerät verloren geht. Jeder Code funktioniert genau einmal, und angezeigt werden sie nur dieses eine Mal.",
|
||||
"codesSaved": "Habe ich gesichert",
|
||||
"codesLeft": "Noch {{n}} unbenutzte Wiederherstellungscodes.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "In die Zwischenablage kopiert.",
|
||||
"download": "Als Datei sichern",
|
||||
"newCodes": "Neue Codes erzeugen",
|
||||
"newCodesTitle": "Neue Wiederherstellungscodes",
|
||||
"newCodesHint": "Die bisherigen Codes werden dabei ungültig. Zur Bestätigung dein Passwort eingeben.",
|
||||
"disable": "Zwei-Faktor abschalten",
|
||||
"disableTitle": "Zwei-Faktor abschalten",
|
||||
"disableHint": "Danach genügt wieder das Passwort allein. Zur Bestätigung dein Passwort eingeben.",
|
||||
"disabled": "Zwei-Faktor abgeschaltet.",
|
||||
"requiredHint": "Die Richtlinie verlangt Zwei-Faktor. Bis zur Einrichtung bleibt der Rest der Anwendung gesperrt."
|
||||
},
|
||||
"policy": {
|
||||
"title": "Passwortrichtlinie",
|
||||
"hint": "Gilt für alle Benutzer dieser Instanz.",
|
||||
"minLength": "Mindestlänge",
|
||||
"require_upper": "Grossbuchstabe verlangen",
|
||||
"require_lower": "Kleinbuchstabe verlangen",
|
||||
"require_digit": "Ziffer verlangen",
|
||||
"require_special": "Sonderzeichen verlangen",
|
||||
"mfaRequired": "Zwei-Faktor für alle verpflichtend",
|
||||
"mfaHint": "Benutzer ohne zweiten Faktor müssen ihn beim nächsten Anmelden einrichten.",
|
||||
"tightenWarning": "Verschärfung: Benutzer, deren Passwort die neue Richtlinie nicht erfüllt, müssen es beim nächsten Anmelden ändern.",
|
||||
"mfaWarning": "Alle Benutzer ohne Zwei-Faktor werden beim nächsten Anmelden zur Einrichtung geführt – auch du."
|
||||
},
|
||||
"restore": {
|
||||
"title": "Backup zurückladen",
|
||||
"hint": "Wähle eine Backup-Datei. Sie wird zuerst nur geprüft; eingespielt wird erst nach deiner Bestätigung.",
|
||||
"file": "Backup-Datei",
|
||||
"empty": "Die Datei enthält keine Daten.",
|
||||
"scopeUser": "Backup der Daten von „{{name}}“.",
|
||||
"scopeSystem": "Systemweites Backup mit {{n}} Benutzern.",
|
||||
"exportedAt": "Erstellt am {{when}}",
|
||||
"mode": "Vorgehen",
|
||||
"modeReplace": "Ersetzen",
|
||||
"modeMerge": "Ergänzen",
|
||||
"modeReplaceHint": "Vorhandene Daten werden gelöscht und durch das Backup ersetzt. Vorher legt der Server automatisch eine Sicherungskopie der Datenbank an.",
|
||||
"modeMergeHint": "Nichts wird gelöscht. Nur was fehlt, kommt hinzu; Budget, Regeln und Einstellungen bleiben unangetastet, wenn dazu schon etwas vorhanden ist.",
|
||||
"start": "Wiederherstellen",
|
||||
"confirmTitle": "Wirklich wiederherstellen?",
|
||||
"confirmReplace": "Alle deine Konten, Buchungen, Kategorien und Einstellungen werden gelöscht und durch das Backup ersetzt.",
|
||||
"confirmSystem": "Alle Benutzer und deren Daten werden gelöscht und durch das Backup ersetzt. Anschliessend sind alle Sitzungen beendet.",
|
||||
"done": "Wiederherstellung abgeschlossen.",
|
||||
"doneSystem": "Wiederherstellung abgeschlossen. Bitte neu anmelden."
|
||||
}
|
||||
}
|
||||
|
||||
+93
-3
@@ -40,7 +40,8 @@
|
||||
"repeat": "Repeat new password",
|
||||
"submit": "Save password",
|
||||
"mismatch": "The new passwords do not match.",
|
||||
"saved": "Password changed."
|
||||
"saved": "Password changed.",
|
||||
"policyHint": "The password policy has changed. Your current password no longer meets it – please choose a new one."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@@ -56,7 +57,12 @@
|
||||
"resetPassword": "Reset password",
|
||||
"deleteConfirm": "Really delete this user?",
|
||||
"created": "User created.",
|
||||
"you": "(you)"
|
||||
"you": "(you)",
|
||||
"twoFactor": "Two-factor",
|
||||
"resetTwoFactor": "Reset two-factor",
|
||||
"resetTwoFactorConfirm": "Turn off two-factor for “{{name}}”? Only needed if both device and recovery codes are lost.",
|
||||
"twoFactorReset": "Two-factor reset.",
|
||||
"generatePassword": "Generate password"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -84,7 +90,14 @@
|
||||
"keywordReviewHint": "These keywords keep getting corrected away — maybe they no longer fit?",
|
||||
"keywordCorrected": "{{overridden}} of {{applied}} transactions corrected",
|
||||
"keep": "Keep",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"security": "Security",
|
||||
"securityHint": "Two-factor authentication for your own account.",
|
||||
"twoFactor": "Manage two-factor",
|
||||
"backupHint": "The full backup contains all of your data and can be restored below.",
|
||||
"exportBackup": "Full backup (JSON)",
|
||||
"systemBackup": "System-wide full backup (JSON)",
|
||||
"systemBackupWarning": "The system-wide backup contains password hashes and two-factor secrets of every user. Treat the file like a password."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Overview",
|
||||
@@ -280,5 +293,82 @@
|
||||
"undone": "undone",
|
||||
"needFile": "Please choose a CSV file first.",
|
||||
"needFileAccount": "Please choose an account and a file."
|
||||
},
|
||||
"password": {
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"met": "(met)",
|
||||
"unmet": "(not met)",
|
||||
"ruleLength": "At least {{n}} characters",
|
||||
"ruleUpper": "At least one uppercase letter",
|
||||
"ruleLower": "At least one lowercase letter",
|
||||
"ruleDigit": "At least one digit",
|
||||
"ruleSpecial": "At least one special character"
|
||||
},
|
||||
"twofactor": {
|
||||
"title": "Two-factor authentication",
|
||||
"intro": "A one-time code from an authenticator app on top of your password. Knowing your password alone is no longer enough.",
|
||||
"on": "Active",
|
||||
"off": "Not set up",
|
||||
"start": "Set up",
|
||||
"scan": "Scan the code with your authenticator app (e.g. Aegis, 1Password, Google Authenticator).",
|
||||
"qrAlt": "QR code for setting up the authenticator app",
|
||||
"manual": "If scanning does not work, enter this key manually:",
|
||||
"code": "Code from the app",
|
||||
"codeHint": "Enter the six-digit code from your authenticator app.",
|
||||
"activate": "Activate",
|
||||
"verify": "Confirm",
|
||||
"useRecovery": "Use a recovery code",
|
||||
"useApp": "Use the app code instead",
|
||||
"recoveryCode": "Recovery code",
|
||||
"recoveryHint": "Enter one of your recovery codes. Each one works only once.",
|
||||
"codesTitle": "Recovery codes",
|
||||
"codesHint": "Keep these codes somewhere safe – printed, for example. They are the only way back in if you lose your device. Each code works exactly once and they are shown here only this one time.",
|
||||
"codesSaved": "I have saved them",
|
||||
"codesLeft": "{{n}} unused recovery codes left.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied to clipboard.",
|
||||
"download": "Save as file",
|
||||
"newCodes": "Generate new codes",
|
||||
"newCodesTitle": "New recovery codes",
|
||||
"newCodesHint": "The previous codes stop working. Enter your password to confirm.",
|
||||
"disable": "Turn off two-factor",
|
||||
"disableTitle": "Turn off two-factor",
|
||||
"disableHint": "Your password alone will be enough again. Enter your password to confirm.",
|
||||
"disabled": "Two-factor turned off.",
|
||||
"requiredHint": "The policy requires two-factor. The rest of the application stays locked until you set it up."
|
||||
},
|
||||
"policy": {
|
||||
"title": "Password policy",
|
||||
"hint": "Applies to every user of this instance.",
|
||||
"minLength": "Minimum length",
|
||||
"require_upper": "Require an uppercase letter",
|
||||
"require_lower": "Require a lowercase letter",
|
||||
"require_digit": "Require a digit",
|
||||
"require_special": "Require a special character",
|
||||
"mfaRequired": "Require two-factor for everyone",
|
||||
"mfaHint": "Users without a second factor must set one up at their next sign-in.",
|
||||
"tightenWarning": "Tightening: users whose password no longer meets the policy will have to change it at their next sign-in.",
|
||||
"mfaWarning": "Every user without two-factor will be taken to the setup at their next sign-in – including you."
|
||||
},
|
||||
"restore": {
|
||||
"title": "Restore a backup",
|
||||
"hint": "Pick a backup file. It is only inspected first; nothing is written until you confirm.",
|
||||
"file": "Backup file",
|
||||
"empty": "The file contains no data.",
|
||||
"scopeUser": "Backup of the data of “{{name}}”.",
|
||||
"scopeSystem": "System-wide backup with {{n}} users.",
|
||||
"exportedAt": "Created on {{when}}",
|
||||
"mode": "How to proceed",
|
||||
"modeReplace": "Replace",
|
||||
"modeMerge": "Add missing",
|
||||
"modeReplaceHint": "Existing data is deleted and replaced by the backup. The server writes a database snapshot beforehand.",
|
||||
"modeMergeHint": "Nothing is deleted. Only missing entries are added; budget, rules and settings stay untouched if anything is already there.",
|
||||
"start": "Restore",
|
||||
"confirmTitle": "Really restore?",
|
||||
"confirmReplace": "All your accounts, transactions, categories and settings will be deleted and replaced by the backup.",
|
||||
"confirmSystem": "All users and their data will be deleted and replaced by the backup. Every session ends afterwards.",
|
||||
"done": "Restore finished.",
|
||||
"doneSystem": "Restore finished. Please sign in again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../auth";
|
||||
@@ -6,6 +6,9 @@ import { api, ApiError } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
import Icon from "../components/Icon";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
import PasswordRules from "../components/PasswordRules";
|
||||
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
|
||||
|
||||
export default function ChangePassword() {
|
||||
const { user, aktualisieren } = useAuth();
|
||||
@@ -15,11 +18,21 @@ export default function ChangePassword() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [policy, setPolicy] = useState(DEFAULT_POLICY);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fällt der Abruf aus, bleibt die Vorgabe stehen – verbindlich prüft
|
||||
// ohnehin der Server, hier geht es nur um die Anzeige.
|
||||
api.getPolicy().then(setPolicy).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
const forced = user.must_change_password;
|
||||
const wegenRichtlinie = user.must_change_password_reason === "policy";
|
||||
const regelnErfuellt = meetsPolicy(policy, next, t);
|
||||
const bereit = regelnErfuellt && next === repeat && current.length > 0;
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -49,7 +62,7 @@ export default function ChangePassword() {
|
||||
{forced && (
|
||||
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="key" size={16} />
|
||||
<span>{t("changepw.forcedHint")}</span>
|
||||
<span>{wegenRichtlinie ? t("changepw.policyHint") : t("changepw.forcedHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && (
|
||||
@@ -59,24 +72,31 @@ export default function ChangePassword() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<label className="field">
|
||||
<span>{t("changepw.current")}</span>
|
||||
<input type="password" autoComplete="current-password" value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)} required />
|
||||
<PasswordInput value={current} onChange={setCurrent}
|
||||
autoComplete="current-password" required />
|
||||
</label>
|
||||
<label>
|
||||
<label className="field">
|
||||
<span>{t("changepw.new")}</span>
|
||||
<input type="password" autoComplete="new-password" value={next}
|
||||
onChange={(e) => setNext(e.target.value)} required />
|
||||
<PasswordInput value={next} onChange={setNext}
|
||||
autoComplete="new-password" required />
|
||||
</label>
|
||||
<label>
|
||||
<PasswordRules policy={policy} value={next} />
|
||||
<label className="field" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<span>{t("changepw.repeat")}</span>
|
||||
<input type="password" autoComplete="new-password" value={repeat}
|
||||
onChange={(e) => setRepeat(e.target.value)} required />
|
||||
<PasswordInput value={repeat} onChange={setRepeat}
|
||||
autoComplete="new-password" required />
|
||||
</label>
|
||||
{repeat && next !== repeat && (
|
||||
<span className="hint-danger">
|
||||
<Icon name="alert" size={12} />
|
||||
{t("changepw.mismatch")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}
|
||||
style={{ width: "100%", marginTop: "var(--sp-2)" }}>
|
||||
<button className="btn primary" type="submit" disabled={busy || !bereit}
|
||||
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
|
||||
{busy ? t("common.loading") : t("changepw.submit")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
+90
-17
@@ -5,24 +5,33 @@ import { useAuth } from "../auth";
|
||||
import { ApiError } from "../api";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
import Icon from "../components/Icon";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
export default function Login() {
|
||||
const { login, abgelaufen } = useAuth();
|
||||
const { login, verifyTwoFactor, abgelaufen } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [zeigePw, setZeigePw] = useState(false);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Zweiter Schritt: erst gesetzt, wenn der Server nach dem Code fragt.
|
||||
const [zweiterSchritt, setZweiterSchritt] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [wiederherstellung, setWiederherstellung] = useState(false);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
const res = await login(username, password);
|
||||
if (res.status === "2fa_required") {
|
||||
setZweiterSchritt(true);
|
||||
setPassword("");
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) setFehler(t("login.error"));
|
||||
else setFehler(err.message || t("server.unreachable"));
|
||||
@@ -31,6 +40,81 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onVerify(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await verifyTwoFactor(code.trim(), wiederherstellung);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setFehler(err.message || t("server.unreachable"));
|
||||
setCode("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function zurueck() {
|
||||
// Das Zwischen-Cookie läuft von allein ab; für den Neustart genügt es,
|
||||
// wieder das Anmeldeformular zu zeigen.
|
||||
setZweiterSchritt(false);
|
||||
setCode("");
|
||||
setWiederherstellung(false);
|
||||
setFehler(null);
|
||||
}
|
||||
|
||||
const meldung = fehler && (
|
||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{fehler}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (zweiterSchritt) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<form className="card auth-card" onSubmit={onVerify}>
|
||||
<BrandMark size={40} />
|
||||
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
|
||||
{wiederherstellung ? t("twofactor.recoveryHint") : t("twofactor.codeHint")}
|
||||
</p>
|
||||
{meldung}
|
||||
|
||||
<label className="field">
|
||||
<span>{wiederherstellung ? t("twofactor.recoveryCode") : t("twofactor.code")}</span>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode={wiederherstellung ? "text" : "numeric"}
|
||||
maxLength={wiederherstellung ? 32 : 6}
|
||||
placeholder={wiederherstellung ? "XXXXX-XXXXX" : "123456"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}
|
||||
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
|
||||
{busy ? t("common.loading") : t("twofactor.verify")}
|
||||
</button>
|
||||
|
||||
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={() => { setWiederherstellung((v) => !v); setCode(""); setFehler(null); }}>
|
||||
{wiederherstellung ? t("twofactor.useApp") : t("twofactor.useRecovery")}
|
||||
</button>
|
||||
<button type="button" className="btn sm ghost" onClick={zurueck}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
@@ -43,12 +127,7 @@ export default function Login() {
|
||||
<span>{t("login.expired")}</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && (
|
||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
{meldung}
|
||||
|
||||
<label className="field">
|
||||
<span>{t("login.username")}</span>
|
||||
@@ -59,14 +138,8 @@ export default function Login() {
|
||||
|
||||
<label className="field">
|
||||
<span>{t("login.password")}</span>
|
||||
<div className="input-with-button">
|
||||
<input type={zeigePw ? "text" : "password"} autoComplete="current-password"
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<button type="button" className="btn-icon" onClick={() => setZeigePw((v) => !v)}
|
||||
title={zeigePw ? t("common.close") : ""} tabIndex={-1}>
|
||||
<Icon name={zeigePw ? "eye-off" : "eye"} size={17} />
|
||||
</button>
|
||||
</div>
|
||||
<PasswordInput value={password} onChange={setPassword}
|
||||
autoComplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../settings";
|
||||
import { useAuth } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import PolicyCard from "./settings/PolicyCard";
|
||||
import RestoreCard from "./settings/RestoreCard";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -116,9 +119,26 @@ export default function Settings() {
|
||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
||||
<Icon name="shield" size={18} /> {t("settings.security")}
|
||||
</h2>
|
||||
<p className="muted small">{t("settings.securityHint")}</p>
|
||||
<Link className="btn" to="/zwei-faktor">
|
||||
<Icon name="smartphone" size={15} /> {t("settings.twoFactor")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isAdmin && <PolicyCard />}
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||
<h2>{t("settings.backup")}</h2>
|
||||
<p className="muted small">{t("settings.exportHint")}</p>
|
||||
<p className="muted small">{t("settings.backupHint")}</p>
|
||||
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
|
||||
<Icon name="download" size={15} /> {t("settings.exportBackup")}
|
||||
</button>
|
||||
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.exportHint")}</p>
|
||||
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||||
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
|
||||
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
|
||||
@@ -130,15 +150,27 @@ export default function Settings() {
|
||||
<Icon name="download" size={15} /> {t("settings.exportAll")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>{t("settings.dbBackupHint")}</p>
|
||||
<button className="btn primary" onClick={() => dl(api.downloadDbBackup)}>
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.dbBackupHint")}</p>
|
||||
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||||
<button className="btn" onClick={() => dl(api.downloadDbBackup)}>
|
||||
<Icon name="download" size={15} /> {t("settings.dbBackup")}
|
||||
</button>
|
||||
<button className="btn" onClick={() => dl(api.exportSystemBackupJson)}>
|
||||
<Icon name="download" size={15} /> {t("settings.systemBackup")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{t("settings.systemBackupWarning")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RestoreCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../auth";
|
||||
import { api } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
/** Codeliste als Textdatei sichern – Zwischenablage ist nicht überall erlaubt. */
|
||||
function codesSichern(codes, benutzer) {
|
||||
const inhalt =
|
||||
`Certain Finance – Wiederherstellungscodes für ${benutzer}\n` +
|
||||
`Jeder Code funktioniert genau einmal. Sicher aufbewahren.\n\n` +
|
||||
codes.join("\n") + "\n";
|
||||
const url = URL.createObjectURL(new Blob([inhalt], { type: "text/plain;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `certain-finance-wiederherstellungscodes_${benutzer}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Anzeige der frisch erzeugten Codes – sie sind danach nicht mehr abrufbar. */
|
||||
function CodeListe({ codes, benutzer, onFertig }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
async function kopieren() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codes.join("\n"));
|
||||
toast(t("twofactor.copied"));
|
||||
} catch {
|
||||
// Ohne sicheren Kontext (http) gibt es keine Zwischenablage – dann
|
||||
// bleibt der Download, und markieren geht ohnehin.
|
||||
codesSichern(codes, benutzer);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)", borderColor: "var(--warn)" }}>
|
||||
<h2 style={{ marginTop: 0 }}>{t("twofactor.codesTitle")}</h2>
|
||||
<p className="muted small">{t("twofactor.codesHint")}</p>
|
||||
<div className="tfa-codes selectable">
|
||||
{codes.map((c) => <span key={c}>{c}</span>)}
|
||||
</div>
|
||||
<div className="btn-pair">
|
||||
<button className="btn" onClick={kopieren}>
|
||||
<Icon name="copy" size={15} /> {t("twofactor.copy")}
|
||||
</button>
|
||||
<button className="btn" onClick={() => codesSichern(codes, benutzer)}>
|
||||
<Icon name="download" size={15} /> {t("twofactor.download")}
|
||||
</button>
|
||||
<button className="btn primary" onClick={onFertig}>{t("twofactor.codesSaved")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TwoFactor() {
|
||||
const { user, aktualisieren } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState(null);
|
||||
const [policy, setPolicy] = useState(null);
|
||||
const [setup, setSetup] = useState(null); // { secret, otpauth_uri, qr_png }
|
||||
const [code, setCode] = useState("");
|
||||
const [codes, setCodes] = useState(null); // frisch erzeugte Wiederherstellungscodes
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [s, p] = await Promise.all([api.twoFactorStatus(), api.getPolicy()]);
|
||||
setStatus(s);
|
||||
setPolicy(p);
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
useEffect(() => { laden(); }, []);
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
// Erst Passwort wechseln, dann zweiter Faktor – dieselbe Reihenfolge, die
|
||||
// der Server erzwingt.
|
||||
if (user.must_change_password) return <Navigate to="/passwort-aendern" replace />;
|
||||
|
||||
const pflicht = Boolean(policy?.mfa_required);
|
||||
const erzwungen = pflicht && !status?.enabled;
|
||||
|
||||
async function starten() {
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
setSetup(await api.twoFactorSetup());
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function aktivieren(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.twoFactorActivate(code.trim());
|
||||
setCodes(res.codes);
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
// Die Sperre „MFA einrichten" fällt damit weg – Benutzer neu laden.
|
||||
aktualisieren(await api.me());
|
||||
await laden();
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function abschalten() {
|
||||
const passwort = await confirm({
|
||||
title: t("twofactor.disableTitle"),
|
||||
message: t("twofactor.disableHint"),
|
||||
confirmLabel: t("twofactor.disable"),
|
||||
danger: true,
|
||||
prompt: true,
|
||||
inputType: "password",
|
||||
});
|
||||
if (!passwort) return;
|
||||
try {
|
||||
aktualisieren(await api.twoFactorDisable(passwort));
|
||||
toast(t("twofactor.disabled"));
|
||||
await laden();
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function codesErneuern() {
|
||||
const passwort = await confirm({
|
||||
title: t("twofactor.newCodesTitle"),
|
||||
message: t("twofactor.newCodesHint"),
|
||||
confirmLabel: t("twofactor.newCodes"),
|
||||
prompt: true,
|
||||
inputType: "password",
|
||||
});
|
||||
if (!passwort) return;
|
||||
try {
|
||||
const res = await api.newRecoveryCodes(passwort);
|
||||
setCodes(res.codes);
|
||||
await laden();
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{t("twofactor.title")}</h1>
|
||||
|
||||
{fehler && (
|
||||
<div className="alert error" style={{ maxWidth: 520, marginBottom: "var(--sp-4)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
{erzwungen && (
|
||||
<div className="alert warn" style={{ maxWidth: 520, marginBottom: "var(--sp-4)" }}>
|
||||
<Icon name="shield" size={16} />
|
||||
<span>{t("twofactor.requiredHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ maxWidth: 520 }}>
|
||||
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
|
||||
<Icon name="shield" size={18} />
|
||||
{status?.enabled ? t("twofactor.on") : t("twofactor.off")}
|
||||
</h2>
|
||||
<p className="muted small">{t("twofactor.intro")}</p>
|
||||
|
||||
{status?.enabled ? (
|
||||
<>
|
||||
<p className="small">
|
||||
{t("twofactor.codesLeft", { n: status.recovery_codes_left })}
|
||||
</p>
|
||||
<div className="btn-pair">
|
||||
<button className="btn" onClick={codesErneuern}>
|
||||
<Icon name="refresh-ccw" size={15} /> {t("twofactor.newCodes")}
|
||||
</button>
|
||||
{!pflicht && (
|
||||
<button className="btn danger" onClick={abschalten}>
|
||||
{t("twofactor.disable")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : setup ? (
|
||||
<form onSubmit={aktivieren}>
|
||||
<p className="small">{t("twofactor.scan")}</p>
|
||||
<img className="tfa-qr" src={setup.qr_png} alt={t("twofactor.qrAlt")} />
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>
|
||||
{t("twofactor.manual")}
|
||||
</p>
|
||||
<code className="tfa-secret selectable">{setup.secret}</code>
|
||||
|
||||
<label className="field" style={{ marginTop: "var(--sp-4)" }}>
|
||||
<span>{t("twofactor.code")}</span>
|
||||
<input value={code} onChange={(e) => setCode(e.target.value)} required
|
||||
inputMode="numeric" maxLength={6} autoComplete="one-time-code"
|
||||
placeholder="123456" />
|
||||
</label>
|
||||
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{busy ? t("common.loading") : t("twofactor.activate")}
|
||||
</button>
|
||||
<button className="btn ghost" type="button" onClick={() => { setSetup(null); setCode(""); }}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn primary" onClick={starten} disabled={busy}>
|
||||
<Icon name="smartphone" size={15} /> {t("twofactor.start")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codes && (
|
||||
<CodeListe
|
||||
codes={codes}
|
||||
benutzer={user.username}
|
||||
onFertig={() => { setCodes(null); if (erzwungen) navigate("/"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+46
-12
@@ -6,13 +6,8 @@ import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
|
||||
function randomPassword(len = 12) {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
const arr = new Uint32Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (n) => chars[n % chars.length]).join("");
|
||||
}
|
||||
import PasswordRules from "../components/PasswordRules";
|
||||
import { DEFAULT_POLICY, meetsPolicy, randomPassword } from "../passwordPolicy";
|
||||
|
||||
export default function Users() {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,6 +16,7 @@ export default function Users() {
|
||||
const toast = useToast();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [policy, setPolicy] = useState(DEFAULT_POLICY);
|
||||
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -35,6 +31,8 @@ export default function Users() {
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
// Die Richtlinie bestimmt, was das erzeugte Temporär-Passwort erfüllen muss.
|
||||
api.getPolicy().then(setPolicy).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function anlegen(e) {
|
||||
@@ -63,13 +61,31 @@ export default function Users() {
|
||||
title: t("users.resetPassword"),
|
||||
message: u.username,
|
||||
prompt: true,
|
||||
defaultValue: randomPassword(),
|
||||
defaultValue: randomPassword(policy),
|
||||
confirmLabel: t("common.save"),
|
||||
});
|
||||
if (!neu) return;
|
||||
try {
|
||||
await api.updateUser(u.id, { password: neu });
|
||||
toast(t("settings.saved"));
|
||||
laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function zweiFaktorZuruecksetzen(u) {
|
||||
const ok = await confirm({
|
||||
title: t("users.resetTwoFactor"),
|
||||
message: t("users.resetTwoFactorConfirm", { name: u.username }),
|
||||
confirmLabel: t("users.resetTwoFactor"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.resetUserTwoFactor(u.id);
|
||||
toast(t("users.twoFactorReset"));
|
||||
laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
@@ -123,12 +139,26 @@ export default function Users() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions", header: t("common.actions"), width: 132, fixed: true,
|
||||
key: "twofactor", header: t("users.twoFactor"), width: 110,
|
||||
filterText: (u) => (u.totp_enabled ? t("twofactor.on") : t("twofactor.off")),
|
||||
render: (u) => (
|
||||
<span className={`badge ${u.totp_enabled ? "ok" : ""}`}>
|
||||
{u.totp_enabled ? t("twofactor.on") : t("twofactor.off")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions", header: t("common.actions"), width: 168, fixed: true,
|
||||
render: (u) => (
|
||||
<div className="nowrap" style={{ textAlign: "right" }}>
|
||||
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
|
||||
<Icon name="key" size={15} />
|
||||
</button>
|
||||
{u.totp_enabled && (
|
||||
<button className="btn sm ghost" title={t("users.resetTwoFactor")} onClick={() => zweiFaktorZuruecksetzen(u)}>
|
||||
<Icon name="shield" size={15} />
|
||||
</button>
|
||||
)}
|
||||
{u.id !== self?.id && (
|
||||
<>
|
||||
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
|
||||
@@ -142,8 +172,9 @@ export default function Users() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// policy muss mit hinein: das erzeugte Vorschlagspasswort richtet sich danach.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [t, self]);
|
||||
], [t, self, policy]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -174,10 +205,12 @@ export default function Users() {
|
||||
<span>{t("users.password")}</span>
|
||||
<div className="input-with-button">
|
||||
<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||
<button type="button" className="btn sm" onClick={() => setForm({ ...form, password: randomPassword() })}>
|
||||
<button type="button" className="btn sm" title={t("users.generatePassword")}
|
||||
onClick={() => setForm({ ...form, password: randomPassword(policy) })}>
|
||||
<Icon name="refresh-ccw" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<PasswordRules policy={policy} value={form.password} />
|
||||
</label>
|
||||
<label style={{ minWidth: 160 }}>
|
||||
<span>{t("users.role")}</span>
|
||||
@@ -188,7 +221,8 @@ export default function Users() {
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">{t("users.passwordHint")}</p>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
<button className="btn primary" type="submit"
|
||||
disabled={busy || !meetsPolicy(policy, form.password, t)}>
|
||||
{busy ? t("common.loading") : t("common.create")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../api";
|
||||
import { useToast } from "../../toast";
|
||||
import Icon from "../../components/Icon";
|
||||
import { DEFAULT_POLICY } from "../../passwordPolicy";
|
||||
|
||||
const SCHALTER = ["require_upper", "require_lower", "require_digit", "require_special"];
|
||||
|
||||
/**
|
||||
* Passwortrichtlinie – gilt für die ganze Instanz, deshalb nur für Admins.
|
||||
*
|
||||
* Bewusst mit Speichern-Knopf statt „sofort übernehmen" wie bei den
|
||||
* persönlichen Einstellungen: eine Verschärfung wirkt auf alle Benutzer, das
|
||||
* soll ein bewusster Schritt sein.
|
||||
*/
|
||||
export default function PolicyCard() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [policy, setPolicy] = useState(null);
|
||||
const [entwurf, setEntwurf] = useState(DEFAULT_POLICY);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getPolicy()
|
||||
.then((p) => { setPolicy(p); setEntwurf(p); })
|
||||
.catch((err) => setFehler(err.message));
|
||||
}, []);
|
||||
|
||||
const geaendert = policy && SCHALTER.concat("min_length", "mfa_required")
|
||||
.some((k) => policy[k] !== entwurf[k]);
|
||||
const verschaerft = policy && (
|
||||
entwurf.min_length > policy.min_length ||
|
||||
SCHALTER.some((k) => entwurf[k] && !policy[k])
|
||||
);
|
||||
const mfaNeu = policy && entwurf.mfa_required && !policy.mfa_required;
|
||||
|
||||
async function speichern() {
|
||||
setBusy(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const gespeichert = await api.setPolicy(entwurf);
|
||||
setPolicy(gespeichert);
|
||||
setEntwurf(gespeichert);
|
||||
toast(t("settings.saved"));
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!policy) {
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||
<h2 style={{ marginTop: 0 }}>{t("policy.title")}</h2>
|
||||
{fehler ? <div className="alert error"><Icon name="alert" size={16} /><span>{fehler}</span></div>
|
||||
: <p className="muted small">{t("common.loading")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
||||
<Icon name="lock" size={18} /> {t("policy.title")}
|
||||
</h2>
|
||||
<p className="muted small">{t("policy.hint")}</p>
|
||||
|
||||
{fehler && (
|
||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} /><span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>{t("policy.minLength")}</span>
|
||||
<input type="number" min="8" max="128" value={entwurf.min_length}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(8, Math.min(128, Number(e.target.value) || 8));
|
||||
setEntwurf({ ...entwurf, min_length: n });
|
||||
}} />
|
||||
</label>
|
||||
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
{SCHALTER.map((k) => (
|
||||
<label key={k} className="field-inline" style={{ marginBottom: 6 }}>
|
||||
<input type="checkbox" checked={entwurf[k]}
|
||||
onChange={(e) => setEntwurf({ ...entwurf, [k]: e.target.checked })} />
|
||||
<span>{t(`policy.${k}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "var(--sp-4)", paddingTop: "var(--sp-3)", borderTop: "1px solid var(--border)" }}>
|
||||
<label className="field-inline">
|
||||
<input type="checkbox" checked={entwurf.mfa_required}
|
||||
onChange={(e) => setEntwurf({ ...entwurf, mfa_required: e.target.checked })} />
|
||||
<span>{t("policy.mfaRequired")}</span>
|
||||
</label>
|
||||
<p className="muted small" style={{ marginTop: 4 }}>{t("policy.mfaHint")}</p>
|
||||
</div>
|
||||
|
||||
{verschaerft && (
|
||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} /><span>{t("policy.tightenWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
{mfaNeu && (
|
||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="shield" size={16} /><span>{t("policy.mfaWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn primary" style={{ marginTop: "var(--sp-3)" }}
|
||||
disabled={busy || !geaendert} onClick={speichern}>
|
||||
{busy ? t("common.loading") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../api";
|
||||
import { useAuth } from "../../auth";
|
||||
import { useConfirm } from "../../confirm";
|
||||
import { useToast } from "../../toast";
|
||||
import Icon from "../../components/Icon";
|
||||
|
||||
/**
|
||||
* Backup zurückladen – zweistufig wie der CSV-Import: erst prüfen lassen, was
|
||||
* in der Datei steckt, dann bewusst bestätigen. Der Server schreibt beim Prüfen
|
||||
* nichts.
|
||||
*/
|
||||
export default function RestoreCard() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const dateiFeld = useRef(null);
|
||||
|
||||
const [datei, setDatei] = useState(null);
|
||||
const [vorschau, setVorschau] = useState(null);
|
||||
const [modus, setModus] = useState("replace");
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function zuruecksetzen() {
|
||||
setDatei(null);
|
||||
setVorschau(null);
|
||||
setFehler(null);
|
||||
if (dateiFeld.current) dateiFeld.current.value = "";
|
||||
}
|
||||
|
||||
async function pruefen(f) {
|
||||
setDatei(f);
|
||||
setVorschau(null);
|
||||
setFehler(null);
|
||||
if (!f) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setVorschau(await api.restorePreview(f));
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function einspielen() {
|
||||
const systemweit = vorschau.scope === "system";
|
||||
if (modus === "replace") {
|
||||
const ok = await confirm({
|
||||
title: t("restore.confirmTitle"),
|
||||
message: systemweit ? t("restore.confirmSystem") : t("restore.confirmReplace"),
|
||||
confirmLabel: t("restore.start"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const res = await api.restoreBackup(datei, systemweit ? "replace" : modus);
|
||||
if (res.logout_required) {
|
||||
// Die eigene Sitzung wurde beim Ersetzen der Benutzertabelle mit
|
||||
// gelöscht – sauber abmelden statt in 401er zu laufen.
|
||||
toast(t("restore.doneSystem"));
|
||||
await logout();
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
toast(t("restore.done"));
|
||||
zuruecksetzen();
|
||||
// Konten, Kategorien und Einstellungen sind komplett andere – neu laden
|
||||
// ist ehrlicher als jeden Zwischenspeicher einzeln zu erneuern.
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
||||
<Icon name="upload" size={18} /> {t("restore.title")}
|
||||
</h2>
|
||||
<p className="muted small">{t("restore.hint")}</p>
|
||||
|
||||
{fehler && (
|
||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} /><span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>{t("restore.file")}</span>
|
||||
<input ref={dateiFeld} type="file" accept="application/json,.json"
|
||||
onChange={(e) => pruefen(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
|
||||
{busy && !vorschau && <p className="muted small">{t("common.loading")}</p>}
|
||||
|
||||
{vorschau && (
|
||||
<>
|
||||
<div className="alert" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="check" size={16} />
|
||||
<div>
|
||||
<div>
|
||||
{vorschau.scope === "system"
|
||||
? t("restore.scopeSystem", { n: vorschau.user_count })
|
||||
: t("restore.scopeUser", { name: vorschau.username || "?" })}
|
||||
</div>
|
||||
<div className="muted small" style={{ marginTop: 2 }}>
|
||||
{t("restore.exportedAt", { when: vorschau.exported_at || "?" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="restore-summary">
|
||||
<tbody>
|
||||
{vorschau.tables.map((z) => (
|
||||
<tr key={z.key}>
|
||||
<td>{z.label}</td>
|
||||
<td>{z.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
{vorschau.tables.length === 0 && (
|
||||
<tr><td colSpan={2} className="muted">{t("restore.empty")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{vorschau.notes.map((n) => (
|
||||
<div className="alert warn" key={n} style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} /><span>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{vorschau.scope === "user" && (
|
||||
<label style={{ marginTop: "var(--sp-3)" }}>
|
||||
<span>{t("restore.mode")}</span>
|
||||
<select value={modus} onChange={(e) => setModus(e.target.value)}>
|
||||
<option value="replace">{t("restore.modeReplace")}</option>
|
||||
<option value="merge">{t("restore.modeMerge")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<p className="muted small">
|
||||
{vorschau.scope === "system" || modus === "replace"
|
||||
? t("restore.modeReplaceHint")
|
||||
: t("restore.modeMergeHint")}
|
||||
</p>
|
||||
|
||||
<div className="btn-pair">
|
||||
<button className="btn danger-solid" disabled={busy} onClick={einspielen}>
|
||||
{busy ? t("common.loading") : t("restore.start")}
|
||||
</button>
|
||||
<button className="btn ghost" disabled={busy} onClick={zuruecksetzen}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Auswertung der Passwortrichtlinie im Browser – nur für die Anzeige.
|
||||
*
|
||||
* Verbindlich prüft der Server (backend/app/services/policy.py); hier geht es
|
||||
* allein darum, beim Tippen sofort zu zeigen, was noch fehlt. Die Regeln müssen
|
||||
* deshalb mit der Backend-Fassung übereinstimmen:
|
||||
* Grossbuchstabe = \p{Lu}, Kleinbuchstabe = \p{Ll}, Ziffer = \p{Nd},
|
||||
* Sonderzeichen = weder Buchstabe noch Ziffer noch Leerraum.
|
||||
*/
|
||||
|
||||
export const DEFAULT_POLICY = {
|
||||
min_length: 10,
|
||||
require_upper: true,
|
||||
require_lower: true,
|
||||
require_digit: true,
|
||||
require_special: false,
|
||||
mfa_required: false,
|
||||
};
|
||||
|
||||
const PRUEFUNGEN = {
|
||||
upper: (pw) => /\p{Lu}/u.test(pw),
|
||||
lower: (pw) => /\p{Ll}/u.test(pw),
|
||||
digit: (pw) => /\p{Nd}/u.test(pw),
|
||||
special: (pw) => /[^\p{L}\p{N}\s]/u.test(pw),
|
||||
};
|
||||
|
||||
/**
|
||||
* Liefert die aktiven Regeln als Liste – jeweils mit Kennung und Erfüllt-Zustand.
|
||||
* `t` ist die i18n-Funktion; die Texte liegen unter `password.rule*`.
|
||||
*/
|
||||
export function ruleList(policy, password, t) {
|
||||
const p = policy || DEFAULT_POLICY;
|
||||
const pw = password || "";
|
||||
const regeln = [
|
||||
{ id: "length", text: t("password.ruleLength", { n: p.min_length }), ok: pw.length >= p.min_length },
|
||||
];
|
||||
for (const id of ["upper", "lower", "digit", "special"]) {
|
||||
if (p[`require_${id}`]) {
|
||||
regeln.push({
|
||||
id,
|
||||
text: t(`password.rule${id[0].toUpperCase()}${id.slice(1)}`),
|
||||
ok: PRUEFUNGEN[id](pw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return regeln;
|
||||
}
|
||||
|
||||
/** True, wenn das Passwort alle aktiven Regeln erfüllt. */
|
||||
export function meetsPolicy(policy, password, t) {
|
||||
return ruleList(policy, password, t).every((r) => r.ok);
|
||||
}
|
||||
|
||||
const GROSS = "ABCDEFGHJKMNPQRSTUVWXYZ"; // ohne I, O, L
|
||||
const KLEIN = "abcdefghijkmnpqrstuvwxyz";
|
||||
const ZIFFERN = "23456789";
|
||||
const SONDER = "!?%+*#$";
|
||||
|
||||
function ziehen(alphabet, anzahl) {
|
||||
const werte = new Uint32Array(anzahl);
|
||||
crypto.getRandomValues(werte);
|
||||
return Array.from(werte, (v) => alphabet[v % alphabet.length]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein zufälliges Passwort, das die Richtlinie garantiert erfüllt.
|
||||
* Zeichen, die man leicht verwechselt (0/O, 1/I/l), bleiben draussen – solche
|
||||
* Passwörter werden vom Admin abgetippt oder weitergegeben.
|
||||
*/
|
||||
export function randomPassword(policy) {
|
||||
const p = policy || DEFAULT_POLICY;
|
||||
const laenge = Math.max(p.min_length, 12);
|
||||
const pflicht = [];
|
||||
if (p.require_upper) pflicht.push(...ziehen(GROSS, 1));
|
||||
if (p.require_lower) pflicht.push(...ziehen(KLEIN, 1));
|
||||
if (p.require_digit) pflicht.push(...ziehen(ZIFFERN, 1));
|
||||
if (p.require_special) pflicht.push(...ziehen(SONDER, 1));
|
||||
|
||||
const rest = GROSS + KLEIN + ZIFFERN + (p.require_special ? SONDER : "");
|
||||
const zeichen = [...pflicht, ...ziehen(rest, Math.max(0, laenge - pflicht.length))];
|
||||
|
||||
// Mischen, damit die Pflichtzeichen nicht immer vorne stehen (Fisher-Yates
|
||||
// mit kryptografischer Quelle).
|
||||
const misch = new Uint32Array(zeichen.length);
|
||||
crypto.getRandomValues(misch);
|
||||
for (let i = zeichen.length - 1; i > 0; i--) {
|
||||
const j = misch[i] % (i + 1);
|
||||
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
|
||||
}
|
||||
return zeichen.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user