Sitzungsdauer einstellbar, bis hin zu unbegrenzt
Die Anmeldung galt fest 7 Tage - und die Frist lief ab dem *Anmelden*, nicht ab der letzten Nutzung. Wer die App taeglich benutzte, flog trotzdem punktgenau nach einer Woche hinaus. Beides ist jetzt geregelt: - Einstellungen -> Anmeldung: 7 Tage bis 1 Jahr oder unbegrenzt. Gilt fuer Website und App gemeinsam, weil beide dasselbe Token bekommen - auch nach Single Sign-on. - POST /auth/refresh schiebt die Frist beim Start nach vorn. Damit zaehlt die eingestellte Dauer ab dem letzten Besuch, und eine geaenderte Einstellung greift ohne Neuanmeldung. "Unbegrenzt" heisst ein Token ganz ohne exp. Das ist bewusst folgenreich: Es gibt keine Sperrliste, eine erteilte Anmeldung laesst sich nicht einzeln zurueckziehen. Die Oberflaeche sagt das dazu und nennt den einzigen Ausweg (JWT_SECRET aendern). /auth/refresh nimmt bewusst nur JWTs an, keine langlebigen API-Token: aus einem jederzeit widerrufbaren Zugang (Home Assistant) waere sonst ein unwiderruflicher geworden. Der Rueckfallwert aus der Umgebung steigt von 7 auf 30 Tage - er gilt nur noch, solange nichts eingestellt ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2b9b98dd14
commit
254737086e
@@ -52,6 +52,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
||||
- **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst
|
||||
gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt
|
||||
daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional).
|
||||
- **Sitzungsdauer einstellbar** (Einstellungen → Anmeldung): 7 Tage bis 1 Jahr
|
||||
oder unbegrenzt, für Website und App gemeinsam. Die Frist zählt ab dem letzten
|
||||
Besuch, nicht ab dem Anmelden.
|
||||
|
||||
### In der iOS-App zusätzlich
|
||||
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
||||
|
||||
@@ -12,7 +12,10 @@ class Settings(BaseSettings):
|
||||
# Auth / JWT
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
# Rueckfallwert fuer die Sitzungsdauer. Im Betrieb entscheidet die
|
||||
# Einstellung "session_days" (Verwaltung -> Einstellungen); dieser Wert gilt
|
||||
# nur, solange dort nichts gesetzt ist.
|
||||
jwt_expire_minutes: int = 60 * 24 * 30 # 30 Tage
|
||||
|
||||
# First admin, created on startup if no users exist
|
||||
admin_username: str = "admin"
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ _credentials_exc = HTTPException(
|
||||
)
|
||||
|
||||
|
||||
def _nutzer_aus_payload(db: Session, payload: dict) -> User | None:
|
||||
def nutzer_aus_payload(db: Session, payload: dict) -> User | None:
|
||||
"""Loest die Sitzung auf – bevorzugt ueber die Benutzer-ID.
|
||||
|
||||
Der Benutzername ist Anzeige und darf sich aendern; die ID nicht. Deshalb
|
||||
@@ -44,7 +44,7 @@ def get_current_user(
|
||||
"""Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token."""
|
||||
payload = decode_access_token(token)
|
||||
if payload is not None:
|
||||
user = _nutzer_aus_payload(db, payload)
|
||||
user = nutzer_aus_payload(db, payload)
|
||||
if user is None:
|
||||
raise _credentials_exc
|
||||
return user
|
||||
|
||||
@@ -3,10 +3,11 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..deps import get_current_user, nutzer_aus_payload, oauth2_scheme
|
||||
from ..models import User
|
||||
from ..schemas import Token, UserOut
|
||||
from ..security import create_access_token, verify_password
|
||||
from ..security import create_access_token, decode_access_token, verify_password
|
||||
from .settings import get_session_minutes
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -21,10 +22,42 @@ def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Benutzername oder Passwort falsch",
|
||||
)
|
||||
token = create_access_token(user_id=user.id, role=user.role.value)
|
||||
token = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
def refresh(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> Token:
|
||||
"""Stellt der laufenden Sitzung ein frisches Token aus.
|
||||
|
||||
Ohne das laeuft die Frist ab dem *Anmelden*, nicht ab der letzten Nutzung:
|
||||
Wer die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf
|
||||
hinaus. Weboberflaeche und App rufen das beim Start auf; damit zaehlt die
|
||||
eingestellte Dauer ab dem letzten Besuch.
|
||||
|
||||
Bewusst NICHT ueber ``get_current_user``: das nimmt auch langlebige
|
||||
API-Token an (Home Assistant). Aus so einem Token hier ein JWT zu machen,
|
||||
haette aus einem jederzeit widerrufbaren Zugang einen unwiderruflichen
|
||||
gemacht.
|
||||
"""
|
||||
payload = decode_access_token(token)
|
||||
user = nutzer_aus_payload(db, payload) if payload is not None else None
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Ungültige oder abgelaufene Anmeldung",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
neu = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=neu, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
@@ -35,6 +35,7 @@ from ..schemas import (
|
||||
Token,
|
||||
)
|
||||
from ..security import create_access_token
|
||||
from .settings import get_session_minutes
|
||||
from ..services import oidc as dienst
|
||||
from ..services import oidc_state
|
||||
|
||||
@@ -63,8 +64,12 @@ def _provider_oder_404(slug: str) -> OidcProvider:
|
||||
return provider
|
||||
|
||||
|
||||
def _token_fuer(user: User) -> Token:
|
||||
token = create_access_token(user_id=user.id, role=user.role.value)
|
||||
def _token_fuer(user: User, db: Session) -> Token:
|
||||
# Die Sitzungsdauer steht in den Einstellungen und gilt fuer JEDEN Weg
|
||||
# herein - nach Single Sign-on genauso wie nach dem Passwort-Login.
|
||||
token = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@@ -209,7 +214,7 @@ def anmeldung_abschliessen(
|
||||
logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
|
||||
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
|
||||
|
||||
token = _token_fuer(nutzer)
|
||||
token = _token_fuer(nutzer, db)
|
||||
logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username)
|
||||
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
|
||||
# an den Server geschickt und landen damit auch nicht in den nginx-Logs.
|
||||
@@ -253,7 +258,7 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler
|
||||
|
||||
logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -394,7 +399,7 @@ def offene_anmeldung_neues_konto(code: str, db: Session = Depends(get_db)) -> To
|
||||
except dienst.OidcFehler as fehler:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from fehler
|
||||
logger.info("SSO '%s': neues Konto '%s' auf Wunsch angelegt.", provider.slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
|
||||
@router.post("/pending/{code}/verknuepfen", response_model=Token)
|
||||
@@ -417,4 +422,4 @@ def offene_anmeldung_verknuepfen(
|
||||
)
|
||||
raise HTTPException(code_http, str(fehler)) from fehler
|
||||
logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
@@ -14,6 +14,11 @@ EXPIRY_WARNING_KEY = "expiry_warning_days"
|
||||
# Treffer vorgeschlagen wird.
|
||||
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
|
||||
RECEIPT_THRESHOLD_DEFAULT = 45
|
||||
# Wie lange eine Anmeldung gilt, in Tagen. 0 = unbegrenzt.
|
||||
SESSION_DAYS_KEY = "session_days"
|
||||
# Zehn Jahre. Wer laenger will, nimmt "unbegrenzt" – ein Token mit einem
|
||||
# Ablauf im naechsten Jahrhundert waere dasselbe, nur unehrlicher.
|
||||
SESSION_DAYS_MAX = 3650
|
||||
|
||||
|
||||
def get_expiry_warning_days(db: Session) -> int:
|
||||
@@ -37,6 +42,31 @@ def get_receipt_match_threshold(db: Session) -> int:
|
||||
return RECEIPT_THRESHOLD_DEFAULT
|
||||
|
||||
|
||||
def get_session_days(db: Session) -> int:
|
||||
"""Gueltigkeitsdauer einer Anmeldung in Tagen; **0 heisst unbegrenzt**.
|
||||
|
||||
Ohne Eintrag gilt der Rueckfallwert aus der Umgebung
|
||||
(``JWT_EXPIRE_MINUTES``), damit eine frische Installation nicht erst
|
||||
eingestellt werden muss.
|
||||
"""
|
||||
vorgabe = max(1, round(get_settings().jwt_expire_minutes / (60 * 24)))
|
||||
row = db.get(Setting, SESSION_DAYS_KEY)
|
||||
if row is None:
|
||||
return vorgabe
|
||||
try:
|
||||
return max(0, min(SESSION_DAYS_MAX, int(row.value)))
|
||||
except ValueError:
|
||||
return vorgabe
|
||||
|
||||
|
||||
def get_session_minutes(db: Session) -> int:
|
||||
"""Dasselbe in Minuten, wie :func:`create_access_token` es erwartet.
|
||||
|
||||
Die 0 fuer "unbegrenzt" ueberlebt die Umrechnung unveraendert.
|
||||
"""
|
||||
return get_session_days(db) * 24 * 60
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingOut])
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
@@ -45,6 +75,7 @@ def list_settings(
|
||||
known = {r.key: r.value for r in rows}
|
||||
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
||||
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db)))
|
||||
known.setdefault(SESSION_DAYS_KEY, str(get_session_days(db)))
|
||||
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
||||
|
||||
|
||||
@@ -55,6 +86,17 @@ def set_setting(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> SettingOut:
|
||||
if key == SESSION_DAYS_KEY:
|
||||
# Ohne diese Pruefung wuerde get_session_days einen Tippfehler still
|
||||
# auf die Vorgabe zurueckdrehen – und der Admin glaubte, es sei gesetzt.
|
||||
try:
|
||||
tage = int(value)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Sitzungsdauer muss eine Zahl in Tagen sein (0 = unbegrenzt)")
|
||||
if not 0 <= tage <= SESSION_DAYS_MAX:
|
||||
raise HTTPException(400, f"Sitzungsdauer: 0 (unbegrenzt) bis {SESSION_DAYS_MAX} Tage")
|
||||
value = str(tage)
|
||||
|
||||
row = db.get(Setting, key)
|
||||
if row is None:
|
||||
row = Setting(key=key, value=value)
|
||||
|
||||
+14
-3
@@ -31,7 +31,9 @@ def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: int, role: str) -> str:
|
||||
def create_access_token(
|
||||
user_id: int, role: str, gueltig_minuten: int | None = None
|
||||
) -> str:
|
||||
"""Stellt ein Sitzungs-Token aus, das den Benutzer ueber seine ID benennt.
|
||||
|
||||
Frueher stand hier der Benutzername. Damit war der Name die Kennung der
|
||||
@@ -41,9 +43,18 @@ def create_access_token(user_id: int, role: str) -> str:
|
||||
``uid`` steht zusaetzlich zu ``sub`` drin, damit sich alte Token (``sub`` =
|
||||
Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst
|
||||
schlicht "42" heissen.
|
||||
|
||||
``gueltig_minuten`` kommt aus der Einstellung ``session_days``; ``None``
|
||||
nimmt den Rueckfallwert aus der Umgebung. **0 heisst unbegrenzt** – dann
|
||||
bekommt das Token gar kein ``exp`` und laeuft nie ab. Diese Wahl ist
|
||||
bewusst folgenreich: Ein ausgestelltes Token laesst sich nicht
|
||||
zurueckrufen, es gibt keine Sperrliste. Wer alle Anmeldungen beenden will,
|
||||
muss JWT_SECRET aendern.
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": str(user_id), "uid": user_id, "role": role, "exp": expire}
|
||||
minuten = settings.jwt_expire_minutes if gueltig_minuten is None else gueltig_minuten
|
||||
payload: dict = {"sub": str(user_id), "uid": user_id, "role": role}
|
||||
if minuten > 0:
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=minuten)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Wie lange eine Anmeldung gilt – einstellbar, bis hin zu unbegrenzt.
|
||||
|
||||
Vorher war die Frist fest verdrahtet und lief ab dem *Anmelden*: Wer die App
|
||||
taeglich benutzte, flog trotzdem punktgenau nach Ablauf hinaus. Beides ist
|
||||
hier geregelt – die Dauer ueber eine Einstellung, das Weiterzaehlen ueber
|
||||
/auth/refresh.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import ApiToken, Role, Setting, User
|
||||
from app.routers.auth import refresh
|
||||
from app.routers.settings import (
|
||||
SESSION_DAYS_KEY,
|
||||
SESSION_DAYS_MAX,
|
||||
get_session_days,
|
||||
get_session_minutes,
|
||||
set_setting,
|
||||
)
|
||||
from app.security import create_access_token, hash_api_token, hash_password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eva(db) -> User:
|
||||
nutzer = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(nutzer)
|
||||
db.commit()
|
||||
db.refresh(nutzer)
|
||||
return nutzer
|
||||
|
||||
|
||||
def _inhalt(token: str) -> dict:
|
||||
e = get_settings()
|
||||
# Ohne Ablaufpruefung, damit auch ein abgelaufenes Token lesbar bleibt.
|
||||
return jwt.decode(
|
||||
token, e.jwt_secret, algorithms=[e.jwt_algorithm],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Einstellung
|
||||
|
||||
def test_ohne_eintrag_gilt_der_wert_aus_der_umgebung(db):
|
||||
erwartet = round(get_settings().jwt_expire_minutes / (60 * 24))
|
||||
assert get_session_days(db) == erwartet
|
||||
|
||||
|
||||
def test_gesetzte_dauer_landet_im_token(db, eva):
|
||||
set_setting(key=SESSION_DAYS_KEY, value="60", db=db, _=eva)
|
||||
|
||||
assert get_session_days(db) == 60
|
||||
assert get_session_minutes(db) == 60 * 24 * 60
|
||||
|
||||
inhalt = _inhalt(
|
||||
create_access_token(eva.id, eva.role.value, gueltig_minuten=get_session_minutes(db))
|
||||
)
|
||||
tage = (datetime.fromtimestamp(inhalt["exp"], timezone.utc)
|
||||
- datetime.now(timezone.utc)).days
|
||||
# 59, weil der angebrochene Tag abgeschnitten wird.
|
||||
assert tage == 59
|
||||
|
||||
|
||||
def test_null_heisst_unbegrenzt(db, eva):
|
||||
"""Die Zusage "einfach fuer immer" – ein Token ganz ohne Ablauf."""
|
||||
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
|
||||
|
||||
assert get_session_minutes(db) == 0
|
||||
inhalt = _inhalt(create_access_token(eva.id, eva.role.value, gueltig_minuten=0))
|
||||
assert "exp" not in inhalt
|
||||
|
||||
|
||||
def test_unbegrenztes_token_wird_angenommen(db, eva):
|
||||
"""Ein Token ohne exp darf nicht an der Pruefung scheitern."""
|
||||
from app.deps import get_current_user
|
||||
|
||||
token = create_access_token(eva.id, eva.role.value, gueltig_minuten=0)
|
||||
assert get_current_user(token=token, db=db).id == eva.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wert", ["viel", "-1", str(SESSION_DAYS_MAX + 1), "3.5"])
|
||||
def test_unsinnige_dauer_wird_abgelehnt(db, eva, wert):
|
||||
"""Absagen statt still auf die Vorgabe zurueckdrehen – sonst glaubt der
|
||||
Admin, es sei gesetzt."""
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
set_setting(key=SESSION_DAYS_KEY, value=wert, db=db, _=eva)
|
||||
assert fehler.value.status_code == 400
|
||||
assert db.get(Setting, SESSION_DAYS_KEY) is None
|
||||
|
||||
|
||||
def test_beschaedigter_eintrag_faellt_auf_die_vorgabe_zurueck(db):
|
||||
"""Von Hand in der Datenbank verbogen: lieber die Vorgabe als ein Absturz."""
|
||||
db.add(Setting(key=SESSION_DAYS_KEY, value="kaputt"))
|
||||
db.commit()
|
||||
assert get_session_days(db) == round(get_settings().jwt_expire_minutes / (60 * 24))
|
||||
|
||||
|
||||
def test_dauer_steht_in_der_einstellungsliste(db, eva):
|
||||
from app.routers.settings import list_settings
|
||||
|
||||
schluessel = {s.key for s in list_settings(db=db, _=eva)}
|
||||
assert SESSION_DAYS_KEY in schluessel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Verlaengern
|
||||
|
||||
def test_refresh_gibt_ein_frisches_token(db, eva):
|
||||
set_setting(key=SESSION_DAYS_KEY, value="30", db=db, _=eva)
|
||||
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
|
||||
|
||||
neu = refresh(token=alt, db=db)
|
||||
|
||||
assert neu.username == "eva"
|
||||
assert _inhalt(neu.access_token)["exp"] > _inhalt(alt)["exp"]
|
||||
|
||||
|
||||
def test_refresh_uebernimmt_die_neue_dauer(db, eva):
|
||||
"""Der Sinn der Sache: eine Aenderung greift, ohne dass man sich abmeldet."""
|
||||
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
|
||||
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
|
||||
|
||||
assert "exp" not in _inhalt(refresh(token=alt, db=db).access_token)
|
||||
|
||||
|
||||
def test_refresh_lehnt_ein_abgelaufenes_token_ab(db, eva):
|
||||
# Von Hand gebaut: ueber create_access_token laesst sich kein abgelaufenes
|
||||
# Token bestellen – alles <= 0 heisst dort "unbegrenzt".
|
||||
e = get_settings()
|
||||
abgelaufen = jwt.encode(
|
||||
{"sub": str(eva.id), "uid": eva.id, "role": eva.role.value,
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1)},
|
||||
e.jwt_secret, algorithm=e.jwt_algorithm,
|
||||
)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
refresh(token=abgelaufen, db=db)
|
||||
assert fehler.value.status_code == 401
|
||||
|
||||
|
||||
def test_refresh_macht_aus_einem_api_token_kein_jwt(db, eva):
|
||||
"""Ein API-Token (Home Assistant) ist jederzeit widerrufbar. Ein daraus
|
||||
gemachtes JWT waere es nicht – deshalb hier eine Absage."""
|
||||
db.add(ApiToken(name="ha", token_hash=hash_api_token("geheim"), user_id=eva.id))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
refresh(token="geheim", db=db)
|
||||
assert fehler.value.status_code == 401
|
||||
@@ -10,8 +10,14 @@ POSTGRES_PASSWORD=vorrania
|
||||
POSTGRES_DB=vorrania
|
||||
|
||||
# Sicherheit – im Betrieb unbedingt ändern!
|
||||
# Ein neuer Wert beendet ALLE laufenden Anmeldungen – das ist zugleich der
|
||||
# einzige Weg, eine unbegrenzt gültige Sitzung wieder loszuwerden.
|
||||
JWT_SECRET=change-me-please
|
||||
|
||||
# Wie lange eine Anmeldung gilt, in Minuten. Nur der Startwert: im Betrieb
|
||||
# stellt man das unter Einstellungen -> Anmeldung ein (auch "unbegrenzt").
|
||||
#JWT_EXPIRE_MINUTES=43200
|
||||
|
||||
# Erster Admin-Benutzer (wird nur beim allerersten Start angelegt)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
@@ -131,6 +131,21 @@ actor APIClient {
|
||||
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
|
||||
}
|
||||
|
||||
/// Schiebt die Frist der laufenden Sitzung nach vorn.
|
||||
///
|
||||
/// Ohne das zaehlt die am Server eingestellte Dauer ab dem *Anmelden*: Wer
|
||||
/// die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf hinaus.
|
||||
///
|
||||
/// Bewusst ohne die Erneuerungsschicht: Ein 401 heisst hier, dass die
|
||||
/// Sitzung wirklich vorbei ist – dann gehoert sie beendet und nicht ueber
|
||||
/// einen Umweg gerettet.
|
||||
func sitzungVerlaengern() async throws -> TokenResponse {
|
||||
let request = try makeRequest("/auth/refresh", method: "POST")
|
||||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||||
try check(antwort, data: daten)
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: daten)
|
||||
}
|
||||
|
||||
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
|
||||
func oidcProviders() async throws -> [OidcProviderResponse] {
|
||||
let (daten, antwort) = try await URLSession.shared.data(
|
||||
|
||||
@@ -23,6 +23,9 @@ struct RootView: View {
|
||||
if session.isLoggedIn {
|
||||
await display.load()
|
||||
await session.refreshMe()
|
||||
// Nach refreshMe, damit eine wirklich abgelaufene Sitzung erst
|
||||
// sauber endet, statt hier noch verlaengert zu werden.
|
||||
await session.sitzungVerlaengern()
|
||||
}
|
||||
}
|
||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||||
|
||||
@@ -224,6 +224,24 @@ final class Session: ObservableObject {
|
||||
// kurzer Netzausfall einen nicht aus der App wirft.
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt beim Start ein frisches Token, damit die am Server eingestellte
|
||||
/// Sitzungsdauer ab dem letzten Besuch zaehlt und nicht ab dem Anmelden.
|
||||
///
|
||||
/// Schluckt jeden Fehler: Ein aelterer Server kennt /auth/refresh noch gar
|
||||
/// nicht (404), und eine wirklich abgelaufene Sitzung hat refreshMe() einen
|
||||
/// Schritt vorher schon beendet. Hier deshalb nie abmelden.
|
||||
@MainActor
|
||||
func sitzungVerlaengern() async {
|
||||
guard isLoggedIn, let profil = activeProfile else { return }
|
||||
guard let neu = try? await APIClient.shared.sitzungVerlaengern() else { return }
|
||||
// oidcSlug MUSS mitgegeben werden: store() setzt das Feld immer, und
|
||||
// ohne ihn verloere das Profil seinen Anmeldedienst – die stille
|
||||
// Erneuerung nach einem 401 fiele damit aus.
|
||||
store(token: neu.accessToken, username: neu.username,
|
||||
isAdmin: neu.role == "admin", persist: stayLoggedIn,
|
||||
oidcSlug: profil.oidcSlug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||||
|
||||
@@ -154,6 +154,10 @@ export const api = {
|
||||
login: (username, password) =>
|
||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||
me: () => request("/auth/me"),
|
||||
// Schiebt die Frist der laufenden Sitzung nach vorn. Wird beim Start
|
||||
// gerufen, damit die eingestellte Dauer ab dem letzten Besuch zaehlt und
|
||||
// nicht ab dem Anmelden.
|
||||
sitzungVerlaengern: () => request("/auth/refresh", { method: "POST" }),
|
||||
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
|
||||
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
|
||||
// und es taucht kein SSO-Knopf auf.
|
||||
|
||||
@@ -13,6 +13,22 @@ export function AuthProvider({ children }) {
|
||||
// meldet einen ab.
|
||||
const [startFehler, setStartFehler] = useState(null);
|
||||
|
||||
// Holt ein frisches Token, damit die eingestellte Dauer ab dem letzten
|
||||
// Besuch zaehlt statt ab dem Anmelden.
|
||||
//
|
||||
// Der Fehler wird geschluckt, weil ein Server- oder Netzfehler hier nichts
|
||||
// kostet: das bisherige Token gilt ja weiter. Ein 401 waere etwas anderes –
|
||||
// um den kuemmert sich bereits die zentrale Abmeldung in api.js, und er kann
|
||||
// direkt nach einem geglueckten me() ohnehin nicht auftreten.
|
||||
async function sitzungVerlaengern() {
|
||||
try {
|
||||
const res = await api.sitzungVerlaengern();
|
||||
setToken(res.access_token);
|
||||
} catch {
|
||||
/* siehe oben */
|
||||
}
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
setStartFehler(null);
|
||||
if (!getToken()) {
|
||||
@@ -21,6 +37,7 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
try {
|
||||
setUser(await api.me());
|
||||
await sitzungVerlaengern();
|
||||
} catch (err) {
|
||||
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
||||
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
|
||||
|
||||
@@ -11,6 +11,18 @@ import { applyFavicon } from "../branding";
|
||||
|
||||
const EXPIRY_KEY = "expiry_warning_days";
|
||||
const RECEIPT_KEY = "receipt_match_threshold";
|
||||
const SESSION_KEY = "session_days";
|
||||
|
||||
// Feste Stufen statt eines Zahlenfeldes: "wie lange bleibe ich angemeldet" ist
|
||||
// eine Entscheidung, keine Rechenaufgabe. 0 heisst unbegrenzt.
|
||||
const SITZUNGSDAUERN = [
|
||||
{ value: "7", label: "7 Tage" },
|
||||
{ value: "30", label: "30 Tage" },
|
||||
{ value: "90", label: "3 Monate" },
|
||||
{ value: "180", label: "6 Monate" },
|
||||
{ value: "365", label: "1 Jahr" },
|
||||
{ value: "0", label: "Unbegrenzt – nie abmelden" },
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const { reload } = useSettings();
|
||||
@@ -18,6 +30,7 @@ export default function Settings() {
|
||||
const [days, setDays] = useState("");
|
||||
const [threshold, setThreshold] = useState("");
|
||||
const [dateFormat, setDateFormat] = useState("de");
|
||||
const [sessionDays, setSessionDays] = useState("30");
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -27,6 +40,7 @@ export default function Settings() {
|
||||
setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
|
||||
setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45");
|
||||
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
|
||||
setSessionDays(settings.find((s) => s.key === SESSION_KEY)?.value ?? "30");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -41,6 +55,7 @@ export default function Settings() {
|
||||
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
|
||||
await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0))));
|
||||
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
|
||||
await api.setSetting(SESSION_KEY, sessionDays);
|
||||
await reload();
|
||||
toast("Gespeichert.");
|
||||
} catch (err) {
|
||||
@@ -50,6 +65,13 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ein ueber die API gesetzter Zwischenwert (z.B. 60) steht nicht in der Liste.
|
||||
// Ohne diesen Zusatz zeigte die Auswahl nichts an und das Speichern haette den
|
||||
// Wert klammheimlich veraendert.
|
||||
const dauerAuswahl = SITZUNGSDAUERN.some((d) => d.value === sessionDays)
|
||||
? SITZUNGSDAUERN
|
||||
: [...SITZUNGSDAUERN, { value: sessionDays, label: `${sessionDays} Tage` }];
|
||||
|
||||
// Beispiel mit einem festen Datum, damit man die Wirkung sofort sieht.
|
||||
const preview = formatDate("2026-12-23", dateFormat);
|
||||
|
||||
@@ -89,6 +111,31 @@ export default function Settings() {
|
||||
sicherere Vorschläge), niedriger = mehr Vorschläge.
|
||||
</p>
|
||||
|
||||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||||
<Icon name="users" /><h2>Anmeldung</h2>
|
||||
</div>
|
||||
<label>
|
||||
Angemeldet bleiben für
|
||||
<select value={sessionDays} onChange={(e) => setSessionDays(e.target.value)}>
|
||||
{dauerAuswahl.map((d) => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted small mt-0">
|
||||
Gilt für die Website und die App gleichermaßen. Die Frist zählt ab dem
|
||||
letzten Besuch – wer regelmäßig vorbeischaut, wird also gar nicht
|
||||
abgemeldet. Geändert wird sie beim nächsten Seitenaufruf übernommen.
|
||||
</p>
|
||||
{sessionDays === "0" && (
|
||||
<p className="muted small mt-0">
|
||||
<Icon name="alert" size={14} /> Unbegrenzt heißt wirklich unbegrenzt:
|
||||
Eine einmal erteilte Anmeldung lässt sich nicht mehr einzeln zurückziehen.
|
||||
Wer alle Geräte aussperren will, muss JWT_SECRET in der .env ändern und
|
||||
den Server neu starten.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||||
<Icon name="settings" /><h2>Darstellung</h2>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user