From 2d3e62addfff11ed3187c62dd8a56534f4e9d59c Mon Sep 17 00:00:00 2001 From: Alexandre Teles Date: Mon, 17 Oct 2022 16:57:29 -0300 Subject: [PATCH] fix: fix token revogation --- .devcontainer/Dockerfile | 2 +- .devcontainer/postCreateCommand.sh | 2 +- .github/dependabot.yml | 2 +- .github/workflows/mypy.yml | 2 +- .gitignore | 2 +- .vscode/settings.json | 2 +- Dockerfile | 2 +- SECURITY.md | 2 +- app/controllers/Announcements.py | 2 +- app/controllers/Auth.py | 5 ++- app/controllers/Clients.py | 49 +++++++++--------------------- app/controllers/Releases.py | 2 +- app/main.py | 13 +++++++- app/models/AnnouncementModels.py | 2 +- app/models/ClientModels.py | 1 - app/models/GeneralErrors.py | 2 +- app/models/MirrorModels.py | 2 +- app/models/ResponseFields.py | 2 +- app/models/ResponseModels.py | 11 ++++++- app/routers/auth.py | 24 ++++++++++++--- app/routers/clients.py | 9 +++--- app/routers/contributors.py | 2 +- app/routers/mirrors.py | 2 +- app/routers/ping.py | 2 +- app/utils/Generators.py | 2 +- app/utils/HTTPXClient.py | 2 +- app/utils/Logger.py | 8 ++--- app/utils/RedisConnector.py | 2 +- config.toml | 7 ++--- deploy/docker-compose.yml | 2 +- deploy/portainer-stack.yml | 2 +- mypy.ini | 4 +++ poetry.lock | 6 ++-- 33 files changed, 101 insertions(+), 80 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f6d7861..529221b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,4 +6,4 @@ RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends python3-venv python-is-python3 micro \ - unzip zip build-essential python3-dev redis-tools \ No newline at end of file + unzip zip build-essential python3-dev redis-tools diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 99b9067..028de52 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -19,4 +19,4 @@ docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:lat printf "Installing dependencies...\n" -poetry install --all-extras \ No newline at end of file +poetry install --all-extras diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f174799..b38df29 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,4 +3,4 @@ updates: - package-ecosystem: "pip" directory: "/" schedule: - interval: "daily" \ No newline at end of file + interval: "daily" diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index a2eede7..f1cc65c 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -23,4 +23,4 @@ jobs: with: checkName: 'mypy' env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.gitignore b/.gitignore index fbde709..979f993 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,4 @@ cython_debug/ # PROJECT SPECIFIC setup_env.sh -admin_info.json \ No newline at end of file +admin_info.json diff --git a/.vscode/settings.json b/.vscode/settings.json index a6735e5..738281a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { "python.analysis.typeCheckingMode": "off" -} \ No newline at end of file +} diff --git a/Dockerfile b/Dockerfile index d738c3a..0e9ce1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,4 @@ RUN apt update && \ apt-get install build-essential libffi-dev -y \ && pip install --no-cache-dir -r requirements.txt -CMD [ "python3", "./run.py" ] \ No newline at end of file +CMD [ "python3", "./run.py" ] diff --git a/SECURITY.md b/SECURITY.md index 6b06e26..a96b0b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,4 +8,4 @@ ## Reporting a Vulnerability -To report a vulnerability, please open an Issue in our issue tracker here on GitHub. \ No newline at end of file +To report a vulnerability, please open an Issue in our issue tracker here on GitHub. diff --git a/app/controllers/Announcements.py b/app/controllers/Announcements.py index 1f7156c..a5353d1 100644 --- a/app/controllers/Announcements.py +++ b/app/controllers/Announcements.py @@ -99,4 +99,4 @@ class Announcements: return False return True else: - return False \ No newline at end of file + return False diff --git a/app/controllers/Auth.py b/app/controllers/Auth.py index 960932d..047798a 100644 --- a/app/controllers/Auth.py +++ b/app/controllers/Auth.py @@ -1,10 +1,13 @@ +from datetime import timedelta import os import toml +from datetime import timedelta from pydantic import BaseModel +from fastapi_paseto_auth import AuthPASETO config: dict = toml.load("config.toml") class PasetoSettings(BaseModel): authpaseto_secret_key: str = os.environ['SECRET_KEY'] authpaseto_access_token_expires: int | bool = config['auth']['access_token_expires'] - \ No newline at end of file + authpaseto_denylist_enabled: bool = True diff --git a/app/controllers/Clients.py b/app/controllers/Clients.py index 709cf80..71df031 100644 --- a/app/controllers/Clients.py +++ b/app/controllers/Clients.py @@ -266,7 +266,13 @@ class Clients: banned: bool = False try: - await self.redis_tokens.set(token, '') + if type(config['auth']['access_token_expires']) is bool: + await self.redis_tokens.set(name=token, value="", nx=True) + else: + await self.redis_tokens.set(name=token, + value="", + nx=True, + ex=config['auth']['access_token_expires']) await self.UserLogger.log("BAN_TOKEN", None, token) banned = True except aioredis.RedisError as e: @@ -275,52 +281,25 @@ class Clients: return banned - async def is_token_banned(self, token: str) -> bool: - """Check if a token is banned - - Args: - token (str): Token to check - - Returns: - bool: True if the token is banned, False otherwise - """ - - banned: bool = True - - try: - banned = await self.redis_tokens.exists(token) - await self.UserLogger.log("CHECK_TOKEN", None, token) - except aioredis.RedisError as e: - await self.UserLogger.log("CHECK_TOKEN", e) - raise e - - return banned - async def auth_checks(self, client_id: str, token: str) -> bool: """Check if a client exists, is active and the token isn't banned Args: client_id (str): UUID of the client secret (str): Secret of the client + token (str): Token JTI Returns: bool: True if the client exists, is active and the token isn't banned, False otherwise """ - if await self.exists(client_id): - if await self.is_active(client_id): - if not await self.is_token_banned(token): - return True - else: - return False - else: - if not await self.is_token_banned(token): - await self.ban_token(token) - return False + if await self.exists(client_id) and await self.is_active(client_id): + return True else: - await self.ban_token(token) - return False + if not await self.redis_tokens.exists(token): + await self.ban_token(token) + return False return False @@ -348,4 +327,4 @@ class Clients: await self.UserLogger.log("CREATE_ADMIN", e) raise e - return created \ No newline at end of file + return created diff --git a/app/controllers/Releases.py b/app/controllers/Releases.py index 37a63da..e2bc7ed 100644 --- a/app/controllers/Releases.py +++ b/app/controllers/Releases.py @@ -186,4 +186,4 @@ class Releases: return payload else: - raise Exception("Invalid organization.") \ No newline at end of file + raise Exception("Invalid organization.") diff --git a/app/main.py b/app/main.py index 0b39b6e..bcc6b7d 100755 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ import os import toml import binascii +from redis import Redis from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse, UJSONResponse @@ -21,6 +22,7 @@ from fastapi_paseto_auth.exceptions import AuthPASETOException import app.controllers.Auth as Auth from app.controllers.Clients import Clients + from app.utils.RedisConnector import RedisConnector import app.models.GeneralErrors as GeneralErrors @@ -100,6 +102,15 @@ def get_config() -> Auth.PasetoSettings: """ return Auth.PasetoSettings() +@AuthPASETO.token_in_denylist_loader +def check_if_token_in_denylist(decrypted_token): + redis = Redis(host=os.environ['REDIS_URL'], + port=os.environ['REDIS_PORT'], + db=config['tokens']['database'], + decode_responses=True) + + return redis.exists(decrypted_token["jti"]) + # Setup custom error handlers @app.exception_handler(AuthPASETOException) @@ -155,4 +166,4 @@ async def startup() -> None: FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])), prefix="fastapi-cache") - return None \ No newline at end of file + return None diff --git a/app/models/AnnouncementModels.py b/app/models/AnnouncementModels.py index 1742ad0..fb3b202 100644 --- a/app/models/AnnouncementModels.py +++ b/app/models/AnnouncementModels.py @@ -43,4 +43,4 @@ class AnnouncementDeleted(BaseModel): BaseModel (pydantic.BaseModel): BaseModel from pydantic """ - deleted: bool \ No newline at end of file + deleted: bool diff --git a/app/models/ClientModels.py b/app/models/ClientModels.py index 67188fb..8f5c95e 100644 --- a/app/models/ClientModels.py +++ b/app/models/ClientModels.py @@ -21,4 +21,3 @@ class ClientAuthModel(BaseModel): id: str secret: str - diff --git a/app/models/GeneralErrors.py b/app/models/GeneralErrors.py index a811556..7562a3a 100644 --- a/app/models/GeneralErrors.py +++ b/app/models/GeneralErrors.py @@ -68,4 +68,4 @@ class MirrorAlreadyExistsError(BaseModel): """ error: str = "Conflict" - message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror." \ No newline at end of file + message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror." diff --git a/app/models/MirrorModels.py b/app/models/MirrorModels.py index 2226dbe..5141edf 100644 --- a/app/models/MirrorModels.py +++ b/app/models/MirrorModels.py @@ -49,4 +49,4 @@ class MirrorDeletedResponseModel(BaseModel): BaseModel (pydantic.BaseModel): BaseModel from pydantic """ deleted: bool - key: str \ No newline at end of file + key: str diff --git a/app/models/ResponseFields.py b/app/models/ResponseFields.py index 5de8f38..5a7b449 100644 --- a/app/models/ResponseFields.py +++ b/app/models/ResponseFields.py @@ -74,4 +74,4 @@ class ChangelogsResponseFields(BaseModel): sha: str author: str message: str - html_url: str \ No newline at end of file + html_url: str diff --git a/app/models/ResponseModels.py b/app/models/ResponseModels.py index 083ac8d..1c0d893 100644 --- a/app/models/ResponseModels.py +++ b/app/models/ResponseModels.py @@ -97,4 +97,13 @@ class ChangelogsResponseModel(BaseModel): repository: str path: str - commits: list[ ResponseFields.ChangelogsResponseFields ] \ No newline at end of file + commits: list[ ResponseFields.ChangelogsResponseFields ] + +class RevokedTokenResponse(BaseModel): + """Implements the response fields for token invalidation. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + revoked: bool diff --git a/app/routers/auth.py b/app/routers/auth.py index e1f77d5..13bbf67 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -6,11 +6,14 @@ import app.models.ClientModels as ClientModels import app.models.GeneralErrors as GeneralErrors import app.models.ResponseModels as ResponseModels -router = APIRouter() +router = APIRouter( + prefix="/auth", + tags=['Authentication'] +) clients = Clients() config: dict = load_config() -@router.post('/auth', response_model=ResponseModels.ClientAuthTokenResponse, status_code=status.HTTP_200_OK, tags=['Authentication']) +@router.post('/', response_model=ResponseModels.ClientAuthTokenResponse, status_code=status.HTTP_200_OK) async def auth(request: Request, response: Response, client: ClientModels.ClientAuthModel, Authorize: AuthPASETO = Depends()) -> dict: """Authenticate a client and get an auth token. @@ -20,7 +23,7 @@ async def auth(request: Request, response: Response, client: ClientModels.Client admin_claim: dict[str, bool] - if await clients.exists(client.id): + if await clients.exists(client.id) and await clients.is_active(client.id): authenticated: bool = await clients.authenticate(client.id, client.secret) if not authenticated: @@ -45,4 +48,17 @@ async def auth(request: Request, response: Response, client: ClientModels.Client "error": GeneralErrors.Unauthorized().error, "message": GeneralErrors.Unauthorized().message } - ) \ No newline at end of file + ) + +@router.delete("/revoke", response_model=ResponseModels.RevokedTokenResponse, status_code=status.HTTP_200_OK) +async def revoke_token(request: Request, response: Response, Authorize: AuthPASETO = Depends()): + Authorize.paseto_required() + + if await clients.ban_token(Authorize.get_jti()): + return {"revoked": True} + else: + raise HTTPException(status_code=500, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) diff --git a/app/routers/clients.py b/app/routers/clients.py index 2086193..09aef13 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -128,7 +128,7 @@ async def update_client(request: Request, response: Response, client_id: str, Au } ) -@router.patch('/client/{client_id}/status', response_model=ResponseModels.ClientStatusResponse, status_code=status.HTTP_200_OK) +@router.patch('/{client_id}/status', response_model=ResponseModels.ClientStatusResponse, status_code=status.HTTP_200_OK) async def client_status(request: Request, response: Response, client_id: str, active: bool, Authorize: AuthPASETO = Depends()) -> dict: """Activate or deactivate a client @@ -144,11 +144,11 @@ async def client_status(request: Request, response: Response, client_id: str, ac if 'admin' in Authorize.get_token_payload(): admin_claim = {"admin": Authorize.get_token_payload()['admin']} - + print("admin claim: ", admin_claim) if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and ( admin_claim['admin'] == True or current_user == client_id ) ): - + print("client exists") if await clients.exists(client_id): if await clients.status(client_id, active): return {"id": client_id, "active": active} @@ -159,6 +159,7 @@ async def client_status(request: Request, response: Response, client_id: str, ac } ) else: + print("Client does not exist") raise HTTPException(status_code=404, detail={ "error": GeneralErrors.ClientNotFound().error, "message": GeneralErrors.ClientNotFound().message @@ -169,4 +170,4 @@ async def client_status(request: Request, response: Response, client_id: str, ac "error": GeneralErrors.Unauthorized().error, "message": GeneralErrors.Unauthorized().message } - ) \ No newline at end of file + ) diff --git a/app/routers/contributors.py b/app/routers/contributors.py index 1e1552a..400845c 100644 --- a/app/routers/contributors.py +++ b/app/routers/contributors.py @@ -18,4 +18,4 @@ async def contributors(request: Request, response: Response) -> dict: Returns: json: list of contributors """ - return await releases.get_contributors(config['app']['repositories']) \ No newline at end of file + return await releases.get_contributors(config['app']['repositories']) diff --git a/app/routers/mirrors.py b/app/routers/mirrors.py index c7444a9..ed03904 100644 --- a/app/routers/mirrors.py +++ b/app/routers/mirrors.py @@ -136,4 +136,4 @@ async def delete_mirror(request: Request, response: Response, org: str, repo: st "error": GeneralErrors.Unauthorized().error, "message": GeneralErrors.Unauthorized().message } - ) \ No newline at end of file + ) diff --git a/app/routers/ping.py b/app/routers/ping.py index b0ecb88..4739a53 100644 --- a/app/routers/ping.py +++ b/app/routers/ping.py @@ -9,4 +9,4 @@ async def ping(request: Request, response: Response) -> None: Returns: None """ - return None \ No newline at end of file + return None diff --git a/app/utils/Generators.py b/app/utils/Generators.py index 95c3d69..ebb5913 100644 --- a/app/utils/Generators.py +++ b/app/utils/Generators.py @@ -27,4 +27,4 @@ class Generators: Returns: int: A timestamp """ - return int(time.time()) \ No newline at end of file + return int(time.time()) diff --git a/app/utils/HTTPXClient.py b/app/utils/HTTPXClient.py index bcdd5d0..be6e59e 100644 --- a/app/utils/HTTPXClient.py +++ b/app/utils/HTTPXClient.py @@ -29,4 +29,4 @@ class HTTPXClient: } ) - return httpx_client \ No newline at end of file + return httpx_client diff --git a/app/utils/Logger.py b/app/utils/Logger.py index 7940212..aa6bc1b 100644 --- a/app/utils/Logger.py +++ b/app/utils/Logger.py @@ -60,9 +60,9 @@ class AnnouncementsLogger: key (str): Key used in the operation """ if type(result) is RedisError: - logger.error(f"[User] REDIS {operation} - Failed with error: {result}") + logger.error(f"[ANNOUNCEMENT] REDIS {operation} - Failed with error: {result}") else: - logger.info(f"[User] REDIS {operation} {key} - OK") + logger.info(f"[ANNOUNCEMENT] REDIS {operation} {key} - OK") class MirrorsLogger: async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None: @@ -73,6 +73,6 @@ class MirrorsLogger: key (str): Key used in the operation """ if type(result) is RedisError: - logger.error(f"[User] REDIS {operation} - Failed with error: {result}") + logger.error(f"[MIRRORS] REDIS {operation} - Failed with error: {result}") else: - logger.info(f"[User] REDIS {operation} {key} - OK") + logger.info(f"[MIRRORS] REDIS {operation} {key} - OK") diff --git a/app/utils/RedisConnector.py b/app/utils/RedisConnector.py index e60c5b5..cbd846e 100644 --- a/app/utils/RedisConnector.py +++ b/app/utils/RedisConnector.py @@ -20,4 +20,4 @@ class RedisConnector: def connect(database: str) -> aioredis.Redis: """Connect to Redis""" redis_url = f"{redis_config['url']}:{redis_config['port']}/{database}" - return aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True) \ No newline at end of file + return aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True) diff --git a/config.toml b/config.toml index b45c3e8..97d07c6 100644 --- a/config.toml +++ b/config.toml @@ -13,17 +13,16 @@ description = """ * Rate Limiting - 60 requests per minute * Cache - 5 minutes -* Token duration - 1 hour -* Token refresh - 30 days +* Token duration - 1 year ### Additional Notes 1. Breaking changes are to be expected -2. Client side caching is adviced to avoid unnecessary requests +2. Client side caching is advised to avoid unnecessary requests 3. Abuse of the API will result in IP blocks """ -version = "0.9 RC2" +version = "1.0.0" [license] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c3dde63..1a6ffd6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -30,4 +30,4 @@ services: networks: infra: - external: true \ No newline at end of file + external: true diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml index 482be18..87fd47e 100644 --- a/deploy/portainer-stack.yml +++ b/deploy/portainer-stack.yml @@ -31,4 +31,4 @@ services: networks: infra: - external: true \ No newline at end of file + external: true diff --git a/mypy.ini b/mypy.ini index baf2713..2c74fba 100644 --- a/mypy.ini +++ b/mypy.ini @@ -71,4 +71,8 @@ ignore_missing_imports = True [mypy-gunicorn.*] # No stubs available +ignore_missing_imports = True + +[mypy-asgiref.*] +# No stubs available ignore_missing_imports = True \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index a8ed67a..c104b6a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -723,7 +723,7 @@ python-versions = ">=3.5" [[package]] name = "types-redis" -version = "4.3.21.1" +version = "4.3.21.2" description = "Typing stubs for redis" category = "dev" optional = false @@ -1478,8 +1478,8 @@ toolz = [ {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, ] types-redis = [ - {file = "types-redis-4.3.21.1.tar.gz", hash = "sha256:493814829643fc04a14595eda6ccd69bdc0606477541ccda54238ce3f60bc993"}, - {file = "types_redis-4.3.21.1-py3-none-any.whl", hash = "sha256:65b8c842f406932218f8ce636f75e5a03cb6b382d3922cb3e5f87e127e6d434d"}, + {file = "types-redis-4.3.21.2.tar.gz", hash = "sha256:ab542249a47d3903b94162e6395ae6be0cc0f562fd184ed51a0a34d8a7021a39"}, + {file = "types_redis-4.3.21.2-py3-none-any.whl", hash = "sha256:eda5fd9e80f453143902db5547ca6506a3af44476ad080db7d18840da532ab19"}, ] types-toml = [ {file = "types-toml-0.10.8.tar.gz", hash = "sha256:b7e7ea572308b1030dc86c3ba825c5210814c2825612ec679eb7814f8dd9295a"},