fix(auth): Sitzungen ueber die Benutzer-ID statt ueber den Namen aufloesen
Das Sitzungs-Token trug den Benutzernamen als Subjekt, und get_current_user suchte danach. Eine Umbenennung machte damit jedes Token auf jedem anderen Geraet stillschweigend ungueltig — der Nutzer flog ohne Hinweis raus. Tokens tragen jetzt zusaetzlich uid, die unveraenderliche Zeilen-ID, und die Aufloesung geht darueber. sub bleibt als Anzeigename erhalten. Tokens ohne uid werden weiter ueber den Namen aufgeloest, damit vor der Aenderung ausgestellte Sitzungen gueltig bleiben — mit "Angemeldet bleiben" laufen die 180 Tage. Fuer SSO war die Zuordnung schon vorher richtig: OIDCIdentity haengt an (provider_key, sub) des Providers, und bei bekanntem sub wird der lokale Benutzername nicht angefasst. Ein umbenannter Nutzer in Authentik landet also unveraendert auf demselben Konto. Vier Tests halten beide Richtungen fest: Umbenennung beim Provider, lokale Umbenennung mit weiterlaufender Sitzung, lokale Umbenennung mit erhaltener SSO-Verknuepfung, und die Kompatibilitaet alter Tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc4daf4a84
commit
725b61c8cb
+20
-1
@@ -43,6 +43,18 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_user_token(user: "models.User", expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Session token for a user.
|
||||
|
||||
``uid`` is the authoritative subject: the immutable row id. ``sub`` keeps
|
||||
the username for readability and for tokens issued before ``uid`` existed,
|
||||
but it is not what identifies the account — otherwise renaming a user would
|
||||
silently invalidate every session on their other devices, and an identity
|
||||
provider that changes a name would break the mapping.
|
||||
"""
|
||||
return create_access_token({"sub": user.username, "uid": user.id}, expires_delta)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> models.User:
|
||||
@@ -57,11 +69,18 @@ def get_current_user(
|
||||
# flow token); only a real access token may authenticate a request.
|
||||
if payload.get("typ") not in (None, "access"):
|
||||
raise exc
|
||||
uid = payload.get("uid")
|
||||
username: str = payload.get("sub")
|
||||
if not username:
|
||||
if uid is None and not username:
|
||||
raise exc
|
||||
except JWTError:
|
||||
raise exc
|
||||
|
||||
if uid is not None:
|
||||
user = db.query(models.User).filter(models.User.id == uid).first()
|
||||
else:
|
||||
# Legacy token issued before uid existed. "Remember me" runs for 180
|
||||
# days, so these stay in circulation for a while.
|
||||
user = db.query(models.User).filter(models.User.username == username).first()
|
||||
if not user:
|
||||
raise exc
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
||||
from auth import create_user_token, get_current_user, get_password_hash, verify_password
|
||||
from database import get_db
|
||||
|
||||
# When "Angemeldet bleiben" is ticked the token lives for half a year.
|
||||
@@ -61,7 +61,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
|
||||
db.add(models.UserSettings(user_id=user.id))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
token = create_access_token({"sub": user.username})
|
||||
token = create_user_token(user)
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="2fa_required",
|
||||
)
|
||||
token = create_access_token({"sub": user.username})
|
||||
token = create_user_token(user)
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
detail="Ungültiger 2FA-Code",
|
||||
)
|
||||
expires = REMEMBER_ME_EXPIRY if req.remember_me else None
|
||||
token = create_access_token({"sub": user.username}, expires_delta=expires)
|
||||
token = create_user_token(user, expires_delta=expires)
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session
|
||||
import models
|
||||
import oidc_client
|
||||
import oidc_config
|
||||
from auth import ALGORITHM, SECRET_KEY, create_access_token, get_current_user
|
||||
from auth import ALGORITHM, SECRET_KEY, create_user_token, get_current_user
|
||||
from database import get_db
|
||||
from dav_util import public_base
|
||||
from oidc_client import OIDCError
|
||||
@@ -275,7 +275,7 @@ def oidc_callback(provider_key: str, request: Request,
|
||||
return _fail(request, e.slug)
|
||||
|
||||
logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key)
|
||||
token = create_access_token({"sub": user.username})
|
||||
token = create_user_token(user)
|
||||
|
||||
resp = RedirectResponse(url="/?sso=1", status_code=302)
|
||||
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
|
||||
@@ -300,13 +300,13 @@ def oidc_complete(request: Request, response: Response, db: Session = Depends(ge
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
uid = payload.get("uid")
|
||||
except JWTError:
|
||||
raise HTTPException(401, "SSO-Sitzung ungültig")
|
||||
if not username:
|
||||
if uid is None:
|
||||
raise HTTPException(401, "SSO-Sitzung ungültig")
|
||||
|
||||
user = db.query(models.User).filter(models.User.username == username).first()
|
||||
user = db.query(models.User).filter(models.User.id == uid).first()
|
||||
if not user:
|
||||
raise HTTPException(401, "SSO-Sitzung ungültig")
|
||||
|
||||
@@ -404,7 +404,7 @@ def oidc_exchange(req: ExchangeRequest, db: Session = Depends(get_db)):
|
||||
|
||||
logger.info("OIDC exchange (%s) user id=%s via %s", action, user.id, provider.key)
|
||||
return {
|
||||
"access_token": create_access_token({"sub": user.username}),
|
||||
"access_token": create_user_token(user),
|
||||
"token_type": "bearer",
|
||||
"user": _user_dict(user),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
import models
|
||||
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
||||
from auth import create_user_token, get_current_user, get_password_hash, verify_password
|
||||
from database import DATA_DIR, get_db
|
||||
|
||||
router = APIRouter()
|
||||
@@ -116,7 +116,9 @@ def update_profile(
|
||||
db.commit()
|
||||
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||
# token, so hand back a fresh one for the client to store.
|
||||
result["access_token"] = create_access_token({"sub": new_login})
|
||||
# The session survives a rename on its own (uid), but the token
|
||||
# still carries the old name for display — hand out a fresh one.
|
||||
result["access_token"] = create_user_token(current_user)
|
||||
return result
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
|
||||
@@ -623,6 +623,79 @@ def test_second_login_reuses_the_same_account(client, authentik, monkeypatch):
|
||||
assert db.query(models.OIDCIdentity).count() == 1
|
||||
|
||||
|
||||
# ── identity is the id, never the name ───────────────────────────────────────
|
||||
|
||||
def test_rename_at_the_provider_keeps_the_same_account(client, authentik, monkeypatch):
|
||||
"""Authentik renames a user: same sub, new preferred_username and email.
|
||||
That must land on the same Calendarr account, unchanged."""
|
||||
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
|
||||
oidc_config.reset_cache()
|
||||
register_admin(client, "admin", "pw")
|
||||
|
||||
first = exchange(client, id_token=make_id_token(
|
||||
aud=MOBILE_CLIENT, sub="stable-sub", email="old@example.com",
|
||||
preferred_username="oldname", name="Old Name"))
|
||||
assert first.status_code == 200
|
||||
uid = first.json()["user"]["id"]
|
||||
original_username = first.json()["user"]["username"]
|
||||
|
||||
second = exchange(client, id_token=make_id_token(
|
||||
aud=MOBILE_CLIENT, sub="stable-sub", email="brand-new@example.com",
|
||||
preferred_username="totally-different", name="New Name"))
|
||||
assert second.status_code == 200
|
||||
assert second.json()["user"]["id"] == uid
|
||||
# The local login name is ours, not the provider's — it does not churn.
|
||||
assert second.json()["user"]["username"] == original_username
|
||||
|
||||
with db_session() as db:
|
||||
assert db.query(models.OIDCIdentity).count() == 1
|
||||
assert db.query(models.User).count() == 2 # admin + this one
|
||||
|
||||
|
||||
def test_local_rename_keeps_existing_sessions_alive(client, authentik):
|
||||
"""A username change must not silently sign the user out everywhere: the
|
||||
token identifies the account by id."""
|
||||
admin_token = register_admin(client, "admin", "pw")
|
||||
_, token = create_user(client, admin_token, "gina")
|
||||
|
||||
renamed = client.put("/api/profile/", headers=auth(token),
|
||||
json={"username": "gina-married"})
|
||||
assert renamed.status_code == 200, renamed.text
|
||||
|
||||
# The *old* token — as still held by another device — keeps working.
|
||||
me = client.get("/api/auth/me", headers=auth(token))
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == "gina-married"
|
||||
|
||||
|
||||
def test_local_rename_keeps_the_sso_link(client, authentik, monkeypatch):
|
||||
"""Renaming locally must not detach a linked SSO identity either."""
|
||||
admin_token = register_admin(client, "admin", "pw")
|
||||
uid, token = create_user(client, admin_token, "gina")
|
||||
with db_session() as db:
|
||||
db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik",
|
||||
issuer=ISSUER, subject="ak-gina"))
|
||||
db.commit()
|
||||
|
||||
client.put("/api/profile/", headers=auth(token), json={"username": "gina-married"})
|
||||
|
||||
r = exchange(client, id_token=make_id_token(sub="ak-gina", aud=MOBILE_CLIENT))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["user"]["id"] == uid
|
||||
|
||||
|
||||
def test_legacy_token_without_uid_still_works(client, authentik):
|
||||
"""Tokens minted before the uid claim stay valid — "remember me" runs for
|
||||
180 days, so they are in circulation for a long time."""
|
||||
from auth import create_access_token
|
||||
|
||||
register_admin(client, "admin", "pw")
|
||||
legacy = create_access_token({"sub": "admin"})
|
||||
me = client.get("/api/auth/me", headers=auth(legacy))
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == "admin"
|
||||
|
||||
|
||||
# ── the password_hash trap ───────────────────────────────────────────────────
|
||||
|
||||
def test_sso_user_password_login_fails_without_500(client, authentik, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user