From 81bab912dca1b0c0c9305a794a19e8f23bd9148d Mon Sep 17 00:00:00 2001 From: Raphael Odini Date: Wed, 15 Nov 2023 13:39:47 +0100 Subject: [PATCH] feat: authentication workflow and store user token (#22) * Install python-multipart. Add OAuth2PasswordBearer * Add /auth path to oauth2_server_url (OFF server) * On auth success, store user token * Replace time with asyncio --- .env | 1 + app/api.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ app/config.py | 1 + app/crud.py | 36 +++++++++++++++++++++++++++++ app/schemas.py | 9 ++++++++ requirements.txt | 1 + 6 files changed, 107 insertions(+) create mode 100644 app/crud.py create mode 100644 app/schemas.py diff --git a/.env b/.env index 918feda6..4cf7e779 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ COMPOSE_PATH_SEPARATOR=; COMPOSE_FILE=docker-compose.yml;docker/dev.yml API_PORT=127.0.0.1:8000 +OAUTH2_SERVER_URL=https://world.openfoodfacts.org/cgi/auth.pl # by default on dev desktop, no restart RESTART_POLICY=no diff --git a/app/api.py b/app/api.py index 28e55e27..d3f1c55e 100644 --- a/app/api.py +++ b/app/api.py @@ -1,14 +1,26 @@ +import asyncio +import uuid from pathlib import Path +from typing import Annotated +import requests +from fastapi import Depends from fastapi import FastAPI +from fastapi import HTTPException from fastapi import Request +from fastapi import Response +from fastapi import status from fastapi.responses import HTMLResponse from fastapi.responses import PlainTextResponse +from fastapi.security import OAuth2PasswordBearer +from fastapi.security import OAuth2PasswordRequestForm from fastapi.templating import Jinja2Templates from openfoodfacts.utils import get_logger +from app import crud from app.config import settings from app.db import session +from app.schemas import UserBase from app.utils import init_sentry @@ -34,6 +46,17 @@ init_sentry(settings.sentry_dns) +# Authentication helpers +# ------------------------------------------------------------------------------ +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth") + + +async def create_token(user_id: str): + return f"{user_id}__U{str(uuid.uuid4())}" + + +# App startup & shutdown +# ------------------------------------------------------------------------------ @app.on_event("startup") async def startup(): global db @@ -45,6 +68,8 @@ async def shutdown(): db.close() +# Routes +# ------------------------------------------------------------------------------ @app.get("/", response_class=HTMLResponse) def main_page(request: Request): return templates.TemplateResponse( @@ -53,6 +78,40 @@ def main_page(request: Request): ) +@app.post("/auth") +async def authentication(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response): + """ + Authentication: provide username/password and get a bearer token in return + + - **username**: Open Food Facts user_id (not email) + - **password**: user password (clear text, but HTTPS encrypted) + + a **token** is returned + to be used in requests with usual "Authorization: bearer token" header + """ + if "oauth2_server_url" not in settings.model_dump(): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OAUTH2_SERVER_URL environment variable missing", + ) + + data = {"user_id": form_data.username, "password": form_data.password} + r = requests.post(settings.oauth2_server_url, data=data) # type: ignore + if r.status_code == 200: + token = await create_token(form_data.username) + user: UserBase = {"user_id": form_data.username, "token": token} # type: ignore + crud.create_user(db, user=user) # type: ignore + return {"access_token": token, "token_type": "bearer"} + elif r.status_code == 403: + await asyncio.sleep(2) # prevents brute-force + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Server error") + + @app.get("/robots.txt", response_class=PlainTextResponse) def robots_txt(): return """User-agent: *\nDisallow: /""" diff --git a/app/config.py b/app/config.py index d6a96af0..df800945 100644 --- a/app/config.py +++ b/app/config.py @@ -33,6 +33,7 @@ class Settings(BaseSettings): postgres_password: str postgres_host: str postgres_port: int = 5432 + oauth2_server_url: str | None = None sentry_dns: str | None = None log_level: LoggingLevel = LoggingLevel.INFO diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 00000000..16ca652f --- /dev/null +++ b/app/crud.py @@ -0,0 +1,36 @@ +from sqlalchemy.orm import Session + +from app.models import User +from app.schemas import UserBase + + +def get_user(db: Session, user_id: str): + return db.query(User).filter(User.user_id == user_id).first() + + +def get_user_by_user_id(db: Session, user_id: str): + return db.query(User).filter(User.user_id == user_id).first() + + +def get_user_by_token(db: Session, token: str): + return db.query(User).filter(User.token == token).first() + + +def create_user(db: Session, user: UserBase): + # first we delete any existing user + delete_user(db, user_id=user["user_id"]) + # then we (re)create a user + db_user = User(user_id=user["user_id"], token=user["token"]) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def delete_user(db: Session, user_id: UserBase): + db_user = get_user_by_user_id(db, user_id=user_id) + if db_user: + db.delete(db_user) + db.commit() + return True + return False diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 00000000..b795f3bc --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel +from pydantic import ConfigDict + + +class UserBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + user_id: str + token: str diff --git a/requirements.txt b/requirements.txt index 848c3da1..e79a3de8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ jinja2==3.1.2 openfoodfacts==0.1.10 psycopg2-binary==2.9.9 pydantic-settings==2.0.3 +python-multipart==0.0.6 requests==2.31.0 sentry-sdk[fastapi]==1.31.0 sqlalchemy==2.0.23