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>
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
"""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
|