Skip to content

Commit

Permalink
🔏 OAuth (#3)
Browse files Browse the repository at this point in the history
# OAuth2
OAuth integration with
[fastapi-oauth2](https://github.com/pysnippet/fastapi-oauth2)

## Features
- OAuth2 provider using GitHub
- Uses cookies to store JWT user token
- Login page
  - Login with GitHub: working
  - Login with Google: not implemented yet
- Integrate with starlette-admin `AuthProvider` routes
- Add OAuth dependency for API routes

## Cleanup
Cleaned up old routes and old code

Closes #1
  • Loading branch information
mrharpo committed Aug 8, 2024
2 parents af8bc7e + d40ceeb commit 3a3afde
Show file tree
Hide file tree
Showing 21 changed files with 1,213 additions and 346 deletions.
24 changes: 24 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"inputs": [
{
"id": "numWorkers",
"type": "promptString",
"default": "4",
"description": "Number of Gunicorn workers to run"
}
],
"configurations": [
{
"name": "Organ",
"type": "debugpy",
"request": "launch",
"python": "${workspaceFolder}/.venv/bin/python",
"program": "${workspaceFolder}/.venv/bin/uvicorn",
"args": ["--host", "0.0.0.0", "--port", "9000", "--reload", "organ:app"]
}
]
}
4 changes: 2 additions & 2 deletions organ/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ._version import __version__
from .main import main
from .app import app

__all__ = ['main', '__version__']
__all__ = ['app', '__version__']
115 changes: 0 additions & 115 deletions organ/api.py

This file was deleted.

62 changes: 62 additions & 0 deletions organ/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import logfire
from fastapi import Depends, FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi_oauth2.middleware import OAuth2Middleware
from fastapi_oauth2.router import router as oauth2_router
from sqlmodel import SQLModel
from starlette.middleware.sessions import SessionMiddleware
from starlette.routing import RedirectResponse, Route
from starlette_admin.contrib.sqlmodel import Admin

from organ._version import __version__
from organ.auth import OAuthProvider
from organ.config import ORGAN_SECRET
from organ.crud import orgs
from organ.db import engine
from organ.models import Organization, User
from organ.oauth import is_user_authenticated, oauth_config, on_auth
from organ.views import OrganizationView, UserView


def init_db():
logfire.info(f'Organ version: {__version__}')
SQLModel.metadata.create_all(engine)


def redirect_to_admin(request):
return RedirectResponse(url="/admin")


app = FastAPI(
on_startup=[init_db],
routes=[
Route("/", redirect_to_admin),
],
)
logfire.configure(pydantic_plugin=logfire.PydanticPlugin(record='all'))
logfire.instrument_fastapi(app)


app.include_router(oauth2_router, tags=["auth"])
app.add_middleware(OAuth2Middleware, config=oauth_config, callback=on_auth)
app.add_middleware(SessionMiddleware, secret_key=ORGAN_SECRET)
app.include_router(orgs, dependencies=[Depends(is_user_authenticated)])

# Add static files
app.mount("/static", StaticFiles(directory="static"), name="static")

admin = Admin(
engine,
title='Organ',
templates_dir='templates',
auth_provider=OAuthProvider(
logout_path="/oauth2/logout",
),
logo_url='/static/GBH_Archives.png',
)

# Add views
admin.add_view(UserView(User, icon="fa fa-users"))
admin.add_view(OrganizationView(Organization, icon="fa fa-box"))

admin.mount_to(app)
87 changes: 17 additions & 70 deletions organ/auth.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,25 @@
from typing import Optional

from sqlmodel import Session, SQLModel, select
from starlette.datastructures import URL
from starlette.requests import Request
from starlette.responses import Response
from starlette.responses import RedirectResponse, Response
from starlette_admin import BaseAdmin
from starlette_admin.auth import AdminUser, AuthProvider
from starlette_admin.exceptions import FormValidationError, LoginFailed
from organ.db import engine, get_user
from organ.models import User

# users = {
# "admin": {
# "name": "Admin",
# "avatar": "avatars/01.png",
# "roles": ["admin"],
# },
# "demo": {
# "name": "John Doe",
# "avatar": None,
# "roles": ["demo"],
# },
# }

class OAuthProvider(AuthProvider):
async def is_authenticated(self, request: Request) -> bool:
if request.get('user'):
return True
return False

class CustomAuthProvider(AuthProvider):
"""
This is for demo purpose, it's not a better
way to save and validate user credentials
"""
def get_admin_user(self, request: Request) -> Optional[AdminUser]:
user = request.user
return AdminUser(
username=user['name'],
photo_url=user['avatar_url'],
)

async def login(
self,
username: str,
password: str,
remember_me: bool,
request: Request,
response: Response,
) -> Response:
if len(username) < 3:
"""Form data validation"""
raise FormValidationError(
{"username": "Please ensure that your username has at least 3 characters"}
)

# load user from db
user = get_user(username)
if user and password == user.password:
"""Save `username` in session"""
request.session.update({"username": username})
return response

raise LoginFailed("Invalid username or password.")

async def is_authenticated(self, request) -> bool:
print(request.method == "GET")
if request.method == "GET" and str(request.url).startswith("/admin/api/organization"):
# allow unauthenticated read access
return True

user = get_user(request.session.get("username", None))
if user:
"""
Save current `user` object in the request state. Can be used later
to restrict access to connected user.
"""
request.state.user = user.username
return True

return False

def get_admin_user(self, request: Request) -> Optional[AdminUser]:
username = request.state.user # Retrieve current user

return AdminUser(username=username)

async def logout(self, request: Request, response: Response) -> Response:
request.session.clear()
return response
async def render_logout(self, request: Request, admin: BaseAdmin) -> Response:
"""Override the default logout to implement custom logic"""
return RedirectResponse(url=URL('/oauth2/logout'))
7 changes: 7 additions & 0 deletions organ/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from os import environ

from dotenv import load_dotenv

load_dotenv()

ENVIRONMENT = environ.get('ENVIRONMENT', 'development')
DB_URL = environ.get('DB_URL', 'postgresql://postgres:postgres@localhost:5432/organ')

ORGAN_SECRET = environ.get('ORGAN_SECRET', 1234567890)

# TEMPLATES_DIR = environ.get('TEMPLATES_DIR', 'templates')
# STATIC_DIR = environ.get('STATIC_DIR', 'static')
SECRET_KEY = environ.get('SECRET_KEY', 'secret')
AUTH0_DOMAIN = environ.get('AUTH0_DOMAIN')
AUTH0_CLIENT_ID = environ.get('AUTH0_CLIENT_ID')
6 changes: 2 additions & 4 deletions organ/crud.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastcrud import FastCRUD, crud_router
from fastcrud import crud_router

from organ.db import get_async_session
from organ.models import Organization, OrganizationSchema, User
from organ.models import Organization, OrganizationSchema

orgs = crud_router(
model=Organization,
Expand All @@ -11,5 +11,3 @@
create_schema=OrganizationSchema,
update_schema=OrganizationSchema,
)

# users = crud_router(User)
26 changes: 0 additions & 26 deletions organ/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,14 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlmodel import Session, select

from organ.config import DB_URL, ENVIRONMENT
from organ.models import User


def get_engine(env=ENVIRONMENT):
return create_engine(DB_URL, echo=True)


def get_user(username):
with Session(engine) as session:
return session.exec(select(User).where(User.username == username)).first()

"""Return a SQLAlchemy engine for the given environment"""
# if env == 'test':
# return create_engine(
# DB_URL,

# )
# if env == 'development':
# return create_engine(DB_URL, echo=True)

# if env == 'production':
# return create_engine(
# DB_URL, connect_args={'check_same_thread': True}, echo=False
# )
# raise Exception(f'Unknown environment: {env}')


# # Create database from SQLModel schema
# SQLModel.metadata.create_all(engine)


# Database session dependency
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
Expand Down
Loading

0 comments on commit 3a3afde

Please sign in to comment.