From 685ed1c227459f48492eac5d476962a5fd1cfc5c Mon Sep 17 00:00:00 2001 From: Anuj Gupta <84966248+Anuj-Gupta4@users.noreply.github.com> Date: Mon, 9 Dec 2024 19:57:43 +0545 Subject: [PATCH] fix(backend): login enforced for management, additional temp login option 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 --- src/backend/app/auth/auth_routes.py | 111 +++++++++++++++++++++-- src/backend/app/auth/osm.py | 76 ++++++++++++++-- src/backend/app/config.py | 5 + src/backend/app/helpers/helper_routes.py | 4 +- 4 files changed, 179 insertions(+), 17 deletions(-) diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index 91df93c4a0..2bfc809dc1 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -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, @@ -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( @@ -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, @@ -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)], ): @@ -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, @@ -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, @@ -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, @@ -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", + ) diff --git a/src/backend/app/auth/osm.py b/src/backend/app/auth/osm.py index 774b9c0b54..e0d115d456 100644 --- a/src/backend/app/auth/osm.py +++ b/src/backend/app/auth/osm.py @@ -20,6 +20,7 @@ import os import time +from typing import Optional import jwt from fastapi import Header, HTTPException, Request @@ -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) @@ -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. @@ -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. @@ -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, @@ -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 diff --git a/src/backend/app/config.py b/src/backend/app/config.py index fc941b7fdf..f9f3f66fd1 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -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( diff --git a/src/backend/app/helpers/helper_routes.py b/src/backend/app/helpers/helper_routes.py index e94d57daee..4686e805ed 100644 --- a/src/backend/app/helpers/helper_routes.py +++ b/src/backend/app/helpers/helper_routes.py @@ -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)}, @@ -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: