Skip to content

Commit

Permalink
feat: add support for cookie authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
raphael0202 committed Nov 23, 2023
1 parent ea2bdb0 commit 9e8413d
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 5 deletions.
35 changes: 30 additions & 5 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
Depends,
FastAPI,
HTTPException,
Query,
Request,
Response,
UploadFile,
status,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi_filter import FilterDepends
Expand All @@ -26,6 +28,7 @@
from sqlalchemy.orm import Session

from app import crud, schemas, tasks
from app.auth import OAuth2PasswordBearerOrAuthCookie
from app.config import settings
from app.db import session
from app.utils import init_sentry
Expand Down Expand Up @@ -74,7 +77,7 @@ def get_db():

# Authentication helpers
# ------------------------------------------------------------------------------
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth")
oauth2_scheme = OAuth2PasswordBearerOrAuthCookie(tokenUrl="auth")


def create_token(user_id: str):
Expand Down Expand Up @@ -110,16 +113,30 @@ def main_page(request: Request):
@app.post("/auth")
def authentication(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
set_cookie: Annotated[
bool,
Query(
description="if set to 1, the token is also set as a cookie "
"named 'session' in the response. This parameter must be passed "
"as a query parameter, e.g.: /auth?set_cookie=1"
),
] = False,
db: Session = Depends(get_db),
):
"""
Authentication: provide username/password and get a bearer token in return
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
A **token** is returned. If the **set_cookie** parameter is set to 1,
the token is also set as a cookie named "session" in the response.
To authenticate, you can either:
- use the **Authorization** header with the **Bearer** scheme,
e.g.: "Authorization: bearer token"
- use the **session** cookie, e.g.: "Cookie: session=token"
"""
if "oauth2_server_url" not in settings.model_dump():
raise HTTPException(
Expand All @@ -133,6 +150,14 @@ def authentication(
token = create_token(form_data.username)
user = schemas.UserBase(user_id=form_data.username, token=token)
crud.create_user(db, user=user)

# set the cookie if requested
if set_cookie:
# Don't add httponly=True or secure=True as it's still in
# development phase, but it should be added once the front-end
# is ready
response.set_cookie(key="session", value=token)

return {"access_token": token, "token_type": "bearer"}
elif r.status_code == 403:
time.sleep(2) # prevents brute-force
Expand Down
53 changes: 53 additions & 0 deletions app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Any, Optional, cast

from fastapi.exceptions 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 starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED


# This class is derived from FastAPI's OAuth2PasswordBearer class,
# but adds support for cookie sessions.
class OAuth2PasswordBearerOrAuthCookie(OAuth2):
def __init__(
self,
tokenUrl: str,
scheme_name: str | None = None,
scopes: dict[str, str] | None = None,
description: str | None = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(
password=cast(Any, {"tokenUrl": tokenUrl, "scopes": scopes})
)
super().__init__(
flows=flows,
scheme_name=scheme_name,
description=description,
auto_error=auto_error,
)

async def __call__(self, request: Request) -> Optional[str]:
authorization = request.headers.get("Authorization")
session_cookie = request.cookies.get("session")
scheme, param = get_authorization_scheme_param(authorization)

# If a session cookie is present, use that instead of the
# Authorization header.
if session_cookie:
return session_cookie

if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param

0 comments on commit 9e8413d

Please sign in to comment.