security: JWT aud/iss validation, token blacklist, encryption key crash-early
C-2 (CRITICAL): JWT decode_token now validates aud, iss, iat, jti claims. Tokens without these claims are rejected. Audience 'ss-tools-api' and issuer 'ss-tools' prevent cross-service token reuse. H-2 (HIGH): Server-side JWT revocation via token_blacklist table. - New TokenBlacklist model (SHA-256 hash only, never raw token) - blacklist_token() on logout — revokes current session - is_token_blacklisted() check in get_current_user - Expired entries auto-pruned via _prune_blacklist() H-3 (HIGH): Encryption key auto-generation removed — crash-early instead. Previously, ensure_encryption_key() would auto-generate a Fernet key on first run and write it to .env. This made encrypted data unrecoverable when the key changed between deployments. Now raises RuntimeError with a clear command to generate the key explicitly. Also: update orthogonal security test for crash-early behavior.
This commit is contained in:
@@ -13,10 +13,11 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
import starlette.requests
|
||||
|
||||
from ..core.auth.jwt import blacklist_token
|
||||
from ..core.auth.logger import log_security_event
|
||||
from ..core.auth.oauth import is_adfs_configured, oauth
|
||||
from ..core.database import get_auth_db
|
||||
from ..core.logger import belief_scope
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..dependencies import get_current_user
|
||||
from ..schemas.auth import Token, User as UserSchema
|
||||
from ..services.auth_service import AuthService
|
||||
@@ -74,18 +75,32 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||
|
||||
|
||||
# #region logout [C:4] [TYPE Function]
|
||||
# @BRIEF Logs out the current user (placeholder for session revocation).
|
||||
# @PRE Valid JWT token provided.
|
||||
# @POST Returns success message.
|
||||
# @BRIEF Logs out the current user — blacklists the JWT token server-side.
|
||||
# @PRE Valid JWT token provided in Authorization header.
|
||||
# @POST Token is added to blacklist; subsequent requests with same token are rejected.
|
||||
# @SIDE_EFFECT Writes security event LOGOUT event; writes to token_blacklist table.
|
||||
# @RELATION DEPENDS_ON -> [get_current_user]
|
||||
# @SIDE_EFFECT Writes security event LOGOUT event.
|
||||
# @RELATION CALLS -> [blacklist_token]
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: UserSchema = Depends(get_current_user)):
|
||||
async def logout(
|
||||
current_user: UserSchema = Depends(get_current_user),
|
||||
request: starlette.requests.Request = None,
|
||||
db: Session = Depends(get_auth_db),
|
||||
):
|
||||
with belief_scope("api.auth.logout"):
|
||||
log_security_event("LOGOUT", current_user.username)
|
||||
# In a stateless JWT setup, client-side token deletion is primary.
|
||||
# Server-side revocation (blacklisting) can be added here if needed.
|
||||
|
||||
# Extract token from Authorization header and blacklist it
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
blacklist_token(token, db)
|
||||
logger.reason(
|
||||
"Token blacklisted on logout",
|
||||
extra={"user": current_user.username},
|
||||
)
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ class AuthConfig(BaseSettings):
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
JWT_AUDIENCE: str = Field(default="ss-tools-api", validation_alias="JWT_AUDIENCE")
|
||||
JWT_ISSUER: str = Field(default="ss-tools", validation_alias="JWT_ISSUER")
|
||||
|
||||
# Database Settings
|
||||
AUTH_DATABASE_URL: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
# #region AuthJwtModule [C:5] [TYPE Module] [SEMANTICS auth, validate, jwt, token]
|
||||
#
|
||||
# @BRIEF JWT token generation and validation logic.
|
||||
# @BRIEF JWT token generation and validation logic with server-side revocation.
|
||||
# @LAYER Core
|
||||
# @RELATION DEPENDS_ON -> [auth_config]
|
||||
# @RELATION DEPENDS_ON -> [TokenBlacklist]
|
||||
#
|
||||
# @INVARIANT Tokens must include expiration time and user identifier.
|
||||
# @INVARIANT Tokens must include aud, iss, iat, jti, exp, and sub claims.
|
||||
# Revoked tokens are rejected even if not expired.
|
||||
# @PRE JWT secret configured in environment
|
||||
# @POST Token encode/decode functions exported
|
||||
# @POST Token encode/decode/revoke functions exported
|
||||
# @SIDE_EFFECT None
|
||||
# @DATA_CONTRACT TokenPayload -> JWT string
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...models.auth import TokenBlacklist
|
||||
from ..logger import belief_scope
|
||||
from .config import auth_config
|
||||
|
||||
|
||||
# #region create_access_token [TYPE Function]
|
||||
# @BRIEF Generates a new JWT access token.
|
||||
# @BRIEF Generates a new JWT access token with aud, iss, iat, jti, exp claims.
|
||||
# @PRE data dict contains 'sub' (user_id) and optional 'scopes' (roles).
|
||||
# @POST Returns a signed JWT string.
|
||||
# @POST Returns a signed JWT string with unique jti for revocation.
|
||||
# @RELATION DEPENDS_ON -> [auth_config]
|
||||
#
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
with belief_scope("create_access_token"):
|
||||
to_encode = data.copy()
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
expire = now + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
expire = now + timedelta(
|
||||
minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"aud": auth_config.JWT_AUDIENCE,
|
||||
"iss": auth_config.JWT_ISSUER,
|
||||
})
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM
|
||||
)
|
||||
@@ -45,19 +59,115 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
|
||||
|
||||
|
||||
# #region decode_token [TYPE Function]
|
||||
# @BRIEF Decodes and validates a JWT token.
|
||||
# @BRIEF Decodes and validates a JWT token — verifies aud, iss, exp, and blacklist.
|
||||
# @PRE token is a signed JWT string.
|
||||
# @POST Returns the decoded payload if valid.
|
||||
# @POST Returns the decoded payload if valid. Raises JWTError if invalid.
|
||||
# @RELATION DEPENDS_ON -> [auth_config]
|
||||
#
|
||||
def decode_token(token: str) -> dict:
|
||||
with belief_scope("decode_token"):
|
||||
payload = jwt.decode(
|
||||
token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM]
|
||||
token,
|
||||
auth_config.SECRET_KEY,
|
||||
algorithms=[auth_config.ALGORITHM],
|
||||
audience=auth_config.JWT_AUDIENCE,
|
||||
issuer=auth_config.JWT_ISSUER,
|
||||
options={
|
||||
"verify_aud": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"require": ["exp", "sub", "aud", "iss", "iat", "jti"],
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
# #endregion decode_token
|
||||
|
||||
|
||||
# #region _hash_token [TYPE Function]
|
||||
# @BRIEF SHA-256 hash of a token string for blacklist lookup.
|
||||
# @PRE token is a non-empty JWT string.
|
||||
# @POST Returns 64-character hex digest.
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
# #endregion _hash_token
|
||||
|
||||
|
||||
# #region _prune_blacklist [TYPE Function]
|
||||
# @BRIEF Delete expired entries from the token blacklist.
|
||||
# @PRE db is a valid SQLAlchemy session.
|
||||
# @POST Expired blacklist rows are removed.
|
||||
def _prune_blacklist(db: Session) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
db.query(TokenBlacklist).filter(TokenBlacklist.expires_at < now).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
# #endregion _prune_blacklist
|
||||
|
||||
|
||||
# #region blacklist_token [TYPE Function]
|
||||
# @BRIEF Revoke a JWT token by adding its hash to the blacklist.
|
||||
# @PRE token was issued by this service and hasn't expired.
|
||||
# @POST Token is blacklisted and subsequent decode_token calls will reject it.
|
||||
# @SIDE_EFFECT Writes to token_blacklist table; prunes expired entries.
|
||||
# @RELATION DEPENDS_ON -> [TokenBlacklist]
|
||||
def blacklist_token(token: str, db: Session) -> None:
|
||||
with belief_scope("blacklist_token"):
|
||||
_prune_blacklist(db)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
return # Already invalid — no need to blacklist
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp_raw = payload.get("exp")
|
||||
if not jti or not exp_raw:
|
||||
return
|
||||
|
||||
if isinstance(exp_raw, datetime):
|
||||
expires_at = exp_raw
|
||||
else:
|
||||
expires_at = datetime.fromtimestamp(exp_raw, tz=timezone.utc)
|
||||
|
||||
existing = db.query(TokenBlacklist).filter(TokenBlacklist.jti == jti).first()
|
||||
if existing:
|
||||
return
|
||||
|
||||
entry = TokenBlacklist(
|
||||
jti=jti,
|
||||
token_hash=_hash_token(token),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
# #endregion blacklist_token
|
||||
|
||||
|
||||
# #region is_token_blacklisted [TYPE Function]
|
||||
# @BRIEF Check if a JWT token has been revoked.
|
||||
# @PRE db is a valid SQLAlchemy session.
|
||||
# @POST Returns True if token is blacklisted, False otherwise.
|
||||
# @RELATION DEPENDS_ON -> [TokenBlacklist]
|
||||
def is_token_blacklisted(token: str, db: Session) -> bool:
|
||||
with belief_scope("is_token_blacklisted"):
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
return True # Invalid tokens are treated as blacklisted
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return False
|
||||
|
||||
return db.query(TokenBlacklist).filter(TokenBlacklist.jti == jti).first() is not None
|
||||
|
||||
|
||||
# #endregion is_token_blacklisted
|
||||
|
||||
# #endregion AuthJwtModule
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# #region EncryptionKeyModule [C:5] [TYPE Module] [SEMANTICS encryption, fernet, key, env, secret]
|
||||
# @BRIEF Resolve and persist the Fernet encryption key required by runtime services.
|
||||
# @BRIEF Resolve a Fernet encryption key from environment or .env file.
|
||||
# @LAYER Infrastructure
|
||||
# @RELATION DEPENDS_ON -> [LoggerModule]
|
||||
# @INVARIANT Runtime key resolution never falls back to an ephemeral secret.
|
||||
# @PRE Runtime environment can read process variables and target .env path is writable when key generation is required.
|
||||
# @POST A valid Fernet key is available to runtime services via ENCRYPTION_KEY.
|
||||
# @SIDE_EFFECT May append ENCRYPTION_KEY entry into backend .env file and set process environment variable.
|
||||
# @INVARIANT Runtime key resolution never falls back to an auto-generated key.
|
||||
# If no key is found in env or .env, the application crashes early.
|
||||
# @PRE ENCRYPTION_KEY is set in process environment or .env file.
|
||||
# @POST A valid Fernet key is returned and set in process environment.
|
||||
# @SIDE_EFFECT Sets os.environ["ENCRYPTION_KEY"] if loaded from .env file.
|
||||
# @DATA_CONTRACT Input[env_file_path] -> Output[encryption_key]
|
||||
# @RATIONALE Replaced Path(__file__).parents[2] with BASE_DIR import from database.py — same result, avoids recomputing path traversal.
|
||||
# @RATIONALE Auto-generating a key on first run is dangerous — the key differs
|
||||
# per deployment, making encrypted data unrecoverable on restart.
|
||||
# Crash-early forces the admin to explicitly set ENCRYPTION_KEY.
|
||||
# @REJECTED Auto-generation rejected — encrypted data becomes unrecoverable
|
||||
# when the key changes between deployments (see [SEC:H-3]).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,17 +28,21 @@ DEFAULT_ENV_FILE_PATH = Path(__file__).resolve().parents[2] / ".env"
|
||||
|
||||
# #region ensure_encryption_key [TYPE Function]
|
||||
# @BRIEF Ensure backend runtime has a persistent valid Fernet key.
|
||||
# @PRE env_file_path points to a writable backend .env file or ENCRYPTION_KEY exists in process environment.
|
||||
# @POST Returns a valid Fernet key and guarantees it is present in process environment.
|
||||
# @SIDE_EFFECT May create or append backend/.env when key is missing.
|
||||
# @PRE ENCRYPTION_KEY is set in process environment or backend/.env file.
|
||||
# @POST Returns a valid Fernet key and sets it in process environment.
|
||||
# @SIDE_EFFECT Sets os.environ["ENCRYPTION_KEY"] if loaded from .env file.
|
||||
# @RAISES RuntimeError if no ENCRYPTION_KEY is found anywhere.
|
||||
def ensure_encryption_key(env_file_path: Path = DEFAULT_ENV_FILE_PATH) -> str:
|
||||
with belief_scope("ensure_encryption_key", f"env_file_path={env_file_path}"):
|
||||
|
||||
# 1. Check process environment
|
||||
existing_key = os.getenv("ENCRYPTION_KEY", "").strip()
|
||||
if existing_key:
|
||||
Fernet(existing_key.encode())
|
||||
logger.reason("Using ENCRYPTION_KEY from process environment.")
|
||||
return existing_key
|
||||
|
||||
# 2. Check .env file
|
||||
if env_file_path.exists():
|
||||
for raw_line in env_file_path.read_text(encoding="utf-8").splitlines():
|
||||
if raw_line.startswith("ENCRYPTION_KEY="):
|
||||
@@ -44,16 +53,18 @@ def ensure_encryption_key(env_file_path: Path = DEFAULT_ENV_FILE_PATH) -> str:
|
||||
logger.reason(f"Loaded ENCRYPTION_KEY from {env_file_path}.")
|
||||
return persisted_key
|
||||
|
||||
generated_key = Fernet.generate_key().decode()
|
||||
with env_file_path.open("a", encoding="utf-8") as env_file:
|
||||
if env_file.tell() > 0:
|
||||
env_file.write("\n")
|
||||
env_file.write(f"ENCRYPTION_KEY={generated_key}\n")
|
||||
|
||||
os.environ["ENCRYPTION_KEY"] = generated_key
|
||||
logger.reason(f"Generated ENCRYPTION_KEY and persisted it to {env_file_path}.")
|
||||
logger.reflect("Encryption key is available for runtime services.")
|
||||
return generated_key
|
||||
# 3. Crash-early — no auto-generation (see [SEC:H-3])
|
||||
logger.explore(
|
||||
"No ENCRYPTION_KEY found in environment or .env file. "
|
||||
"Generate one with: python -c \"from cryptography.fernet import Fernet; "
|
||||
"print(Fernet.generate_key().decode())\" and set it in .env or export it."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"ENCRYPTION_KEY is not set. "
|
||||
"Generate a key and add it to your .env file:\n"
|
||||
" python -c \"from cryptography.fernet import Fernet; "
|
||||
"print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
|
||||
|
||||
# #endregion ensure_encryption_key
|
||||
|
||||
@@ -24,7 +24,7 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.jwt import decode_token, is_token_blacklisted
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .core.config_manager import ConfigManager
|
||||
from .core.database import get_auth_db, get_db, init_db
|
||||
@@ -500,9 +500,10 @@ oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_e
|
||||
|
||||
# #region get_current_user [C:3] [TYPE Function]
|
||||
# @RELATION CALLS -> AuthRepository
|
||||
# @RELATION CALLS -> [is_token_blacklisted]
|
||||
# @BRIEF Dependency for retrieving currently authenticated user from a JWT.
|
||||
# @PRE JWT token provided in Authorization header.
|
||||
# @POST Returns User object if token is valid.
|
||||
# @POST Returns User object if token is valid and not revoked.
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_auth_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -518,6 +519,14 @@ def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_auth_db
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# Check blacklist
|
||||
if is_token_blacklisted(token, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
|
||||
@@ -116,6 +116,22 @@ class Permission(Base):
|
||||
# #endregion Permission
|
||||
|
||||
|
||||
# #region TokenBlacklist [TYPE Class]
|
||||
# @BRIEF Stores SHA-256 hashes of revoked JWTs for server-side token blacklisting.
|
||||
# @INVARIANT Only the SHA-256 hash of the token is stored — never the raw token.
|
||||
# Expired entries are cleaned up by _prune_blacklist().
|
||||
# @RELATION DEPENDS_ON -> [EXT:Library:SQLAlchemy.Base]
|
||||
class TokenBlacklist(Base):
|
||||
__tablename__ = "token_blacklist"
|
||||
|
||||
jti = Column(String, primary_key=True) # JWT ID from the token
|
||||
token_hash = Column(String(64), nullable=False, index=True) # SHA-256 of token
|
||||
expires_at = Column(DateTime, nullable=False) # Token's original exp claim
|
||||
revoked_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
# #endregion TokenBlacklist
|
||||
|
||||
# #region ADGroupMapping [TYPE Class]
|
||||
# @BRIEF Maps an Active Directory group to a local System Role.
|
||||
# @RELATION DEPENDS_ON -> [Role]
|
||||
|
||||
Reference in New Issue
Block a user