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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user