fix: fix token revogation

This commit is contained in:
Alexandre Teles 2022-10-17 16:57:29 -03:00
parent 0a9c2bae63
commit a46c62a898
33 changed files with 101 additions and 80 deletions

View File

@ -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
unzip zip build-essential python3-dev redis-tools

View File

@ -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
poetry install --all-extras

View File

@ -3,4 +3,4 @@ updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
interval: "daily"

View File

@ -23,4 +23,4 @@ jobs:
with:
checkName: 'mypy'
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

2
.gitignore vendored
View File

@ -153,4 +153,4 @@ cython_debug/
# PROJECT SPECIFIC
setup_env.sh
admin_info.json
admin_info.json

View File

@ -1,3 +1,3 @@
{
"python.analysis.typeCheckingMode": "off"
}
}

View File

@ -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" ]
CMD [ "python3", "./run.py" ]

View File

@ -8,4 +8,4 @@
## Reporting a Vulnerability
To report a vulnerability, please open an Issue in our issue tracker here on GitHub.
To report a vulnerability, please open an Issue in our issue tracker here on GitHub.

View File

@ -99,4 +99,4 @@ class Announcements:
return False
return True
else:
return False
return False

View File

@ -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']
authpaseto_denylist_enabled: bool = True

View File

@ -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
return created

View File

@ -186,4 +186,4 @@ class Releases:
return payload
else:
raise Exception("Invalid organization.")
raise Exception("Invalid organization.")

View File

@ -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
return None

View File

@ -43,4 +43,4 @@ class AnnouncementDeleted(BaseModel):
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
deleted: bool
deleted: bool

View File

@ -21,4 +21,3 @@ class ClientAuthModel(BaseModel):
id: str
secret: str

View File

@ -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."
message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror."

View File

@ -49,4 +49,4 @@ class MirrorDeletedResponseModel(BaseModel):
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
deleted: bool
key: str
key: str

View File

@ -74,4 +74,4 @@ class ChangelogsResponseFields(BaseModel):
sha: str
author: str
message: str
html_url: str
html_url: str

View File

@ -97,4 +97,13 @@ class ChangelogsResponseModel(BaseModel):
repository: str
path: str
commits: list[ ResponseFields.ChangelogsResponseFields ]
commits: list[ ResponseFields.ChangelogsResponseFields ]
class RevokedTokenResponse(BaseModel):
"""Implements the response fields for token invalidation.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
revoked: bool

View File

@ -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
}
)
)
@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
}
)

View File

@ -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
}
)
)

View File

@ -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'])
return await releases.get_contributors(config['app']['repositories'])

View File

@ -136,4 +136,4 @@ async def delete_mirror(request: Request, response: Response, org: str, repo: st
"error": GeneralErrors.Unauthorized().error,
"message": GeneralErrors.Unauthorized().message
}
)
)

View File

@ -9,4 +9,4 @@ async def ping(request: Request, response: Response) -> None:
Returns:
None
"""
return None
return None

View File

@ -27,4 +27,4 @@ class Generators:
Returns:
int: A timestamp
"""
return int(time.time())
return int(time.time())

View File

@ -29,4 +29,4 @@ class HTTPXClient:
}
)
return httpx_client
return httpx_client

View File

@ -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")

View File

@ -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)
return aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True)

View File

@ -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]

View File

@ -30,4 +30,4 @@ services:
networks:
infra:
external: true
external: true

View File

@ -31,4 +31,4 @@ services:
networks:
infra:
external: true
external: true

View File

@ -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

6
poetry.lock generated
View File

@ -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"},