diff --git a/creyPY/helpers.py b/creyPY/helpers.py new file mode 100644 index 0000000..bfb334b --- /dev/null +++ b/creyPY/helpers.py @@ -0,0 +1,16 @@ +import random +import string + + +def create_random_password(length: int = 12) -> str: + all_characters = string.ascii_letters + string.digits + string.punctuation + + password = [ + random.choice(string.ascii_lowercase), + random.choice(string.ascii_uppercase), + random.choice(string.digits), + random.choice(string.punctuation), + ] + password += random.choices(all_characters, k=length - 4) + random.shuffle(password) + return "".join(password) diff --git a/creyPY/services/auth0/__init__.py b/creyPY/services/auth0/__init__.py new file mode 100644 index 0000000..fb934e5 --- /dev/null +++ b/creyPY/services/auth0/__init__.py @@ -0,0 +1,3 @@ +from .exceptions import * # noqa +from .manage import * # noqa +from .utils import * # noqa diff --git a/creyPY/services/auth0/common.py b/creyPY/services/auth0/common.py new file mode 100644 index 0000000..c15174d --- /dev/null +++ b/creyPY/services/auth0/common.py @@ -0,0 +1,13 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN") +AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID") +AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET") +AUTH0_ALGORIGHM = os.getenv("AUTH0_ALGORIGHM", "RS256") + +AUTH0_AUDIENCE = os.getenv("AUTH0_AUDIENCE") +AUTH0_ISSUER = os.getenv("AUTH0_ISSUER") diff --git a/creyPY/services/auth0/exceptions.py b/creyPY/services/auth0/exceptions.py new file mode 100644 index 0000000..535b674 --- /dev/null +++ b/creyPY/services/auth0/exceptions.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException, status + + +class UnauthorizedException(HTTPException): + def __init__(self, detail: str, **kwargs): + """Returns HTTP 403""" + super().__init__(status.HTTP_403_FORBIDDEN, detail=detail) + + +class UnauthenticatedException(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Requires authentication") diff --git a/creyPY/services/auth0/manage.py b/creyPY/services/auth0/manage.py new file mode 100644 index 0000000..d809859 --- /dev/null +++ b/creyPY/services/auth0/manage.py @@ -0,0 +1,20 @@ +import requests +from cachetools import TTLCache, cached + +from .common import AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_DOMAIN + +cache = TTLCache(maxsize=100, ttl=600) + + +@cached(cache) +def get_management_token() -> str: + re = requests.post( + f"https://{AUTH0_DOMAIN}/oauth/token", + json={ + "client_id": AUTH0_CLIENT_ID, + "client_secret": AUTH0_CLIENT_SECRET, + "audience": f"https://{AUTH0_DOMAIN}/api/v2/", # This should be the management audience + "grant_type": "client_credentials", + }, + ).json() + return re["access_token"] diff --git a/creyPY/services/auth0/utils.py b/creyPY/services/auth0/utils.py new file mode 100644 index 0000000..3cb209e --- /dev/null +++ b/creyPY/services/auth0/utils.py @@ -0,0 +1,131 @@ +from typing import Optional + +import jwt +import requests +from fastapi import HTTPException, Request, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from creyPY.helpers import create_random_password + +from .common import ( + AUTH0_ALGORIGHM, + AUTH0_AUDIENCE, + AUTH0_CLIENT_ID, + AUTH0_DOMAIN, + AUTH0_ISSUER, +) +from .exceptions import UnauthenticatedException, UnauthorizedException +from .manage import get_management_token + +JWKS_CLIENT = jwt.PyJWKClient(f"https://{AUTH0_DOMAIN}/.well-known/jwks.json") + + +async def verify( + request: Request, + token: Optional[HTTPAuthorizationCredentials] = Security(HTTPBearer(auto_error=False)), +) -> str: + if token is None: + raise UnauthenticatedException + + # This gets the 'kid' from the passed token + try: + signing_key = JWKS_CLIENT.get_signing_key_from_jwt(token.credentials).key + except jwt.exceptions.PyJWKClientError as error: + raise UnauthorizedException(str(error)) + except jwt.exceptions.DecodeError as error: + raise UnauthorizedException(str(error)) + + try: + payload = jwt.decode( + token.credentials, + signing_key, + algorithms=[AUTH0_ALGORIGHM], + audience=AUTH0_AUDIENCE, + issuer=AUTH0_ISSUER, + ) + except Exception as error: + raise UnauthorizedException(str(error)) + + return payload["sub"] + + +### GENERIC AUTH0 CALLS ### +def get_user(sub) -> dict: + re = requests.get( + f"https://{AUTH0_DOMAIN}/api/v2/users/{sub}", + headers={"Authorization": f"Bearer {get_management_token()}"}, + ) + if re.status_code != 200: + raise HTTPException(re.status_code, re.json()) + return re.json() + + +def patch_user(input_obj: dict, sub) -> dict: + re = requests.patch( + f"https://{AUTH0_DOMAIN}/api/v2/users/{sub}", + headers={"Authorization": f"Bearer {get_management_token()}"}, + json=input_obj, + ) + if re.status_code != 200: + raise HTTPException(re.status_code, re.json()) + return re.json() + + +### USER METADATA CALLS ### +def get_user_metadata(sub) -> dict: + try: + return get_user(sub).get("user_metadata", {}) + except: + return {} + + +def patch_user_metadata(input_obj: dict, sub) -> dict: + return patch_user({"user_metadata": input_obj}, sub) + + +def clear_user_metadata(sub) -> dict: + return patch_user({"user_metadata": {}}, sub) + + +def request_verification_mail(sub: str) -> None: + re = requests.post( + f"https://{AUTH0_DOMAIN}/api/v2/jobs/verification-email", + headers={"Authorization": f"Bearer {get_management_token()}"}, + json={"user_id": sub}, + ) + if re.status_code != 201: + raise HTTPException(re.status_code, re.json()) + return re.json() + + +def create_user_invite(email: str) -> dict: + re = requests.post( + f"https://{AUTH0_DOMAIN}/api/v2/users", + headers={"Authorization": f"Bearer {get_management_token()}"}, + json={ + "email": email, + "connection": "Username-Password-Authentication", + "password": create_random_password(), + "verify_email": False, + "app_metadata": {"invitedToMyApp": True}, + }, + ) + if re.status_code != 201: + raise HTTPException(re.status_code, re.json()) + return re.json() + + +def password_change_mail(email: str) -> bool: + re = requests.post( + f"https://{AUTH0_DOMAIN}/dbconnections/change_password", + headers={"Authorization": f"Bearer {get_management_token()}"}, + json={ + "client_id": AUTH0_CLIENT_ID, + "email": email, + "connection": "Username-Password-Authentication", + }, + ) + + if re.status_code != 200: + raise HTTPException(re.status_code, re.json()) + return True diff --git a/requirements.auth0.txt b/requirements.auth0.txt new file mode 100644 index 0000000..2175c1a --- /dev/null +++ b/requirements.auth0.txt @@ -0,0 +1,7 @@ +cachetools==5.5.0 # for caching +charset-normalizer==3.4.0 # Auth0 API interactions +requests==2.32.3 # Auth0 API interactions +pyjwt==2.10.0 # Auth0 API interactions +cffi==1.17.1 # Auth0 API interactions +cryptography==43.0.3 # Auth0 API interactions +pycparser==2.22 # Auth0 API interactions diff --git a/setup.py b/setup.py index 8dd4e9c..f9539c9 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,9 @@ with open("requirements.build.txt") as f: with open("requirements.pg.txt") as f: pg_requirements = f.read().splitlines() +with open("requirements.auth0.txt") as f: + auth0_requirements = f.read().splitlines() + def get_latest_git_tag() -> str: try: @@ -39,7 +42,11 @@ setup( license="MIT", python_requires=">=3.12", install_requires=requirements, - extras_require={"build": build_requirements, "postgres": pg_requirements}, + extras_require={ + "build": build_requirements, + "postgres": pg_requirements, + "auth0": auth0_requirements, + }, keywords=[ "creyPY", "Python",