Skip to content

Commit

Permalink
feat: authentication workflow and store user token (#22)
Browse files Browse the repository at this point in the history
* Install python-multipart. Add OAuth2PasswordBearer

* Add /auth path to oauth2_server_url (OFF server)

* On auth success, store user token

* Replace time with asyncio
  • Loading branch information
raphodn committed Nov 15, 2023
1 parent 556cc6f commit 81bab91
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 0 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
Expand All @@ -45,6 +68,8 @@ async def shutdown():
db.close()


# Routes
# ------------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def main_page(request: Request):
return templates.TemplateResponse(
Expand All @@ -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: /"""
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions app/crud.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions app/schemas.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 81bab91

Please sign in to comment.