Skip to content

Replace plaintext auth tokens with HttpOnly cookies #1606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions backend/.idea/backend.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/.idea/inspectionProfiles/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions backend/.idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions backend/.idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/.idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 15 additions & 3 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import APIKeyCookie, OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session
Expand All @@ -16,6 +16,7 @@
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
cookie_scheme = APIKeyCookie(name="http_only_auth_cookie")


def get_db() -> Generator[Session, None, None]:
Expand All @@ -27,22 +28,33 @@ def get_db() -> Generator[Session, None, None]:
TokenDep = Annotated[str, Depends(reusable_oauth2)]


def get_current_user(session: SessionDep, token: TokenDep) -> User:
def get_current_user(
session: SessionDep,
http_only_auth_cookie: str = Depends(cookie_scheme),
) -> User:
if not http_only_auth_cookie:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)

try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
http_only_auth_cookie, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")

return user


Expand Down
29 changes: 19 additions & 10 deletions backend/app/api/routes/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.security import OAuth2PasswordRequestForm

from app import crud
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from app.api.deps import (
CurrentUser,
SessionDep,
get_current_active_superuser,
get_current_user,
)
from app.core import security
from app.core.config import settings
from app.core.security import get_password_hash
from app.models import Message, NewPassword, Token, UserPublic
from app.models import Message, NewPassword, UserPublic
from app.utils import (
generate_password_reset_token,
generate_reset_password_email,
Expand All @@ -24,9 +29,9 @@
@router.post("/login/access-token")
def login_access_token(
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
) -> JSONResponse:
"""
OAuth2 compatible token login, get an access token for future requests
OAuth2-compatible token login: get an access token for future requests (sent in an HTTP-only cookie)
"""
user = crud.authenticate(
session=session, email=form_data.username, password=form_data.password
Expand All @@ -36,11 +41,7 @@ def login_access_token(
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return Token(
access_token=security.create_access_token(
user.id, expires_delta=access_token_expires
)
)
return security.set_auth_cookie(user.id, access_token_expires)


@router.post("/login/test-token", response_model=UserPublic)
Expand Down Expand Up @@ -122,3 +123,11 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any:
return HTMLResponse(
content=email_data.html_content, headers={"subject:": email_data.subject}
)


@router.post("/logout", dependencies=[Depends(get_current_user)])
def logout() -> JSONResponse:
"""
Delete the HTTP-only cookie during logout
"""
return security.delete_auth_cookie()
6 changes: 4 additions & 2 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CurrentUser,
SessionDep,
get_current_active_superuser,
get_current_user,
)
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
Expand Down Expand Up @@ -117,15 +118,16 @@ def update_password_me(
return Message(message="Password updated successfully")


@router.get("/me", response_model=UserPublic)
@router.get("/me", response_model=UserPublic, dependencies=[Depends(get_current_user)])
def read_user_me(current_user: CurrentUser) -> Any:
"""
Get current user.
"""
print(current_user)
return current_user


@router.delete("/me", response_model=Message)
@router.delete("/me", response_model=Message, dependencies=[Depends(get_current_user)])
def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
"""
Delete own user.
Expand Down
33 changes: 32 additions & 1 deletion backend/app/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
from typing import Any

import jwt
from fastapi.responses import JSONResponse
from passlib.context import CryptContext

from app.core.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


ALGORITHM = "HS256"


Expand All @@ -19,6 +19,37 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
return encoded_jwt


def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> JSONResponse:
access_token = create_access_token(subject, expires_delta)
response = JSONResponse(content={"message": "Login successful"})
# Note: The secure flag on cookies ensures they're only sent over encrypted HTTPS connections. For local development (HTTP) set it to False
response.set_cookie(
key="http_only_auth_cookie",
value=access_token,
httponly=True,
max_age=3600,
expires=3600,
samesite="lax",
secure=True,
domain=None,
)
return response


def delete_auth_cookie() -> JSONResponse:
response = JSONResponse(content={"message": "Logout successful"})

response.delete_cookie(
key="http_only_auth_cookie",
path="/",
domain=None,
httponly=True,
samesite="lax",
secure=False, # Should be True in production
)
return response


def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)

Expand Down
Loading
Loading