fix: fix token revogation

This commit is contained in:
Alexandre Teles 2022-10-17 16:57:29 -03:00
parent 07800c4d62
commit 2d3e62addf
33 changed files with 101 additions and 80 deletions

View File

@ -1,10 +1,13 @@
from datetime import timedelta
import os import os
import toml import toml
from datetime import timedelta
from pydantic import BaseModel from pydantic import BaseModel
from fastapi_paseto_auth import AuthPASETO
config: dict = toml.load("config.toml") config: dict = toml.load("config.toml")
class PasetoSettings(BaseModel): class PasetoSettings(BaseModel):
authpaseto_secret_key: str = os.environ['SECRET_KEY'] authpaseto_secret_key: str = os.environ['SECRET_KEY']
authpaseto_access_token_expires: int | bool = config['auth']['access_token_expires'] 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 banned: bool = False
try: 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) await self.UserLogger.log("BAN_TOKEN", None, token)
banned = True banned = True
except aioredis.RedisError as e: except aioredis.RedisError as e:
@ -275,50 +281,23 @@ class Clients:
return banned 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: async def auth_checks(self, client_id: str, token: str) -> bool:
"""Check if a client exists, is active and the token isn't banned """Check if a client exists, is active and the token isn't banned
Args: Args:
client_id (str): UUID of the client client_id (str): UUID of the client
secret (str): Secret of the client secret (str): Secret of the client
token (str): Token JTI
Returns: Returns:
bool: True if the client exists, is active bool: True if the client exists, is active
and the token isn't banned, False otherwise and the token isn't banned, False otherwise
""" """
if await self.exists(client_id): if await self.exists(client_id) and await self.is_active(client_id):
if await self.is_active(client_id):
if not await self.is_token_banned(token):
return True return True
else: else:
return False if not await self.redis_tokens.exists(token):
else:
if not await self.is_token_banned(token):
await self.ban_token(token)
return False
else:
await self.ban_token(token) await self.ban_token(token)
return False return False

View File

@ -3,6 +3,7 @@
import os import os
import toml import toml
import binascii import binascii
from redis import Redis
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse, UJSONResponse from fastapi.responses import JSONResponse, UJSONResponse
@ -21,6 +22,7 @@ from fastapi_paseto_auth.exceptions import AuthPASETOException
import app.controllers.Auth as Auth import app.controllers.Auth as Auth
from app.controllers.Clients import Clients from app.controllers.Clients import Clients
from app.utils.RedisConnector import RedisConnector from app.utils.RedisConnector import RedisConnector
import app.models.GeneralErrors as GeneralErrors import app.models.GeneralErrors as GeneralErrors
@ -100,6 +102,15 @@ def get_config() -> Auth.PasetoSettings:
""" """
return 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 # Setup custom error handlers
@app.exception_handler(AuthPASETOException) @app.exception_handler(AuthPASETOException)

View File

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

View File

@ -98,3 +98,12 @@ class ChangelogsResponseModel(BaseModel):
repository: str repository: str
path: 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.GeneralErrors as GeneralErrors
import app.models.ResponseModels as ResponseModels import app.models.ResponseModels as ResponseModels
router = APIRouter() router = APIRouter(
prefix="/auth",
tags=['Authentication']
)
clients = Clients() clients = Clients()
config: dict = load_config() 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: async def auth(request: Request, response: Response, client: ClientModels.ClientAuthModel, Authorize: AuthPASETO = Depends()) -> dict:
"""Authenticate a client and get an auth token. """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] 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) authenticated: bool = await clients.authenticate(client.id, client.secret)
if not authenticated: if not authenticated:
@ -46,3 +49,16 @@ async def auth(request: Request, response: Response, client: ClientModels.Client
"message": GeneralErrors.Unauthorized().message "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: async def client_status(request: Request, response: Response, client_id: str, active: bool, Authorize: AuthPASETO = Depends()) -> dict:
"""Activate or deactivate a client """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(): if 'admin' in Authorize.get_token_payload():
admin_claim = {"admin": Authorize.get_token_payload()['admin']} 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 if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and
( admin_claim['admin'] == True or ( admin_claim['admin'] == True or
current_user == client_id ) ): current_user == client_id ) ):
print("client exists")
if await clients.exists(client_id): if await clients.exists(client_id):
if await clients.status(client_id, active): if await clients.status(client_id, active):
return {"id": client_id, "active": active} return {"id": client_id, "active": active}
@ -159,6 +159,7 @@ async def client_status(request: Request, response: Response, client_id: str, ac
} }
) )
else: else:
print("Client does not exist")
raise HTTPException(status_code=404, detail={ raise HTTPException(status_code=404, detail={
"error": GeneralErrors.ClientNotFound().error, "error": GeneralErrors.ClientNotFound().error,
"message": GeneralErrors.ClientNotFound().message "message": GeneralErrors.ClientNotFound().message

View File

@ -60,9 +60,9 @@ class AnnouncementsLogger:
key (str): Key used in the operation key (str): Key used in the operation
""" """
if type(result) is RedisError: 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: else:
logger.info(f"[User] REDIS {operation} {key} - OK") logger.info(f"[ANNOUNCEMENT] REDIS {operation} {key} - OK")
class MirrorsLogger: class MirrorsLogger:
async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None: 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 key (str): Key used in the operation
""" """
if type(result) is RedisError: 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: else:
logger.info(f"[User] REDIS {operation} {key} - OK") logger.info(f"[MIRRORS] REDIS {operation} {key} - OK")

View File

@ -13,17 +13,16 @@ description = """
* Rate Limiting - 60 requests per minute * Rate Limiting - 60 requests per minute
* Cache - 5 minutes * Cache - 5 minutes
* Token duration - 1 hour * Token duration - 1 year
* Token refresh - 30 days
### Additional Notes ### Additional Notes
1. Breaking changes are to be expected 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 3. Abuse of the API will result in IP blocks
""" """
version = "0.9 RC2" version = "1.0.0"
[license] [license]

View File

@ -72,3 +72,7 @@ ignore_missing_imports = True
[mypy-gunicorn.*] [mypy-gunicorn.*]
# No stubs available # No stubs available
ignore_missing_imports = True 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]] [[package]]
name = "types-redis" name = "types-redis"
version = "4.3.21.1" version = "4.3.21.2"
description = "Typing stubs for redis" description = "Typing stubs for redis"
category = "dev" category = "dev"
optional = false optional = false
@ -1478,8 +1478,8 @@ toolz = [
{file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"},
] ]
types-redis = [ types-redis = [
{file = "types-redis-4.3.21.1.tar.gz", hash = "sha256:493814829643fc04a14595eda6ccd69bdc0606477541ccda54238ce3f60bc993"}, {file = "types-redis-4.3.21.2.tar.gz", hash = "sha256:ab542249a47d3903b94162e6395ae6be0cc0f562fd184ed51a0a34d8a7021a39"},
{file = "types_redis-4.3.21.1-py3-none-any.whl", hash = "sha256:65b8c842f406932218f8ce636f75e5a03cb6b382d3922cb3e5f87e127e6d434d"}, {file = "types_redis-4.3.21.2-py3-none-any.whl", hash = "sha256:eda5fd9e80f453143902db5547ca6506a3af44476ad080db7d18840da532ab19"},
] ]
types-toml = [ types-toml = [
{file = "types-toml-0.10.8.tar.gz", hash = "sha256:b7e7ea572308b1030dc86c3ba825c5210814c2825612ec679eb7814f8dd9295a"}, {file = "types-toml-0.10.8.tar.gz", hash = "sha256:b7e7ea572308b1030dc86c3ba825c5210814c2825612ec679eb7814f8dd9295a"},