Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2614df4
Enhance Docker and JWT handling: update Dockerfile to install OpenSSL…
Apr 7, 2025
c8bc60e
Implement JWT key rotation: add endpoint to rotate keys, update key m…
Apr 8, 2025
968da27
Refactor JWT key handling: remove hardcoded key loading, utilize dyna…
Apr 8, 2025
01eff90
Add timestamp logging for key creation in JWT rotation
Apr 8, 2025
702725a
Change route to be more RESTful
Apr 8, 2025
f2834ee
Remove JWT_SECRET_KEY from config and add script for cleaning up expi…
Apr 8, 2025
6bc3104
Refactor JWT tests to use public key for encoding/decoding and enhanc…
Apr 8, 2025
ffb491a
Remove redundant sample_person_id fixture from test_refresh.py
Apr 8, 2025
ab6d222
Merge branch 'main' into security/use_rsa_for_jwt
Apr 10, 2025
ae6c76a
Refactor key cleanup script to use dynamic expiry days from JWT confi…
Apr 10, 2025
5c0e461
Refactor JWT handling by moving related functions and classes to a ne…
Apr 11, 2025
7b392a5
Refactor JWT key handling by consolidating functions into common modu…
Apr 11, 2025
21602d3
Refactor JWT test suite by removing obsolete test files and consolida…
Apr 11, 2025
b1f94b3
Clarify entrypoint script comment to specify starting the API
Apr 11, 2025
e4078fd
Optimize payload generation
Vianpyro Apr 13, 2025
bc7d9d0
Merge branch 'main' into security/use_rsa_for_jwt
Apr 13, 2025
68a2081
Add key management functionality and refactor key paths
Apr 13, 2025
8b05ed4
Refactor Dockerfile to remove entrypoint script and directly run key …
Apr 13, 2025
9d4a087
Add OpenSSL installation steps for Linux, macOS, and Windows in Pytes…
Apr 13, 2025
3ad2c9c
Fix variable name inconsistency for key directory in jwtoken and keys…
Apr 13, 2025
2eb3e51
Refactor Dockerfile to use entrypoint script for key rotation and app…
Apr 13, 2025
926a4cc
Reorganize Dockerfile to copy entrypoint script before exposing Flask…
Apr 13, 2025
8c15694
Remove entrypoint script copy command from Dockerfile; streamline app…
Apr 13, 2025
7ebd0e5
Update ENTRYPOINT in Dockerfile to use entrypoint.sh for improved pro…
Apr 13, 2025
89ba7cd
Update ENTRYPOINT in Dockerfile to use entrypoint.sh for improved pro…
Apr 13, 2025
7e9fc5f
Refactor app.py to use ACTIVE_KID_FILE constant for key existence che…
Apr 13, 2025
cd26564
Remove OpenSSL installation step for macOS in Pytest CI workflow; cla…
Apr 13, 2025
5c18cc5
Refactor datetime usage in token generation and logging setup; improv…
Apr 13, 2025
c28cf21
Remove entrypoint.sh and update ENTRYPOINT in Dockerfile to directly …
Apr 13, 2025
b9f9624
Add curl to fix healthcheck and remove cached libraries to save up space
Apr 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
logs/
*.log

# Ignore all pem files
# Ignore JWT keys
keys/
*.pem

# Upload folder
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]
1 change: 0 additions & 1 deletion config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
20 changes: 20 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 the API
exec python3 app.py
78 changes: 0 additions & 78 deletions jwt_helper.py

This file was deleted.

File renamed without changes.
24 changes: 24 additions & 0 deletions jwtoken/decorators.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions jwtoken/exceptions.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions jwtoken/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from datetime import datetime, timedelta, timezone

import jwt

from utility.jwtoken.common import get_active_kid, load_private_key, load_public_key

from .exceptions import TokenError

JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30)


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
Comment thread
Vianpyro marked this conversation as resolved.
Outdated
"token_type": "access",
}
headers = {"kid": kid}
return jwt.encode(payload, private_key, algorithm="RS256", headers=headers)


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",
}
headers = {"kid": kid}

return jwt.encode(payload, private_key, algorithm="RS256", headers=headers)


def verify_token(token: str, required_type: str) -> dict:
"""Verify and decode a JWT token."""
try:
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:
raise TokenError("Invalid token", 401)
10 changes: 3 additions & 7 deletions routes/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
2 changes: 1 addition & 1 deletion routes/picture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import pytest


@pytest.fixture
def sample_person_id() -> int:
"""Provide a sample person ID for testing"""
return 12345
54 changes: 0 additions & 54 deletions tests/test_jwt/test_verify_token.py

This file was deleted.

Empty file added tests/test_jwtoken/__init__.py
Empty file.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import jwt
import pytest
from flask import Flask

from jwt_helper import generate_access_token
from jwtoken.tokens import generate_access_token


@pytest.fixture
Expand All @@ -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")
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down
Loading