Skip to content

Commit

Permalink
fix(backend): login enforced for management, additional temp login op…
Browse files Browse the repository at this point in the history
…tion for mappers (#1948)

* resolves Backend: OSM login enforced for management, additional temp login option for mappers #1911

* fix(auth): rename endpoint from /refresh/mgmt to /refresh/management
  • Loading branch information
Anuj-Gupta4 authored Dec 9, 2024
1 parent 6fdbf96 commit 685ed1c
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 17 deletions.
111 changes: 103 additions & 8 deletions src/backend/app/auth/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@
from app.auth.osm import (
create_jwt_tokens,
extract_refresh_token_from_cookie,
extract_refresh_token_from_osm_cookie,
extract_token_from_cookie,
init_osm_auth,
login_required,
mapper_login_required,
refresh_access_token,
set_cookies,
verify_token,
Expand Down Expand Up @@ -113,7 +116,7 @@ async def callback(

# Get OSM token from response (serialised in cookie, deserialise to use)
serialised_osm_token = tokens.get("oauth_token")
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
cookie_name = settings.cookie_name
osm_cookie_name = f"{cookie_name}_osm"
log.debug(f"Creating cookie '{osm_cookie_name}' with OSM token")
response_plus_cookies.set_cookie(
Expand All @@ -140,11 +143,19 @@ async def logout():
"""Reset httpOnly cookie to sign out user."""
response = Response(status_code=HTTPStatus.OK)
# Reset all cookies (logout)
fmtm_cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
fmtm_cookie_name = settings.cookie_name
refresh_cookie_name = f"{fmtm_cookie_name}_refresh"
temp_cookie_name = f"{fmtm_cookie_name}_temp"
temp_refresh_cookie_name = f"{fmtm_cookie_name}_temp_refresh"
osm_cookie_name = f"{fmtm_cookie_name}_osm"

for cookie_name in [fmtm_cookie_name, refresh_cookie_name, osm_cookie_name]:
for cookie_name in [
fmtm_cookie_name,
refresh_cookie_name,
temp_cookie_name,
temp_refresh_cookie_name,
osm_cookie_name,
]:
log.debug(f"Resetting cookie in response named '{cookie_name}'")
response.set_cookie(
key=cookie_name,
Expand Down Expand Up @@ -252,8 +263,8 @@ async def my_data(
return await get_or_create_user(db, current_user)


@router.get("/refresh", response_model=AuthUserWithToken)
async def refresh_token(
@router.get("/refresh/management", response_model=AuthUserWithToken)
async def refresh_mgmt_token(
request: Request,
current_user: Annotated[AuthUser, Depends(login_required)],
):
Expand All @@ -267,7 +278,10 @@ async def refresh_token(
},
)
try:
refresh_token = extract_refresh_token_from_cookie(request)
cookie_name = settings.cookie_name
access_token = extract_token_from_cookie(request)

refresh_token = extract_refresh_token_from_osm_cookie(request)
if not refresh_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
Expand All @@ -284,7 +298,6 @@ async def refresh_token(
**current_user.model_dump(),
},
)
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
response.set_cookie(
key=cookie_name,
value=access_token,
Expand All @@ -305,6 +318,83 @@ async def refresh_token(
) from e


@router.get("/refresh/mapper", response_model=AuthUserWithToken)
async def refresh_mapper_token(
request: Request,
current_user: Annotated[AuthUser, Depends(mapper_login_required)],
):
"""Uses the refresh token to generate a new access token."""
if settings.DEBUG:
return JSONResponse(
status_code=HTTPStatus.OK,
content={
"token": "debugtoken",
**current_user.model_dump(),
},
)
try:
refresh_token, temp_refresh_token = extract_refresh_token_from_cookie(request)
if not refresh_token and not temp_refresh_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No refresh token provided",
)

# Will check for both refresh and temp_refresh cookies
# If both are present, the refresh cookie will be used
token_data = None
cookie_name = settings.cookie_name
try:
if refresh_token:
token_data = verify_token(refresh_token)
except Exception:
log.warning("Failed to verify refresh token, checking temp token...")

if not token_data:
token_data = verify_token(temp_refresh_token)
cookie_name = f"{settings.cookie_name}_temp"

access_token = refresh_access_token(token_data)

response = JSONResponse(
status_code=HTTPStatus.OK,
content={
"token": access_token,
**current_user.model_dump(),
},
)
response.set_cookie(
key=cookie_name,
value=access_token,
max_age=86400,
expires=86400,
path="/",
domain=settings.FMTM_DOMAIN,
secure=False if settings.DEBUG else True,
httponly=True,
samesite="lax",
)
if temp_refresh_token and refresh_token:
response.set_cookie(
key=f"{settings.cookie_name}_temp_refresh",
value="",
max_age=0, # Set to expire immediately
expires=0, # Set to expire immediately
path="/",
domain=settings.FMTM_DOMAIN,
secure=False if settings.DEBUG else True,
httponly=True,
samesite="lax",
)
return response

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


@router.get("/temp-login")
async def temp_login(
# email: Optional[str] = None,
Expand All @@ -331,4 +421,9 @@ async def temp_login(
"role": UserRole.MAPPER,
}
access_token, refresh_token = create_jwt_tokens(jwt_data)
return set_cookies(access_token, refresh_token)
return set_cookies(
access_token,
refresh_token,
f"{settings.cookie_name}_temp",
f"{settings.cookie_name}_temp_refresh",
)
76 changes: 69 additions & 7 deletions src/backend/app/auth/osm.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import os
import time
from typing import Optional

import jwt
from fastapi import Header, HTTPException, Request
Expand Down Expand Up @@ -59,6 +60,41 @@ async def login_required(
role=UserRole.ADMIN,
)

# Attempt extract from cookie if access token not passed
if not access_token:
access_token = extract_token_from_osm_cookie(request)

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

try:
token_data = verify_token(access_token)
except ValueError as e:
log.exception(
f"Failed to deserialise access token. Error: {e}", stack_info=True
)
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Access token not valid",
) from e

return AuthUser(**token_data)


async def mapper_login_required(
request: Request, access_token: str = Header(None)
) -> AuthUser:
"""Dependency to inject into endpoints requiring login for mapper frontend."""
if settings.DEBUG:
return AuthUser(
sub="fmtm|1",
username="localadmin",
role=UserRole.ADMIN,
)

# Attempt extract from cookie if access token not passed
if not access_token:
access_token = extract_token_from_cookie(request)
Expand All @@ -83,19 +119,38 @@ async def login_required(
return AuthUser(**token_data)


def extract_token_from_cookie(request: Request) -> str:
def extract_token_from_osm_cookie(request: Request) -> str:
"""Extract access token from cookies."""
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
cookie_name = settings.cookie_name
log.debug(f"Extracting token from cookie {cookie_name}")
return request.cookies.get(cookie_name)


def extract_refresh_token_from_cookie(request: Request) -> str:
def extract_token_from_cookie(request: Request) -> str:
"""Extract access token from cookies."""
cookie_name = settings.cookie_name
temp_cookie_name = f"{cookie_name}_temp"
log.debug(f"Extracting token from cookie {cookie_name}")
return request.cookies.get(cookie_name) or request.cookies.get(temp_cookie_name)


def extract_refresh_token_from_osm_cookie(request: Request) -> str:
"""Extract refresh token from cookies."""
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
cookie_name = settings.cookie_name
return request.cookies.get(f"{cookie_name}_refresh")


def extract_refresh_token_from_cookie(request: Request) -> str:
"""Extract refresh token from cookies."""
cookie_name = settings.cookie_name
refresh_cookie_name = f"{cookie_name}_refresh"
temp_refresh_cookie_name = f"{cookie_name}_temp_refresh"

return request.cookies.get(refresh_cookie_name), request.cookies.get(
temp_refresh_cookie_name
)


def create_jwt_tokens(input_data: dict) -> tuple[str, str]:
"""Generates tokens for the specified user.
Expand Down Expand Up @@ -166,12 +221,20 @@ def verify_token(token: str):
) from e


def set_cookies(access_token: str, refresh_token: str) -> JSONResponse:
def set_cookies(
access_token: str,
refresh_token: str,
cookie_name: Optional[str] = settings.cookie_name,
refresh_cookie_name: Optional[str] = f"{settings.cookie_name}_refresh",
) -> JSONResponse:
"""Sets cookies for the access token and refresh token.
Args:
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:
JSONResponse: A response object with the cookies set.
Expand All @@ -186,7 +249,6 @@ def set_cookies(access_token: str, refresh_token: str) -> JSONResponse:
status_code=HTTPStatus.OK,
content={"token": access_token},
)
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
response.set_cookie(
key=cookie_name,
value=access_token,
Expand All @@ -199,7 +261,7 @@ def set_cookies(access_token: str, refresh_token: str) -> JSONResponse:
samesite="lax",
)
response.set_cookie(
key=f"{cookie_name}_refresh",
key=refresh_cookie_name,
value=refresh_token,
max_age=86400 * 7,
expires=86400 * 7, # expiry set for 7 days
Expand Down
5 changes: 5 additions & 0 deletions src/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ class Settings(BaseSettings):

EXTRA_CORS_ORIGINS: Optional[str | list[str]] = None

@property
def cookie_name(self) -> str:
"""Get the cookie name for the domain."""
return self.FMTM_DOMAIN.replace(".", "_")

@field_validator("EXTRA_CORS_ORIGINS", mode="before")
@classmethod
def assemble_cors_origins(
Expand Down
4 changes: 2 additions & 2 deletions src/backend/app/helpers/helper_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ async def view_user_oauth_token(
The token is encrypted with a secret key and only usable via
this FMTM instance and the osm-login-python module.
"""
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
cookie_name = settings.cookie_name
return JSONResponse(
status_code=HTTPStatus.OK,
content={"access_token": request.cookies.get(cookie_name)},
Expand Down Expand Up @@ -289,7 +289,7 @@ async def send_test_osm_message(
osm_auth: Annotated[Auth, Depends(init_osm_auth)],
):
"""Sends a test message to currently logged in OSM user."""
cookie_name = f"{settings.FMTM_DOMAIN.replace('.', '_')}_osm"
cookie_name = f"{settings.cookie_name}_osm"
log.debug(f"Extracting OSM token from cookie {cookie_name}")
serialised_osm_token = request.cookies.get(cookie_name)
if not serialised_osm_token:
Expand Down

0 comments on commit 685ed1c

Please sign in to comment.