diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44b72b..ed6657a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,12 +40,15 @@ jobs: with: python-version: '3.12' - run: python -m pip install --upgrade pip - - run: python -m pip install -r requirements.txt + - run: | + python -m pip install -r requirements.txt + python -m pip install -r requirements.pg.txt + python -m pip install -r requirements.auth0.txt - run: python test.py tag_and_publish: runs-on: ubuntu-latest - if: github.ref_name == 'master' || github.ref_name == 'dev' + if: (github.ref_name == 'master' || github.ref_name == 'dev') && github.event_name == 'push' needs: test permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing diff --git a/README.md b/README.md index 0b1ac62..335a9ae 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,3 @@ from creyPY.const import LanguageEnum print(LanguageEnum.EN) # Output: LanguageEnum.EN print(LanguageEnum.EN.value) # Output: English ``` - -## TODO - -- Add async support for database connection -- Add version without postgresql dependency diff --git a/creyPY/fastapi/db/__init__.py b/creyPY/fastapi/db/__init__.py index 395efc9..94b32a2 100644 --- a/creyPY/fastapi/db/__init__.py +++ b/creyPY/fastapi/db/__init__.py @@ -1,2 +1,3 @@ +from .async_session import * # noqa +from .helpers import * # noqa from .session import * # noqa -from .async_session import * # noqa diff --git a/creyPY/fastapi/db/async_session.py b/creyPY/fastapi/db/async_session.py index 07a0e61..f0186ca 100644 --- a/creyPY/fastapi/db/async_session.py +++ b/creyPY/fastapi/db/async_session.py @@ -1,22 +1,14 @@ -import os from typing import AsyncGenerator -from dotenv import load_dotenv + from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker +from .common import SQLALCHEMY_DATABASE_URL, name -load_dotenv() - -host = os.getenv("POSTGRES_HOST", "localhost") -user = os.getenv("POSTGRES_USER", "postgres") -password = os.getenv("POSTGRES_PASSWORD", "root") -port = os.getenv("POSTGRES_PORT", "5432") -name = os.getenv("POSTGRES_DB", "fastapi") - -SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/" - +async_engine = create_async_engine( + SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"} +) -async_engine = create_async_engine(SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True) AsyncSessionLocal = sessionmaker( bind=async_engine, class_=AsyncSession, diff --git a/creyPY/fastapi/db/common.py b/creyPY/fastapi/db/common.py new file mode 100644 index 0000000..67c43dd --- /dev/null +++ b/creyPY/fastapi/db/common.py @@ -0,0 +1,13 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +host = os.getenv("POSTGRES_HOST", "localhost") +user = os.getenv("POSTGRES_USER", "postgres") +password = os.getenv("POSTGRES_PASSWORD", "root") +port = os.getenv("POSTGRES_PORT", "5432") +name = os.getenv("POSTGRES_DB", "fastapi") + +SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/" diff --git a/creyPY/fastapi/db/helpers.py b/creyPY/fastapi/db/helpers.py new file mode 100644 index 0000000..864ec4b --- /dev/null +++ b/creyPY/fastapi/db/helpers.py @@ -0,0 +1,8 @@ +from sqlalchemy_utils import create_database, database_exists + + +def create_if_not_exists(db_name: str): + from .common import SQLALCHEMY_DATABASE_URL + + if not database_exists(SQLALCHEMY_DATABASE_URL + db_name): + create_database(SQLALCHEMY_DATABASE_URL + db_name) diff --git a/creyPY/fastapi/db/session.py b/creyPY/fastapi/db/session.py index 213e56b..5ae5c6d 100644 --- a/creyPY/fastapi/db/session.py +++ b/creyPY/fastapi/db/session.py @@ -1,23 +1,14 @@ -import os from typing import Generator -from dotenv import load_dotenv from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.orm.session import Session -load_dotenv() +from .common import SQLALCHEMY_DATABASE_URL, name -host = os.getenv("POSTGRES_HOST", "localhost") -user = os.getenv("POSTGRES_USER", "postgres") -password = os.getenv("POSTGRES_PASSWORD", "root") -port = os.getenv("POSTGRES_PORT", "5432") -name = os.getenv("POSTGRES_DB", "fastapi") - -SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/" - - -engine = create_engine(SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True) +engine = create_engine( + SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"} +) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/creyPY/fastapi/testing_async.py b/creyPY/fastapi/testing_async.py index 91c835e..836e3f1 100644 --- a/creyPY/fastapi/testing_async.py +++ b/creyPY/fastapi/testing_async.py @@ -1,11 +1,13 @@ import json -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient class AsyncGenericClient: - def __init__(self, app): - self.c = AsyncClient(app=app, base_url="http://testserver", follow_redirects=True) - self.default_headers = {} + def __init__(self, app, headers={}): + self.c = AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver", follow_redirects=True + ) + self.default_headers = headers async def get(self, url: str, r_code: int = 200, parse_json=True): re = await self.c.get(url, headers=self.default_headers) @@ -33,7 +35,8 @@ async def post( ) if re.status_code != r_code: print(re.content) - assert r_code == re.status_code + if not raw_response: + assert r_code == re.status_code return re.json() if not raw_response else re async def post_file( diff --git a/creyPY/helpers.py b/creyPY/helpers.py new file mode 100644 index 0000000..7773e98 --- /dev/null +++ b/creyPY/helpers.py @@ -0,0 +1,16 @@ +import secrets +import string + + +def create_random_password(length: int = 12) -> str: + all_characters = string.ascii_letters + string.digits + string.punctuation + + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice(string.punctuation), + ] + password += [secrets.choice(all_characters) for _ in range(length - 4)] + secrets.SystemRandom().shuffle(password) + return "".join(password) diff --git a/creyPY/services/__init__.py b/creyPY/services/__init__.py new file mode 100644 index 0000000..a2ec6fe --- /dev/null +++ b/creyPY/services/__init__.py @@ -0,0 +1 @@ +from .auth0 import * # noqa 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..28682fb --- /dev/null +++ b/creyPY/services/auth0/manage.py @@ -0,0 +1,21 @@ +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: + response = 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", + }, + timeout=5, # Add a timeout parameter to avoid hanging requests + ).json() + return response["access_token"] diff --git a/creyPY/services/auth0/utils.py b/creyPY/services/auth0/utils.py new file mode 100644 index 0000000..917ff4b --- /dev/null +++ b/creyPY/services/auth0/utils.py @@ -0,0 +1,136 @@ +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()}"}, + timeout=5, + ) + 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, + timeout=5, + ) + 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}, + timeout=5, + ) + 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}, + }, + timeout=5, + ) + 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", + }, + timeout=5, + ) + + if re.status_code != 200: + raise HTTPException(re.status_code, re.json()) + return True diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..88b0152 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommitTypeAll(feat)" + ] +} diff --git a/requirements.auth0.txt b/requirements.auth0.txt new file mode 100644 index 0000000..7de9f5b --- /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.1 # 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/requirements.build.txt b/requirements.build.txt index 9353eba..270aabd 100644 --- a/requirements.build.txt +++ b/requirements.build.txt @@ -23,5 +23,3 @@ twine>=5.0.0 urllib3>=2.2.1 wheel>=0.43.0 zipp>=3.18.1 - --r requirements.txt diff --git a/requirements.pg.txt b/requirements.pg.txt new file mode 100644 index 0000000..e205e3e --- /dev/null +++ b/requirements.pg.txt @@ -0,0 +1,5 @@ +psycopg>=3.2.1 # PostgreSQL +psycopg-binary>=3.2.1 # PostgreSQL +psycopg-pool>=3.2.2 # PostgreSQL +asyncpg>=0.30.0 # SQLAlchemy +greenlet>=3.1.1 # Async diff --git a/requirements.txt b/requirements.txt index a03bdfe..383fca6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,16 +11,10 @@ starlette>=0.37.2 # FastAPI fastapi-pagination>=0.12.26 # Pagination sqlalchemy>=2.0.31 # SQLAlchemy +sqlalchemy-utils>=0.41.2 # For managing databases python-dotenv>=1.0.1 # Environment variables -psycopg>=3.2.1 # PostgreSQL -psycopg-binary>=3.2.1 # PostgreSQL -psycopg-pool>=3.2.2 # PostgreSQL - h11>=0.14.0 # Testing httpcore>=1.0.5 # Testing httpx>=0.27.0 # Testing - -asyncpg>=0.30.0 #SQLAlchemy -greenlet>=3.1.1 #Async diff --git a/setup.py b/setup.py index 6cd69b9..f15a71c 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,15 @@ with open("requirements.txt") as f: requirements = f.read().splitlines() +with open("requirements.build.txt") as f: + build_requirements = f.read().splitlines() + +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: @@ -33,6 +42,12 @@ def get_latest_git_tag() -> str: license="MIT", python_requires=">=3.12", install_requires=requirements, + extras_require={ + "build": build_requirements, + "postgres": pg_requirements, + "auth0": auth0_requirements, + "all": build_requirements + pg_requirements + auth0_requirements, + }, keywords=[ "creyPY", "Python", @@ -40,7 +55,6 @@ def get_latest_git_tag() -> str: "shortcuts", "snippets", "utils", - "personal library", ], platforms="any", )