@@ -763,6 +763,58 @@ def api_docs():
763763
764764jwt = JWTManager (app )
765765
766+ # Token blocklist storage for revoked access tokens
767+ # Uses Redis for production scalability, falls back to in-memory for testing
768+ _revoked_tokens = set ()
769+
770+
771+ def get_revoked_tokens_storage ():
772+ """Get the revoked tokens storage (Redis or in-memory fallback)."""
773+ try :
774+ import redis
775+
776+ redis_url = SETTINGS .get ("CELERY_BROKER_URL" )
777+ if redis_url :
778+ return redis .from_url (redis_url )
779+ except Exception as e :
780+ logger .debug (f"Redis not available for token blocklist, using in-memory: { e } " )
781+ return None
782+
783+
784+ def add_token_to_blocklist (jti : str , expires_in_seconds : int = 3600 ) -> None :
785+ """Add a token JTI to the blocklist."""
786+ redis_client = get_revoked_tokens_storage ()
787+ if redis_client :
788+ try :
789+ # Store in Redis with expiration matching token lifetime
790+ redis_client .setex (f"blocklist:{ jti } " , expires_in_seconds , "revoked" )
791+ return
792+ except Exception as e :
793+ logger .warning (f"Failed to add token to Redis blocklist: { e } " )
794+ # Fallback to in-memory (note: not shared across workers)
795+ _revoked_tokens .add (jti )
796+
797+
798+ def is_token_in_blocklist (jti : str ) -> bool :
799+ """Check if a token JTI is in the blocklist."""
800+ redis_client = get_revoked_tokens_storage ()
801+ if redis_client :
802+ try :
803+ return redis_client .exists (f"blocklist:{ jti } " ) > 0
804+ except Exception as e :
805+ logger .warning (f"Failed to check Redis blocklist: { e } " )
806+ # Fallback to in-memory
807+ return jti in _revoked_tokens
808+
809+
810+ @jwt .token_in_blocklist_loader
811+ def check_if_token_in_blocklist (jwt_header , jwt_payload ):
812+ """Check if JWT access token has been revoked."""
813+ jti = jwt_payload .get ("jti" )
814+ if not jti :
815+ return False
816+ return is_token_in_blocklist (jti )
817+
766818
767819from gefapi .models import User # noqa:E402
768820from gefapi .services import UserService # noqa:E402
@@ -845,7 +897,27 @@ def refresh_token():
845897@jwt_required ()
846898def logout ():
847899 logger .info ("[JWT]: User logout..." )
848- refresh_token_string = request .json .get ("refresh_token" , None )
900+ from flask_jwt_extended import get_jwt
901+
902+ # Revoke the current access token by adding its JTI to blocklist
903+ try :
904+ jwt_data = get_jwt ()
905+ jti = jwt_data .get ("jti" )
906+ if jti :
907+ # Get remaining token lifetime for blocklist expiration
908+ exp = jwt_data .get ("exp" , 0 )
909+ import time
910+
911+ remaining_seconds = max (int (exp - time .time ()), 0 ) + 60 # Add buffer
912+ add_token_to_blocklist (jti , remaining_seconds )
913+ logger .info (f"[JWT]: Access token { jti [:8 ]} ... added to blocklist" )
914+ except Exception as e :
915+ logger .warning (f"[JWT]: Failed to revoke access token: { e } " )
916+
917+ # Revoke refresh token if provided
918+ refresh_token_string = None
919+ if request .json :
920+ refresh_token_string = request .json .get ("refresh_token" , None )
849921
850922 if refresh_token_string :
851923 # Import here to avoid circular imports
@@ -878,6 +950,47 @@ def user_lookup_callback(_jwt_header, jwt_data):
878950 return User .query .filter_by (id = identity ).one_or_none ()
879951
880952
953+ @jwt .expired_token_loader
954+ def expired_token_callback (jwt_header , jwt_payload ):
955+ """Handle expired JWT tokens with consistent error response."""
956+ logger .debug ("[JWT]: Expired token detected" )
957+ return jsonify (
958+ {"status" : 401 , "detail" : "Token has expired" , "error" : "token_expired" }
959+ ), 401
960+
961+
962+ @jwt .invalid_token_loader
963+ def invalid_token_callback (error_message ):
964+ """Handle invalid JWT tokens with consistent error response."""
965+ logger .warning (f"[JWT]: Invalid token: { error_message } " )
966+ return jsonify (
967+ {"status" : 401 , "detail" : "Invalid token" , "error" : "invalid_token" }
968+ ), 401
969+
970+
971+ @jwt .unauthorized_loader
972+ def missing_token_callback (error_message ):
973+ """Handle missing JWT tokens with consistent error response."""
974+ logger .debug (f"[JWT]: Missing token: { error_message } " )
975+ return jsonify (
976+ {
977+ "status" : 401 ,
978+ "detail" : "Authorization token required" ,
979+ "error" : "authorization_required" ,
980+ }
981+ ), 401
982+
983+
984+ @jwt .revoked_token_loader
985+ def revoked_token_callback (jwt_header , jwt_payload ):
986+ """Handle revoked JWT tokens with consistent error response."""
987+ jti = jwt_payload .get ("jti" , "unknown" )
988+ logger .info (f"[JWT]: Revoked token access attempt: { jti [:8 ]} ..." )
989+ return jsonify (
990+ {"status" : 401 , "detail" : "Token has been revoked" , "error" : "token_revoked" }
991+ ), 401
992+
993+
881994@app .errorhandler (403 )
882995def forbidden (e ):
883996 return error (status = 403 , detail = "Forbidden" )
0 commit comments