From 2614df497a4311a8cda44aecfda7812187b2e27a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sun, 6 Apr 2025 23:56:03 -0400 Subject: [PATCH 01/28] Enhance Docker and JWT handling: update Dockerfile to install OpenSSL, modify jwt_helper to use RSA keys, and add entrypoint script for key generation --- .devcontainer/Dockerfile | 2 +- Dockerfile | 5 +++-- entrypoint.sh | 20 ++++++++++++++++++++ jwt_helper.py | 22 ++++++++++++++-------- 4 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 entrypoint.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ef2170d..03ed08c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,7 +2,7 @@ FROM alpine:latest # Install common tools RUN apk add --no-cache bash git \ - python3 py3-pip + python3 py3-pip openssl # Setup default user ARG USERNAME=vscode diff --git a/Dockerfile b/Dockerfile index 3df21ad..4ac1e8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ FROM python:3.13-slim # Set a specific working directory in the container WORKDIR /app -# Install dependencies separately for better caching +# Install dependencies +RUN apt update && apt install -y openssl COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -23,4 +24,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 # Command to run the app -CMD ["python", "app.py"] +ENTRYPOINT ["./scripts/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..ebd4f98 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -e # Exit if any command fails + +KEY_DIR="/app/keys" +PRIVATE_KEY="$KEY_DIR/private_key.pem" +PUBLIC_KEY="$KEY_DIR/public_key.pem" + +mkdir -p "$KEY_DIR" + +if [ ! -f "$PRIVATE_KEY" ] || [ ! -f "$PUBLIC_KEY" ]; then + echo "🔐 Generating RSA key pair..." + openssl genpkey -algorithm RSA -out "$PRIVATE_KEY" -pkeyopt rsa_keygen_bits:2048 + openssl rsa -pubout -in "$PRIVATE_KEY" -out "$PUBLIC_KEY" +else + echo "✅ Keys already exist. Skipping generation." +fi + +# Start your Python app +exec python3 app.py diff --git a/jwt_helper.py b/jwt_helper.py index f9f4d9e..7004113 100644 --- a/jwt_helper.py +++ b/jwt_helper.py @@ -1,14 +1,21 @@ -import os from datetime import datetime, timedelta, timezone from functools import wraps +from pathlib import Path import jwt from flask import jsonify, request -JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "SuperSecretKey") +PRIVATE_KEY_PATH = Path("keys/private_key.pem") +PUBLIC_KEY_PATH = Path("keys/public_key.pem") JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) +with open(PRIVATE_KEY_PATH, "rb") as f: + PRIVATE_KEY = f.read() + +with open(PUBLIC_KEY_PATH, "rb") as f: + PUBLIC_KEY = f.read() + class TokenError(Exception): """Custom exception for token-related errors.""" @@ -20,25 +27,24 @@ def __init__(self, message, status_code): def generate_access_token(person_id: int) -> str: - """Generate a short-lived JWT access token for a user.""" payload = { "person_id": person_id, "exp": datetime.now(timezone.utc) + JWT_ACCESS_TOKEN_EXPIRY, # Expiration "iat": datetime.now(timezone.utc), # Issued at "token_type": "access", } - return jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256") + return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256") def generate_refresh_token(person_id: int) -> str: """Generate a long-lived refresh token for a user.""" payload = { "person_id": person_id, - "exp": datetime.now(timezone.utc) + JWT_REFRESH_TOKEN_EXPIRY, - "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + JWT_REFRESH_TOKEN_EXPIRY, # Expiration + "iat": datetime.now(timezone.utc), # Issued at "token_type": "refresh", } - return jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256") + return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256") def extract_token_from_header() -> str: @@ -52,7 +58,7 @@ def extract_token_from_header() -> str: def verify_token(token: str, required_type: str) -> dict: """Verify and decode a JWT token.""" try: - decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"]) + decoded = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) if decoded.get("token_type") != required_type: raise jwt.InvalidTokenError("Invalid token type") return decoded From c8bc60e0442e66266c409ca6021b600f4897b909 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 15:14:21 +0000 Subject: [PATCH 02/28] Implement JWT key rotation: add endpoint to rotate keys, update key management in jwt_helper, and modify .gitignore for key files --- .gitignore | 3 +- jwt_helper.py | 37 +++++++++++++++++++++-- routes/__init__.py | 2 ++ routes/admin/__init__.py | 7 +++++ routes/admin/jwt_rotation.py | 57 ++++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 routes/admin/__init__.py create mode 100644 routes/admin/jwt_rotation.py diff --git a/.gitignore b/.gitignore index 02e28a6..6577ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Log files *.log -# Ignore all pem files +# Ignore JWT keys +keys/ *.pem # Upload folder diff --git a/jwt_helper.py b/jwt_helper.py index 7004113..3b2dcb7 100644 --- a/jwt_helper.py +++ b/jwt_helper.py @@ -26,14 +26,31 @@ def __init__(self, message, status_code): self.message = message +import os + + +def get_active_kid(): + with open("keys/active_kid.txt", "r") as f: + return f.read().strip() + + +def load_private_key(kid): + with open(f"keys/{kid}/private.pem", "rb") as f: + return f.read() + + def generate_access_token(person_id: int) -> str: + kid = get_active_kid() + private_key = load_private_key(kid) + payload = { "person_id": person_id, "exp": datetime.now(timezone.utc) + JWT_ACCESS_TOKEN_EXPIRY, # Expiration "iat": datetime.now(timezone.utc), # Issued at "token_type": "access", } - return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256") + headers = {"kid": kid} + return jwt.encode(payload, private_key, algorithm="RS256", headers=headers) def generate_refresh_token(person_id: int) -> str: @@ -55,13 +72,29 @@ def extract_token_from_header() -> str: return auth_header.split("Bearer ")[1] +def load_public_key(kid): + try: + with open(f"keys/{kid}/public.pem", "rb") as f: + return f.read() + except FileNotFoundError: + raise TokenError("Unknown key ID", 401) + + def verify_token(token: str, required_type: str) -> dict: """Verify and decode a JWT token.""" try: - decoded = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + if not kid: + raise TokenError("KID missing in token header", 401) + + public_key = load_public_key(kid) + + decoded = jwt.decode(token, public_key, algorithms=["RS256"]) if decoded.get("token_type") != required_type: raise jwt.InvalidTokenError("Invalid token type") return decoded + except jwt.ExpiredSignatureError: raise TokenError("Token has expired", 401) except jwt.InvalidTokenError: diff --git a/routes/__init__.py b/routes/__init__.py index 4bf76b0..571fd6d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,3 +1,4 @@ +from .admin import admin_blueprint from .authentication import authentication_blueprint from .comment import comment_blueprint from .ingredient import ingredient_blueprint @@ -9,6 +10,7 @@ def register_routes(app): + app.register_blueprint(admin_blueprint, url_prefix="/admin") app.register_blueprint(authentication_blueprint, url_prefix="/auth") app.register_blueprint(comment_blueprint, url_prefix="/comment") app.register_blueprint(ingredient_blueprint, url_prefix="/ingredient") diff --git a/routes/admin/__init__.py b/routes/admin/__init__.py new file mode 100644 index 0000000..e942a02 --- /dev/null +++ b/routes/admin/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +from routes.admin.jwt_rotation import jwt_rotation_blueprint + +admin_blueprint = Blueprint("admin", __name__) + +admin_blueprint.register_blueprint(jwt_rotation_blueprint, url_prefix="/jwt_rotation") diff --git a/routes/admin/jwt_rotation.py b/routes/admin/jwt_rotation.py new file mode 100644 index 0000000..84a7c1c --- /dev/null +++ b/routes/admin/jwt_rotation.py @@ -0,0 +1,57 @@ +import os +import secrets +import subprocess + +from flask import Blueprint, abort, jsonify, request + +from config.ratelimit import limiter + +jwt_rotation_blueprint = Blueprint("jwt_rotation", __name__) + + +@jwt_rotation_blueprint.route("/rotate-keys", methods=["POST"]) +@limiter.limit("1 per day") +def rotate_keys(): + """ + Rotate the keys used for JWT signing. + This endpoint is protected and should only be accessible from localhost. + """ + if request.remote_addr != "127.0.0.1": + abort(403) + + new_kid = secrets.token_hex(8) + key_dir = f"keys/{new_kid}" + os.makedirs(key_dir, exist_ok=False) + + # Use OpenSSL to generate keys + subprocess.run( + [ + "openssl", + "genpkey", + "-algorithm", + "RSA", + "-out", + f"{key_dir}/private.pem", + "-pkeyopt", + "rsa_keygen_bits:2048", + ], + check=True, + ) + subprocess.run( + [ + "openssl", + "rsa", + "-pubout", + "-in", + f"{key_dir}/private.pem", + "-out", + f"{key_dir}/public.pem", + ], + check=True, + ) + + # Update active_kid + with open("keys/active_kid.txt", "w") as f: + f.write(new_kid) + + return jsonify(message=f"KID rotated. New kid: {new_kid}") From 968da276f236d689c1271469b8a3c6ac7f186396 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 15:18:44 +0000 Subject: [PATCH 03/28] Refactor JWT key handling: remove hardcoded key loading, utilize dynamic key retrieval in token generation --- jwt_helper.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/jwt_helper.py b/jwt_helper.py index 3b2dcb7..8a13c3b 100644 --- a/jwt_helper.py +++ b/jwt_helper.py @@ -1,21 +1,12 @@ from datetime import datetime, timedelta, timezone from functools import wraps -from pathlib import Path import jwt from flask import jsonify, request -PRIVATE_KEY_PATH = Path("keys/private_key.pem") -PUBLIC_KEY_PATH = Path("keys/public_key.pem") JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) -with open(PRIVATE_KEY_PATH, "rb") as f: - PRIVATE_KEY = f.read() - -with open(PUBLIC_KEY_PATH, "rb") as f: - PUBLIC_KEY = f.read() - class TokenError(Exception): """Custom exception for token-related errors.""" @@ -26,9 +17,6 @@ def __init__(self, message, status_code): self.message = message -import os - - def get_active_kid(): with open("keys/active_kid.txt", "r") as f: return f.read().strip() @@ -55,13 +43,18 @@ def generate_access_token(person_id: int) -> str: def generate_refresh_token(person_id: int) -> str: """Generate a long-lived refresh token for a user.""" + kid = get_active_kid() + private_key = load_private_key(kid) + payload = { "person_id": person_id, "exp": datetime.now(timezone.utc) + JWT_REFRESH_TOKEN_EXPIRY, # Expiration "iat": datetime.now(timezone.utc), # Issued at "token_type": "refresh", } - return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256") + headers = {"kid": kid} + + return jwt.encode(payload, private_key, algorithm="RS256", headers=headers) def extract_token_from_header() -> str: From 01eff90dcefefae0078995bd961498ea613f3b72 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 15:24:40 +0000 Subject: [PATCH 04/28] Add timestamp logging for key creation in JWT rotation --- routes/admin/jwt_rotation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routes/admin/jwt_rotation.py b/routes/admin/jwt_rotation.py index 84a7c1c..b6fcd5e 100644 --- a/routes/admin/jwt_rotation.py +++ b/routes/admin/jwt_rotation.py @@ -1,6 +1,7 @@ import os import secrets import subprocess +from datetime import datetime, timezone from flask import Blueprint, abort, jsonify, request @@ -54,4 +55,8 @@ def rotate_keys(): with open("keys/active_kid.txt", "w") as f: f.write(new_kid) + # Save the kid creation time for cleanup purposes + with open(f"{key_dir}/created_at.txt", "w") as f: + f.write(datetime.now(timezone.utc).isoformat()) + return jsonify(message=f"KID rotated. New kid: {new_kid}") From 702725ad3633d8a33695dc165ef2752e8084b006 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 15:29:12 +0000 Subject: [PATCH 05/28] Change route to be more RESTful --- routes/admin/__init__.py | 2 +- routes/admin/jwt_rotation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/admin/__init__.py b/routes/admin/__init__.py index e942a02..3182130 100644 --- a/routes/admin/__init__.py +++ b/routes/admin/__init__.py @@ -4,4 +4,4 @@ admin_blueprint = Blueprint("admin", __name__) -admin_blueprint.register_blueprint(jwt_rotation_blueprint, url_prefix="/jwt_rotation") +admin_blueprint.register_blueprint(jwt_rotation_blueprint, url_prefix="/jwt_keys") diff --git a/routes/admin/jwt_rotation.py b/routes/admin/jwt_rotation.py index b6fcd5e..0315161 100644 --- a/routes/admin/jwt_rotation.py +++ b/routes/admin/jwt_rotation.py @@ -10,7 +10,7 @@ jwt_rotation_blueprint = Blueprint("jwt_rotation", __name__) -@jwt_rotation_blueprint.route("/rotate-keys", methods=["POST"]) +@jwt_rotation_blueprint.route("/rotate", methods=["POST"]) @limiter.limit("1 per day") def rotate_keys(): """ From f2834ee7edfba024678f11a43af7419e9add4552 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 16:02:03 +0000 Subject: [PATCH 06/28] Remove JWT_SECRET_KEY from config and add script for cleaning up expired JWT keys --- config/settings.py | 1 - utility/jwt_keys_cleanup.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 utility/jwt_keys_cleanup.py diff --git a/config/settings.py b/config/settings.py index e12fe8f..205ab06 100644 --- a/config/settings.py +++ b/config/settings.py @@ -20,7 +20,6 @@ class Config: MYSQL_CURSORCLASS = "DictCursor" # JWT configuration - JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) diff --git a/utility/jwt_keys_cleanup.py b/utility/jwt_keys_cleanup.py new file mode 100644 index 0000000..371690a --- /dev/null +++ b/utility/jwt_keys_cleanup.py @@ -0,0 +1,39 @@ +""" +To run this script ensure you're in the root directory of the project. +Run the script with: `python3 -m utility.jwt_keys_cleanup` +""" + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from jwt_helper import get_active_kid + +KEYS_DIR = Path("keys") +EXPIRY_DAYS = 35 + + +def cleanup_old_keys(): + now = datetime.now(timezone.utc) + active_kid = get_active_kid() + + for kid_dir in KEYS_DIR.iterdir(): + if not kid_dir.is_dir(): + continue + if kid_dir.name == active_kid: + continue # Don't delete active key + created_at_file = kid_dir / "created_at.txt" + if not created_at_file.exists(): + continue # Skip keys without metadata + + with open(created_at_file, "r") as f: + created_at = datetime.fromisoformat(f.read().strip()) + + if (now - created_at) > timedelta(days=EXPIRY_DAYS): + print(f"Deleting expired key: {kid_dir.name}") + for item in kid_dir.iterdir(): + item.unlink() + kid_dir.rmdir() + + +if __name__ == "__main__": + cleanup_old_keys() From 6bc3104563543a3e4a4e73f173e0236a4063c476 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 16:13:34 +0000 Subject: [PATCH 07/28] Refactor JWT tests to use public key for encoding/decoding and enhance sample token generation --- tests/test_jwt/conftest.py | 6 ++ tests/test_jwt/test_generate_access_token.py | 14 ++-- tests/test_jwt/test_refresh_token.py | 17 +++-- tests/test_jwt/test_verify_token.py | 70 +++++++++++++------ .../test_authentication/test_refresh.py | 23 ++++-- 5 files changed, 95 insertions(+), 35 deletions(-) diff --git a/tests/test_jwt/conftest.py b/tests/test_jwt/conftest.py index 72a5cf3..7ffcf65 100644 --- a/tests/test_jwt/conftest.py +++ b/tests/test_jwt/conftest.py @@ -1,3 +1,4 @@ +import jwt import pytest from flask import Flask @@ -27,3 +28,8 @@ def sample_token(): def sample_access_token(sample_person_id): """Provide a sample access token for testing""" return generate_access_token(sample_person_id) + + +@pytest.fixture +def sample_kid(sample_access_token): + return jwt.get_unverified_header(sample_access_token).get("kid") diff --git a/tests/test_jwt/test_generate_access_token.py b/tests/test_jwt/test_generate_access_token.py index ab0c57d..83bae9d 100644 --- a/tests/test_jwt/test_generate_access_token.py +++ b/tests/test_jwt/test_generate_access_token.py @@ -1,6 +1,6 @@ import jwt -from jwt_helper import JWT_ACCESS_TOKEN_EXPIRY, JWT_SECRET_KEY +from jwt_helper import JWT_ACCESS_TOKEN_EXPIRY, load_public_key def test_access_token_type(sample_access_token): @@ -8,28 +8,32 @@ def test_access_token_type(sample_access_token): assert isinstance(sample_access_token, str) -def test_decoded_access_token(sample_person_id, sample_access_token): +def test_decoded_access_token(sample_person_id, sample_access_token, sample_kid): """ Ensure the generated access token can be decoded and contains the correct payload - Check if the payload contains the correct person ID - Check if the token has an expiration time - Check if the token type is 'access' """ - payload = jwt.decode(sample_access_token, JWT_SECRET_KEY, algorithms=["HS256"]) + public_key = load_public_key(sample_kid) + + payload = jwt.decode(sample_access_token, public_key, algorithms=["RS256"]) assert payload["person_id"] == sample_person_id assert "exp" in payload assert payload["token_type"] == "access" -def test_access_token_expiration(sample_access_token): +def test_access_token_expiration(sample_access_token, sample_kid): """ Ensure the generated access token has a valid expiration time - Check if the expiration time is greater than 0 - Check if the expiration time is greater than the issued at time - Check if the token is not expired """ - payload = jwt.decode(sample_access_token, JWT_SECRET_KEY, algorithms=["HS256"]) + public_key = load_public_key(sample_kid) + + payload = jwt.decode(sample_access_token, public_key, algorithms=["RS256"]) assert payload["exp"] > 0 assert payload["exp"] > payload["iat"] diff --git a/tests/test_jwt/test_refresh_token.py b/tests/test_jwt/test_refresh_token.py index ffd9a4a..b55df95 100644 --- a/tests/test_jwt/test_refresh_token.py +++ b/tests/test_jwt/test_refresh_token.py @@ -1,7 +1,7 @@ import jwt import pytest -from jwt_helper import JWT_REFRESH_TOKEN_EXPIRY, JWT_SECRET_KEY, generate_refresh_token +from jwt_helper import JWT_REFRESH_TOKEN_EXPIRY, generate_refresh_token, load_public_key @pytest.fixture @@ -15,28 +15,35 @@ def test_refresh_token_type(sample_refresh_token): assert isinstance(sample_refresh_token, str) -def test_decoded_refresh_token_decoded(sample_person_id, sample_refresh_token): +def test_decoded_refresh_token_decoded( + sample_person_id, sample_refresh_token, sample_kid +): """ Ensure the generated refresh token can be decoded and contains the correct payload - Check if the payload contains the correct person ID - Check if the token has an expiration time - Check if the token type is 'refresh' """ - payload = jwt.decode(sample_refresh_token, JWT_SECRET_KEY, algorithms=["HS256"]) + + public_key = load_public_key(sample_kid) + + payload = jwt.decode(sample_refresh_token, public_key, algorithms=["RS256"]) assert payload["person_id"] == sample_person_id assert "exp" in payload assert payload["token_type"] == "refresh" -def test_refresh_token_expiration(sample_refresh_token): +def test_refresh_token_expiration(sample_refresh_token, sample_kid): """ Ensure the generated refresh token has a valid expiration time - Check if the expiration time is greater than 0 - Check if the expiration time is greater than the issued at time - Check if the token is not expired """ - payload = jwt.decode(sample_refresh_token, JWT_SECRET_KEY, algorithms=["HS256"]) + public_key = load_public_key(sample_kid) + + payload = jwt.decode(sample_refresh_token, public_key, algorithms=["RS256"]) assert payload["exp"] > 0 assert payload["exp"] > payload["iat"] diff --git a/tests/test_jwt/test_verify_token.py b/tests/test_jwt/test_verify_token.py index ca97280..d5d45c5 100644 --- a/tests/test_jwt/test_verify_token.py +++ b/tests/test_jwt/test_verify_token.py @@ -1,54 +1,84 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import jwt import pytest from flask import Flask -from jwt_helper import JWT_SECRET_KEY, TokenError, verify_token +from jwt_helper import ( + TokenError, + generate_access_token, + generate_refresh_token, + get_active_kid, + load_private_key, + verify_token, +) app = Flask(__name__) -def test_verify_valid_access_token(): +def test_verify_valid_access_token(sample_person_id): """Test verifying a valid access token.""" - access_token = jwt.encode( - {"token_type": "access"}, JWT_SECRET_KEY, algorithm="HS256" - ) - decoded = verify_token(access_token, "access") + token = generate_access_token(sample_person_id) + decoded = verify_token(token, "access") + assert decoded["person_id"] == sample_person_id assert decoded["token_type"] == "access" -def test_verify_valid_refresh_token(): +def test_verify_valid_refresh_token(sample_person_id): """Test verifying a valid refresh token.""" - refresh_token = jwt.encode( - {"token_type": "refresh"}, JWT_SECRET_KEY, algorithm="HS256" - ) - decoded = verify_token(refresh_token, "refresh") + token = generate_refresh_token(sample_person_id) + decoded = verify_token(token, "refresh") + assert decoded["person_id"] == sample_person_id assert decoded["token_type"] == "refresh" -def test_verify_token_invalid_type(): +def test_verify_token_invalid_type(sample_person_id): """Test verifying a token with an incorrect type.""" - token = jwt.encode({"token_type": "invalid"}, JWT_SECRET_KEY, algorithm="HS256") + kid = get_active_kid() + private_key = load_private_key(kid) + + # Create a token with token_type = "invalid" + token = jwt.encode( + { + "person_id": sample_person_id, + "token_type": "invalid", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + "iat": datetime.now(timezone.utc), + }, + private_key, + algorithm="RS256", + headers={"kid": kid}, + ) + with pytest.raises(TokenError, match="Invalid token") as excinfo: verify_token(token, "access") assert excinfo.value.status_code == 401 -def test_verify_expired_token(): +def test_verify_expired_token(sample_person_id): """Test verifying an expired token.""" + kid = get_active_kid() + private_key = load_private_key(kid) + expired_token = jwt.encode( - {"token_type": "access", "exp": datetime.now() - timedelta(seconds=1)}, - JWT_SECRET_KEY, - algorithm="HS256", + { + "person_id": sample_person_id, + "token_type": "access", + "exp": datetime.now(timezone.utc) - timedelta(seconds=1), + "iat": datetime.now(timezone.utc) - timedelta(hours=1), + }, + private_key, + algorithm="RS256", + headers={"kid": kid}, ) + with pytest.raises(TokenError, match="Token has expired") as excinfo: verify_token(expired_token, "access") assert excinfo.value.status_code == 401 def test_verify_invalid_token(): - """Test verifying an invalid token.""" + """Test verifying a malformed or tampered token.""" with pytest.raises(TokenError, match="Invalid token") as excinfo: - verify_token("invalid_token", "access") + verify_token("not_a_real_token", "access") assert excinfo.value.status_code == 401 diff --git a/tests/test_routes/test_authentication/test_refresh.py b/tests/test_routes/test_authentication/test_refresh.py index a8e5b83..3dacd4d 100644 --- a/tests/test_routes/test_authentication/test_refresh.py +++ b/tests/test_routes/test_authentication/test_refresh.py @@ -4,7 +4,7 @@ import pytest from flask.testing import FlaskClient -from jwt_helper import JWT_SECRET_KEY, generate_refresh_token +from jwt_helper import generate_refresh_token, get_active_kid, load_private_key @pytest.fixture @@ -22,6 +22,9 @@ def sample_refresh_token(sample_person_id: int) -> str: @pytest.fixture def sample_expired_token(sample_person_id) -> str: """Generate a deliberately expired JWT refresh token.""" + kid = get_active_kid() + private_key = load_private_key(kid) + payload = { "person_id": sample_person_id, "exp": datetime.datetime.now(datetime.timezone.utc) @@ -30,15 +33,26 @@ def sample_expired_token(sample_person_id) -> str: - datetime.timedelta(hours=1), "token_type": "refresh", } - return jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256") + + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) -def test_refresh_token_success(client, sample_refresh_token): +def test_refresh_token_success(client: FlaskClient, sample_refresh_token): """Test the refresh token endpoint with a valid token""" headers = {"Authorization": f"Bearer {sample_refresh_token}"} response = client.post("/refresh", headers=headers) + + assert response.status_code == 200 encoded_token = response.json["access_token"] - token = jwt.decode(encoded_token, JWT_SECRET_KEY, algorithms=["HS256"]) + + # Decode the returned access token using RS256 + unverified_header = jwt.get_unverified_header(encoded_token) + kid = unverified_header["kid"] + public_key_path = f"keys/{kid}/public.pem" + with open(public_key_path, "rb") as f: + public_key = f.read() + + token = jwt.decode(encoded_token, public_key, algorithms=["RS256"]) assert "person_id" in token assert "exp" in token @@ -66,7 +80,6 @@ def test_refresh_token_missing_token(client: FlaskClient): def test_refresh_token_expired_token(client: FlaskClient, sample_expired_token): """Test the refresh token endpoint with an expired token""" headers = {"Authorization": f"Bearer {sample_expired_token}"} - response = client.post("/refresh", headers=headers) assert response.status_code == 401 From ffb491a9979edd9c5f6eba69a9a3299badddfd96 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 8 Apr 2025 19:31:03 +0000 Subject: [PATCH 08/28] Remove redundant sample_person_id fixture from test_refresh.py --- tests/conftest.py | 7 +++++++ tests/test_routes/test_authentication/test_refresh.py | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b0228d8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.fixture +def sample_person_id() -> int: + """Provide a sample person ID for testing""" + return 12345 diff --git a/tests/test_routes/test_authentication/test_refresh.py b/tests/test_routes/test_authentication/test_refresh.py index 3dacd4d..7252870 100644 --- a/tests/test_routes/test_authentication/test_refresh.py +++ b/tests/test_routes/test_authentication/test_refresh.py @@ -7,12 +7,6 @@ from jwt_helper import generate_refresh_token, get_active_kid, load_private_key -@pytest.fixture -def sample_person_id() -> int: - """Provide a sample person ID for testing""" - return 12345 - - @pytest.fixture def sample_refresh_token(sample_person_id: int) -> str: """Provide a sample refresh token for testing""" From ae6c76a850d7a3bac62d06561cbd6533ecf6260f Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 10 Apr 2025 14:32:22 +0000 Subject: [PATCH 09/28] Refactor key cleanup script to use dynamic expiry days from JWT configuration --- utility/jwt_keys_cleanup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utility/jwt_keys_cleanup.py b/utility/jwt_keys_cleanup.py index 371690a..cdb49ea 100644 --- a/utility/jwt_keys_cleanup.py +++ b/utility/jwt_keys_cleanup.py @@ -6,10 +6,10 @@ from datetime import datetime, timedelta, timezone from pathlib import Path -from jwt_helper import get_active_kid +from jwt_helper import JWT_REFRESH_TOKEN_EXPIRY, get_active_kid KEYS_DIR = Path("keys") -EXPIRY_DAYS = 35 +EXPIRY_DAYS = JWT_REFRESH_TOKEN_EXPIRY.days + 1 def cleanup_old_keys(): From 5c0e4618dde6eeb45ec4500ff146a4a69be177b8 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 11 Apr 2025 13:42:01 +0000 Subject: [PATCH 10/28] Refactor JWT handling by moving related functions and classes to a new jwtoken module, removing deprecated admin routes and cleanup scripts. --- jwtoken/__init__.py | 0 jwtoken/decorators.py | 24 ++++++++ jwtoken/exceptions.py | 7 +++ jwt_helper.py => jwtoken/tokens.py | 57 ++----------------- routes/__init__.py | 2 - routes/admin/__init__.py | 7 --- routes/authentication.py | 10 +--- routes/picture.py | 2 +- tests/test_jwt/conftest.py | 2 +- .../test_extract_token_from_header.py | 3 +- tests/test_jwt/test_generate_access_token.py | 2 +- tests/test_jwt/test_refresh_token.py | 6 +- tests/test_jwt/test_token_required.py | 2 +- tests/test_jwt/test_verify_token.py | 6 +- .../test_authentication/conftest.py | 2 +- .../test_authentication/test_refresh.py | 3 +- utility/jwtoken/__init__.py | 0 utility/jwtoken/common.py | 11 ++++ .../keys_cleanup.py} | 3 +- utility/jwtoken/keys_id.py | 19 +++++++ .../jwtoken/keys_rotation.py | 21 ++----- 21 files changed, 92 insertions(+), 97 deletions(-) create mode 100644 jwtoken/__init__.py create mode 100644 jwtoken/decorators.py create mode 100644 jwtoken/exceptions.py rename jwt_helper.py => jwtoken/tokens.py (54%) delete mode 100644 routes/admin/__init__.py create mode 100644 utility/jwtoken/__init__.py create mode 100644 utility/jwtoken/common.py rename utility/{jwt_keys_cleanup.py => jwtoken/keys_cleanup.py} (91%) create mode 100644 utility/jwtoken/keys_id.py rename routes/admin/jwt_rotation.py => utility/jwtoken/keys_rotation.py (67%) diff --git a/jwtoken/__init__.py b/jwtoken/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jwtoken/decorators.py b/jwtoken/decorators.py new file mode 100644 index 0000000..9ee475e --- /dev/null +++ b/jwtoken/decorators.py @@ -0,0 +1,24 @@ +from functools import wraps + +from flask import jsonify, request + +from utility.jwtoken.common import extract_token_from_header + +from .exceptions import TokenError +from .tokens import verify_token + + +def token_required(f): + """Decorator to protect routes by requiring a valid token.""" + + @wraps(f) + def decorated(*args, **kwargs): + try: + token = extract_token_from_header() + decoded = verify_token(token, required_type="access") + request.person_id = decoded["person_id"] + return f(*args, **kwargs) + except TokenError as e: + return jsonify(message=e.message), e.status_code + + return decorated diff --git a/jwtoken/exceptions.py b/jwtoken/exceptions.py new file mode 100644 index 0000000..95b0731 --- /dev/null +++ b/jwtoken/exceptions.py @@ -0,0 +1,7 @@ +class TokenError(Exception): + """Custom exception for token-related errors.""" + + def __init__(self, message, status_code): + super().__init__(message) + self.status_code = status_code + self.message = message diff --git a/jwt_helper.py b/jwtoken/tokens.py similarity index 54% rename from jwt_helper.py rename to jwtoken/tokens.py index 8a13c3b..16c6019 100644 --- a/jwt_helper.py +++ b/jwtoken/tokens.py @@ -1,30 +1,13 @@ from datetime import datetime, timedelta, timezone -from functools import wraps import jwt -from flask import jsonify, request -JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) -JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) - - -class TokenError(Exception): - """Custom exception for token-related errors.""" - - def __init__(self, message, status_code): - super().__init__(message) - self.status_code = status_code - self.message = message +from utility.jwtoken.keys_id import get_active_kid, load_private_key, load_public_key +from .exceptions import TokenError -def get_active_kid(): - with open("keys/active_kid.txt", "r") as f: - return f.read().strip() - - -def load_private_key(kid): - with open(f"keys/{kid}/private.pem", "rb") as f: - return f.read() +JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) +JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) def generate_access_token(person_id: int) -> str: @@ -57,22 +40,6 @@ def generate_refresh_token(person_id: int) -> str: return jwt.encode(payload, private_key, algorithm="RS256", headers=headers) -def extract_token_from_header() -> str: - """Extract the Bearer token from the Authorization header.""" - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise TokenError("Token is missing or improperly formatted", 401) - return auth_header.split("Bearer ")[1] - - -def load_public_key(kid): - try: - with open(f"keys/{kid}/public.pem", "rb") as f: - return f.read() - except FileNotFoundError: - raise TokenError("Unknown key ID", 401) - - def verify_token(token: str, required_type: str) -> dict: """Verify and decode a JWT token.""" try: @@ -92,19 +59,3 @@ def verify_token(token: str, required_type: str) -> dict: raise TokenError("Token has expired", 401) except jwt.InvalidTokenError: raise TokenError("Invalid token", 401) - - -def token_required(f): - """Decorator to protect routes by requiring a valid token.""" - - @wraps(f) - def decorated(*args, **kwargs): - try: - token = extract_token_from_header() - decoded = verify_token(token, required_type="access") - request.person_id = decoded["person_id"] - return f(*args, **kwargs) - except TokenError as e: - return jsonify(message=e.message), e.status_code - - return decorated diff --git a/routes/__init__.py b/routes/__init__.py index 571fd6d..4bf76b0 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,4 +1,3 @@ -from .admin import admin_blueprint from .authentication import authentication_blueprint from .comment import comment_blueprint from .ingredient import ingredient_blueprint @@ -10,7 +9,6 @@ def register_routes(app): - app.register_blueprint(admin_blueprint, url_prefix="/admin") app.register_blueprint(authentication_blueprint, url_prefix="/auth") app.register_blueprint(comment_blueprint, url_prefix="/comment") app.register_blueprint(ingredient_blueprint, url_prefix="/ingredient") diff --git a/routes/admin/__init__.py b/routes/admin/__init__.py deleted file mode 100644 index 3182130..0000000 --- a/routes/admin/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from flask import Blueprint - -from routes.admin.jwt_rotation import jwt_rotation_blueprint - -admin_blueprint = Blueprint("admin", __name__) - -admin_blueprint.register_blueprint(jwt_rotation_blueprint, url_prefix="/jwt_keys") diff --git a/routes/authentication.py b/routes/authentication.py index 4d5477d..eb550c4 100644 --- a/routes/authentication.py +++ b/routes/authentication.py @@ -3,15 +3,11 @@ from pymysql import MySQLError from config.ratelimit import limiter -from jwt_helper import ( - TokenError, - extract_token_from_header, - generate_access_token, - generate_refresh_token, - verify_token, -) +from jwtoken.exceptions import TokenError +from jwtoken.tokens import generate_access_token, generate_refresh_token, verify_token from utility.database import database_cursor from utility.encryption import encrypt_email, hash_email, hash_password, verify_password +from utility.jwtoken.common import extract_token_from_header from utility.validation import validate_email, validate_password authentication_blueprint = Blueprint("authentication", __name__) diff --git a/routes/picture.py b/routes/picture.py index 84b2191..af77f37 100644 --- a/routes/picture.py +++ b/routes/picture.py @@ -3,7 +3,7 @@ from flask import Blueprint, jsonify, request, send_from_directory from config.settings import Config -from jwt_helper import token_required +from jwtoken.decorators import token_required from utility.database import database_cursor picture_blueprint = Blueprint("picture", __name__) diff --git a/tests/test_jwt/conftest.py b/tests/test_jwt/conftest.py index 7ffcf65..41fbd1c 100644 --- a/tests/test_jwt/conftest.py +++ b/tests/test_jwt/conftest.py @@ -2,7 +2,7 @@ import pytest from flask import Flask -from jwt_helper import generate_access_token +from jwtoken.tokens import generate_access_token @pytest.fixture diff --git a/tests/test_jwt/test_extract_token_from_header.py b/tests/test_jwt/test_extract_token_from_header.py index e83eb92..eee6a57 100644 --- a/tests/test_jwt/test_extract_token_from_header.py +++ b/tests/test_jwt/test_extract_token_from_header.py @@ -1,7 +1,8 @@ import pytest from flask import Flask -from jwt_helper import TokenError, extract_token_from_header +from jwtoken.exceptions import TokenError +from utility.jwtoken.common import extract_token_from_header app = Flask(__name__) diff --git a/tests/test_jwt/test_generate_access_token.py b/tests/test_jwt/test_generate_access_token.py index 83bae9d..bae0c63 100644 --- a/tests/test_jwt/test_generate_access_token.py +++ b/tests/test_jwt/test_generate_access_token.py @@ -1,6 +1,6 @@ import jwt -from jwt_helper import JWT_ACCESS_TOKEN_EXPIRY, load_public_key +from jwtoken.tokens import JWT_ACCESS_TOKEN_EXPIRY, load_public_key def test_access_token_type(sample_access_token): diff --git a/tests/test_jwt/test_refresh_token.py b/tests/test_jwt/test_refresh_token.py index b55df95..6dd6889 100644 --- a/tests/test_jwt/test_refresh_token.py +++ b/tests/test_jwt/test_refresh_token.py @@ -1,7 +1,11 @@ import jwt import pytest -from jwt_helper import JWT_REFRESH_TOKEN_EXPIRY, generate_refresh_token, load_public_key +from jwtoken.tokens import ( + JWT_REFRESH_TOKEN_EXPIRY, + generate_refresh_token, + load_public_key, +) @pytest.fixture diff --git a/tests/test_jwt/test_token_required.py b/tests/test_jwt/test_token_required.py index c74862b..0fefd0a 100644 --- a/tests/test_jwt/test_token_required.py +++ b/tests/test_jwt/test_token_required.py @@ -1,6 +1,6 @@ from flask import Flask, jsonify -from jwt_helper import token_required +from jwtoken.decorators import token_required app = Flask(__name__) diff --git a/tests/test_jwt/test_verify_token.py b/tests/test_jwt/test_verify_token.py index d5d45c5..f0c5b96 100644 --- a/tests/test_jwt/test_verify_token.py +++ b/tests/test_jwt/test_verify_token.py @@ -4,14 +4,14 @@ import pytest from flask import Flask -from jwt_helper import ( - TokenError, +from jwtoken.exceptions import TokenError +from jwtoken.tokens import ( generate_access_token, generate_refresh_token, - get_active_kid, load_private_key, verify_token, ) +from utility.jwtoken.keys_id import get_active_kid app = Flask(__name__) diff --git a/tests/test_routes/test_authentication/conftest.py b/tests/test_routes/test_authentication/conftest.py index d5897ca..e963b1b 100644 --- a/tests/test_routes/test_authentication/conftest.py +++ b/tests/test_routes/test_authentication/conftest.py @@ -1,7 +1,7 @@ import pytest from flask import Flask -from jwt_helper import generate_access_token +from jwtoken.tokens import generate_access_token from routes.authentication import authentication_blueprint diff --git a/tests/test_routes/test_authentication/test_refresh.py b/tests/test_routes/test_authentication/test_refresh.py index 7252870..77f4e73 100644 --- a/tests/test_routes/test_authentication/test_refresh.py +++ b/tests/test_routes/test_authentication/test_refresh.py @@ -4,7 +4,8 @@ import pytest from flask.testing import FlaskClient -from jwt_helper import generate_refresh_token, get_active_kid, load_private_key +from jwtoken.tokens import generate_refresh_token, load_private_key +from utility.jwtoken.keys_id import get_active_kid @pytest.fixture diff --git a/utility/jwtoken/__init__.py b/utility/jwtoken/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utility/jwtoken/common.py b/utility/jwtoken/common.py new file mode 100644 index 0000000..d0e6ca5 --- /dev/null +++ b/utility/jwtoken/common.py @@ -0,0 +1,11 @@ +from flask import request + +from jwtoken.exceptions import TokenError + + +def extract_token_from_header() -> str: + """Extract the Bearer token from the Authorization header.""" + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise TokenError("Token is missing or improperly formatted", 401) + return auth_header.split("Bearer ")[1] diff --git a/utility/jwt_keys_cleanup.py b/utility/jwtoken/keys_cleanup.py similarity index 91% rename from utility/jwt_keys_cleanup.py rename to utility/jwtoken/keys_cleanup.py index cdb49ea..837accc 100644 --- a/utility/jwt_keys_cleanup.py +++ b/utility/jwtoken/keys_cleanup.py @@ -6,7 +6,8 @@ from datetime import datetime, timedelta, timezone from pathlib import Path -from jwt_helper import JWT_REFRESH_TOKEN_EXPIRY, get_active_kid +from jwtoken.tokens import JWT_REFRESH_TOKEN_EXPIRY +from utility.jwtoken.keys_id import get_active_kid KEYS_DIR = Path("keys") EXPIRY_DAYS = JWT_REFRESH_TOKEN_EXPIRY.days + 1 diff --git a/utility/jwtoken/keys_id.py b/utility/jwtoken/keys_id.py new file mode 100644 index 0000000..3e50e48 --- /dev/null +++ b/utility/jwtoken/keys_id.py @@ -0,0 +1,19 @@ +from jwtoken.exceptions import TokenError + + +def get_active_kid(): + with open("keys/active_kid.txt", "r") as f: + return f.read().strip() + + +def load_private_key(kid): + with open(f"keys/{kid}/private.pem", "rb") as f: + return f.read() + + +def load_public_key(kid): + try: + with open(f"keys/{kid}/public.pem", "rb") as f: + return f.read() + except FileNotFoundError: + raise TokenError("Unknown key ID", 401) diff --git a/routes/admin/jwt_rotation.py b/utility/jwtoken/keys_rotation.py similarity index 67% rename from routes/admin/jwt_rotation.py rename to utility/jwtoken/keys_rotation.py index 0315161..3a09daf 100644 --- a/routes/admin/jwt_rotation.py +++ b/utility/jwtoken/keys_rotation.py @@ -1,24 +1,15 @@ +""" +Rotate the keys used for JWT signing. +Run the script with: `python3 -m utility.jwt_keys_rotation` +""" + import os import secrets import subprocess from datetime import datetime, timezone -from flask import Blueprint, abort, jsonify, request - -from config.ratelimit import limiter - -jwt_rotation_blueprint = Blueprint("jwt_rotation", __name__) - -@jwt_rotation_blueprint.route("/rotate", methods=["POST"]) -@limiter.limit("1 per day") def rotate_keys(): - """ - Rotate the keys used for JWT signing. - This endpoint is protected and should only be accessible from localhost. - """ - if request.remote_addr != "127.0.0.1": - abort(403) new_kid = secrets.token_hex(8) key_dir = f"keys/{new_kid}" @@ -58,5 +49,3 @@ def rotate_keys(): # Save the kid creation time for cleanup purposes with open(f"{key_dir}/created_at.txt", "w") as f: f.write(datetime.now(timezone.utc).isoformat()) - - return jsonify(message=f"KID rotated. New kid: {new_kid}") From 7b392a543521c0652f45a520012de0cfc4b7fae9 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 11 Apr 2025 13:48:30 +0000 Subject: [PATCH 11/28] Refactor JWT key handling by consolidating functions into common module and removing deprecated keys_id module --- jwtoken/tokens.py | 2 +- tests/test_jwt/test_verify_token.py | 2 +- .../test_authentication/test_refresh.py | 2 +- utility/jwtoken/common.py | 18 ++++++++++++++++++ utility/jwtoken/keys_cleanup.py | 2 +- utility/jwtoken/keys_id.py | 19 ------------------- 6 files changed, 22 insertions(+), 23 deletions(-) delete mode 100644 utility/jwtoken/keys_id.py diff --git a/jwtoken/tokens.py b/jwtoken/tokens.py index 16c6019..e73c073 100644 --- a/jwtoken/tokens.py +++ b/jwtoken/tokens.py @@ -2,7 +2,7 @@ import jwt -from utility.jwtoken.keys_id import get_active_kid, load_private_key, load_public_key +from utility.jwtoken.common import get_active_kid, load_private_key, load_public_key from .exceptions import TokenError diff --git a/tests/test_jwt/test_verify_token.py b/tests/test_jwt/test_verify_token.py index f0c5b96..e2ff86a 100644 --- a/tests/test_jwt/test_verify_token.py +++ b/tests/test_jwt/test_verify_token.py @@ -11,7 +11,7 @@ load_private_key, verify_token, ) -from utility.jwtoken.keys_id import get_active_kid +from utility.jwtoken.common import get_active_kid app = Flask(__name__) diff --git a/tests/test_routes/test_authentication/test_refresh.py b/tests/test_routes/test_authentication/test_refresh.py index 77f4e73..f3b107b 100644 --- a/tests/test_routes/test_authentication/test_refresh.py +++ b/tests/test_routes/test_authentication/test_refresh.py @@ -5,7 +5,7 @@ from flask.testing import FlaskClient from jwtoken.tokens import generate_refresh_token, load_private_key -from utility.jwtoken.keys_id import get_active_kid +from utility.jwtoken.common import get_active_kid @pytest.fixture diff --git a/utility/jwtoken/common.py b/utility/jwtoken/common.py index d0e6ca5..3986e63 100644 --- a/utility/jwtoken/common.py +++ b/utility/jwtoken/common.py @@ -9,3 +9,21 @@ def extract_token_from_header() -> str: if not auth_header or not auth_header.startswith("Bearer "): raise TokenError("Token is missing or improperly formatted", 401) return auth_header.split("Bearer ")[1] + + +def get_active_kid(): + with open("keys/active_kid.txt", "r") as f: + return f.read().strip() + + +def load_private_key(kid): + with open(f"keys/{kid}/private.pem", "rb") as f: + return f.read() + + +def load_public_key(kid): + try: + with open(f"keys/{kid}/public.pem", "rb") as f: + return f.read() + except FileNotFoundError: + raise TokenError("Unknown key ID", 401) diff --git a/utility/jwtoken/keys_cleanup.py b/utility/jwtoken/keys_cleanup.py index 837accc..80723bc 100644 --- a/utility/jwtoken/keys_cleanup.py +++ b/utility/jwtoken/keys_cleanup.py @@ -7,7 +7,7 @@ from pathlib import Path from jwtoken.tokens import JWT_REFRESH_TOKEN_EXPIRY -from utility.jwtoken.keys_id import get_active_kid +from utility.jwtoken.common import get_active_kid KEYS_DIR = Path("keys") EXPIRY_DAYS = JWT_REFRESH_TOKEN_EXPIRY.days + 1 diff --git a/utility/jwtoken/keys_id.py b/utility/jwtoken/keys_id.py deleted file mode 100644 index 3e50e48..0000000 --- a/utility/jwtoken/keys_id.py +++ /dev/null @@ -1,19 +0,0 @@ -from jwtoken.exceptions import TokenError - - -def get_active_kid(): - with open("keys/active_kid.txt", "r") as f: - return f.read().strip() - - -def load_private_key(kid): - with open(f"keys/{kid}/private.pem", "rb") as f: - return f.read() - - -def load_public_key(kid): - try: - with open(f"keys/{kid}/public.pem", "rb") as f: - return f.read() - except FileNotFoundError: - raise TokenError("Unknown key ID", 401) From 21602d3c61f4cc30e2e41b7106c0b61d47badc52 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 11 Apr 2025 14:19:03 +0000 Subject: [PATCH 12/28] Refactor JWT test suite by removing obsolete test files and consolidating test fixtures into a new structure --- tests/{test_jwt => test_jwtoken}/__init__.py | 0 tests/{test_jwt => test_jwtoken}/conftest.py | 0 .../test_extract_token_from_header.py | 0 .../test_generate_access_token.py | 0 tests/test_jwtoken/test_invalid_token.py | 82 +++++++++++++++++++ .../test_refresh_token.py | 0 .../test_token_required.py | 0 .../test_verify_token.py | 0 8 files changed, 82 insertions(+) rename tests/{test_jwt => test_jwtoken}/__init__.py (100%) rename tests/{test_jwt => test_jwtoken}/conftest.py (100%) rename tests/{test_jwt => test_jwtoken}/test_extract_token_from_header.py (100%) rename tests/{test_jwt => test_jwtoken}/test_generate_access_token.py (100%) create mode 100644 tests/test_jwtoken/test_invalid_token.py rename tests/{test_jwt => test_jwtoken}/test_refresh_token.py (100%) rename tests/{test_jwt => test_jwtoken}/test_token_required.py (100%) rename tests/{test_jwt => test_jwtoken}/test_verify_token.py (100%) diff --git a/tests/test_jwt/__init__.py b/tests/test_jwtoken/__init__.py similarity index 100% rename from tests/test_jwt/__init__.py rename to tests/test_jwtoken/__init__.py diff --git a/tests/test_jwt/conftest.py b/tests/test_jwtoken/conftest.py similarity index 100% rename from tests/test_jwt/conftest.py rename to tests/test_jwtoken/conftest.py diff --git a/tests/test_jwt/test_extract_token_from_header.py b/tests/test_jwtoken/test_extract_token_from_header.py similarity index 100% rename from tests/test_jwt/test_extract_token_from_header.py rename to tests/test_jwtoken/test_extract_token_from_header.py diff --git a/tests/test_jwt/test_generate_access_token.py b/tests/test_jwtoken/test_generate_access_token.py similarity index 100% rename from tests/test_jwt/test_generate_access_token.py rename to tests/test_jwtoken/test_generate_access_token.py diff --git a/tests/test_jwtoken/test_invalid_token.py b/tests/test_jwtoken/test_invalid_token.py new file mode 100644 index 0000000..4398861 --- /dev/null +++ b/tests/test_jwtoken/test_invalid_token.py @@ -0,0 +1,82 @@ +from datetime import datetime, timedelta, timezone + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.exceptions import InvalidKeyError + +from jwtoken.exceptions import TokenError +from jwtoken.tokens import generate_access_token, verify_token +from utility.jwtoken.common import get_active_kid + + +@pytest.fixture +def active_kid(): + return get_active_kid() + + +@pytest.fixture +def fake_private_key(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +@pytest.fixture +def public_key(active_kid): + with open(f"keys/{active_kid}/public.pem", "rb") as f: + return f.read() + + +@pytest.fixture +def sample_payload(sample_person_id): + return { + "person_id": sample_person_id, + "exp": datetime.now(timezone.utc) + timedelta(minutes=10), + "iat": datetime.now(timezone.utc), + "token_type": "access", + } + + +def test_hs256_forged_token_rejected(sample_person_id, public_key, active_kid): + headers = {"kid": active_kid, "alg": "HS256"} + payload = { + "person_id": sample_person_id, + "exp": datetime.now(timezone.utc) + timedelta(minutes=10), + "iat": datetime.now(timezone.utc), + "token_type": "access", + } + + with pytest.raises(InvalidKeyError, match="asymmetric key.*HMAC"): + jwt.encode(payload, public_key, algorithm="HS256", headers=headers) + + +def test_tampered_token_expiry_extension(sample_person_id, fake_private_key): + original_token = generate_access_token(sample_person_id) + original_payload = jwt.decode(original_token, options={"verify_signature": False}) + original_header = jwt.get_unverified_header(original_token) + + modified_payload = original_payload.copy() + modified_payload["exp"] = datetime.now(timezone.utc) + timedelta(days=365) + + forged_token = jwt.encode( + modified_payload, fake_private_key, algorithm="RS256", headers=original_header + ) + + with pytest.raises(TokenError, match="Invalid token"): + verify_token(forged_token, "access") + + +def test_unknown_kid_rejected(fake_private_key, sample_payload): + headers = {"kid": "fake123456"} + + fake_token = jwt.encode( + sample_payload, fake_private_key, algorithm="RS256", headers=headers + ) + + with pytest.raises(TokenError, match="Unknown key ID"): + verify_token(fake_token, "access") diff --git a/tests/test_jwt/test_refresh_token.py b/tests/test_jwtoken/test_refresh_token.py similarity index 100% rename from tests/test_jwt/test_refresh_token.py rename to tests/test_jwtoken/test_refresh_token.py diff --git a/tests/test_jwt/test_token_required.py b/tests/test_jwtoken/test_token_required.py similarity index 100% rename from tests/test_jwt/test_token_required.py rename to tests/test_jwtoken/test_token_required.py diff --git a/tests/test_jwt/test_verify_token.py b/tests/test_jwtoken/test_verify_token.py similarity index 100% rename from tests/test_jwt/test_verify_token.py rename to tests/test_jwtoken/test_verify_token.py From b1f94b3ebc32e9cf018e57e5d019d0db72e76be6 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 11 Apr 2025 14:20:28 +0000 Subject: [PATCH 13/28] Clarify entrypoint script comment to specify starting the API --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index ebd4f98..065179f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,5 +16,5 @@ else echo "✅ Keys already exist. Skipping generation." fi -# Start your Python app +# Start the API exec python3 app.py From e4078fd67733c2d38c215805d5578a15c3d9bfe1 Mon Sep 17 00:00:00 2001 From: Vianney Veremme <10519369+Vianpyro@users.noreply.github.com> Date: Sun, 13 Apr 2025 11:45:58 -0400 Subject: [PATCH 14/28] Optimize payload generation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- jwtoken/tokens.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jwtoken/tokens.py b/jwtoken/tokens.py index e73c073..f06c9c7 100644 --- a/jwtoken/tokens.py +++ b/jwtoken/tokens.py @@ -14,10 +14,11 @@ def generate_access_token(person_id: int) -> str: kid = get_active_kid() private_key = load_private_key(kid) + current_time = datetime.now(timezone.utc) payload = { "person_id": person_id, - "exp": datetime.now(timezone.utc) + JWT_ACCESS_TOKEN_EXPIRY, # Expiration - "iat": datetime.now(timezone.utc), # Issued at + "exp": current_time + JWT_ACCESS_TOKEN_EXPIRY, # Expiration + "iat": current_time, # Issued at "token_type": "access", } headers = {"kid": kid} From 68a20813a25c36dcd7dc70547fc75f095f6ace8c Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 16:29:01 +0000 Subject: [PATCH 15/28] Add key management functionality and refactor key paths - Introduced key rotation logic to ensure keys directory and active_kid.txt file exist. - Updated key paths in the rotate_keys function to use constants from the new config file. - Created a new config file for JWT key management constants. - Refactored keys_cleanup.py to use the new CREATED_AT_FILE constant. --- app.py | 6 ++++++ config/jwtoken.py | 8 ++++++++ utility/jwtoken/keys_cleanup.py | 5 ++--- utility/jwtoken/keys_rotation.py | 17 +++++++++-------- 4 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 config/jwtoken.py diff --git a/app.py b/app.py index 7a060a1..882f4bb 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import argparse +import os import traceback from flask import Flask, jsonify, request @@ -10,6 +11,7 @@ from config.settings import Config from routes import register_routes from utility.database import extract_error_message +from utility.jwtoken.keys_rotation import rotate_keys app = Flask(__name__) app.config.from_object(Config) @@ -26,6 +28,10 @@ if app.config["TESTING"]: limiter.enabled = False +# Ensure the keys directory and active_kid.txt file exist +if not os.path.exists("keys/active_kid.txt"): + rotate_keys() + @app.route("/") def home(): diff --git a/config/jwtoken.py b/config/jwtoken.py new file mode 100644 index 0000000..0ad9912 --- /dev/null +++ b/config/jwtoken.py @@ -0,0 +1,8 @@ +from pathlib import Path + +KEY_DIR = Path("keys") + +ACTIVE_KID_FILE = KEY_DIR / "active_kid.txt" +CREATED_AT_FILE = "created_at.txt" +PRIVATE_KEY_FILE = "private.pem" +PUBLIC_KEY_FILE = "public.pem" diff --git a/utility/jwtoken/keys_cleanup.py b/utility/jwtoken/keys_cleanup.py index b4ddd15..ac1d47c 100644 --- a/utility/jwtoken/keys_cleanup.py +++ b/utility/jwtoken/keys_cleanup.py @@ -4,12 +4,11 @@ """ from datetime import datetime, timedelta, timezone -from pathlib import Path +from config.jwtoken import CREATED_AT_FILE, KEYS_DIR from jwtoken.tokens import JWT_REFRESH_TOKEN_EXPIRY from utility.jwtoken.common import get_active_kid -KEYS_DIR = Path("keys") EXPIRY_DAYS = JWT_REFRESH_TOKEN_EXPIRY.days + 1 @@ -22,7 +21,7 @@ def cleanup_old_keys(): continue if kid_dir.name == active_kid: continue # Don't delete active key - created_at_file = kid_dir / "created_at.txt" + created_at_file = kid_dir / CREATED_AT_FILE if not created_at_file.exists(): continue # Skip keys without metadata diff --git a/utility/jwtoken/keys_rotation.py b/utility/jwtoken/keys_rotation.py index 5cc61e4..262fb89 100644 --- a/utility/jwtoken/keys_rotation.py +++ b/utility/jwtoken/keys_rotation.py @@ -8,12 +8,13 @@ import subprocess from datetime import datetime, timezone +from config.jwtoken import ACTIVE_KID_FILE, KEY_DIR, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE -def rotate_keys(): +def rotate_keys(): new_kid = secrets.token_hex(8) - key_dir = f"keys/{new_kid}" - os.makedirs(key_dir, exist_ok=False) + new_key_dir = f"{KEY_DIR}/{new_kid}" + os.makedirs(new_key_dir, exist_ok=False) # Use OpenSSL to generate keys subprocess.run( @@ -23,7 +24,7 @@ def rotate_keys(): "-algorithm", "RSA", "-out", - f"{key_dir}/private.pem", + f"{new_key_dir}/{PRIVATE_KEY_FILE}", "-pkeyopt", "rsa_keygen_bits:2048", ], @@ -35,17 +36,17 @@ def rotate_keys(): "rsa", "-pubout", "-in", - f"{key_dir}/private.pem", + f"{new_key_dir}/{PRIVATE_KEY_FILE}", "-out", - f"{key_dir}/public.pem", + f"{new_key_dir}/{PUBLIC_KEY_FILE}", ], check=True, ) # Update active_kid - with open("keys/active_kid.txt", "w") as f: + with open(ACTIVE_KID_FILE, "w") as f: f.write(new_kid) # Save the kid creation time for cleanup purposes - with open(f"{key_dir}/created_at.txt", "w") as f: + with open(ACTIVE_KID_FILE, "w") as f: f.write(datetime.now(timezone.utc).isoformat()) From 8b05ed46988e72bf9b78b8fabb0aec95f2bf27c1 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 16:34:07 +0000 Subject: [PATCH 16/28] Refactor Dockerfile to remove entrypoint script and directly run key rotation before starting the app; update keys_rotation.py for improved key management and error handling. --- Dockerfile | 2 +- entrypoint.sh | 20 ---------------- utility/jwtoken/keys_rotation.py | 41 +++++++++++++++++++++----------- 3 files changed, 28 insertions(+), 35 deletions(-) delete mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 4ac1e8c..a18860b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 # Command to run the app -ENTRYPOINT ["./scripts/entrypoint.sh"] +CMD python3 -m utility.jwtoken.keys_rotation && python3 app.py diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 065179f..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh - -set -e # Exit if any command fails - -KEY_DIR="/app/keys" -PRIVATE_KEY="$KEY_DIR/private_key.pem" -PUBLIC_KEY="$KEY_DIR/public_key.pem" - -mkdir -p "$KEY_DIR" - -if [ ! -f "$PRIVATE_KEY" ] || [ ! -f "$PUBLIC_KEY" ]; then - echo "🔐 Generating RSA key pair..." - openssl genpkey -algorithm RSA -out "$PRIVATE_KEY" -pkeyopt rsa_keygen_bits:2048 - openssl rsa -pubout -in "$PRIVATE_KEY" -out "$PUBLIC_KEY" -else - echo "✅ Keys already exist. Skipping generation." -fi - -# Start the API -exec python3 app.py diff --git a/utility/jwtoken/keys_rotation.py b/utility/jwtoken/keys_rotation.py index 262fb89..fae3213 100644 --- a/utility/jwtoken/keys_rotation.py +++ b/utility/jwtoken/keys_rotation.py @@ -3,20 +3,29 @@ Run the script with: `python3 -m utility.jwtoken.keys_rotation` """ -import os import secrets import subprocess from datetime import datetime, timezone -from config.jwtoken import ACTIVE_KID_FILE, KEY_DIR, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE +from config.jwtoken import ( + ACTIVE_KID_FILE, + CREATED_AT_FILE, + KEY_DIR, + PRIVATE_KEY_FILE, + PUBLIC_KEY_FILE, +) def rotate_keys(): new_kid = secrets.token_hex(8) - new_key_dir = f"{KEY_DIR}/{new_kid}" - os.makedirs(new_key_dir, exist_ok=False) + new_key_dir = KEY_DIR / new_kid + new_key_dir.mkdir(parents=True, exist_ok=False) - # Use OpenSSL to generate keys + private_key_path = new_key_dir / PRIVATE_KEY_FILE + public_key_path = new_key_dir / PUBLIC_KEY_FILE + created_at_path = new_key_dir / CREATED_AT_FILE + + # Generate private key subprocess.run( [ "openssl", @@ -24,29 +33,33 @@ def rotate_keys(): "-algorithm", "RSA", "-out", - f"{new_key_dir}/{PRIVATE_KEY_FILE}", + str(private_key_path), "-pkeyopt", "rsa_keygen_bits:2048", ], check=True, ) + + # Extract public key subprocess.run( [ "openssl", "rsa", "-pubout", "-in", - f"{new_key_dir}/{PRIVATE_KEY_FILE}", + str(private_key_path), "-out", - f"{new_key_dir}/{PUBLIC_KEY_FILE}", + str(public_key_path), ], check=True, ) - # Update active_kid - with open(ACTIVE_KID_FILE, "w") as f: - f.write(new_kid) + # Set new active KID + ACTIVE_KID_FILE.write_text(new_kid) + + # Save creation time + created_at_path.write_text(datetime.now(timezone.utc).isoformat()) + - # Save the kid creation time for cleanup purposes - with open(ACTIVE_KID_FILE, "w") as f: - f.write(datetime.now(timezone.utc).isoformat()) +if __name__ == "__main__": + rotate_keys() From 9d4a0874df2e96ec2df725dfbf1c398c8b2ab133 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 16:46:49 +0000 Subject: [PATCH 17/28] Add OpenSSL installation steps for Linux, macOS, and Windows in Pytest CI workflow --- .github/workflows/pytest.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 146e6b1..8e86c4c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -33,6 +33,18 @@ jobs: python-version: ${{ matrix.python-version }} cache: "pip" + - name: Install OpenSSL (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libssl-dev + + - name: Install OpenSSL (macOS) + if: runner.os == 'macOS' + run: brew install openssl + + - name: Install OpenSSL (Windows) + if: runner.os == 'Windows' + run: choco install openssl.light --no-progress + - name: Install dependencies run: | pip install -r requirements.txt From 3ad2c9c41e7b9b006254e692f246f43c5e9ff3db Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 16:55:14 +0000 Subject: [PATCH 18/28] Fix variable name inconsistency for key directory in jwtoken and keys_rotation modules --- config/jwtoken.py | 4 ++-- utility/jwtoken/keys_rotation.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/jwtoken.py b/config/jwtoken.py index 0ad9912..8c22967 100644 --- a/config/jwtoken.py +++ b/config/jwtoken.py @@ -1,8 +1,8 @@ from pathlib import Path -KEY_DIR = Path("keys") +KEYS_DIR = Path("keys") -ACTIVE_KID_FILE = KEY_DIR / "active_kid.txt" +ACTIVE_KID_FILE = KEYS_DIR / "active_kid.txt" CREATED_AT_FILE = "created_at.txt" PRIVATE_KEY_FILE = "private.pem" PUBLIC_KEY_FILE = "public.pem" diff --git a/utility/jwtoken/keys_rotation.py b/utility/jwtoken/keys_rotation.py index fae3213..41759ec 100644 --- a/utility/jwtoken/keys_rotation.py +++ b/utility/jwtoken/keys_rotation.py @@ -10,7 +10,7 @@ from config.jwtoken import ( ACTIVE_KID_FILE, CREATED_AT_FILE, - KEY_DIR, + KEYS_DIR, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE, ) @@ -18,7 +18,7 @@ def rotate_keys(): new_kid = secrets.token_hex(8) - new_key_dir = KEY_DIR / new_kid + new_key_dir = KEYS_DIR / new_kid new_key_dir.mkdir(parents=True, exist_ok=False) private_key_path = new_key_dir / PRIVATE_KEY_FILE From 2eb3e515a702c9a5791e044160ff877bfb12121a Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 17:33:21 +0000 Subject: [PATCH 19/28] Refactor Dockerfile to use entrypoint script for key rotation and app startup; add entrypoint.sh for improved process management. --- Dockerfile | 7 +++++-- entrypoint.sh | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index a18860b..e89e88b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.13-slim WORKDIR /app # Install dependencies -RUN apt update && apt install -y openssl +RUN apt-get update && apt-get install -y --no-install-recommends openssl COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -24,4 +24,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 # Command to run the app -CMD python3 -m utility.jwtoken.keys_rotation && python3 app.py +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/start.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..f24b9fc --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +python3 -m utility.jwtoken.keys_rotation +exec python3 app.py From 926a4cc97c961262955495cacaa1d4b92c40833b Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 13:39:07 -0400 Subject: [PATCH 20/28] Reorganize Dockerfile to copy entrypoint script before exposing Flask port; ensure proper permissions are set for execution. --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index e89e88b..c8d1e1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,19 +12,19 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the rest of the application files COPY . . -# Expose the Flask port -EXPOSE 5000 +# Command to run the app +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh # Create a non-root user and set permissions for the /app directory RUN adduser --disabled-password --gecos '' apiuser && chown -R apiuser /app USER apiuser +# Expose the Flask port +EXPOSE 5000 + # Add health check for the container HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 -# Command to run the app -COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - ENTRYPOINT ["/app/start.sh"] From 8c1569489f9eae3342ee900832c090a45b3d9273 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 13:39:32 -0400 Subject: [PATCH 21/28] Remove entrypoint script copy command from Dockerfile; streamline application setup. --- Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c8d1e1a..eab2100 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,6 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the rest of the application files COPY . . - -# Command to run the app -COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh # Create a non-root user and set permissions for the /app directory From 7ebd0e5edfa41b9da2004cde78dc19bba22ff6bc Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 13:44:42 -0400 Subject: [PATCH 22/28] Update ENTRYPOINT in Dockerfile to use entrypoint.sh for improved process management --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index eab2100..d0e57a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 -ENTRYPOINT ["/app/start.sh"] +ENTRYPOINT ["/app/entrypoint.sh"] From 89ba7cd08428a19c8a077235e5e58e5f94358f74 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 13:44:42 -0400 Subject: [PATCH 23/28] Update ENTRYPOINT in Dockerfile to use entrypoint.sh for improved process management --- entrypoint.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 From 7e9fc5f069fc7dd1df9466e22f7781116539fcc9 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 18:00:31 +0000 Subject: [PATCH 24/28] Refactor app.py to use ACTIVE_KID_FILE constant for key existence check; improve code readability. --- app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 882f4bb..dc52048 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ from flask_cors import CORS from werkzeug.exceptions import HTTPException +from config.jwtoken import ACTIVE_KID_FILE from config.logging import setup_logging from config.ratelimit import limiter from config.settings import Config @@ -29,7 +30,7 @@ limiter.enabled = False # Ensure the keys directory and active_kid.txt file exist -if not os.path.exists("keys/active_kid.txt"): +if not os.path.exists(ACTIVE_KID_FILE): rotate_keys() From cd265642fb9328c60029b8748cdb0f568b04da08 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 18:07:38 +0000 Subject: [PATCH 25/28] Remove OpenSSL installation step for macOS in Pytest CI workflow; clarify that it's pre-installed. --- .github/workflows/pytest.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 8e86c4c..767abfb 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -37,9 +37,7 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libssl-dev - - name: Install OpenSSL (macOS) - if: runner.os == 'macOS' - run: brew install openssl + # MacOS does not require OpenSSL installation as it is pre-installed - name: Install OpenSSL (Windows) if: runner.os == 'Windows' From 5c18cc5041809ffc323725d73ac6cf87a9ca5337 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 18:23:40 +0000 Subject: [PATCH 26/28] Refactor datetime usage in token generation and logging setup; improve consistency and readability. --- config/logging.py | 4 ++-- jwtoken/tokens.py | 5 +++-- tests/test_jwtoken/test_invalid_token.py | 10 ++++++---- tests/test_jwtoken/test_verify_token.py | 10 ++++++---- tests/test_routes/test_authentication/test_refresh.py | 9 ++++----- utility/jwtoken/keys_cleanup.py | 4 ++-- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/config/logging.py b/config/logging.py index d39258c..455780e 100644 --- a/config/logging.py +++ b/config/logging.py @@ -12,12 +12,12 @@ def setup_logging(): logger.setLevel(logging.DEBUG) # Create a file handler that rotates logs daily - timestamp = datetime.datetime.now().strftime("%Y-%m-%d") + current_time = datetime.datetime.now().strftime("%Y-%m-%d") # Create a directory for logs if it doesn't exist if not os.path.exists(LOGS_DIRECTORY): os.makedirs(LOGS_DIRECTORY) - log_filename = f"{LOGS_DIRECTORY}/{timestamp}.log" + log_filename = f"{LOGS_DIRECTORY}/{current_time}.log" # Set up timed rotating file handler file_handler = TimedRotatingFileHandler( diff --git a/jwtoken/tokens.py b/jwtoken/tokens.py index f06c9c7..db551d2 100644 --- a/jwtoken/tokens.py +++ b/jwtoken/tokens.py @@ -30,10 +30,11 @@ def generate_refresh_token(person_id: int) -> str: kid = get_active_kid() private_key = load_private_key(kid) + current_time = datetime.now(timezone.utc) payload = { "person_id": person_id, - "exp": datetime.now(timezone.utc) + JWT_REFRESH_TOKEN_EXPIRY, # Expiration - "iat": datetime.now(timezone.utc), # Issued at + "exp": current_time + JWT_REFRESH_TOKEN_EXPIRY, # Expiration + "iat": current_time, # Issued at "token_type": "refresh", } headers = {"kid": kid} diff --git a/tests/test_jwtoken/test_invalid_token.py b/tests/test_jwtoken/test_invalid_token.py index 4398861..8e57802 100644 --- a/tests/test_jwtoken/test_invalid_token.py +++ b/tests/test_jwtoken/test_invalid_token.py @@ -34,20 +34,22 @@ def public_key(active_kid): @pytest.fixture def sample_payload(sample_person_id): + current_time = datetime.now(timezone.utc) return { "person_id": sample_person_id, - "exp": datetime.now(timezone.utc) + timedelta(minutes=10), - "iat": datetime.now(timezone.utc), + "exp": current_time + timedelta(minutes=10), + "iat": current_time, "token_type": "access", } def test_hs256_forged_token_rejected(sample_person_id, public_key, active_kid): headers = {"kid": active_kid, "alg": "HS256"} + current_time = datetime.now(timezone.utc) payload = { "person_id": sample_person_id, - "exp": datetime.now(timezone.utc) + timedelta(minutes=10), - "iat": datetime.now(timezone.utc), + "exp": current_time + timedelta(minutes=10), + "iat": current_time, "token_type": "access", } diff --git a/tests/test_jwtoken/test_verify_token.py b/tests/test_jwtoken/test_verify_token.py index e2ff86a..03efe84 100644 --- a/tests/test_jwtoken/test_verify_token.py +++ b/tests/test_jwtoken/test_verify_token.py @@ -38,12 +38,13 @@ def test_verify_token_invalid_type(sample_person_id): private_key = load_private_key(kid) # Create a token with token_type = "invalid" + current_time = datetime.now(timezone.utc) token = jwt.encode( { "person_id": sample_person_id, "token_type": "invalid", - "exp": datetime.now(timezone.utc) + timedelta(minutes=5), - "iat": datetime.now(timezone.utc), + "exp": current_time + timedelta(minutes=5), + "iat": current_time, }, private_key, algorithm="RS256", @@ -60,12 +61,13 @@ def test_verify_expired_token(sample_person_id): kid = get_active_kid() private_key = load_private_key(kid) + current_time = datetime.now(timezone.utc) expired_token = jwt.encode( { "person_id": sample_person_id, "token_type": "access", - "exp": datetime.now(timezone.utc) - timedelta(seconds=1), - "iat": datetime.now(timezone.utc) - timedelta(hours=1), + "exp": current_time - timedelta(seconds=1), + "iat": current_time - timedelta(hours=1), }, private_key, algorithm="RS256", diff --git a/tests/test_routes/test_authentication/test_refresh.py b/tests/test_routes/test_authentication/test_refresh.py index f3b107b..e7d745c 100644 --- a/tests/test_routes/test_authentication/test_refresh.py +++ b/tests/test_routes/test_authentication/test_refresh.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timedelta, timezone import jwt import pytest @@ -20,12 +20,11 @@ def sample_expired_token(sample_person_id) -> str: kid = get_active_kid() private_key = load_private_key(kid) + current_time = datetime.now(timezone.utc) payload = { "person_id": sample_person_id, - "exp": datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(seconds=1), # Already expired - "iat": datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(hours=1), + "exp": current_time - timedelta(seconds=1), # Already expired + "iat": current_time - timedelta(hours=1), "token_type": "refresh", } diff --git a/utility/jwtoken/keys_cleanup.py b/utility/jwtoken/keys_cleanup.py index ac1d47c..56be1fc 100644 --- a/utility/jwtoken/keys_cleanup.py +++ b/utility/jwtoken/keys_cleanup.py @@ -13,7 +13,7 @@ def cleanup_old_keys(): - now = datetime.now(timezone.utc) + current_time = datetime.now(timezone.utc) active_kid = get_active_kid() for kid_dir in KEYS_DIR.iterdir(): @@ -28,7 +28,7 @@ def cleanup_old_keys(): with open(created_at_file, "r") as f: created_at = datetime.fromisoformat(f.read().strip()) - if (now - created_at) > timedelta(days=EXPIRY_DAYS): + if (current_time - created_at) > timedelta(days=EXPIRY_DAYS): print(f"Deleting expired key: {kid_dir.name}") for item in kid_dir.iterdir(): item.unlink() From c28cf21c2c8370022b4c5a762201cbc5030e812c Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 14:54:42 -0400 Subject: [PATCH 27/28] Remove entrypoint.sh and update ENTRYPOINT in Dockerfile to directly run app.py; streamline container startup process. --- Dockerfile | 3 +-- entrypoint.sh | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index d0e57a3..378144b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the rest of the application files COPY . . -RUN chmod +x /app/entrypoint.sh # Create a non-root user and set permissions for the /app directory RUN adduser --disabled-password --gecos '' apiuser && chown -R apiuser /app @@ -24,4 +23,4 @@ EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail http://localhost:5000/ || exit 1 -ENTRYPOINT ["/app/entrypoint.sh"] +ENTRYPOINT ["python3", "app.py"] diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index f24b9fc..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -e - -python3 -m utility.jwtoken.keys_rotation -exec python3 app.py From b9f96240ff36bbea91b34d3400a01b8f85b35b07 Mon Sep 17 00:00:00 2001 From: Vianney Veremme Date: Sun, 13 Apr 2025 16:04:10 -0400 Subject: [PATCH 28/28] Add curl to fix healthcheck and remove cached libraries to save up space --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 378144b..7efa99b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,10 @@ FROM python:3.13-slim WORKDIR /app # Install dependencies -RUN apt-get update && apt-get install -y --no-install-recommends openssl +RUN apt-get update && apt-get install -y --no-install-recommends curl openssl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install required Python packages COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt