|
11 | 11 | from flask_limiter.util import get_remote_address |
12 | 12 | from sqlalchemy.orm import joinedload |
13 | 13 | from flask_migrate import Migrate |
| 14 | +from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError |
14 | 15 | from sqlalchemy.exc import SQLAlchemyError |
15 | 16 | from dotenv import load_dotenv |
16 | 17 | from backend.spine_generator import create_spine |
|
111 | 112 | # Apply configuration to Flask app |
112 | 113 | app.config.update(app_config.flask_config) |
113 | 114 |
|
| 115 | +# ===================================================================== |
| 116 | +# SECURITY COMPLIANCE UPDATE: CSRF PROTECTION (FLASK-WTF) |
| 117 | +# ===================================================================== |
| 118 | +# Cross-Site Request Forgery (CSRF) is a serious vulnerability where |
| 119 | +# an attacker tricks a user into performing actions they didn't intend |
| 120 | +# to do on a different website where they are authenticated. |
| 121 | +# |
| 122 | +# While JWT-Extended provides CSRF protection for authenticated |
| 123 | +# requests via cookies, the initial authentication flow (Login/Register) |
| 124 | +# often remains vulnerable if not explicitly protected. |
| 125 | +# |
| 126 | +# We initialize Flask-WTF's CSRFProtect to provide a secondary layer |
| 127 | +# of defense. This will automatically validate CSRF tokens for all |
| 128 | +# POST, PUT, PATCH, and DELETE requests. |
| 129 | +# ===================================================================== |
| 130 | +csrf = CSRFProtect(app) |
| 131 | + |
| 132 | +# Exclude certain endpoints from global CSRF if they are handled by JWT CSRF |
| 133 | +# or if they are intended to be public-facing without token requirements. |
| 134 | +# In this architecture, we prefer explicit protection on all mutation routes. |
| 135 | +# csrf.exempt(some_blueprint) |
| 136 | + |
114 | 137 | # Initialize JWT Manager |
115 | 138 | jwt = JWTManager(app) |
116 | 139 |
|
@@ -152,6 +175,30 @@ def page_not_found(e: Exception): |
152 | 175 | return app.send_static_file('404.html'), 404 |
153 | 176 |
|
154 | 177 |
|
| 178 | +@app.errorhandler(CSRFError) |
| 179 | +def handle_csrf_error(e): |
| 180 | + """ |
| 181 | + ========================================================================= |
| 182 | + CUSTOM CSRF ERROR HANDLER |
| 183 | + ========================================================================= |
| 184 | + Intercepts CSRF validation failures and returns a standardized JSON |
| 185 | + error response instead of the default HTML error page. |
| 186 | + |
| 187 | + This is critical for a RESTful API architecture where the client |
| 188 | + expects consistent JSON structures even during security failures. |
| 189 | + |
| 190 | + Status: 400 Bad Request (as per Flask-WTF default for CSRF failures) |
| 191 | + ========================================================================= |
| 192 | + """ |
| 193 | + logger.warning(f"CSRF Validation Failed: {e.description} | Remote IP: {request.remote_addr}") |
| 194 | + return jsonify({ |
| 195 | + "success": False, |
| 196 | + "error": "CSRF_VALIDATION_FAILED", |
| 197 | + "message": f"Security token validation failed: {e.description}. Please refresh the page.", |
| 198 | + "code": 400 |
| 199 | + }), 400 |
| 200 | + |
| 201 | + |
155 | 202 | @app.after_request |
156 | 203 | def add_security_headers(response): |
157 | 204 | """ |
@@ -309,6 +356,25 @@ def get_config(): |
309 | 356 | "google_books_key_secondary": os.getenv('GOOGLE_BOOKS_API_KEY_SECONDARY', '') |
310 | 357 | }) |
311 | 358 |
|
| 359 | +# ===================================================================== |
| 360 | +# ENDPOINT: CSRF Token Retrieval |
| 361 | +# ===================================================================== |
| 362 | +# Since this is a decoupled frontend, we need a way for the client-side |
| 363 | +# application to "prime" itself with a valid CSRF token before attempting |
| 364 | +# a state-mutating request (like Login or Register). |
| 365 | +# |
| 366 | +# This endpoint generates a new token and sets the associated session |
| 367 | +# cookie. The frontend should call this on page load of sensitive forms. |
| 368 | +# ===================================================================== |
| 369 | +@app.route('/api/v1/csrf-token', methods=['GET']) |
| 370 | +def get_csrf_token(): |
| 371 | + """ |
| 372 | + Generate and return a fresh CSRF token. |
| 373 | + The token is automatically tied to the user's session. |
| 374 | + """ |
| 375 | + token = generate_csrf() |
| 376 | + return success_response(data={"csrf_token": token}) |
| 377 | + |
312 | 378 | @app.route('/') |
313 | 379 | def index(): |
314 | 380 | """Simple index page showing available API endpoints.""" |
@@ -1090,6 +1156,15 @@ def register(): |
1090 | 1156 | try: |
1091 | 1157 | data = request.get_json() |
1092 | 1158 |
|
| 1159 | + # ========================================================================= |
| 1160 | + # SECURITY AUDIT: REGISTRATION ATTEMPT |
| 1161 | + # ========================================================================= |
| 1162 | + # All registration attempts are logged for security auditing purposes. |
| 1163 | + # CSRF protection is enforced automatically by Flask-WTF for this |
| 1164 | + # POST request, ensuring the signup originates from our own UI. |
| 1165 | + # ========================================================================= |
| 1166 | + logger.info(f"Registration attempt for user: {data.get('username')} from IP: {request.remote_addr}") |
| 1167 | + |
1093 | 1168 | is_valid, validated_data = validate_request(RegisterRequest, data) |
1094 | 1169 | if not is_valid: |
1095 | 1170 | return jsonify(validated_data), 400 |
@@ -1134,6 +1209,15 @@ def login(): |
1134 | 1209 | try: |
1135 | 1210 | data = request.get_json() |
1136 | 1211 |
|
| 1212 | + # ========================================================================= |
| 1213 | + # SECURITY AUDIT: LOGIN ATTEMPT |
| 1214 | + # ========================================================================= |
| 1215 | + # All login attempts are strictly validated against CSRF tokens. |
| 1216 | + # This prevents an attacker from creating a malicious site that |
| 1217 | + # automatically logs a user into an account they control. |
| 1218 | + # ========================================================================= |
| 1219 | + logger.info(f"Login attempt for identifier: {data.get('username')} from IP: {request.remote_addr}") |
| 1220 | + |
1137 | 1221 | is_valid, validated_data = validate_request(LoginRequest, data) |
1138 | 1222 | if not is_valid: |
1139 | 1223 | return jsonify(validated_data), 400 |
@@ -1169,6 +1253,22 @@ def logout(): |
1169 | 1253 | return resp, status |
1170 | 1254 |
|
1171 | 1255 |
|
| 1256 | +@app.route('/api/v1/auth/verify', methods=['GET']) |
| 1257 | +@jwt_required() |
| 1258 | +def verify_auth_session(): |
| 1259 | + """Validate JWT from access cookie and return the current user (session restore).""" |
| 1260 | + try: |
| 1261 | + uid = get_jwt_identity() |
| 1262 | + user = User.query.get(int(uid)) |
| 1263 | + if not user: |
| 1264 | + return jsonify({"error": "User not found"}), 404 |
| 1265 | + return jsonify({ |
| 1266 | + "user": {"id": user.id, "username": user.username, "email": user.email} |
| 1267 | + }), 200 |
| 1268 | + except (TypeError, ValueError): |
| 1269 | + return jsonify({"error": "Invalid session"}), 401 |
| 1270 | + |
| 1271 | + |
1172 | 1272 | # ==================== READING STATS ENDPOINTS ==================== |
1173 | 1273 | @app.route('/api/v1/stats/goal', methods=['POST']) |
1174 | 1274 | @jwt_required() |
|
0 commit comments