From e13d7b537ea3f4a938325f40666c0dd6164fa008 Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 15:55:24 -0500 Subject: [PATCH 01/10] add response schema --- api/src/api/users/user_schemas.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 3ea05421f..12754259d 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -51,3 +51,7 @@ class UserTokenSchema(Schema): class UserTokenResponseSchema(AbstractResponseSchema): data = fields.Nested(UserTokenSchema) + +class UserTokenRefreshResponseSchema(AbstractResponseSchema): + # No data returned + data = fields.MixinField(metadata={"example": None}) \ No newline at end of file From 5c9cba716c42bb032a0622b2d7492210fd85e273 Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 15:55:53 -0500 Subject: [PATCH 02/10] add token refresh endpoint --- api/src/api/users/user_routes.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 1c34f0b5a..1a86527e6 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -1,10 +1,17 @@ import logging +from datetime import timedelta +from src.adapters import db +from src.adapters.db import flask_db from src.api import response from src.api.route_utils import raise_flask_error from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint +from src.api.users.user_schemas import UserTokenRefreshResponseSchema +from src.auth import api_jwt_auth +from src.auth.api_jwt_auth import get_config from src.auth.api_key_auth import api_key_auth +from src.util import datetime_util logger = logging.getLogger(__name__) @@ -34,3 +41,30 @@ def user_token(x_oauth_login_gov: dict) -> response.ApiResponse: logger.info(message) raise_flask_error(400, message) + + +@user_blueprint.post("/token/refresh") +@user_blueprint.output(UserTokenRefreshResponseSchema) +@user_blueprint.doc(responses=[200, 401]) +@user_blueprint.auth_required(api_jwt_auth) +@flask_db.with_db_session() +def user_token_refresh(db_session: db.Session) -> response.ApiResponse: + logger.info("POST /v1/users/token/refresh") + + user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + config = get_config() + expiration_time = datetime_util.utcnow() + timedelta(minutes=config.token_expiration_minutes) + + with db_session.begin(): + user_token_session.expires_at = expiration_time + db_session.add(user_token_session) + + logger.info( + "Refreshed a user token", + extra={ + "user_token_session.token_id": str(user_token_session.token_id), + "user_token_session.user_id": str(user_token_session.user_id), + }, + ) + + return response.ApiResponse(message="Success") \ No newline at end of file From ad4b5936128e9d7788edf096006b67f4d386532b Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 15:56:16 -0500 Subject: [PATCH 03/10] add suceess and expired token test --- .../src/api/users/test_user_route_token.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/api/tests/src/api/users/test_user_route_token.py b/api/tests/src/api/users/test_user_route_token.py index d0811de94..340a05e0e 100644 --- a/api/tests/src/api/users/test_user_route_token.py +++ b/api/tests/src/api/users/test_user_route_token.py @@ -1,6 +1,10 @@ ################## # POST /token ################## +from datetime import datetime + +from src.auth.api_jwt_auth import create_jwt_for_user +from tests.src.db.models.factories import UserFactory def test_post_user_route_token_200(client, api_auth_token): @@ -25,3 +29,34 @@ def test_post_user_route_token_400(client, api_auth_token): resp = client.post("v1/users/token", headers={"X-Auth": api_auth_token}) assert resp.status_code == 400 assert resp.get_json()["message"] == "Missing X-OAuth-login-gov header" + + +def test_post_user_route_token_refresh_200( + enable_factory_create, client, db_session, api_auth_token +): + user = UserFactory.create() + token, user_token_session = create_jwt_for_user(user, db_session) + expiration = user_token_session.expires_at + db_session.commit() + + resp = client.post("v1/users/token/refresh", headers={"X-SGG-Token": token}) + + db_session.refresh(user_token_session) + + assert resp.status_code == 200 + assert not user_token_session.expired_at != expiration + + +def test_post_user_route_token_refresh_expired( + enable_factory_create, client, db_session, api_auth_token +): + user = UserFactory.create() + + token, session = create_jwt_for_user(user, db_session) + session.expires_at = datetime.fromisoformat("1980-01-01 12:00:00+00:00") + db_session.commit() + + resp = client.post("v1/users/token/refresh", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token expired" \ No newline at end of file From aa8a1920fd33dae638900c215d51141b798e34b2 Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 16:01:58 -0500 Subject: [PATCH 04/10] cleanup --- api/src/api/users/user_routes.py | 6 +++--- api/src/api/users/user_schemas.py | 3 ++- api/tests/src/api/users/test_user_route_token.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 1a86527e6..ce4009179 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -8,9 +8,9 @@ from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint from src.api.users.user_schemas import UserTokenRefreshResponseSchema -from src.auth import api_jwt_auth -from src.auth.api_jwt_auth import get_config +from src.auth.api_jwt_auth import api_jwt_auth, get_config from src.auth.api_key_auth import api_key_auth +from src.db.models.user_models import UserTokenSession from src.util import datetime_util logger = logging.getLogger(__name__) @@ -67,4 +67,4 @@ def user_token_refresh(db_session: db.Session) -> response.ApiResponse: }, ) - return response.ApiResponse(message="Success") \ No newline at end of file + return response.ApiResponse(message="Success") diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 12754259d..03a1310ee 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -52,6 +52,7 @@ class UserTokenSchema(Schema): class UserTokenResponseSchema(AbstractResponseSchema): data = fields.Nested(UserTokenSchema) + class UserTokenRefreshResponseSchema(AbstractResponseSchema): # No data returned - data = fields.MixinField(metadata={"example": None}) \ No newline at end of file + data = fields.MixinField(metadata={"example": None}) diff --git a/api/tests/src/api/users/test_user_route_token.py b/api/tests/src/api/users/test_user_route_token.py index 340a05e0e..d1690542b 100644 --- a/api/tests/src/api/users/test_user_route_token.py +++ b/api/tests/src/api/users/test_user_route_token.py @@ -44,7 +44,7 @@ def test_post_user_route_token_refresh_200( db_session.refresh(user_token_session) assert resp.status_code == 200 - assert not user_token_session.expired_at != expiration + assert user_token_session.expires_at != expiration def test_post_user_route_token_refresh_expired( @@ -59,4 +59,4 @@ def test_post_user_route_token_refresh_expired( resp = client.post("v1/users/token/refresh", headers={"X-SGG-Token": token}) assert resp.status_code == 401 - assert resp.get_json()["message"] == "Token expired" \ No newline at end of file + assert resp.get_json()["message"] == "Token expired" From 67ef19ba2c518ca24eb0ae3ca2a67663bd02b250 Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Fri, 22 Nov 2024 21:10:33 +0000 Subject: [PATCH 05/10] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 7cda9c07c..e47164754 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -119,6 +119,27 @@ paths: summary: User Token security: - ApiKeyAuth: [] + /v1/users/token/refresh: + post: + parameters: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserTokenRefreshResponse' + description: Successful response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + tags: + - User v1 + summary: User Token Refresh + security: + - ApiJwtAuth: [] /v1/opportunities/search: post: parameters: [] @@ -645,6 +666,19 @@ components: type: integer description: The HTTP status code example: 200 + UserTokenRefreshResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + example: null + status_code: + type: integer + description: The HTTP status code + example: 200 FundingInstrumentFilterV1: type: object properties: From 6821a01d05b592273089dfc53cc5b03c6d97511f Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 17:01:11 -0500 Subject: [PATCH 06/10] created method to get expiration time --- api/src/auth/api_jwt_auth.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/src/auth/api_jwt_auth.py b/api/src/auth/api_jwt_auth.py index 5c0e979d1..5464a5f1e 100644 --- a/api/src/auth/api_jwt_auth.py +++ b/api/src/auth/api_jwt_auth.py @@ -5,6 +5,7 @@ import jwt from apiflask import HTTPTokenAuth +from black import datetime from pydantic import Field from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -205,3 +206,12 @@ def example_method(db_session: db.Session) -> response.ApiResponse: # The message is just the value we set when constructing the JwtValidationError logger.info("JWT Authentication Failed for provided token", extra={"auth.issue": e.message}) raise_flask_error(401, e.message) + + +def set_token_expiration_time(config: ApiJwtConfig | None = None) -> datetime: + if config is None: + config = get_config() + + expiration_time = datetime_util.utcnow() + timedelta(minutes=config.token_expiration_minutes) + + return expiration_time From 695427ddd4493982f8c1bd3503e59df3fecd649d Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 17:01:24 -0500 Subject: [PATCH 07/10] clean up --- api/src/api/users/user_routes.py | 8 ++------ api/tests/src/api/users/test_user_route_token.py | 6 ++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index ce4009179..fce2d2409 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -1,5 +1,4 @@ import logging -from datetime import timedelta from src.adapters import db from src.adapters.db import flask_db @@ -8,10 +7,9 @@ from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint from src.api.users.user_schemas import UserTokenRefreshResponseSchema -from src.auth.api_jwt_auth import api_jwt_auth, get_config +from src.auth.api_jwt_auth import api_jwt_auth, set_token_expiration_time from src.auth.api_key_auth import api_key_auth from src.db.models.user_models import UserTokenSession -from src.util import datetime_util logger = logging.getLogger(__name__) @@ -52,11 +50,9 @@ def user_token_refresh(db_session: db.Session) -> response.ApiResponse: logger.info("POST /v1/users/token/refresh") user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore - config = get_config() - expiration_time = datetime_util.utcnow() + timedelta(minutes=config.token_expiration_minutes) with db_session.begin(): - user_token_session.expires_at = expiration_time + user_token_session.expires_at = set_token_expiration_time() db_session.add(user_token_session) logger.info( diff --git a/api/tests/src/api/users/test_user_route_token.py b/api/tests/src/api/users/test_user_route_token.py index d1690542b..3b3b78f7d 100644 --- a/api/tests/src/api/users/test_user_route_token.py +++ b/api/tests/src/api/users/test_user_route_token.py @@ -3,6 +3,8 @@ ################## from datetime import datetime +from freezegun import freeze_time + from src.auth.api_jwt_auth import create_jwt_for_user from tests.src.db.models.factories import UserFactory @@ -31,12 +33,12 @@ def test_post_user_route_token_400(client, api_auth_token): assert resp.get_json()["message"] == "Missing X-OAuth-login-gov header" +@freeze_time("2024-11-22 12:00:00", tz_offset=0) def test_post_user_route_token_refresh_200( enable_factory_create, client, db_session, api_auth_token ): user = UserFactory.create() token, user_token_session = create_jwt_for_user(user, db_session) - expiration = user_token_session.expires_at db_session.commit() resp = client.post("v1/users/token/refresh", headers={"X-SGG-Token": token}) @@ -44,7 +46,7 @@ def test_post_user_route_token_refresh_200( db_session.refresh(user_token_session) assert resp.status_code == 200 - assert user_token_session.expires_at != expiration + assert user_token_session.expires_at == datetime.fromisoformat("2024-11-22 12:30:00+00:00") def test_post_user_route_token_refresh_expired( From 1bd2f847da29ac792bcd539514a49cbe7baf7ea4 Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Fri, 22 Nov 2024 22:08:06 +0000 Subject: [PATCH 08/10] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index ec774f487..ceabdda5e 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -119,7 +119,7 @@ paths: summary: User Token security: - ApiKeyAuth: [] - /v1/users/token/refresh: + /v1/users/token/logout: post: parameters: [] responses: @@ -127,7 +127,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserTokenRefreshResponse' + $ref: '#/components/schemas/UserTokenLogoutResponse' description: Successful response '401': content: @@ -137,10 +137,10 @@ paths: description: Authentication error tags: - User v1 - summary: User Token Refresh + summary: User Token Logout security: - ApiJwtAuth: [] - /v1/users/token/logout: + /v1/users/token/refresh: post: parameters: [] responses: @@ -148,7 +148,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserTokenLogoutResponse' + $ref: '#/components/schemas/UserTokenRefreshResponse' description: Successful response '401': content: @@ -158,7 +158,7 @@ paths: description: Authentication error tags: - User v1 - summary: User Token Logout + summary: User Token Refresh security: - ApiJwtAuth: [] /v1/opportunities/search: @@ -687,7 +687,7 @@ components: type: integer description: The HTTP status code example: 200 - UserTokenRefreshResponse: + UserTokenLogoutResponse: type: object properties: message: @@ -700,7 +700,7 @@ components: type: integer description: The HTTP status code example: 200 - UserTokenLogoutResponse: + UserTokenRefreshResponse: type: object properties: message: From d5d6d620d4e46068e678c353333adb1e6f2d1321 Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 17:35:19 -0500 Subject: [PATCH 09/10] rename func add tokens session param --- api/src/auth/api_jwt_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/auth/api_jwt_auth.py b/api/src/auth/api_jwt_auth.py index 5464a5f1e..8ce0ec164 100644 --- a/api/src/auth/api_jwt_auth.py +++ b/api/src/auth/api_jwt_auth.py @@ -208,10 +208,11 @@ def example_method(db_session: db.Session) -> response.ApiResponse: raise_flask_error(401, e.message) -def set_token_expiration_time(config: ApiJwtConfig | None = None) -> datetime: +def refresh_token_expiration(token_session: UserTokenSession, config: ApiJwtConfig | None = None) -> datetime: if config is None: config = get_config() expiration_time = datetime_util.utcnow() + timedelta(minutes=config.token_expiration_minutes) + token_session.expires_at = expiration_time - return expiration_time + return token_session From 2d25edba2151facbe7b05020ba94316c2de1f1b1 Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 22 Nov 2024 17:37:33 -0500 Subject: [PATCH 10/10] cleanup --- api/src/api/users/user_routes.py | 4 ++-- api/src/auth/api_jwt_auth.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index e30341be1..2e06ad05e 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -7,7 +7,7 @@ from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint from src.api.users.user_schemas import UserTokenLogoutResponseSchema, UserTokenRefreshResponseSchema -from src.auth.api_jwt_auth import api_jwt_auth, set_token_expiration_time +from src.auth.api_jwt_auth import api_jwt_auth, refresh_token_expiration from src.auth.api_key_auth import api_key_auth from src.db.models.user_models import UserTokenSession @@ -52,7 +52,7 @@ def user_token_refresh(db_session: db.Session) -> response.ApiResponse: user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore with db_session.begin(): - user_token_session.expires_at = set_token_expiration_time() + refresh_token_expiration(user_token_session) db_session.add(user_token_session) logger.info( diff --git a/api/src/auth/api_jwt_auth.py b/api/src/auth/api_jwt_auth.py index 8ce0ec164..e201771e0 100644 --- a/api/src/auth/api_jwt_auth.py +++ b/api/src/auth/api_jwt_auth.py @@ -5,7 +5,6 @@ import jwt from apiflask import HTTPTokenAuth -from black import datetime from pydantic import Field from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -208,7 +207,9 @@ def example_method(db_session: db.Session) -> response.ApiResponse: raise_flask_error(401, e.message) -def refresh_token_expiration(token_session: UserTokenSession, config: ApiJwtConfig | None = None) -> datetime: +def refresh_token_expiration( + token_session: UserTokenSession, config: ApiJwtConfig | None = None +) -> UserTokenSession: if config is None: config = get_config()