diff --git a/api/app.py b/api/app.py index fd7152be0..1c223ce07 100644 --- a/api/app.py +++ b/api/app.py @@ -15,7 +15,7 @@ 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.subs import insights, metrics, v1_api, health, usability_tests +from routers.subs import insights, metrics, v1_api, health, usability_tests, spot loglevel = config("LOGLEVEL", default=logging.WARNING) print(f">Loglevel set to: {loglevel}") @@ -124,6 +124,10 @@ app.include_router(usability_tests.public_app) app.include_router(usability_tests.app) 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"]) diff --git a/api/chalicelib/core/authorizers.py b/api/chalicelib/core/authorizers.py index a8eb3e771..9073bb7fc 100644 --- a/api/chalicelib/core/authorizers.py +++ b/api/chalicelib/core/authorizers.py @@ -19,7 +19,7 @@ def jwt_authorizer(scheme: str, token: str, leeway=0): token, config("jwt_secret"), algorithms=config("jwt_algorithm"), - audience=[f"front:{helper.get_stage_name()}"], + audience=[f"front:{helper.get_stage_name()}",f"spot:{helper.get_stage_name()}"], leeway=leeway ) except jwt.ExpiredSignatureError: @@ -63,7 +63,7 @@ def jwt_context(context): } -def generate_jwt(user_id, tenant_id, iat, aud): +def generate_spot_jwt(user_id, tenant_id, iat, aud): token = jwt.encode( payload={ "userId": user_id, @@ -79,7 +79,7 @@ def generate_jwt(user_id, tenant_id, iat, aud): return token -def generate_jwt_refresh(user_id, tenant_id, iat, aud, jwt_jti): +def generate_spot_jwt_refresh(user_id, tenant_id, iat, aud, jwt_jti): token = jwt.encode( payload={ "userId": user_id, diff --git a/api/chalicelib/core/spot.py b/api/chalicelib/core/spot.py new file mode 100644 index 000000000..44db90151 --- /dev/null +++ b/api/chalicelib/core/spot.py @@ -0,0 +1,86 @@ +from decouple import config + +from chalicelib.core import authorizers, users +from chalicelib.utils import helper +from chalicelib.utils import pg_client + + +def change_spot_jwt_iat_jti(user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""UPDATE public.users + SET spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), + spot_jwt_refresh_jti = 0, + spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + WHERE user_id = %(user_id)s + RETURNING 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;""", + {"user_id": user_id}) + cur.execute(query) + row = cur.fetchone() + return row.get("spot_jwt_iat"), row.get("spot_jwt_refresh_jti"), row.get("spot_jwt_refresh_iat") + + +def refresh_spot_jwt_iat_jti(user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""UPDATE public.users + SET spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), + spot_jwt_refresh_jti = spot_jwt_refresh_jti + 1 + WHERE user_id = %(user_id)s + RETURNING EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, + spot_jwt_refresh_jti, + EXTRACT (epoch FROM spot_jwt_refresh_jti)::BIGINT AS spot_jwt_refresh_jti;""", + {"user_id": user_id}) + cur.execute(query) + row = cur.fetchone() + return row.get("spot_jwt_iat"), row.get("spot_jwt_refresh_jti"), row.get("spot_jwt_refresh_jti") + + +def authenticate(email, password) -> dict | None: + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + f"""SELECT + users.user_id, + 1 AS tenant_id, + users.name, + users.email + FROM public.users INNER JOIN public.basic_authentication USING(user_id) + WHERE users.email = %(email)s + AND basic_authentication.password = crypt(%(password)s, basic_authentication.password) + AND basic_authentication.user_id = (SELECT su.user_id FROM public.users AS su WHERE su.email=%(email)s AND su.deleted_at IS NULL LIMIT 1) + LIMIT 1;""", + {"email": email, "password": password}) + + cur.execute(query) + r = cur.fetchone() + + if r is not None: + r = helper.dict_to_camel_case(r) + spot_jwt_iat, spot_jwt_r_jti, spot_jwt_r_iat = change_spot_jwt_iat_jti(user_id=r['userId']) + return { + "jwt": authorizers.generate_spot_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=spot_jwt_iat, + aud=f"spot:{helper.get_stage_name()}"), + "refreshToken": authorizers.generate_spot_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], + iat=spot_jwt_r_iat, aud=f"spot:{helper.get_stage_name()}", + jwt_jti=spot_jwt_r_jti), + "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), + "email": email, + **r + } + return None + + +def logout(user_id: int): + users.logout(user_id=user_id) + + +def refresh(user_id: int, tenant_id: int = -1) -> dict: + spot_jwt_iat, spot_jwt_r_jti, spot_jwt_r_iat = refresh_spot_jwt_iat_jti(user_id=user_id) + return { + "jwt": authorizers.generate_spot_jwt(user_id=user_id, tenant_id=tenant_id, iat=spot_jwt_iat, + aud=f"spot:{helper.get_stage_name()}"), + "refreshToken": authorizers.generate_spot_jwt_refresh(user_id=user_id, tenant_id=tenant_id, iat=spot_jwt_r_iat, + aud=f"spot:{helper.get_stage_name()}", + jwt_jti=spot_jwt_r_jti), + "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int) - (spot_jwt_iat - spot_jwt_r_iat) + } diff --git a/api/chalicelib/core/users.py b/api/chalicelib/core/users.py index 0cff15c4c..d8711e4a6 100644 --- a/api/chalicelib/core/users.py +++ b/api/chalicelib/core/users.py @@ -611,11 +611,11 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No 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, - aud=f"front:{helper.get_stage_name()}"), - "refreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], - iat=jwt_r_iat, aud=f"front:{helper.get_stage_name()}", - jwt_jti=jwt_r_jti), + "jwt": authorizers.generate_spot_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=jwt_iat, + aud=f"front:{helper.get_stage_name()}"), + "refreshToken": authorizers.generate_spot_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], + iat=jwt_r_iat, aud=f"front:{helper.get_stage_name()}", + jwt_jti=jwt_r_jti), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), "email": email, **r @@ -627,7 +627,8 @@ def logout(user_id: int): with pg_client.PostgresClient() as cur: query = cur.mogrify( """UPDATE public.users - SET jwt_iat = NULL, jwt_refresh_jti = NULL, jwt_refresh_iat = NULL + SET jwt_iat = NULL, jwt_refresh_jti = NULL, jwt_refresh_iat = NULL, + spot_jwt_iat = NULL, spot_jwt_refresh_jti = NULL, spot_jwt_refresh_iat = NULL WHERE user_id = %(user_id)s;""", {"user_id": user_id}) cur.execute(query) @@ -636,11 +637,11 @@ def logout(user_id: int): def refresh(user_id: int, tenant_id: int = -1) -> dict: jwt_iat, jwt_r_jti, jwt_r_iat = refresh_jwt_iat_jti(user_id=user_id) return { - "jwt": authorizers.generate_jwt(user_id=user_id, tenant_id=tenant_id, iat=jwt_iat, - aud=f"front:{helper.get_stage_name()}"), - "refreshToken": authorizers.generate_jwt_refresh(user_id=user_id, tenant_id=tenant_id, - iat=jwt_r_iat, aud=f"front:{helper.get_stage_name()}", - jwt_jti=jwt_r_jti), + "jwt": authorizers.generate_spot_jwt(user_id=user_id, tenant_id=tenant_id, iat=jwt_iat, + aud=f"front:{helper.get_stage_name()}"), + "refreshToken": authorizers.generate_spot_jwt_refresh(user_id=user_id, tenant_id=tenant_id, iat=jwt_r_iat, + aud=f"front:{helper.get_stage_name()}", + jwt_jti=jwt_r_jti), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int) - (jwt_iat - jwt_r_iat) } diff --git a/api/routers/base.py b/api/routers/base.py index 09821e93e..558fe5ff2 100644 --- a/api/routers/base.py +++ b/api/routers/base.py @@ -6,11 +6,11 @@ from auth.auth_project import ProjectAuthorizer from or_dependencies import ORRoute -def get_routers(extra_dependencies=[]) -> (APIRouter, APIRouter, APIRouter): - public_app = APIRouter(route_class=ORRoute) +def get_routers(prefix="", extra_dependencies=[], tags=[]) -> (APIRouter, APIRouter, APIRouter): + public_app = APIRouter(route_class=ORRoute, prefix=prefix, tags=tags) app = APIRouter(dependencies=[Depends(JWTAuth()), Depends(ProjectAuthorizer("projectId"))] + extra_dependencies, - route_class=ORRoute) + route_class=ORRoute, prefix=prefix, tags=tags) app_apikey = APIRouter( dependencies=[Depends(APIKeyAuth()), Depends(ProjectAuthorizer("projectKey"))] + extra_dependencies, - route_class=ORRoute) + route_class=ORRoute, prefix=prefix, tags=tags) return public_app, app, app_apikey diff --git a/api/routers/subs/spot.py b/api/routers/subs/spot.py new file mode 100644 index 000000000..6158860f3 --- /dev/null +++ b/api/routers/subs/spot.py @@ -0,0 +1,63 @@ +from fastapi import Body, Depends +from fastapi import HTTPException, status +from starlette.responses import JSONResponse, Response + +import schemas +from chalicelib.core import spot +from chalicelib.utils import captcha +from chalicelib.utils import helper +from or_dependencies import OR_context +from routers.base import get_routers + +public_app, app, app_apikey = get_routers(prefix="/spot", tags=["spot"]) + + +@public_app.post('/login', tags=["authentication"]) +def login_spot(response: JSONResponse, 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 = spot.authenticate(data.email, data.password.get_secret_value()) + if r is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You've entered invalid Email or Password." + ) + if "errors" in r: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=r["errors"][0] + ) + + refresh_token = r.pop("refreshToken") + refresh_token_max_age = r.pop("refreshTokenMaxAge") + content = { + 'jwt': r.pop('jwt'), + 'data': { + "user": r + } + } + response = JSONResponse(content=content) + response.set_cookie(key="refreshToken", value=refresh_token, path="/api/spot/refresh", + max_age=refresh_token_max_age, secure=True, httponly=True) + return response + + +@app.get('/logout', tags=["login"]) +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") + return {"data": "success"} + + +@app.get('/refresh', tags=["login"]) +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="/api/refresh", + max_age=r.pop("refreshTokenMaxAge"), secure=True, httponly=True) + return response diff --git a/scripts/schema/db/init_dbs/postgresql/1.20.0/1.20.0.sql b/scripts/schema/db/init_dbs/postgresql/1.20.0/1.20.0.sql index d7f7ab3cc..c6b26554a 100644 --- a/scripts/schema/db/init_dbs/postgresql/1.20.0/1.20.0.sql +++ b/scripts/schema/db/init_dbs/postgresql/1.20.0/1.20.0.sql @@ -22,6 +22,11 @@ ALTER TABLE IF EXISTS events.clicks ALTER COLUMN normalized_x SET DATA TYPE decimal, ALTER COLUMN normalized_y SET DATA TYPE decimal; +ALTER TABLE IF EXISTS public.users + ADD COLUMN IF NOT EXISTS spot_jwt_iat timestamp without time zone NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS spot_jwt_refresh_jti integer NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS spot_jwt_refresh_iat timestamp without time zone NULL DEFAULT NULL; + COMMIT; \elif :is_next diff --git a/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/scripts/schema/db/init_dbs/postgresql/init_schema.sql index ec1bb4730..0e9828ec0 100644 --- a/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -110,19 +110,22 @@ CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member'); CREATE TABLE public.users ( - user_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, - email text NOT NULL UNIQUE, - role user_role NOT NULL DEFAULT 'member', - name text NOT NULL, - created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), - deleted_at timestamp without time zone NULL DEFAULT NULL, - api_key text UNIQUE DEFAULT generate_api_key(20) NOT NULL, - jwt_iat timestamp without time zone NULL DEFAULT NULL, - jwt_refresh_jti integer NULL DEFAULT NULL, - jwt_refresh_iat timestamp without time zone NULL DEFAULT NULL, - data jsonb NOT NULL DEFAULT '{}'::jsonb, - weekly_report boolean NOT NULL DEFAULT TRUE, - settings jsonb DEFAULT NULL + user_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + email text NOT NULL UNIQUE, + role user_role NOT NULL DEFAULT 'member', + name text NOT NULL, + created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + deleted_at timestamp without time zone NULL DEFAULT NULL, + api_key text UNIQUE DEFAULT generate_api_key(20) NOT NULL, + jwt_iat timestamp without time zone NULL DEFAULT NULL, + jwt_refresh_jti integer NULL DEFAULT NULL, + jwt_refresh_iat timestamp without time zone NULL DEFAULT NULL, + spot_jwt_iat timestamp without time zone NULL DEFAULT NULL, + spot_jwt_refresh_jti integer NULL DEFAULT NULL, + spot_jwt_refresh_iat timestamp without time zone NULL DEFAULT NULL, + data jsonb NOT NULL DEFAULT '{}'::jsonb, + weekly_report boolean NOT NULL DEFAULT TRUE, + settings jsonb DEFAULT NULL ); CREATE TABLE public.basic_authentication