diff --git a/.env.example b/.env.example index 99b0ef3a..66c6fe0c 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,10 @@ RDS_BACKEND_BASE_URL='http://localhost:3000' RDS_PUBLIC_KEY="public-key-here" GOOGLE_OAUTH_CLIENT_ID="google-client-id" GOOGLE_OAUTH_CLIENT_SECRET="client-secret" -GOOGLE_OAUTH_REDIRECT_URI="environment-url/auth/google/callback" -GOOGLE_JWT_SECRET_KEY=generate-secret-key \ No newline at end of file +# Google JWT RSA Keys +GOOGLE_JWT_PRIVATE_KEY="generate keys and paste here" +GOOGLE_JWT_PUBLIC_KEY="generate keys and paste here" + +# use if required +# GOOGLE_JWT_ACCESS_LIFETIME="20" +# GOOGLE_JWT_REFRESH_LIFETIME="30" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f8144b9..c528d54a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,12 +12,10 @@ jobs: env: MONGODB_URI: mongodb://db:27017 DB_NAME: todo-app - GOOGLE_JWT_SECRET_KEY: "test-secret-key-for-jwt" GOOGLE_JWT_ACCESS_LIFETIME: "3600" GOOGLE_JWT_REFRESH_LIFETIME: "604800" GOOGLE_OAUTH_CLIENT_ID: "test-client-id" GOOGLE_OAUTH_CLIENT_SECRET: "test-client-secret" - GOOGLE_OAUTH_REDIRECT_URI: "http://localhost:3000/auth/callback" COOKIE_SECURE: "False" COOKIE_SAMESITE: "Lax" diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index 8559d404..f88e0672 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -3,9 +3,17 @@ from django.http import JsonResponse from todo.utils.jwt_utils import verify_jwt_token -from todo.utils.google_jwt_utils import validate_google_access_token +from todo.utils.google_jwt_utils import ( + validate_google_access_token, + validate_google_refresh_token, + generate_google_access_token, +) from todo.exceptions.auth_exceptions import TokenMissingError, TokenExpiredError, TokenInvalidError -from todo.exceptions.google_auth_exceptions import GoogleTokenExpiredError, GoogleTokenInvalidError +from todo.exceptions.google_auth_exceptions import ( + GoogleTokenExpiredError, + GoogleTokenInvalidError, + GoogleRefreshTokenExpiredError, +) from todo.constants.messages import AuthErrorMessages, ApiErrors from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail @@ -25,7 +33,8 @@ def __call__(self, request): auth_success = self._try_authentication(request) if auth_success: - return self.get_response(request) + response = self.get_response(request) + return self._process_response(request, response) else: error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, @@ -73,25 +82,61 @@ def _try_google_auth(self, request) -> bool: try: google_token = request.COOKIES.get("ext-access") - if not google_token: + if google_token: + try: + payload = validate_google_access_token(google_token) + self._set_google_user_data(request, payload) + return True + except (GoogleTokenExpiredError, GoogleTokenInvalidError): + pass + + return self._try_google_refresh(request) + + except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: + raise e + except Exception: + return False + + def _try_google_refresh(self, request) -> bool: + """Try to refresh Google access token""" + try: + refresh_token = request.COOKIES.get("ext-refresh") + + if not refresh_token: return False - payload = validate_google_access_token(google_token) + payload = validate_google_refresh_token(refresh_token) - request.auth_type = "google" - request.user_id = payload["user_id"] - request.google_id = payload["google_id"] - request.user_email = payload["email"] - request.user_name = payload["name"] - request.user_role = "external_user" + user_data = { + "user_id": payload["user_id"], + "google_id": payload["google_id"], + "email": payload["email"], + "name": payload.get("name", ""), + } + + new_access_token = generate_google_access_token(user_data) + + self._set_google_user_data(request, payload) + + request._new_access_token = new_access_token + request._access_token_expires = settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"] return True - except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: - raise e + except (GoogleRefreshTokenExpiredError, GoogleTokenInvalidError): + return False except Exception: return False + def _set_google_user_data(self, request, payload): + """Set Google user data on request""" + request.auth_type = "google" + request.user_id = payload["user_id"] + request.google_id = payload["google_id"] + request.user_email = payload["email"] + request.user_name = payload.get("name", "") + request.user_role = "external_user" + def _try_rds_auth(self, request) -> bool: try: rds_token = request.COOKIES.get(self.rds_cookie_name) @@ -112,6 +157,25 @@ def _try_rds_auth(self, request) -> bool: except Exception: return False + def _process_response(self, request, response): + """Process response and set new cookies if Google token was refreshed""" + if hasattr(request, "_new_access_token"): + config = self._get_cookie_config() + response.set_cookie( + "ext-access", request._new_access_token, max_age=request._access_token_expires, **config + ) + return response + + def _get_cookie_config(self): + """Get Google cookie configuration""" + return { + "path": "/", + "domain": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN"), + "secure": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_SECURE", False), + "httponly": True, + "samesite": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_SAMESITE", "Lax"), + } + def _is_public_path(self, path: str) -> bool: return any(path.startswith(public_path) for public_path in settings.PUBLIC_PATHS) diff --git a/todo/tests/integration/base_mongo_test.py b/todo/tests/integration/base_mongo_test.py index f8695cc5..319dc140 100644 --- a/todo/tests/integration/base_mongo_test.py +++ b/todo/tests/integration/base_mongo_test.py @@ -20,8 +20,7 @@ def setUpClass(cls): cls.db = cls.mongo_client.get_database("testdb") cls.override = override_settings( - MONGODB_URI=cls.mongo_url, - DB_NAME="testdb", + MONGODB_URI=cls.mongo_url, DB_NAME="testdb", FRONTEND_URL="http://localhost:4000" ) cls.override.enable() DatabaseManager.reset() diff --git a/todo/tests/unit/middlewares/test_jwt_auth.py b/todo/tests/unit/middlewares/test_jwt_auth.py index 2681898c..5e10e69a 100644 --- a/todo/tests/unit/middlewares/test_jwt_auth.py +++ b/todo/tests/unit/middlewares/test_jwt_auth.py @@ -1,7 +1,6 @@ from unittest import TestCase from unittest.mock import Mock, patch from django.http import HttpRequest, JsonResponse -from django.conf import settings from rest_framework import status import json @@ -17,9 +16,6 @@ def setUp(self): self.request.path = "/v1/tasks" self.request.headers = {} self.request.COOKIES = {} - self._original_public_paths = settings.PUBLIC_PATHS - settings.PUBLIC_PATHS = ["/v1/auth/google/login"] - self.addCleanup(setattr, settings, "PUBLIC_PATHS", self._original_public_paths) def test_public_path_authentication_bypass(self): """Test that requests to public paths bypass authentication""" diff --git a/todo/tests/unit/views/test_auth.py b/todo/tests/unit/views/test_auth.py index 4ffe8263..596fd371 100644 --- a/todo/tests/unit/views/test_auth.py +++ b/todo/tests/unit/views/test_auth.py @@ -4,14 +4,10 @@ from unittest.mock import patch, Mock, PropertyMock from bson.objectid import ObjectId -from todo.views.auth import ( - GoogleCallbackView, -) - -from todo.utils.google_jwt_utils import ( - generate_google_token_pair, -) -from todo.constants.messages import AppMessages, AuthErrorMessages +from todo.views.auth import GoogleCallbackView +from todo.utils.google_jwt_utils import generate_google_token_pair +from todo.constants.messages import AppMessages +from todo.tests.fixtures.user import google_auth_user_payload, users_db_data class GoogleLoginViewTests(APITestCase): @@ -21,7 +17,7 @@ def setUp(self): self.url = reverse("google_login") @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") - def test_get_returns_redirect_url_for_html_request(self, mock_get_auth_url): + def test_get_returns_redirect_for_html_request(self, mock_get_auth_url): mock_auth_url = "https://accounts.google.com/o/oauth2/auth" mock_state = "test_state" mock_get_auth_url.return_value = (mock_auth_url, mock_state) @@ -41,12 +37,31 @@ def test_get_returns_json_for_json_request(self, mock_get_auth_url): response = self.client.get(self.url, HTTP_ACCEPT="application/json") self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["statusCode"], status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Google OAuth URL generated successfully") self.assertEqual(response.data["data"]["authUrl"], mock_auth_url) self.assertEqual(response.data["data"]["state"], mock_state) mock_get_auth_url.assert_called_once_with(None) @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") - def test_get_with_redirect_url(self, mock_get_auth_url): + def test_get_returns_json_with_format_parameter(self, mock_get_auth_url): + """Test that format=json parameter returns JSON response""" + mock_auth_url = "https://accounts.google.com/o/oauth2/auth" + mock_state = "test_state" + mock_get_auth_url.return_value = (mock_auth_url, mock_state) + + response = self.client.get(f"{self.url}?format=json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["statusCode"], status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Google OAuth URL generated successfully") + self.assertEqual(response.data["data"]["authUrl"], mock_auth_url) + self.assertEqual(response.data["data"]["state"], mock_state) + mock_get_auth_url.assert_called_once_with(None) + + @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") + def test_get_with_redirect_url_html_request(self, mock_get_auth_url): + """Test HTML request with redirect URL""" mock_auth_url = "https://accounts.google.com/o/oauth2/auth" mock_state = "test_state" mock_get_auth_url.return_value = (mock_auth_url, mock_state) @@ -58,6 +73,33 @@ def test_get_with_redirect_url(self, mock_get_auth_url): self.assertEqual(response.url, mock_auth_url) mock_get_auth_url.assert_called_once_with(redirect_url) + @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") + def test_get_with_redirect_url_json_request(self, mock_get_auth_url): + """Test JSON request with redirect URL""" + mock_auth_url = "https://accounts.google.com/o/oauth2/auth" + mock_state = "test_state" + mock_get_auth_url.return_value = (mock_auth_url, mock_state) + redirect_url = "http://localhost:3000/callback" + + response = self.client.get(f"{self.url}?redirectURL={redirect_url}", HTTP_ACCEPT="application/json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["data"]["authUrl"], mock_auth_url) + self.assertEqual(response.data["data"]["state"], mock_state) + mock_get_auth_url.assert_called_once_with(redirect_url) + + @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") + def test_stores_state_in_session(self, mock_get_auth_url): + """Test that state is stored in session for both request types""" + mock_auth_url = "https://accounts.google.com/o/oauth2/auth" + mock_state = "test_state" + mock_get_auth_url.return_value = (mock_auth_url, mock_state) + + response = self.client.get(self.url, HTTP_ACCEPT="application/json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.client.session.get("oauth_state"), mock_state) + class GoogleCallbackViewTests(APITestCase): def setUp(self): @@ -67,6 +109,8 @@ def setUp(self): self.factory = APIRequestFactory() self.view = GoogleCallbackView.as_view() + self.test_user_data = users_db_data[0] + def test_get_redirects_for_oauth_error(self): error = "access_denied" response = self.client.get(f"{self.url}?error={error}") @@ -78,46 +122,39 @@ def test_get_redirects_for_missing_code(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertIn("error=missing_parameters", response.url) + self.assertIn("error=missing_code", response.url) - def test_get_redirects_for_valid_code_and_state(self): - response = self.client.get(f"{self.url}?code=test_code&state=test_state") + def test_get_redirects_for_missing_state(self): + response = self.client.get(f"{self.url}?code=test_code") self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertIn("code=test_code", response.url) - self.assertIn("state=test_state", response.url) - - def test_post_returns_error_for_missing_code(self): - response = self.client.post(self.url, {}) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["message"], "No authorization code received from Google") - - def test_post_returns_error_for_invalid_state(self): + self.assertIn("error=missing_state", response.url) + def test_get_redirects_for_invalid_state(self): session = self.client.session - session["oauth_state"] = "different_state" + session["oauth_state"] = "correct_state" session.save() - response = self.client.post(self.url, {"code": "test_code", "state": "invalid_state"}) + response = self.client.get(f"{self.url}?code=test_code&state=wrong_state") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["message"], "Invalid state parameter") + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("error=invalid_state", response.url) @patch("todo.services.google_oauth_service.GoogleOAuthService.handle_callback") @patch("todo.services.user_service.UserService.create_or_update_user") - def test_post_handles_callback_successfully(self, mock_create_user, mock_handle_callback): + def test_get_redirects_for_valid_code_and_state(self, mock_create_user, mock_handle_callback): mock_google_data = { - "id": "test_google_id", - "email": "test@example.com", - "name": "Test User", + "id": self.test_user_data["google_id"], + "email": self.test_user_data["email_id"], + "name": self.test_user_data["name"], } + user_id = str(ObjectId()) mock_user = Mock() mock_user.id = ObjectId(user_id) - mock_user.google_id = mock_google_data["id"] - mock_user.email_id = mock_google_data["email"] - mock_user.name = mock_google_data["name"] + mock_user.google_id = self.test_user_data["google_id"] + mock_user.email_id = self.test_user_data["email_id"] + mock_user.name = self.test_user_data["name"] type(mock_user).id = PropertyMock(return_value=ObjectId(user_id)) mock_handle_callback.return_value = mock_google_data @@ -127,54 +164,26 @@ def test_post_handles_callback_successfully(self, mock_create_user, mock_handle_ session["oauth_state"] = "test_state" session.save() - response = self.client.post(self.url, {"code": "test_code", "state": "test_state"}) + response = self.client.get(f"{self.url}?code=test_code&state=test_state") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["data"]["user"]["id"], user_id) - self.assertEqual(response.data["data"]["user"]["name"], mock_user.name) - self.assertEqual(response.data["data"]["user"]["email"], mock_user.email_id) - self.assertEqual(response.data["data"]["user"]["google_id"], mock_user.google_id) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("success=true", response.url) self.assertIn("ext-access", response.cookies) self.assertIn("ext-refresh", response.cookies) self.assertNotIn("oauth_state", self.client.session) + @patch("todo.services.google_oauth_service.GoogleOAuthService.handle_callback") + def test_get_redirects_for_callback_exception(self, mock_handle_callback): + mock_handle_callback.side_effect = Exception("OAuth service error") + session = self.client.session + session["oauth_state"] = "test_state" + session.save() + response = self.client.get(f"{self.url}?code=test_code&state=test_state") - -class GoogleRefreshViewTests(APITestCase): - def setUp(self): - super().setUp() - self.client = APIClient() - self.url = reverse("google_refresh") - - def test_get_returns_401_when_no_refresh_token(self): - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertEqual(response.data["message"], AuthErrorMessages.NO_REFRESH_TOKEN) - self.assertEqual(response.data["authenticated"], False) - self.assertEqual(response.data["statusCode"], status.HTTP_401_UNAUTHORIZED) - - @patch("todo.utils.google_jwt_utils.validate_google_refresh_token") - def test_get_refreshes_token_successfully(self, mock_validate_token): - user_data = { - "user_id": str(ObjectId()), - "google_id": "test_google_id", - "email": "test@example.com", - "name": "Test User", - } - tokens = generate_google_token_pair(user_data) - mock_validate_token.return_value = user_data - - self.client.cookies["ext-refresh"] = tokens["refresh_token"] - - response = self.client.get(self.url, HTTP_ACCEPT="application/json") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["data"]["success"], True) - self.assertEqual(response.data["message"], AppMessages.TOKEN_REFRESHED) - self.assertIn("ext-access", response.cookies) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("error=auth_failed", response.url) class GoogleLogoutViewTests(APITestCase): @@ -183,52 +192,57 @@ def setUp(self): self.client = APIClient() self.url = reverse("google_logout") - def test_get_returns_success_and_clears_cookies(self): - user_data = { - "user_id": str(ObjectId()), - "google_id": "test_google_id", - "email": "test@example.com", - "name": "Test User", - } - tokens = generate_google_token_pair(user_data) - self.client.cookies["ext-access"] = tokens["access_token"] - self.client.cookies["ext-refresh"] = tokens["refresh_token"] - - response = self.client.get(self.url, HTTP_ACCEPT="application/json") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["data"]["success"], True) - self.assertEqual(response.data["message"], AppMessages.GOOGLE_LOGOUT_SUCCESS) - self.assertEqual(response.cookies.get("ext-access").value, "") - self.assertEqual(response.cookies.get("ext-refresh").value, "") - - def test_get_redirects_when_not_json_request(self): + def test_get_returns_json_response(self): redirect_url = "http://localhost:3000" self.client.cookies["ext-access"] = "test_access_token" self.client.cookies["ext-refresh"] = "test_refresh_token" response = self.client.get(f"{self.url}?redirectURL={redirect_url}") - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual(response.url, redirect_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["data"]["success"], True) + self.assertEqual(response.data["message"], AppMessages.GOOGLE_LOGOUT_SUCCESS) self.assertEqual(response.cookies.get("ext-access").value, "") self.assertEqual(response.cookies.get("ext-refresh").value, "") def test_post_returns_success_and_clears_cookies(self): + """Test that POST requests return JSON""" user_data = { "user_id": str(ObjectId()), - "google_id": "test_google_id", - "email": "test@example.com", - "name": "Test User", + "google_id": google_auth_user_payload["google_id"], + "email": google_auth_user_payload["email"], + "name": google_auth_user_payload["name"], } tokens = generate_google_token_pair(user_data) self.client.cookies["ext-access"] = tokens["access_token"] self.client.cookies["ext-refresh"] = tokens["refresh_token"] - response = self.client.post(self.url, HTTP_ACCEPT="application/json") + response = self.client.post(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["data"]["success"], True) self.assertEqual(response.data["message"], AppMessages.GOOGLE_LOGOUT_SUCCESS) self.assertEqual(response.cookies.get("ext-access").value, "") self.assertEqual(response.cookies.get("ext-refresh").value, "") + + def test_logout_clears_session(self): + """Test that logout clears session data""" + session = self.client.session + session["oauth_state"] = "test_state" + session["some_other_data"] = "test_data" + session.save() + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn("oauth_state", self.client.session) + self.assertNotIn("some_other_data", self.client.session) + + def test_logout_clears_sessionid_cookie(self): + """Test that logout clears sessionid cookie""" + self.client.cookies["sessionid"] = "test_session_id" + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.cookies.get("sessionid").value, "") diff --git a/todo/urls.py b/todo/urls.py index a0540986..a098a5b2 100644 --- a/todo/urls.py +++ b/todo/urls.py @@ -5,7 +5,6 @@ from todo.views.auth import ( GoogleLoginView, GoogleCallbackView, - GoogleRefreshView, GoogleLogoutView, ) @@ -16,6 +15,5 @@ path("labels", LabelListView.as_view(), name="labels"), path("auth/google/login/", GoogleLoginView.as_view(), name="google_login"), path("auth/google/callback/", GoogleCallbackView.as_view(), name="google_callback"), - path("auth/google/refresh/", GoogleRefreshView.as_view(), name="google_refresh"), path("auth/google/logout/", GoogleLogoutView.as_view(), name="google_logout"), ] diff --git a/todo/utils/google_jwt_utils.py b/todo/utils/google_jwt_utils.py index 008ba6bf..c4aa4375 100644 --- a/todo/utils/google_jwt_utils.py +++ b/todo/utils/google_jwt_utils.py @@ -8,7 +8,7 @@ GoogleRefreshTokenExpiredError, ) -from todo.constants.messages import AuthErrorMessages, ApiErrors +from todo.constants.messages import AuthErrorMessages def generate_google_access_token(user_data: dict) -> str: @@ -29,13 +29,12 @@ def generate_google_access_token(user_data: dict) -> str: } token = jwt.encode( - payload=payload, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] + payload=payload, key=settings.GOOGLE_JWT["PRIVATE_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] ) - return token - except Exception: - raise GoogleTokenInvalidError(ApiErrors.GOOGLE_API_ERROR) + except Exception as e: + raise GoogleTokenInvalidError(f"Token generation failed: {str(e)}") def generate_google_refresh_token(user_data: dict) -> str: @@ -53,21 +52,20 @@ def generate_google_refresh_token(user_data: dict) -> str: "email": user_data["email"], "token_type": "refresh", } - token = jwt.encode( - payload=payload, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] + payload=payload, key=settings.GOOGLE_JWT["PRIVATE_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] ) return token - except Exception: - raise GoogleTokenInvalidError(ApiErrors.GOOGLE_API_ERROR) + except Exception as e: + raise GoogleTokenInvalidError(f"Refresh token generation failed: {str(e)}") def validate_google_access_token(token: str) -> dict: try: payload = jwt.decode( - jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] + jwt=token, key=settings.GOOGLE_JWT["PUBLIC_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] ) if payload.get("token_type") != "access": @@ -77,16 +75,17 @@ def validate_google_access_token(token: str) -> dict: except jwt.ExpiredSignatureError: raise GoogleTokenExpiredError() - except jwt.InvalidTokenError: - raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + except jwt.InvalidTokenError as e: + raise GoogleTokenInvalidError(f"Invalid token: {str(e)}") + except Exception as e: + raise GoogleTokenInvalidError(f"Token validation failed: {str(e)}") def validate_google_refresh_token(token: str) -> dict: try: payload = jwt.decode( - jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] + jwt=token, key=settings.GOOGLE_JWT["PUBLIC_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] ) - if payload.get("token_type") != "refresh": raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) @@ -94,8 +93,10 @@ def validate_google_refresh_token(token: str) -> dict: except jwt.ExpiredSignatureError: raise GoogleRefreshTokenExpiredError() - except jwt.InvalidTokenError: - raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + except jwt.InvalidTokenError as e: + raise GoogleTokenInvalidError(f"Invalid refresh token: {str(e)}") + except Exception as e: + raise GoogleTokenInvalidError(f"Refresh token validation failed: {str(e)}") def generate_google_token_pair(user_data: dict) -> dict: diff --git a/todo/views/auth.py b/todo/views/auth.py index 3015c5bc..5995745e 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -8,19 +8,8 @@ from drf_spectacular.types import OpenApiTypes from todo.services.google_oauth_service import GoogleOAuthService from todo.services.user_service import UserService -from todo.utils.google_jwt_utils import ( - validate_google_refresh_token, - generate_google_access_token, - generate_google_token_pair, -) - -from todo.constants.messages import AuthErrorMessages, AppMessages -from todo.exceptions.google_auth_exceptions import ( - GoogleAuthException, - GoogleTokenExpiredError, - GoogleTokenMissingError, - GoogleAPIException, -) +from todo.utils.google_jwt_utils import generate_google_token_pair +from todo.constants.messages import AppMessages class GoogleLoginView(APIView): @@ -107,36 +96,22 @@ def get(self, request: Request): state = request.query_params.get("state") error = request.query_params.get("error") - frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" - if error: + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" return HttpResponseRedirect(f"{frontend_callback}?error={error}") - elif code and state: - return HttpResponseRedirect(f"{frontend_callback}?code={code}&state={state}") - else: - return HttpResponseRedirect(f"{frontend_callback}?error=missing_parameters") - - @extend_schema( - operation_id="google_callback_post", - summary="Handle Google OAuth callback (POST)", - description="Processes the OAuth callback from Google via POST request", - tags=["auth"], - responses={ - 200: OpenApiResponse(description="OAuth callback processed successfully"), - 400: OpenApiResponse(description="Bad request - invalid parameters"), - 500: OpenApiResponse(description="Internal server error"), - }, - ) - def post(self, request: Request): - code = request.data.get("code") - state = request.data.get("state") if not code: - raise GoogleAuthException("No authorization code received from Google") + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + return HttpResponseRedirect(f"{frontend_callback}?error=missing_code") + + if not state: + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + return HttpResponseRedirect(f"{frontend_callback}?error=missing_state") stored_state = request.session.get("oauth_state") if not stored_state or stored_state != state: - raise GoogleAuthException("Invalid state parameter") + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + return HttpResponseRedirect(f"{frontend_callback}?error=invalid_state") try: google_data = GoogleOAuthService.handle_callback(code) @@ -151,31 +126,17 @@ def post(self, request: Request): } ) - response = Response( - { - "statusCode": status.HTTP_200_OK, - "message": AppMessages.GOOGLE_LOGIN_SUCCESS, - "data": { - "user": { - "id": str(user.id), - "name": user.name, - "email": user.email_id, - "google_id": user.google_id, - }, - "tokens": { - "access_token_expires_in": tokens["expires_in"], - "refresh_token_expires_in": settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], - }, - }, - } - ) + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + response = HttpResponseRedirect(f"{frontend_callback}?success=true") self._set_auth_cookies(response, tokens) request.session.pop("oauth_state", None) return response - except Exception as e: - raise GoogleAPIException(str(e)) + + except Exception: + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + return HttpResponseRedirect(f"{frontend_callback}?error=auth_failed") def _get_cookie_config(self): return { @@ -194,57 +155,6 @@ def _set_auth_cookies(self, response, tokens): ) -class GoogleRefreshView(APIView): - @extend_schema( - operation_id="google_refresh_token", - summary="Refresh access token", - description="Refresh the access token using the refresh token from cookies", - tags=["auth"], - responses={ - 200: OpenApiResponse(description="Token refreshed successfully"), - 401: OpenApiResponse(description="Unauthorized - invalid or missing refresh token"), - 500: OpenApiResponse(description="Internal server error"), - }, - ) - def get(self, request: Request): - refresh_token = request.COOKIES.get("ext-refresh") - - if not refresh_token: - raise GoogleTokenMissingError(AuthErrorMessages.NO_REFRESH_TOKEN) - - try: - payload = validate_google_refresh_token(refresh_token) - user_data = { - "user_id": payload["user_id"], - "google_id": payload["google_id"], - "email": payload["email"], - "name": payload.get("name", ""), - } - new_access_token = generate_google_access_token(user_data) - - response = Response( - {"statusCode": status.HTTP_200_OK, "message": AppMessages.TOKEN_REFRESHED, "data": {"success": True}} - ) - - config = self._get_cookie_config() - response.set_cookie( - "ext-access", new_access_token, max_age=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"], **config - ) - - return response - except Exception as e: - raise GoogleTokenExpiredError(str(e)) - - def _get_cookie_config(self): - return { - "path": "/", - "domain": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN"), - "secure": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_SECURE", False), - "httponly": True, - "samesite": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_SAMESITE", "Lax"), - } - - class GoogleLogoutView(APIView): @extend_schema( operation_id="google_logout", @@ -288,28 +198,17 @@ def post(self, request: Request): return self._handle_logout(request) def _handle_logout(self, request: Request): - redirect_url = request.query_params.get("redirectURL") + request.session.flush() - wants_json = ( - "application/json" in request.headers.get("Accept", "").lower() - or request.query_params.get("format") == "json" - or request.method == "POST" + response = Response( + { + "statusCode": status.HTTP_200_OK, + "message": AppMessages.GOOGLE_LOGOUT_SUCCESS, + "data": {"success": True}, + } ) - if wants_json: - response = Response( - { - "statusCode": status.HTTP_200_OK, - "message": AppMessages.GOOGLE_LOGOUT_SUCCESS, - "data": {"success": True}, - } - ) - else: - redirect_url = redirect_url or "/" - response = HttpResponseRedirect(redirect_url) - self._clear_auth_cookies(response) - return response def _get_cookie_config(self): @@ -322,10 +221,15 @@ def _get_cookie_config(self): } def _clear_auth_cookies(self, response): - """Clear authentication cookies with only the parameters that delete_cookie accepts""" delete_config = { "path": "/", "domain": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN"), } response.delete_cookie("ext-access", **delete_config) response.delete_cookie("ext-refresh", **delete_config) + + session_delete_config = { + "path": getattr(settings, "SESSION_COOKIE_PATH", "/"), + "domain": getattr(settings, "SESSION_COOKIE_DOMAIN", None), + } + response.delete_cookie("sessionid", **session_delete_config) diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index f01dbf1c..af1be5c8 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -119,12 +120,25 @@ "SCOPES": ["openid", "email", "profile"], } -GOOGLE_JWT = { - "ALGORITHM": "HS256", - "SECRET_KEY": os.getenv("GOOGLE_JWT_SECRET_KEY"), - "ACCESS_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_ACCESS_LIFETIME", "3600")), - "REFRESH_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_REFRESH_LIFETIME", "604800")), -} +TESTING = "test" in sys.argv or "pytest" in sys.modules or os.getenv("TESTING") == "True" + +if TESTING: + # Test JWT configuration (HS256 - simpler for tests) + GOOGLE_JWT = { + "ALGORITHM": "HS256", + "PRIVATE_KEY": "test-secret-key-for-jwt-signing-very-long-key-needed-for-security", + "PUBLIC_KEY": "test-secret-key-for-jwt-signing-very-long-key-needed-for-security", + "ACCESS_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_ACCESS_LIFETIME", "3600")), + "REFRESH_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_REFRESH_LIFETIME", "604800")), + } +else: + GOOGLE_JWT = { + "ALGORITHM": "RS256", + "PRIVATE_KEY": os.getenv("GOOGLE_JWT_PRIVATE_KEY"), + "PUBLIC_KEY": os.getenv("GOOGLE_JWT_PUBLIC_KEY"), + "ACCESS_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_ACCESS_LIFETIME", "3600")), + "REFRESH_TOKEN_LIFETIME": int(os.getenv("GOOGLE_JWT_REFRESH_LIFETIME", "604800")), + } GOOGLE_COOKIE_SETTINGS = { "ACCESS_COOKIE_NAME": os.getenv("GOOGLE_ACCESS_COOKIE_NAME", "ext-access"), diff --git a/todo_project/settings/development.py b/todo_project/settings/development.py index c4f6ac07..68632fbf 100644 --- a/todo_project/settings/development.py +++ b/todo_project/settings/development.py @@ -6,9 +6,9 @@ # Service ports configuration SERVICE_PORTS = { - "BACKEND": 3000, + "BACKEND": 8087, "AUTH": 8000, - "FRONTEND": 4000, + "FRONTEND": 3000, } # Base URL configuration