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:
2026-05-26 15:08:55 +03:00
parent a9a453109c
commit 23719ecfbc
7 changed files with 212 additions and 53 deletions

View File

@@ -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"}