feat(auth): move JWT blacklist to Redis with TTL [FASE-0.2]
Revoke tokens by jti in a dedicated Redis DB, honor TTL from JWT exp on logout, reject revoked tokens in get_current_user, and add FakeRedis-backed API tests.
This commit is contained in:
@@ -80,12 +80,37 @@ def db():
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db):
|
||||
def client(db, monkeypatch):
|
||||
"""Create a test client with database override.
|
||||
|
||||
Imports ``app.main`` lazily to avoid pulling in boto3 / APScheduler
|
||||
when only the ``db`` fixture is needed.
|
||||
|
||||
MinIO and the background scheduler are no-ops here so tests do not
|
||||
require Docker services for application startup.
|
||||
|
||||
JWT blacklist uses an in-memory FakeRedis so tests do not require a
|
||||
real Redis instance.
|
||||
"""
|
||||
import fakeredis
|
||||
|
||||
_fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||
|
||||
def _blacklist_conn():
|
||||
return _fake_redis
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.infrastructure.redis_client.get_redis_blacklist",
|
||||
_blacklist_conn,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.main.ensure_bucket_exists", lambda: None)
|
||||
monkeypatch.setattr("app.main.start_scheduler", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"app.main.scheduler.shutdown",
|
||||
lambda wait=False: None,
|
||||
)
|
||||
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
import app.database as _db_mod
|
||||
@@ -118,6 +143,7 @@ def admin_user(db):
|
||||
hashed_password=hash_password("admin123"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -134,6 +160,7 @@ def red_tech_user(db):
|
||||
hashed_password=hash_password("redtech123"),
|
||||
role="red_tech",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -150,6 +177,7 @@ def blue_tech_user(db):
|
||||
hashed_password=hash_password("bluetech123"),
|
||||
role="blue_tech",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -166,6 +194,7 @@ def red_lead_user(db):
|
||||
hashed_password=hash_password("redlead123"),
|
||||
role="red_lead",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -182,6 +211,7 @@ def blue_lead_user(db):
|
||||
hashed_password=hash_password("bluelead123"),
|
||||
role="blue_lead",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
@@ -79,3 +79,46 @@ def test_get_me_invalid_token(client):
|
||||
headers={"Authorization": "Bearer invalidtoken"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_logout_revokes_token(client, admin_user):
|
||||
"""After logout, the same JWT must be rejected (Redis blacklist).
|
||||
|
||||
Prefer Authorization over the HttpOnly cookie so logout blacklists the
|
||||
same token the client sends on the next request; clear cookies to avoid
|
||||
stale jar state across calls.
|
||||
"""
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
client.cookies.clear()
|
||||
out = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert out.status_code == 200
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.infrastructure.redis_client import get_redis_blacklist
|
||||
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
jti = payload.get("jti")
|
||||
assert jti
|
||||
assert get_redis_blacklist().exists(f"blacklist:{jti}")
|
||||
|
||||
client.cookies.clear()
|
||||
assert not len(client.cookies), "cookie jar must be empty to force Authorization Bearer"
|
||||
me = client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert me.status_code == 401
|
||||
assert me.json()["detail"] == "Token has been revoked"
|
||||
|
||||
Reference in New Issue
Block a user