From 99ef741e3962b25735f2962874b0d7e50dbf4fce Mon Sep 17 00:00:00 2001 From: fusion44 Date: Sun, 21 Jul 2024 16:35:13 +0200 Subject: [PATCH 1/3] fix: use seconds for JWT token expiry time refs #262 --- app/auth/auth_bearer.py | 4 +-- app/auth/auth_handler.py | 29 ++++++++++++--------- poetry.lock | 49 +++++++++++++++++++++++++++++++++-- pyproject.toml | 1 + tests/auth/__init__.py | 0 tests/auth/test_auth.py | 56 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 tests/auth/__init__.py create mode 100644 tests/auth/test_auth.py diff --git a/app/auth/auth_bearer.py b/app/auth/auth_bearer.py index 0e69087..6a442d8 100644 --- a/app/auth/auth_bearer.py +++ b/app/auth/auth_bearer.py @@ -1,7 +1,7 @@ from fastapi import HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from .auth_handler import decodeJWT +from .auth_handler import decode_jwt # https://testdriven.io/blog/fastapi-jwt-auth/ @@ -36,7 +36,7 @@ def verify_jwt(self, jwtoken: str) -> bool: isTokenValid: bool = False try: - payload = decodeJWT(jwtoken) + payload = decode_jwt(jwtoken) except: # noqa: E722 payload = None diff --git a/app/auth/auth_handler.py b/app/auth/auth_handler.py index 52f7c3c..c23e05e 100644 --- a/app/auth/auth_handler.py +++ b/app/auth/auth_handler.py @@ -1,30 +1,33 @@ import asyncio import os import time -from typing import Dict import jwt from decouple import config from loguru import logger -JWT_SECRET = config("secret") -JWT_ALGORITHM = config("algorithm") -JWT_EXPIRY_TIME = config("jwt_expiry_time", default=300, cast=int) - -def sign_jwt() -> Dict[str, str]: +def sign_jwt() -> str: payload = { "user_id": "admin", - "expires": int(round(time.time() * 1000) + JWT_EXPIRY_TIME), + "expires": int( + time.time() + config("jwt_expiry_time", default=300, cast=int), + ), } - token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + token = jwt.encode( + payload, + config("secret"), + algorithm=str(config("algorithm")), + ) return token -def decodeJWT(token: str) -> dict: +def decode_jwt(token: str): try: - decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return decoded_token if decoded_token["expires"] >= time.time() * 1000 else None + decoded_token = jwt.decode( + token, config("secret"), algorithms=[str(config("algorithm"))] + ) + return decoded_token if decoded_token["expires"] >= time.time() else None except Exception as e: logger.warning(f"Unable to decode jwt_token {e}") return {} @@ -57,9 +60,11 @@ def remove_local_cookie(): def register_cookie_updater(): # We need to update the cookie file once the cookie is expired + expiry_time = config("jwt_expiry_time") + async def _cookie_updater(): while True: - await asyncio.sleep(JWT_EXPIRY_TIME - 10) + await asyncio.sleep(expiry_time - 10) handle_local_cookie() loop = asyncio.get_event_loop() diff --git a/poetry.lock b/poetry.lock index f47529a..234ebb8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1030,6 +1030,51 @@ files = [ {file = "hiredis-2.3.2.tar.gz", hash = "sha256:733e2456b68f3f126ddaf2cd500a33b25146c3676b97ea843665717bda0c5d43"}, ] +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "identify" version = "2.5.35" @@ -2114,4 +2159,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "50ad61bb8ac78338df35cae7dae4c641b1f2625bc3cd2f27458440933aa0ceda" +content-hash = "58d0fd6d28eaf5ddff32b26d2d7a18d4f5886b08af17e4ce9e304dfcf45c6499" diff --git a/pyproject.toml b/pyproject.toml index 78ee698..9eaae85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ ruff = "^0.1.15" ruff-lsp = "^0.0.52" debugpy = "^1.8.1" click = "^8.1.7" +httpx = "^0.27.0" [build-system] requires = ["poetry-core"] diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_auth.py b/tests/auth/test_auth.py new file mode 100644 index 0000000..7ebd14f --- /dev/null +++ b/tests/auth/test_auth.py @@ -0,0 +1,56 @@ +import jwt + +from app.auth.auth_handler import decode_jwt, sign_jwt + +UNIX_TIME = 1629200000 + +DEFAULT_VALUES = { + "secret": "test_secret", + "algorithm": "HS256", + "jwt_expiry_time": 36008, +} + + +def mock_config(key, default=None, cast=None, vals=DEFAULT_VALUES): + if key not in vals: + raise ValueError(f"Unknown key {key}") + + return vals.get(key, default) + + +def test_sign_jwt_valid_token(monkeypatch): + monkeypatch.setattr("app.auth.auth_handler.config", mock_config) + monkeypatch.setattr("app.auth.auth_handler.time.time", lambda: UNIX_TIME) + + token = sign_jwt() + + try: + t = jwt.decode( + token, + mock_config("secret"), + algorithms=[str(mock_config("algorithm"))], + ) + + assert "user_id" in t + assert "expires" in t + assert t["user_id"] == "admin" + assert t["expires"] == UNIX_TIME + mock_config("jwt_expiry_time") + except jwt.ExpiredSignatureError: + raise AssertionError("Token expired unexpectedly") + except jwt.InvalidTokenError as e: + print(e) + raise AssertionError(f"Invalid token: {e}") + + +def test_sign_jwt_expired_token(monkeypatch): + monkeypatch.setattr("app.auth.auth_handler.config", mock_config) + monkeypatch.setattr("app.auth.auth_handler.time.time", lambda: UNIX_TIME) + + token = sign_jwt() + + expired_time = UNIX_TIME + mock_config("jwt_expiry_time") + 3600 + monkeypatch.setattr("app.auth.auth_handler.time.time", lambda: expired_time) + + # Should return None when expired + res = decode_jwt(token) + assert res is None From a096cd50152403ed83d62975b1efe761bab9a334 Mon Sep 17 00:00:00 2001 From: fusion44 Date: Sun, 21 Jul 2024 20:02:23 +0200 Subject: [PATCH 2/3] fix: missing cast; set cookie on token refresh --- .env_sample | 4 ++-- app/auth/auth_handler.py | 2 +- app/system/router.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env_sample b/.env_sample index 1fcf55b..31ec7d8 100644 --- a/.env_sample +++ b/.env_sample @@ -1,7 +1,7 @@ secret=please_please_update_me_please algorithm=HS256 -# expiry time in milliseconds (3600000 = 1 hour) -jwt_expiry_time=3600000 +# expiry time in seconds (3600 = 1 hour) +jwt_expiry_time=3600 # the log level # values [TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL] diff --git a/app/auth/auth_handler.py b/app/auth/auth_handler.py index c23e05e..7b9e36d 100644 --- a/app/auth/auth_handler.py +++ b/app/auth/auth_handler.py @@ -60,7 +60,7 @@ def remove_local_cookie(): def register_cookie_updater(): # We need to update the cookie file once the cookie is expired - expiry_time = config("jwt_expiry_time") + expiry_time = config("jwt_expiry_time", default=300, cast=int) async def _cookie_updater(): while True: diff --git a/app/system/router.py b/app/system/router.py index 6c0943c..d0b4aac 100644 --- a/app/system/router.py +++ b/app/system/router.py @@ -63,8 +63,10 @@ async def login_path(i: LoginInput, response: Response): response_description="Returns a fresh JWT token.", dependencies=[Depends(JWTBearer())], ) -def refresh_token(): - return sign_jwt() +def refresh_token(response: Response): + token = sign_jwt() + response.set_cookie("access_token", token) + return token @router.post( From 27caf9c78ccdd4f33a8a099436816b391d47ee46 Mon Sep 17 00:00:00 2001 From: fusion44 Date: Sun, 21 Jul 2024 20:03:11 +0200 Subject: [PATCH 3/3] feat: use correct typing on login routes and funcs --- app/system/impl/native_python.py | 2 +- app/system/impl/raspiblitz.py | 2 +- app/system/impl/system_base.py | 2 +- app/system/service.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/system/impl/native_python.py b/app/system/impl/native_python.py index c1a025a..2b20b24 100644 --- a/app/system/impl/native_python.py +++ b/app/system/impl/native_python.py @@ -63,7 +63,7 @@ async def get_connection_info(self) -> ConnectionInfo: # return an empty connection info object for now return ConnectionInfo() - async def login(self, i: LoginInput) -> Dict[str, str]: + async def login(self, i: LoginInput) -> str: matches = secrets.compare_digest(i.password, config("login_password", cast=str)) if matches: return sign_jwt() diff --git a/app/system/impl/raspiblitz.py b/app/system/impl/raspiblitz.py index 91dcae0..1f1f7d4 100644 --- a/app/system/impl/raspiblitz.py +++ b/app/system/impl/raspiblitz.py @@ -200,7 +200,7 @@ async def get_connection_info(self) -> ConnectionInfo: cl_rest_onion=data_cl_rest_onion, ) - async def login(self, i: LoginInput) -> Dict[str, str]: + async def login(self, i: LoginInput) -> str: matches = await self._match_password(i) if matches: return sign_jwt() diff --git a/app/system/impl/system_base.py b/app/system/impl/system_base.py index f13b3be..91c66b5 100644 --- a/app/system/impl/system_base.py +++ b/app/system/impl/system_base.py @@ -28,7 +28,7 @@ async def get_connection_info(self) -> ConnectionInfo: raise NotImplementedError() @abstractmethod - async def login(self, i: LoginInput) -> Dict[str, str]: + async def login(self, i: LoginInput) -> str: raise NotImplementedError() @abstractmethod diff --git a/app/system/service.py b/app/system/service.py index 3b7e48b..609171e 100644 --- a/app/system/service.py +++ b/app/system/service.py @@ -122,7 +122,7 @@ async def register_hardware_info_gatherer(): loop.create_task(_handle_gather_hardware_info()) -async def login(i: LoginInput) -> Dict[str, str]: +async def login(i: LoginInput) -> str: try: return await system.login(i) except HTTPException: