diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cac839 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +dist/ +services/__pycache__/ +services/*.py[cod] +tests/__pycache__/ +tests/*.py[cod] +routes/__pycache__/ diff --git a/README.md b/README.md index 744c051..caef8f5 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# auth-core-flask \ No newline at end of file +# Flycatch Auth + +Flycatch Auth is a Python authentication package that provides JWT-based authentication with grant-based access control for both Flask and FastAPI applications. + +## Features + +- **JWT authentication**:Supports access and refresh tokens with customizable expiry times. + +- **Grant-based access control** + +- **Works with Flask and FastAPI** + +- **Simple integration with existing user services** + +## Installation + +Install flycatch_auth using pip: + +```bash +pip install flycatch-auth +``` + +## Usage + +```python + +from flask import Flask, request, jsonify +from flycatch_auth import auth, AuthCoreJwtConfig + +app = Flask(__name__) + +class Userservice(IdentityService): + def load_user(self,username: str) -> Identity: + user = user_db.get_users() + return { + "id": "", + "username": "", + "password": "", + "grants": [list(map(lambda user: user.permission.name, users))] # read_user + } + +jwt_config = AuthCoreJwtConfig( + enable=True, + secret="mysecret", + expiresIn="2h", + refresh=True, + prefix="/auth/jwt", +) + +auth.init_app( + app=app, + user_service=MockUserService(), + credential_checker=lambda input, user: input == user, + jwt=jwt_config, +) + + +@app.route("/me") +@auth.verify() +def get_curr_user(): + return jsonify({"message": "Access granted"}), 200 + +if __name__ == "__main__": + app.run(debug=True) +``` + +This snippet demonstrates how to configure and initialize Auth using MockUserService and JWT-based authentication + +## Configuration Options + +### JWT Configuration + +```python +jwt_config = { + "enabled": True, + "secret": "your-jwt-secret", + "expiresIn": "8h", # Expiry time for JWT + "refreshToken": True, # Enable refresh tokens + "prefix": "/auth/jwt", # Prefix for JWT-related routes +} +``` +### User Service + +```python +class UserService(IdentityService): + def load_user(self, username: str)-> Identity: + # users = users.get_db() + # your logic for implemention + return { + "id": "1", + "username": "testuser", + "password": "password123", + "grants": ["read_user"], + } +``` + +### Password Checker + +```python +credential_checker=lambda input, user: input == user["password"], +``` + +## License + +This project is licensed under the GPL-3.0 License. + +--- + +For more details and advanced use cases, visit the [GitHub repository](#) or contact the project maintainers. + +``` + +``` diff --git a/flycatch_auth/__init__.py b/flycatch_auth/__init__.py new file mode 100644 index 0000000..12a7a5a --- /dev/null +++ b/flycatch_auth/__init__.py @@ -0,0 +1,8 @@ +from .auth import AuthCore +from .model_types import Identity, IdentityService, CredentialChecker +from .services import AuthCoreJwtConfig + +auth = AuthCore() + +# It enables top-level imports like `from flycatch_auth import auth, AuthCoreJwtConfig, JwtService` +__all__ = ["auth", "Identity", "IdentityService", "AuthCoreJwtConfig","CredentialChecker"] \ No newline at end of file diff --git a/flycatch_auth/auth.py b/flycatch_auth/auth.py new file mode 100644 index 0000000..318220f --- /dev/null +++ b/flycatch_auth/auth.py @@ -0,0 +1,53 @@ +import logging +# from flask_session import Session +# from authlib.integrations.flask_client import OAuth +from .model_types import IdentityService, CredentialChecker +from .services import AuthCoreJwtConfig, JwtAuthService +from .routes import create_jwt_routes +from .middleware import verify_request +logger = logging.getLogger("auth_core") + + +class AuthCore: + def __init__(self): + self.app = None + self.jwt = None + self.session = None + self.user_service = None + self.credential_checker = None + self.auth_service = None + + def init_app( + self, + app, + user_service: IdentityService, + credential_checker: CredentialChecker, + jwt: AuthCoreJwtConfig, + ): + """Initialize authentication with JWT if enabled.""" + self.app = app + self.jwt = jwt + self.user_service = user_service + self.credential_checker = credential_checker + + if jwt and jwt.get("enable"): + # Initialize JWT authentication service + self.auth_service = JwtAuthService( + user_service, credential_checker, jwt) + + # Set up JWT authentication routes + create_jwt_routes(app, jwt, user_service, credential_checker) + + # if session and session.enable: + # # Initialize session-based authentication service + # self.auth_service = SessionAuthService( + # user_service, credential_checker, session) + + return self.auth_service + + def verify(self): + """Middleware to verify authentication.""" + return verify_request(self.auth_service) + + +auth = AuthCore() diff --git a/flycatch_auth/middleware.py b/flycatch_auth/middleware.py new file mode 100644 index 0000000..f0fec84 --- /dev/null +++ b/flycatch_auth/middleware.py @@ -0,0 +1,41 @@ +import jwt +import logging +from functools import wraps +from flask import request, jsonify +from .model_types import api_response +logger = logging.getLogger("auth_core") + + +def verify_request(auth_service): + """Middleware function to verify authentication.""" + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + auth_header = request.headers.get("Authorization") + + if not auth_header: + logger.warning( + "Unauthorized access attempt: Missing Authorization header") + return api_response(401, "Unauthorized access attempt",False) + if auth_service.jwt_config.get("enable"): + try: + token = auth_header.split(" ")[1] # Extract the token + decoded_token = jwt.decode( + token, auth_service.jwt_config.get('secret'), algorithms=["HS256"]) + + logger.info( + f"User {decoded_token['username']} authenticated successfully") + request.user = decoded_token # Attach user info to request + return f(*args, **kwargs) + + except jwt.ExpiredSignatureError: + logger.warning("Token expired") + return api_response(403, "Token expired", False) + except jwt.InvalidTokenError: + logger.warning("Invalid token") + return api_response(401, "Invalid token", False) + + return wrapper + + return decorator diff --git a/flycatch_auth/model_types.py b/flycatch_auth/model_types.py new file mode 100644 index 0000000..b856f54 --- /dev/null +++ b/flycatch_auth/model_types.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod +from typing import List, Optional, TypedDict +from datetime import datetime + + +class Identity: + """Manage user authentication and authorization.""" + + def __init__(self, user_id: str, username: str, grants: List[str]): + self.id = user_id + self.username = username + self.grants = grants + + def has_grant(self, grant: str) -> bool: + """Check if the identity has a specific grant.""" + return grant in self.grants + + +class IdentityService(ABC): + @abstractmethod + def load_user(self, username: str) -> Optional[Identity]: + """Override this method to fetch a user from DB.""" + pass + + +class UserType(TypedDict): + id: str + username: str + password: str + + +def api_response(code: int, message: str, status: str, data=None): + """Format API response.""" + return { + "code": code, + "timestamp": datetime.utcnow().isoformat() + "Z", + "message": message, + "status": status, + "data": data or {}, + } + + +class CredentialChecker: + def __init__(self): + pass + + def verify(self, input_password: str, user_password: str) -> bool: + """Checks if the provided password matches the stored password.""" + return input_password == user_password + + +class SessionConfig(TypedDict, total=False): + session_key: str + session_expiration: int + enabled: bool + prefix: str + secret: str + resave: bool + saveUninitialized: bool + cookie: dict + + +class AuthCoreJwtConfig(TypedDict, total=False): + enable: bool + secret: str + expiresIn: str + refresh: bool + prefix: str diff --git a/flycatch_auth/routes/__init__.py b/flycatch_auth/routes/__init__.py new file mode 100644 index 0000000..1f9185c --- /dev/null +++ b/flycatch_auth/routes/__init__.py @@ -0,0 +1,3 @@ +from .jwt_routes import create_jwt_routes + +__all__ = ["create_jwt_routes"] \ No newline at end of file diff --git a/flycatch_auth/routes/jwt_routes.py b/flycatch_auth/routes/jwt_routes.py new file mode 100644 index 0000000..b78a2c0 --- /dev/null +++ b/flycatch_auth/routes/jwt_routes.py @@ -0,0 +1,36 @@ +from flask import Blueprint, request, jsonify +import logging +from ..services import JwtAuthService + + +logger = logging.getLogger(__name__) + + +def create_jwt_routes(app, config, user_service, credential_checker): + router = Blueprint("jwt_auth", __name__) + prefix = config.get("prefix") or "/auth/jwt" + + jwt_auth = JwtAuthService(user_service, credential_checker, config) + + @router.route(f"{prefix}/login", methods=["POST"]) + def login(): + """Login route to generate access and refresh tokens""" + data = request.json + username, password = data.get("username"), data.get("password") + return jsonify(jwt_auth.login(username=username, password=password)) + + @router.route(f"{prefix}/refresh", methods=["POST"]) + def refresh(): + """Refresh access token using a valid refresh token""" + auth_header = request.headers.get("Authorization") + logger.info("Refresh token attempt received") + + if not auth_header: + logger.warning("Refresh token missing in request") + return jsonify({"error": "Refresh token is required"}), 400 + + refresh_token = auth_header.split(" ")[1] + response = jwt_auth.refresh(refresh_token) + return jsonify(response), 200 if "access_token" in response else 403 + + app.register_blueprint(router) diff --git a/flycatch_auth/services/__init__.py b/flycatch_auth/services/__init__.py new file mode 100644 index 0000000..09bb482 --- /dev/null +++ b/flycatch_auth/services/__init__.py @@ -0,0 +1,5 @@ +from .base import AuthService +from .jwt_services import JwtAuthService, AuthCoreJwtConfig +# from .session_service import SessionAuthService # Uncomment when session auth is implemented + +__all__ = ["AuthService", "JwtAuthService","AuthCoreJwtConfig"] diff --git a/flycatch_auth/services/base.py b/flycatch_auth/services/base.py new file mode 100644 index 0000000..ff7cbc9 --- /dev/null +++ b/flycatch_auth/services/base.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from typing import Optional, Dict + + +class AuthService(ABC): + """Abstract base class for authentication services.""" + + @abstractmethod + def authenticate(self, username: str, password: str) -> Optional[Dict]: + """Authenticate a user and return user data if successful.""" + raise NotImplementedError + + @abstractmethod + def login(self, username: str, password: str) -> Dict: + """Handle user login and return authentication tokens or session details.""" + raise NotImplementedError + + @abstractmethod + def refresh(self, refresh_token: str) -> Dict: + """Refresh authentication tokens (if applicable).""" + raise NotImplementedError + + + + diff --git a/flycatch_auth/services/fastapi_auth.py b/flycatch_auth/services/fastapi_auth.py new file mode 100644 index 0000000..817458d --- /dev/null +++ b/flycatch_auth/services/fastapi_auth.py @@ -0,0 +1,33 @@ +from fastapi import Depends, HTTPException, Security +from fastapi.security import HTTPBearer +from starlette.requests import Request + +security = HTTPBearer() + + +class FastAPIAuth: + def __init__(self, user_service, credential_checker, jwt): + self.user_service = user_service + self.credential_checker = credential_checker + self.jwt = jwt + + async def fastapi_verify(self, request: Request, token: str = Security(security)): + """Verify JWT token in FastAPI""" + if not token.credentials or not self.jwt.verify_token(token.credentials): + raise HTTPException(status_code=401, detail="Unauthorized") + return self.jwt.verify_token(token.credentials) # Return user payload + + def fastapi_has_grants(self, required_grants): + """FastAPI dependency for grant-based access control""" + async def dependency(token: str = Security(security)): + payload = self.jwt.verify_token(token.credentials) + if not payload: + raise HTTPException(status_code=401, detail="Unauthorized") + + user_grants = payload.get("grants", []) + if not all(grant in user_grants for grant in required_grants): + raise HTTPException(status_code=403, detail="Forbidden") + + return payload # Return user payload for further use in FastAPI routes + + return dependency diff --git a/flycatch_auth/services/jwt_services.py b/flycatch_auth/services/jwt_services.py new file mode 100644 index 0000000..6c9083f --- /dev/null +++ b/flycatch_auth/services/jwt_services.py @@ -0,0 +1,78 @@ +import jwt +import datetime +from typing import Optional, Dict +from .base import AuthService +from flycatch_auth.model_types import IdentityService, AuthCoreJwtConfig, api_response + + +class JwtAuthService(AuthService): + """JWT-based authentication service.""" + + def __init__(self, user_service: IdentityService, credential_checker, jwt_config: AuthCoreJwtConfig): + self.user_service = user_service + self.credential_checker = credential_checker + self.jwt_config = jwt_config + + def authenticate(self, username: str, password: str) -> Optional[Dict]: + """Authenticate user credentials.""" + user = self.user_service.load_user(username) + if user and self.credential_checker(password, user["password"]): + return user + return None + + def generate_token(self, user, token_type="access") -> str: + """Generate JWT access or refresh token.""" + expiration = ( + datetime.datetime.utcnow() + datetime.timedelta(hours=8) + if token_type == "access" + else datetime.datetime.utcnow() + datetime.timedelta(days=7) + ) + + payload = { + "id": user["id"], + "username": user["username"], + "type": token_type, + "exp": expiration, + } + return jwt.encode(payload, self.jwt_config.get('secret'), algorithm="HS256") + + def decode_token(self, token: str) -> Optional[dict]: + """Decode JWT token and return user data""" + try: + return jwt.decode(token, self.secret, algorithms=["HS256"]) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + def verify_token(self, token: str) -> bool: + """Verify JWT token""" + return self.decode_token(token) is not None + + def verify_refresh_token(self, token: str) -> bool: + """Verify JWT refresh token""" + return self.decode_token(token) is not None + + def login(self, username: str, password: str) -> Dict: + """Authenticate and return JWT access & refresh tokens.""" + user = self.authenticate(username, password) + if user: + return api_response(200, "Login successful", True, { + "access_token": self.generate_token(user, "access"), + "refresh_token": self.generate_token(user, "refresh"), + }) + return api_response(401, "Invalid credentials", False) + + def refresh(self, refresh_token: str) -> Dict: + """Refresh JWT access token.""" + try: + decoded_token = jwt.decode( + refresh_token, self.jwt_config.get('secret'), algorithms=["HS256"]) + if decoded_token.get("type") != "refresh": + return {"error": "Invalid token type"} + + user = {"id": decoded_token["id"], + "username": decoded_token["username"]} + return api_response(200, "Token refreshed", True, {"access_token": self.generate_token(user, "access"), "refresh_token": self.generate_token(user, "refresh")}) + except jwt.ExpiredSignatureError: + return api_response(403, "Refresh token expired", False) + except jwt.InvalidTokenError: + return api_response(403, "Invalid refresh token", False) diff --git a/flycatch_auth/services/session_service.py b/flycatch_auth/services/session_service.py new file mode 100644 index 0000000..654c3e6 --- /dev/null +++ b/flycatch_auth/services/session_service.py @@ -0,0 +1,18 @@ +from typing import Dict, Optional +from .base import AuthService + + +class SessionAuthService(AuthService): + """Session-based authentication service (to be implemented).""" + + def authenticate(self, username: str, password: str) -> Optional[Dict]: + """Authenticate user using session management.""" + pass + + def login(self, username: str, password: str) -> Dict: + """Handle user login via session.""" + pass + + def refresh(self, refresh_token: str) -> Dict: + """Refresh session authentication (if applicable).""" + pass diff --git a/hello.py b/hello.py new file mode 100644 index 0000000..d52e1a1 --- /dev/null +++ b/hello.py @@ -0,0 +1,43 @@ +from flask import Flask, request, jsonify +from flycatch_auth import auth, AuthCoreJwtConfig, IdentityService, Identity + +app = Flask(__name__) + + +class Userservice(IdentityService): + def load_user(self, username: str) -> Identity: + # user = user_db.get_users() + return { + "id": "1", + "username": "testuser", + "password": "password123", + # read_user + # "grants": [list(map(lambda user: user.permission.name, users))] + "grants": ["read_user"] + } + + +jwt_config = AuthCoreJwtConfig( + enable=True, + secret="mysecret", + expiresIn="2h", + refresh=True, + prefix="/auth/jwt", +) + +auth.init_app( + app=app, + user_service=Userservice(), + credential_checker=lambda input, user: input == user, + jwt=jwt_config, +) + + +@app.route("/me") +# @auth.verify() +def get_curr_user(): + return jsonify({"message": "Access granted"}), 200 + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..f6da45e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,267 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "flask" +version = "3.1.0" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136"}, + {file = "flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac"}, +] + +[package.dependencies] +blinker = ">=1.9" +click = ">=8.1.3" +itsdangerous = ">=2.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.1" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.5" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "6ef42315c2ffd19d45577bbefb1bee3f01e9961e05ef0fcdd6f86b7cb88555da" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b4491c9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "flycatch_auth" +version = "0.1.0" +description = "A Python authentication package for Flask and FastAPI" +authors = ["flycatch_auth"] +packages = [{include = "flycatch_auth"}] # This ensures Poetry detects the package + + + +[tool.poetry.dependencies] +python = "^3.12" +flask = "^3.1.0" +pyjwt = "^2.10.1" + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.4" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3126821 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,99 @@ +import pytest +from flask import Flask, request, jsonify +from flycatch_auth import auth, AuthCoreJwtConfig, IdentityService, Identity + + +class MockUserService(IdentityService): + def load_user(self, username: str) -> Identity: + return { + "id": "1", + "username": "testuser", + "password": "password123", + "grants": ["read_user"], + } + + +@pytest.fixture +def app(): + app = Flask(__name__) + app.config["TESTING"] = True + + jwt_config = AuthCoreJwtConfig( + enable=True, + secret="mysecret", + expiresIn="2h", + refresh=True, + prefix="/auth/jwt", + ) + + auth.init_app( + app=app, + user_service=MockUserService(), + credential_checker=lambda input, user: input == user, + jwt=jwt_config, + ) + + @app.post("/auth/jwt/login") + def login(): + username = request.json.get("username") + password = request.json.get("password") + user = auth.authenticate(username, password) + if user: + tokens = auth.generate_tokens(user) + return jsonify(tokens), 200 + return jsonify({"message": "Invalid credentials"}), 401 + + @app.post("/auth/jwt/refresh") + def refresh(): + refresh_token = request.json.get("refresh_token") + new_token = auth.refresh_access_token(refresh_token) + if new_token: + return jsonify({"access_token": new_token}), 200 + return jsonify({"message": "Invalid refresh token"}), 401 + + @app.get("/me") + @auth.verify() + def protected(): + return jsonify({"message": "Access granted"}), 200 + + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_jwt_token_generation(client): + response = client.post( + "/auth/jwt/login", json={"username": "testuser", "password": "password123"} + ) + assert response.status_code == 200 + assert "access_token" in response.json.get("data") + assert "refresh_token" in response.json.get("data") + + +def test_protected_route_access(client): + login_response = client.post( + "/auth/jwt/login", json={"username": "testuser", "password": "password123"} + ) + token = login_response.json["data"]["access_token"] + + response = client.get("/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + assert response.json["message"] == "Access granted" + + +def test_refresh_token(client): + login_response = client.post( + "/auth/jwt/login", json={"username": "testuser", "password": "password123"} + ) + refresh_token = login_response.json["data"]["refresh_token"] + print(refresh_token) + + refresh_response = client.post( + "/auth/jwt/refresh", + headers={"Authorization": f"Bearer {refresh_token}"}, + ) + assert refresh_response.json['code'] == 200 + assert "access_token" in refresh_response.json['data'] diff --git a/tests/test_fastapi_integration.py b/tests/test_fastapi_integration.py new file mode 100644 index 0000000..d2766f2 --- /dev/null +++ b/tests/test_fastapi_integration.py @@ -0,0 +1,38 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from flycatch_auth import Auth, AuthCoreJwtConfig, IdentityService + +class MockIdentityService(IdentityService): + def load_user(self, username: str): + if username == "test_user": + return {"id": "1", "username": "test_user", "password": "securepass", "grants": ["read_user"]} + return None + +def mock_credential_checker(input_password, stored_password): + return input_password == stored_password + +@pytest.fixture +def fastapi_app(): + app = FastAPI() + auth = Auth(MockIdentityService(), mock_credential_checker, AuthCoreJwtConfig( + enable=True, + secret="test-secret", + expiresIn="1h", + refresh=True, + prefix="/auth/jwt", + ),) + + @app.get("/protected") + async def protected_route(user=auth.verify): + return {"message": "Access granted", "user": user} + return app + +@pytest.fixture +def client(fastapi_app): + return TestClient(fastapi_app) + +def test_fastapi_protected_route(client): + response = client.get("/protected") + print(response.json) + assert response.status_code == 401 # Should be unauthorized without token diff --git a/tests/test_flask_integration.py b/tests/test_flask_integration.py new file mode 100644 index 0000000..fbc682f --- /dev/null +++ b/tests/test_flask_integration.py @@ -0,0 +1,57 @@ +import pytest +from flask import Flask, jsonify, request +from flycatch_auth import auth, AuthCoreJwtConfig, IdentityService, Identity + + +class MockUserService(IdentityService): + def load_user(self, username: str) -> Identity: + return {"id": "1", "username": "testuser", "password": "password123", "grants": ["read_user"]} + + +@pytest.fixture +def app(): + + app = Flask(__name__) + jwt_config = AuthCoreJwtConfig( + enable=True, + secret="mysecret", + expiresIn="2h", + refresh=True, + prefix="/auth/jwt", + ) + + auth.init_app( + app=app, + user_service=MockUserService(), + credential_checker=lambda input, user: input == user, + jwt=jwt_config, + ) + + @app.route("/me") + @auth.verify() + # @auth.has_grants(["read_user"])["flask"] + def get_curr_user(): + return jsonify({"name": "Test User"}), 200 + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_me_route_with_auth(client): + login_response = client.post( + "/auth/jwt/login", json={"username": "testuser", "password": "password123"}) + print(login_response.json) + + assert login_response.status_code == 200, "Login successful" + + token = login_response.json['data']['access_token'] + assert token, "Token is missing from response" + + headers = {"Authorization": f"Bearer {token}"} + response = client.get("/me", headers=headers) + + assert response.status_code == 200, "Access granted" + assert response.json["name"] == "Test User"