diff --git a/api/app.py b/api/app.py index 1c223ce07..db1f99bf5 100644 --- a/api/app.py +++ b/api/app.py @@ -14,7 +14,7 @@ from starlette.responses import StreamingResponse from chalicelib.utils import helper from chalicelib.utils import pg_client from crons import core_crons, core_dynamic_crons -from routers import core, core_dynamic, additional_routes +from routers import core, core_dynamic from routers.subs import insights, metrics, v1_api, health, usability_tests, spot loglevel = config("LOGLEVEL", default=logging.WARNING) @@ -127,12 +127,3 @@ app.include_router(usability_tests.app_apikey) app.include_router(spot.public_app) app.include_router(spot.app) app.include_router(spot.app_apikey) - -app.include_router(additional_routes.app) - -# @app.get('/private/shutdown', tags=["private"]) -# async def stop_server(): -# logging.info("Requested shutdown") -# await shutdown() -# import os, signal -# os.kill(1, signal.SIGTERM) diff --git a/api/chalicelib/core/users.py b/api/chalicelib/core/users.py index 865ac5022..393c201f0 100644 --- a/api/chalicelib/core/users.py +++ b/api/chalicelib/core/users.py @@ -3,10 +3,11 @@ import secrets from decouple import config from fastapi import BackgroundTasks +from pydantic import BaseModel import schemas from chalicelib.core import authorizers, metadata, projects -from chalicelib.core import tenants, assist +from chalicelib.core import tenants, assist, spot from chalicelib.utils import email_helper, smtp from chalicelib.utils import helper from chalicelib.utils import pg_client @@ -555,20 +556,40 @@ def refresh_auth_exists(user_id, jwt_jti=None): return r is not None -def change_jwt_iat_jti(user_id): +class ChangeJwt(BaseModel): + jwt_iat: int + jwt_refresh_jti: int + jwt_refresh_iat: int + spot_jwt_iat: int | None = None + spot_jwt_refresh_jti: int | None = None + spot_jwt_refresh_iat: int | None = None + + +def change_jwt_iat_jti(user_id, include_spot: bool = False): + sub_query = "" + sub_result = "" + if include_spot: + sub_query = """,spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), + spot_jwt_refresh_jti = 0, + spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s')""" + sub_result = """,EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, + spot_jwt_refresh_jti, + EXTRACT (epoch FROM spot_jwt_refresh_iat)::BIGINT AS spot_jwt_refresh_iat""" with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), jwt_refresh_jti = 0, - jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + {sub_query} WHERE user_id = %(user_id)s RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, jwt_refresh_jti, - EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat;""", + EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat + {sub_result};""", {"user_id": user_id}) cur.execute(query) row = cur.fetchone() - return row.get("jwt_iat"), row.get("jwt_refresh_jti"), row.get("jwt_refresh_iat") + return ChangeJwt(**row) def refresh_jwt_iat_jti(user_id): @@ -586,7 +607,7 @@ def refresh_jwt_iat_jti(user_id): return row.get("jwt_iat"), row.get("jwt_refresh_jti"), row.get("jwt_refresh_iat") -def authenticate(email, password, for_change_password=False) -> dict | bool | None: +def authenticate(email, password, for_change_password=False, include_spot=False) -> dict | bool | None: with pg_client.PostgresClient() as cur: query = cur.mogrify( f"""SELECT @@ -611,17 +632,29 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No if for_change_password: return True r = helper.dict_to_camel_case(r) - jwt_iat, jwt_r_jti, jwt_r_iat = change_jwt_iat_jti(user_id=r['userId']) - return { - "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=jwt_iat, + j_r = change_jwt_iat_jti(user_id=r['userId'], include_spot=include_spot) + response = { + "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=j_r.jwt_iat, aud=AUDIENCE), "refreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], - iat=jwt_r_iat, aud=AUDIENCE, - jwt_jti=jwt_r_jti), + iat=j_r.jwt_refresh_iat, aud=AUDIENCE, + jwt_jti=j_r.jwt_refresh_jti), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), "email": email, **r } + if include_spot: + response = {**response, + "spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], + iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE), + "spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], + tenant_id=r['tenantId'], + iat=j_r.spot_jwt_refresh_iat, + aud=spot.AUDIENCE, + jwt_jti=j_r.spot_jwt_refresh_jti), + "spotRefreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), + } + return response return None diff --git a/api/requirements-alerts.txt b/api/requirements-alerts.txt index 8c1bd49ab..f7ac53f88 100644 --- a/api/requirements-alerts.txt +++ b/api/requirements-alerts.txt @@ -1,7 +1,7 @@ # Keep this version to not have conflicts between requests and boto3 urllib3==1.26.16 requests==2.32.3 -boto3==1.34.147 +boto3==1.34.151 pyjwt==2.8.0 psycopg2-binary==2.9.9 psycopg[pool,binary]==3.2.1 diff --git a/api/requirements.txt b/api/requirements.txt index 57a28a50a..cfbb23b76 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,7 +1,7 @@ # Keep this version to not have conflicts between requests and boto3 urllib3==1.26.16 requests==2.32.3 -boto3==1.34.147 +boto3==1.34.151 pyjwt==2.8.0 psycopg2-binary==2.9.9 psycopg[pool,binary]==3.2.1 diff --git a/api/routers/additional_routes.py b/api/routers/additional_routes.py deleted file mode 100644 index 268f06e9f..000000000 --- a/api/routers/additional_routes.py +++ /dev/null @@ -1,4 +0,0 @@ -# this file will be overwritten by the managed saas -from routers.base import get_routers - -public_app, app, app_apikey = get_routers() diff --git a/api/routers/core_dynamic.py b/api/routers/core_dynamic.py index dfee4218d..4a6ed5938 100644 --- a/api/routers/core_dynamic.py +++ b/api/routers/core_dynamic.py @@ -46,14 +46,14 @@ if not tenants.tenants_exists_sync(use_pool=False): @public_app.post('/login', tags=["authentication"]) -def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...)): +def login_user(response: JSONResponse, spot: Optional[bool] = False, data: schemas.UserLoginSchema = Body(...)): if helper.allow_captcha() and not captcha.is_valid(data.g_recaptcha_response): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid captcha." ) - r = users.authenticate(data.email, data.password.get_secret_value()) + r = users.authenticate(email=data.email, password=data.password.get_secret_value(), include_spot=spot) if r is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -74,9 +74,17 @@ def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) "user": r } } + if spot: + content["spotJwt"] = r.pop("spotJwt") + spot_refresh_token = r.pop("spotRefreshToken") + spot_refresh_token_max_age = r.pop("spotRefreshTokenMaxAge") + response = JSONResponse(content=content) response.set_cookie(key="refreshToken", value=refresh_token, path="/api/refresh", max_age=refresh_token_max_age, secure=True, httponly=True) + if spot: + response.set_cookie(key="spotRefreshToken", value=spot_refresh_token, path="/api/spot/refresh", + max_age=spot_refresh_token_max_age, secure=True, httponly=True) return response @@ -84,6 +92,7 @@ def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) def logout_user(response: Response, context: schemas.CurrentContext = Depends(OR_context)): users.logout(user_id=context.user_id) response.delete_cookie(key="refreshToken", path="/api/refresh") + response.delete_cookie(key="spotRefreshToken", path="/api/spot/refresh") return {"data": "success"} diff --git a/api/routers/subs/spot.py b/api/routers/subs/spot.py index 1c44c93a5..52b8d950b 100644 --- a/api/routers/subs/spot.py +++ b/api/routers/subs/spot.py @@ -43,7 +43,7 @@ def login_spot(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) } } response = JSONResponse(content=content) - response.set_cookie(key="refreshToken", value=refresh_token, path=COOKIE_PATH, + response.set_cookie(key="spotRefreshToken", value=refresh_token, path=COOKIE_PATH, max_age=refresh_token_max_age, secure=True, httponly=True) return response @@ -51,7 +51,7 @@ def login_spot(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) @app.get('/logout') def logout_spot(response: Response, context: schemas.CurrentContext = Depends(OR_context)): spot.logout(user_id=context.user_id) - response.delete_cookie(key="refreshToken", path="/api/refresh") + response.delete_cookie(key="spotRefreshToken", path="/api/refresh") return {"data": "success"} @@ -60,7 +60,7 @@ def refresh_spot_login(context: schemas.CurrentContext = Depends(OR_context)): r = spot.refresh(user_id=context.user_id) content = {"jwt": r.get("jwt")} response = JSONResponse(content=content) - response.set_cookie(key="refreshToken", value=r.get("refreshToken"), path=COOKIE_PATH, + response.set_cookie(key="spotRefreshToken", value=r.get("refreshToken"), path=COOKIE_PATH, max_age=r.pop("refreshTokenMaxAge"), secure=True, httponly=True) return response diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 70bd865ea..28d27af71 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -4,11 +4,12 @@ import secrets from decouple import config from fastapi import BackgroundTasks, HTTPException +from pydantic import BaseModel from starlette import status import schemas from chalicelib.core import authorizers, metadata, projects -from chalicelib.core import roles +from chalicelib.core import roles, spot from chalicelib.core import tenants, assist from chalicelib.utils import email_helper, smtp from chalicelib.utils import helper @@ -643,20 +644,40 @@ def refresh_auth_exists(user_id, tenant_id, jwt_jti=None): return r is not None -def change_jwt_iat_jti(user_id): +class ChangeJwt(BaseModel): + jwt_iat: int + jwt_refresh_jti: int + jwt_refresh_iat: int + spot_jwt_iat: int | None = None + spot_jwt_refresh_jti: int | None = None + spot_jwt_refresh_iat: int | None = None + + +def change_jwt_iat_jti(user_id, include_spot: bool = False): + sub_query = "" + sub_result = "" + if include_spot: + sub_query = """,spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), + spot_jwt_refresh_jti = 0, + spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s')""" + sub_result = """,EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, + spot_jwt_refresh_jti, + EXTRACT (epoch FROM spot_jwt_refresh_iat)::BIGINT AS spot_jwt_refresh_iat""" with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), jwt_refresh_jti = 0, - jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + {sub_query} WHERE user_id = %(user_id)s RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, jwt_refresh_jti, - EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat;""", + EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat + {sub_result};""", {"user_id": user_id}) cur.execute(query) row = cur.fetchone() - return row.get("jwt_iat"), row.get("jwt_refresh_jti"), row.get("jwt_refresh_iat") + return ChangeJwt(**row) def refresh_jwt_iat_jti(user_id): @@ -674,7 +695,7 @@ def refresh_jwt_iat_jti(user_id): return row.get("jwt_iat"), row.get("jwt_refresh_jti"), row.get("jwt_refresh_iat") -def authenticate(email, password, for_change_password=False) -> dict | bool | None: +def authenticate(email, password, for_change_password=False, include_spot=False) -> dict | bool | None: with pg_client.PostgresClient() as cur: query = cur.mogrify( f"""SELECT @@ -724,18 +745,29 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No elif config("enforce_SSO", cast=bool, default=False) and helper.is_saml2_available(): return {"errors": ["must sign-in with SSO, enforced by admin"]} - jwt_iat, jwt_r_jti, jwt_r_iat = change_jwt_iat_jti(user_id=r['userId']) - - return { - "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=jwt_iat, + j_r = change_jwt_iat_jti(user_id=r['userId'], include_spot=include_spot) + response = { + "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=j_r.jwt_iat, aud=AUDIENCE), "refreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], - iat=jwt_r_iat, aud=AUDIENCE, - jwt_jti=jwt_r_jti), + iat=j_r.jwt_refresh_iat, aud=AUDIENCE, + jwt_jti=j_r.jwt_refresh_jti), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), "email": email, **r } + if include_spot: + response = {**response, + "spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], + iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE), + "spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], + tenant_id=r['tenantId'], + iat=j_r.spot_jwt_refresh_iat, + aud=spot.AUDIENCE, + jwt_jti=j_r.spot_jwt_refresh_jti), + "spotRefreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), + } + return response if config("enforce_SSO", cast=bool, default=False) and helper.is_saml2_available(): return {"errors": ["must sign-in with SSO, enforced by admin"]} return None diff --git a/ee/api/requirements-alerts.txt b/ee/api/requirements-alerts.txt index 8e0ba4e16..4a0290b39 100644 --- a/ee/api/requirements-alerts.txt +++ b/ee/api/requirements-alerts.txt @@ -1,7 +1,7 @@ # Keep this version to not have conflicts between requests and boto3 urllib3==1.26.16 requests==2.32.3 -boto3==1.34.147 +boto3==1.34.151 pyjwt==2.8.0 psycopg2-binary==2.9.9 psycopg[pool,binary]==3.2.1 diff --git a/ee/api/requirements-crons.txt b/ee/api/requirements-crons.txt index e954035fc..897be8273 100644 --- a/ee/api/requirements-crons.txt +++ b/ee/api/requirements-crons.txt @@ -1,7 +1,7 @@ # Keep this version to not have conflicts between requests and boto3 urllib3==1.26.16 requests==2.32.3 -boto3==1.34.147 +boto3==1.34.151 pyjwt==2.8.0 psycopg2-binary==2.9.9 psycopg[pool,binary]==3.2.1 diff --git a/ee/api/requirements.txt b/ee/api/requirements.txt index 9642c202b..d506ae76d 100644 --- a/ee/api/requirements.txt +++ b/ee/api/requirements.txt @@ -1,7 +1,7 @@ # Keep this version to not have conflicts between requests and boto3 urllib3==1.26.16 requests==2.32.3 -boto3==1.34.147 +boto3==1.34.151 pyjwt==2.8.0 psycopg2-binary==2.9.9 psycopg[pool,binary]==3.2.1 diff --git a/ee/api/routers/core_dynamic.py b/ee/api/routers/core_dynamic.py index 90e9f8701..4e93b52ac 100644 --- a/ee/api/routers/core_dynamic.py +++ b/ee/api/routers/core_dynamic.py @@ -52,14 +52,14 @@ if config("MULTI_TENANTS", cast=bool, default=False) or not tenants.tenants_exis @public_app.post('/login', tags=["authentication"]) -def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...)): +def login_user(response: JSONResponse, spot: Optional[bool] = False, data: schemas.UserLoginSchema = Body(...)): if helper.allow_captcha() and not captcha.is_valid(data.g_recaptcha_response): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid captcha." ) - r = users.authenticate(data.email, data.password.get_secret_value()) + r = users.authenticate(email=data.email, password=data.password.get_secret_value(), include_spot=spot) if r is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -80,9 +80,17 @@ def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) "user": r } } + if spot: + content["spotJwt"] = r.pop("spotJwt") + spot_refresh_token = r.pop("spotRefreshToken") + spot_refresh_token_max_age = r.pop("spotRefreshTokenMaxAge") + response = JSONResponse(content=content) response.set_cookie(key="refreshToken", value=refresh_token, path="/api/refresh", max_age=refresh_token_max_age, secure=True, httponly=True) + if spot: + response.set_cookie(key="spotRefreshToken", value=spot_refresh_token, path="/api/spot/refresh", + max_age=spot_refresh_token_max_age, secure=True, httponly=True) return response @@ -90,6 +98,7 @@ def login_user(response: JSONResponse, data: schemas.UserLoginSchema = Body(...) def logout_user(response: Response, context: schemas.CurrentContext = Depends(OR_context)): users.logout(user_id=context.user_id) response.delete_cookie(key="refreshToken", path="/api/refresh") + response.delete_cookie(key="spotRefreshToken", path="/api/spot/refresh") return {"data": "success"}