From e4b24454a5233fb21583bb05d832c9eaa47784a7 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 19 Jun 2023 21:00:58 +0400 Subject: [PATCH 01/24] Convert the airnominal to the core app --- examples/airnominal/.env => .env | 0 README.md | 10 +- .../fastapi_sso => demo}/__init__.py | 0 {examples/airnominal => demo}/config.py | 0 .../auth.py => demo/dependencies.py | 83 +---------- demo/router.py | 65 +++++++++ demo/utils.py | 17 +++ examples/DogeAPI/.gitignore | 130 ------------------ examples/DogeAPI/api/blog.py | 113 --------------- examples/DogeAPI/api/user.py | 62 --------- examples/DogeAPI/core/auth.py | 53 ------- examples/DogeAPI/core/blog.py | 116 ---------------- examples/DogeAPI/core/user.py | 57 -------- examples/DogeAPI/database/configuration.py | 30 ---- examples/DogeAPI/database/dogeapi.sqlite | Bin 24576 -> 0 bytes examples/DogeAPI/fastapi_allauth/__init__.py | 1 - .../fastapi_allauth/allauth_manager.py | 71 ---------- .../DogeAPI/fastapi_allauth/auth/__init__.py | 3 - .../fastapi_allauth/auth/authenticate.py | 88 ------------ .../DogeAPI/fastapi_allauth/auth/login.py | 15 -- .../DogeAPI/fastapi_allauth/auth/register.py | 11 -- .../DogeAPI/fastapi_allauth/model/BaseUser.py | 47 ------- .../DogeAPI/fastapi_allauth/model/__init__.py | 1 - .../fastapi_allauth/oauth/BaseOauth.py | 115 ---------------- .../fastapi_allauth/oauth/GithubOauth.py | 43 ------ .../DogeAPI/fastapi_allauth/oauth/__init__.py | 2 - .../DogeAPI/fastapi_allauth/secret_handler.py | 11 -- examples/DogeAPI/main.py | 50 ------- examples/DogeAPI/models/models.py | 32 ----- examples/DogeAPI/requirements.txt | 26 ---- examples/DogeAPI/schema/hash.py | 33 ----- examples/DogeAPI/schema/oa2.py | 26 ---- examples/DogeAPI/schema/schemas.py | 52 ------- examples/DogeAPI/schema/token.py | 59 -------- examples/DogeAPI/static/main.js | 46 ------- examples/DogeAPI/static/style.css | 120 ---------------- examples/DogeAPI/templates/index.html | 30 ---- examples/airnominal/templates/index.html | 19 --- fastapi_oauth2/__init__.py | 0 .../fastapi_sso => fastapi_oauth2}/base.py | 16 +-- .../fastapi_sso => fastapi_oauth2}/github.py | 2 - examples/airnominal/main.py => main.py | 8 +- templates/index.html | 39 ++++++ 43 files changed, 144 insertions(+), 1558 deletions(-) rename examples/airnominal/.env => .env (100%) rename {examples/airnominal/fastapi_sso => demo}/__init__.py (100%) rename {examples/airnominal => demo}/config.py (100%) rename examples/airnominal/auth.py => demo/dependencies.py (50%) create mode 100644 demo/router.py create mode 100644 demo/utils.py delete mode 100644 examples/DogeAPI/.gitignore delete mode 100644 examples/DogeAPI/api/blog.py delete mode 100644 examples/DogeAPI/api/user.py delete mode 100644 examples/DogeAPI/core/auth.py delete mode 100644 examples/DogeAPI/core/blog.py delete mode 100644 examples/DogeAPI/core/user.py delete mode 100644 examples/DogeAPI/database/configuration.py delete mode 100644 examples/DogeAPI/database/dogeapi.sqlite delete mode 100644 examples/DogeAPI/fastapi_allauth/__init__.py delete mode 100644 examples/DogeAPI/fastapi_allauth/allauth_manager.py delete mode 100644 examples/DogeAPI/fastapi_allauth/auth/__init__.py delete mode 100644 examples/DogeAPI/fastapi_allauth/auth/authenticate.py delete mode 100644 examples/DogeAPI/fastapi_allauth/auth/login.py delete mode 100644 examples/DogeAPI/fastapi_allauth/auth/register.py delete mode 100644 examples/DogeAPI/fastapi_allauth/model/BaseUser.py delete mode 100644 examples/DogeAPI/fastapi_allauth/model/__init__.py delete mode 100644 examples/DogeAPI/fastapi_allauth/oauth/BaseOauth.py delete mode 100644 examples/DogeAPI/fastapi_allauth/oauth/GithubOauth.py delete mode 100644 examples/DogeAPI/fastapi_allauth/oauth/__init__.py delete mode 100644 examples/DogeAPI/fastapi_allauth/secret_handler.py delete mode 100644 examples/DogeAPI/main.py delete mode 100644 examples/DogeAPI/models/models.py delete mode 100644 examples/DogeAPI/requirements.txt delete mode 100644 examples/DogeAPI/schema/hash.py delete mode 100644 examples/DogeAPI/schema/oa2.py delete mode 100644 examples/DogeAPI/schema/schemas.py delete mode 100644 examples/DogeAPI/schema/token.py delete mode 100644 examples/DogeAPI/static/main.js delete mode 100644 examples/DogeAPI/static/style.css delete mode 100644 examples/DogeAPI/templates/index.html delete mode 100644 examples/airnominal/templates/index.html create mode 100644 fastapi_oauth2/__init__.py rename {examples/airnominal/fastapi_sso => fastapi_oauth2}/base.py (96%) rename {examples/airnominal/fastapi_sso => fastapi_oauth2}/github.py (94%) rename examples/airnominal/main.py => main.py (87%) create mode 100644 templates/index.html diff --git a/examples/airnominal/.env b/.env similarity index 100% rename from examples/airnominal/.env rename to .env diff --git a/README.md b/README.md index fce1c10..d0ca719 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,11 @@ Easy to setup social authentication mechanism with support for several auth providers. -## Examples +## Demo -- [airnominal](./examples/airnominal) - [fastapi-sso](https://github.com/tomasvotava/fastapi-sso) based implementation -- [dogeapi](./examples/DogeAPI) - [fastapi-allauth](https://github.com/K-villain/fastapi-allauth) based implementation +This sample application is made to demonstrate the use of the [**fastapi-oauth2**](./fastapi_oauth2) package. -Both can be run using the following command: +## Running the application ```bash uvicorn main:app --reload @@ -15,4 +14,5 @@ uvicorn main:app --reload ## TODO -- Segregate the prototype of the `fastapi-oauth2` core. +- Make the [**fastapi-oauth2**](./fastapi_oauth2) depend + on (overuse) the [**social-core**](https://github.com/python-social-auth/social-core) diff --git a/examples/airnominal/fastapi_sso/__init__.py b/demo/__init__.py similarity index 100% rename from examples/airnominal/fastapi_sso/__init__.py rename to demo/__init__.py diff --git a/examples/airnominal/config.py b/demo/config.py similarity index 100% rename from examples/airnominal/config.py rename to demo/config.py diff --git a/examples/airnominal/auth.py b/demo/dependencies.py similarity index 50% rename from examples/airnominal/auth.py rename to demo/dependencies.py index 2b0f64b..6852113 100644 --- a/examples/airnominal/auth.py +++ b/demo/dependencies.py @@ -1,47 +1,14 @@ -import os -from datetime import datetime, timedelta from typing import Optional -from fastapi import APIRouter from fastapi import Depends, HTTPException from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel -from fastapi.responses import RedirectResponse -from fastapi.security import HTTPBearer from fastapi.security import OAuth2 from fastapi.security.utils import get_authorization_scheme_param -from jose import jwt -from jwt import PyJWTError +from jose import jwt, JWTError from starlette.requests import Request from starlette.status import HTTP_403_FORBIDDEN -from config import CLIENT_ID, CLIENT_SECRET, redirect_url, SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, \ - redirect_url_main_page -from fastapi_sso.github import GithubSSO - -router = APIRouter() - -# config for GitHub SSO -os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - -sso = GithubSSO( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - redirect_uri=redirect_url, - allow_insecure_http=True, -) - -security = HTTPBearer() - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt +from .config import SECRET_KEY, ALGORITHM class OAuth2PasswordBearerCookie(OAuth2): @@ -80,6 +47,8 @@ async def __call__(self, request: Request) -> Optional[str]: else: authorization = False + scheme = "" + param = "" if not authorization or scheme.lower() != "bearer": if self.auto_error: @@ -97,49 +66,7 @@ async def __call__(self, request: Request) -> Optional[str]: async def get_current_user(token: str = Depends(oauth2_scheme)): try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - except PyJWTError: + except JWTError: raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" ) - - -@router.get("/user") -def user(current_user=Depends(get_current_user)): - return current_user - - -@router.post("/token") -def token(request: Request): - return request.cookies.get("Authorization") - - -@router.get("/auth/login") -async def auth_init(): - """Initialize auth and redirect""" - return await sso.get_login_redirect() - - -@router.get("/auth/callback") -async def auth_callback(request: Request): - """Verify login""" - user = await sso.verify_and_process(request) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data=dict(user), expires_delta=access_token_expires - ) - response = RedirectResponse(redirect_url_main_page) - response.set_cookie( - "Authorization", - value=f"Bearer {access_token}", - httponly=True, - max_age=1800, - expires=1800, - ) - return response - - -@router.get("/auth/logout") -async def auth_logout(): - response = RedirectResponse(redirect_url_main_page) - response.delete_cookie("Authorization") - return response diff --git a/demo/router.py b/demo/router.py new file mode 100644 index 0000000..ef070af --- /dev/null +++ b/demo/router.py @@ -0,0 +1,65 @@ +from datetime import timedelta + +from fastapi import APIRouter +from fastapi import Depends +from fastapi.responses import RedirectResponse +from starlette.requests import Request + +from fastapi_oauth2.github import GithubSSO +from .config import ( + CLIENT_ID, + CLIENT_SECRET, + redirect_url, + ACCESS_TOKEN_EXPIRE_MINUTES, + redirect_url_main_page, +) +from .dependencies import get_current_user +from .utils import create_access_token + +router = APIRouter() +sso = GithubSSO( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + redirect_uri=redirect_url, + allow_insecure_http=True, +) + + +@router.get("/user") +def user(current_user=Depends(get_current_user)): + return current_user + + +@router.post("/token") +def token(request: Request): + return request.cookies.get("Authorization") + + +@router.get("/auth/login") +async def auth_init(): + return await sso.get_login_redirect() + + +@router.get("/auth/callback") +async def auth_callback(request: Request): + user = await sso.verify_and_process(request) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data=dict(user), expires_delta=access_token_expires + ) + response = RedirectResponse(redirect_url_main_page) + response.set_cookie( + "Authorization", + value=f"Bearer {access_token}", + httponly=sso.allow_insecure_http, + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + expires=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + return response + + +@router.get("/auth/logout") +async def auth_logout(): + response = RedirectResponse(redirect_url_main_page) + response.delete_cookie("Authorization") + return response diff --git a/demo/utils.py b/demo/utils.py new file mode 100644 index 0000000..d8f0716 --- /dev/null +++ b/demo/utils.py @@ -0,0 +1,17 @@ +from datetime import datetime, timedelta +from typing import Optional + +from jose import jwt + +from .config import SECRET_KEY, ALGORITHM + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt diff --git a/examples/DogeAPI/.gitignore b/examples/DogeAPI/.gitignore deleted file mode 100644 index f52fa55..0000000 --- a/examples/DogeAPI/.gitignore +++ /dev/null @@ -1,130 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -blog.db - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/examples/DogeAPI/api/blog.py b/examples/DogeAPI/api/blog.py deleted file mode 100644 index 0473b6d..0000000 --- a/examples/DogeAPI/api/blog.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/python3 - -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from models import models -from schema import schemas - - -def get_all(db: Session): - """ - Get all blogs - - Args: - db (Session): Database session - - Returns: - List[models.Blog]: List of blogs - """ - return db.query(models.Blog).all() - - -def create(request: schemas.Blog, db: Session): - """ - Create a new blog - - Args: - request (schemas.Blog): Blog object - db (Session): Database session - - Returns: - models.Blog: Blog object - """ - new_blog = models.Blog(title=request.title, body=request.body) - db.add(new_blog) - db.commit() - db.refresh(new_blog) - return new_blog - - -def destroy(id: int, db: Session): - """ - Delete a blog - - Args: - id (int): Blog id - db (Session): Database session - - Raises: - HTTPException: 404 not found - - Returns: - str: Success message - """ - blog_to_delete = db.query(models.Blog).filter(models.Blog.id == id) - - if not blog_to_delete.first(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Blog with id {id} not found.", - ) - blog_to_delete.delete(synchronize_session=False) - db.commit() - return {"done"} - - -def update(id: int, request: schemas.Blog, db: Session): - """ - Update a blog - - Args: - id (int): Blog id - request (schemas.Blog): Blog object - db (Session): Database session - - Raises: - HTTPException: 404 not found - - Returns: - models.Blog: Blog object - """ - blog = db.query(models.Blog).filter(models.Blog.id == id) - if not blog.first(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=f"Blog with id {id} not found" - ) - blog.update(request.__dict__) - db.commit() - return "updated" - - -def show(id: int, db: Session): - """ - Get a blog - - Args: - id (int): Blog id - db (Session): Database session - - Raises: - HTTPException: 404 not found - - Returns: - models.Blog: Blog object - """ - blog = db.query(models.Blog).filter(models.Blog.id == id).first() - if blog: - return blog - else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Blog with the id {id} is not available", - ) diff --git a/examples/DogeAPI/api/user.py b/examples/DogeAPI/api/user.py deleted file mode 100644 index b472ed3..0000000 --- a/examples/DogeAPI/api/user.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/python3 - -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from models import models -from schema import schemas -from schema.hash import Hash - - -def create(request: schemas.User, db: Session): - """ - Create a new user - - Args: - request (schemas.User): User data - db (Session): Database session - - Returns: - models.User: User created - """ - hashedPassword = Hash.bcrypt(request.password) - user = models.User(name=request.name, email=request.email, password=hashedPassword) - db.add(user) - db.commit() - db.refresh(user) - return user - - -def show(id: int, db: Session): - """ - Show a user - - Args: - id (int): User id - db (Session): Database session - - Raises: - HTTPException: User not found - - Returns: - models.User: User found - """ - user = db.query(models.User).filter(models.User.id == id).first() - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"User with id {id} not found" - ) - return user - - -def get_all(db: Session): - """ - Get all users - - Args: - db (Session): Database session - - Returns: - list: List of users - """ - return db.query(models.User).all() diff --git a/examples/DogeAPI/core/auth.py b/examples/DogeAPI/core/auth.py deleted file mode 100644 index 9892da8..0000000 --- a/examples/DogeAPI/core/auth.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/python3 - - -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security.oauth2 import OAuth2PasswordRequestForm -from sqlalchemy.orm import Session - -from database import configuration -from models import models -from schema import schemas -from schema.hash import Hash -from schema.token import create_access_token - -router = APIRouter(prefix="/login", tags=["Authentication"],) - - -@router.post("/") -def login( - request: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(configuration.get_db), -): - """ - Login user - - Args: - request (OAuth2PasswordRequestForm, optional): OAuth2PasswordRequestForm. - db (Session, optional): Session. Defaults to Depends(configuration.get_db). - - Raises: - HTTPException: 401 Unauthorized - HTTPException: 404 Not Found - - Returns: - Hash: Hash - """ - user: schemas.User = db.query(models.User).filter( - models.User.email == request.username - ).first() - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Invalid Credentials" - ) - - if not Hash.verify(user.password, request.password): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Incorrect password" - ) - - # access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token(data={"sub": user.email}) - - # generate JWT token and return - return {"access_token": access_token, "token_type": "bearer"} diff --git a/examples/DogeAPI/core/blog.py b/examples/DogeAPI/core/blog.py deleted file mode 100644 index f8fb1c8..0000000 --- a/examples/DogeAPI/core/blog.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/python3 - -from typing import List - -from fastapi import APIRouter, Depends, Response, status -from sqlalchemy.orm import Session - -from api import blog -from database import configuration -from schema import schemas -from schema.oa2 import get_current_user - -router = APIRouter(tags=["Blogs"], prefix="/blog") -get_db = configuration.get_db - - -@router.get("/", response_model=List[schemas.ShowBlog]) -def get_all_blogs( - db: Session = Depends(get_db), - current_user: schemas.User = Depends(get_current_user), -): - """ - Get all blogs - - Args: - db (Session, optional): Database session. Defaults to None. - current_user (schemas.User, optional): Current user. Defaults to None. - - Returns: - List[schemas.ShowBlog]: List of blogs - """ - return blog.get_all(db) - - -@router.post("/", status_code=status.HTTP_201_CREATED) -def create( - request: schemas.Blog, - db: Session = Depends(get_db), - current_user: schemas.User = Depends(get_current_user), -): - """ - Create a blog - - Args: - request (schemas.Blog): Blog to create - db (Session, optional): Database session. Defaults to None. - current_user (schemas.User, optional): Current user. Defaults to None. - - Returns: - schemas.Blog: Created blog - """ - return blog.create(request, db) - - -@router.get("/{id}", status_code=status.HTTP_200_OK, response_model=schemas.ShowBlog) -def get_blog_by_id( - id: int, - response: Response, - db: Session = Depends(get_db), - current_user: schemas.User = Depends(get_current_user), -): - """ - Get a blog by id - - Args: - id (int): Blog id - response (Response): FastAPI response - db (Session, optional): Database session. Defaults to None. - current_user (schemas.User, optional): Current user. Defaults to None. - - Returns: - schemas.ShowBlog: Blog - """ - return blog.show(id, db) - - -@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_blog( - id: int, - db: Session = Depends(get_db), - current_user: schemas.User = Depends(get_current_user), -): - """ - Delete a blog by id - - Args: - id (int): Blog id - db (Session, optional): Database session. Defaults to None. - current_user (schemas.User, optional): Current user. Defaults to None. - - Returns: - None: None - """ - return blog.destroy(id, db) - - -@router.put("/{id}", status_code=status.HTTP_202_ACCEPTED) -def update_blog( - id: int, - request: schemas.Blog, - db: Session = Depends(get_db), - current_user: schemas.User = Depends(get_current_user), -): - """ - Update a blog by id - - Args: - id (int): Blog id - request (schemas.Blog): Blog to update - db (Session, optional): Database session. Defaults to Depends(get_db). - current_user (schemas.User, optional): Current user. Defaults to Depends(get_current_user). - - Returns: - schemas.Blog: Updated blog - """ - return blog.update(id, request, db) diff --git a/examples/DogeAPI/core/user.py b/examples/DogeAPI/core/user.py deleted file mode 100644 index 3dbca90..0000000 --- a/examples/DogeAPI/core/user.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/python3 - -from typing import List - -from fastapi import APIRouter, Depends, status -from sqlalchemy.orm import Session - -from api import user -from database import configuration -from schema import schemas - -router = APIRouter(tags=["Users"], prefix="/users") -get_db = configuration.get_db - - -@router.post("/", status_code=status.HTTP_201_CREATED, response_model=schemas.ShowUser) -def create_user(request: schemas.User, db: Session = Depends(get_db)): - """ - Create a new user - - Args: - request (schemas.User): User to create - db (Session, optional): Database session. - - Returns: - schemas.ShowUser: User created - """ - return user.create(request, db) - - -@router.get("/", status_code=status.HTTP_200_OK, response_model=List[schemas.ShowUser]) -def get_users(db: Session = Depends(get_db)): - """ - Get all users - - Args: - db (Session, optional): Database session. Defaults to Depends(get_db). - - Returns: - List[schemas.ShowUser]: List of users - """ - return user.get_all(db) - - -@router.get("/{id}", status_code=status.HTTP_200_OK, response_model=schemas.ShowUser) -def get_user_by_id(id: int, db: Session = Depends(get_db)): - """ - Get a user by id - - Args: - id (int): User id - db (Session, optional): Database session. Defaults to Depends(get_db). - - Returns: - schemas.ShowUser: User - """ - return user.show(id, db) diff --git a/examples/DogeAPI/database/configuration.py b/examples/DogeAPI/database/configuration.py deleted file mode 100644 index 1d968d3..0000000 --- a/examples/DogeAPI/database/configuration.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/python3 - -from decouple import config -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker - -SQLALCHEMY_DATABASE_URL = config( - "DATABASE_URL", default="sqlite:///./database/dogeapi.sqlite" -) -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) - -SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) -Base = declarative_base() - - -def get_db(): - """ - Get the database session - - Yields: - Session: The database session - """ - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/examples/DogeAPI/database/dogeapi.sqlite b/examples/DogeAPI/database/dogeapi.sqlite deleted file mode 100644 index c2aee60ca8f991b1a5b43df63d6de8f09b24defb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeI%O>fgM7zc1?eHpE^rMDchvUt-6NpPx@|&R&m_Jfd;BUa_2-q(u~kJf)NnQY&)3$YrV) z4Ux;Fl#SoiHS*}}{gL{Z)Q;Ok{h|(zS8A(hB0&HG5P$##AOHafKmY;|fWZGM(680C z6HS>Vmpr;jZsu26w9e)UmvwzG@x7@}hru)d9o-qFrva5u&<6=`Z)oLE(=_Gt{hWo% zNDj!Nx}87u`Xj%b&}<}}zV#-97v6*hr&Aid8I2y&W`kYj7wJ05Kb51R^OCKi-TP?8 zlI3p42bN{m>H5b{ib<~~!?8D+(U<;AF0k2dSL@mnO(8PO?BlXH9M6mQrSg6ov@X{y zj>q@JZuJlLWx{28E@Wg~`B|nM6*96dUwo}dn9W$i$bMNI?7?iGmoUR%>Oc4QVe%wj z?jeLJ-$VK}-Xjq~OB6^DfB*y_009U<00Izz00bZa0SMexfu;xy=l{F9y@(3}5P$## zAOHafKmY;|fB*y_AO*zte?|RDiVFz>5P$##AOHafKmY;|fB*y_0D*riaIPGVH{xvW zEVyN{PA`tFMc3iX2~Fl0QEW5AwGA`WxxH{h*Y3r(&UGV<7FJKUBiD+##k-EKTV~wp zFxN4SE_2Lor None: - self.db = db - self.user = user - self.secret = secret - self.lifetime_second = lifetime_second - - def get_oauth_router(self, oauth: BaseOauth) -> APIRouter: - - router = APIRouter() - - @router.get("/authorize") - async def authorize(scope: Optional[str] = None): - url = await oauth.get_authorization_url(scope=scope) - return {"url": url} - - @router.get("/callback") - async def callback(code: Optional[str] = None, state: Optional[str] = None): - tokens = await oauth.get_access_token(code=code, state=state) - user_json = oauth.get_userinfo(tokens["access_token"]) - _user = self.user.create( - open_id=oauth.get_open_id(user_json=user_json), provider=oauth.provider) - - if self.get_user_by_authority(_user.authority) is None: - try: - register(self.db, _user) - except Exception("Register failed"): - pass - - return login(_user, self.secret, self.lifetime_second) - - return router - - def get_user_by_authority(self, authority: str): - return self.db.query(BaseUser).filter(BaseUser.authority == authority).first() - - def login_required(self, func): - @wraps(func) - async def wrapper(*args, **kwargs): - auth_handler = AuthHandler(self.secret, self.lifetime_second) - token = kwargs.get('authorization', False) - if token: - authority = auth_handler.decode_access_token(token) - - if not self.get_user_by_authority(authority): - raise HTTPException(status_code=401, detail="user not exist") - - else: - raise HTTPException(status_code=401, detail="token required") - - # success - return await func(*args, **kwargs) - - return wrapper diff --git a/examples/DogeAPI/fastapi_allauth/auth/__init__.py b/examples/DogeAPI/fastapi_allauth/auth/__init__.py deleted file mode 100644 index cf6bc80..0000000 --- a/examples/DogeAPI/fastapi_allauth/auth/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .authenticate import AuthHandler -from .login import login -from .register import register diff --git a/examples/DogeAPI/fastapi_allauth/auth/authenticate.py b/examples/DogeAPI/fastapi_allauth/auth/authenticate.py deleted file mode 100644 index 89ddcbe..0000000 --- a/examples/DogeAPI/fastapi_allauth/auth/authenticate.py +++ /dev/null @@ -1,88 +0,0 @@ -import time -from datetime import datetime, timedelta - -import jwt -from fastapi import HTTPException, Security -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from ..secret_handler import SecretType, _get_secret_value - -INVALID = "Invalid token" - - -class AuthHandler: - secret: SecretType - lifetime: int - - def __init__(self, secret, lifetime_seconds) -> None: - self.secret = secret - self.lifetime_seconds = lifetime_seconds - - security = HTTPBearer() - - @staticmethod - def datetime_from_utc_to_local(utc_datetime): - epoch = time.mktime(utc_datetime.timetuple()) - offset = datetime.fromtimestamp( - epoch) - datetime.utcfromtimestamp(epoch) - return utc_datetime + offset - - def encode_token(self, payload: dict, type): - payload['sub'] = type - local_datetime = self.datetime_from_utc_to_local(datetime.utcnow()) - if type == "access_token": - payload.update( - {"exp": local_datetime + timedelta(seconds=self.lifetime_seconds)}) - else: - payload.update({"exp": local_datetime + timedelta(hours=720)}) - - return jwt.encode(payload, _get_secret_value(self.secret), algorithm='HS256') - - def encode_login_token(self, payload: dict): - access_token = self.encode_token(payload, "access_token") - refresh_token = self.encode_token(payload, "refresh_token") - - login_token = dict( - access_token=f"{access_token}", - refresh_token=f"{refresh_token}" - ) - return login_token - - def encode_update_token(self, payload: dict): - access_token = self.encode_token(payload, "access_token") - - update_token = dict( - access_token=f"{access_token}" - ) - return update_token - - def decode_access_token(self, token): - - try: - payload = jwt.decode(token, _get_secret_value(self.secret), algorithms=['HS256']) - if payload['sub'] != "access_token": - raise HTTPException(status_code=401, detail=INVALID) - return payload['authority'] - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=401, detail='Signature has expired') - except jwt.InvalidTokenError as e: - raise HTTPException(status_code=401, detail=INVALID) - - def decode_refresh_token(self, token): - try: - payload = jwt.decode(token, _get_secret_value(self.secret), algorithms=['HS256']) - if payload['sub'] != "refresh_token": - raise HTTPException(status_code=401, detail=INVALID) - return payload['authority'] - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=401, detail='Signature has expired') - except jwt.InvalidTokenError as e: - raise HTTPException(status_code=401, detail=INVALID) - - def auth_access_wrapper(self, auth: HTTPAuthorizationCredentials = Security(security)): - return self.decode_access_token(auth.credentials) - - def auth_refresh_wrapper(self, auth: HTTPAuthorizationCredentials = Security(security)): - return self.decode_refresh_token(auth.credentials) diff --git a/examples/DogeAPI/fastapi_allauth/auth/login.py b/examples/DogeAPI/fastapi_allauth/auth/login.py deleted file mode 100644 index 7d6cb87..0000000 --- a/examples/DogeAPI/fastapi_allauth/auth/login.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..auth.authenticate import AuthHandler -from ..model.BaseUser import BaseUser -from ..secret_handler import SecretType, _get_secret_value - - -def login(user: BaseUser, secret: SecretType, lifetime_seconds: int): - authhandler = AuthHandler(_get_secret_value(secret), lifetime_seconds) - - _payload = {} - for key in user.payload: - _payload[key] = user[key] - - token = authhandler.encode_login_token(payload=_payload) - - return token diff --git a/examples/DogeAPI/fastapi_allauth/auth/register.py b/examples/DogeAPI/fastapi_allauth/auth/register.py deleted file mode 100644 index f300fee..0000000 --- a/examples/DogeAPI/fastapi_allauth/auth/register.py +++ /dev/null @@ -1,11 +0,0 @@ -from sqlalchemy.orm import Session - -from ..model.BaseUser import BaseUser - - -def register(db: Session, user: BaseUser): - db.add(user) - db.commit() - db.refresh(user) - - return user diff --git a/examples/DogeAPI/fastapi_allauth/model/BaseUser.py b/examples/DogeAPI/fastapi_allauth/model/BaseUser.py deleted file mode 100644 index 2c8bcd2..0000000 --- a/examples/DogeAPI/fastapi_allauth/model/BaseUser.py +++ /dev/null @@ -1,47 +0,0 @@ -import hashlib -import uuid - -from sqlalchemy import Column, String - -from database.configuration import Base - - -class BaseUser(Base): - __tablename__ = "users" - - id = Column(String, primary_key=True, index=True) - authority = Column(String) - - payload = ['id', 'authority'] - - def __init__(self, id, authority): - self.id = id - self.authority = authority - - @classmethod - def create_authority(cls, open_id, provider): - context = str(open_id) + provider - authority = hashlib.sha256(context.encode()).hexdigest() - return authority - - @classmethod - def create( - cls, - open_id: String, - provider: String, - ): - authority = cls.create_authority(open_id, provider) - id = uuid.uuid4().hex - - return cls(id=id, authority=authority) - - class Config: - orm_mode = True - - def __getitem__(self, key): - return getattr(self, key) - - -__all__ = [ - "BaseUser" -] diff --git a/examples/DogeAPI/fastapi_allauth/model/__init__.py b/examples/DogeAPI/fastapi_allauth/model/__init__.py deleted file mode 100644 index 8608a35..0000000 --- a/examples/DogeAPI/fastapi_allauth/model/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .BaseUser import BaseUser diff --git a/examples/DogeAPI/fastapi_allauth/oauth/BaseOauth.py b/examples/DogeAPI/fastapi_allauth/oauth/BaseOauth.py deleted file mode 100644 index 1dea31a..0000000 --- a/examples/DogeAPI/fastapi_allauth/oauth/BaseOauth.py +++ /dev/null @@ -1,115 +0,0 @@ -import time -from typing import Optional, List, Dict, TypeVar, Any, cast -from urllib.parse import urlencode - -import requests - -from ..secret_handler import SecretType, _get_secret_value - -T = TypeVar("T") - - -class BaseOauth: - provider: str - client_id: str - client_secret: SecretType - redirect_uri: str - authorize_url: str - access_token_url: str - refresh_token_url: Optional[str] - revoke_token_url: Optional[str] - base_scope: Optional[List[str]] - request_header: Dict[str, str] - - def __init__( - self, - provider: str, - client_id: str, - client_secret: SecretType, - redirect_uri: str, - authorize_url: str, - access_token_url: str, - refresh_token_url: Optional[str] = None, - revoke_token_url: Optional[str] = None, - base_scope: Optional[List[str]] = None, - ): - - self.provider = provider - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.authorize_url = authorize_url - self.access_token_url = access_token_url - self.refresh_token_url = refresh_token_url - self.revoke_token_url = revoke_token_url - self.base_scope = base_scope - - self.request_header = {"Accept": "application/json"} - - async def get_authorization_url( - self, - state: Optional[str] = None, - scope: Optional[List[str]] = None, - extras_params: Optional[T] = None, - ) -> str: - params = { - "response_type": "code", - "client_id": self.client_id, - "redirect_uri": self.redirect_uri - } - - if state is not None: - params["state"] = state - - _scope = scope or self.base_scope - if _scope is not None: - params["scope"] = " ".join(_scope) - - if extras_params is not None: - params = {**params, **extras_params} # type: ignore - return f"{self.authorize_url}?{urlencode(params)}" - - async def get_access_token(self, code: str, state: Optional[str] = None): - data = { - "grant_type": "authorization_code", - "client_id": self.client_id, - "client_secret": _get_secret_value(self.client_secret), - "code": code, - "redirect_uri": self.redirect_uri, - "state": state, - } - - response = requests.post( - self.access_token_url, data=data, headers=self.request_header) - if response.status_code >= 400: - raise Exception(response.text) - - data = cast(Dict[str, Any], response.json()) - - return OAuth2Token(data) - - async def get_userinfo(self, access_token: str): - raise NotImplementedError - - async def get_open_id(self, user_json: dict): - raise NotImplementedError - - -class OAuth2Token(Dict[str, Any]): - def __init__(self, token_dict: Dict[str, Any]): - if "expires_at" in token_dict: - token_dict["expires_at"] = int(token_dict["expires_at"]) - elif "expires_in" in token_dict: - token_dict["expires_at"] = int( - time.time()) + int(token_dict["expires_in"]) - super().__init__(token_dict) - - def is_expired(self): - if "expires_at" not in self: - return False - return time.time() > self["expires_at"] - - -__all__ = [ - "BaseOauth" -] diff --git a/examples/DogeAPI/fastapi_allauth/oauth/GithubOauth.py b/examples/DogeAPI/fastapi_allauth/oauth/GithubOauth.py deleted file mode 100644 index 039afba..0000000 --- a/examples/DogeAPI/fastapi_allauth/oauth/GithubOauth.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Optional, List - -import requests - -from .BaseOauth import BaseOauth -from ..secret_handler import SecretType - -AUTH_URL = "https://github.com/login/oauth/authorize" -TOKEN_URL = "https://github.com/login/oauth/access_token" -USER_INFO_URL = "https://api.github.com/user" - - -class GithubOauth(BaseOauth): - - def __init__( - self, - provider: str = "GITHUB", - client_id: str = "", - client_secret: SecretType = "", - redirect_uri: str = "", - scope: Optional[List[str]] = None, - refresh_token_url: Optional[str] = None, - revoke_token_url: Optional[str] = None - ): - super().__init__( - provider=provider, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - authorize_url=AUTH_URL, - access_token_url=TOKEN_URL, - base_scope=scope, - refresh_token_url=refresh_token_url, - revoke_token_url=revoke_token_url - ) - - def get_userinfo(self, access_token: str): - response = requests.get(USER_INFO_URL, headers={ - "authorization": f"Bearer {access_token}"}) - return response.json() - - def get_open_id(self, user_json: dict): - return user_json["id"] diff --git a/examples/DogeAPI/fastapi_allauth/oauth/__init__.py b/examples/DogeAPI/fastapi_allauth/oauth/__init__.py deleted file mode 100644 index 5eb92c7..0000000 --- a/examples/DogeAPI/fastapi_allauth/oauth/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .BaseOauth import BaseOauth -from .GithubOauth import GithubOauth diff --git a/examples/DogeAPI/fastapi_allauth/secret_handler.py b/examples/DogeAPI/fastapi_allauth/secret_handler.py deleted file mode 100644 index 8b5efe0..0000000 --- a/examples/DogeAPI/fastapi_allauth/secret_handler.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Union - -from pydantic import SecretStr - -SecretType = Union[str, SecretStr] - - -def _get_secret_value(secret: SecretStr): - if isinstance(secret, SecretStr): - return secret.get_secret_value() - return secret diff --git a/examples/DogeAPI/main.py b/examples/DogeAPI/main.py deleted file mode 100644 index 129213d..0000000 --- a/examples/DogeAPI/main.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/python3 - -from fastapi import FastAPI, Request, Depends -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from starlette.responses import HTMLResponse - -from core import auth, blog, user -from database.configuration import engine, get_db -from fastapi_allauth import AllauthManager -from fastapi_allauth.oauth import GithubOauth -from models import models -from models.models import User - -models.Base.metadata.create_all(bind=engine) - -allauthManager = AllauthManager(db=Depends(get_db), user=User, secret="secret", lifetime_second=3600) -githubOauth = GithubOauth( - client_id="eccd08d6736b7999a32a", - client_secret="642999c1c5f2b3df8b877afdc78252ef5b594d31", - redirect_uri="http://127.0.0.1:8000/github/callback", - scope=["openid", "profile"] -) - -app = FastAPI( - title="DogeAPI", - description="API with high performance built with FastAPI & SQLAlchemy, help to improve connection with your Backend Side.", - version="1.0.0", -) -app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") - -app.include_router(auth.router) -app.include_router(blog.router) -app.include_router(user.router) -app.include_router(allauthManager.get_oauth_router(githubOauth), prefix="/github", tags=["github"]) - - -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - """ - Home page - - Args: - request (Request): Request object - - Returns: - HTMLResponse: HTML response - """ - return templates.TemplateResponse("index.html", {"request": request}) diff --git a/examples/DogeAPI/models/models.py b/examples/DogeAPI/models/models.py deleted file mode 100644 index 16d91eb..0000000 --- a/examples/DogeAPI/models/models.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/python3 -from fastapi_allauth.model import BaseUser -from sqlalchemy import Column, Integer, String - -from database.configuration import Base - - -class Blog(Base): - """ - Blog class - - Args: - Base (sqlalchemy.ext.declarative.api.Base): Base class - """ - - __tablename__ = "blogs" - id = Column(Integer, primary_key=True, index=True) - title = Column(String) - body = Column(String) - - -class User(BaseUser): - """ - User class - - Args: - Base (sqlalchemy.ext.declarative.api.Base): Base class - """ - - name = Column(String) - email = Column(String) - password = Column(String) diff --git a/examples/DogeAPI/requirements.txt b/examples/DogeAPI/requirements.txt deleted file mode 100644 index d970a80..0000000 --- a/examples/DogeAPI/requirements.txt +++ /dev/null @@ -1,26 +0,0 @@ -aiofiles -asgiref -bcrypt -cffi -click -colorama -ecdsa -fastapi -greenlet -h11 -Jinja2 -MarkupSafe -passlib -pyasn1 -pycparser -pydantic -python-decouple -python-jose -python-multipart -rsa -six -SQLAlchemy -starlette -typing-extensions -uvicorn -pre-commit \ No newline at end of file diff --git a/examples/DogeAPI/schema/hash.py b/examples/DogeAPI/schema/hash.py deleted file mode 100644 index f7598af..0000000 --- a/examples/DogeAPI/schema/hash.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/python3 - -from passlib.context import CryptContext - -pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -class Hash: - @staticmethod - def bcrypt(password: str): - """ - Generate a bcrypt hashed password - - Args: - password (str): The password to hash - - Returns: - str: The hashed password - """ - return pwd_ctx.hash(password) - - def verify(hashed_password, plain_password): - """ - Verify a password against a hash - - Args: - hashed_password (bool): The hashed password - plain_password ([type]): The plain password - - Returns: - bool: True if the password matches, False otherwise - """ - return pwd_ctx.verify(plain_password, hashed_password) diff --git a/examples/DogeAPI/schema/oa2.py b/examples/DogeAPI/schema/oa2.py deleted file mode 100644 index c1fd66d..0000000 --- a/examples/DogeAPI/schema/oa2.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/python3 - -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer - -from schema.token import verify_token - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") - - -def get_current_user(token: str = Depends(oauth2_scheme)): - """ - Get the current user from the token. - - Args: - token (str, optional): The token to verify. - - Returns: - dict: The user. - """ - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "bearer"}, - ) - return verify_token(token, credentials_exception=credentials_exception) diff --git a/examples/DogeAPI/schema/schemas.py b/examples/DogeAPI/schema/schemas.py deleted file mode 100644 index e3295ac..0000000 --- a/examples/DogeAPI/schema/schemas.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/python3 - -from typing import List, Optional - -from pydantic import BaseModel - - -class BlogBase(BaseModel): - title: str - body: str - - -class Blog(BlogBase): - class Config: - orm_mode = True - - -class User(BaseModel): - name: str - email: str - password: str - - -class ShowUser(BaseModel): - name: str - email: str - - class Config: - orm_mode = True - - -class ShowBlog(BaseModel): - title: str - body: str - creator: ShowUser - - class Config: - orm_mode = True - - -class Login(BaseModel): - username: str - password: str - - -class Token(BaseModel): - access_token: str - token_type: str - - -class TokenData(BaseModel): - username: Optional[str] = None diff --git a/examples/DogeAPI/schema/token.py b/examples/DogeAPI/schema/token.py deleted file mode 100644 index c244dcf..0000000 --- a/examples/DogeAPI/schema/token.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/python3 - -from datetime import datetime, timedelta -from typing import Optional - -from decouple import config -from jose import JWTError, jwt - -from schema.schemas import TokenData - -# openssl rand -hex 32 -SECRET_KEY = config("SECRET_KEY", default="secret") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = config( - "ACCESS_TOKEN_EXPIRE_MINUTES", default=60, cast=int -) - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - """ - Generate JWT token - - Args: - data (dict): payload - expires_delta (Optional[timedelta]): token expiration time - - Returns: - str: JWT token - """ - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode["exp"] = expire - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - - -def verify_token(token: str, credentials_exception): - """ - Verify JWT token - - Args: - token (str): JWT token - credentials_exception (Exception): exception to raise if token is invalid - - Raises: - credentials_exception: if token is invalid - credentials_exception: if token is expired - """ - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=ALGORITHM) - email: str = payload.get("sub") - if email is None: - raise credentials_exception - token_data = TokenData(email=email) - except JWTError: - raise credentials_exception diff --git a/examples/DogeAPI/static/main.js b/examples/DogeAPI/static/main.js deleted file mode 100644 index 9e9d9a2..0000000 --- a/examples/DogeAPI/static/main.js +++ /dev/null @@ -1,46 +0,0 @@ -const faceButton = document.querySelector(".face-button"); -const faceContainer = document.querySelector(".face-container"); -const containerCoords = document.querySelector("#container"); -const mouseCoords = containerCoords.getBoundingClientRect(); - -faceButton.addEventListener("mousemove", function (e) { - const mouseX = e.pageX - containerCoords.offsetLeft; - const mouseY = e.pageY - containerCoords.offsetTop; - - TweenMax.to(faceButton, 0.3, { - x: ((mouseX - mouseCoords.width / 2) / mouseCoords.width) * 50, - y: ((mouseY - mouseCoords.height / 2) / mouseCoords.width) * 50, - ease: Power4.easeOut, - }); -}); - -faceButton.addEventListener("mousemove", function (e) { - const mouseX = e.pageX - containerCoords.offsetLeft; - const mouseY = e.pageY - containerCoords.offsetTop; - - TweenMax.to(faceContainer, 0.3, { - x: ((mouseX - mouseCoords.width / 2) / mouseCoords.width) * 25, - y: ((mouseY - mouseCoords.height / 2) / mouseCoords.width) * 25, - ease: Power4.easeOut, - }); -}); - -faceButton.addEventListener("mouseenter", function (e) { - TweenMax.to(faceButton, 0.3, { - scale: 0.975, - }); -}); - -faceButton.addEventListener("mouseleave", function (e) { - TweenMax.to(faceButton, 0.3, { - x: 0, - y: 0, - scale: 1, - }); - - TweenMax.to(faceContainer, 0.3, { - x: 0, - y: 0, - scale: 1, - }); -}); diff --git a/examples/DogeAPI/static/style.css b/examples/DogeAPI/static/style.css deleted file mode 100644 index 62cfd61..0000000 --- a/examples/DogeAPI/static/style.css +++ /dev/null @@ -1,120 +0,0 @@ -* { - box-sizing: border-box; -} -*::before, -*::after { - box-sizing: border-box; -} - -body { - display: flex; - align-items: center; - justify-content: center; - margin: 0; - min-height: 100vh; - background: #000000; -} - -#container { - display: flex; - align-items: center; - justify-content: center; - width: 6.25rem; - height: 6.25rem; -} - -button { - position: relative; - display: inline-block; - cursor: pointer; - outline: none; - border: 0; - vertical-align: middle; -} -button.face-button { - width: 6.25rem; - height: 6.25rem; - border-radius: 50%; - background: #fdda5f; - box-shadow: inset 2px -4px 18px #fd9744; -} - -.face-container { - position: relative; - display: block; - width: 40px; - height: 20px; - margin: auto; -} - -.eye { - position: absolute; - height: 0.5rem; - width: 0.5rem; - background: #2a2927; - border-radius: 50%; - -webkit-animation: eyeBlink 3200ms linear infinite; - animation: eyeBlink 3200ms linear infinite; -} -.eye.left { - left: 0; -} -.eye.right { - left: 2rem; -} - -.mouth { - position: absolute; - top: 1.125rem; - left: 0.8rem; - width: 1rem; - height: 0.125rem; - background: #2a2927; - border-radius: 0; -} - -.eye, -.mouth { - box-shadow: inset 1px 2px 4px #121110; -} - -.face-button:hover .mouth, -.face-button:active .mouth { - left: 1rem; - width: 0.5rem; - height: 0.4rem; - border-radius: 1rem 1rem 0.125rem 0.125rem; -} - -.face-button:hover .eye, -.face-button:active .eye { - height: 0.375rem; - width: 0.375rem; - box-shadow: 0 0 0 0.25rem #fff; -} - -@-webkit-keyframes eyeBlink { - 0%, - 30%, - 36%, - 100% { - transform: scale(1); - } - 32%, - 34% { - transform: scale(1, 0); - } -} - -@keyframes eyeBlink { - 0%, - 30%, - 36%, - 100% { - transform: scale(1); - } - 32%, - 34% { - transform: scale(1, 0); - } -} diff --git a/examples/DogeAPI/templates/index.html b/examples/DogeAPI/templates/index.html deleted file mode 100644 index a261c36..0000000 --- a/examples/DogeAPI/templates/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - DogeAPI - - - - - - \ No newline at end of file diff --git a/examples/airnominal/templates/index.html b/examples/airnominal/templates/index.html deleted file mode 100644 index 67fe78a..0000000 --- a/examples/airnominal/templates/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - Index - - -{% if request.user %} -

Hello, {{ request.user.name }}

- Pic - Logout -{% else %} - Sign in -{% endif %} - - \ No newline at end of file diff --git a/fastapi_oauth2/__init__.py b/fastapi_oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/airnominal/fastapi_sso/base.py b/fastapi_oauth2/base.py similarity index 96% rename from examples/airnominal/fastapi_sso/base.py rename to fastapi_oauth2/base.py index 36e7e04..2a28f64 100644 --- a/examples/airnominal/fastapi_sso/base.py +++ b/fastapi_oauth2/base.py @@ -1,6 +1,5 @@ -"""SSO login base dependency""" - import json +import os import sys import warnings from typing import Any, Dict, List, Optional @@ -26,7 +25,7 @@ class UnsetStateWarning(UserWarning): class SSOLoginError(HTTPException): - """Raised when any login-related error ocurrs + """Raised when any login-related error occurs (such as when user is not verified or if there was an attempt for fake login) """ @@ -55,6 +54,8 @@ def __init__( self.client_secret = client_secret self.redirect_uri = redirect_uri self.allow_insecure_http = allow_insecure_http + if allow_insecure_http: + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" # TODO: Remove use_state argument and attribute if use_state: warnings.warn( @@ -131,16 +132,15 @@ async def get_login_url( redirect_uri: Optional[str] = None, params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, - ) -> str: + ) -> Any: """Return prepared login url. This is low-level, see {get_login_redirect} instead.""" params = params or {} redirect_uri = redirect_uri or self.redirect_uri if redirect_uri is None: raise ValueError("redirect_uri must be provided, either at construction or request time") - request_uri = self.oauth_client.prepare_request_uri( + return self.oauth_client.prepare_request_uri( await self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params ) - return request_uri async def get_login_redirect( self, @@ -149,7 +149,7 @@ async def get_login_redirect( params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> RedirectResponse: - """Return redirect response by Stalette to login page of Oauth SSO provider + """Return redirect response by Starlette to login page of Oauth SSO provider Arguments: redirect_uri {Optional[str]} -- Override redirect_uri specified on this instance (default: None) @@ -225,7 +225,7 @@ async def process_login( redirect_url=redirect_uri or self.redirect_uri or current_path, code=code, **params, - ) # type: ignore + ) if token_url is None: return None diff --git a/examples/airnominal/fastapi_sso/github.py b/fastapi_oauth2/github.py similarity index 94% rename from examples/airnominal/fastapi_sso/github.py rename to fastapi_oauth2/github.py index 4250a2e..9e49d75 100644 --- a/examples/airnominal/fastapi_sso/github.py +++ b/fastapi_oauth2/github.py @@ -1,5 +1,3 @@ -"""GitHub SSO Oauth Helper class""" - from .base import DiscoveryDocument, SSOBase diff --git a/examples/airnominal/main.py b/main.py similarity index 87% rename from examples/airnominal/main.py rename to main.py index 56766bd..6dbb2f8 100644 --- a/examples/airnominal/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import json + import jwt from fastapi import FastAPI, Request, APIRouter from fastapi.responses import HTMLResponse @@ -5,8 +7,8 @@ from starlette.authentication import AuthenticationBackend from starlette.middleware.authentication import AuthenticationMiddleware -from auth import router as auth_router -from config import SECRET_KEY, ALGORITHM +from demo.config import SECRET_KEY, ALGORITHM +from demo.router import router as auth_router router = APIRouter() templates = Jinja2Templates(directory="templates") @@ -14,7 +16,7 @@ @router.get("/", response_class=HTMLResponse) async def root(request: Request): - return templates.TemplateResponse("index.html", {"request": request, "user": request.user}) + return templates.TemplateResponse("index.html", {"request": request, "user": request.user, "json": json}) app = FastAPI() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2d4e6fc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,39 @@ + + + + + + + OAuth2 Demo + + +
+
+ {% if request.user %} + Sign out + Pic + {% else %} + + GH + + {% endif %} +
+
+
+ {% if request.user %} +

Hi, {{ request.user.name }}

+

This is what your JWT contains currently

+
{{ json.dumps(request.user, indent=4) }}
+ {% else %} +

You are not authenticated

+

You should sign in by clicking the GitHub's icon

+ {% endif %} +
+ + \ No newline at end of file From 9f5a84ca8dac99664275eafa7e5f8b7d74b0794b Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Wed, 21 Jun 2023 20:52:00 +0400 Subject: [PATCH 02/24] Remove unused and unnecessary stuff --- fastapi_oauth2/base.py | 110 +++++---------------------------------- fastapi_oauth2/github.py | 15 ++---- 2 files changed, 16 insertions(+), 109 deletions(-) diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index 2a28f64..e5218d6 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -1,7 +1,5 @@ import json import os -import sys -import warnings from typing import Any, Dict, List, Optional import httpx @@ -10,15 +8,6 @@ from starlette.requests import Request from starlette.responses import RedirectResponse -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - -DiscoveryDocument = TypedDict( - "DiscoveryDocument", {"authorization_endpoint": str, "token_endpoint": str, "userinfo_endpoint": str} -) - class UnsetStateWarning(UserWarning): """Warning about unset state parameter""" @@ -41,13 +30,16 @@ class SSOBase: _oauth_client: Optional[WebApplicationClient] = None additional_headers: Optional[Dict[str, Any]] = None + authorization_endpoint: str = NotImplemented + token_endpoint: str = NotImplemented + userinfo_endpoint: str = NotImplemented + def __init__( self, client_id: str, client_secret: str, redirect_uri: Optional[str] = None, allow_insecure_http: bool = False, - use_state: bool = False, scope: Optional[List[str]] = None, ): self.client_id = client_id @@ -56,33 +48,11 @@ def __init__( self.allow_insecure_http = allow_insecure_http if allow_insecure_http: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - # TODO: Remove use_state argument and attribute - if use_state: - warnings.warn( - ( - "Argument 'use_state' of SSOBase's constructor is deprecated and will be removed in " - "future releases. Use 'state' argument of individual methods instead." - ), - DeprecationWarning, - ) self.scope = scope or self.scope - self._refresh_token: Optional[str] = None - self._state: Optional[str] = None - - @property - def state(self) -> Optional[str]: - """Gets state as it was returned from the server""" - if self._state is None: - warnings.warn( - "'state' parameter is unset. This means the server either " - "didn't return state (was this expected?) or 'verify_and_process' hasn't been called yet.", - UnsetStateWarning, - ) - return self._state + self.state: Optional[str] = None @property def oauth_client(self) -> WebApplicationClient: - """OAuth Client to help us generate requests and parse responses""" if self.client_id == NotImplemented: raise NotImplementedError(f"Provider {self.provider} not supported") if self._oauth_client is None: @@ -91,41 +61,16 @@ def oauth_client(self) -> WebApplicationClient: @property def access_token(self) -> Optional[str]: - """Access token from token endpoint""" return self.oauth_client.access_token @property def refresh_token(self) -> Optional[str]: - """Get refresh token (if returned from provider)""" - return self._refresh_token or self.oauth_client.refresh_token + return self.oauth_client.refresh_token @classmethod async def openid_from_response(cls, response: dict) -> dict: - """Return {dict} object from provider's user info endpoint response""" raise NotImplementedError(f"Provider {cls.provider} not supported") - async def get_discovery_document(self) -> DiscoveryDocument: - """Get discovery document containing handy urls""" - raise NotImplementedError(f"Provider {self.provider} not supported") - - @property - async def authorization_endpoint(self) -> Optional[str]: - """Return `authorization_endpoint` from discovery document""" - discovery = await self.get_discovery_document() - return discovery.get("authorization_endpoint") - - @property - async def token_endpoint(self) -> Optional[str]: - """Return `token_endpoint` from discovery document""" - discovery = await self.get_discovery_document() - return discovery.get("token_endpoint") - - @property - async def userinfo_endpoint(self) -> Optional[str]: - """Return `userinfo_endpoint` from discovery document""" - discovery = await self.get_discovery_document() - return discovery.get("userinfo_endpoint") - async def get_login_url( self, *, @@ -133,13 +78,12 @@ async def get_login_url( params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> Any: - """Return prepared login url. This is low-level, see {get_login_redirect} instead.""" params = params or {} redirect_uri = redirect_uri or self.redirect_uri if redirect_uri is None: raise ValueError("redirect_uri must be provided, either at construction or request time") return self.oauth_client.prepare_request_uri( - await self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params + self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params ) async def get_login_redirect( @@ -149,20 +93,8 @@ async def get_login_redirect( params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> RedirectResponse: - """Return redirect response by Starlette to login page of Oauth SSO provider - - Arguments: - redirect_uri {Optional[str]} -- Override redirect_uri specified on this instance (default: None) - params {Optional[Dict[str, Any]]} -- Add additional query parameters to the login request. - state {Optional[str]} -- Add state parameter. This is useful if you want - the server to return something specific back to you. - - Returns: - RedirectResponse -- Starlette response (may directly be returned from FastAPI) - """ login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state) - response = RedirectResponse(login_uri, 303) - return response + return RedirectResponse(login_uri, 303) async def verify_and_process( self, @@ -172,21 +104,11 @@ async def verify_and_process( headers: Optional[Dict[str, Any]] = None, redirect_uri: Optional[str] = None, ) -> Optional[dict]: - """Get FastAPI (Starlette) Request object and process login. - This handler should be used for your /callback path. - - Arguments: - request {Request} -- FastAPI request object (or Starlette) - params {Optional[Dict[str, Any]]} -- Optional additional query parameters to pass to the provider - - Returns: - Optional[dict] -- dict if the login was successfully - """ headers = headers or {} code = request.query_params.get("code") if code is None: raise SSOLoginError(400, "'code' parameter was not found in callback request") - self._state = request.query_params.get("state") + self.state = request.query_params.get("state") return await self.process_login( code, request, params=params, additional_headers=headers, redirect_uri=redirect_uri ) @@ -200,13 +122,6 @@ async def process_login( additional_headers: Optional[Dict[str, Any]] = None, redirect_uri: Optional[str] = None, ) -> Optional[dict]: - """This method should be called from callback endpoint to verify the user and request user info endpoint. - This is low level, you should use {verify_and_process} instead. - - Arguments: - params {Optional[Dict[str, Any]]} -- Optional additional query parameters to pass to the provider - additional_headers {Optional[Dict[str, Any]]} -- Optional additional headers to be added to all requests - """ params = params or {} additional_headers = additional_headers or {} additional_headers.update(self.additional_headers or {}) @@ -220,7 +135,7 @@ async def process_login( current_path = f"{scheme}://{url.netloc}{url.path}" token_url, headers, body = self.oauth_client.prepare_token_request( - await self.token_endpoint, + self.token_endpoint, authorization_response=current_url, redirect_url=redirect_uri or self.redirect_uri or current_path, code=code, @@ -236,11 +151,10 @@ async def process_login( async with httpx.AsyncClient() as session: response = await session.post(token_url, headers=headers, content=body, auth=auth) content = response.json() - self._refresh_token = content.get("refresh_token") self.oauth_client.parse_request_body_response(json.dumps(content)) - uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint) + uri, headers, _ = self.oauth_client.add_token(self.userinfo_endpoint) response = await session.get(uri, headers=headers) content = response.json() - return await self.openid_from_response(content) + return content diff --git a/fastapi_oauth2/github.py b/fastapi_oauth2/github.py index 9e49d75..d653eee 100644 --- a/fastapi_oauth2/github.py +++ b/fastapi_oauth2/github.py @@ -1,4 +1,4 @@ -from .base import DiscoveryDocument, SSOBase +from .base import SSOBase class GithubSSO(SSOBase): @@ -8,13 +8,6 @@ class GithubSSO(SSOBase): scope = ["user:email"] additional_headers = {"accept": "application/json"} - async def get_discovery_document(self) -> DiscoveryDocument: - return { - "authorization_endpoint": "https://github.com/login/oauth/authorize", - "token_endpoint": "https://github.com/login/oauth/access_token", - "userinfo_endpoint": "https://api.github.com/user", - } - - @classmethod - async def openid_from_response(cls, response: dict) -> dict: - return {**response, "provider": cls.provider} + authorization_endpoint = "https://github.com/login/oauth/authorize" + token_endpoint = "https://github.com/login/oauth/access_token" + userinfo_endpoint = "https://api.github.com/user" From f8b84e8185495824516f06db72108d95d0644de4 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 22 Jun 2023 13:08:02 +0400 Subject: [PATCH 03/24] Fix 'state' usage and validation --- fastapi_oauth2/base.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index e5218d6..d88a79a 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -9,10 +9,6 @@ from starlette.responses import RedirectResponse -class UnsetStateWarning(UserWarning): - """Warning about unset state parameter""" - - class SSOLoginError(HTTPException): """Raised when any login-related error occurs (such as when user is not verified or if there was an attempt for fake login) @@ -78,6 +74,7 @@ async def get_login_url( params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> Any: + self.state = state params = params or {} redirect_uri = redirect_uri or self.redirect_uri if redirect_uri is None: @@ -108,7 +105,8 @@ async def verify_and_process( code = request.query_params.get("code") if code is None: raise SSOLoginError(400, "'code' parameter was not found in callback request") - self.state = request.query_params.get("state") + if self.state != request.query_params.get("state"): + raise SSOLoginError(400, "'state' parameter does not match") return await self.process_login( code, request, params=params, additional_headers=headers, redirect_uri=redirect_uri ) From c06334056bf94fc219c0384521abefe1c6ddd50f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 22 Jun 2023 15:46:54 +0400 Subject: [PATCH 04/24] Combine `process_login` and `verify_and_process` --- demo/router.py | 4 +-- fastapi_oauth2/base.py | 73 +++++++++++++--------------------------- fastapi_oauth2/github.py | 3 +- 3 files changed, 26 insertions(+), 54 deletions(-) diff --git a/demo/router.py b/demo/router.py index ef070af..6a2a620 100644 --- a/demo/router.py +++ b/demo/router.py @@ -5,7 +5,7 @@ from fastapi.responses import RedirectResponse from starlette.requests import Request -from fastapi_oauth2.github import GithubSSO +from fastapi_oauth2.github import GitHubSSO from .config import ( CLIENT_ID, CLIENT_SECRET, @@ -17,7 +17,7 @@ from .utils import create_access_token router = APIRouter() -sso = GithubSSO( +sso = GitHubSSO( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, redirect_uri=redirect_url, diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index d88a79a..044e2c5 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -1,5 +1,6 @@ import json import os +import re from typing import Any, Dict, List, Optional import httpx @@ -18,17 +19,18 @@ class SSOLoginError(HTTPException): class SSOBase: """Base class (mixin) for all SSO providers""" - provider: str = NotImplemented - client_id: str = NotImplemented - client_secret: str = NotImplemented - redirect_uri: Optional[str] = NotImplemented - scope: List[str] = NotImplemented + client_id: str = None + client_secret: str = None + redirect_uri: Optional[str] = None + allow_insecure_http: bool = False + scope: Optional[List[str]] = None + state: Optional[str] = None _oauth_client: Optional[WebApplicationClient] = None additional_headers: Optional[Dict[str, Any]] = None - authorization_endpoint: str = NotImplemented - token_endpoint: str = NotImplemented - userinfo_endpoint: str = NotImplemented + authorization_endpoint: str = None + token_endpoint: str = None + userinfo_endpoint: str = None def __init__( self, @@ -45,12 +47,9 @@ def __init__( if allow_insecure_http: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" self.scope = scope or self.scope - self.state: Optional[str] = None @property def oauth_client(self) -> WebApplicationClient: - if self.client_id == NotImplemented: - raise NotImplementedError(f"Provider {self.provider} not supported") if self._oauth_client is None: self._oauth_client = WebApplicationClient(self.client_id) return self._oauth_client @@ -63,10 +62,6 @@ def access_token(self) -> Optional[str]: def refresh_token(self) -> Optional[str]: return self.oauth_client.refresh_token - @classmethod - async def openid_from_response(cls, response: dict) -> dict: - raise NotImplementedError(f"Provider {cls.provider} not supported") - async def get_login_url( self, *, @@ -101,58 +96,36 @@ async def verify_and_process( headers: Optional[Dict[str, Any]] = None, redirect_uri: Optional[str] = None, ) -> Optional[dict]: - headers = headers or {} - code = request.query_params.get("code") - if code is None: + params = params or {} + additional_headers = headers or {} + additional_headers.update(self.additional_headers or {}) + if not request.query_params.get("code"): raise SSOLoginError(400, "'code' parameter was not found in callback request") if self.state != request.query_params.get("state"): raise SSOLoginError(400, "'state' parameter does not match") - return await self.process_login( - code, request, params=params, additional_headers=headers, redirect_uri=redirect_uri - ) - async def process_login( - self, - code: str, - request: Request, - *, - params: Optional[Dict[str, Any]] = None, - additional_headers: Optional[Dict[str, Any]] = None, - redirect_uri: Optional[str] = None, - ) -> Optional[dict]: - params = params or {} - additional_headers = additional_headers or {} - additional_headers.update(self.additional_headers or {}) url = request.url - scheme = url.scheme - if not self.allow_insecure_http and scheme != "https": - current_url = str(url).replace("http://", "https://") - scheme = "https" - else: - current_url = str(url) + scheme = "http" if self.allow_insecure_http else "https" current_path = f"{scheme}://{url.netloc}{url.path}" + current_path = re.sub(r"^https?", scheme, current_path) + current_url = re.sub(r"^https?", scheme, str(url)) - token_url, headers, body = self.oauth_client.prepare_token_request( + token_url, headers, content = self.oauth_client.prepare_token_request( self.token_endpoint, authorization_response=current_url, redirect_url=redirect_uri or self.redirect_uri or current_path, - code=code, + code=request.query_params.get("code"), **params, ) - if token_url is None: - return None - headers.update(additional_headers) - auth = httpx.BasicAuth(self.client_id, self.client_secret) async with httpx.AsyncClient() as session: - response = await session.post(token_url, headers=headers, content=body, auth=auth) - content = response.json() - self.oauth_client.parse_request_body_response(json.dumps(content)) + response = await session.post(token_url, headers=headers, content=content, auth=auth) + self.oauth_client.parse_request_body_response(json.dumps(response.json())) - uri, headers, _ = self.oauth_client.add_token(self.userinfo_endpoint) - response = await session.get(uri, headers=headers) + url, headers, _ = self.oauth_client.add_token(self.userinfo_endpoint) + response = await session.get(url, headers=headers) content = response.json() return content diff --git a/fastapi_oauth2/github.py b/fastapi_oauth2/github.py index d653eee..2f9408b 100644 --- a/fastapi_oauth2/github.py +++ b/fastapi_oauth2/github.py @@ -1,10 +1,9 @@ from .base import SSOBase -class GithubSSO(SSOBase): +class GitHubSSO(SSOBase): """Class providing login via GitHub SSO""" - provider = "github" scope = ["user:email"] additional_headers = {"accept": "application/json"} From 127966deb263b5b367ef62855d3bee8fbcbbcd8f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 22 Jun 2023 17:37:05 +0400 Subject: [PATCH 05/24] Rearrange the files and create some necessary ones --- .env | 12 ++++----- demo/config.py | 16 ------------ demo/dependencies.py | 39 +++++++---------------------- demo/router.py | 48 ------------------------------------ demo/utils.py | 17 ------------- fastapi_oauth2/base.py | 12 ++++----- fastapi_oauth2/config.py | 14 +++++++++++ fastapi_oauth2/router.py | 53 ++++++++++++++++++++++++++++++++++++++++ fastapi_oauth2/utils.py | 11 +++++++++ main.py | 18 +++++++------- 10 files changed, 108 insertions(+), 132 deletions(-) delete mode 100644 demo/config.py delete mode 100644 demo/utils.py create mode 100644 fastapi_oauth2/config.py create mode 100644 fastapi_oauth2/router.py create mode 100644 fastapi_oauth2/utils.py diff --git a/.env b/.env index ee65b30..dc3a160 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ -GITHUB_CLIENT_ID=eccd08d6736b7999a32a -GITHUB_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 -GITHUB_REDIRECT_URL=http://127.0.0.1:8000/auth/callback -MAIN_PAGE_REDIRECT_URL=http://127.0.0.1:8000/ +CLIENT_ID=eccd08d6736b7999a32a +CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 +CALLBACK_URL=http://127.0.0.1:8000/auth/callback +REDIRECT_URL=http://127.0.0.1:8000/ -JWT_SECRET_KEY=secret +JWT_SECRET=secret JWT_ALGORITHM=HS256 -JWT_TOKEN_EXPIRES=300 +JWT_EXPIRES=5 diff --git a/demo/config.py b/demo/config.py deleted file mode 100644 index d981796..0000000 --- a/demo/config.py +++ /dev/null @@ -1,16 +0,0 @@ -import os - -from dotenv import load_dotenv - -load_dotenv() - -# config for GitHub SSO -CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") -CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") -redirect_url = os.getenv("GITHUB_REDIRECT_URL") -redirect_url_main_page = os.getenv("MAIN_PAGE_REDIRECT_URL") - -# config for jwt generation -SECRET_KEY = os.getenv("JWT_SECRET_KEY") -ALGORITHM = os.getenv("JWT_ALGORITHM") -ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_TOKEN_EXPIRES")) diff --git a/demo/dependencies.py b/demo/dependencies.py index 6852113..13fd5d1 100644 --- a/demo/dependencies.py +++ b/demo/dependencies.py @@ -8,7 +8,7 @@ from starlette.requests import Request from starlette.status import HTTP_403_FORBIDDEN -from .config import SECRET_KEY, ALGORITHM +from fastapi_oauth2.config import JWT_SECRET, JWT_ALGORITHM class OAuth2PasswordBearerCookie(OAuth2): @@ -19,38 +19,17 @@ def __init__( scopes: dict = None, auto_error: bool = True, ): - if not scopes: - scopes = {} - flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) + flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes or {}}) super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) async def __call__(self, request: Request) -> Optional[str]: - header_authorization: str = request.headers.get("Authorization") - cookie_authorization: str = request.cookies.get("Authorization") + scheme, param = get_authorization_scheme_param(request.headers.get("Authorization")) + authorization = scheme.lower() == "bearer" + if not authorization: + scheme, param = get_authorization_scheme_param(request.cookies.get("Authorization")) + authorization = scheme.lower() == "bearer" - header_scheme, header_param = get_authorization_scheme_param( - header_authorization - ) - cookie_scheme, cookie_param = get_authorization_scheme_param( - cookie_authorization - ) - - if header_scheme.lower() == "bearer": - authorization = True - scheme = header_scheme - param = header_param - - elif cookie_scheme.lower() == "bearer": - authorization = True - scheme = cookie_scheme - param = cookie_param - - else: - authorization = False - scheme = "" - param = "" - - if not authorization or scheme.lower() != "bearer": + if not authorization: if self.auto_error: raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Not authenticated" @@ -65,7 +44,7 @@ async def __call__(self, request: Request) -> Optional[str]: async def get_current_user(token: str = Depends(oauth2_scheme)): try: - return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) except JWTError: raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" diff --git a/demo/router.py b/demo/router.py index 6a2a620..e83edf9 100644 --- a/demo/router.py +++ b/demo/router.py @@ -1,28 +1,10 @@ -from datetime import timedelta - from fastapi import APIRouter from fastapi import Depends -from fastapi.responses import RedirectResponse from starlette.requests import Request -from fastapi_oauth2.github import GitHubSSO -from .config import ( - CLIENT_ID, - CLIENT_SECRET, - redirect_url, - ACCESS_TOKEN_EXPIRE_MINUTES, - redirect_url_main_page, -) from .dependencies import get_current_user -from .utils import create_access_token router = APIRouter() -sso = GitHubSSO( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - redirect_uri=redirect_url, - allow_insecure_http=True, -) @router.get("/user") @@ -33,33 +15,3 @@ def user(current_user=Depends(get_current_user)): @router.post("/token") def token(request: Request): return request.cookies.get("Authorization") - - -@router.get("/auth/login") -async def auth_init(): - return await sso.get_login_redirect() - - -@router.get("/auth/callback") -async def auth_callback(request: Request): - user = await sso.verify_and_process(request) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data=dict(user), expires_delta=access_token_expires - ) - response = RedirectResponse(redirect_url_main_page) - response.set_cookie( - "Authorization", - value=f"Bearer {access_token}", - httponly=sso.allow_insecure_http, - max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60, - expires=ACCESS_TOKEN_EXPIRE_MINUTES * 60, - ) - return response - - -@router.get("/auth/logout") -async def auth_logout(): - response = RedirectResponse(redirect_url_main_page) - response.delete_cookie("Authorization") - return response diff --git a/demo/utils.py b/demo/utils.py deleted file mode 100644 index d8f0716..0000000 --- a/demo/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional - -from jose import jwt - -from .config import SECRET_KEY, ALGORITHM - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index 044e2c5..ccf89d9 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -21,7 +21,7 @@ class SSOBase: client_id: str = None client_secret: str = None - redirect_uri: Optional[str] = None + callback_url: Optional[str] = None allow_insecure_http: bool = False scope: Optional[List[str]] = None state: Optional[str] = None @@ -36,13 +36,13 @@ def __init__( self, client_id: str, client_secret: str, - redirect_uri: Optional[str] = None, + callback_url: Optional[str] = None, allow_insecure_http: bool = False, scope: Optional[List[str]] = None, ): self.client_id = client_id self.client_secret = client_secret - self.redirect_uri = redirect_uri + self.callback_url = callback_url self.allow_insecure_http = allow_insecure_http if allow_insecure_http: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -71,9 +71,9 @@ async def get_login_url( ) -> Any: self.state = state params = params or {} - redirect_uri = redirect_uri or self.redirect_uri + redirect_uri = redirect_uri or self.callback_url if redirect_uri is None: - raise ValueError("redirect_uri must be provided, either at construction or request time") + raise ValueError("callback_url must be provided, either at construction or request time") return self.oauth_client.prepare_request_uri( self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params ) @@ -113,7 +113,7 @@ async def verify_and_process( token_url, headers, content = self.oauth_client.prepare_token_request( self.token_endpoint, authorization_response=current_url, - redirect_url=redirect_uri or self.redirect_uri or current_path, + redirect_url=redirect_uri or self.callback_url or current_path, code=request.query_params.get("code"), **params, ) diff --git a/fastapi_oauth2/config.py b/fastapi_oauth2/config.py new file mode 100644 index 0000000..46cca37 --- /dev/null +++ b/fastapi_oauth2/config.py @@ -0,0 +1,14 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +CLIENT_ID = os.getenv("CLIENT_ID") +CLIENT_SECRET = os.getenv("CLIENT_SECRET") +CALLBACK_URL = os.getenv("CALLBACK_URL") +REDIRECT_URL = os.getenv("REDIRECT_URL") + +JWT_SECRET = os.getenv("JWT_SECRET") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") +JWT_EXPIRES = int(os.getenv("JWT_EXPIRES")) diff --git a/fastapi_oauth2/router.py b/fastapi_oauth2/router.py new file mode 100644 index 0000000..2260b8a --- /dev/null +++ b/fastapi_oauth2/router.py @@ -0,0 +1,53 @@ +from datetime import timedelta + +from fastapi import APIRouter +from fastapi.responses import RedirectResponse +from starlette.requests import Request + +from fastapi_oauth2.github import GitHubSSO +from .config import ( + CLIENT_ID, + CLIENT_SECRET, + CALLBACK_URL, + JWT_EXPIRES, + REDIRECT_URL, +) +from .utils import create_access_token + +router = APIRouter() +sso = GitHubSSO( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + callback_url=CALLBACK_URL, + allow_insecure_http=True, +) + + +@router.get("/auth/login") +async def login(): + return await sso.get_login_redirect() + + +@router.get("/auth/callback") +async def callback(request: Request): + user = await sso.verify_and_process(request) + expires_delta = timedelta(minutes=JWT_EXPIRES) + access_token = create_access_token( + data=dict(user), expires_delta=expires_delta + ) + response = RedirectResponse(REDIRECT_URL) + response.set_cookie( + "Authorization", + value=f"Bearer {access_token}", + httponly=sso.allow_insecure_http, + max_age=JWT_EXPIRES * 60, + expires=JWT_EXPIRES * 60, + ) + return response + + +@router.get("/auth/logout") +async def logout(): + response = RedirectResponse(REDIRECT_URL) + response.delete_cookie("Authorization") + return response diff --git a/fastapi_oauth2/utils.py b/fastapi_oauth2/utils.py new file mode 100644 index 0000000..b1116c7 --- /dev/null +++ b/fastapi_oauth2/utils.py @@ -0,0 +1,11 @@ +from datetime import datetime, timedelta +from typing import Optional + +from jose import jwt + +from .config import JWT_SECRET, JWT_ALGORITHM + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + expire = datetime.utcnow() + expires_delta if expires_delta else timedelta(minutes=15) + return jwt.encode({**data, "exp": expire}, JWT_SECRET, algorithm=JWT_ALGORITHM) diff --git a/main.py b/main.py index 6dbb2f8..1f51144 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,15 @@ import json -import jwt from fastapi import FastAPI, Request, APIRouter from fastapi.responses import HTMLResponse +from fastapi.security.utils import get_authorization_scheme_param from fastapi.templating import Jinja2Templates from starlette.authentication import AuthenticationBackend from starlette.middleware.authentication import AuthenticationMiddleware -from demo.config import SECRET_KEY, ALGORITHM -from demo.router import router as auth_router +from demo.dependencies import get_current_user +from demo.router import router as demo_router +from fastapi_oauth2.router import router as oauth2_router router = APIRouter() templates = Jinja2Templates(directory="templates") @@ -21,20 +22,19 @@ async def root(request: Request): app = FastAPI() app.include_router(router) -app.include_router(auth_router) +app.include_router(demo_router) +app.include_router(oauth2_router) class BearerTokenAuthBackend(AuthenticationBackend): async def authenticate(self, request): authorization = request.cookies.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) - if not authorization: + if not scheme or not param: return "", None - access_token = authorization.split(" ")[1] - user = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM]) - - return authorization, user + return authorization, await get_current_user(param) @app.on_event('startup') From 1a02068d56b52278878e7949b564a4d835765619 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 22 Jun 2023 20:01:30 +0400 Subject: [PATCH 06/24] Refactor env vars and cookie setting --- .env | 8 ++++---- demo/dependencies.py | 6 +++--- fastapi_oauth2/base.py | 35 ++++++++++++++++++++++++++++------ fastapi_oauth2/config.py | 10 +++++----- fastapi_oauth2/github.py | 4 ++-- fastapi_oauth2/router.py | 41 ++++++++++++---------------------------- fastapi_oauth2/utils.py | 17 ++++++++++++----- 7 files changed, 67 insertions(+), 54 deletions(-) diff --git a/.env b/.env index dc3a160..a7b56d0 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ -CLIENT_ID=eccd08d6736b7999a32a -CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 -CALLBACK_URL=http://127.0.0.1:8000/auth/callback -REDIRECT_URL=http://127.0.0.1:8000/ +OAUTH2_CLIENT_ID=eccd08d6736b7999a32a +OAUTH2_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 +OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/auth/callback +OAUTH2_REDIRECT_URL=http://127.0.0.1:8000/ JWT_SECRET=secret JWT_ALGORITHM=HS256 diff --git a/demo/dependencies.py b/demo/dependencies.py index 13fd5d1..5934307 100644 --- a/demo/dependencies.py +++ b/demo/dependencies.py @@ -4,11 +4,11 @@ from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel from fastapi.security import OAuth2 from fastapi.security.utils import get_authorization_scheme_param -from jose import jwt, JWTError +from jose import JWTError from starlette.requests import Request from starlette.status import HTTP_403_FORBIDDEN -from fastapi_oauth2.config import JWT_SECRET, JWT_ALGORITHM +from fastapi_oauth2.utils import jwt_decode class OAuth2PasswordBearerCookie(OAuth2): @@ -44,7 +44,7 @@ async def __call__(self, request: Request) -> Optional[str]: async def get_current_user(token: str = Depends(oauth2_scheme)): try: - return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return jwt_decode(token) except JWTError: raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index ccf89d9..9d02be0 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -9,14 +9,17 @@ from starlette.requests import Request from starlette.responses import RedirectResponse +from .config import JWT_EXPIRES, OAUTH2_REDIRECT_URL +from .utils import create_access_token -class SSOLoginError(HTTPException): + +class OAuth2LoginError(HTTPException): """Raised when any login-related error occurs (such as when user is not verified or if there was an attempt for fake login) """ -class SSOBase: +class OAuth2Base: """Base class (mixin) for all SSO providers""" client_id: str = None @@ -78,7 +81,7 @@ async def get_login_url( self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params ) - async def get_login_redirect( + async def login_redirect( self, *, redirect_uri: Optional[str] = None, @@ -88,7 +91,7 @@ async def get_login_redirect( login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state) return RedirectResponse(login_uri, 303) - async def verify_and_process( + async def get_token_data( self, request: Request, *, @@ -100,9 +103,9 @@ async def verify_and_process( additional_headers = headers or {} additional_headers.update(self.additional_headers or {}) if not request.query_params.get("code"): - raise SSOLoginError(400, "'code' parameter was not found in callback request") + raise OAuth2LoginError(400, "'code' parameter was not found in callback request") if self.state != request.query_params.get("state"): - raise SSOLoginError(400, "'state' parameter does not match") + raise OAuth2LoginError(400, "'state' parameter does not match") url = request.url scheme = "http" if self.allow_insecure_http else "https" @@ -129,3 +132,23 @@ async def verify_and_process( content = response.json() return content + + async def token_redirect( + self, + request: Request, + *, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + redirect_uri: Optional[str] = None, + ) -> RedirectResponse: + token_data = await self.get_token_data(request, params=params, headers=headers, redirect_uri=redirect_uri) + access_token = create_access_token(token_data) + response = RedirectResponse(OAUTH2_REDIRECT_URL) + response.set_cookie( + "Authorization", + value=f"Bearer {access_token}", + httponly=self.allow_insecure_http, + max_age=JWT_EXPIRES * 60, + expires=JWT_EXPIRES * 60, + ) + return response diff --git a/fastapi_oauth2/config.py b/fastapi_oauth2/config.py index 46cca37..f534137 100644 --- a/fastapi_oauth2/config.py +++ b/fastapi_oauth2/config.py @@ -4,11 +4,11 @@ load_dotenv() -CLIENT_ID = os.getenv("CLIENT_ID") -CLIENT_SECRET = os.getenv("CLIENT_SECRET") -CALLBACK_URL = os.getenv("CALLBACK_URL") -REDIRECT_URL = os.getenv("REDIRECT_URL") +OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") +OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") +OAUTH2_CALLBACK_URL = os.getenv("OAUTH2_CALLBACK_URL") +OAUTH2_REDIRECT_URL = os.getenv("OAUTH2_REDIRECT_URL") JWT_SECRET = os.getenv("JWT_SECRET") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") -JWT_EXPIRES = int(os.getenv("JWT_EXPIRES")) +JWT_EXPIRES = int(os.getenv("JWT_EXPIRES", "15")) diff --git a/fastapi_oauth2/github.py b/fastapi_oauth2/github.py index 2f9408b..954e377 100644 --- a/fastapi_oauth2/github.py +++ b/fastapi_oauth2/github.py @@ -1,7 +1,7 @@ -from .base import SSOBase +from .base import OAuth2Base -class GitHubSSO(SSOBase): +class GitHubOAuth2(OAuth2Base): """Class providing login via GitHub SSO""" scope = ["user:email"] diff --git a/fastapi_oauth2/router.py b/fastapi_oauth2/router.py index 2260b8a..8828031 100644 --- a/fastapi_oauth2/router.py +++ b/fastapi_oauth2/router.py @@ -1,53 +1,36 @@ -from datetime import timedelta - from fastapi import APIRouter from fastapi.responses import RedirectResponse from starlette.requests import Request -from fastapi_oauth2.github import GitHubSSO +from fastapi_oauth2.github import GitHubOAuth2 from .config import ( - CLIENT_ID, - CLIENT_SECRET, - CALLBACK_URL, - JWT_EXPIRES, - REDIRECT_URL, + OAUTH2_CLIENT_ID, + OAUTH2_CLIENT_SECRET, + OAUTH2_CALLBACK_URL, + OAUTH2_REDIRECT_URL, ) -from .utils import create_access_token router = APIRouter() -sso = GitHubSSO( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - callback_url=CALLBACK_URL, +oauth2 = GitHubOAuth2( + client_id=OAUTH2_CLIENT_ID, + client_secret=OAUTH2_CLIENT_SECRET, + callback_url=OAUTH2_CALLBACK_URL, allow_insecure_http=True, ) @router.get("/auth/login") async def login(): - return await sso.get_login_redirect() + return await oauth2.login_redirect() @router.get("/auth/callback") async def callback(request: Request): - user = await sso.verify_and_process(request) - expires_delta = timedelta(minutes=JWT_EXPIRES) - access_token = create_access_token( - data=dict(user), expires_delta=expires_delta - ) - response = RedirectResponse(REDIRECT_URL) - response.set_cookie( - "Authorization", - value=f"Bearer {access_token}", - httponly=sso.allow_insecure_http, - max_age=JWT_EXPIRES * 60, - expires=JWT_EXPIRES * 60, - ) - return response + return await oauth2.token_redirect(request) @router.get("/auth/logout") async def logout(): - response = RedirectResponse(REDIRECT_URL) + response = RedirectResponse(OAUTH2_REDIRECT_URL) response.delete_cookie("Authorization") return response diff --git a/fastapi_oauth2/utils.py b/fastapi_oauth2/utils.py index b1116c7..02a9a2e 100644 --- a/fastapi_oauth2/utils.py +++ b/fastapi_oauth2/utils.py @@ -1,11 +1,18 @@ from datetime import datetime, timedelta -from typing import Optional from jose import jwt -from .config import JWT_SECRET, JWT_ALGORITHM +from .config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRES -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - expire = datetime.utcnow() + expires_delta if expires_delta else timedelta(minutes=15) - return jwt.encode({**data, "exp": expire}, JWT_SECRET, algorithm=JWT_ALGORITHM) +def jwt_encode(data: dict) -> str: + return jwt.encode(data, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def jwt_decode(token: str) -> dict: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + + +def create_access_token(token_data: dict) -> str: + expire = datetime.utcnow() + timedelta(minutes=JWT_EXPIRES) + return jwt_encode({**token_data, "exp": expire}) From d8bbbc7a1f1656e54edc9754b78a29c903328337 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 23 Jun 2023 18:39:43 +0400 Subject: [PATCH 07/24] Define the desired list of features --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d0ca719..e925b0c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fastapi-oauth2 -Easy to setup social authentication mechanism with support for several auth providers. +Easy to setup OAuth2 social authentication mechanism with support for several auth providers. ## Demo @@ -16,3 +16,11 @@ uvicorn main:app --reload - Make the [**fastapi-oauth2**](./fastapi_oauth2) depend on (overuse) the [**social-core**](https://github.com/python-social-auth/social-core) + +## Features + +- Integrate with any existing FastAPI project (no dependencies of the project should stop the work of + the `fastapi-oauth2`) +- Use multiple OAuth2 providers at the same time +- Token to user data, user data to token easy conversion +- Customize OAuth2 routes From cf054441879ece46735ea4a657b6cc840af8f821 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 23 Jun 2023 18:40:35 +0400 Subject: [PATCH 08/24] Change the route prefixes --- .env | 2 +- fastapi_oauth2/base.py | 2 +- fastapi_oauth2/router.py | 10 +++++----- templates/index.html | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index a7b56d0..c644a5c 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ OAUTH2_CLIENT_ID=eccd08d6736b7999a32a OAUTH2_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 -OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/auth/callback +OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/oauth2/token OAUTH2_REDIRECT_URL=http://127.0.0.1:8000/ JWT_SECRET=secret diff --git a/fastapi_oauth2/base.py b/fastapi_oauth2/base.py index 9d02be0..e03b1d1 100644 --- a/fastapi_oauth2/base.py +++ b/fastapi_oauth2/base.py @@ -98,7 +98,7 @@ async def get_token_data( params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None, redirect_uri: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[Dict[str, Any]]: params = params or {} additional_headers = headers or {} additional_headers.update(self.additional_headers or {}) diff --git a/fastapi_oauth2/router.py b/fastapi_oauth2/router.py index 8828031..3a0b2d5 100644 --- a/fastapi_oauth2/router.py +++ b/fastapi_oauth2/router.py @@ -10,7 +10,7 @@ OAUTH2_REDIRECT_URL, ) -router = APIRouter() +router = APIRouter(prefix="/oauth2") oauth2 = GitHubOAuth2( client_id=OAUTH2_CLIENT_ID, client_secret=OAUTH2_CLIENT_SECRET, @@ -19,17 +19,17 @@ ) -@router.get("/auth/login") +@router.get("/login") async def login(): return await oauth2.login_redirect() -@router.get("/auth/callback") -async def callback(request: Request): +@router.get("/token") +async def token(request: Request): return await oauth2.token_redirect(request) -@router.get("/auth/logout") +@router.get("/logout") async def logout(): response = RedirectResponse(OAUTH2_REDIRECT_URL) response.delete_cookie("Authorization") diff --git a/templates/index.html b/templates/index.html index 2d4e6fc..a15b0c2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,10 +11,10 @@
{% if request.user %} - Sign out + Sign out Pic {% else %} - + Date: Fri, 23 Jun 2023 19:08:05 +0400 Subject: [PATCH 09/24] Shift the package source in the project tree (move to `src`) --- fastapi_oauth2/__init__.py | 0 src/fastapi_oauth2/__init__.py | 1 + {fastapi_oauth2 => src/fastapi_oauth2}/base.py | 0 {fastapi_oauth2 => src/fastapi_oauth2}/config.py | 0 {fastapi_oauth2 => src/fastapi_oauth2}/github.py | 0 {fastapi_oauth2 => src/fastapi_oauth2}/router.py | 0 {fastapi_oauth2 => src/fastapi_oauth2}/utils.py | 0 7 files changed, 1 insertion(+) delete mode 100644 fastapi_oauth2/__init__.py create mode 100644 src/fastapi_oauth2/__init__.py rename {fastapi_oauth2 => src/fastapi_oauth2}/base.py (100%) rename {fastapi_oauth2 => src/fastapi_oauth2}/config.py (100%) rename {fastapi_oauth2 => src/fastapi_oauth2}/github.py (100%) rename {fastapi_oauth2 => src/fastapi_oauth2}/router.py (100%) rename {fastapi_oauth2 => src/fastapi_oauth2}/utils.py (100%) diff --git a/fastapi_oauth2/__init__.py b/fastapi_oauth2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/fastapi_oauth2/__init__.py b/src/fastapi_oauth2/__init__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/src/fastapi_oauth2/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/fastapi_oauth2/base.py b/src/fastapi_oauth2/base.py similarity index 100% rename from fastapi_oauth2/base.py rename to src/fastapi_oauth2/base.py diff --git a/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py similarity index 100% rename from fastapi_oauth2/config.py rename to src/fastapi_oauth2/config.py diff --git a/fastapi_oauth2/github.py b/src/fastapi_oauth2/github.py similarity index 100% rename from fastapi_oauth2/github.py rename to src/fastapi_oauth2/github.py diff --git a/fastapi_oauth2/router.py b/src/fastapi_oauth2/router.py similarity index 100% rename from fastapi_oauth2/router.py rename to src/fastapi_oauth2/router.py diff --git a/fastapi_oauth2/utils.py b/src/fastapi_oauth2/utils.py similarity index 100% rename from fastapi_oauth2/utils.py rename to src/fastapi_oauth2/utils.py From 3f356eb429b090c119e04d8e41144e908197a686 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 23 Jun 2023 19:09:06 +0400 Subject: [PATCH 10/24] Configure the setup metadata and build-system --- pyproject.toml | 7 +++++++ setup.cfg | 45 +++++++++++++++++++++++++++++++++++++++++++++ setup.py | 4 ++++ 3 files changed, 56 insertions(+) create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6063eaa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["setuptools>=42.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..22ec0f7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,45 @@ +[metadata] +name = fastapi-oauth2 +version = attr: fastapi_oauth2.__version__ +author = Artyom Vancyan +author_email = artyom@pysnippet.org +description = Easy to setup OAuth2 social authentication mechanism with support for several auth providers. +long_description = file: README.md +long_description_content_type = text/markdown +project_urls = + Documentation=https://github.com/pysnippet/fastapi-oauth2/ + Source Code=https://github.com/pysnippet/fastapi-oauth2/ +keywords = + python + auth + login + social + oauth2 + fastapi + authentication +license = MIT +license_files = LICENSE +platforms = unix, linux, osx, win32 +classifiers = + Operating System :: OS Independent + Development Status :: 1 - Planning + Framework :: FastAPI + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + License :: OSI Approved :: MIT License + +[options] +packages = + fastapi_oauth2 +install_requires = + fastapi>=0.85.2 +include_package_data = yes +python_requires = >=3.6 +package_dir = + =src +zip_safe = no \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7f1a176 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup() From b8d2c50556e6e3a819edba62d3fb2f97a706c24a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 23 Jun 2023 19:11:07 +0400 Subject: [PATCH 11/24] Automate the wheel building to avoid editable mode bugs --- build.sh | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 build.sh diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..b8d3a87 --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# last version of `build` supporting Python 3.6 +pip install build==0.9.0 + +# build the wheel and install it +WHEEL_NAME=$(python -m build | grep -Po "fastapi_oauth2-.*\.whl" | tail -n 1) +pip install dist/$WHEEL_NAME \ No newline at end of file From d0e208e146da4cbff766ef1721e6e5b9cefaa052 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 24 Jun 2023 20:31:46 +0400 Subject: [PATCH 12/24] Create the core middleware and integrate --- main.py | 31 +++++++++----------------- src/fastapi_oauth2/base.py | 21 ++++++------------ src/fastapi_oauth2/middleware.py | 38 ++++++++++++++++++++++++++++++++ src/fastapi_oauth2/types.py | 32 +++++++++++++++++++++++++++ src/fastapi_oauth2/utils.py | 2 +- 5 files changed, 89 insertions(+), 35 deletions(-) create mode 100644 src/fastapi_oauth2/middleware.py create mode 100644 src/fastapi_oauth2/types.py diff --git a/main.py b/main.py index 1f51144..3eb21b4 100644 --- a/main.py +++ b/main.py @@ -2,13 +2,10 @@ from fastapi import FastAPI, Request, APIRouter from fastapi.responses import HTMLResponse -from fastapi.security.utils import get_authorization_scheme_param from fastapi.templating import Jinja2Templates -from starlette.authentication import AuthenticationBackend -from starlette.middleware.authentication import AuthenticationMiddleware -from demo.dependencies import get_current_user from demo.router import router as demo_router +from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.router import router as oauth2_router router = APIRouter() @@ -24,19 +21,13 @@ async def root(request: Request): app.include_router(router) app.include_router(demo_router) app.include_router(oauth2_router) - - -class BearerTokenAuthBackend(AuthenticationBackend): - async def authenticate(self, request): - authorization = request.cookies.get("Authorization") - scheme, param = get_authorization_scheme_param(authorization) - - if not scheme or not param: - return "", None - - return authorization, await get_current_user(param) - - -@app.on_event('startup') -async def startup(): - app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend()) +app.add_middleware(OAuth2Middleware, config={ + "allow_http": True, + "providers": { + "github": { + "client_id": "eccd08d6736b7999a32a", + "client_secret": "642999c1c5f2b3df8b877afdc78252ef5b594d31", + "redirect_uri": "http://127.0.0.1:8000/", + }, + } +}) diff --git a/src/fastapi_oauth2/base.py b/src/fastapi_oauth2/base.py index e03b1d1..e8b5f80 100644 --- a/src/fastapi_oauth2/base.py +++ b/src/fastapi_oauth2/base.py @@ -10,7 +10,7 @@ from starlette.responses import RedirectResponse from .config import JWT_EXPIRES, OAUTH2_REDIRECT_URL -from .utils import create_access_token +from .utils import jwt_create class OAuth2LoginError(HTTPException): @@ -68,27 +68,22 @@ def refresh_token(self) -> Optional[str]: async def get_login_url( self, *, - redirect_uri: Optional[str] = None, params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> Any: self.state = state params = params or {} - redirect_uri = redirect_uri or self.callback_url - if redirect_uri is None: - raise ValueError("callback_url must be provided, either at construction or request time") return self.oauth_client.prepare_request_uri( - self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params + self.authorization_endpoint, redirect_uri=self.callback_url, state=state, scope=self.scope, **params ) async def login_redirect( self, *, - redirect_uri: Optional[str] = None, params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> RedirectResponse: - login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state) + login_uri = await self.get_login_url(params=params, state=state) return RedirectResponse(login_uri, 303) async def get_token_data( @@ -97,7 +92,6 @@ async def get_token_data( *, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None, - redirect_uri: Optional[str] = None, ) -> Optional[Dict[str, Any]]: params = params or {} additional_headers = headers or {} @@ -116,7 +110,7 @@ async def get_token_data( token_url, headers, content = self.oauth_client.prepare_token_request( self.token_endpoint, authorization_response=current_url, - redirect_url=redirect_uri or self.callback_url or current_path, + redirect_url=self.callback_url or current_path, code=request.query_params.get("code"), **params, ) @@ -131,7 +125,7 @@ async def get_token_data( response = await session.get(url, headers=headers) content = response.json() - return content + return {**content, "scope": self.scope} async def token_redirect( self, @@ -139,10 +133,9 @@ async def token_redirect( *, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None, - redirect_uri: Optional[str] = None, ) -> RedirectResponse: - token_data = await self.get_token_data(request, params=params, headers=headers, redirect_uri=redirect_uri) - access_token = create_access_token(token_data) + token_data = await self.get_token_data(request, params=params, headers=headers) + access_token = jwt_create(token_data) response = RedirectResponse(OAUTH2_REDIRECT_URL) response.set_cookie( "Authorization", diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py new file mode 100644 index 0000000..023b06e --- /dev/null +++ b/src/fastapi_oauth2/middleware.py @@ -0,0 +1,38 @@ +from typing import Optional, Tuple, Union + +from fastapi.security.utils import get_authorization_scheme_param +from starlette.authentication import AuthenticationBackend, AuthCredentials +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request +from starlette.types import Send, Receive, Scope, ASGIApp + +from .types import Config +from .types import ConfigParams +from .utils import jwt_decode + + +class OAuth2Backend(AuthenticationBackend): + async def authenticate(self, request: Request) -> Optional[Tuple["AuthCredentials", Optional[dict]]]: + authorization = request.cookies.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + + if not scheme or not param: + return AuthCredentials(), None + + access_token = jwt_decode(param) + scope = access_token.pop("scope") + return AuthCredentials(scope), access_token + + +class OAuth2Middleware: + def __init__(self, app: ASGIApp, config: Union[Config, ConfigParams]) -> None: + if isinstance(config, Config): + self.config = config + elif isinstance(config, dict): + self.config = Config(**config) + else: + raise ValueError("config does not contain valid parameters") + self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend()) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.auth_middleware(scope, receive, send) diff --git a/src/fastapi_oauth2/types.py b/src/fastapi_oauth2/types.py new file mode 100644 index 0000000..ce79fd8 --- /dev/null +++ b/src/fastapi_oauth2/types.py @@ -0,0 +1,32 @@ +from enum import Enum +from typing import Dict, TypedDict + + +class OAuth2Provider(str, Enum): + github = "github" + + +class OAuth2Client(Dict[str, str]): + client_id: str + client_secret: str + redirect_uri: str + + +class ConfigParams(TypedDict): + allow_http: bool + jwt_secret: str + jwt_expires: int + jwt_algorithm: str + providers: Dict[OAuth2Provider, OAuth2Client] + + +class Config: + allow_http: bool = False + jwt_secret: str = "" + jwt_expires: int = 900 + jwt_algorithm: str = "HS256" + providers: Dict[OAuth2Provider, OAuth2Client] = {} + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) diff --git a/src/fastapi_oauth2/utils.py b/src/fastapi_oauth2/utils.py index 02a9a2e..d77db77 100644 --- a/src/fastapi_oauth2/utils.py +++ b/src/fastapi_oauth2/utils.py @@ -13,6 +13,6 @@ def jwt_decode(token: str) -> dict: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) -def create_access_token(token_data: dict) -> str: +def jwt_create(token_data: dict) -> str: expire = datetime.utcnow() + timedelta(minutes=JWT_EXPIRES) return jwt_encode({**token_data, "exp": expire}) From 40284e881f32bdb910fe79bfb7f89cf57c49c658 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 24 Jun 2023 23:23:03 +0400 Subject: [PATCH 13/24] Move exceptions to a separate module --- src/fastapi_oauth2/base.py | 10 ++-------- src/fastapi_oauth2/exceptions.py | 7 +++++++ 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 src/fastapi_oauth2/exceptions.py diff --git a/src/fastapi_oauth2/base.py b/src/fastapi_oauth2/base.py index e8b5f80..177c787 100644 --- a/src/fastapi_oauth2/base.py +++ b/src/fastapi_oauth2/base.py @@ -5,21 +5,15 @@ import httpx from oauthlib.oauth2 import WebApplicationClient -from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import RedirectResponse from .config import JWT_EXPIRES, OAUTH2_REDIRECT_URL +from .exceptions import OAuth2LoginError from .utils import jwt_create -class OAuth2LoginError(HTTPException): - """Raised when any login-related error occurs - (such as when user is not verified or if there was an attempt for fake login) - """ - - -class OAuth2Base: +class OAuth2Core: """Base class (mixin) for all SSO providers""" client_id: str = None diff --git a/src/fastapi_oauth2/exceptions.py b/src/fastapi_oauth2/exceptions.py new file mode 100644 index 0000000..31d60fa --- /dev/null +++ b/src/fastapi_oauth2/exceptions.py @@ -0,0 +1,7 @@ +from starlette.exceptions import HTTPException + + +class OAuth2LoginError(HTTPException): + """Raised when any login-related error occurs + (such as when user is not verified or if there was an attempt for fake login) + """ From f0c5434f001028655e61346f15f0cce76c3364bf Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 24 Jun 2023 23:23:46 +0400 Subject: [PATCH 14/24] Add a few notes about usage expectations --- README.md | 4 ++- main.py | 17 +++++---- src/fastapi_oauth2/middleware.py | 9 +++-- src/fastapi_oauth2/types.py | 60 ++++++++++++++++++++------------ 4 files changed, 54 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e925b0c..2d06d4e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ uvicorn main:app --reload - Integrate with any existing FastAPI project (no dependencies of the project should stop the work of the `fastapi-oauth2`) + * Implementation must allow to provide a context for configurations (also, see how it is done in another projects) - Use multiple OAuth2 providers at the same time -- Token to user data, user data to token easy conversion + * There need to be provided a way to configure the OAuth2 for multiple providers +- Token -> user data, user data -> token easy conversion - Customize OAuth2 routes diff --git a/main.py b/main.py index 3eb21b4..a3f6a3a 100644 --- a/main.py +++ b/main.py @@ -3,10 +3,12 @@ from fastapi import FastAPI, Request, APIRouter from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from social_core.backends.github import GithubOAuth2 from demo.router import router as demo_router from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.router import router as oauth2_router +from fastapi_oauth2.types import OAuth2Client router = APIRouter() templates = Jinja2Templates(directory="templates") @@ -23,11 +25,12 @@ async def root(request: Request): app.include_router(oauth2_router) app.add_middleware(OAuth2Middleware, config={ "allow_http": True, - "providers": { - "github": { - "client_id": "eccd08d6736b7999a32a", - "client_secret": "642999c1c5f2b3df8b877afdc78252ef5b594d31", - "redirect_uri": "http://127.0.0.1:8000/", - }, - } + "clients": [ + OAuth2Client( + backend=GithubOAuth2, + client_id="eccd08d6736b7999a32a", + client_secret="642999c1c5f2b3df8b877afdc78252ef5b594d31", + redirect_uri="http://127.0.0.1:8000/", + ), + ] }) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 023b06e..3cd933e 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -6,8 +6,7 @@ from starlette.requests import Request from starlette.types import Send, Receive, Scope, ASGIApp -from .types import Config -from .types import ConfigParams +from .types import OAuth2Config from .utils import jwt_decode @@ -25,11 +24,11 @@ async def authenticate(self, request: Request) -> Optional[Tuple["AuthCredential class OAuth2Middleware: - def __init__(self, app: ASGIApp, config: Union[Config, ConfigParams]) -> None: - if isinstance(config, Config): + def __init__(self, app: ASGIApp, config: Union[OAuth2Config, dict]) -> None: + if isinstance(config, OAuth2Config): self.config = config elif isinstance(config, dict): - self.config = Config(**config) + self.config = OAuth2Config(**config) else: raise ValueError("config does not contain valid parameters") self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend()) diff --git a/src/fastapi_oauth2/types.py b/src/fastapi_oauth2/types.py index ce79fd8..0b8a64c 100644 --- a/src/fastapi_oauth2/types.py +++ b/src/fastapi_oauth2/types.py @@ -1,32 +1,46 @@ -from enum import Enum -from typing import Dict, TypedDict +from typing import List, Optional, Type +from social_core.backends.oauth import BaseOAuth2 -class OAuth2Provider(str, Enum): - github = "github" - -class OAuth2Client(Dict[str, str]): +class OAuth2Client: + backend: Type[BaseOAuth2] client_id: str client_secret: str - redirect_uri: str - - -class ConfigParams(TypedDict): + redirect_uri: Optional[str] + + def __init__( + self, + *, + backend: Type[BaseOAuth2], + client_id: str, + client_secret: str, + redirect_uri: Optional[str] = None, + ): + self.backend = backend + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + +class OAuth2Config: allow_http: bool jwt_secret: str jwt_expires: int jwt_algorithm: str - providers: Dict[OAuth2Provider, OAuth2Client] - - -class Config: - allow_http: bool = False - jwt_secret: str = "" - jwt_expires: int = 900 - jwt_algorithm: str = "HS256" - providers: Dict[OAuth2Provider, OAuth2Client] = {} - - def __init__(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) + clients: List[OAuth2Client] + + def __init__( + self, + *, + allow_http: bool = False, + jwt_secret: str = "", + jwt_expires: int = 900, + jwt_algorithm: str = "HS256", + clients: List[OAuth2Client] = None, + ): + self.allow_http = allow_http + self.jwt_secret = jwt_secret + self.jwt_expires = jwt_expires + self.jwt_algorithm = jwt_algorithm + self.clients = clients or [] From e3a7450ec44cfce74b76db2fb4e4d71c215488ea Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 25 Jun 2023 16:54:41 +0400 Subject: [PATCH 15/24] Remove usage of the `OAUTH2_REDIRECT_URL` env var --- .env | 1 - src/fastapi_oauth2/config.py | 27 ++++++++++++++- src/fastapi_oauth2/{base.py => core.py} | 4 +-- src/fastapi_oauth2/github.py | 4 +-- src/fastapi_oauth2/middleware.py | 19 +++++++--- src/fastapi_oauth2/router.py | 10 +++--- src/fastapi_oauth2/types.py | 46 ------------------------- templates/index.html | 2 +- 8 files changed, 50 insertions(+), 63 deletions(-) rename src/fastapi_oauth2/{base.py => core.py} (97%) delete mode 100644 src/fastapi_oauth2/types.py diff --git a/.env b/.env index c644a5c..aa1f395 100644 --- a/.env +++ b/.env @@ -1,7 +1,6 @@ OAUTH2_CLIENT_ID=eccd08d6736b7999a32a OAUTH2_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/oauth2/token -OAUTH2_REDIRECT_URL=http://127.0.0.1:8000/ JWT_SECRET=secret JWT_ALGORITHM=HS256 diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index f534137..a59d6a5 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -1,14 +1,39 @@ import os +from typing import List from dotenv import load_dotenv +from .client import OAuth2Client + load_dotenv() OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") OAUTH2_CALLBACK_URL = os.getenv("OAUTH2_CALLBACK_URL") -OAUTH2_REDIRECT_URL = os.getenv("OAUTH2_REDIRECT_URL") JWT_SECRET = os.getenv("JWT_SECRET") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") JWT_EXPIRES = int(os.getenv("JWT_EXPIRES", "15")) + + +class OAuth2Config: + allow_http: bool + jwt_secret: str + jwt_expires: int + jwt_algorithm: str + clients: List[OAuth2Client] + + def __init__( + self, + *, + allow_http: bool = False, + jwt_secret: str = "", + jwt_expires: int = 900, + jwt_algorithm: str = "HS256", + clients: List[OAuth2Client] = None, + ): + self.allow_http = allow_http + self.jwt_secret = jwt_secret + self.jwt_expires = jwt_expires + self.jwt_algorithm = jwt_algorithm + self.clients = clients or [] diff --git a/src/fastapi_oauth2/base.py b/src/fastapi_oauth2/core.py similarity index 97% rename from src/fastapi_oauth2/base.py rename to src/fastapi_oauth2/core.py index 177c787..97a759e 100644 --- a/src/fastapi_oauth2/base.py +++ b/src/fastapi_oauth2/core.py @@ -8,7 +8,7 @@ from starlette.requests import Request from starlette.responses import RedirectResponse -from .config import JWT_EXPIRES, OAUTH2_REDIRECT_URL +from .config import JWT_EXPIRES from .exceptions import OAuth2LoginError from .utils import jwt_create @@ -130,7 +130,7 @@ async def token_redirect( ) -> RedirectResponse: token_data = await self.get_token_data(request, params=params, headers=headers) access_token = jwt_create(token_data) - response = RedirectResponse(OAUTH2_REDIRECT_URL) + response = RedirectResponse(request.base_url) response.set_cookie( "Authorization", value=f"Bearer {access_token}", diff --git a/src/fastapi_oauth2/github.py b/src/fastapi_oauth2/github.py index 954e377..107280b 100644 --- a/src/fastapi_oauth2/github.py +++ b/src/fastapi_oauth2/github.py @@ -1,7 +1,7 @@ -from .base import OAuth2Base +from .core import OAuth2Core -class GitHubOAuth2(OAuth2Base): +class GitHubOAuth2(OAuth2Core): """Class providing login via GitHub SSO""" scope = ["user:email"] diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 3cd933e..5fa6d6c 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -1,12 +1,18 @@ -from typing import Optional, Tuple, Union +from typing import Optional +from typing import Tuple +from typing import Union from fastapi.security.utils import get_authorization_scheme_param -from starlette.authentication import AuthenticationBackend, AuthCredentials +from starlette.authentication import AuthCredentials +from starlette.authentication import AuthenticationBackend from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request -from starlette.types import Send, Receive, Scope, ASGIApp +from starlette.types import ASGIApp +from starlette.types import Receive +from starlette.types import Scope +from starlette.types import Send -from .types import OAuth2Config +from .config import OAuth2Config from .utils import jwt_decode @@ -24,13 +30,16 @@ async def authenticate(self, request: Request) -> Optional[Tuple["AuthCredential class OAuth2Middleware: + config: OAuth2Config + auth_middleware: AuthenticationMiddleware + def __init__(self, app: ASGIApp, config: Union[OAuth2Config, dict]) -> None: if isinstance(config, OAuth2Config): self.config = config elif isinstance(config, dict): self.config = OAuth2Config(**config) else: - raise ValueError("config does not contain valid parameters") + raise TypeError("config is not a valid type") self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend()) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: diff --git a/src/fastapi_oauth2/router.py b/src/fastapi_oauth2/router.py index 3a0b2d5..652a5ac 100644 --- a/src/fastapi_oauth2/router.py +++ b/src/fastapi_oauth2/router.py @@ -7,7 +7,6 @@ OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_CALLBACK_URL, - OAUTH2_REDIRECT_URL, ) router = APIRouter(prefix="/oauth2") @@ -19,8 +18,9 @@ ) -@router.get("/login") -async def login(): +@router.get("/{provider}/auth") +async def login(provider: str): + print(provider) return await oauth2.login_redirect() @@ -30,7 +30,7 @@ async def token(request: Request): @router.get("/logout") -async def logout(): - response = RedirectResponse(OAUTH2_REDIRECT_URL) +async def logout(request: Request): + response = RedirectResponse(request.base_url) response.delete_cookie("Authorization") return response diff --git a/src/fastapi_oauth2/types.py b/src/fastapi_oauth2/types.py deleted file mode 100644 index 0b8a64c..0000000 --- a/src/fastapi_oauth2/types.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import List, Optional, Type - -from social_core.backends.oauth import BaseOAuth2 - - -class OAuth2Client: - backend: Type[BaseOAuth2] - client_id: str - client_secret: str - redirect_uri: Optional[str] - - def __init__( - self, - *, - backend: Type[BaseOAuth2], - client_id: str, - client_secret: str, - redirect_uri: Optional[str] = None, - ): - self.backend = backend - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - - -class OAuth2Config: - allow_http: bool - jwt_secret: str - jwt_expires: int - jwt_algorithm: str - clients: List[OAuth2Client] - - def __init__( - self, - *, - allow_http: bool = False, - jwt_secret: str = "", - jwt_expires: int = 900, - jwt_algorithm: str = "HS256", - clients: List[OAuth2Client] = None, - ): - self.allow_http = allow_http - self.jwt_secret = jwt_secret - self.jwt_expires = jwt_expires - self.jwt_algorithm = jwt_algorithm - self.clients = clients or [] diff --git a/templates/index.html b/templates/index.html index a15b0c2..a0a77a8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,7 +14,7 @@ Sign out Pic {% else %} - + Date: Sun, 25 Jun 2023 16:55:47 +0400 Subject: [PATCH 16/24] Move the `OAuth2Client` to a separate module --- main.py | 3 ++- src/fastapi_oauth2/client.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/fastapi_oauth2/client.py diff --git a/main.py b/main.py index a3f6a3a..fd7c9a7 100644 --- a/main.py +++ b/main.py @@ -6,9 +6,9 @@ from social_core.backends.github import GithubOAuth2 from demo.router import router as demo_router +from fastapi_oauth2.client import OAuth2Client from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.router import router as oauth2_router -from fastapi_oauth2.types import OAuth2Client router = APIRouter() templates = Jinja2Templates(directory="templates") @@ -31,6 +31,7 @@ async def root(request: Request): client_id="eccd08d6736b7999a32a", client_secret="642999c1c5f2b3df8b877afdc78252ef5b594d31", redirect_uri="http://127.0.0.1:8000/", + scope=["user:email"], ), ] }) diff --git a/src/fastapi_oauth2/client.py b/src/fastapi_oauth2/client.py new file mode 100644 index 0000000..7837188 --- /dev/null +++ b/src/fastapi_oauth2/client.py @@ -0,0 +1,26 @@ +from typing import Optional, Type, Sequence + +from social_core.backends.oauth import BaseOAuth2 + + +class OAuth2Client: + backend: Type[BaseOAuth2] + client_id: str + client_secret: str + redirect_uri: Optional[str] + scope: Optional[Sequence[str]] + + def __init__( + self, + *, + backend: Type[BaseOAuth2], + client_id: str, + client_secret: str, + redirect_uri: Optional[str] = None, + scope: Optional[Sequence[str]] = None, + ): + self.backend = backend + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scope = scope or [] From ca20dace7f2aa6e14d0b60d71441ecd5b85f002f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 25 Jun 2023 18:20:46 +0400 Subject: [PATCH 17/24] Implement `Auth` and `User` response types --- src/fastapi_oauth2/middleware.py | 27 +++++++++++++++++++++------ templates/index.html | 4 ++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 5fa6d6c..c6062e5 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -1,9 +1,9 @@ +from typing import List from typing import Optional from typing import Tuple from typing import Union from fastapi.security.utils import get_authorization_scheme_param -from starlette.authentication import AuthCredentials from starlette.authentication import AuthenticationBackend from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request @@ -16,17 +16,32 @@ from .utils import jwt_decode +class Auth: + scopes: List[str] + + def __init__(self, scopes: Optional[List[str]] = None) -> None: + self.scopes = scopes or [] + + +class User(dict): + is_authenticated: bool + + def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: + self.is_authenticated = seq is not None + super().__init__(seq or {}, **kwargs) + + class OAuth2Backend(AuthenticationBackend): - async def authenticate(self, request: Request) -> Optional[Tuple["AuthCredentials", Optional[dict]]]: + async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"]]: authorization = request.cookies.get("Authorization") scheme, param = get_authorization_scheme_param(authorization) if not scheme or not param: - return AuthCredentials(), None + return Auth(), User() - access_token = jwt_decode(param) - scope = access_token.pop("scope") - return AuthCredentials(scope), access_token + user = jwt_decode(param) + scopes = user.pop("scope") + return Auth(scopes), User(user) class OAuth2Middleware: diff --git a/templates/index.html b/templates/index.html index a0a77a8..f8a7d82 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,7 +10,7 @@
- {% if request.user %} + {% if request.user.is_authenticated %}

Hi, {{ request.user.name }}

This is what your JWT contains currently

{{ json.dumps(request.user, indent=4) }}
From d3299037922efb84699e1c472e84719dc08ae14b Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 14:15:42 +0400 Subject: [PATCH 18/24] Make `OAuth2Core` replace the `GitHubOAuth2` --- src/fastapi_oauth2/core.py | 75 ++++++++++++++++---------------- src/fastapi_oauth2/exceptions.py | 7 --- src/fastapi_oauth2/github.py | 12 ----- 3 files changed, 37 insertions(+), 57 deletions(-) delete mode 100644 src/fastapi_oauth2/exceptions.py delete mode 100644 src/fastapi_oauth2/github.py diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 97a759e..7382711 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -1,16 +1,24 @@ import json -import os import re -from typing import Any, Dict, List, Optional +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from urllib.parse import urljoin import httpx from oauthlib.oauth2 import WebApplicationClient +from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import RedirectResponse -from .config import JWT_EXPIRES -from .exceptions import OAuth2LoginError -from .utils import jwt_create +from .client import OAuth2Client + + +class OAuth2LoginError(HTTPException): + """Raised when any login-related error occurs + (such as when user is not verified or if there was an attempt for fake login) + """ class OAuth2Core: @@ -19,7 +27,7 @@ class OAuth2Core: client_id: str = None client_secret: str = None callback_url: Optional[str] = None - allow_insecure_http: bool = False + allow_http: bool = False scope: Optional[List[str]] = None state: Optional[str] = None _oauth_client: Optional[WebApplicationClient] = None @@ -29,21 +37,15 @@ class OAuth2Core: token_endpoint: str = None userinfo_endpoint: str = None - def __init__( - self, - client_id: str, - client_secret: str, - callback_url: Optional[str] = None, - allow_insecure_http: bool = False, - scope: Optional[List[str]] = None, - ): - self.client_id = client_id - self.client_secret = client_secret - self.callback_url = callback_url - self.allow_insecure_http = allow_insecure_http - if allow_insecure_http: - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - self.scope = scope or self.scope + def __init__(self, client: OAuth2Client) -> None: + self.client_id = client.client_id + self.client_secret = client.client_secret + self.scope = client.scope or self.scope + self.provider = client.backend.name + self.authorization_endpoint = client.backend.AUTHORIZATION_URL + self.token_endpoint = client.backend.ACCESS_TOKEN_URL + self.userinfo_endpoint = "https://api.github.com/user" + self.additional_headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} @property def oauth_client(self) -> WebApplicationClient: @@ -51,33 +53,31 @@ def oauth_client(self) -> WebApplicationClient: self._oauth_client = WebApplicationClient(self.client_id) return self._oauth_client - @property - def access_token(self) -> Optional[str]: - return self.oauth_client.access_token - - @property - def refresh_token(self) -> Optional[str]: - return self.oauth_client.refresh_token + def get_redirect_uri(self, request: Request) -> str: + return urljoin(str(request.base_url), "/oauth2/%s/token" % self.provider) async def get_login_url( self, + request: Request, *, params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> Any: self.state = state params = params or {} + redirect_uri = self.get_redirect_uri(request) return self.oauth_client.prepare_request_uri( - self.authorization_endpoint, redirect_uri=self.callback_url, state=state, scope=self.scope, **params + self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params ) async def login_redirect( self, + request: Request, *, params: Optional[Dict[str, Any]] = None, state: Optional[str] = None, ) -> RedirectResponse: - login_uri = await self.get_login_url(params=params, state=state) + login_uri = await self.get_login_url(request, params=params, state=state) return RedirectResponse(login_uri, 303) async def get_token_data( @@ -96,15 +96,14 @@ async def get_token_data( raise OAuth2LoginError(400, "'state' parameter does not match") url = request.url - scheme = "http" if self.allow_insecure_http else "https" - current_path = f"{scheme}://{url.netloc}{url.path}" - current_path = re.sub(r"^https?", scheme, current_path) + scheme = "http" if self.allow_http else "https" current_url = re.sub(r"^https?", scheme, str(url)) + redirect_uri = self.get_redirect_uri(request) token_url, headers, content = self.oauth_client.prepare_token_request( self.token_endpoint, + redirect_url=redirect_uri, authorization_response=current_url, - redirect_url=self.callback_url or current_path, code=request.query_params.get("code"), **params, ) @@ -129,13 +128,13 @@ async def token_redirect( headers: Optional[Dict[str, Any]] = None, ) -> RedirectResponse: token_data = await self.get_token_data(request, params=params, headers=headers) - access_token = jwt_create(token_data) + access_token = request.auth.jwt_create(token_data) response = RedirectResponse(request.base_url) response.set_cookie( "Authorization", value=f"Bearer {access_token}", - httponly=self.allow_insecure_http, - max_age=JWT_EXPIRES * 60, - expires=JWT_EXPIRES * 60, + httponly=self.allow_http, + max_age=request.auth.expires, + expires=request.auth.expires, ) return response diff --git a/src/fastapi_oauth2/exceptions.py b/src/fastapi_oauth2/exceptions.py deleted file mode 100644 index 31d60fa..0000000 --- a/src/fastapi_oauth2/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -from starlette.exceptions import HTTPException - - -class OAuth2LoginError(HTTPException): - """Raised when any login-related error occurs - (such as when user is not verified or if there was an attempt for fake login) - """ diff --git a/src/fastapi_oauth2/github.py b/src/fastapi_oauth2/github.py deleted file mode 100644 index 107280b..0000000 --- a/src/fastapi_oauth2/github.py +++ /dev/null @@ -1,12 +0,0 @@ -from .core import OAuth2Core - - -class GitHubOAuth2(OAuth2Core): - """Class providing login via GitHub SSO""" - - scope = ["user:email"] - additional_headers = {"accept": "application/json"} - - authorization_endpoint = "https://github.com/login/oauth/authorize" - token_endpoint = "https://github.com/login/oauth/access_token" - userinfo_endpoint = "https://api.github.com/user" From 5c785b577d9d640cf9a156906cb9bd4a021e4839 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 14:17:08 +0400 Subject: [PATCH 19/24] Move JWT manipulation methods to the `Auth` class --- src/fastapi_oauth2/middleware.py | 67 ++++++++++++++++++++++++++------ src/fastapi_oauth2/utils.py | 18 --------- 2 files changed, 55 insertions(+), 30 deletions(-) delete mode 100644 src/fastapi_oauth2/utils.py diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index c6062e5..d6d9d07 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -1,9 +1,14 @@ +from datetime import datetime +from datetime import timedelta +from typing import Dict from typing import List from typing import Optional from typing import Tuple from typing import Union from fastapi.security.utils import get_authorization_scheme_param +from jose.jwt import decode as jwt_decode +from jose.jwt import encode as jwt_encode from starlette.authentication import AuthenticationBackend from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request @@ -12,16 +17,50 @@ from starlette.types import Scope from starlette.types import Send +from .client import OAuth2Client from .config import OAuth2Config -from .utils import jwt_decode +from .core import OAuth2Core class Auth: + secret: str + expires: int + algorithm: str scopes: List[str] + clients: Dict[str, OAuth2Core] = {} def __init__(self, scopes: Optional[List[str]] = None) -> None: self.scopes = scopes or [] + @classmethod + def set_secret(cls, secret: str) -> None: + cls.secret = secret + + @classmethod + def set_expires(cls, expires: int) -> None: + cls.expires = expires + + @classmethod + def set_algorithm(cls, algorithm: str) -> None: + cls.algorithm = algorithm + + @classmethod + def register_client(cls, client: OAuth2Client) -> None: + cls.clients[client.backend.name] = OAuth2Core(client) + + @classmethod + def jwt_encode(cls, data: dict) -> str: + return jwt_encode(data, cls.secret, algorithm=cls.algorithm) + + @classmethod + def jwt_decode(cls, token: str) -> dict: + return jwt_decode(token, cls.secret, algorithms=[cls.algorithm]) + + @classmethod + def jwt_create(cls, token_data: dict) -> str: + expire = datetime.utcnow() + timedelta(minutes=cls.expires) + return cls.jwt_encode({**token_data, "exp": expire}) + class User(dict): is_authenticated: bool @@ -32,6 +71,14 @@ def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: class OAuth2Backend(AuthenticationBackend): + def __init__(self, config: OAuth2Config) -> None: + Auth.set_secret(config.jwt_secret) + Auth.set_expires(config.jwt_expires) + Auth.set_algorithm(config.jwt_algorithm) + OAuth2Core.allow_http = config.allow_http + for client in config.clients: + Auth.register_client(client) + async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"]]: authorization = request.cookies.get("Authorization") scheme, param = get_authorization_scheme_param(authorization) @@ -39,23 +86,19 @@ async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"] if not scheme or not param: return Auth(), User() - user = jwt_decode(param) - scopes = user.pop("scope") - return Auth(scopes), User(user) + user = Auth.jwt_decode(param) + return Auth(user.pop("scope")), User(user) class OAuth2Middleware: - config: OAuth2Config - auth_middleware: AuthenticationMiddleware + auth_middleware: AuthenticationMiddleware = None def __init__(self, app: ASGIApp, config: Union[OAuth2Config, dict]) -> None: - if isinstance(config, OAuth2Config): - self.config = config - elif isinstance(config, dict): - self.config = OAuth2Config(**config) - else: + if isinstance(config, dict): + config = OAuth2Config(**config) + elif not isinstance(config, OAuth2Config): raise TypeError("config is not a valid type") - self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend()) + self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend(config)) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.auth_middleware(scope, receive, send) diff --git a/src/fastapi_oauth2/utils.py b/src/fastapi_oauth2/utils.py deleted file mode 100644 index d77db77..0000000 --- a/src/fastapi_oauth2/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta - -from jose import jwt - -from .config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRES - - -def jwt_encode(data: dict) -> str: - return jwt.encode(data, JWT_SECRET, algorithm=JWT_ALGORITHM) - - -def jwt_decode(token: str) -> dict: - return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - - -def jwt_create(token_data: dict) -> str: - expire = datetime.utcnow() + timedelta(minutes=JWT_EXPIRES) - return jwt_encode({**token_data, "exp": expire}) From 0d602a7fcaabd59627619d6eac1daff97a498cab Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 14:17:45 +0400 Subject: [PATCH 20/24] Provide configs from client code --- main.py | 12 +++++++++--- src/fastapi_oauth2/config.py | 16 +++------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/main.py b/main.py index fd7c9a7..5512df3 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,7 @@ import json +import os +from dotenv import load_dotenv from fastapi import FastAPI, Request, APIRouter from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates @@ -10,6 +12,7 @@ from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.router import router as oauth2_router +load_dotenv() router = APIRouter() templates = Jinja2Templates(directory="templates") @@ -25,12 +28,15 @@ async def root(request: Request): app.include_router(oauth2_router) app.add_middleware(OAuth2Middleware, config={ "allow_http": True, + "jwt_secret": os.getenv("JWT_SECRET"), + "jwt_expires": os.getenv("JWT_EXPIRES"), + "jwt_algorithm": os.getenv("JWT_ALGORITHM"), "clients": [ OAuth2Client( backend=GithubOAuth2, - client_id="eccd08d6736b7999a32a", - client_secret="642999c1c5f2b3df8b877afdc78252ef5b594d31", - redirect_uri="http://127.0.0.1:8000/", + client_id=os.getenv("OAUTH2_CLIENT_ID"), + client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), + # redirect_uri="http://127.0.0.1:8000/", scope=["user:email"], ), ] diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index a59d6a5..8ce77e1 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -1,20 +1,8 @@ import os from typing import List -from dotenv import load_dotenv - from .client import OAuth2Client -load_dotenv() - -OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") -OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") -OAUTH2_CALLBACK_URL = os.getenv("OAUTH2_CALLBACK_URL") - -JWT_SECRET = os.getenv("JWT_SECRET") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") -JWT_EXPIRES = int(os.getenv("JWT_EXPIRES", "15")) - class OAuth2Config: allow_http: bool @@ -32,8 +20,10 @@ def __init__( jwt_algorithm: str = "HS256", clients: List[OAuth2Client] = None, ): + if allow_http: + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" self.allow_http = allow_http self.jwt_secret = jwt_secret - self.jwt_expires = jwt_expires + self.jwt_expires = int(jwt_expires) self.jwt_algorithm = jwt_algorithm self.clients = clients or [] From 5f3d33a398af739bd5873bf71a399357e20c7eb9 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 14:19:49 +0400 Subject: [PATCH 21/24] Replace the deprecated usage with new style --- src/fastapi_oauth2/router.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/fastapi_oauth2/router.py b/src/fastapi_oauth2/router.py index 652a5ac..773bc09 100644 --- a/src/fastapi_oauth2/router.py +++ b/src/fastapi_oauth2/router.py @@ -2,31 +2,17 @@ from fastapi.responses import RedirectResponse from starlette.requests import Request -from fastapi_oauth2.github import GitHubOAuth2 -from .config import ( - OAUTH2_CLIENT_ID, - OAUTH2_CLIENT_SECRET, - OAUTH2_CALLBACK_URL, -) - router = APIRouter(prefix="/oauth2") -oauth2 = GitHubOAuth2( - client_id=OAUTH2_CLIENT_ID, - client_secret=OAUTH2_CLIENT_SECRET, - callback_url=OAUTH2_CALLBACK_URL, - allow_insecure_http=True, -) @router.get("/{provider}/auth") -async def login(provider: str): - print(provider) - return await oauth2.login_redirect() +async def login(request: Request, provider: str): + return await request.auth.clients[provider].login_redirect(request) -@router.get("/token") -async def token(request: Request): - return await oauth2.token_redirect(request) +@router.get("/{provider}/token") +async def token(request: Request, provider: str): + return await request.auth.clients[provider].token_redirect(request) @router.get("/logout") From 7930bc222f381269bfb28a3b4322a6041df5f1f6 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 14:20:37 +0400 Subject: [PATCH 22/24] Sanitize code snippets --- demo/dependencies.py | 14 +------------- demo/router.py | 6 +++--- src/fastapi_oauth2/client.py | 4 +++- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/demo/dependencies.py b/demo/dependencies.py index 5934307..52594b6 100644 --- a/demo/dependencies.py +++ b/demo/dependencies.py @@ -1,15 +1,12 @@ from typing import Optional -from fastapi import Depends, HTTPException +from fastapi import HTTPException from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel from fastapi.security import OAuth2 from fastapi.security.utils import get_authorization_scheme_param -from jose import JWTError from starlette.requests import Request from starlette.status import HTTP_403_FORBIDDEN -from fastapi_oauth2.utils import jwt_decode - class OAuth2PasswordBearerCookie(OAuth2): def __init__( @@ -40,12 +37,3 @@ async def __call__(self, request: Request) -> Optional[str]: oauth2_scheme = OAuth2PasswordBearerCookie(tokenUrl="/token") - - -async def get_current_user(token: str = Depends(oauth2_scheme)): - try: - return jwt_decode(token) - except JWTError: - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" - ) diff --git a/demo/router.py b/demo/router.py index e83edf9..822dab1 100644 --- a/demo/router.py +++ b/demo/router.py @@ -2,14 +2,14 @@ from fastapi import Depends from starlette.requests import Request -from .dependencies import get_current_user +from .dependencies import oauth2_scheme router = APIRouter() @router.get("/user") -def user(current_user=Depends(get_current_user)): - return current_user +def user(request: Request, _: str = Depends(oauth2_scheme)): + return request.user @router.post("/token") diff --git a/src/fastapi_oauth2/client.py b/src/fastapi_oauth2/client.py index 7837188..b88e245 100644 --- a/src/fastapi_oauth2/client.py +++ b/src/fastapi_oauth2/client.py @@ -1,4 +1,6 @@ -from typing import Optional, Type, Sequence +from typing import Optional +from typing import Sequence +from typing import Type from social_core.backends.oauth import BaseOAuth2 From a4dedfa153ba241cc6ffc35c2286d89e6a25cce9 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 18:08:58 +0400 Subject: [PATCH 23/24] Change the JWT expiration time --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index aa1f395..a1f6457 100644 --- a/.env +++ b/.env @@ -4,4 +4,4 @@ OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/oauth2/token JWT_SECRET=secret JWT_ALGORITHM=HS256 -JWT_EXPIRES=5 +JWT_EXPIRES=900 From 5b8cae668d17e7cfa2c910c5175f93d9a29c00f7 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 26 Jun 2023 18:10:20 +0400 Subject: [PATCH 24/24] Implement a custom strategy for using the `user_data` method --- src/fastapi_oauth2/core.py | 103 ++++++++++++++----------------- src/fastapi_oauth2/middleware.py | 7 ++- 2 files changed, 51 insertions(+), 59 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 7382711..8191eb4 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -1,5 +1,7 @@ import json +import random import re +import string from typing import Any from typing import Dict from typing import List @@ -7,7 +9,10 @@ from urllib.parse import urljoin import httpx +import requests from oauthlib.oauth2 import WebApplicationClient +from social_core.backends.oauth import BaseOAuth2 +from social_core.strategy import BaseStrategy from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import RedirectResponse @@ -21,31 +26,42 @@ class OAuth2LoginError(HTTPException): """ +class OAuth2Strategy(BaseStrategy): + def request_data(self, merge=True): + return {} + + def absolute_uri(self, path=None): + return path + + def get_setting(self, name): + return None + + @staticmethod + def get_json(url, method='GET', *args, **kwargs): + return requests.request(method, url, *args, **kwargs) + + class OAuth2Core: """Base class (mixin) for all SSO providers""" client_id: str = None client_secret: str = None callback_url: Optional[str] = None - allow_http: bool = False scope: Optional[List[str]] = None - state: Optional[str] = None + backend: BaseOAuth2 = None _oauth_client: Optional[WebApplicationClient] = None - additional_headers: Optional[Dict[str, Any]] = None authorization_endpoint: str = None token_endpoint: str = None - userinfo_endpoint: str = None def __init__(self, client: OAuth2Client) -> None: self.client_id = client.client_id self.client_secret = client.client_secret self.scope = client.scope or self.scope self.provider = client.backend.name + self.backend = client.backend(OAuth2Strategy()) self.authorization_endpoint = client.backend.AUTHORIZATION_URL self.token_endpoint = client.backend.ACCESS_TOKEN_URL - self.userinfo_endpoint = "https://api.github.com/user" - self.additional_headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} @property def oauth_client(self) -> WebApplicationClient: @@ -56,47 +72,24 @@ def oauth_client(self) -> WebApplicationClient: def get_redirect_uri(self, request: Request) -> str: return urljoin(str(request.base_url), "/oauth2/%s/token" % self.provider) - async def get_login_url( - self, - request: Request, - *, - params: Optional[Dict[str, Any]] = None, - state: Optional[str] = None, - ) -> Any: - self.state = state - params = params or {} + async def get_login_url(self, request: Request) -> Any: redirect_uri = self.get_redirect_uri(request) + state = "".join([random.choice(string.ascii_letters) for _ in range(32)]) return self.oauth_client.prepare_request_uri( - self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params + self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope ) - async def login_redirect( - self, - request: Request, - *, - params: Optional[Dict[str, Any]] = None, - state: Optional[str] = None, - ) -> RedirectResponse: - login_uri = await self.get_login_url(request, params=params, state=state) - return RedirectResponse(login_uri, 303) - - async def get_token_data( - self, - request: Request, - *, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: - params = params or {} - additional_headers = headers or {} - additional_headers.update(self.additional_headers or {}) + async def login_redirect(self, request: Request) -> RedirectResponse: + return RedirectResponse(await self.get_login_url(request), 303) + + async def get_token_data(self, request: Request) -> Optional[Dict[str, Any]]: if not request.query_params.get("code"): raise OAuth2LoginError(400, "'code' parameter was not found in callback request") - if self.state != request.query_params.get("state"): - raise OAuth2LoginError(400, "'state' parameter does not match") + if not request.query_params.get("state"): + raise OAuth2LoginError(400, "'state' parameter was not found in callback request") url = request.url - scheme = "http" if self.allow_http else "https" + scheme = "http" if request.auth.http else "https" current_url = re.sub(r"^https?", scheme, str(url)) redirect_uri = self.get_redirect_uri(request) @@ -105,36 +98,30 @@ async def get_token_data( redirect_url=redirect_uri, authorization_response=current_url, code=request.query_params.get("code"), - **params, + state=request.query_params.get("state"), ) - headers.update(additional_headers) + headers.update({ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) auth = httpx.BasicAuth(self.client_id, self.client_secret) async with httpx.AsyncClient() as session: response = await session.post(token_url, headers=headers, content=content, auth=auth) - self.oauth_client.parse_request_body_response(json.dumps(response.json())) - - url, headers, _ = self.oauth_client.add_token(self.userinfo_endpoint) - response = await session.get(url, headers=headers) - content = response.json() - - return {**content, "scope": self.scope} - - async def token_redirect( - self, - request: Request, - *, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, Any]] = None, - ) -> RedirectResponse: - token_data = await self.get_token_data(request, params=params, headers=headers) + token = self.oauth_client.parse_request_body_response(json.dumps(response.json())) + data = self.backend.user_data(token.get("access_token")) + + return {**data, "scope": self.scope} + + async def token_redirect(self, request: Request) -> RedirectResponse: + token_data = await self.get_token_data(request) access_token = request.auth.jwt_create(token_data) response = RedirectResponse(request.base_url) response.set_cookie( "Authorization", value=f"Bearer {access_token}", - httponly=self.allow_http, max_age=request.auth.expires, expires=request.auth.expires, + httponly=request.auth.http, ) return response diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index d6d9d07..5ffda19 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -23,6 +23,7 @@ class Auth: + http: bool secret: str expires: int algorithm: str @@ -32,6 +33,10 @@ class Auth: def __init__(self, scopes: Optional[List[str]] = None) -> None: self.scopes = scopes or [] + @classmethod + def set_http(cls, http: bool) -> None: + cls.http = http + @classmethod def set_secret(cls, secret: str) -> None: cls.secret = secret @@ -72,10 +77,10 @@ def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: class OAuth2Backend(AuthenticationBackend): def __init__(self, config: OAuth2Config) -> None: + Auth.set_http(config.allow_http) Auth.set_secret(config.jwt_secret) Auth.set_expires(config.jwt_expires) Auth.set_algorithm(config.jwt_algorithm) - OAuth2Core.allow_http = config.allow_http for client in config.clients: Auth.register_client(client)