diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py new file mode 100644 index 0000000000..98f0ec020d --- /dev/null +++ b/src/backend/app/auth/auth_deps.py @@ -0,0 +1,243 @@ +# Copyright (c) Humanitarian OpenStreetMap Team +# +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# + +"""Auth dependencies, for restricted routes and cookie handling.""" + +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 +from app.config import settings +from app.db.enums import HTTPStatus, UserRole + +### Cookie / Token Handling + + +def get_cookie_value(request: Request, *cookie_names: str) -> Optional[str]: + """Get the first available value from a list of cookie names.""" + for name in cookie_names: + value = request.cookies.get(name) + if value: + return value + return None + + +def set_cookie( + response: Response, + key: str, + value: str, + max_age: int, + secure: bool, + domain: str, +) -> None: + """Helper function to set a cookie on a response. + + For now, samesite is set lax, max_age equals expiry. + """ + response.set_cookie( + key=key, + value=value, + max_age=max_age, + expires=max_age, + path="/", + domain=domain, + secure=secure, + httponly=True, + samesite="lax", + ) + + +def set_cookies( + access_token: str, + refresh_token: str, + cookie_name: str = settings.cookie_name, + refresh_cookie_name: str = f"{settings.cookie_name}_refresh", +) -> JSONResponse: + """Set cookies for the access and refresh tokens. + + 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 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 = JSONResponse(status_code=HTTPStatus.OK, content={}) + + secure = not settings.DEBUG + domain = settings.FMTM_DOMAIN + + set_cookie( + response, + cookie_name, + access_token, + max_age=86400, # 1 day + secure=secure, + domain=domain, + ) + set_cookie( + response, + refresh_cookie_name, + refresh_token, + max_age=86400 * 7, # 1 week + secure=secure, + domain=domain, + ) + + return response + + +def create_jwt_tokens(input_data: dict) -> tuple[str, str]: + """Generate access and refresh tokens. + + Args: + input_data (dict): user data for which the access token is being generated. + + Returns: + tuple[str]: The generated access tokens. + """ + access_token_data = input_data.copy() + # Set refresh token expiry to 7 days + refresh_token_data = {**input_data, "exp": int(time.time()) + 86400 * 7} + + encryption_key = settings.ENCRYPTION_KEY.get_secret_value() + algorithm = settings.JWT_ENCRYPTION_ALGORITHM + + access_token = jwt.encode(access_token_data, encryption_key, algorithm=algorithm) + refresh_token = jwt.encode(refresh_token_data, encryption_key, algorithm=algorithm) + + return access_token, refresh_token + + +def refresh_jwt_token( + payload: dict, + # Default expiry 1 day + expiry_seconds: int = 86400, +) -> str: + """Generate a new JTW token with expiry.""" + payload["exp"] = int(time.time()) + expiry_seconds + return jwt.encode( + payload, + settings.ENCRYPTION_KEY.get_secret_value(), + algorithm=settings.JWT_ENCRYPTION_ALGORITHM, + ) + + +def verify_jwt_token(token: str, ignore_expiry: bool = False) -> dict: + """Verify the access token and return its payload. + + Args: + token (str): The access token to be verified. + ignore_expiry (bool): Do not throw an error if the token is expired + upon deserialisation. + + Returns: + dict: The payload of the access token if verification is successful. + + Raises: + HTTPException: If the token has expired or credentials could not be validated. + """ + try: + return jwt.decode( + token, + settings.ENCRYPTION_KEY.get_secret_value(), + algorithms=[settings.JWT_ENCRYPTION_ALGORITHM], + audience=settings.FMTM_DOMAIN, + options={"verify_exp": False if ignore_expiry else True}, + ) + except jwt.ExpiredSignatureError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Refresh token has expired", + ) from e + except jwt.PyJWTError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Could not validate refresh token", + ) from e + except Exception as e: + log.exception(f"Unknown cookie/jwt error: {e}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Could not validate refresh token", + ) from e + + +### Endpoint Dependencies ### + + +async def login_required( + request: Request, access_token: str = Header(None) +) -> AuthUser: + """Dependency for endpoints requiring login.""" + if settings.DEBUG: + return AuthUser(sub="fmtm|1", username="localadmin", role=UserRole.ADMIN) + + # Extract access token only from the OSM cookie + extracted_token = access_token or get_cookie_value( + request, + settings.cookie_name, # OSM cookie + ) + return await _authenticate_user(extracted_token) + + +async def mapper_login_required( + request: Request, access_token: str = Header(None) +) -> AuthUser: + """Dependency for mapper frontend login.""" + if settings.DEBUG: + return AuthUser(sub="fmtm|1", username="localadmin", role=UserRole.ADMIN) + + # Extract access token from OSM cookie, fallback to temp auth cookie + extracted_token = access_token or get_cookie_value( + request, + settings.cookie_name, # OSM cookie + f"{settings.cookie_name}_temp", # Temp cookie + ) + return await _authenticate_user(extracted_token) + + +async def _authenticate_user(access_token: Optional[str]) -> AuthUser: + """Authenticate user by verifying the access token.""" + if not access_token: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="No access token provided", + ) + + try: + token_data = verify_jwt_token(access_token) + except ValueError as e: + log.exception(f"Failed to verify access token: {e}", stack_info=True) + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Access token not valid", + ) from e + + return AuthUser(**token_data) diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index 2bfc809dc1..0fa29d9495 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team +# Copyright (c) Humanitarian OpenStreetMap Team # # This file is part of FMTM. # @@ -27,19 +27,17 @@ from psycopg import Connection from psycopg.rows import class_row -from app.auth.auth_schemas import AuthUser, AuthUserWithToken, FMTMUser -from app.auth.osm import ( +from app.auth.auth_deps import ( create_jwt_tokens, - extract_refresh_token_from_cookie, - extract_refresh_token_from_osm_cookie, - extract_token_from_cookie, - init_osm_auth, + get_cookie_value, login_required, mapper_login_required, - refresh_access_token, + refresh_jwt_token, set_cookies, - verify_token, + verify_jwt_token, ) +from app.auth.auth_schemas import AuthUser, FMTMUser +from app.auth.providers.osm import handle_osm_callback, init_osm_auth from app.config import settings from app.db.database import db_conn from app.db.enums import HTTPStatus, UserRole @@ -80,62 +78,12 @@ async def callback( Provides an access token that can be used for authenticating other endpoints. Also returns a cookie containing the access token for persistence in frontend apps. - - Args: - request: The GET request. - request: The response, including a cookie. - osm_auth: The Auth object from osm-login-python. - - Returns: - JSONResponse: A response including cookies that will be set in-browser. """ try: - log.debug(f"Callback url requested: {request.url}") - - # Enforce https callback url for openstreetmap.org - callback_url = str(request.url).replace("http://", "https://") - - # Get user data from response - tokens = osm_auth.callback(callback_url) - serialised_user_data = tokens.get("user_data") - log.debug(f"Access token returned of length {len(serialised_user_data)}") - osm_user = osm_auth.deserialize_data(serialised_user_data) - user_data = { - "sub": f"fmtm|{osm_user['id']}", - "aud": settings.FMTM_DOMAIN, - "iat": int(time()), - "exp": int(time()) + 86400, # expiry set to 1 day - "username": osm_user["username"], - "email": osm_user.get("email"), - "picture": osm_user.get("img_url"), - "role": UserRole.MAPPER, - } - # Create our JWT tokens from user data - fmtm_token, refresh_token = create_jwt_tokens(user_data) - response_plus_cookies = set_cookies(fmtm_token, refresh_token) - - # Get OSM token from response (serialised in cookie, deserialise to use) - serialised_osm_token = tokens.get("oauth_token") - 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( - key=osm_cookie_name, - value=serialised_osm_token, - max_age=864000, - expires=864000, # expiry set for 10 days - path="/", - domain=settings.FMTM_DOMAIN, - secure=False if settings.DEBUG else True, - httponly=True, - samesite="lax", - ) - + response_plus_cookies = await handle_osm_callback(request, osm_auth) return response_plus_cookies except Exception as e: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, detail=f"Invalid OSM token: {e}" - ) from e + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) from e @router.get("/logout") @@ -263,52 +211,51 @@ async def my_data( return await get_or_create_user(db, current_user) -@router.get("/refresh/management", response_model=AuthUserWithToken) -async def refresh_mgmt_token( - request: Request, - current_user: Annotated[AuthUser, Depends(login_required)], -): - """Uses the refresh token to generate a new access token.""" +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={ - "token": "debugtoken", - **current_user.model_dump(), - }, + content={**current_user.model_dump()}, ) + try: - cookie_name = settings.cookie_name - access_token = extract_token_from_cookie(request) + 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 = extract_refresh_token_from_osm_cookie(request) + 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", ) - token_data = verify_token(refresh_token) - access_token = refresh_access_token(token_data) + # 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) - 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", - ) + # 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. + response.content = access_token_data return response except Exception as e: @@ -318,98 +265,62 @@ async def refresh_mgmt_token( ) from e -@router.get("/refresh/mapper", response_model=AuthUserWithToken) -async def refresh_mapper_token( +@router.get("/refresh/management", response_model=AuthUser) +async def refresh_management_cookies( request: Request, - current_user: Annotated[AuthUser, Depends(mapper_login_required)], + current_user: Annotated[AuthUser, Depends(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" + """Uses the refresh token to generate a new access token. - access_token = refresh_access_token(token_data) + This endpoint is specific to the management desktop frontend. + Any temp auth cookies will be ignored and removed. + OSM login is required. + """ + response = await refresh_fmtm_cookies(request, current_user) - response = JSONResponse( - status_code=HTTPStatus.OK, - content={ - "token": access_token, - **current_user.model_dump(), - }, - ) + # 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, + ]: + log.debug(f"Resetting cookie in response named '{cookie_name}'") response.set_cookie( key=cookie_name, - value=access_token, - max_age=86400, - expires=86400, + 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", ) - 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 + return response -@router.get("/temp-login") -async def temp_login( - # email: Optional[str] = None, +@router.get("/refresh/mapper", response_model=AuthUser) +async def refresh_mapper_token( + request: Request, + current_user: Annotated[AuthUser, Depends(mapper_login_required)], ): - """Handles the authentication check endpoint. - - By creating a temporary access token and - setting it as a cookie. - - Args: - email: email of non-osm user. + """Uses the refresh token to generate a new access token. - Returns: - Response: The response object containing the access token as a cookie. + 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. """ + try: + response = await refresh_fmtm_cookies(request, current_user) + 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", @@ -421,9 +332,11 @@ async def temp_login( "role": UserRole.MAPPER, } access_token, refresh_token = create_jwt_tokens(jwt_data) - return set_cookies( + response = set_cookies( access_token, refresh_token, f"{settings.cookie_name}_temp", f"{settings.cookie_name}_temp_refresh", ) + response.content = jwt_data + return response diff --git a/src/backend/app/auth/auth_schemas.py b/src/backend/app/auth/auth_schemas.py index 52e1d5752d..db1527e0fb 100644 --- a/src/backend/app/auth/auth_schemas.py +++ b/src/backend/app/auth/auth_schemas.py @@ -62,10 +62,10 @@ def id(self) -> int: return int(sub.split("|")[1]) -class AuthUserWithToken(AuthUser): - """Add the JWT token variable to AuthUser response.""" - - token: str +# NOTE we no longer use this, but is present as an example +# class AuthUserWithToken(AuthUser): +# """Add the JWT token variable to AuthUser response.""" +# token: str class FMTMUser(BaseModel): diff --git a/src/backend/app/auth/osm.py b/src/backend/app/auth/osm.py deleted file mode 100644 index e0d115d456..0000000000 --- a/src/backend/app/auth/osm.py +++ /dev/null @@ -1,274 +0,0 @@ -# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team -# -# This file is part of FMTM. -# -# FMTM is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# FMTM is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with FMTM. If not, see . -# - -"""Auth methods related to OSM OAuth2.""" - -import os -import time -from typing import Optional - -import jwt -from fastapi import Header, HTTPException, Request -from fastapi.responses import JSONResponse -from loguru import logger as log -from osm_login_python.core import Auth - -from app.auth.auth_schemas import AuthUser -from app.config import settings -from app.db.enums import HTTPStatus, UserRole - -if settings.DEBUG: - # Required as callback url is http during dev - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - - -async def init_osm_auth(): - """Initialise Auth object from osm-login-python.""" - return Auth( - osm_url=settings.OSM_URL, - client_id=settings.OSM_CLIENT_ID, - client_secret=settings.OSM_CLIENT_SECRET.get_secret_value(), - secret_key=settings.OSM_SECRET_KEY.get_secret_value(), - login_redirect_uri=settings.OSM_LOGIN_REDIRECT_URI, - scope=settings.OSM_SCOPE, - ) - - -async def login_required( - request: Request, access_token: str = Header(None) -) -> AuthUser: - """Dependency to inject into endpoints requiring login.""" - 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_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) - - 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) - - -def extract_token_from_osm_cookie(request: Request) -> str: - """Extract access token from cookies.""" - cookie_name = settings.cookie_name - log.debug(f"Extracting token from cookie {cookie_name}") - return request.cookies.get(cookie_name) - - -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.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. - - Args: - input_data (dict): user data for which the access token is being generated. - - Returns: - Tuple: The generated access tokens. - """ - access_token_data = input_data - access_token = jwt.encode( - access_token_data, - settings.ENCRYPTION_KEY.get_secret_value(), - algorithm=settings.JWT_ENCRYPTION_ALGORITHM, - ) - refresh_token_data = input_data - refresh_token_data["exp"] = ( - int(time.time()) + 86400 * 7 - ) # set refresh token expiry to 7 days - refresh_token = jwt.encode( - refresh_token_data, - settings.ENCRYPTION_KEY.get_secret_value(), - algorithm=settings.JWT_ENCRYPTION_ALGORITHM, - ) - - return access_token, refresh_token - - -def refresh_access_token(payload: dict) -> str: - """Generate a new access token.""" - payload["exp"] = int(time.time()) + 86400 # Access token valid for 1 day - - return jwt.encode( - payload, - settings.ENCRYPTION_KEY.get_secret_value(), - algorithm=settings.JWT_ENCRYPTION_ALGORITHM, - ) - - -def verify_token(token: str): - """Verifies the access token and returns the payload if valid. - - Args: - token (str): The access token to be verified. - - Returns: - dict: The payload of the access token if verification is successful. - - Raises: - HTTPException: If the token has expired or credentials could not be validated. - """ - try: - return jwt.decode( - token, - settings.ENCRYPTION_KEY.get_secret_value(), - algorithms=[settings.JWT_ENCRYPTION_ALGORITHM], - audience=settings.FMTM_DOMAIN, - ) - except jwt.ExpiredSignatureError as e: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="Refresh token has expired", - ) from e - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="Could not validate refresh token", - ) from e - - -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. - - TODO we can refactor this to remove setting the access_token cookie - TODO only the refresh token should be stored in the httpOnly cookie - TODO the access token is used in memory in the browser (not stored) - """ - # NOTE we return the access token to the frontend for electric-sql - # as the expiry is set to 1hr and is relatively safe - response = JSONResponse( - status_code=HTTPStatus.OK, - content={"token": access_token}, - ) - response.set_cookie( - key=cookie_name, - value=access_token, - max_age=86400, - expires=86400, # expiry set for 1 day - path="/", - domain=settings.FMTM_DOMAIN, - secure=False if settings.DEBUG else True, - httponly=True, - samesite="lax", - ) - response.set_cookie( - key=refresh_cookie_name, - value=refresh_token, - max_age=86400 * 7, - expires=86400 * 7, # expiry set for 7 days - path="/", - domain=settings.FMTM_DOMAIN, - secure=False if settings.DEBUG else True, - httponly=True, - samesite="lax", - ) - return response diff --git a/src/backend/app/auth/providers/osm.py b/src/backend/app/auth/providers/osm.py new file mode 100644 index 0000000000..728e984a19 --- /dev/null +++ b/src/backend/app/auth/providers/osm.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team +# +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# + +"""Auth methods related to OSM OAuth2.""" + +import os +from time import time + +from fastapi import Request +from loguru import logger as log +from osm_login_python.core import Auth + +from app.auth.auth_deps import create_jwt_tokens, set_cookies +from app.config import settings +from app.db.enums import UserRole + +if settings.DEBUG: + # Required as callback url is http during dev + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + + +async def init_osm_auth(): + """Initialise Auth object from osm-login-python.""" + return Auth( + osm_url=settings.OSM_URL, + client_id=settings.OSM_CLIENT_ID, + client_secret=settings.OSM_CLIENT_SECRET.get_secret_value(), + secret_key=settings.OSM_SECRET_KEY.get_secret_value(), + login_redirect_uri=settings.OSM_LOGIN_REDIRECT_URI, + scope=settings.OSM_SCOPE, + ) + + +async def handle_osm_callback(request: Request, osm_auth: Auth): + """Handle OSM callback in OAuth flow. + + Args: + request: The GET request. + osm_auth: The Auth object from osm-login-python. + + Returns: + Response: A response including cookies that will be set in-browser. + """ + log.debug(f"Callback url requested: {request.url}") + + # Enforce https callback url for openstreetmap.org + callback_url = str(request.url).replace("http://", "https://") + + # Get user data from response + try: + tokens = osm_auth.callback(callback_url) + serialised_user_data = tokens.get("user_data") + log.debug(f"Access token returned of length {len(serialised_user_data)}") + osm_user = osm_auth.deserialize_data(serialised_user_data) + user_data = { + "sub": f"fmtm|{osm_user['id']}", + "aud": settings.FMTM_DOMAIN, + "iat": int(time()), + "exp": int(time()) + 86400, # expiry set to 1 day + "username": osm_user["username"], + "email": osm_user.get("email"), + "picture": osm_user.get("img_url"), + "role": UserRole.MAPPER, + } + except Exception as e: + raise ValueError(f"Invalid OSM token: {e}") from e + + # Create our JWT tokens from user data + fmtm_token, refresh_token = create_jwt_tokens(user_data) + response_plus_cookies = set_cookies(fmtm_token, refresh_token) + + # Get OSM token from response (serialised in cookie, deserialise to use) + serialised_osm_token = tokens.get("oauth_token") + 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( + key=osm_cookie_name, + value=serialised_osm_token, + max_age=864000, + expires=864000, # expiry set for 10 days + path="/", + domain=settings.FMTM_DOMAIN, + secure=False if settings.DEBUG else True, + httponly=True, + samesite="lax", + ) + + return response_plus_cookies diff --git a/src/backend/app/auth/roles.py b/src/backend/app/auth/roles.py index c4b4e7a951..c14cc588e0 100644 --- a/src/backend/app/auth/roles.py +++ b/src/backend/app/auth/roles.py @@ -30,8 +30,8 @@ from psycopg.rows import class_row from pydantic import Field +from app.auth.auth_deps import login_required, mapper_login_required from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict -from app.auth.osm import login_required from app.db.database import db_conn from app.db.enums import HTTPStatus, ProjectRole, ProjectVisibility from app.db.models import DbProject, DbUser @@ -347,7 +347,8 @@ async def validator( async def mapper( project: Annotated[DbProject, Depends(get_project)], db: Annotated[Connection, Depends(db_conn)], - current_user: Annotated[AuthUser, Depends(login_required)], + # Here temp auth token/cookie is allowed + current_user: Annotated[AuthUser, Depends(mapper_login_required)], ) -> ProjectUserDict: """A mapper for a specific project. @@ -363,6 +364,9 @@ async def mapper( "project": project, } + # As the default user for temp auth (svcfmtm) does not have valid permissions + # on any project, this will block access for temp login users on projects + # that are not public return await wrap_check_access( project, db, diff --git a/src/backend/app/central/central_routes.py b/src/backend/app/central/central_routes.py index e352161659..128f2af6bf 100644 --- a/src/backend/app/central/central_routes.py +++ b/src/backend/app/central/central_routes.py @@ -25,8 +25,8 @@ from loguru import logger as log from psycopg import Connection +from app.auth.auth_deps import login_required from app.auth.auth_schemas import AuthUser -from app.auth.osm import login_required from app.auth.roles import project_manager from app.central import central_crud from app.db.database import db_conn diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 61a951ba1d..043a9757b8 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -1008,9 +1008,41 @@ def set_odk_credentials_on_project( ) @classmethod - async def one(cls, db: Connection, project_id: int) -> Self: + async def one(cls, db: Connection, project_id: int, minimal: bool = False) -> Self: """Get project by ID, including all tasks and other details.""" - async with db.cursor(row_factory=class_row(cls)) as cur: + # Simpler query without additional metadata + if minimal: + sql = """ + SELECT + p.*, + ST_AsGeoJSON(p.outline)::jsonb AS outline, + ST_AsGeoJSON(ST_Centroid(p.outline))::jsonb AS centroid, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', t.id, + 'project_id', t.project_id, + 'project_task_index', t.project_task_index, + 'outline', ST_AsGeoJSON(t.outline)::jsonb, + 'feature_count', t.feature_count + ) + ) FILTER (WHERE t.id IS NOT NULL), '[]'::json + ) AS tasks + FROM + projects p + LEFT JOIN + tasks t ON t.project_id = %(project_id)s + WHERE + p.id = %(project_id)s AND ( + t.project_id = %(project_id)s + -- Also required to return a project with if tasks + OR t.project_id IS NULL + ) + GROUP BY p.id; + """ + + # Full query with all additional calculated fields + else: sql = """ WITH latest_status_per_task AS ( SELECT DISTINCT ON (task_id) @@ -1106,36 +1138,7 @@ async def one(cls, db: Connection, project_id: int) -> Self: p.id, project_org.id, project_bbox.bbox; """ - # Simpler query without additional metadata - # sql = """ - # SELECT - # p.*, - # ST_AsGeoJSON(p.outline)::jsonb AS outline, - # ST_AsGeoJSON(ST_Centroid(p.outline))::jsonb AS centroid, - # COALESCE( - # JSON_AGG( - # JSON_BUILD_OBJECT( - # 'id', t.id, - # 'project_id', t.project_id, - # 'project_task_index', t.project_task_index, - # 'outline', ST_AsGeoJSON(t.outline)::jsonb, - # 'feature_count', t.feature_count - # ) - # ) FILTER (WHERE t.id IS NOT NULL), '[]'::json - # ) AS tasks - # FROM - # projects p - # LEFT JOIN - # tasks t ON t.project_id = %(project_id)s - # WHERE - # p.id = %(project_id)s AND ( - # t.project_id = %(project_id)s - # -- Also required to return a project with if tasks - # OR t.project_id IS NULL - # ) - # GROUP BY p.id; - # """ - + async with db.cursor(row_factory=class_row(cls)) as cur: await cur.execute( sql, {"project_id": project_id}, diff --git a/src/backend/app/helpers/helper_routes.py b/src/backend/app/helpers/helper_routes.py index 4686e805ed..5f37167e41 100644 --- a/src/backend/app/helpers/helper_routes.py +++ b/src/backend/app/helpers/helper_routes.py @@ -38,8 +38,9 @@ from osm_fieldwork.xlsforms import xlsforms_path from osm_login_python.core import Auth +from app.auth.auth_deps import login_required from app.auth.auth_schemas import AuthUser -from app.auth.osm import init_osm_auth, login_required +from app.auth.providers.osm import init_osm_auth from app.central import central_deps from app.central.central_crud import ( convert_geojson_to_odk_csv, diff --git a/src/backend/app/organisations/organisation_routes.py b/src/backend/app/organisations/organisation_routes.py index 76a60f53ea..ed9443c864 100644 --- a/src/backend/app/organisations/organisation_routes.py +++ b/src/backend/app/organisations/organisation_routes.py @@ -30,8 +30,8 @@ from loguru import logger as log from psycopg import Connection +from app.auth.auth_deps import login_required from app.auth.auth_schemas import AuthUser, OrgUserDict -from app.auth.osm import login_required from app.auth.roles import org_admin, super_admin from app.db.database import db_conn from app.db.enums import HTTPStatus diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index ecfe348778..dbc626f06c 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -45,8 +45,8 @@ from osm_fieldwork.xlsforms import xlsforms_path from psycopg import Connection +from app.auth.auth_deps import login_required, mapper_login_required from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict -from app.auth.osm import login_required from app.auth.roles import mapper, org_admin, project_manager from app.central import central_crud, central_deps, central_schemas from app.config import settings @@ -1133,6 +1133,29 @@ async def read_project( return project_user.get("project") +@router.get("/{project_id}/minimal", response_model=project_schemas.ProjectOut) +async def read_project_minimal( + project_id: int, + db: Annotated[Connection, Depends(db_conn)], + current_user: Annotated[AuthUser, Depends(mapper_login_required)], +): + """Get a specific project by ID, with minimal metadata. + + This endpoint is used for a quick return on the mapper frontend, + without all additional calculated fields. + + It can also be accessed via temporary authentication, regardless of + project visibility, hence has very minimal metadata included + (no sensitive fields). + + NOTE this does mean the odk_token can be retrieved from this endpoint + and is a small leak that could be addressed in future if needed. + (any user could theoretically submit a contribution via the ODK + token, even if this is a private project). + """ + return await DbProject.one(db, project_id, minimal=True) + + @router.get("/{project_id}/download") async def download_project_boundary( project_user: Annotated[ProjectUserDict, Depends(mapper)], diff --git a/src/frontend/src/utilfunctions/login.ts b/src/frontend/src/utilfunctions/login.ts index e246b04f28..777fcc2137 100644 --- a/src/frontend/src/utilfunctions/login.ts +++ b/src/frontend/src/utilfunctions/login.ts @@ -1,3 +1,5 @@ +// The /auth/me endpoint does an UPSERT in the database, ensuring the user +// exists in the FMTM DB export const getUserDetailsFromApi = async () => { const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, { credentials: 'include', diff --git a/src/frontend/src/views/PlaywrightTempLogin.tsx b/src/frontend/src/views/PlaywrightTempLogin.tsx index d23bdf550b..ec8b34f8a3 100644 --- a/src/frontend/src/views/PlaywrightTempLogin.tsx +++ b/src/frontend/src/views/PlaywrightTempLogin.tsx @@ -11,7 +11,7 @@ import { LoginActions } from '@/store/slices/LoginSlice'; async function PlaywrightTempAuth() { const dispatch = CoreModules.useAppDispatch(); // Sets a cookie in the browser that is used for auth - await axios.get(`${import.meta.env.VITE_API_URL}/auth/temp-login`); + await axios.get(`${import.meta.env.VITE_API_URL}/auth/refresh/management`); const apiUser = await getUserDetailsFromApi(); if (!apiUser) { diff --git a/src/mapper/src/constants/enums.ts b/src/mapper/src/constants/enums.ts index 93365d14f1..880a2831bc 100644 --- a/src/mapper/src/constants/enums.ts +++ b/src/mapper/src/constants/enums.ts @@ -1,4 +1,8 @@ export enum projectSetupStep { + // TODO add a prompt here for the user to log in via OSM + // if they are not already, informing them this is required + // to get attribution for their mapping contributions + 'osm_login_prompt' = 0, 'odk_project_load' = 1, 'task_selection' = 2, 'complete_setup' = 3, diff --git a/src/mapper/src/lib/components/header.svelte b/src/mapper/src/lib/components/header.svelte index 597ab86579..bbebeab6ab 100644 --- a/src/mapper/src/lib/components/header.svelte +++ b/src/mapper/src/lib/components/header.svelte @@ -5,7 +5,7 @@ import Login from '$lib/components/login.svelte'; import { getLoginStore } from '$store/login.svelte.ts'; import { drawerItems as menuItems } from '$constants/drawerItems.ts'; - import { revokeCookie } from '$lib/utils/login'; + import { revokeCookies } from '$lib/utils/login'; import { getAlertStore } from '$store/common.svelte'; let drawerRef: any = $state(); @@ -13,17 +13,16 @@ const alertStore = getAlertStore(); onMount(() => { - // retrieve persisted auth details from local storage and set auth details to store - const persistedAuth = localStorage.getItem('persist:login'); - if (!persistedAuth) return; - loginStore.setAuthDetails(JSON.parse(JSON.parse(persistedAuth).authDetails)); + // retrieve persisted auth details from local storage and set auth details to store + loginStore.retrieveAuthDetailsFromLocalStorage(); }); const handleSignOut = async () => { try { - await revokeCookie(); + await revokeCookies(); loginStore.signOut(); - window.location.href = window.location.origin; + drawerRef.hide(); + // window.location.href = window.location.origin; } catch (error) { alertStore.setAlert({ variant: 'danger', message: 'Sign Out Failed' }); } diff --git a/src/mapper/src/lib/components/login.svelte b/src/mapper/src/lib/components/login.svelte index 4d49c46efc..3f9b2f542c 100644 --- a/src/mapper/src/lib/components/login.svelte +++ b/src/mapper/src/lib/components/login.svelte @@ -1,8 +1,7 @@ diff --git a/src/mapper/src/lib/utils/login.ts b/src/mapper/src/lib/utils/login.ts index b79b30a4d7..a3568e1805 100644 --- a/src/mapper/src/lib/utils/login.ts +++ b/src/mapper/src/lib/utils/login.ts @@ -1,19 +1,5 @@ -export const getUserDetailsFromApi = async () => { - const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, { - credentials: 'include', - }); - - if (resp.status !== 200) { - return false; - } - - const apiUser = await resp.json(); - - if (!apiUser) return false; - - return apiUser; -}; - +// Note the callback is handled in the management frontend under /osmauth, +// then the user is redirected back to the mapper frontend URL requested export const osmLoginRedirect = async () => { try { const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/osm-login`); @@ -22,7 +8,7 @@ export const osmLoginRedirect = async () => { } catch (error) {} }; -export const revokeCookie = async () => { +export const revokeCookies = async () => { try { const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout`, { credentials: 'include' }); if (!response.ok) { @@ -32,11 +18,3 @@ export const revokeCookie = async () => { throw error; } }; - -export const TemporaryLoginService: Function = async (url: string) => { - // Sets a cookie in the browser that is used for auth - await fetch(url, { credentials: 'include' }); - - const apiUser = await getUserDetailsFromApi(); - return apiUser; -}; diff --git a/src/mapper/src/routes/[projectId]/+page.ts b/src/mapper/src/routes/[projectId]/+page.ts index a8ecfa0bfd..370aa8a237 100644 --- a/src/mapper/src/routes/[projectId]/+page.ts +++ b/src/mapper/src/routes/[projectId]/+page.ts @@ -20,10 +20,18 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { } const userObj = await userResponse.json(); + // Clear stored auth state if mismatch (but skip for localadmin id=1) + if (userObj.id !== 1 && userObj.username !== loginStore.getAuthDetails?.username) { + loginStore.signOut(); + throw error(401, { message: `Please log in again` }); + } else { + loginStore.setAuthDetails(userObj); + } + /* Project details */ - const projectResponse = await fetch(`${API_URL}/projects/${projectId}`, { credentials: 'include' }); + const projectResponse = await fetch(`${API_URL}/projects/${projectId}/minimal`, { credentials: 'include' }); if (projectResponse.status === 401) { // TODO redirect to different error page to handle login throw error(401, { message: `You must log in first` }); @@ -35,12 +43,6 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { credentials: 'include', }); - /* - Basemaps - */ - // Load existing OPFS PMTiles archive if present - // TODO - return { project: await projectResponse.json(), projectId: parseInt(projectId), diff --git a/src/mapper/src/store/login.svelte.ts b/src/mapper/src/store/login.svelte.ts index 2d4b49636d..122043f369 100644 --- a/src/mapper/src/store/login.svelte.ts +++ b/src/mapper/src/store/login.svelte.ts @@ -20,6 +20,20 @@ function getLoginStore() { }, setAuthDetails: (authData: authDetailsType) => { authDetails = authData; + // the react frontend uses redux-persist to store the authDetails in + // the local storage, so we maintain the same schema to store data here also + localStorage.setItem( + 'persist:login', + JSON.stringify({ + authDetails: JSON.stringify(authData), + _persist: JSON.stringify({ version: -1, rehydrated: true }), + }), + ); + }, + retrieveAuthDetailsFromLocalStorage: () => { + const persistedAuth = localStorage.getItem('persist:login'); + if (!persistedAuth) return; + authDetails = JSON.parse(JSON.parse(persistedAuth).authDetails); }, toggleLoginModal: (status: boolean) => { isLoginModalOpen = status;