feat(auth): login + change_password re-issue JWTs with wid + role
After PR4 every protected request demands wid. Three JWT-issue paths must encode it; T4.8/T4.9 covered /initialize and /register, and this commit closes the remaining two: - login_local calls _ensure_default_workspace (idempotent — returns the existing default_workspace_id when set) so users who pre-date PR4 are backfilled at login time, and signs the new cookie with wid + role='owner'. - change_password does the same, ensuring a password change does not strip the workspace claim and lock the user out on their next request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -340,7 +340,15 @@ async def login_local(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_record_login_success(client_ip)
|
_record_login_success(client_ip)
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
# Ensure the user has a workspace (covers pre-PR4 users still in DB
|
||||||
|
# whose default_workspace_id was never backfilled by the lifespan hook).
|
||||||
|
workspace_id = await _ensure_default_workspace(user)
|
||||||
|
token = create_access_token(
|
||||||
|
str(user.id),
|
||||||
|
token_version=user.token_version,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
_set_session_cookie(response, token, request)
|
_set_session_cookie(response, token, request)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
@@ -423,8 +431,17 @@ async def change_password(request: Request, response: Response, body: ChangePass
|
|||||||
|
|
||||||
await provider.update_user(user)
|
await provider.update_user(user)
|
||||||
|
|
||||||
# Re-issue cookie with new token_version
|
# Re-issue cookie with new token_version. wid + role must be carried
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
# forward so the re-signed JWT still passes the AuthMiddleware
|
||||||
|
# workspace gate; _ensure_default_workspace fills in for the (rare)
|
||||||
|
# case where the user predates PR4 and has not been backfilled.
|
||||||
|
workspace_id = await _ensure_default_workspace(user)
|
||||||
|
token = create_access_token(
|
||||||
|
str(user.id),
|
||||||
|
token_version=user.token_version,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
_set_session_cookie(response, token, request)
|
_set_session_cookie(response, token, request)
|
||||||
|
|
||||||
return MessageResponse(message="Password changed successfully")
|
return MessageResponse(message="Password changed successfully")
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""``change_password`` and ``login_local`` re-issue JWTs carrying wid + role.
|
||||||
|
|
||||||
|
Stage 0 PR4 T4.12. The contract:
|
||||||
|
|
||||||
|
- After ``POST /auth/change-password`` the new session cookie's JWT must
|
||||||
|
still encode the user's workspace under ``wid`` (and ``role='owner'``),
|
||||||
|
with ``ver`` bumped. Dropping ``wid`` here would lock the user out of
|
||||||
|
every protected endpoint immediately after a password change.
|
||||||
|
- Same for ``POST /auth/login/local`` — it issues a fresh JWT and must
|
||||||
|
also encode ``wid``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-change-password-wid-32x")
|
||||||
|
|
||||||
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||||
|
|
||||||
|
_TEST_SECRET = "test-secret-key-change-password-wid-32x"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup_auth(tmp_path):
|
||||||
|
from app.gateway import deps
|
||||||
|
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
|
||||||
|
from deerflow.persistence.engine import close_engine, init_engine
|
||||||
|
|
||||||
|
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||||
|
url = f"sqlite+aiosqlite:///{tmp_path}/change_pwd.db"
|
||||||
|
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
||||||
|
deps._cached_local_provider = None
|
||||||
|
deps._cached_repo = None
|
||||||
|
_SETUP_STATUS_COOLDOWN.clear()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
deps._cached_local_provider = None
|
||||||
|
deps._cached_repo = None
|
||||||
|
_SETUP_STATUS_COOLDOWN.clear()
|
||||||
|
asyncio.run(close_engine())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(_setup_auth):
|
||||||
|
from app.gateway.app import create_app
|
||||||
|
|
||||||
|
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||||
|
yield TestClient(create_app())
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(token: str) -> dict:
|
||||||
|
return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"])
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_user(client) -> tuple[str, dict]:
|
||||||
|
"""Register a user and return (user_id, initial claims)."""
|
||||||
|
client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"})
|
||||||
|
resp = client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"], _decode(resp.cookies["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_keeps_wid_and_bumps_ver(client):
|
||||||
|
"""change_password re-signs the JWT with wid + role; ver moves forward."""
|
||||||
|
user_id, initial_claims = _bootstrap_user(client)
|
||||||
|
|
||||||
|
# /register set the csrf cookie; the matching header is required on
|
||||||
|
# the change-password POST (Double Submit Cookie pattern).
|
||||||
|
csrf = client.cookies.get("csrf_token")
|
||||||
|
assert csrf, "register must have set csrf_token cookie"
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/change-password",
|
||||||
|
json={"current_password": "Tr0ub4dor3a-strong!", "new_password": "Tr0ub4dor3a-strong2!"},
|
||||||
|
headers={"X-CSRF-Token": csrf},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
new_claims = _decode(resp.cookies["access_token"])
|
||||||
|
assert new_claims["sub"] == user_id
|
||||||
|
assert new_claims["wid"] == initial_claims["wid"], "wid must survive a password change"
|
||||||
|
assert new_claims["role"] == "owner"
|
||||||
|
assert new_claims["ver"] == initial_claims["ver"] + 1, "token_version must advance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_issues_wid_carrying_jwt(client):
|
||||||
|
"""/auth/login/local issues a JWT that the workspace middleware will accept."""
|
||||||
|
user_id, _ = _bootstrap_user(client)
|
||||||
|
|
||||||
|
# Clear the session cookie set by /register so the login response is observed in isolation.
|
||||||
|
client.cookies.clear()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login/local",
|
||||||
|
data={"username": "alice@example.com", "password": "Tr0ub4dor3a-strong!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
claims = _decode(resp.cookies["access_token"])
|
||||||
|
assert claims["sub"] == user_id
|
||||||
|
assert claims.get("wid"), "login must include wid"
|
||||||
|
assert claims["role"] == "owner"
|
||||||
Reference in New Issue
Block a user