├── templates ├── content.error.j2 ├── content.top.j2 ├── spa_top.j2 ├── debug_csrf_html.j2 ├── content.secret.j2 ├── auth_navbar.login.js.j2 ├── spa.j2 ├── admin.login.j2 ├── auth_navbar.login.html.j2 ├── auth_navbar.login.callback.j2 ├── content.list.tbody.j2 ├── content.list.j2 ├── head.j2 ├── debug_csrf_js.j2 ├── auth_navbar.logout.j2 ├── auth_refresh_token.j2 └── auth_navbar.login.j2 ├── data ├── docker-compose.yml ├── renew_admin_session.sh ├── create_data.sh └── db.py ├── images ├── cat_meme.png ├── dog_meme.png ├── admin_icon.webp ├── door-check-out-icon.png ├── unknown-person-icon.png ├── FastAPI-HTMX-Google-OAuth043.gif └── image.py ├── .gitignore ├── config.py ├── htmx ├── spa.py ├── htmx_secret.py └── htmx.py ├── log_config.yaml ├── admin ├── user.py ├── admin.py ├── debug.py ├── cachestore.py └── auth.py ├── main.py └── Readme.md /templates/content.error.j2: -------------------------------------------------------------------------------- 1 |

2 |

{{message}}

3 |

4 | -------------------------------------------------------------------------------- /templates/content.top.j2: -------------------------------------------------------------------------------- 1 |

2 |

{{title}}

3 |

4 | -------------------------------------------------------------------------------- /data/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis 4 | ports: 5 | - 6379:6379 6 | 7 | -------------------------------------------------------------------------------- /images/cat_meme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/cat_meme.png -------------------------------------------------------------------------------- /images/dog_meme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/dog_meme.png -------------------------------------------------------------------------------- /images/admin_icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/admin_icon.webp -------------------------------------------------------------------------------- /images/door-check-out-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/door-check-out-icon.png -------------------------------------------------------------------------------- /images/unknown-person-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/unknown-person-icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __init__.py 2 | __pycache__ 3 | *~ 4 | .env 5 | .venv 6 | requirement.txt 7 | data.db 8 | cache.db 9 | .vscode 10 | -------------------------------------------------------------------------------- /images/FastAPI-HTMX-Google-OAuth043.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktaka-ccmp/fastapi-htmx-google-oauth/HEAD/images/FastAPI-HTMX-Google-OAuth043.gif -------------------------------------------------------------------------------- /templates/spa_top.j2: -------------------------------------------------------------------------------- 1 | {% extends "spa.j2" %} 2 | 3 | {% block content_section %} 4 |
5 | {% include 'content.top.j2' %} 6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings 2 | 3 | class Settings(BaseSettings): 4 | origin_server: str 5 | google_oauth2_client_id: str 6 | admin_email: str 7 | session_max_age: int 8 | cache_store: str 9 | redis_host: str 10 | redis_port: int 11 | 12 | class Config: 13 | env_file = ".env" 14 | 15 | settings=Settings() 16 | -------------------------------------------------------------------------------- /templates/debug_csrf_html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CSRF Protected Form 5 | 6 | 7 |
8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/content.secret.j2: -------------------------------------------------------------------------------- 1 |

2 |

{{title}}

3 |

4 | 5 |
6 | pet mischief 7 |
8 | 9 | {# The below is needed to prevent protected content in the cache from being revealed to unauthenticated users. #} 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/auth_navbar.login.js.j2: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /templates/spa.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include 'head.j2' %} 5 | 6 | 7 | {# Header #} 8 |
9 |
11 |
12 |
13 | 14 | {# Content #} 15 | {%block content_section%} 16 |
18 |
19 | {%endblock%} 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /templates/admin.login.j2: -------------------------------------------------------------------------------- 1 |

2 |

{{title}}

3 |

4 | 5 |
6 |
7 | 8 |
9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /htmx/spa.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from fastapi.templating import Jinja2Templates 3 | from fastapi.responses import HTMLResponse 4 | 5 | router = APIRouter() 6 | templates = Jinja2Templates(directory='templates') 7 | 8 | @router.get("/", response_class=HTMLResponse) 9 | async def spa_top(request: Request): 10 | context = {"request": request, "title": "Htmx Spa Top"} 11 | return templates.TemplateResponse("spa_top.j2", context) 12 | 13 | 14 | @router.get("/{page}", response_class=HTMLResponse) 15 | async def spa(request: Request, page: str | None = None): 16 | context = {"request": request, "page": page} 17 | return templates.TemplateResponse("spa.j2", context) 18 | -------------------------------------------------------------------------------- /images/image.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.responses import FileResponse 3 | 4 | router = APIRouter() 5 | 6 | @router.get("/icon.png") 7 | async def main(): 8 | return FileResponse("images/unknown-person-icon.png") 9 | 10 | @router.get("/logout.png") 11 | async def main(): 12 | return FileResponse("images/door-check-out-icon.png") 13 | 14 | @router.get("/secret1.png") 15 | async def main(): 16 | return FileResponse("images/dog_meme.png") 17 | 18 | @router.get("/secret2.png") 19 | async def main(): 20 | return FileResponse("images/cat_meme.png") 21 | 22 | @router.get("/admin_icon.webp") 23 | async def main(): 24 | return FileResponse("images/admin_icon.webp") 25 | -------------------------------------------------------------------------------- /templates/auth_navbar.login.html.j2: -------------------------------------------------------------------------------- 1 | 2 | {# The following script should be loaded every time this component is loaded. #} 3 | 4 | 5 |
16 |
17 | 18 |
24 |
25 | -------------------------------------------------------------------------------- /templates/auth_navbar.login.callback.j2: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /templates/content.list.tbody.j2: -------------------------------------------------------------------------------- 1 | {# Table should be always returned first until this commit is taken into main branch. 2 | https://github.com/bigskysoftware/htmx/pull/1794/commits #} 3 | 4 | 5 | 6 | {{ skip_next - limit }} 7 | {{ skip_next }} 8 | 9 | 10 | 11 | 12 | {% for cs in customers %} 13 | 14 | {{ cs.id }} 15 | {{ cs.name }} 16 | {{ cs.email }} 17 | 18 | {% endfor %} 19 | 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /log_config.yaml: -------------------------------------------------------------------------------- 1 | # uvicorn main:app --host 0.0.0.0 --reload --log-config log_config.yaml 2 | # 3 | version: 1 4 | disable_existing_loggers: False 5 | formatters: 6 | default: 7 | "()": uvicorn.logging.DefaultFormatter 8 | format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 9 | access: 10 | "()": uvicorn.logging.AccessFormatter 11 | format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 12 | handlers: 13 | default: 14 | formatter: default 15 | class: logging.StreamHandler 16 | stream: ext://sys.stderr 17 | access: 18 | formatter: access 19 | class: logging.StreamHandler 20 | stream: ext://sys.stdout 21 | loggers: 22 | uvicorn.error: 23 | level: INFO 24 | handlers: 25 | - default 26 | propagate: no 27 | uvicorn.access: 28 | level: INFO 29 | handlers: 30 | - access 31 | propagate: no 32 | -------------------------------------------------------------------------------- /templates/content.list.j2: -------------------------------------------------------------------------------- 1 |

2 |

{{title}}

3 |

4 | 5 |
6 |
7 |
8 |

The skip counters are updated by hx-swap-oob. This is so cool!

9 | 12 | 13 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
skipskip_next
29 |
30 |
31 |
32 |
33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
idnameemail
47 |
48 | -------------------------------------------------------------------------------- /htmx/htmx_secret.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi import APIRouter, Request, HTTPException, status, Header 3 | from fastapi.templating import Jinja2Templates 4 | from fastapi.responses import HTMLResponse 5 | from config import settings 6 | 7 | router = APIRouter() 8 | templates = Jinja2Templates(directory='templates') 9 | 10 | # Normal Response function 11 | @router.get("/content.secret1", response_class=HTMLResponse) 12 | async def content_secret1(request: Request, hx_request: Optional[str] = Header(None)): 13 | if not hx_request: 14 | raise HTTPException( 15 | status_code=status.HTTP_400_BAD_REQUEST, 16 | detail="Only HX request is allowed to this end point." 17 | ) 18 | img_url = settings.origin_server + "/img/secret1.png" 19 | context = {"request": request, "title": "Oops, my secret's been revealed!", "img_url": img_url} 20 | return templates.TemplateResponse("content.secret.j2", context) 21 | 22 | @router.get("/content.secret2", response_class=HTMLResponse) 23 | async def content_secret2(request: Request, hx_request: Optional[str] = Header(None)): 24 | if not hx_request: 25 | raise HTTPException( 26 | status_code=status.HTTP_400_BAD_REQUEST, 27 | detail="Only HX request is allowed to this end point." 28 | ) 29 | img_url = settings.origin_server + "/img/secret2.png" 30 | context = {"request": request, "title": "Believe it or not, it's absolutely not me!", "img_url": img_url} 31 | return templates.TemplateResponse("content.secret.j2", context) 32 | -------------------------------------------------------------------------------- /templates/head.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {# #} 4 | 5 | {# For bootstrap #} 6 | 7 | 9 | 12 | 13 | {# For bootstrap Icons#} 14 | 15 | 16 | {{title}} 17 | 18 | 21 | 22 | {# The next line is needed for auth.menu.login."js".html to work, 23 | i.e. for js api of Sign in with Google, script shoudl be loaded in the
. 24 | 25 | For Html api the next line is not needed, although dosen't harm anything. 26 | The script should be loaded in the auth.menu.login."html".html, 27 | i.e. should be loaded every time the component file is loaded. #} 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /data/renew_admin_session.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source .env 4 | 5 | pwgen(){ 6 | basenc --base64url < /dev/urandom | head -c 64 ; echo 7 | } 8 | 9 | email=${ADMIN_EMAIL} 10 | ssid=$(pwgen) 11 | csrf_token=$(pwgen) 12 | 13 | SQL_STORE=data/cache.db 14 | 15 | delete(){ 16 | echo "delete from sessions where email = '$email'" | sqlite3 $SQL_STORE 17 | } 18 | 19 | delete_all(){ 20 | echo "delete from sessions" | sqlite3 $SQL_STORE 21 | } 22 | 23 | insert_or_replace(){ 24 | echo "insert or replace into sessions (id, session_id,user_id,email,csrf_token) 25 | values (1, '$ssid', 1, '$email', '$csrf_token')" | sqlite3 $SQL_STORE 26 | } 27 | 28 | check(){ 29 | echo "session_id for $email : " $ssid 30 | echo 31 | echo "Sessions in $SQL_STORE:" 32 | echo "select * from sessions" | sqlite3 $SQL_STORE 33 | } 34 | 35 | insert_or_replace_redis(){ 36 | if [ "$CACHE_STORE" == "redis" ]; then 37 | reds_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT" 38 | 39 | # Clean up existing admin sessions 40 | for k in $($reds_cmd keys "*"); do 41 | if ($reds_cmd get $k | egrep "\"email\": \"$ADMIN_EMAIL\"" > /dev/null) ; then 42 | $reds_cmd del $k > /dev/null 43 | fi 44 | done 45 | 46 | # Create admin session 47 | session_data="{\"session_id\": \"$ssid\", \"csrf_token\": \"$csrf_token\", \"user_id\": \"1\", \"email\": \"$email\"}" 48 | # echo $reds_cmd set session:$ssid \"$session_data\" 49 | $reds_cmd set session:$ssid "$session_data" > /dev/null 50 | fi 51 | } 52 | 53 | check_redis(){ 54 | echo 55 | echo "Sessions in Redis:" 56 | for k in $(redis-cli keys "*" | xargs) ; do 57 | echo -n $k": " ; redis-cli get $k|xargs 58 | done 59 | } 60 | 61 | insert_or_replace 62 | check 63 | 64 | if [ "$CACHE_STORE" == "redis" ]; then 65 | insert_or_replace_redis 66 | check_redis 67 | fi 68 | -------------------------------------------------------------------------------- /data/create_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source .env 4 | 5 | DB=data/data.db 6 | 7 | for i in {001..080} ; do 8 | echo "insert into customer(name,email) values('a$i','a$i@example.com')" \ 9 | | sqlite3 $DB 10 | done 11 | 12 | for i in {01..01} ; do 13 | echo "insert into user(name,email,disabled,admin,password,picture) values('${ADMIN_EMAIL}','${ADMIN_EMAIL}','0','1','fakehashed_admin$i','/img/admin_icon.webp')" | sqlite3 $DB 14 | done 15 | 16 | for i in {02..02} ; do 17 | echo "insert into user(name,email,disabled,admin) values('admin$i','admin$i@example.com','1','1')" | sqlite3 $DB 18 | done 19 | 20 | for i in {01..01} ; do 21 | echo "insert into user(name,email,disabled,admin) values('user$i','user$i@example.com','0','0')" | sqlite3 $DB 22 | done 23 | 24 | for i in {02..02} ; do 25 | echo "insert into user(name,email,disabled,admin) values('user$i','user$i@example.com','1','0')" | sqlite3 $DB 26 | done 27 | 28 | 29 | echo "Customer:" 30 | echo "select * from customer" | sqlite3 $DB | tail 31 | 32 | echo "Users:" 33 | echo "select * from user" | sqlite3 $DB 34 | 35 | pwgen(){ 36 | basenc --base64url < /dev/urandom | head -c 64 ; echo 37 | } 38 | 39 | email=${ADMIN_EMAIL} 40 | ssid=$(pwgen) 41 | csrf_token=$(pwgen) 42 | 43 | DB=data/cache.db 44 | echo "insert or replace into sessions (id, session_id,user_id,email,csrf_token) values (1, '$ssid', 1, '$email', '$csrf_token')" | sqlite3 $DB 45 | echo "Sessions:" 46 | echo "select * from sessions" | sqlite3 $DB 47 | 48 | if [ "$CACHE_STORE" == "redis" ]; then 49 | reds_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT" 50 | 51 | # Clean up existing sessions 52 | $reds_cmd keys "*"| xargs -i $reds_cmd del {} 53 | 54 | # Create admin session 55 | # session_data="{session_id: $ssid, csrf_token: $csrf_token, user_id: 1, email: $email}" 56 | session_data="{\"session_id\": \"$ssid\", \"csrf_token\": \"$csrf_token\", \"user_id\": \"1\", \"email\": \"$email\"}" 57 | echo $reds_cmd set session:$ssid \"$session_data\" 58 | $reds_cmd set session:$ssid "$session_data" 59 | fi 60 | -------------------------------------------------------------------------------- /admin/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, APIRouter, HTTPException 2 | from sqlalchemy.orm import Session 3 | from data.db import User, UserBase, get_db 4 | 5 | router = APIRouter() 6 | 7 | def get_user_by_name(db_session: Session, name: str): 8 | return db_session.query(User).filter(User.name==name).first() 9 | 10 | def get_user_by_email(db_session: Session, email: str): 11 | return db_session.query(User).filter(User.email==email).first() 12 | 13 | def get_user_by_id(db_session: Session, user_id: int): 14 | return db_session.query(User).filter(User.id==user_id).first() 15 | 16 | async def create(idinfo: str, db_session: Session): 17 | print("#### idinfo: ",idinfo) 18 | db_user = User(name=idinfo['name'], email=idinfo['email'], picture=idinfo['picture']) 19 | user = await create_user(db_user, db_session) 20 | return user 21 | 22 | @router.get("/users/") 23 | def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): 24 | return db.query(User).offset(skip).limit(limit).all() 25 | 26 | @router.get("/user/{name}") 27 | def read_user_by_name(name: str, db_session: Session = Depends(get_db)): 28 | user = get_user_by_name(db_session, name) 29 | return user 30 | 31 | @router.post("/user/") 32 | async def create_user(user: UserBase, db_session: Session = Depends(get_db)): 33 | db_user = get_user_by_email(db_session, user.email) 34 | if db_user: 35 | return db_user 36 | if not db_user: 37 | user_model = User(name=user.name, email=user.email, picture=user.picture) 38 | db_session.add(user_model) 39 | db_session.commit() 40 | db_session.refresh(user_model) 41 | db_user = get_user_by_email(db_session, user.email) 42 | return db_user 43 | 44 | @router.delete("/user/{name}") 45 | def delete_user(name: str, db_session: Session = Depends(get_db)): 46 | user = get_user_by_name(db_session, name) 47 | if not user: 48 | raise HTTPException(status_code=400, detail=f"\'{name}\' does not exist.") 49 | if user: 50 | db_session.delete(user) 51 | db_session.commit() 52 | return {"status": f"\'{name}\' has been deleted."} 53 | -------------------------------------------------------------------------------- /templates/debug_csrf_js.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CSRF Token Example 7 | 8 | 9 |

CSRF Token Response

10 |
Awaiting response...
11 | 12 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /admin/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | from fastapi import APIRouter, Request, Response, status, Depends, Form 3 | from fastapi.responses import JSONResponse, RedirectResponse 4 | from fastapi.openapi import docs, utils 5 | 6 | from admin import auth 7 | from config import settings 8 | from admin.cachestore import CacheStore, get_cache_store 9 | 10 | router = APIRouter() 11 | 12 | @router.post("/login") 13 | def login(response: Response, email: str = Form(...), 14 | apikey: str = Form(...), cs: CacheStore = Depends(get_cache_store)): 15 | 16 | if not apikey or not email: 17 | return None 18 | 19 | session = cs.get_session(apikey) 20 | if not session: 21 | response = JSONResponse({"Error": "ApiKey not found"}) 22 | response.delete_cookie("session_id") 23 | return response 24 | 25 | if email == session["email"]: 26 | max_age = settings.session_max_age 27 | expires = datetime.now(timezone.utc) + timedelta(seconds=max_age) 28 | response = RedirectResponse(url="/docs", 29 | status_code=status.HTTP_303_SEE_OTHER) 30 | response.set_cookie(key="session_id", value=apikey, httponly=True, 31 | samesite="Lax", secure=True, max_age=max_age, expires=expires,) 32 | response.set_cookie(key="csrf_token", value=session["csrf_token"], httponly=False, 33 | samesite="Lax", secure=True, max_age=max_age, expires=expires,) 34 | response.set_cookie(key="user_token", value=auth.hash_email(email), httponly=False, 35 | samesite="Lax", secure=True, max_age=max_age, expires=expires,) 36 | return response 37 | 38 | response = JSONResponse({"Error": "Invalid ApiKey"}) 39 | response.delete_cookie("session_id") 40 | return response 41 | 42 | doc_router = APIRouter() 43 | 44 | @doc_router.get("/docs") 45 | async def get_documentation(): 46 | return docs.get_swagger_ui_html( 47 | openapi_url="/openapi.json", title="docs") 48 | 49 | @doc_router.get("/redoc") 50 | async def get_redoc_documentation(): 51 | return docs.get_redoc_html( 52 | openapi_url="/openapi.json", title="docs") 53 | 54 | @doc_router.get("/openapi.json") 55 | async def openapi(request: Request): 56 | return utils.get_openapi( 57 | title= request.app.title, 58 | version=request.app.version, 59 | routes=request.app.routes) 60 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, Depends 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.responses import RedirectResponse 4 | 5 | from admin import debug, user, auth, admin 6 | from htmx import htmx, htmx_secret, spa 7 | from images import image 8 | 9 | app = FastAPI( 10 | swagger_ui_parameters={"persistAuthorization": True}, 11 | docs_url=None, redoc_url=None, openapi_url = None 12 | ) 13 | 14 | import os 15 | from starlette.middleware.sessions import SessionMiddleware 16 | app.add_middleware(SessionMiddleware, secret_key=os.urandom(24), 17 | https_only=True,same_site="Strict", 18 | max_age=86400,session_cookie="starlette_session") 19 | 20 | @app.exception_handler(auth.RequiresLogin) 21 | async def requires_login(request: Request, _: Exception): 22 | return RedirectResponse(url="/spa/admin.login") 23 | 24 | app.include_router( 25 | spa.router, 26 | prefix="/spa", 27 | tags=["spa"], 28 | ) 29 | 30 | app.include_router( 31 | htmx.router, 32 | prefix="/htmx", 33 | tags=["htmx"], 34 | ) 35 | 36 | app.include_router( 37 | htmx_secret.router, 38 | prefix="/htmx", 39 | tags=["htmx"], 40 | dependencies=[Depends(auth.is_authenticated)], 41 | ) 42 | 43 | app.include_router( 44 | image.router, 45 | prefix="/img", 46 | tags=["Images"], 47 | ) 48 | 49 | app.include_router( 50 | auth.router, 51 | prefix="/auth", 52 | tags=["Auth"], 53 | ) 54 | 55 | app.include_router( 56 | debug.router, 57 | prefix="/debug", 58 | tags=["Debug"], 59 | dependencies=[Depends(auth.is_authenticated)], 60 | ) 61 | 62 | app.include_router( 63 | user.router, 64 | prefix="/crud", 65 | tags=["CRUD"], 66 | dependencies=[Depends(auth.is_authenticated)], 67 | ) 68 | 69 | # docs and redocs 70 | app.include_router( 71 | admin.doc_router, 72 | tags=["Admin"], 73 | include_in_schema=False, 74 | dependencies=[Depends(auth.is_authenticated_admin)] 75 | ) 76 | 77 | # login endpoint for doc and redoc 78 | app.include_router( 79 | admin.router, 80 | prefix="/admin", 81 | tags=["Admin"], 82 | include_in_schema=False, 83 | ) 84 | 85 | origins = [ 86 | "http://localhost:3000", 87 | "http://v200.h.ccmp.jp:4000", 88 | ] 89 | 90 | app.add_middleware( 91 | CORSMiddleware, 92 | allow_origins=origins, 93 | allow_credentials=True, 94 | allow_methods=["*"], 95 | allow_headers=["*"], 96 | ) 97 | -------------------------------------------------------------------------------- /htmx/htmx.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi import APIRouter, Request, HTTPException, status, Depends, Header 3 | from fastapi.templating import Jinja2Templates 4 | from fastapi.responses import HTMLResponse 5 | 6 | from sqlalchemy.orm import Session 7 | from data.db import Customer, get_db 8 | 9 | router = APIRouter() 10 | templates = Jinja2Templates(directory='templates') 11 | 12 | @router.get("/content.top", response_class=HTMLResponse) 13 | async def spa_content(request: Request, hx_request: Optional[str] = Header(None)): 14 | if not hx_request: 15 | raise HTTPException( 16 | status_code=status.HTTP_400_BAD_REQUEST, 17 | detail="Only HX request is allowed to this end point." 18 | ) 19 | context = {"request": request, "title": "Htmx Spa Top"} 20 | return templates.TemplateResponse("content.top.j2", context) 21 | 22 | # HTMX Incremental table update 23 | @router.get("/content.list", response_class=HTMLResponse) 24 | async def content_list(request: Request, skip: int = 0, limit: int = 2, hx_request: Optional[str] = Header(None)): 25 | if not hx_request: 26 | raise HTTPException( 27 | status_code=status.HTTP_400_BAD_REQUEST, 28 | detail="Only HX request is allowed to this end point." 29 | ) 30 | context = {"request": request, "skip_next": skip, "limit": limit, "title": "Incremental hx-get demo"} 31 | return templates.TemplateResponse("content.list.j2", context) 32 | 33 | @router.get("/content.list.tbody", response_class=HTMLResponse) 34 | async def content_list_tbody(request: Request, skip: int = 0, limit: int = 1, hx_request: Optional[str] = Header(None), db: Session = Depends(get_db)): 35 | if not hx_request: 36 | raise HTTPException( 37 | status_code=status.HTTP_400_BAD_REQUEST, 38 | detail="Only HX request is allowed to this end point." 39 | ) 40 | customers = db.query(Customer).offset(skip).limit(limit).all() 41 | context = {"request": request, "skip_next": skip+limit, "limit": limit, 'customers': customers} 42 | return templates.TemplateResponse("content.list.tbody.j2", context) 43 | 44 | @router.get("/admin.login", response_class=HTMLResponse) 45 | async def admin_login(request: Request, hx_request: Optional[str] = Header(None)): 46 | if not hx_request: 47 | raise HTTPException( 48 | status_code=status.HTTP_400_BAD_REQUEST, 49 | detail="Only HX request is allowed to this end point." 50 | ) 51 | context = {"request": request, "title": "Admin Login"} 52 | return templates.TemplateResponse("admin.login.j2", context) 53 | -------------------------------------------------------------------------------- /data/db.py: -------------------------------------------------------------------------------- 1 | # database.py 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import sessionmaker, declarative_base 4 | from sqlalchemy import Boolean 5 | 6 | DATA_STORE_URI = "sqlite:///data/data.db" 7 | 8 | DataStore = create_engine( 9 | DATA_STORE_URI, connect_args={"check_same_thread": False}, echo=False 10 | ) 11 | SessionDATA = sessionmaker(autocommit=False, autoflush=False, bind=DataStore) 12 | 13 | CACHE_STORE_URI = "sqlite:///data/cache.db" 14 | 15 | CacheStore = create_engine( 16 | CACHE_STORE_URI, connect_args={"check_same_thread": False}, echo=False 17 | ) 18 | SessionCACHE = sessionmaker(autocommit=False, autoflush=False, bind=CacheStore) 19 | 20 | DataStoreBase = declarative_base() 21 | CacheStoreBase = declarative_base() 22 | 23 | # models.py 24 | from sqlalchemy import Boolean, Column, Integer, String 25 | 26 | class Customer(DataStoreBase): 27 | __tablename__ = 'customer' 28 | id = Column('id', Integer, primary_key = True, autoincrement = True) 29 | name = Column('name', String(30)) 30 | email = Column('email', String(254)) 31 | 32 | class User(DataStoreBase): 33 | __tablename__ = 'user' 34 | id = Column('id', Integer, primary_key = True, autoincrement = True) 35 | name = Column('name', String(30)) 36 | email = Column('email', String(254)) 37 | disabled = Column('disabled', Boolean, default=False) 38 | admin = Column('admin', Boolean, default=False) 39 | password = Column('password', String(254)) 40 | picture = Column('picture', String(1024)) 41 | 42 | class Sessions(CacheStoreBase): 43 | __tablename__ = 'sessions' 44 | id = Column('id', Integer, primary_key = True, autoincrement = True) 45 | session_id = Column('session_id', String(254)) 46 | csrf_token = Column('csrf_token', String(254)) 47 | user_id = Column('user_id', Integer) 48 | email = Column('email', String(254)) 49 | expires = Column('expires', Integer) 50 | 51 | # schemas.py 52 | from pydantic import BaseModel, EmailStr, HttpUrl 53 | 54 | class CustomerBase(BaseModel): 55 | id: int 56 | name: str 57 | email: EmailStr 58 | class Config: 59 | from_attributes = True 60 | 61 | class UserBase(BaseModel): 62 | id: int 63 | name: str 64 | email: EmailStr 65 | disabled: bool 66 | admin: bool 67 | password: str | None 68 | picture: HttpUrl | str | None 69 | class Config: 70 | from_attributes = True 71 | 72 | class SessionBase(BaseModel): 73 | id: int 74 | session_id: str 75 | csrf_token: str 76 | user_id: int 77 | email: EmailStr 78 | expires: int 79 | class Config: 80 | from_attributes = True 81 | 82 | DataStoreBase.metadata.create_all(bind=DataStore) 83 | CacheStoreBase.metadata.create_all(bind=CacheStore) 84 | 85 | def get_db(): 86 | ds = SessionDATA() 87 | try: 88 | yield ds 89 | finally: 90 | ds.close() 91 | 92 | def get_cache(): 93 | cs = SessionCACHE() 94 | try: 95 | yield cs 96 | finally: 97 | cs.close() 98 | 99 | -------------------------------------------------------------------------------- /templates/auth_navbar.logout.j2: -------------------------------------------------------------------------------- 1 | 53 | 54 | 55 | 56 | {% include 'auth_refresh_token.j2' %} 57 | 58 | 67 | -------------------------------------------------------------------------------- /templates/auth_refresh_token.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 80 | -------------------------------------------------------------------------------- /templates/auth_navbar.login.j2: -------------------------------------------------------------------------------- 1 | {% include 'auth_navbar.login.callback.j2' %} 2 | 3 | {# {% include 'auth_navbar.login.html.j2' %} #} 4 | {# {% include 'auth_navbar.login.js.j2' %} #} 5 | 6 | 67 | 68 | 76 | 77 | {% include 'auth_refresh_token.j2' %} 78 | 79 | 88 | -------------------------------------------------------------------------------- /admin/debug.py: -------------------------------------------------------------------------------- 1 | from config import settings 2 | from fastapi import APIRouter, HTTPException, Response, Request, Depends, Cookie, Header, Form 3 | from fastapi.templating import Jinja2Templates 4 | from fastapi.responses import JSONResponse, HTMLResponse 5 | from typing import Annotated 6 | from data.db import UserBase 7 | from admin import auth 8 | 9 | from admin.cachestore import CacheStore, get_cache_store 10 | 11 | router = APIRouter() 12 | templates = Jinja2Templates(directory='templates') 13 | 14 | @router.get("/sessions") 15 | async def list_sessions(cs: CacheStore = Depends(get_cache_store)): 16 | return cs.list_sessions() 17 | 18 | @router.get("/env/") 19 | async def env(): 20 | print("settings: ", settings) 21 | return { 22 | "origin_server": settings.origin_server, 23 | "google_oauth2_client_id": settings.google_oauth2_client_id, 24 | } 25 | 26 | @router.get("/me") 27 | async def dump_users_info(request: Request, user: UserBase = Depends(auth.get_current_user), cs: CacheStore = Depends(get_cache_store)): 28 | session_id = request.cookies.get("session_id") 29 | session = cs.get_session(session_id) 30 | try: 31 | return {"user": user, "session": session} 32 | except: 33 | return None 34 | 35 | @router.get("/debug_headers") 36 | async def debug_headers(request: Request): 37 | headers = request.headers 38 | print("Headers: ", headers) 39 | return{"Headers": headers} 40 | 41 | @router.get("/refresh_token") 42 | def refresh_token(response: Response, 43 | session_id: Annotated[str | None, Cookie()] = None, 44 | cs: CacheStore = Depends(get_cache_store)): 45 | # print("session_id: ", session_id) 46 | session = cs.get_session(session_id) 47 | if not session: 48 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id) 49 | 50 | try: 51 | new_session = auth.mutate_session(response, session, cs, False) 52 | return {"ok": True, "new_token": new_session["session_id"], "csrf_token": new_session["csrf_token"]} 53 | except HTTPException as e: 54 | return JSONResponse(status_code=e.status_code, content={"detail": e.detail}) 55 | 56 | @router.get("/csrf_js") 57 | async def csrf_js_get(request: Request): 58 | context = {"request": request, "url":"/debug/csrf_js"} 59 | return templates.TemplateResponse("debug_csrf_js.j2", context) 60 | 61 | @router.post("/csrf_js") 62 | async def csrf_js_post(x_csrf_token: Annotated[str | None, Header()] = None, 63 | session_id: Annotated[str | None, Cookie()] = None, 64 | cs: CacheStore = Depends(get_cache_store)): 65 | csrf_token = x_csrf_token 66 | return await csrf_post(session_id, cs, csrf_token) 67 | 68 | @router.get("/csrf_html", response_class=HTMLResponse) 69 | async def csrf_html_get(request: Request, 70 | csrf_token: Annotated[str | None, Cookie()] = None): 71 | context = {"request": request, "csrf_token": csrf_token, "url":"/debug/csrf_html"} 72 | response = templates.TemplateResponse("debug_csrf_html.j2", context) 73 | return response 74 | 75 | @router.post("/csrf_html") 76 | async def csrf_html_post(csrf_token: Annotated[str | None, Form()] = None, 77 | session_id: Annotated[str | None, Cookie()] = None, 78 | cs: CacheStore = Depends(get_cache_store)): 79 | return await csrf_post(session_id, cs, csrf_token) 80 | 81 | async def csrf_post(session_id, cs, csrf_token): 82 | session = cs.get_session(session_id) 83 | if not session: 84 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id) 85 | csrf_token = auth.csrf_verify(csrf_token, session) 86 | return {"ok": True, "csrf_token": csrf_token} 87 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # FastAPI + HTMX with Sign in with Google 2 | 3 | ## What this look like 4 | 5 | 6 | 7 | ## Directory structure 8 | 9 | ``` 10 | . 11 | ├── Readme.md 12 | ├── admin 13 | │   ├── auth.py 14 | │   ├── debug.py 15 | │   └── user.py 16 | ├── auth 17 | ├── config.py 18 | ├── customer 19 | ├── data 20 | │   ├── cache.db 21 | │   ├── create_data.sh 22 | │   ├── data.db 23 | │   └── db.py 24 | ├── htmx 25 | │   ├── htmx.py 26 | │   └── htmx_secret.py 27 | ├── images 28 | │   ├── FastAPI-HTMX-Google-OAuth01.gif 29 | │   ├── cat_meme.png 30 | │   ├── dog_meme.png 31 | │   ├── door-check-out-icon.png 32 | │   ├── image.py 33 | │   └── unknown-person-icon.png 34 | ├── main.py 35 | └── templates 36 | ├── auth_navbar.login.callback.j2 37 | ├── auth_navbar.login.html.j2 38 | ├── auth_navbar.login.j2 39 | ├── auth_navbar.login.js.j2 40 | ├── auth_navbar.logout.j2 41 | ├── content.error.j2 42 | ├── content.list.j2 43 | ├── content.list.tbody.j2 44 | ├── content.secret.j2 45 | ├── content.top.j2 46 | ├── head.j2 47 | └── spa.j2 48 | ``` 49 | 50 | # Howto run app in this repository. 51 | 52 | ## 1. Setup OAuth configuration on Google APIs console 53 | 54 | 1. Open https://console.cloud.google.com/apis/credentials. 55 | 1. Go CREATE CREDENTIALS -> Go Create OAuth client ID -> Choose "Web applicatin" as Application type -> create. 56 | 1. Save the client ID somewhere, as it is needed later. 57 | 1. Go one of the OAuth 2.0 Client IDs just created, then add both of the following to the Authorized JavaScript origins box. 58 | 59 | ~~~ 60 | http://localhost 61 | http://localhost:8000 62 | ~~~ 63 | 64 | For details, see https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid. 65 | 66 | ## 2. FastAPI 67 | 68 | Prepare python venv and install packages 69 | ~~~ 70 | python3 -m venv .venv 71 | source .venv/bin/activate 72 | pip install fastapi sqlalchemy uvicorn google-auth requests python-dotenv python-multipart pydantic-settings pydantic[email] jinja2 PyJWT redis 73 | ~~~ 74 | 75 | Create database 76 | ~~~ 77 | rm data/data.db data/cache.db 78 | python3 data/db.py 79 | ./data/create_data.sh 80 | ~~~ 81 | 82 | Edit .env in the directory where main.py exists. 83 | ~~~ 84 | ORIGIN_SERVER=http://localhost:3000 85 | GOOGLE_OAUTH2_CLIENT_ID=888888888888-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com 86 | ADMIN_EMAIL=admin@example.com 87 | SESSION_MAX_AGE=300 88 | CACHE_STORE=sql 89 | # CACHE_STORE=redis 90 | REDIS_HOST=localhost 91 | REDIS_PORT=6379 92 | ~~~ 93 | 94 | Run server 95 | ~~~ 96 | uvicorn main:app --host 0.0.0.0 --reload --log-config log_config.yaml 97 | ~~~ 98 | 99 | ## (Optional) Redis for Session Storage 100 | 101 | Run redis 102 | 103 | ``` 104 | docker compose -f data/docker-compose.yml up -d 105 | ``` 106 | 107 | Edit .env file 108 | 109 | ``` 110 | # CACHE_STORE=sql 111 | CACHE_STORE=redis 112 | ``` 113 | 114 | re-create a session for admin login 115 | ``` 116 | ./data/renew_admin_session.sh 117 | ``` 118 | 119 | restart uvicon 120 | ``` 121 | uvicorn main:app --host 0.0.0.0 --reload --log-config log_config.yaml 122 | ``` 123 | 124 | ## (Optional) Monitor Session storage contents 125 | 126 | ### SQLite 127 | 128 | ``` 129 | watch -n 1 'echo "select * from sessions" | sqlite3 data/cache.db' 130 | ``` 131 | 132 | ### Redis 133 | 134 | If the OS has redis-cli, use the following; 135 | 136 | ``` 137 | watch -n 1 'for k in $(redis-cli keys "*" | xargs) ; do echo -n $k": " ; redis-cli get $k|xargs ; done' 138 | ``` 139 | 140 | If the OS does not have redis-cli, first exec into the redis docker container, 141 | 142 | ``` 143 | $ docker ps 144 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 145 | 29a78e16ec02 redis "docker-entrypoint.s…" 7 days ago Up 7 days 0.0.0.0:6379->6379/tcp, :::6379->6379/tcp data-redis-1 146 | 147 | $ docker exec -it data-redis-1 bash 148 | ``` 149 | 150 | then monitor the contents using redis-cli, for example; 151 | 152 | ``` 153 | while true ; do sleep 1 ;clear; for k in $(redis-cli keys "*" | xargs) ; do echo -n $k": " ; redis-cli get $k|xargs ;done ; done 154 | ``` 155 | -------------------------------------------------------------------------------- /admin/cachestore.py: -------------------------------------------------------------------------------- 1 | import secrets, json 2 | import redis 3 | from typing import Optional, Dict, List 4 | from datetime import datetime, timezone, timedelta 5 | from abc import ABC, abstractmethod 6 | from fastapi import HTTPException, status 7 | from sqlalchemy.exc import SQLAlchemyError, NoResultFound 8 | 9 | from data.db import Sessions, get_cache 10 | from config import settings 11 | 12 | class CacheStore(ABC): 13 | 14 | @abstractmethod 15 | def get_session(self, session_id: str) -> Optional[Dict]: 16 | pass 17 | 18 | def list_sessions(self) -> List[Dict]: 19 | pass 20 | 21 | @abstractmethod 22 | def create_session(self, user_id: int, email: str) -> Dict: 23 | pass 24 | 25 | @abstractmethod 26 | def delete_session(self, session_id: str) -> None: 27 | pass 28 | 29 | @abstractmethod 30 | def cleanup_sessions(self) -> None: 31 | pass 32 | 33 | class SQLCacheStore(CacheStore): 34 | def __init__(self, db_session): 35 | self.cs = db_session 36 | 37 | def get_session(self, session_id: str) -> Optional[Dict]: 38 | try: 39 | session_data = self.cs.query(Sessions).filter(Sessions.session_id == session_id).one() 40 | except NoResultFound: 41 | return None 42 | except SQLAlchemyError as e: 43 | print(f"An error occurred while retrieving the session: {e}") 44 | raise RuntimeError(f"An error occurred while retrieving the session: {e}") 45 | 46 | if session_data: 47 | session = session_data.__dict__ 48 | print("session: ", session) 49 | print("session_id: ", session["session_id"]) 50 | return session 51 | else: 52 | return None 53 | 54 | def list_sessions(self) -> List[Dict]: 55 | sessions = self.cs.query(Sessions).offset(0).limit(100).all() 56 | return [session.__dict__ for session in sessions] 57 | 58 | def create_session(self, user_id: int, email: str) -> Dict: 59 | session_id = secrets.token_urlsafe(64) 60 | csrf_token = secrets.token_urlsafe(32) 61 | 62 | session = self.get_session(session_id) 63 | if session: 64 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Duplicate session_id") 65 | 66 | expires = int((datetime.now(timezone.utc) 67 | + timedelta(seconds=settings.session_max_age)).timestamp()) 68 | 69 | session_entry = Sessions(session_id=session_id, csrf_token=csrf_token, 70 | user_id=user_id, email=email, expires=expires) 71 | self.cs.add(session_entry) 72 | self.cs.commit() 73 | self.cs.refresh(session_entry) 74 | return session_entry.__dict__ 75 | 76 | def delete_session(self, session_id: str) -> None: 77 | session = self.cs.query(Sessions).filter(Sessions.session_id == session_id).first() 78 | # session is not dict 79 | if session.email == settings.admin_email: 80 | return 81 | if session: 82 | self.cs.delete(session) 83 | self.cs.commit() 84 | 85 | def cleanup_sessions(self) -> None: 86 | now = int(datetime.now().timestamp()) 87 | expired_sessions = self.cs.query(Sessions).filter(Sessions.expires <= now).all() 88 | for session in expired_sessions: 89 | print("Cleaning up expired session: ", session.session_id) 90 | self.cs.delete(session) 91 | self.cs.commit() 92 | 93 | class RedisCacheStore(CacheStore): 94 | 95 | def __init__(self): 96 | self.redis_client = redis.Redis( 97 | host=settings.redis_host, 98 | port=settings.redis_port, 99 | db=0, 100 | decode_responses=True) 101 | 102 | def get_session(self, session_id: str) -> Optional[dict]: 103 | session_data = self.redis_client.get(f"session:{session_id}") 104 | session = json.loads(session_data) if session_data else None 105 | if session: 106 | print("session: ", session) 107 | print("session_id: ", session["session_id"]) 108 | return session 109 | 110 | def list_sessions(self) -> List[Dict]: 111 | sessions = [] 112 | for key in self.redis_client.scan_iter(match="session:*"): 113 | session_data = self.redis_client.get(key) 114 | session = json.loads(session_data) 115 | sessions.append(session) 116 | return sessions 117 | 118 | def create_session(self, user_id: int, email: str) -> Dict: 119 | session_id = secrets.token_urlsafe(64) 120 | csrf_token = secrets.token_urlsafe(32) 121 | expires = settings.session_max_age 122 | session_data = { 123 | "session_id": session_id, 124 | "csrf_token": csrf_token, 125 | "user_id": user_id, 126 | "email": email, 127 | "expires": int((datetime.now(timezone.utc) + timedelta(seconds=expires)).timestamp()) 128 | } 129 | self.redis_client.setex(f"session:{session_id}", expires, json.dumps(session_data)) 130 | return session_data 131 | 132 | def delete_session(self, session_id: str) -> None: 133 | session = self.get_session(session_id) 134 | # session is dict 135 | if session["email"] == settings.admin_email: 136 | return 137 | self.redis_client.delete(f"session:{session_id}") 138 | 139 | def cleanup_sessions(self) -> None: 140 | # Redis handles session expiration automatically based on the TTL set during creation. 141 | pass 142 | 143 | def get_cache_store() -> CacheStore: 144 | if settings.cache_store == 'redis': 145 | return RedisCacheStore() 146 | elif settings.cache_store == 'sql': 147 | cs = next(get_cache()) 148 | return SQLCacheStore(cs) 149 | 150 | -------------------------------------------------------------------------------- /admin/auth.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import hashlib, hmac, base64 3 | from datetime import datetime, timezone, timedelta 4 | from fastapi import Depends, APIRouter, HTTPException, status, Response, Request, BackgroundTasks, Header, Cookie 5 | from fastapi.responses import JSONResponse, HTMLResponse 6 | from sqlalchemy.orm import Session 7 | from data.db import User, UserBase 8 | from data.db import get_db 9 | from admin.user import create as GetOrCreateUser 10 | 11 | from typing import Annotated 12 | from fastapi.security import APIKeyCookie 13 | 14 | from google.oauth2 import id_token 15 | from google.auth.transport import requests 16 | from config import settings 17 | 18 | from admin.cachestore import CacheStore, get_cache_store 19 | 20 | from fastapi.templating import Jinja2Templates 21 | 22 | router = APIRouter() 23 | templates = Jinja2Templates(directory='templates') 24 | 25 | cookie_scheme = APIKeyCookie(name="session_id", description="Admin session_id is created by create_session.sh") 26 | 27 | def hash_email(email: str): 28 | # return hashlib.sha256(email.encode()).hexdigest() 29 | return base64.urlsafe_b64encode(hashlib.sha256(email.encode()).digest()).decode() 30 | 31 | def mutate_session(response: Response, old_session: dict, cs: CacheStore, immediate: bool = False): 32 | if not old_session: 33 | raise HTTPException(status_code=404, detail="Session not found") 34 | if old_session["email"] == settings.admin_email: 35 | return old_session 36 | 37 | age_left = old_session["expires"] - int(datetime.now(timezone.utc).timestamp()) 38 | if not immediate and age_left*2 > settings.session_max_age: 39 | print("Session still has much time: ", age_left, " seconds left.") 40 | return old_session 41 | 42 | print("Session expires soon in", age_left, ". Mutating the session.") 43 | session = cs.create_session(old_session["user_id"], old_session["email"]) 44 | new_cookie(response, session) 45 | cs.delete_session(old_session["session_id"]) 46 | return session 47 | 48 | def new_cookie(response: Response, session: dict): 49 | if not session: 50 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No session provided") 51 | 52 | max_age = settings.session_max_age 53 | samesite = "Strict" # For normal web pages, it is better to use Strict. 54 | # samesite = "Lax" For API server, it is better to use Lax. 55 | # !!! Warining; It is very important to set httponly=True for the session_id cookie!!! 56 | response.set_cookie(key="session_id", value=session["session_id"], httponly=True, 57 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"]) 58 | response.set_cookie(key="csrf_token", value=session["csrf_token"], httponly=False, 59 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"]) 60 | response.set_cookie(key="user_token", value=hash_email(session["email"]), httponly=False, 61 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"]) 62 | return response 63 | 64 | def delete_cookie(response: Response): 65 | max_age = 0 66 | expires = datetime.now(timezone.utc) + timedelta(seconds=max_age) 67 | samesite = "Strict" # For normal web pages, it is better to use Strict. 68 | # samesite = "Lax" For API server, it is better to use Lax. 69 | response.set_cookie(key="session_id", value="", httponly=True, 70 | samesite=samesite, secure=True, max_age=max_age, expires=expires,) 71 | response.set_cookie(key="csrf_token", value="", httponly=False, 72 | samesite=samesite, secure=True, max_age=max_age, expires=expires,) 73 | response.set_cookie(key="user_token", value="", httponly=False, 74 | samesite=samesite, secure=True, max_age=max_age, expires=expires,) 75 | return response 76 | 77 | def csrf_verify(csrf_token: str, session: dict): 78 | print("### Debug: csrf_verify: ", csrf_token) 79 | if hmac.compare_digest(csrf_token, session['csrf_token']): 80 | # if csrf_token == session['csrf_token']: 81 | return csrf_token 82 | else: 83 | raise HTTPException(status_code=403, detail="CSRF token: "+csrf_token+" did not match the record.") 84 | 85 | def user_verify(user_token: str, session: dict): 86 | print("### Debug: user_verify: ", user_token) 87 | if hmac.compare_digest(user_token, hash_email(session['email'])): 88 | # if user_token == hash_email(session["email"]): 89 | return user_token 90 | else: 91 | raise HTTPException(status_code=403, detail="USER token: "+user_token+" did not match the record.") 92 | 93 | def get_user_by_user_id(user_id: int, ds: Session): 94 | user=ds.query(User).filter(User.id==user_id).first().__dict__ 95 | return user 96 | 97 | async def get_current_user(session_id: str = Depends(cookie_scheme), 98 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)): 99 | if not session_id: 100 | return None 101 | 102 | session = cs.get_session(session_id) 103 | if not session: 104 | print("get_current_user: No session found for the session_id: ", session_id) 105 | return None 106 | # raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id) 107 | 108 | user_dict = get_user_by_user_id(session["user_id"], ds) 109 | user=UserBase(**user_dict) 110 | return user 111 | 112 | async def is_authenticated(session_id: str = Depends(cookie_scheme), 113 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)): 114 | 115 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds) 116 | 117 | if not user: 118 | raise HTTPException( 119 | status_code=status.HTTP_401_UNAUTHORIZED, 120 | detail="NotAuthenticated" 121 | ) 122 | elif user.disabled: 123 | raise HTTPException( 124 | status_code=status.HTTP_403_FORBIDDEN, 125 | detail="Disabled user" 126 | ) 127 | else: 128 | print("Authenticated.") 129 | return JSONResponse({"message": "Authenticated"}) 130 | 131 | class RequiresLogin(Exception): 132 | pass 133 | 134 | async def is_authenticated_admin( 135 | session_id: Annotated[str | None, Cookie()] = None, 136 | ds: Session = Depends(get_db), 137 | cs: CacheStore = Depends(get_cache_store) 138 | ): 139 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds) 140 | if not user: 141 | raise RequiresLogin("You must log in as Admin") 142 | if not user.disabled and user.admin: 143 | print("Authenticated as Admin.") 144 | return JSONResponse({"message": "Authenticated Admin"}) 145 | raise RequiresLogin("You must log in as Admin") 146 | 147 | async def VerifyToken(jwt: str): 148 | try: 149 | idinfo = id_token.verify_oauth2_token( 150 | jwt, 151 | requests.Request(), 152 | settings.google_oauth2_client_id) 153 | except ValueError: 154 | print("Error: Failed to validate JWT token with GOOGLE_OAUTH2_CLIENT_ID=" + settings.google_oauth2_client_id +".") 155 | return None 156 | 157 | print("idinfo: ", idinfo) 158 | return idinfo 159 | 160 | @router.post("/login") 161 | async def login(request: Request, ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)): 162 | 163 | body = await request.body() 164 | jwt = dict(urllib.parse.parse_qsl(body.decode('utf-8'))).get('credential') 165 | 166 | idinfo = await VerifyToken(jwt) 167 | if not idinfo: 168 | print("Error: Failed to validate JWT token") 169 | return Response("Error: Failed to validate JWT token") 170 | 171 | expected_nonce = request.session.get('expected_nonce') 172 | if not expected_nonce or idinfo['nonce'] != expected_nonce: 173 | raise HTTPException(status_code=400, detail="Invalid nonce") 174 | 175 | request.session['expected_nonce'] = None 176 | 177 | user = await GetOrCreateUser(idinfo, ds) 178 | if not user: 179 | print("Error: Failed to GetOrCreateUser") 180 | return Response("Error: Failed to GetOrCreateUser for the JWT") 181 | 182 | response = JSONResponse({"Authenticated_as": user.name}) 183 | session = cs.create_session(user.id, user.email) 184 | new_cookie(response, session) 185 | 186 | response.headers["HX-Trigger"] = "ReloadNavbar" 187 | return response 188 | 189 | @router.get("/logout") 190 | async def logout(response: Response, 191 | session_id: Annotated[str | None, Cookie()] = None, 192 | hx_request: Annotated[str | None, Header()] = None, 193 | cs: CacheStore = Depends(get_cache_store)): 194 | 195 | if not hx_request: 196 | raise HTTPException( 197 | status_code=status.HTTP_400_BAD_REQUEST, 198 | detail="Only HX request is allowed to this end point." 199 | ) 200 | 201 | response = JSONResponse({"message": "user logged out"}) 202 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent" 203 | cs.delete_session(session_id) 204 | delete_cookie(response) 205 | return response 206 | 207 | @router.get("/auth_navbar", response_class=HTMLResponse) 208 | async def auth_navbar(request: Request, 209 | session_id: Annotated[str|None, Cookie()] = None, 210 | hx_request: Annotated[str|None, Header()] = None, 211 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store) 212 | ): 213 | 214 | if not hx_request: 215 | raise HTTPException( 216 | status_code=status.HTTP_400_BAD_REQUEST, 217 | detail="Only HX request is allowed to this end point." 218 | ) 219 | 220 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds) 221 | 222 | # For authenticated users, return the menu.logout component. 223 | if user: 224 | logout_url = "/auth/logout" 225 | icon_url = "/img/logout.png" 226 | refresh_token_url = "/auth/refresh_token" 227 | mutate_user_url = "/auth/mutate_user" 228 | 229 | context = {"request": request, "logout_url":logout_url, 230 | "icon_url": icon_url, "refresh_token_url": refresh_token_url, "mutate_user_url": mutate_user_url, 231 | "name": user.name, "picture": user.picture, "userToken": hash_email(user.email)} 232 | return templates.TemplateResponse("auth_navbar.logout.j2", context) 233 | 234 | print("User not logged-in.") 235 | cs.cleanup_sessions() 236 | 237 | # For unauthenticated users, return the menu.login component. 238 | client_id = settings.google_oauth2_client_id 239 | login_url = "/auth/login" 240 | icon_url = "/img/icon.png" 241 | refresh_token_url = "/auth/refresh_token" 242 | mutate_user_url = "/auth/mutate_user" 243 | nonce = base64.urlsafe_b64encode(hashlib.sha256(str(datetime.now()).encode()).digest()).decode() 244 | 245 | request.session['expected_nonce'] = nonce 246 | 247 | context = {"request": request, "client_id": client_id, "login_url": login_url, 248 | "icon_url": icon_url, "refresh_token_url": refresh_token_url, "mutate_user_url": mutate_user_url, 249 | "userToken": "anonymous", "nonce": nonce} 250 | response = templates.TemplateResponse("auth_navbar.login.j2", context) 251 | return response 252 | 253 | @router.get("/check") 254 | async def check(response: Response, 255 | session_id: Annotated[str|None, Cookie()] = None, 256 | hx_request: Annotated[str|None, Header()] = None, 257 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)): 258 | 259 | if not hx_request: 260 | raise HTTPException( 261 | status_code=status.HTTP_400_BAD_REQUEST, 262 | detail="Only HX request is allowed to this end point." 263 | ) 264 | 265 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds) 266 | 267 | if not user: 268 | response = JSONResponse({"message": "user logged out"}) 269 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent" 270 | return response 271 | 272 | return Response(status_code=status.HTTP_204_NO_CONTENT) 273 | 274 | @router.get("/refresh_token") 275 | async def refresh_token(response: Response, 276 | hx_request: Annotated[str | None, Header()] = None, 277 | session_id: Annotated[str | None, Cookie()] = None, 278 | x_csrf_token: Annotated[str | None, Header()] = None, 279 | x_user_token: Annotated[str | None, Header()] = None, 280 | cs: CacheStore = Depends(get_cache_store)): 281 | 282 | if not hx_request: 283 | raise HTTPException( 284 | status_code=status.HTTP_400_BAD_REQUEST, 285 | detail="Only HX request is allowed to this end point.") 286 | 287 | if not session_id: 288 | raise HTTPException(status_code=403, detail="No session_id in the request") 289 | 290 | session = cs.get_session(session_id) 291 | if not session: 292 | print("refresh_token: No session found for the session_id: ", session_id) 293 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id) 294 | 295 | try: 296 | csrf_verify(x_csrf_token, session) 297 | user_verify(x_user_token, session) 298 | new_session = mutate_session(response, session, cs, False) 299 | if new_session != session: 300 | print("Session mutated, new_session: ", new_session) 301 | response.headers["HX-Trigger"] = "ReloadNavbar" 302 | return {"ok": True, "new_session_id": new_session["session_id"]} 303 | except HTTPException as e: 304 | response = JSONResponse(status_code=e.status_code, content={"detail": e.detail}) 305 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent" 306 | return response 307 | 308 | @router.get("/mutate_user") 309 | async def mutate_user( 310 | hx_request: Annotated[str | None, Header()] = None, 311 | x_user_token: Annotated[str | None, Header()] = None, 312 | ): 313 | 314 | if not hx_request: 315 | raise HTTPException( 316 | status_code=status.HTTP_400_BAD_REQUEST, 317 | detail="Only HX request is allowed to this end point.") 318 | 319 | response = JSONResponse({"User mutated, new user": x_user_token}) 320 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent" 321 | return response 322 | 323 | @router.get("/logout_content") 324 | async def logout_content(request: Request, 325 | hx_request: Annotated[str|None, Header()] = None): 326 | if not hx_request: 327 | raise HTTPException( 328 | status_code=status.HTTP_400_BAD_REQUEST, 329 | detail="Only HX request is allowed to this end point." 330 | ) 331 | 332 | context = {"request": request, "message": "User logged out"} 333 | return templates.TemplateResponse("content.error.j2", context) 334 | 335 | @router.get("/cleanup_sessions") 336 | async def cleanup_sessions( 337 | background_tasks: BackgroundTasks, 338 | session_id: Annotated[str|None, Cookie()] = None, 339 | cs: CacheStore = Depends(get_cache_store)): 340 | if not session_id: 341 | return {"message": "Session CleanUp not triggered. Please login first."} 342 | 343 | background_tasks.add_task(cs.cleanup_sessions) 344 | return {"message": "Session CleanUp triggered."} 345 | --------------------------------------------------------------------------------