diff --git a/backend/Makefile b/backend/Makefile index 33dd03b..efb45d4 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -86,9 +86,7 @@ validate: lint format-check type-check dep-audit: @echo "🔒 Scanning dependencies for known vulnerabilities..." - @# CVE-2024-23342: ecdsa timing attack via python-jose (transitive). No fix available. - @# We only use HS256 (not ECDSA signing), so this is not exploitable. Track python-jose replacement separately. - @uv run pip-audit --desc --skip-editable --ignore-vuln CVE-2024-23342 + @uv run pip-audit --desc --skip-editable @echo "✅ No known vulnerabilities found!" license-check: diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index df8ad39..085134e 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,23 +1,21 @@ import asyncio -import logging import uuid from datetime import UTC, datetime, timedelta from functools import partial from typing import Any -from jose import JWTError, jwt -from passlib.context import CryptContext +import bcrypt +import jwt +from jwt.exceptions import ( + ExpiredSignatureError, + InvalidTokenError, + MissingRequiredClaimError, +) from pydantic import ValidationError from app.core.config import settings from app.schemas.users import TokenData, TokenPayload -# Suppress passlib bcrypt warnings about ident -logging.getLogger("passlib").setLevel(logging.ERROR) - -# Password hashing context -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - # Custom exceptions for auth class AuthError(Exception): @@ -37,13 +35,16 @@ class TokenMissingClaimError(AuthError): def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against a hash.""" - return pwd_context.verify(plain_password, hashed_password) + """Verify a password against a bcrypt hash.""" + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) def get_password_hash(password: str) -> str: - """Generate a password hash.""" - return pwd_context.hash(password) + """Generate a bcrypt password hash.""" + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") async def verify_password_async(plain_password: str, hashed_password: str) -> bool: @@ -60,9 +61,9 @@ async def verify_password_async(plain_password: str, hashed_password: str) -> bo Returns: True if password matches, False otherwise """ - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor( - None, partial(pwd_context.verify, plain_password, hashed_password) + None, partial(verify_password, plain_password, hashed_password) ) @@ -80,8 +81,8 @@ async def get_password_hash_async(password: str) -> str: Returns: Hashed password string """ - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, pwd_context.hash, password) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, get_password_hash, password) def create_access_token( @@ -121,11 +122,7 @@ def create_access_token( to_encode.update(claims) # Create the JWT - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - - return encoded_jwt + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def create_refresh_token( @@ -154,11 +151,7 @@ def create_refresh_token( "type": "refresh", } - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - - return encoded_jwt + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def decode_token(token: str, verify_type: str | None = None) -> TokenPayload: @@ -198,7 +191,7 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload: # Reject weak or unexpected algorithms # NOTE: These are defensive checks that provide defense-in-depth. - # The python-jose library rejects these tokens BEFORE we reach here, + # PyJWT rejects these tokens BEFORE we reach here, # but we keep these checks in case the library changes or is misconfigured. # Coverage: Marked as pragma since library catches first (see tests/core/test_auth_security.py) if token_algorithm == "NONE": # pragma: no cover @@ -219,10 +212,11 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload: token_data = TokenPayload(**payload) return token_data - except JWTError as e: - # Check if the error is due to an expired token - if "expired" in str(e).lower(): - raise TokenExpiredError("Token has expired") + except ExpiredSignatureError: + raise TokenExpiredError("Token has expired") + except MissingRequiredClaimError as e: + raise TokenMissingClaimError(f"Token missing required claim: {e}") + except InvalidTokenError: raise TokenInvalidError("Invalid authentication token") except ValidationError: raise TokenInvalidError("Invalid token payload") diff --git a/backend/app/services/oauth_provider_service.py b/backend/app/services/oauth_provider_service.py index fbffabc..3352201 100755 --- a/backend/app/services/oauth_provider_service.py +++ b/backend/app/services/oauth_provider_service.py @@ -25,8 +25,8 @@ from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID -from jose import JWTError, jwt -from jose.exceptions import ExpiredSignatureError +import jwt +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings @@ -704,7 +704,7 @@ async def revoke_token( "Revoked refresh token via access token JTI %s...", jti[:8] ) return True - except JWTError: + except InvalidTokenError: pass except Exception: # noqa: S110 - Intentional: invalid JWT not an error pass @@ -827,7 +827,7 @@ async def introspect_token( } except ExpiredSignatureError: return {"active": False} - except JWTError: + except InvalidTokenError: pass except Exception: # noqa: S110 - Intentional: invalid JWT falls through to refresh token check pass diff --git a/backend/app/services/oauth_service.py b/backend/app/services/oauth_service.py index 0834828..5f2d6d1 100644 --- a/backend/app/services/oauth_service.py +++ b/backend/app/services/oauth_service.py @@ -537,8 +537,9 @@ class OAuthService: AuthenticationError: If verification fails """ import httpx - from jose import jwt as jose_jwt - from jose.exceptions import JWTError + import jwt as pyjwt + from jwt.algorithms import RSAAlgorithm + from jwt.exceptions import InvalidTokenError try: # Fetch Google's public keys (JWKS) @@ -552,24 +553,27 @@ class OAuthService: jwks = jwks_response.json() # Get the key ID from the token header - unverified_header = jose_jwt.get_unverified_header(id_token) + unverified_header = pyjwt.get_unverified_header(id_token) kid = unverified_header.get("kid") if not kid: raise AuthenticationError("ID token missing key ID (kid)") # Find the matching public key - public_key = None + jwk_data = None for key in jwks.get("keys", []): if key.get("kid") == kid: - public_key = key + jwk_data = key break - if not public_key: + if not jwk_data: raise AuthenticationError("ID token signed with unknown key") + # Convert JWK to a public key object for PyJWT + public_key = RSAAlgorithm.from_jwk(jwk_data) + # Verify the token signature and decode claims - # jose library will verify signature against the JWK - payload = jose_jwt.decode( + # PyJWT will verify signature against the RSA public key + payload = pyjwt.decode( id_token, public_key, algorithms=["RS256"], # Google uses RS256 @@ -597,7 +601,7 @@ class OAuthService: logger.debug("Google ID token verified successfully") return payload - except JWTError as e: + except InvalidTokenError as e: logger.warning("Google ID token verification failed: %s", e) raise AuthenticationError("Invalid ID token signature") except httpx.HTTPError as e: diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index c0e074f..c4fe7d7 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -79,12 +79,13 @@ This FastAPI backend application follows a **clean layered architecture** patter ### Authentication & Security -- **python-jose**: JWT token generation and validation - - Cryptographic signing +- **PyJWT**: JWT token generation and validation + - Cryptographic signing (HS256, RS256) - Token expiration handling - Claims validation + - JWK support for Google ID token verification -- **passlib + bcrypt**: Password hashing +- **bcrypt**: Password hashing - Industry-standard bcrypt algorithm - Configurable cost factor - Salt generation diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fd2ccab..acbde98 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,9 +43,8 @@ dependencies = [ "pytz>=2024.1", "pillow>=12.1.1", "apscheduler==3.11.0", - # Security and authentication (pinned for reproducibility) - "python-jose==3.4.0", - "passlib==1.7.4", + # Security and authentication + "PyJWT>=2.9.0", "bcrypt==4.2.1", "cryptography>=46.0.5", # OAuth authentication diff --git a/backend/tests/core/test_auth.py b/backend/tests/core/test_auth.py index 42c12e2..c8e0893 100755 --- a/backend/tests/core/test_auth.py +++ b/backend/tests/core/test_auth.py @@ -2,8 +2,8 @@ import uuid from datetime import UTC, datetime, timedelta +import jwt import pytest -from jose import jwt from app.core.auth import ( TokenExpiredError, @@ -215,6 +215,7 @@ class TestTokenDecoding: payload = { "sub": 123, # sub should be a string, not an integer "exp": int((now + timedelta(minutes=30)).timestamp()), + "iat": int(now.timestamp()), } token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) diff --git a/backend/tests/core/test_auth_security.py b/backend/tests/core/test_auth_security.py index 2f0d1ba..b620a7b 100644 --- a/backend/tests/core/test_auth_security.py +++ b/backend/tests/core/test_auth_security.py @@ -9,8 +9,8 @@ Critical security tests covering: These tests cover critical security vulnerabilities that could be exploited. """ +import jwt import pytest -from jose import jwt from app.core.auth import TokenInvalidError, create_access_token, decode_token from app.core.config import settings @@ -38,8 +38,8 @@ class TestJWTAlgorithmSecurityAttacks: Attacker creates a token with "alg: none" to bypass signature verification. NOTE: Lines 209 and 212 in auth.py are DEFENSIVE CODE that's never reached - because python-jose library rejects "none" algorithm tokens BEFORE we get there. - This is good for security! The library throws JWTError which becomes TokenInvalidError. + because PyJWT rejects "none" algorithm tokens BEFORE we get there. + This is good for security! The library throws InvalidTokenError which becomes TokenInvalidError. This test verifies the overall protection works, even though our defensive checks at lines 209-212 don't execute because the library catches it first. @@ -108,36 +108,33 @@ class TestJWTAlgorithmSecurityAttacks: Test that tokens with wrong algorithm are rejected. Attack Scenario: - Attacker changes algorithm from HS256 to RS256, attempting to use - the public key as the HMAC secret. This could allow token forgery. + Attacker changes the "alg" header to RS256 while keeping an HMAC + signature, attempting algorithm confusion to forge tokens. Reference: https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2019/january/jwt-algorithm-confusion/ - - NOTE: Like the "none" algorithm test, python-jose library catches this - before our defensive checks at line 212. This is good for security! """ + import base64 + import json import time now = int(time.time()) - - # Create a valid payload payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"} - # Encode with wrong algorithm (RS256 instead of HS256) - # This simulates an attacker trying algorithm substitution - wrong_algorithm = "RS256" if settings.ALGORITHM == "HS256" else "HS256" + # Hand-craft a token claiming RS256 in the header — PyJWT cannot encode + # RS256 with an HMAC key, so we craft the header manually (same technique + # as the "alg: none" tests) to produce a token that actually reaches decode_token. + header = {"alg": "RS256", "typ": "JWT"} + header_encoded = ( + base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=") + ) + payload_encoded = ( + base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + ) + # Attach a fake signature to form a complete (but invalid) JWT + malicious_token = f"{header_encoded}.{payload_encoded}.fakesignature" - try: - malicious_token = jwt.encode( - payload, settings.SECRET_KEY, algorithm=wrong_algorithm - ) - - # Should reject the token (library catches mismatch) - with pytest.raises(TokenInvalidError): - decode_token(malicious_token) - except Exception: - # If encoding fails, that's also acceptable (library protection) - pass + with pytest.raises(TokenInvalidError): + decode_token(malicious_token) def test_reject_hs384_when_hs256_expected(self): """ diff --git a/backend/uv.lock b/backend/uv.lock index df7f600..dd42baf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -549,18 +549,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, -] - [[package]] name = "email-validator" version = "2.3.0" @@ -599,13 +587,12 @@ dependencies = [ { name = "fastapi" }, { name = "fastapi-utils" }, { name = "httpx" }, - { name = "passlib" }, { name = "pillow" }, { name = "psycopg2-binary" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-dotenv" }, - { name = "python-jose" }, { name = "python-multipart" }, { name = "pytz" }, { name = "slowapi" }, @@ -653,7 +640,6 @@ requires-dist = [ { name = "fastapi-utils", specifier = "==0.8.0" }, { name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "passlib", specifier = "==1.7.4" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" }, { name = "pip-licenses", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -661,13 +647,13 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.9" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic-settings", specifier = ">=2.2.1" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "python-jose", specifier = "==3.4.0" }, { name = "python-multipart", specifier = ">=0.0.22" }, { name = "pytz", specifier = ">=2024.1" }, { name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.0" }, @@ -1177,15 +1163,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - [[package]] name = "pillow" version = "12.1.1" @@ -1435,15 +1412,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] -[[package]] -name = "pyasn1" -version = "0.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -1562,6 +1530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1696,20 +1673,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-jose" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" }, -] - [[package]] name = "python-multipart" version = "0.0.22" @@ -1934,18 +1897,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.14.4"