Skip to content
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

Finalise auth setup between frontends #1981

Merged
merged 2 commits into from
Dec 11, 2024
Merged
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
84 changes: 73 additions & 11 deletions src/backend/app/auth/auth_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@

"""Auth dependencies, for restricted routes and cookie handling."""

import time
from time import time
from typing import Optional

import jwt
from fastapi import Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from loguru import logger as log

from app.auth.auth_schemas import AuthUser
Expand Down Expand Up @@ -67,28 +68,25 @@ def set_cookie(


def set_cookies(
response: Response,
access_token: str,
refresh_token: str,
cookie_name: str = settings.cookie_name,
refresh_cookie_name: str = f"{settings.cookie_name}_refresh",
) -> Response:
) -> JSONResponse:
"""Set cookies for the access and refresh tokens.

Args:
response (str): The response to attach the cookies to.
access_token (str): The access token to be stored in the cookie.
refresh_token (str): The refresh token to be stored in the cookie.
cookie_name (str, optional): The name of the cookie to store the access token.
refresh_cookie_name (str, optional): The name of the cookie to store the
refresh token.

Returns:
Response: A response with attached cookies (set-cookie headers).
JSONResponse: A response with attached cookies (set-cookie headers).
"""
# NOTE if needed we can return the token in the JSON response, but we don't for now
# response = JSONResponse(status_code=HTTPStatus.OK,
# content={"token": access_token})
response = Response(status_code=HTTPStatus.OK)

secure = not settings.DEBUG
domain = settings.FMTM_DOMAIN

Expand Down Expand Up @@ -123,7 +121,7 @@ def create_jwt_tokens(input_data: dict) -> tuple[str, str]:
"""
access_token_data = input_data.copy()
# Set refresh token expiry to 7 days
refresh_token_data = {**input_data, "exp": int(time.time()) + 86400 * 7}
refresh_token_data = {**input_data, "exp": int(time()) + 86400 * 7}

encryption_key = settings.ENCRYPTION_KEY.get_secret_value()
algorithm = settings.JWT_ENCRYPTION_ALGORITHM
Expand All @@ -140,7 +138,7 @@ def refresh_jwt_token(
expiry_seconds: int = 86400,
) -> str:
"""Generate a new JTW token with expiry."""
payload["exp"] = int(time.time()) + expiry_seconds
payload["exp"] = int(time()) + expiry_seconds
return jwt.encode(
payload,
settings.ENCRYPTION_KEY.get_secret_value(),
Expand Down Expand Up @@ -188,6 +186,58 @@ def verify_jwt_token(token: str, ignore_expiry: bool = False) -> dict:
) from e


async def refresh_cookies(
request: Request,
current_user: AuthUser,
cookie_name: str,
refresh_cookie_name: str,
):
"""Reusable function to renew the expiry on cookies.

Used by both management and mapper refresh endpoints.
"""
if settings.DEBUG:
return JSONResponse(
status_code=HTTPStatus.OK,
content={**current_user.model_dump()},
)

access_token = get_cookie_value(request, cookie_name)
if not access_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No access token provided",
)

refresh_token = get_cookie_value(request, refresh_cookie_name)
if not refresh_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No refresh token provided",
)

# Decode JWT and get data from both cookies,
# checking refresh expiry is valid first
refresh_token_data = verify_jwt_token(refresh_token)
access_token_data = verify_jwt_token(access_token, ignore_expiry=True)

try:
# Refresh token + refresh token
new_access_token = refresh_jwt_token(access_token_data)
new_refresh_token = refresh_jwt_token(refresh_token_data)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to refresh tokens: {e}",
) from e

# NOTE Append the user data to the JSONResponse so we can display in the
# frontend header. For the mapper frontend this is enough, but for the
# management frontend we instead use the return from /auth/me
response = JSONResponse(status_code=HTTPStatus.OK, content=access_token_data)
return set_cookies(response, new_access_token, new_refresh_token)


### Endpoint Dependencies ###


Expand Down Expand Up @@ -219,7 +269,19 @@ async def mapper_login_required(
settings.cookie_name, # OSM cookie
f"{settings.cookie_name}_temp", # Temp cookie
)
return await _authenticate_user(extracted_token)

# Verify login and continue
if extracted_token:
return await _authenticate_user(extracted_token)

# Else user has no token, so we provide login data automatically
username = "svcfmtm"
temp_user = {
"sub": "fmtm|20386219",
"username": username,
"role": UserRole.MAPPER,
}
return AuthUser(**temp_user)


async def _authenticate_user(access_token: Optional[str]) -> AuthUser:
Expand Down
123 changes: 38 additions & 85 deletions src/backend/app/auth/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""Auth routes, to login, logout, and get user details."""

from time import time
from typing import Annotated
from typing import Annotated, Optional

from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
Expand All @@ -29,12 +29,10 @@

from app.auth.auth_deps import (
create_jwt_tokens,
get_cookie_value,
login_required,
mapper_login_required,
refresh_jwt_token,
refresh_cookies,
set_cookies,
verify_jwt_token,
)
from app.auth.auth_schemas import AuthUser, FMTMUser
from app.auth.providers.osm import handle_osm_callback, init_osm_auth
Expand Down Expand Up @@ -80,6 +78,7 @@ async def callback(
Also returns a cookie containing the access token for persistence in frontend apps.
"""
try:
# This includes the main cookie, refresh cookie, osm token cookie
response_plus_cookies = await handle_osm_callback(request, osm_auth)
return response_plus_cookies
except Exception as e:
Expand Down Expand Up @@ -164,7 +163,7 @@ async def get_or_create_user(
{
"user_id": user_data.id,
"username": user_data.username,
"profile_img": user_data.picture or "",
"profile_img": user_data.profile_img or "",
"role": UserRole(user_data.role).name,
},
)
Expand Down Expand Up @@ -211,64 +210,7 @@ async def my_data(
return await get_or_create_user(db, current_user)


async def refresh_fmtm_cookies(request: Request, current_user: AuthUser):
"""Reusable function to renew the expiry on the FMTM and expiry tokens.

Used by both management and mapper refresh endpoints.
"""
if settings.DEBUG:
return JSONResponse(
status_code=HTTPStatus.OK,
content={**current_user.model_dump()},
)

try:
access_token = get_cookie_value(
request,
settings.cookie_name, # OSM cookie
)
if not access_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No access token provided",
)

refresh_token = get_cookie_value(
request,
f"{settings.cookie_name}_refresh", # OSM refresh cookie
)
if not refresh_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No refresh token provided",
)

# Decode JWT and get data from both cookies
access_token_data = verify_jwt_token(access_token, ignore_expiry=True)
refresh_token_data = verify_jwt_token(refresh_token)

# Refresh token + refresh token
new_access_token = refresh_jwt_token(access_token_data)
new_refresh_token = refresh_jwt_token(refresh_token_data)

response = set_cookies(new_access_token, new_refresh_token)
# Append the user data to the JSONResponse
# We use this in the frontend to determine if the token user matches the
# currently logged in user. If no, we clear the frontend auth state.
return JSONResponse(
status=response.status,
headers=response.headers,
content=access_token_data,
)

except Exception as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to refresh the access token: {e}",
) from e


@router.get("/refresh/management", response_model=AuthUser)
@router.get("/refresh/management", response_model=FMTMUser)
async def refresh_management_cookies(
request: Request,
current_user: Annotated[AuthUser, Depends(login_required)],
Expand All @@ -278,16 +220,20 @@ async def refresh_management_cookies(
This endpoint is specific to the management desktop frontend.
Any temp auth cookies will be ignored and removed.
OSM login is required.

NOTE this endpoint has no db calls and returns in ~2ms.
"""
response = await refresh_fmtm_cookies(request, current_user)
response = await refresh_cookies(
request,
current_user,
settings.cookie_name,
f"{settings.cookie_name}_refresh",
)

# Invalidate any temp cookies from mapper frontend
fmtm_cookie_name = settings.cookie_name
temp_cookie_name = f"{fmtm_cookie_name}_temp"
temp_refresh_cookie_name = f"{fmtm_cookie_name}_temp_refresh"
for cookie_name in [
temp_cookie_name,
temp_refresh_cookie_name,
f"{settings.cookie_name}_temp",
f"{settings.cookie_name}_temp_refresh",
]:
log.debug(f"Resetting cookie in response named '{cookie_name}'")
response.set_cookie(
Expand All @@ -305,7 +251,7 @@ async def refresh_management_cookies(
return response


@router.get("/refresh/mapper", response_model=AuthUser)
@router.get("/refresh/mapper", response_model=Optional[FMTMUser])
async def refresh_mapper_token(
request: Request,
current_user: Annotated[AuthUser, Depends(mapper_login_required)],
Expand All @@ -315,34 +261,41 @@ async def refresh_mapper_token(
This endpoint is specific to the mapper mobile frontend.
By default the user will be logged in with a temporary auth cookie.
OSM auth is optional, if the user wishes to be attributed for contributions.

NOTE this endpoint has no db calls and returns in ~2ms.
"""
try:
response = await refresh_fmtm_cookies(request, current_user)
# If standard login cookie is passed, use that
response = await refresh_cookies(
request,
current_user,
settings.cookie_name,
f"{settings.cookie_name}_refresh",
)
return response
except HTTPException:
# NOTE we allow for token verification to fail for the main cookie
# and fallback to to generate a temp auth cookie
pass

username = "svcfmtm"
jwt_data = {
"sub": "fmtm|20386219",
# Refresh the temp cookies (we must re-create the 'sub' field)
temp_jwt_details = {
**current_user.model_dump(exclude=["id"]),
"sub": f"fmtm|{current_user.id}",
"aud": settings.FMTM_DOMAIN,
"iat": int(time()),
"exp": int(time()) + 86400, # set token expiry to 1 day
"username": username,
"picture": None,
"role": UserRole.MAPPER,
}
access_token, refresh_token = create_jwt_tokens(jwt_data)
response = set_cookies(
access_token,

fmtm_token, refresh_token = create_jwt_tokens(temp_jwt_details)
# NOTE be sure to not append content=current_user.model_dump() to this JSONResponse
# as we want the login state on the frontend to remain empty (allowing the user to
# log in via OSM instead / override)
response = JSONResponse(status_code=HTTPStatus.OK, content={})
return set_cookies(
response,
fmtm_token,
refresh_token,
f"{settings.cookie_name}_temp",
f"{settings.cookie_name}_temp_refresh",
)
return JSONResponse(
status=response.status,
headers=response.headers,
content=jwt_data,
)
Loading
Loading