From ac20f1844145ef48d3f843183a5920a6aa2fb411 Mon Sep 17 00:00:00 2001 From: onkar0127 Date: Tue, 23 Jun 2026 00:00:03 +0530 Subject: [PATCH] Fix microservice authentication for internal scanning APIs --- backend/api.py | 52 +++++++++++---- backend/server.js | 12 ++++ backend/tests/test_internal_secret.py | 93 +++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_internal_secret.py diff --git a/backend/api.py b/backend/api.py index 3806a4f..e085b9a 100644 --- a/backend/api.py +++ b/backend/api.py @@ -37,10 +37,34 @@ app = Flask(__name__) CORS(app, resources={r"/*": {"origins": "*" }}) -from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity +from functools import wraps +from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity, verify_jwt_in_request app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret") jwt = JWTManager(app) +def jwt_or_secret_required(): + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + secret = request.headers.get("X-Internal-Secret") + expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key") + if secret and secret == expected_secret: + return fn(*args, **kwargs) + verify_jwt_in_request() + return fn(*args, **kwargs) + return wrapper + return decorator + +def get_current_user_identity(): + secret = request.headers.get("X-Internal-Secret") + expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key") + if secret and secret == expected_secret: + return request.headers.get("X-User-Username") + try: + return get_jwt_identity() + except Exception: + return None + BASE_DIR = Path(__file__).resolve().parent def resolve_path(env_var, default_filename): @@ -401,11 +425,11 @@ def gmail_auth_url(): return jsonify({"auth_url": url}) @app.route("/gmail/callback", methods=["GET"]) -@jwt_required() +@jwt_or_secret_required() def gmail_callback(): code = request.args.get("code") redirect_uri = request.args.get("redirect_uri") or "http://localhost:3000/gmail/callback" - username = get_jwt_identity() + username = get_current_user_identity() if not code: return jsonify({"error": "Authorization code is missing"}), 400 @@ -420,9 +444,9 @@ def gmail_callback(): return jsonify({"error": f"Failed to exchange Google code: {str(e)}"}), 500 @app.route("/gmail/emails", methods=["GET"]) -@jwt_required() +@jwt_or_secret_required() def gmail_emails(): - username = get_jwt_identity() + username = get_current_user_identity() user_tokens = TOKEN_STORE.get(username, {}).get("gmail") if not user_tokens: @@ -451,11 +475,11 @@ def outlook_auth_url(): return jsonify({"auth_url": url}) @app.route("/outlook/callback", methods=["GET"]) -@jwt_required() +@jwt_or_secret_required() def outlook_callback(): code = request.args.get("code") redirect_uri = request.args.get("redirect_uri") or "http://localhost:3000/outlook/callback" - username = get_jwt_identity() + username = get_current_user_identity() if not code: return jsonify({"error": "Authorization code is missing"}), 400 @@ -470,9 +494,9 @@ def outlook_callback(): return jsonify({"error": f"Failed to exchange Outlook code: {str(e)}"}), 500 @app.route("/outlook/emails", methods=["GET"]) -@jwt_required() +@jwt_or_secret_required() def outlook_emails(): - username = get_jwt_identity() + username = get_current_user_identity() user_tokens = TOKEN_STORE.get(username, {}).get("outlook") if not user_tokens: @@ -495,11 +519,11 @@ def outlook_emails(): return jsonify({"error": f"Failed to fetch Outlook emails: {str(e)}"}), 500 @app.route("/scan-emails", methods=["POST"]) -@jwt_required() +@jwt_or_secret_required() def scan_emails_route(): data = request.get_json(silent=True) or {} provider = data.get("provider", "").lower() - username = get_jwt_identity() + username = get_current_user_identity() if provider not in ("gmail", "outlook"): return jsonify({"error": "Invalid provider. Must be 'gmail' or 'outlook'."}), 400 @@ -577,8 +601,12 @@ def _schedule_user_job(username, interval_minutes): def _require_username(): """The Node gateway authenticates the user and forwards their identity via this - header (it does not forward a Flask-issued JWT, so jwt_required() can't be used here). + header. We also verify the internal secret for security. """ + secret = request.headers.get("X-Internal-Secret") + expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key") + if not secret or secret != expected_secret: + return None username = request.headers.get("X-User-Username") if not username: return None diff --git a/backend/server.js b/backend/server.js index ee29dc0..d2b9cb3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,18 @@ dns.setServers(["8.8.8.8", "1.1.1.1"]); // ensure SRV records resolve on all net const express = require("express"); const cors = require("cors"); const axios = require("axios"); + +// Configure global request interceptor to append the internal secret API key +axios.interceptors.request.use( + (config) => { + const internalSecret = process.env.INTERNAL_SECRET || "super-secret-internal-key"; + config.headers["X-Internal-Secret"] = internalSecret; + return config; + }, + (error) => { + return Promise.reject(error); + } +); const mongoose = require("mongoose"); const History = require("./models/History"); diff --git a/backend/tests/test_internal_secret.py b/backend/tests/test_internal_secret.py new file mode 100644 index 0000000..825d80f --- /dev/null +++ b/backend/tests/test_internal_secret.py @@ -0,0 +1,93 @@ +import os +import sys +from pathlib import Path +import pytest +from unittest.mock import patch + +BASE_DIR = Path(__file__).resolve().parents[2] +BACKEND_DIR = BASE_DIR / "backend" + +os.environ.setdefault("MODEL_PATH", str(BASE_DIR / "linear_svm_model.pkl")) +os.environ.setdefault("VECTORIZER_PATH", str(BACKEND_DIR / "tfidf_vectorizer.pkl")) +os.environ.setdefault("LABEL_ENCODER_PATH", str(BASE_DIR / "label_encoder.pkl")) +os.environ.setdefault("URL_MODEL_PATH", str(BACKEND_DIR / "url_detector.pkl")) +os.environ.setdefault("URL_VECTORIZER_PATH", str(BACKEND_DIR / "url_vectorizer.pkl")) + +sys.path.insert(0, str(BACKEND_DIR)) + +import api as api_module +from flask_jwt_extended import create_access_token + +@pytest.fixture +def client(): + api_module.app.config["TESTING"] = True + api_module.app.config["JWT_SECRET_KEY"] = "super-secret" + with api_module.app.test_client() as c: + yield c + +def test_jwt_or_secret_required_with_valid_secret(client): + headers = { + "X-Internal-Secret": "super-secret-internal-key", + "X-User-Username": "test_user" + } + api_module.TOKEN_STORE["test_user"] = { + "gmail": { + "access_token": "mock_gmail_access_token" + } + } + + with patch("api.fetch_gmail_emails") as mock_fetch: + mock_fetch.return_value = [] + res = client.get("/gmail/emails", headers=headers) + assert res.status_code == 200 + +def test_jwt_or_secret_required_with_valid_jwt(client): + with api_module.app.app_context(): + token = create_access_token(identity="test_user") + + headers = { + "Authorization": f"Bearer {token}" + } + + api_module.TOKEN_STORE["test_user"] = { + "gmail": { + "access_token": "mock_gmail_access_token" + } + } + + with patch("api.fetch_gmail_emails") as mock_fetch: + mock_fetch.return_value = [] + res = client.get("/gmail/emails", headers=headers) + assert res.status_code == 200 + +def test_jwt_or_secret_required_missing_auth(client): + res = client.get("/gmail/emails") + assert res.status_code == 401 + +def test_jwt_or_secret_required_invalid_secret(client): + headers = { + "X-Internal-Secret": "wrong-secret", + "X-User-Username": "test_user" + } + res = client.get("/gmail/emails", headers=headers) + assert res.status_code == 401 + +def test_imap_require_username_with_secret(client): + headers = { + "X-Internal-Secret": "super-secret-internal-key", + "X-User-Username": "test_user" + } + + with patch("imap_store.get_connection") as mock_get_conn: + mock_get_conn.return_value = None + res = client.get("/imap/status", headers=headers) + assert res.status_code == 200 + assert res.get_json() == {"connected": False} + +def test_imap_require_username_missing_secret(client): + headers = { + "X-User-Username": "test_user" + } + res = client.get("/imap/status", headers=headers) + assert res.status_code == 401 + assert res.get_json() == {"error": "Missing X-User-Username header"}