diff --git a/.env.example b/.env.example index 0cf6d900fb..4bc5c33e91 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ ENCRYPTION_KEY=${ENCRYPTION_KEY:-"pIxxYIXe4oAVHI36lTveyc97FKK2O_l2VHeiuqU-K_4="} FMTM_DOMAIN=${FMTM_DOMAIN:-"fmtm.localhost"} FMTM_DEV_PORT=${FMTM_DEV_PORT:-7050} CERT_EMAIL=${CERT_EMAIL} +AUTH_PUBLIC_KEY=${AUTH_PUBLIC_KEY} +AUTH_PRIVATE_KEY=${AUTH_PRIVATE_KEY} # Use API_PREFIX if running behind a proxy subpath (e.g. /api) API_PREFIX=${API_PREFIX:-/} @@ -21,7 +23,6 @@ OSM_URL=${OSM_URL:-"https://www.openstreetmap.org"} OSM_SCOPE=${OSM_SCOPE:-"read_prefs"} OSM_LOGIN_REDIRECT_URI="http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth/" OSM_SECRET_KEY=${OSM_SECRET_KEY} -OSM_SVC_ACCOUNT_TOKEN=${OSM_SVC_ACCOUNT_TOKEN} ### S3 File Storage ### S3_ENDPOINT=${S3_ENDPOINT:-"http://s3:9000"} diff --git a/.gitignore b/.gitignore index 07934d6068..6c83c3b1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ envsubst # Chart dependencies chart/charts + +# Secrets / keys +**/**/*.pem \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index f6e93cd5b0..b1eec18020 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -126,6 +126,10 @@ bash scripts/gen-env.sh > Note: If extra cors origins are required for testing, the variable > `EXTRA_CORS_ORIGINS` is a set of comma separated strings, e.g.: > +> +> Note: It is possible to generate the auth pub/priv key manually using: +> openssl genrsa -out fmtm-private.pem 4096 +> openssl rsa -in fmtm-private.pem -pubout -out fmtm-private.pem ### Start the API with Docker diff --git a/chart/README.md b/chart/README.md index 1615a50f8d..a4827300f7 100644 --- a/chart/README.md +++ b/chart/README.md @@ -50,6 +50,8 @@ kubectl - key: OSM_CLIENT_ID - key: OSM_CLIENT_SECRET - key: OSM_SECRET_KEY + - key: AUTH_PUBLIC_KEY + - key: AUTH_PRIVATE_KEY ```bash kubectl create secret generic api-fmtm-vars --namespace fmtm \ @@ -57,7 +59,9 @@ kubectl --from-literal=FMTM_DOMAIN=some.domain.com \ --from-literal=OSM_CLIENT_ID=xxxxxxx \ --from-literal=OSM_CLIENT_SECRET=xxxxxxx \ - --from-literal=OSM_SECRET_KEY=xxxxxxx + --from-literal=OSM_SECRET_KEY=xxxxxxx \ + --from-file=AUTH_PUBLIC_KEY=/path/to/pub/key.pem \ + --from-file=AUTH_PRIVATE_KEY=/path/to/priv/key.pem ``` ## Deployment diff --git a/docs/dev/Setup.md b/docs/dev/Setup.md index fd4e22383f..d5295f6da5 100644 --- a/docs/dev/Setup.md +++ b/docs/dev/Setup.md @@ -13,8 +13,6 @@ - [FMTM frontend](#fmtm-frontend) - [FMTM backend](#fmtm-backend) - [Prerequisites for Contribution](#prerequisites-for-contribution) - - [Development: Setup Your Local Environment](#setup-your-local-environment) - - [Verify Setup](#verify-setup) - [Start Developing](#start-developing) ## Overview @@ -312,93 +310,6 @@ your changes and request that they be merged into the main codebase. That's it! You've now contributed to the Field Mapping Tasking Manager. -### Setup Your Local Environment - -These steps are essential to run and test your code! - -#### 1. Setup OSM OAUTH 2.0 - -The FMTM uses OAUTH2 with OSM to authenticate users. To properly configure your -FMTM project, you will need to create keys for OSM. - -1. [Login to OSM](https://www.openstreetmap.org/login) (_If you do not have an - account yet, click the signup button at the top navigation bar to create one_). - Click the drop down arrow on the extreme right of the navigation bar and - select My Settings. - -2. Register your FMTM instance to OAuth 2 applications. Put your login redirect - url as `http://127.0.0.1:7051/osmauth/`, For Production replace the URL as - production API Url - - > Note: `127.0.0.1` is required instead of `localhost` due to OSM restrictions. - - image - -3. Right now read user preferences permission is enough later on fmtm may need - permission to modify the map option which should be updated on OSM_SCOPE - variable on .env , Keep read_prefs for now. - -4. Now Copy your Client ID and Client Secret. Put them in the `OSM_CLIENT_ID` - and `OSM_CLIENT_SECRET` field of your `.env` file - -##### 2. Create an `.env` File - -Environmental variables are used throughout this project. -To get started, create `.env` file in the top level dir, -a sample is located at `.env.example`. - -This can be created interactively by running: - -```bash -bash scripts/gen-env.sh -``` - -> Note: If extra cors origins are required for testing, the variable -> `EXTRA_CORS_ORIGINS` is a set of comma separated strings, e.g.: -> - -### Verify Setup - -#### Check Deployment - -For details on how to run this project locally for development, please look at: -[Backend Docs](https://docs.fmtm.dev/dev/Backend) - -#### Check Authentication - -Once you have deployed, you will need to check that you can properly -authenticate. - -1. Navigate to `http://api.fmtm.localhost:7050/docs` - - Three endpoints are responsible for oauth - image - -2. Select the `/auth/osm-login/` endpoint, click `Try it out` and then - `Execute`. - This would give you the Login URL where you can supply your osm username - and password. - - Your response should look like this: - - ```json - { - "login_url": "https://www.openstreetmap.org/oauth2/authorize/?response_type=code&client_id=xxxx" - } - ``` - - Now copy and paste your login_url in a new tab. You would be redirected to - OSM for your LOGIN. Give FMTM the necessary permission. - - After a successful login, you will get your `access_token` for FMTM, Copy - it. Now, you can use it for rest of the endpoints that needs authorization. - -3. Check your access token: Select the `/auth/me/` endpoint and click - `Try it out`. - Pass in the `access_token` you copied in the previous step into the - `access-token` field and click `Execute`. You should get your osm id, - username and profile picture id. - ### Start Developing Don't forget to review the diff --git a/docs/dev/Troubleshooting.md b/docs/dev/Troubleshooting.md index cf9488bd92..0c9810ee8f 100644 --- a/docs/dev/Troubleshooting.md +++ b/docs/dev/Troubleshooting.md @@ -34,6 +34,10 @@ OSM_SCOPE field required (type=value_error.missing) OSM_LOGIN_REDIRECT_URI field required (type=value_error.missing) +AUTH_PUBLIC_KEY + field required (type=value_error.missing) +AUTH_PRIVATE_KEY + field required (type=value_error.missing) ``` Then you need to set the env variables on your system. @@ -45,5 +49,6 @@ an alternative can be to feed them into the pdm command: FMTM_DOMAIN="" \ OSM_CLIENT_ID="" OSM_CLIENT_SECRET="" OSM_SECRET_KEY="" \ S3_ACCESS_KEY="" S3_SECRET_KEY="" \ +AUTH_PUBLIC_KEY="" AUTH_PRIVATE_KEY="" \ pdm run uvicorn app.main:api --host 0.0.0.0 --port 8000 ``` diff --git a/scripts/gen-env.sh b/scripts/gen-env.sh index 1fbc35b874..1ee52c7468 100644 --- a/scripts/gen-env.sh +++ b/scripts/gen-env.sh @@ -342,6 +342,28 @@ check_change_port() { fi } +generate_auth_keys() { + pretty_echo "Generating Auth Keys" + + if ! AUTH_PRIVATE_KEY=$(openssl genrsa 4096 2>/dev/null); then + echo "Error generating private key. Aborting." + return 1 + fi + + if ! AUTH_PUBLIC_KEY=$(echo "$AUTH_PRIVATE_KEY" | openssl rsa -pubout 2>/dev/null); then + echo "Error generating public key. Aborting." + return 1 + fi + + # Quotes are required around key variables, else dotenv does not load + export AUTH_PRIVATE_KEY="\"$AUTH_PRIVATE_KEY\"" + export AUTH_PUBLIC_KEY="\"$AUTH_PUBLIC_KEY\"" + + echo + echo "Auth keys generated." + echo +} + generate_dotenv() { pretty_echo "Generating Dotenv File" @@ -390,6 +412,7 @@ prompt_user_gen_dotenv() { fi set_osm_credentials + generate_auth_keys generate_dotenv pretty_echo "Completed dotenv file generation" diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index e3725a5f7c..6ff85ca3c2 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -18,7 +18,7 @@ """Auth routes, to login, logout, and get user details.""" -from datetime import datetime, timezone +import time from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse @@ -26,7 +26,16 @@ from sqlalchemy import text from sqlalchemy.orm import Session -from app.auth.osm import AuthUser, init_osm_auth, login_required +from app.auth.auth_schemas import AuthUser, FMTMUser +from app.auth.osm import ( + create_tokens, + extract_refresh_token_from_cookie, + init_osm_auth, + login_required, + refresh_access_token, + set_cookies, + verify_token, +) from app.config import settings from app.db import database from app.models.enums import HTTPStatus, UserRole @@ -73,36 +82,32 @@ async def callback(request: Request, osm_auth=Depends(init_osm_auth)): Returns: access_token (string): The access token provided by the login URL request. """ - log.debug(f"Callback url requested: {request.url}") - - # Enforce https callback url for openstreetmap.org - callback_url = str(request.url).replace("http://", "https://") - - # Get access token - access_token = osm_auth.callback(callback_url).get("access_token") - log.debug(f"Access token returned of length {len(access_token)}") - response = Response(status_code=HTTPStatus.OK) - - # Set cookie - cookie_name = settings.FMTM_DOMAIN.replace(".", "_") - log.debug( - f"Setting cookie in response named '{cookie_name}' with params: " - f"max_age=31536000 | expires=31536000 | path='/' | " - f"domain={settings.FMTM_DOMAIN} | httponly=True | samesite='lax' | " - f"secure={False if settings.DEBUG else True}" - ) - response.set_cookie( - key=cookie_name, - value=access_token, - max_age=31536000, # OSM currently has no expiry - expires=31536000, # OSM currently has no expiry - path="/", - domain=settings.FMTM_DOMAIN, - secure=False if settings.DEBUG else True, - httponly=True, - samesite="lax", - ) - return response + 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 access token + access_token = osm_auth.callback(callback_url).get("access_token") + log.debug(f"Access token returned of length {len(access_token)}") + osm_user = osm_auth.deserialize_access_token(access_token) + user_data = { + "sub": f"fmtm|{osm_user['id']}", + "aud": settings.FMTM_DOMAIN, + "iat": int(time.time()), + "exp": int(time.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, + } + access_token, refresh_token = create_tokens(user_data) + return set_cookies(access_token, refresh_token) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=f"Invalid OSM token: {e}" + ) from e @router.get("/logout/") @@ -132,79 +137,73 @@ async def get_or_create_user( ): """Get user from User table if exists, else create.""" try: - update_sql = text( + upsert_sql = text( """ - INSERT INTO users ( + WITH upserted_user AS ( + INSERT INTO users ( id, username, profile_img, role, mapping_level, is_email_verified, is_expert, tasks_mapped, tasks_validated, tasks_invalidated, date_registered, last_validation_date - ) - VALUES ( + ) VALUES ( :user_id, :username, :profile_img, :role, - :mapping_level, FALSE, FALSE, 0, 0, 0, - :current_date, :current_date - ) - ON CONFLICT (id) - DO UPDATE SET profile_img = :profile_img; + 'BEGINNER', FALSE, FALSE, 0, 0, 0, NOW(), NOW() + ) + ON CONFLICT (id) + DO UPDATE SET + profile_img = EXCLUDED.profile_img + RETURNING id, username, profile_img, role + ) + SELECT + u.id, u.username, u.profile_img, u.role, + array_agg( + DISTINCT om.organisation_id + ) FILTER (WHERE om.organisation_id IS NOT NULL) as orgs_managed, + jsonb_object_agg( + ur.project_id, + COALESCE(ur.role, 'MAPPER') + ) FILTER (WHERE ur.project_id IS NOT NULL) as project_roles + FROM upserted_user u + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN organisation_managers om ON u.id = om.user_id + GROUP BY u.id, u.username, u.profile_img, u.role; """ ) - role = UserRole(user_data.role).name - db.execute( - update_sql, - { - "user_id": user_data.id, - "username": user_data.username, - "profile_img": user_data.img_url, - "role": role, - "mapping_level": "BEGINNER", - "current_date": datetime.now(timezone.utc), - }, - ) - db.commit() - get_sql = text( - """ - SELECT users.*, - user_roles.project_id as project_id, - organisation_managers.organisation_id as created_org, - COALESCE(user_roles.role, 'MAPPER') as project_role - FROM users - LEFT JOIN user_roles ON users.id = user_roles.user_id - LEFT JOIN organisation_managers on users.id = organisation_managers.user_id - WHERE users.id = :user_id; - """ - ) - result = db.execute( - get_sql, - {"user_id": user_data.id}, - ) - db_user = result.first() - - user = { - "id": db_user.id, - "username": db_user.username, - "profile_img": db_user.profile_img, - "role": db_user.role, - "project_id": db_user.project_id, - "project_role": db_user.project_role, - "created_org": db_user.created_org, + parameters = { + "user_id": user_data.id, + "username": user_data.username, + "profile_img": user_data.picture or "", + "role": UserRole(user_data.role).name, } - return user + result = db.execute(upsert_sql, parameters) + db.commit() + + db_user_details = result.first() + if not db_user_details: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"User ID ({user_data.id}) could not be inserted in db", + ) + + return db_user_details except Exception as e: - # Check if the exception is due to username already existing + db.rollback() + log.error(f"Exception occurred: {e}") if 'duplicate key value violates unique constraint "users_username_key"' in str( e ): raise HTTPException( - status_code=400, + status_code=HTTPStatus.BAD_REQUEST, detail=f"User with this username {user_data.username} already exists.", ) from e else: - raise HTTPException(status_code=400, detail=str(e)) from e + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(e) + ) from e -@router.get("/me/") +@router.get("/me/", response_model=FMTMUser) async def my_data( db: Session = Depends(database.get_db), user_data: AuthUser = Depends(login_required), @@ -221,15 +220,40 @@ async def my_data( return await get_or_create_user(db, user_data) -@router.get("/introspect", response_model=AuthUser) -async def check_login( - user_data: AuthUser = Depends(login_required), -): +@router.get("/refresh") +async def refresh_token( + request: Request, user_data: AuthUser = Depends(login_required) +) -> AuthUser: """Verifies the validity of login cookies. Returns True if authenticated, False otherwise. """ - return user_data + try: + refresh_token = extract_refresh_token_from_cookie(request) + if not refresh_token: + raise HTTPException(status_code=401, detail="No tokens provided") + + token_data = verify_token(refresh_token) + access_token = refresh_access_token(token_data) + response = JSONResponse(content=user_data.dict(), status_code=200) + cookie_name = settings.FMTM_DOMAIN.replace(".", "_") + 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", + ) + return response + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"fail to refresh the access token: {e}", + ) from e @router.get("/temp-login") @@ -248,33 +272,15 @@ async def temp_login( Returns: Response: The response object containing the access token as a cookie. """ - access_token = settings.OSM_SVC_ACCOUNT_TOKEN - - if not access_token: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=( - "OSM_SVC_ACCOUNT_TOKEN variable is not set. Temp login not possible." - ), - ) - - response = Response(status_code=HTTPStatus.OK) - cookie_name = settings.FMTM_DOMAIN.replace(".", "_") - log.debug( - f"Setting TEMP cookie in response named '{cookie_name}' with params: " - f"max_age=604800 | expires=604800 | path='/' | " - f"domain={settings.FMTM_DOMAIN} | httponly=True | samesite='lax' | " - f"secure={False if settings.DEBUG else True}" - ) - response.set_cookie( - key=cookie_name, - value=access_token, - max_age=604800, - expires=604800, # expiry set to 7 days, - path="/", - domain=settings.FMTM_DOMAIN, - secure=False if settings.DEBUG else True, - httponly=True, - samesite="lax", - ) - return response + username = "svcfmtm" + user_data = { + "sub": "fmtm|20386219", + "aud": settings.FMTM_DOMAIN, + "iat": int(time.time()), + "exp": int(time.time()) + 86400 * 7, # expiry set to 7 days + "username": username, + "picture": None, + "role": UserRole.MAPPER, + } + access_token, refresh_token = create_tokens(user_data) + return set_cookies(access_token, refresh_token) diff --git a/src/backend/app/auth/auth_schemas.py b/src/backend/app/auth/auth_schemas.py new file mode 100644 index 0000000000..ed53b04406 --- /dev/null +++ b/src/backend/app/auth/auth_schemas.py @@ -0,0 +1,103 @@ +# +# 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 . +# +"""Pydantic models for Auth.""" + +from typing import Any, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict, PrivateAttr, computed_field +from pydantic.functional_validators import field_validator + +from app.db.db_models import DbOrganisation, DbProject, DbUser +from app.models.enums import ProjectRole, UserRole + + +class OrgUserDict(TypedDict): + """Dict of both DbOrganisation & DbUser.""" + + user: DbUser + org: DbOrganisation + + +class ProjectUserDict(TypedDict): + """Dict of both DbProject & DbUser.""" + + user: DbUser + project: DbProject + + +class AuthUser(BaseModel): + """The user model returned from OSM OAuth2.""" + + model_config = ConfigDict(use_enum_values=True) + + username: str + picture: Optional[str] = None + role: Optional[UserRole] = UserRole.MAPPER + + _sub: str = PrivateAttr() # it won't return this field + + def __init__(self, sub: str, **data): + """Initializes the AuthUser class.""" + super().__init__(**data) + self._sub = sub + + @computed_field + @property + def id(self) -> int: + """Compute id from sub field.""" + sub = self._sub + return int(sub.split("|")[1]) + + +class FMTMUser(BaseModel): + """User details returned to the frontend. + + TODO this should inherit from AuthUser and extend. + TODO profile_img should be refactored to `picture`. + """ + + id: int + username: str + profile_img: str + role: UserRole + project_roles: Optional[dict[int, ProjectRole]] = {} + orgs_managed: Optional[list[int]] = [] + + @field_validator("role", mode="before") + @classmethod + def convert_user_role_str_to_ints(cls, role: Any) -> Optional[UserRole]: + """User role strings returned from db converted to enum integers.""" + if not role: + return None + if isinstance(role, str): + return UserRole[role] + return role + + @field_validator("project_roles", mode="before") + @classmethod + def convert_project_role_str_to_ints( + cls, roles: dict[int, Any] + ) -> Optional[dict[int, ProjectRole]]: + """User project strings returned from db converted to enum integers.""" + if not roles: + return {} + + first_value = next(iter(roles.values()), None) + if isinstance(first_value, str): + return {id: ProjectRole[role] for id, role in roles.items()} + + return roles diff --git a/src/backend/app/auth/osm.py b/src/backend/app/auth/osm.py index ccd6771722..e3a954c30b 100644 --- a/src/backend/app/auth/osm.py +++ b/src/backend/app/auth/osm.py @@ -19,13 +19,14 @@ """Auth methods related to OSM OAuth2.""" import os -from typing import Optional +import time -from fastapi import Header, HTTPException, Request +import jwt +from fastapi import Header, HTTPException, Request, Response from loguru import logger as log from osm_login_python.core import Auth -from pydantic import BaseModel, ConfigDict +from app.auth.auth_schemas import AuthUser from app.config import settings from app.models.enums import UserRole @@ -34,17 +35,6 @@ os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" -class AuthUser(BaseModel): - """The user model returned from OSM OAuth2.""" - - model_config = ConfigDict(use_enum_values=True) - - id: int - username: str - img_url: Optional[str] = None - role: Optional[UserRole] = UserRole.MAPPER - - async def init_osm_auth(): """Initialise Auth object from osm-login-python.""" return Auth( @@ -63,27 +53,135 @@ async def login_required( """Dependency to inject into endpoints requiring login.""" if settings.DEBUG: return AuthUser( - id=1, + sub="fmtm|1", username="localadmin", role=UserRole.ADMIN, ) - osm_auth = await init_osm_auth() - # Attempt extract from cookie if access token not passed if not access_token: - cookie_name = settings.FMTM_DOMAIN.replace(".", "_") - log.debug(f"Extracting token from cookie {cookie_name}") - access_token = request.cookies.get(cookie_name) + access_token = extract_token_from_cookie(request) if not access_token: raise HTTPException(status_code=401, detail="No access token provided") try: - osm_user = osm_auth.deserialize_access_token(access_token) + token_data = verify_token(access_token) except ValueError as e: log.error(e) log.error("Failed to deserialise access token") raise HTTPException(status_code=401, detail="Access token not valid") from e - return AuthUser(**osm_user) + return AuthUser(**token_data) + + +def extract_token_from_cookie(request: Request) -> str: + """Extract access token from cookies.""" + cookie_name = settings.FMTM_DOMAIN.replace(".", "_") + log.debug(f"Extracting token from cookie {cookie_name}") + return request.cookies.get(cookie_name) + + +def extract_refresh_token_from_cookie(request: Request) -> str: + """Extract refresh token from cookies.""" + cookie_name = settings.FMTM_DOMAIN.replace(".", "_") + return request.cookies.get(f"{cookie_name}_refresh") + + +def create_tokens(payload: dict) -> tuple[str, str]: + """Generates tokens for the specified user. + + Args: + payload (dict): user data for which the access token is being generated. + + Returns: + Tuple: The generated access tokens. + """ + access_token_payload = payload + access_token_payload["exp"] = ( + int(time.time()) + 86400 + ) # set access token expiry to 1 day + private_key = settings.AUTH_PRIVATE_KEY + access_token = jwt.encode( + access_token_payload, str(private_key), algorithm=settings.ALGORITHM + ) + refresh_token = jwt.encode(payload, str(private_key), algorithm=settings.ALGORITHM) + return access_token, refresh_token + + +def refresh_access_token(payload: dict) -> str: + """Generate a new access token.""" + access_token_payload = payload + access_token_payload["exp"] = ( + int(time.time()) + 60 + ) # Access token valid for 15 minutes + + private_key = settings.AUTH_PRIVATE_KEY + return jwt.encode( + access_token_payload, str(private_key), algorithm=settings.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. + """ + public_key = settings.AUTH_PUBLIC_KEY + try: + return jwt.decode( + token, + str(public_key), + algorithms=[settings.ALGORITHM], + audience=settings.FMTM_DOMAIN, + ) + except jwt.ExpiredSignatureError as e: + raise HTTPException(status_code=401, detail="Refresh token has expired") from e + except Exception as e: + raise HTTPException( + status_code=401, detail="Could not validate refresh token" + ) from e + + +def set_cookies(access_token: str, refresh_token: str): + """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. + + Returns: + Response: A response object with the cookies set. + """ + response = Response(status_code=200) + cookie_name = settings.FMTM_DOMAIN.replace(".", "_") + 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=f"{cookie_name}_refresh", + 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/roles.py b/src/backend/app/auth/roles.py index b5879b3f1d..84180d6e3f 100644 --- a/src/backend/app/auth/roles.py +++ b/src/backend/app/auth/roles.py @@ -29,7 +29,8 @@ from sqlalchemy import text from sqlalchemy.orm import Session -from app.auth.osm import AuthUser, login_required +from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict +from app.auth.osm import login_required from app.db.database import get_db from app.db.db_models import DbProject, DbUser from app.models.enums import HTTPStatus, ProjectRole, ProjectVisibility @@ -152,7 +153,7 @@ async def super_admin( return db_user log.error( - f"User {user_data.username} requested an admin endpoint, " "but is not admin" + f"User {user_data.username} requested an admin endpoint, but is not admin" ) raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="User must be an administrator" @@ -163,7 +164,7 @@ async def check_org_admin( db: Session, user: Union[AuthUser, int], org_id: int, -) -> dict: +) -> OrgUserDict: """Database check to determine if org admin role. Returns: @@ -234,51 +235,85 @@ async def org_admin( return org_user_dict -async def project_admin( - project: DbProject = Depends(get_project_by_id), - db: Session = Depends(get_db), - user_data: AuthUser = Depends(login_required), -) -> dict: - """Project admin role.""" +async def wrap_check_access( + project: DbProject, + db: Session, + user_data: AuthUser, + role: ProjectRole, +) -> ProjectUserDict: + """Wrap check_access call with HTTPException.""" db_user = await check_access( user_data, db, project_id=project.id, - role=ProjectRole.PROJECT_MANAGER, + role=role, ) - if db_user: - project_user_dict = { - "user": db_user, - "project": project, - } - return project_user_dict + if not db_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not a project manager", + ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User is not a project manager", + return { + "user": db_user, + "project": project, + } + + +async def project_manager( + project: DbProject = Depends(get_project_by_id), + db: Session = Depends(get_db), + user_data: AuthUser = Depends(login_required), +) -> ProjectUserDict: + """A project manager for a specific project.""" + return await wrap_check_access( + project, + db, + user_data, + ProjectRole.PROJECT_MANAGER, ) -async def validator( +async def associate_project_manager( project: DbProject = Depends(get_project_by_id), db: Session = Depends(get_db), user_data: AuthUser = Depends(login_required), -) -> DbUser: - """A validator for a specific project.""" - db_user = await check_access( +) -> ProjectUserDict: + """An associate project manager for a specific project.""" + return await wrap_check_access( + project, + db, user_data, + ProjectRole.ASSOCIATE_PROJECT_MANAGER, + ) + + +async def field_manager( + project: DbProject = Depends(get_project_by_id), + db: Session = Depends(get_db), + user_data: AuthUser = Depends(login_required), +) -> ProjectUserDict: + """A field manager for a specific project.""" + return await wrap_check_access( + project, db, - project_id=project.id, - role=ProjectRole.VALIDATOR, + user_data, + ProjectRole.FIELD_MANAGER, ) - if db_user: - return db_user - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User does not have validator permission", +async def validator( + project: DbProject = Depends(get_project_by_id), + db: Session = Depends(get_db), + user_data: AuthUser = Depends(login_required), +) -> ProjectUserDict: + """A validator for a specific project.""" + return await wrap_check_access( + project, + db, + user_data, + ProjectRole.VALIDATOR, ) @@ -286,24 +321,17 @@ async def mapper( project: DbProject = Depends(get_project_by_id), db: Session = Depends(get_db), user_data: AuthUser = Depends(login_required), -) -> Optional[DbUser]: +) -> AuthUser: """A mapper for a specific project.""" # If project is public, skip permission check if project.visibility == ProjectVisibility.PUBLIC: - # FIXME this is a different type than DbUser return user_data - db_user = await check_access( - user_data, + await wrap_check_access( + project, db, - project_id=project.id, - role=ProjectRole.MAPPER, + user_data, + ProjectRole.MAPPER, ) - if db_user: - return db_user - - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User does not have mapper permission", - ) + return user_data diff --git a/src/backend/app/config.py b/src/backend/app/config.py index 19c1e814fc..11ce2fe76b 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -270,22 +270,19 @@ def configure_s3_download_root(cls, v: Optional[str], info: ValidationInfo) -> s @field_validator("RAW_DATA_API_AUTH_TOKEN", mode="before") @classmethod def set_raw_data_api_auth_none(cls, v: Optional[str]) -> Optional[str]: - """Set RAW_DATA_API_AUTH_TOKEN to None if set to empty string.""" - if v == "": - return None - return v - - # Used for temporary auth feature - OSM_SVC_ACCOUNT_TOKEN: Optional[str] = None + """Set RAW_DATA_API_AUTH_TOKEN to None if set to empty string. - @field_validator("OSM_SVC_ACCOUNT_TOKEN", mode="before") - @classmethod - def set_osm_svc_account_none(cls, v: Optional[str]) -> Optional[str]: - """Set OSM_SVC_ACCOUNT_TOKEN to None if set to empty string.""" + This variable is used by HOTOSM to track raw-data-api usage. + It is not required if running your own instance. + """ if v == "": return None return v + ALGORITHM: str = "RS256" + AUTH_PRIVATE_KEY: str + AUTH_PUBLIC_KEY: str + MONITORING: Optional[MonitoringTypes] = None @computed_field @@ -305,7 +302,10 @@ def get_settings(): _settings = Settings() if _settings.DEBUG: - print("Loaded settings: " f"{_settings.model_dump()}") + print( + "Loaded settings: " + f"{_settings.model_dump(exclude=['AUTH_PRIVATE_KEY', 'AUTH_PUBLIC_KEY'])}" + ) return _settings diff --git a/src/backend/app/db/postgis_utils.py b/src/backend/app/db/postgis_utils.py index 6c1cf55b1f..af2d9cc706 100644 --- a/src/backend/app/db/postgis_utils.py +++ b/src/backend/app/db/postgis_utils.py @@ -38,6 +38,8 @@ from sqlalchemy.exc import ProgrammingError from sqlalchemy.orm import Session +from app.config import settings + log = logging.getLogger(__name__) @@ -535,7 +537,13 @@ def get_address_from_lat_lon(latitude, longitude): "lon": longitude, "zoom": 18, } - headers = {"Accept-Language": "en"} # Set the language to English + headers = { + # Set the language to English + "Accept-Language": "en", + # Referer or User-Agent required as per usage policy: + # https://operations.osmfoundation.org/policies/nominatim + "Referer": settings.FMTM_DOMAIN, + } log.debug( f"Getting Nominatim address from project lat ({latitude}) lon ({longitude})" diff --git a/src/backend/app/helpers/helper_routes.py b/src/backend/app/helpers/helper_routes.py index 6475391054..b4dc907f49 100644 --- a/src/backend/app/helpers/helper_routes.py +++ b/src/backend/app/helpers/helper_routes.py @@ -34,7 +34,8 @@ from osm_fieldwork.xlsforms import xlsforms_path from requests import get -from app.auth.osm import AuthUser, login_required +from app.auth.auth_schemas import AuthUser +from app.auth.osm import login_required 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_crud.py b/src/backend/app/organisations/organisation_crud.py index bc80bb0178..3c07871776 100644 --- a/src/backend/app/organisations/organisation_crud.py +++ b/src/backend/app/organisations/organisation_crud.py @@ -25,7 +25,7 @@ from sqlalchemy import text, update from sqlalchemy.orm import Session -from app.auth.osm import AuthUser +from app.auth.auth_schemas import AuthUser from app.config import encrypt_value, settings from app.db import db_models from app.models.enums import HTTPStatus diff --git a/src/backend/app/organisations/organisation_routes.py b/src/backend/app/organisations/organisation_routes.py index 5b44b2f97f..436b56e598 100644 --- a/src/backend/app/organisations/organisation_routes.py +++ b/src/backend/app/organisations/organisation_routes.py @@ -27,7 +27,8 @@ ) from sqlalchemy.orm import Session -from app.auth.osm import AuthUser, 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 import database from app.db.db_models import DbOrganisation, DbUser @@ -65,7 +66,7 @@ async def get_my_organisations( @router.get("/unapproved/", response_model=list[organisation_schemas.OrganisationOut]) async def list_unapproved_organisations( db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(super_admin), + current_user: DbUser = Depends(super_admin), ) -> list[DbOrganisation]: """Get a list of all organisations.""" return await organisation_crud.get_unapproved_organisations(db) @@ -75,7 +76,7 @@ async def list_unapproved_organisations( async def unapproved_org_detail( org_id: int, db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(super_admin), + current_user: DbUser = Depends(super_admin), ): """Get a detail of an unapproved organisations.""" return await organisation_crud.get_unapproved_org_detail(db, org_id) @@ -112,7 +113,7 @@ async def update_organisation( logo: UploadFile = File(None), organisation: DbOrganisation = Depends(org_exists), db: Session = Depends(database.get_db), - org_user_dict: DbUser = Depends(org_admin), + org_user_dict: OrgUserDict = Depends(org_admin), ): """Partial update for an existing organisation.""" return await organisation_crud.update_organisation( @@ -123,7 +124,7 @@ async def update_organisation( @router.delete("/{org_id}") async def delete_org( db: Session = Depends(database.get_db), - org_user_dict: DbUser = Depends(org_admin), + org_user_dict: OrgUserDict = Depends(org_admin), ): """Delete an organisation.""" return await organisation_crud.delete_organisation(db, org_user_dict["org"]) @@ -169,7 +170,7 @@ async def add_new_organisation_admin( db: Session = Depends(database.get_db), user: DbUser = Depends(user_exists_in_db), org: DbOrganisation = Depends(org_exists), - org_user_dict: DbUser = Depends(org_admin), + org_user_dict: OrgUserDict = Depends(org_admin), ): """Add a new organisation admin. diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index e2d472b315..d333f23dde 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -1620,10 +1620,10 @@ def count_user_contributions(db: Session, user_id: int, project_id: int) -> int: return contributions_count -async def add_project_admin( +async def add_project_manager( db: Session, user: db_models.DbUser, project: db_models.DbProject ): - """Adds a user as an admin to the specified organisation. + """Adds a user as an manager to the specified project. Args: db (Session): The database session. diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 6a1a89a9c9..8b1669aa18 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -45,8 +45,9 @@ from sqlalchemy.orm import Session from sqlalchemy.sql import text -from app.auth.osm import AuthUser, login_required -from app.auth.roles import mapper, org_admin, project_admin +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_schemas from app.db import database, db_models from app.db.postgis_utils import ( @@ -441,11 +442,10 @@ async def read_project( @router.delete("/{project_id}") async def delete_project( db: Session = Depends(database.get_db), - org_user_dict: db_models.DbUser = Depends(org_admin), + project: db_models.DbProject = Depends(project_deps.get_project_by_id), + org_user_dict: OrgUserDict = Depends(org_admin), ): """Delete a project from both ODK Central and the local database.""" - project = org_user_dict.get("project") - log.info( f"User {org_user_dict.get('user').username} attempting " f"deletion of project {project.id}" @@ -463,7 +463,7 @@ async def delete_project( @router.post("/create_project", response_model=project_schemas.ProjectOut) async def create_project( project_info: project_schemas.ProjectUpload, - org_user_dict: db_models.DbUser = Depends(org_admin), + org_user_dict: OrgUserDict = Depends(org_admin), db: Session = Depends(database.get_db), ): """Create a project in ODK Central and the local database. @@ -476,6 +476,7 @@ async def create_project( TODO but first check doesn't break other endpoints """ db_user = org_user_dict.get("user") + db_org = org_user_dict.get("org") project_info.organisation_id = db_org.id @@ -510,7 +511,7 @@ async def create_project( """ ) result = db.execute(sql, {"project_name": project_info.project_info.name.lower()}) - project_exists = result.fetchone()[0] + project_exists = result.scalar() if project_exists: raise HTTPException( status_code=400, @@ -539,7 +540,7 @@ async def create_project( async def update_project( project_info: project_schemas.ProjectUpdate, db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Update an existing project by ID. @@ -549,7 +550,7 @@ async def update_project( Parameters: - id: ID of the project to update - project_info: Updated project information - - current_user (DbUser): Check if user is project_admin + - current_user (DbUser): Check if user is project_manager Returns: - Updated project information @@ -569,7 +570,7 @@ async def update_project( async def project_partial_update( project_info: project_schemas.ProjectPartialUpdate, db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Partial Update an existing project by ID. @@ -600,7 +601,7 @@ async def upload_project_task_boundaries( project_id: int, task_geojson: UploadFile = File(...), db: Session = Depends(database.get_db), - org_user_dict: db_models.DbUser = Depends(org_admin), + org_user_dict: OrgUserDict = Depends(org_admin), ): """Set project task boundaries using split GeoJSON from frontend. @@ -706,7 +707,7 @@ async def generate_files( background_tasks: BackgroundTasks, xls_form_upload: Optional[UploadFile] = File(None), db: Session = Depends(database.get_db), - org_user_dict: db_models.DbUser = Depends(org_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Generate additional content to initialise the project. @@ -730,12 +731,12 @@ async def generate_files( xls_form_upload (UploadFile, optional): A custom XLSForm to use in the project. A file should be provided if user wants to upload a custom xls form. db (Session): Database session, provided automatically. - org_user_dict (AuthUser): Current logged in user. Must be org admin. + project_user_dict (ProjectUserDict): Project admin role. Returns: json (JSONResponse): A success message containing the project ID. """ - project = org_user_dict.get("project") + project = project_user_dict.get("project") log.debug(f"Generating media files tasks for project: {project.id}") @@ -860,7 +861,7 @@ async def get_or_set_data_extract( url: Optional[str] = None, project_id: int = Query(..., description="Project ID"), db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Get or set the data extract URL for a project.""" fgb_url = await project_crud.get_or_set_data_extract_url( @@ -877,7 +878,7 @@ async def upload_custom_extract( custom_extract_file: UploadFile = File(...), project_id: int = Query(..., description="Project ID"), db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Upload a custom data extract geojson for a project. @@ -951,7 +952,7 @@ async def update_project_form( category: XLSFormType = Form(...), upload: Optional[UploadFile] = File(None), db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ) -> project_schemas.ProjectBase: """Update the XForm data in ODK Central. @@ -1256,15 +1257,15 @@ async def get_contributors( return project_users -@router.post("/add_admin/") -async def add_new_project_admin( +@router.post("/add-manager/") +async def add_new_project_manager( db: Session = Depends(database.get_db), - project_user_dict: dict = Depends(project_admin), + project_user_dict: ProjectUserDict = Depends(project_manager), ): """Add a new project manager. The logged in user must be either the admin of the organisation or a super admin. """ - return await project_crud.add_project_admin( + return await project_crud.add_project_manager( db, project_user_dict["user"], project_user_dict["project"] ) diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 605d10daea..1b9ea43883 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -241,8 +241,10 @@ class ProjectPartialUpdate(BaseModel): @computed_field @property - def project_name_prefix(self) -> str: + def project_name_prefix(self) -> Optional[str]: """Compute project name prefix with underscores.""" + if not self.name: + return None return self.name.replace(" ", "_").lower() diff --git a/src/backend/app/submissions/submission_routes.py b/src/backend/app/submissions/submission_routes.py index 090997eb91..a69f9838f9 100644 --- a/src/backend/app/submissions/submission_routes.py +++ b/src/backend/app/submissions/submission_routes.py @@ -27,8 +27,9 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from app.auth.osm import AuthUser, login_required -from app.auth.roles import mapper, project_admin +from app.auth.auth_schemas import AuthUser +from app.auth.osm import login_required +from app.auth.roles import mapper, project_manager from app.central import central_crud from app.db import database, db_models, postgis_utils from app.models.enums import HTTPStatus, ReviewStateEnum @@ -508,7 +509,7 @@ async def update_review_state( project_id: int, instance_id: str, review_state: ReviewStateEnum, - current_user: AuthUser = Depends(project_admin), + current_user: AuthUser = Depends(project_manager), db: Session = Depends(database.get_db), ): """Updates the review state of a project submission. diff --git a/src/backend/app/tasks/tasks_crud.py b/src/backend/app/tasks/tasks_crud.py index 5c77627e9c..67a02bc06a 100644 --- a/src/backend/app/tasks/tasks_crud.py +++ b/src/backend/app/tasks/tasks_crud.py @@ -24,7 +24,6 @@ from sqlalchemy.orm import Session from sqlalchemy.sql import text -from app.auth.osm import AuthUser from app.db import database, db_models from app.models.enums import ( TaskStatus, @@ -253,14 +252,14 @@ async def create_task_history_for_status_change( async def add_task_comments( - db: Session, comment: tasks_schemas.TaskCommentRequest, user_data: AuthUser + db: Session, comment: tasks_schemas.TaskCommentRequest, user_id: int ): """Add a comment to a task. Parameters: - db: SQLAlchemy database session - comment: TaskCommentBase instance containing the comment details - - user_data: AuthUser instance containing the user details + - user_id: OAuth user id. Returns: - Dictionary with the details of the added comment @@ -293,7 +292,7 @@ async def add_task_comments( "task_id": comment.task_id, "comment_text": comment.comment, "current_date": currentdate, - "user_id": user_data.id, + "user_id": user_id, } # Execute the query with the named parameters and commit the transaction diff --git a/src/backend/app/tasks/tasks_routes.py b/src/backend/app/tasks/tasks_routes.py index d0dcca3b51..54b37bec71 100644 --- a/src/backend/app/tasks/tasks_routes.py +++ b/src/backend/app/tasks/tasks_routes.py @@ -24,7 +24,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app.auth.osm import AuthUser +from app.auth.auth_schemas import AuthUser from app.auth.roles import get_uid, mapper from app.db import database from app.models.enums import TaskStatus @@ -139,7 +139,7 @@ async def update_task_status( async def add_task_comments( comment: tasks_schemas.TaskCommentRequest, db: Session = Depends(database.get_db), - user_data: AuthUser = Depends(mapper), + current_user: AuthUser = Depends(mapper), ): """Create a new task comment. @@ -151,7 +151,8 @@ async def add_task_comments( Returns: TaskCommentResponse: The created task comment. """ - task_comment_list = await tasks_crud.add_task_comments(db, comment, user_data) + user_id = await get_uid(current_user) + task_comment_list = await tasks_crud.add_task_comments(db, comment, user_id) return task_comment_list diff --git a/src/backend/pdm.lock b/src/backend/pdm.lock index 0acf9c319c..cf702f2b6a 100644 --- a/src/backend/pdm.lock +++ b/src/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "debug", "dev", "docs", "test", "monitoring"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:f9fd06ce15d9580e9ba1652829935c473e1853b7ebb0d1439f7ddf7a5ece0b9a" +content_hash = "sha256:dc843ea7774920cf4858affe48fec8310148cea089b2877e190e12732e754e90" [[package]] name = "aiohttp" @@ -2154,6 +2154,16 @@ files = [ {file = "pyinstrument-4.6.1.tar.gz", hash = "sha256:f4731b27121350f5a983d358d2272fe3df2f538aed058f57217eef7801a89288"}, ] +[[package]] +name = "pyjwt" +version = "2.8.0" +requires_python = ">=3.7" +summary = "JSON Web Token implementation in Python" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + [[package]] name = "pymdown-extensions" version = "10.7" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 73bb165adb..ecff63e1d1 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "sozipfile==0.3.2", "cryptography>=42.0.1", "defusedxml>=0.7.1", + "pyjwt>=2.8.0", "osm-login-python==1.0.3", "osm-fieldwork==0.12.4", "osm-rawdata==0.3.0", diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 27564c3e34..9ee0eed9f4 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -31,7 +31,7 @@ from sqlalchemy_utils import create_database, database_exists from app.auth.auth_routes import get_or_create_user -from app.auth.osm import AuthUser +from app.auth.auth_schemas import AuthUser, FMTMUser from app.central import central_crud from app.config import settings from app.db.database import Base, get_db @@ -93,15 +93,18 @@ async def admin_user(db): db_user = await get_or_create_user( db, AuthUser( + sub="fmtm|1", username="localadmin", - id=1, role=UserRole.ADMIN, ), ) - # Upgrade role from default MAPPER (if user already exists) - db_user["role"] = UserRole.ADMIN - db.commit() - return db_user + + return FMTMUser( + id=db_user.id, + username=db_user.username, + role=UserRole[db_user.role], + profile_img=db_user.profile_img, + ) @pytest.fixture(scope="function") @@ -174,11 +177,7 @@ async def project(db, admin_user, organisation): db, project_metadata, odkproject["id"], - AuthUser( - username=admin_user["username"], - id=admin_user["id"], - role=UserRole.ADMIN, - ), + admin_user, ) log.debug(f"Project returned: {new_project.__dict__}") assert new_project is not None diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py index 722cd24884..516229e73a 100644 --- a/src/backend/tests/test_projects_routes.py +++ b/src/backend/tests/test_projects_routes.py @@ -21,8 +21,8 @@ import os from io import BytesIO from pathlib import Path -from random import randint from unittest.mock import Mock, patch +from uuid import uuid4 import pytest import requests @@ -48,11 +48,11 @@ async def test_create_project(client, admin_user, organisation): "odk_central_user": odk_central_user, "odk_central_password": odk_central_password, } - odk_credentials = project_schemas.ODKCentralDecrypted(**odk_credentials) + odk_creds_models = project_schemas.ODKCentralDecrypted(**odk_credentials) project_data = { "project_info": { - "name": f"Test Project {randint(1, 1000000)}", + "name": f"Test Project {uuid4()}", "short_description": "test", "description": "test", }, @@ -71,7 +71,7 @@ async def test_create_project(client, admin_user, organisation): "type": "Polygon", }, } - project_data.update(**odk_credentials.model_dump()) + project_data.update(**odk_creds_models.model_dump()) response = client.post( f"/projects/create_project?org_id={organisation.id}", json=project_data @@ -87,7 +87,6 @@ async def test_create_project(client, admin_user, organisation): async def test_delete_project(client, admin_user, project): """Test deleting a FMTM project, plus ODK Central project.""" - log.warning(project) response = client.delete(f"/projects/{project.id}") assert response.status_code == 204 @@ -279,7 +278,7 @@ async def test_update_project(client, admin_user, project): """Test update project metadata.""" updated_project_data = { "project_info": { - "name": f"Updated Test Project {randint(1, 1000000)}", + "name": f"Updated Test Project {uuid4()}", "short_description": "updated short description", "description": "updated description", }, diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index ea321910b5..083e51608c 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -19,7 +19,7 @@ const CheckLoginState = () => { const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails); const checkIfUserLoginValid = () => { - fetch(`${import.meta.env.VITE_API_URL}/auth/introspect`, { credentials: 'include' }) + fetch(`${import.meta.env.VITE_API_URL}/auth/refresh`, { credentials: 'include' }) .then((resp) => { if (resp.status !== 200) { dispatch(LoginActions.signOut()); diff --git a/src/frontend/src/store/types/ILogin.ts b/src/frontend/src/store/types/ILogin.ts index cf8a0798aa..93f19d25a4 100644 --- a/src/frontend/src/store/types/ILogin.ts +++ b/src/frontend/src/store/types/ILogin.ts @@ -5,7 +5,7 @@ export type LoginStateTypes = { type authDetailsType = { id: string; - img_url: string; + picture: string; role: string; username: string; // sessionToken: string | null;