diff --git a/backend/app/auth.py b/backend/app/auth.py index 92421d2..146cfb0 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -80,11 +80,11 @@ def blacklist_token(jti: str, exp: float) -> None: to ``exp - now`` so the key vanishes when the token would have expired naturally. """ - from app.infrastructure.redis_client import get_redis + from app.infrastructure.redis_client import get_redis_blacklist ttl = max(int(exp - datetime.now(timezone.utc).timestamp()), 1) try: - r = get_redis() + r = get_redis_blacklist() r.setex(f"{_BLACKLIST_PREFIX}{jti}", ttl, "1") except Exception: logger.warning("Failed to blacklist token %s in Redis", jti, exc_info=True) @@ -92,10 +92,10 @@ def blacklist_token(jti: str, exp: float) -> None: def is_token_blacklisted(jti: str) -> bool: """Return ``True`` if *jti* has been revoked (exists in Redis).""" - from app.infrastructure.redis_client import get_redis + from app.infrastructure.redis_client import get_redis_blacklist try: - r = get_redis() + r = get_redis_blacklist() return r.exists(f"{_BLACKLIST_PREFIX}{jti}") > 0 except Exception: logger.warning("Failed to check blacklist for %s in Redis", jti, exc_info=True) diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index 36e174c..9771d02 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -15,7 +15,7 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy.orm import Session -from app.auth import is_token_blacklisted +from app import auth as auth_lib from app.config import settings from app.database import get_db from app.models.user import User @@ -57,6 +57,11 @@ async def get_current_user( detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + revoked_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has been revoked", + headers={"WWW-Authenticate": "Bearer"}, + ) # Prefer cookie, fall back to header token = aegis_token or bearer_token @@ -74,8 +79,8 @@ async def get_current_user( raise credentials_exception # Check token blacklist (revoked tokens) jti: str | None = payload.get("jti") - if jti and is_token_blacklisted(jti): - raise credentials_exception + if jti and auth_lib.is_token_blacklisted(jti): + raise revoked_exception except JWTError: raise credentials_exception diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fb161f4..30be77d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -96,13 +96,26 @@ def logout( The token's ``jti`` is added to the Redis blacklist so it cannot be reused even if the cookie has already been copied elsewhere. The blacklist entry auto-expires when the token's ``exp`` is reached. + + When both HttpOnly cookie and ``Authorization: Bearer`` are present + (typical for API clients), **both** are revoked so the session cannot + survive on whichever credential the next request prefers. """ - # Attempt to blacklist the token's jti - token = aegis_token or request.headers.get("Authorization", "").removeprefix("Bearer ").strip() - if token: + bearer = ( + request.headers.get("Authorization") + or request.headers.get("authorization") + or "" + ) + bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() + + seen: set[str] = set() + for raw in (aegis_token, bearer): + if not raw or raw in seen: + continue + seen.add(raw) try: payload = jwt.decode( - token, + raw, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], ) @@ -111,7 +124,7 @@ def logout( if jti: blacklist_token(jti, float(exp)) except JWTError: - pass # token already invalid — nothing to revoke + pass # token already invalid — nothing to revoke for this raw value response.delete_cookie( key=_COOKIE_NAME, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 98882e9..1191a28 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 974dbbc..c6de441 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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"