From 44b64108226f2e249299d5d7872a6b26344ab2bc Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 2 Jun 2025 01:30:25 +0530 Subject: [PATCH 01/17] feat(auth): Integrated RDS Auth --- .env.example | 4 ++- .github/workflows/test.yml | 8 +++-- pyproject.toml | 3 ++ requirements.txt | 3 ++ todo/auth/jwt_utils.py | 48 ++++++++++++++++++++++++++++ todo/constants/messages.py | 10 ++++++ todo/exceptions/auth_exceptions.py | 19 +++++++++++ todo/exceptions/exception_handler.py | 38 ++++++++++++++++++++-- todo/middlewares/jwt_auth.py | 41 ++++++++++++++++++++++++ todo_project/settings/base.py | 44 +++++++++++++++++++++++-- todo_project/settings/configure.py | 4 +++ todo_project/settings/development.py | 27 ++++++++++++++-- todo_project/settings/staging.py | 33 +++++++++++++++++++ 13 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 todo/auth/jwt_utils.py create mode 100644 todo/exceptions/auth_exceptions.py create mode 100644 todo/middlewares/jwt_auth.py create mode 100644 todo_project/settings/staging.py diff --git a/.env.example b/.env.example index ecfce9e7..9d4c25af 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ ENV='DEVELOPMENT' SECRET_KEY='unique-secret' ALLOWED_HOSTS='localhost,127.0.0.1' MONGODB_URI='mongodb://localhost:27017' -DB_NAME='todo-app' \ No newline at end of file +DB_NAME='todo-app' +RDS_BACKEND_BASE_URL= 'http://localhost:3000' +RDS_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCaca/R8T5zYYPdwpp28Kr2R7M6\nbhUU7b1csF9rSSsLdUeNYfwTbaOSBUJeuY6DJtOX9hmVNHarr8C4Kykl2HvAV6pF\nKJnHqTs8PFz1pEusNAbPi0aweVzsPmeNQs2XXBCv/qVGy25Ew3itqCwazvbXXudI\nbUeESPW19nqfdciiwQIDAQAB\n-----END PUBLIC KEY-----" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5abf49cd..cba4e1d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,12 +7,14 @@ on: jobs: build: runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.title, '[skip tests]') }} + services: db: image: mongo:latest ports: - 27017:27017 - + env: MONGODB_URI: mongodb://db:27017 DB_NAME: todo-app @@ -24,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11.*' + python-version: "3.11.*" - name: Install dependencies run: | @@ -36,4 +38,4 @@ jobs: - name: Run tests run: | - python3.11 manage.py test \ No newline at end of file + python3.11 manage.py test diff --git a/pyproject.toml b/pyproject.toml index 1568474e..12cf6c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ ignore = [] fixable = ["ALL"] unfixable = [] +[tool.ruff.lint.per-file-ignores] +"todo_project/settings/*.py" = ["F403", "F405"] + [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" diff --git a/requirements.txt b/requirements.txt index 01636e96..c7e1ace5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ ruff==0.7.1 sqlparse==0.5.1 typing_extensions==4.12.2 virtualenv==20.27.0 +django-cors-headers==4.7.0 +cryptography==45.0.3 +PyJWT==2.10.1 \ No newline at end of file diff --git a/todo/auth/jwt_utils.py b/todo/auth/jwt_utils.py new file mode 100644 index 00000000..87b233ee --- /dev/null +++ b/todo/auth/jwt_utils.py @@ -0,0 +1,48 @@ +import jwt +from django.conf import settings +from todo.exceptions.auth_exceptions import TokenExpiredError, TokenInvalidError + + +def verify_jwt_token(token: str) -> dict: + """ + Verify and decode the JWT token using the RSA public key. + + Args: + token (str): The JWT token to verify + + Returns: + dict: The decoded token payload + + Raises: + TokenExpiredError: If token has expired + TokenInvalidError: If token is invalid + """ + if not token or not token.strip(): + raise TokenInvalidError() + + try: + public_key = settings.JWT_AUTH["PUBLIC_KEY"] + algorithm = settings.JWT_AUTH["ALGORITHM"] + + if not public_key: + raise TokenInvalidError() + + payload = jwt.decode( + token, + public_key, + algorithms=[algorithm], + options={"verify_signature": True, "verify_exp": True, "require": ["exp", "iat", "userId", "role"]}, + ) + + required_fields = ["userId", "role"] + for field in required_fields: + if not payload.get(field): + raise TokenInvalidError() + return payload + + except jwt.ExpiredSignatureError: + raise TokenExpiredError() + except jwt.InvalidTokenError: + raise TokenInvalidError() + except Exception: + raise TokenInvalidError() diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 47f2fc3c..e7e87423 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -37,3 +37,13 @@ class ValidationErrors: INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format." FUTURE_STARTED_AT = "The start date cannot be set in the future." INVALID_LABELS_STRUCTURE = "Labels must be provided as a list or tuple of ObjectId strings." + + +# Auth messages +class AuthErrorMessages: + TOKEN_MISSING = "Authentication token is required" + TOKEN_EXPIRED = "Authentication token has expired" + TOKEN_INVALID = "Invalid authentication token" + AUTHENTICATION_REQUIRED = "Authentication required" + TOKEN_EXPIRED_TITLE = "Token Expired" + INVALID_TOKEN_TITLE = "Invalid Token" diff --git a/todo/exceptions/auth_exceptions.py b/todo/exceptions/auth_exceptions.py new file mode 100644 index 00000000..dd245cdf --- /dev/null +++ b/todo/exceptions/auth_exceptions.py @@ -0,0 +1,19 @@ +from todo.constants.messages import AuthErrorMessages + + +class TokenMissingError(Exception): + def __init__(self, message: str = AuthErrorMessages.TOKEN_MISSING): + self.message = message + super().__init__(self.message) + + +class TokenExpiredError(Exception): + def __init__(self, message: str = AuthErrorMessages.TOKEN_EXPIRED): + self.message = message + super().__init__(self.message) + + +class TokenInvalidError(Exception): + def __init__(self, message: str = AuthErrorMessages.TOKEN_INVALID): + self.message = message + super().__init__(self.message) diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index 760800af..a105d9af 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -8,8 +8,9 @@ from bson.errors import InvalidId as BsonInvalidId from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorResponse, ApiErrorSource -from todo.constants.messages import ApiErrors, ValidationErrors +from todo.constants.messages import ApiErrors, ValidationErrors, AuthErrorMessages from todo.exceptions.task_exceptions import TaskNotFoundException +from .auth_exceptions import TokenExpiredError, TokenMissingError, TokenInvalidError def format_validation_errors(errors) -> List[ApiErrorDetail]: @@ -39,7 +40,40 @@ def handle_exception(exc, context): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR determined_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED - if isinstance(exc, TaskNotFoundException): + if isinstance(exc, TokenExpiredError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, + detail=detail_message_str, + ) + ) + elif isinstance(exc, TokenMissingError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=detail_message_str, + ) + ) + elif isinstance(exc, TokenInvalidError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=detail_message_str, + ) + ) + elif isinstance(exc, TaskNotFoundException): status_code = status.HTTP_404_NOT_FOUND detail_message_str = str(exc) determined_message = detail_message_str diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py new file mode 100644 index 00000000..b4c34361 --- /dev/null +++ b/todo/middlewares/jwt_auth.py @@ -0,0 +1,41 @@ +from django.conf import settings +from todo.auth.jwt_utils import verify_jwt_token +from todo.exceptions.auth_exceptions import TokenMissingError, TokenExpiredError, TokenInvalidError + + +class JWTAuthenticationMiddleware: + def __init__(self, get_response) -> None: + self.get_response = get_response + self.cookie_name = settings.JWT_COOKIE_SETTINGS["RDS_SESSION_V2_COOKIE_NAME"] + + def __call__(self, request): + path = request.path + + if self._is_public_path(path): + return self.get_response(request) + + try: + token = self._extract_token(request) + + if not token: + raise TokenMissingError() + + payload = verify_jwt_token(token) + + request.user_id = payload["userId"] + request.user_role = payload["role"] + + return self.get_response(request) + + except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: + raise e + except Exception: + raise TokenInvalidError() + + def _is_public_path(self, path: str) -> bool: + is_public = any(path.startswith(public_path) for public_path in settings.PUBLIC_PATHS) + return is_public + + def _extract_token(self, request) -> str | None: + token = request.COOKIES.get(self.cookie_name) + return token diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index 91accee1..d81d67b9 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -17,9 +17,10 @@ MONGODB_URI = os.getenv("MONGODB_URI") DB_NAME = os.getenv("DB_NAME") -# Application definition +# Application definition INSTALLED_APPS = [ + "corsheaders", "rest_framework", "todo", ] @@ -29,13 +30,12 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "todo.middlewares.jwt_auth.JWTAuthenticationMiddleware", ] ROOT_URLCONF = "todo_project.urls" - WSGI_APPLICATION = "todo_project.wsgi.application" - LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" @@ -54,6 +54,37 @@ "DEFAULT_PAGE_LIMIT": 20, "MAX_PAGE_LIMIT": 200, }, + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], +} + +# JWT Verification Settings + +JWT_AUTH = { + "ALGORITHM": "RS256", + "PUBLIC_KEY": os.getenv("RDS_PUBLIC_KEY"), +} + +# Cookie Settings + +JWT_COOKIE_SETTINGS = { + "RDS_SESSION_COOKIE_NAME": os.getenv("RDS_SESSION_COOKIE_NAME", "rds-session-development"), + "RDS_SESSION_V2_COOKIE_NAME": os.getenv("RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development"), + "COOKIE_DOMAIN": os.getenv("COOKIE_DOMAIN", None), + "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "False").lower() == "true", + "COOKIE_HTTPONLY": True, + "COOKIE_SAMESITE": os.getenv("COOKIE_SAMESITE", "None"), + "COOKIE_PATH": "/", +} + +# RDS Backend Integration +MAIN_APP = { + "RDS_BACKEND_BASE_URL": os.getenv("RDS_BACKEND_BASE_URL", "http://localhost:3000"), } DATABASES = { @@ -62,3 +93,10 @@ "NAME": BASE_DIR / "db.sqlite3", } } + +PUBLIC_PATHS = [ + "/favicon.ico", + "/v1/health", + "/api/docs", + "/static/", +] diff --git a/todo_project/settings/configure.py b/todo_project/settings/configure.py index 91765314..85352e68 100644 --- a/todo_project/settings/configure.py +++ b/todo_project/settings/configure.py @@ -6,8 +6,10 @@ ENV_VAR_NAME = "ENV" PRODUCTION = "PRODUCTION" DEVELOPMENT = "DEVELOPMENT" +STAGING = "STAGING" PRODUCTION_SETTINGS = "todo_project.settings.production" DEVELOPMENT_SETTINGS = "todo_project.settings.development" +STAGING_SETTINGS = "todo_project.settings.staging" DEFAULT_SETTINGS = DEVELOPMENT_SETTINGS @@ -18,5 +20,7 @@ def configure_settings_module(): if env == PRODUCTION: django_settings_module = PRODUCTION_SETTINGS + elif env == STAGING: + django_settings_module = STAGING_SETTINGS os.environ.setdefault("DJANGO_SETTINGS_MODULE", django_settings_module) diff --git a/todo_project/settings/development.py b/todo_project/settings/development.py index aa4ee915..0bb85a49 100644 --- a/todo_project/settings/development.py +++ b/todo_project/settings/development.py @@ -1,2 +1,25 @@ -# Add settings for development environment here -from .base import * # noqa: F403 +# Development specific settings +from .base import * + +DEBUG = True +ALLOWED_HOSTS = ["*"] + +JWT_COOKIE_SETTINGS.update( + { + "RDS_SESSION_COOKIE_NAME": "rds-session-development", + "RDS_SESSION_V2_COOKIE_NAME": "rds-session-v2-development", + "COOKIE_SECURE": False, + } +) + +MAIN_APP.update( + { + "RDS_BACKEND_BASE_URL": "http://localhost:3000", + } +) + +# CORS middleware for development +MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") + +CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_CREDENTIALS = True diff --git a/todo_project/settings/staging.py b/todo_project/settings/staging.py new file mode 100644 index 00000000..b778d9b7 --- /dev/null +++ b/todo_project/settings/staging.py @@ -0,0 +1,33 @@ +# Staging specific settings +from .base import * + +DEBUG = False +ALLOWED_HOSTS = ["staging-api.realdevsquad.com", "services.realdevsquad.com"] + +JWT_COOKIE_SETTINGS.update( + { + "RDS_SESSION_COOKIE_NAME": "rds-session-staging", + "RDS_SESSION_V2_COOKIE_NAME": "rds-session-v2-staging", + "COOKIE_DOMAIN": ".realdevsquad.com", + "COOKIE_SECURE": True, + } +) + +MAIN_APP.update( + { + "RDS_BACKEND_BASE_URL": "https://staging-api.realdevsquad.com", + } +) + +# Staging CORS settings +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = [ + "https://staging-todo.realdevsquad.com", +] + +# Security settings for staging +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") From 13051de976ffcacd5403a9d11c841eeb390460a8 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 2 Jun 2025 01:30:25 +0530 Subject: [PATCH 02/17] feat(auth): Integrated RDS Auth --- todo/constants/messages.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/todo/constants/messages.py b/todo/constants/messages.py index e7e87423..76eb61a1 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -47,3 +47,13 @@ class AuthErrorMessages: AUTHENTICATION_REQUIRED = "Authentication required" TOKEN_EXPIRED_TITLE = "Token Expired" INVALID_TOKEN_TITLE = "Invalid Token" + + +# Auth messages +class AuthErrorMessages: + TOKEN_MISSING = "Authentication token is required" + TOKEN_EXPIRED = "Authentication token has expired" + TOKEN_INVALID = "Invalid authentication token" + AUTHENTICATION_REQUIRED = "Authentication required" + TOKEN_EXPIRED_TITLE = "Token Expired" + INVALID_TOKEN_TITLE = "Invalid Token" From 54418c5b47d4ac208d66074ac130e46f94287fc6 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Thu, 5 Jun 2025 10:06:09 +0530 Subject: [PATCH 03/17] fix: env.example --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 9d4c25af..87b60ad8 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,6 @@ ENV='DEVELOPMENT' SECRET_KEY='unique-secret' ALLOWED_HOSTS='localhost,127.0.0.1' MONGODB_URI='mongodb://localhost:27017' -DB_NAME='todo-app' +DB_NAME='db-name' RDS_BACKEND_BASE_URL= 'http://localhost:3000' -RDS_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCaca/R8T5zYYPdwpp28Kr2R7M6\nbhUU7b1csF9rSSsLdUeNYfwTbaOSBUJeuY6DJtOX9hmVNHarr8C4Kykl2HvAV6pF\nKJnHqTs8PFz1pEusNAbPi0aweVzsPmeNQs2XXBCv/qVGy25Ew3itqCwazvbXXudI\nbUeESPW19nqfdciiwQIDAQAB\n-----END PUBLIC KEY-----" \ No newline at end of file +RDS_PUBLIC_KEY="public-key-here" \ No newline at end of file From 500f0b646241e7c2949cecb4d5ff5f956864d585 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Thu, 5 Jun 2025 10:16:57 +0530 Subject: [PATCH 04/17] refactor: changes based on ai pr review --- .env.example | 2 +- todo/auth/jwt_utils.py | 4 ---- todo_project/settings/base.py | 8 ++++++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 87b60ad8..8ec32914 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,5 @@ SECRET_KEY='unique-secret' ALLOWED_HOSTS='localhost,127.0.0.1' MONGODB_URI='mongodb://localhost:27017' DB_NAME='db-name' -RDS_BACKEND_BASE_URL= 'http://localhost:3000' +RDS_BACKEND_BASE_URL='http://localhost:3000' RDS_PUBLIC_KEY="public-key-here" \ No newline at end of file diff --git a/todo/auth/jwt_utils.py b/todo/auth/jwt_utils.py index 87b233ee..9ac41fb3 100644 --- a/todo/auth/jwt_utils.py +++ b/todo/auth/jwt_utils.py @@ -34,10 +34,6 @@ def verify_jwt_token(token: str) -> dict: options={"verify_signature": True, "verify_exp": True, "require": ["exp", "iat", "userId", "role"]}, ) - required_fields = ["userId", "role"] - for field in required_fields: - if not payload.get(field): - raise TokenInvalidError() return payload except jwt.ExpiredSignatureError: diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index d81d67b9..b8d3b6f3 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -73,8 +73,12 @@ # Cookie Settings JWT_COOKIE_SETTINGS = { - "RDS_SESSION_COOKIE_NAME": os.getenv("RDS_SESSION_COOKIE_NAME", "rds-session-development"), - "RDS_SESSION_V2_COOKIE_NAME": os.getenv("RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development"), + "RDS_SESSION_COOKIE_NAME": os.getenv( + "RDS_SESSION_COOKIE_NAME", "rds-session-development" + ), + "RDS_SESSION_V2_COOKIE_NAME": os.getenv( + "RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development" + ), "COOKIE_DOMAIN": os.getenv("COOKIE_DOMAIN", None), "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "False").lower() == "true", "COOKIE_HTTPONLY": True, From 90603698cd8d4535671b5fbb3c606e9c1184055d Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 9 Jun 2025 18:36:58 +0530 Subject: [PATCH 05/17] feat(auth): Implemented google authentication for todo-app --- .env.example | 6 +- requirements.txt | 3 +- todo/constants/messages.py | 35 ++ todo/exceptions/exception_handler.py | 74 ++++ todo/exceptions/google_auth_exceptions.py | 37 ++ todo/middlewares/jwt_auth.py | 125 ++++++- todo/models/user.py | 20 ++ todo/repositories/user_repository.py | 56 +++ todo/services/google_oauth_service.py | 102 ++++++ todo/services/user_service.py | 40 +++ todo/urls.py | 13 +- todo/utils/google_jwt_utils.py | 125 +++++++ todo/{auth => utils}/jwt_utils.py | 0 todo/views/auth.py | 415 +++++++++++++++++++++ todo/views/auth2.py | 420 ++++++++++++++++++++++ todo_project/settings/base.py | 58 ++- todo_project/settings/development.py | 31 ++ todo_project/settings/staging.py | 35 +- 18 files changed, 1566 insertions(+), 29 deletions(-) create mode 100644 todo/exceptions/google_auth_exceptions.py create mode 100644 todo/models/user.py create mode 100644 todo/repositories/user_repository.py create mode 100644 todo/services/google_oauth_service.py create mode 100644 todo/services/user_service.py create mode 100644 todo/utils/google_jwt_utils.py rename todo/{auth => utils}/jwt_utils.py (100%) create mode 100644 todo/views/auth.py create mode 100644 todo/views/auth2.py diff --git a/.env.example b/.env.example index 8ec32914..923c824f 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,8 @@ ALLOWED_HOSTS='localhost,127.0.0.1' MONGODB_URI='mongodb://localhost:27017' DB_NAME='db-name' RDS_BACKEND_BASE_URL='http://localhost:3000' -RDS_PUBLIC_KEY="public-key-here" \ No newline at end of file +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 diff --git a/requirements.txt b/requirements.txt index c7e1ace5..1bf46481 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,4 +22,5 @@ typing_extensions==4.12.2 virtualenv==20.27.0 django-cors-headers==4.7.0 cryptography==45.0.3 -PyJWT==2.10.1 \ No newline at end of file +PyJWT==2.10.1 +requests==2.32.3 \ No newline at end of file diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 76eb61a1..2681a3bb 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -1,12 +1,18 @@ # Application Messages class AppMessages: TASK_CREATED = "Task created successfully" + GOOGLE_LOGIN_SUCCESS = "Successfully logged in with Google" + GOOGLE_LOGOUT_SUCCESS = "Successfully logged out" + TOKEN_REFRESHED = "Access token refreshed successfully" # Repository error messages class RepositoryErrors: TASK_CREATION_FAILED = "Failed to create task: {0}" DB_INIT_FAILED = "Failed to initialize database: {0}" + USER_NOT_FOUND = "User not found: {0}" + USER_OPERATION_FAILED = "User operation failed" + USER_CREATE_UPDATE_FAILED = "User create/update failed: {0}" # API error messages @@ -23,6 +29,17 @@ class ApiErrors: TASK_NOT_FOUND = "Task with ID {0} not found." TASK_NOT_FOUND_GENERIC = "Task not found." RESOURCE_NOT_FOUND_TITLE = "Resource Not Found" + GOOGLE_AUTH_FAILED = "Google authentication failed" + GOOGLE_API_ERROR = "Google API error" + INVALID_AUTH_CODE = "Invalid authorization code" + TOKEN_EXCHANGE_FAILED = "Failed to exchange authorization code" + MISSING_USER_INFO_FIELDS = "Missing user info fields: {0}" + USER_INFO_FETCH_FAILED = "Failed to get user info: {0}" + OAUTH_INITIALIZATION_FAILED = "OAuth initialization failed: {0}" + AUTHENTICATION_FAILED = "Authentication failed: {0}" + INVALID_STATE_PARAMETER = "Invalid state parameter" + TOKEN_REFRESH_FAILED = "Token refresh failed: {0}" + LOGOUT_FAILED = "Logout failed: {0}" # Validation error messages @@ -37,6 +54,9 @@ class ValidationErrors: INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format." FUTURE_STARTED_AT = "The start date cannot be set in the future." INVALID_LABELS_STRUCTURE = "Labels must be provided as a list or tuple of ObjectId strings." + MISSING_GOOGLE_ID = "Google ID is required" + MISSING_EMAIL = "Email is required" + MISSING_NAME = "Name is required" # Auth messages @@ -57,3 +77,18 @@ class AuthErrorMessages: AUTHENTICATION_REQUIRED = "Authentication required" TOKEN_EXPIRED_TITLE = "Token Expired" INVALID_TOKEN_TITLE = "Invalid Token" + +# Auth messages +class AuthErrorMessages: + TOKEN_MISSING = "Authentication token is required" + TOKEN_EXPIRED = "Authentication token has expired" + TOKEN_INVALID = "Invalid authentication token" + AUTHENTICATION_REQUIRED = "Authentication required" + TOKEN_EXPIRED_TITLE = "Token Expired" + INVALID_TOKEN_TITLE = "Invalid Token" + GOOGLE_TOKEN_EXPIRED = "Google access token has expired" + GOOGLE_REFRESH_TOKEN_EXPIRED = "Google refresh token has expired, please login again" + GOOGLE_TOKEN_INVALID = "Invalid Google token" + MISSING_REQUIRED_PARAMETER = "Missing required parameter: {0}" + NO_ACCESS_TOKEN = "No access token" + NO_REFRESH_TOKEN = "No refresh token found" diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index a105d9af..81144d5c 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -11,6 +11,14 @@ from todo.constants.messages import ApiErrors, ValidationErrors, AuthErrorMessages from todo.exceptions.task_exceptions import TaskNotFoundException from .auth_exceptions import TokenExpiredError, TokenMissingError, TokenInvalidError +from .google_auth_exceptions import ( + GoogleAuthException, + GoogleTokenExpiredError, + GoogleTokenInvalidError, + GoogleRefreshTokenExpiredError, + GoogleAPIException, + GoogleUserNotFoundException, +) def format_validation_errors(errors) -> List[ApiErrorDetail]: @@ -73,6 +81,72 @@ def handle_exception(exc, context): detail=detail_message_str, ) ) + elif isinstance(exc, GoogleTokenExpiredError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, + detail=detail_message_str, + ) + ) + elif isinstance(exc, GoogleTokenInvalidError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=detail_message_str, + ) + ) + elif isinstance(exc, GoogleRefreshTokenExpiredError): + status_code = status.HTTP_401_UNAUTHORIZED + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, + detail=detail_message_str, + ) + ) + elif isinstance(exc, GoogleAuthException): + status_code = status.HTTP_400_BAD_REQUEST + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_auth"}, + title=ApiErrors.GOOGLE_AUTH_FAILED, + detail=detail_message_str, + ) + ) + elif isinstance(exc, GoogleAPIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_api"}, + title=ApiErrors.GOOGLE_API_ERROR, + detail=detail_message_str, + ) + ) + elif isinstance(exc, GoogleUserNotFoundException): + status_code = status.HTTP_404_NOT_FOUND + detail_message_str = str(exc) + determined_message = detail_message_str + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "user_id"}, + title=ApiErrors.RESOURCE_NOT_FOUND_TITLE, + detail=detail_message_str, + ) + ) elif isinstance(exc, TaskNotFoundException): status_code = status.HTTP_404_NOT_FOUND detail_message_str = str(exc) diff --git a/todo/exceptions/google_auth_exceptions.py b/todo/exceptions/google_auth_exceptions.py new file mode 100644 index 00000000..e5b87a79 --- /dev/null +++ b/todo/exceptions/google_auth_exceptions.py @@ -0,0 +1,37 @@ +from todo.constants.messages import AuthErrorMessages, ApiErrors, RepositoryErrors + + +class GoogleAuthException(Exception): + def __init__(self, message: str = ApiErrors.GOOGLE_AUTH_FAILED): + self.message = message + super().__init__(self.message) + + +class GoogleTokenExpiredError(Exception): + def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_EXPIRED): + self.message = message + super().__init__(self.message) + + +class GoogleTokenInvalidError(Exception): + def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_INVALID): + self.message = message + super().__init__(self.message) + + +class GoogleRefreshTokenExpiredError(Exception): + def __init__(self, message: str = AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED): + self.message = message + super().__init__(self.message) + + +class GoogleAPIException(Exception): + def __init__(self, message: str = ApiErrors.GOOGLE_API_ERROR): + self.message = message + super().__init__(self.message) + + +class GoogleUserNotFoundException(Exception): + def __init__(self, message: str = RepositoryErrors.USER_NOT_FOUND): + self.message = message + super().__init__(self.message) diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index b4c34361..881d044d 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -1,12 +1,18 @@ from django.conf import settings -from todo.auth.jwt_utils import verify_jwt_token +from django.http import JsonResponse +from rest_framework import status + +from todo.utils.jwt_utils import verify_jwt_token +from todo.utils.google_jwt_utils import validate_google_access_token from todo.exceptions.auth_exceptions import TokenMissingError, TokenExpiredError, TokenInvalidError +from todo.exceptions.google_auth_exceptions import GoogleTokenExpiredError, GoogleTokenInvalidError class JWTAuthenticationMiddleware: def __init__(self, get_response) -> None: self.get_response = get_response - self.cookie_name = settings.JWT_COOKIE_SETTINGS["RDS_SESSION_V2_COOKIE_NAME"] + + self.rds_cookie_name = settings.JWT_COOKIE_SETTINGS["RDS_SESSION_V2_COOKIE_NAME"] def __call__(self, request): path = request.path @@ -15,27 +21,120 @@ def __call__(self, request): return self.get_response(request) try: - token = self._extract_token(request) + auth_success = self._try_authentication(request) + + if auth_success: + return self.get_response(request) + else: + return self._unauthorized_response("Authentication required") + + except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: + return self._handle_rds_auth_error(e) + except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: + return self._handle_google_auth_error(e) + except Exception: + return self._unauthorized_response("Authentication failed") + + def _try_authentication(self, request) -> bool: + if self._try_google_auth(request): + return True + + if self._try_rds_auth(request): + return True + + return False + + def _try_google_auth(self, request) -> bool: + try: + google_token = request.COOKIES.get("ext-access") + + if not google_token: + return False + + payload = validate_google_access_token(google_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" + + return True + + except (GoogleTokenExpiredError, GoogleTokenInvalidError): + return False + except Exception: + return False + + def _try_rds_auth(self, request) -> bool: + try: + rds_token = request.COOKIES.get(self.rds_cookie_name) - if not token: - raise TokenMissingError() + if not rds_token: + return False - payload = verify_jwt_token(token) + payload = verify_jwt_token(rds_token) + request.auth_type = "rds" request.user_id = payload["userId"] request.user_role = payload["role"] - return self.get_response(request) + return True except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: raise e except Exception: - raise TokenInvalidError() + return False def _is_public_path(self, path: str) -> bool: - is_public = any(path.startswith(public_path) for public_path in settings.PUBLIC_PATHS) - return is_public + return any(path.startswith(public_path) for public_path in settings.PUBLIC_PATHS) + + def _handle_rds_auth_error(self, exception): + return JsonResponse( + {"error": True, "message": str(exception), "auth_type": "rds"}, status=status.HTTP_401_UNAUTHORIZED + ) + + def _handle_google_auth_error(self, exception): + return JsonResponse( + {"error": True, "message": str(exception), "auth_type": "google"}, status=status.HTTP_401_UNAUTHORIZED + ) + + def _unauthorized_response(self, message: str): + return JsonResponse({"error": True, "message": message}, status=status.HTTP_401_UNAUTHORIZED) + + +def is_google_user(request) -> bool: + return getattr(request, "auth_type", None) == "google" + + +def is_rds_user(request) -> bool: + return getattr(request, "auth_type", None) == "rds" + + +def get_current_user_info(request) -> dict: + if not hasattr(request, "user_id"): + return None + + user_info = { + "user_id": request.user_id, + "auth_type": getattr(request, "auth_type", "unknown"), + } + + if is_google_user(request): + user_info.update( + { + "google_id": getattr(request, "google_id", None), + "email": getattr(request, "user_email", None), + "name": getattr(request, "user_name", None), + } + ) + + if is_rds_user(request): + user_info.update( + { + "role": getattr(request, "user_role", None), + } + ) - def _extract_token(self, request) -> str | None: - token = request.COOKIES.get(self.cookie_name) - return token + return user_info diff --git a/todo/models/user.py b/todo/models/user.py new file mode 100644 index 00000000..1d5491c1 --- /dev/null +++ b/todo/models/user.py @@ -0,0 +1,20 @@ +from pydantic import Field +from typing import ClassVar +from datetime import datetime, timezone + +from todo.models.common.document import Document + + +class UserModel(Document): + """ + Model for external users authenticated via Google OAuth. + Separate from internal RDS authenticated users. + """ + + collection_name: ClassVar[str] = "users" + + googleId: str + emailId: str + name: str + createdAt: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updatedAt: datetime | None diff --git a/todo/repositories/user_repository.py b/todo/repositories/user_repository.py new file mode 100644 index 00000000..cd4c355e --- /dev/null +++ b/todo/repositories/user_repository.py @@ -0,0 +1,56 @@ +from datetime import datetime, timezone +from typing import Optional + +from todo.models.user import UserModel +from todo.models.common.pyobjectid import PyObjectId +from todo_project.db.config import DatabaseManager +from todo.constants.messages import RepositoryErrors +from todo.exceptions.google_auth_exceptions import GoogleUserNotFoundException, GoogleAPIException + + +class UserRepository: + @classmethod + def _get_collection(cls): + return DatabaseManager().get_collection("users") + + @classmethod + def get_by_id(cls, user_id: str) -> Optional[UserModel]: + try: + collection = cls._get_collection() + object_id = PyObjectId(user_id) + doc = collection.find_one({"_id": object_id}) + return UserModel(**doc) if doc else None + except Exception: + raise GoogleUserNotFoundException() + + @classmethod + def create_or_update(cls, user_data: dict) -> UserModel: + try: + collection = cls._get_collection() + now = datetime.now(timezone.utc) + google_id = user_data["google_id"] + + result = collection.find_one_and_update( + {"googleId": google_id}, + { + "$set": { + "emailId": user_data["email"], + "name": user_data["name"], + "updatedAt": now, + "lastLoginAt": now, + }, + "$setOnInsert": {"googleId": google_id, "createdAt": now}, + }, + upsert=True, + return_document=True, + ) + + if not result: + raise GoogleAPIException(RepositoryErrors.USER_OPERATION_FAILED) + + return UserModel(**result) + + except Exception as e: + if isinstance(e, GoogleAPIException): + raise + raise GoogleAPIException(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format(str(e))) diff --git a/todo/services/google_oauth_service.py b/todo/services/google_oauth_service.py new file mode 100644 index 00000000..d01bf73f --- /dev/null +++ b/todo/services/google_oauth_service.py @@ -0,0 +1,102 @@ +import requests +import secrets +from urllib.parse import urlencode +from django.conf import settings + +from todo.exceptions.google_auth_exceptions import GoogleAPIException, GoogleAuthException +from todo.constants.messages import ApiErrors + + +class GoogleOAuthService: + GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" + GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + + @classmethod + def get_authorization_url(cls, redirect_url: str = None) -> tuple[str, str]: + try: + state = secrets.token_urlsafe(32) + + params = { + "client_id": settings.GOOGLE_OAUTH["CLIENT_ID"], + "redirect_uri": settings.GOOGLE_OAUTH["REDIRECT_URI"], + "response_type": "code", + "scope": " ".join(settings.GOOGLE_OAUTH["SCOPES"]), + "access_type": "offline", + "prompt": "consent", + "state": state, + } + + auth_url = f"{cls.GOOGLE_AUTH_URL}?{urlencode(params)}" + return auth_url, state + + except Exception: + raise GoogleAuthException(ApiErrors.GOOGLE_AUTH_FAILED) + + @classmethod + def handle_callback(cls, authorization_code: str) -> dict: + try: + tokens = cls._exchange_code_for_tokens(authorization_code) + + user_info = cls._get_user_info(tokens["access_token"]) + + return { + "google_id": user_info["id"], + "email": user_info["email"], + "name": user_info["name"], + "picture": user_info.get("picture"), + } + + except Exception as e: + if isinstance(e, GoogleAPIException): + raise + raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) + + @classmethod + def _exchange_code_for_tokens(cls, code: str) -> dict: + """Exchange authorization code for tokens""" + try: + data = { + "client_id": settings.GOOGLE_OAUTH["CLIENT_ID"], + "client_secret": settings.GOOGLE_OAUTH["CLIENT_SECRET"], + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.GOOGLE_OAUTH["REDIRECT_URI"], + } + + response = requests.post(cls.GOOGLE_TOKEN_URL, data=data, timeout=30) + + if response.status_code != 200: + raise GoogleAPIException(ApiErrors.TOKEN_EXCHANGE_FAILED) + + tokens = response.json() + + if "error" in tokens: + raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) + + return tokens + + except requests.exceptions.RequestException: + raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) + + @classmethod + def _get_user_info(cls, access_token: str) -> dict: + try: + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(cls.GOOGLE_USER_INFO_URL, headers=headers, timeout=30) + + if response.status_code != 200: + raise GoogleAPIException(ApiErrors.USER_INFO_FETCH_FAILED.format("HTTP error")) + + user_info = response.json() + + required_fields = ["id", "email", "name"] + missing_fields = [field for field in required_fields if field not in user_info] + + if missing_fields: + raise GoogleAPIException(ApiErrors.MISSING_USER_INFO_FIELDS.format(", ".join(missing_fields))) + + return user_info + + except requests.exceptions.RequestException: + raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) diff --git a/todo/services/user_service.py b/todo/services/user_service.py new file mode 100644 index 00000000..ff7cff44 --- /dev/null +++ b/todo/services/user_service.py @@ -0,0 +1,40 @@ +from todo.models.user import UserModel +from todo.repositories.user_repository import UserRepository +from todo.constants.messages import ValidationErrors, RepositoryErrors +from todo.exceptions.google_auth_exceptions import GoogleUserNotFoundException, GoogleAPIException +from rest_framework.exceptions import ValidationError as DRFValidationError + + +class UserService: + @classmethod + def create_or_update_user(cls, google_user_data: dict) -> UserModel: + try: + cls._validate_google_user_data(google_user_data) + return UserRepository.create_or_update(google_user_data) + except (GoogleUserNotFoundException, GoogleAPIException, DRFValidationError): + raise + except Exception as e: + raise GoogleAPIException(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format(str(e))) + + @classmethod + def get_user_by_id(cls, user_id: str) -> UserModel: + user = UserRepository.get_by_id(user_id) + if not user: + raise GoogleUserNotFoundException() + return user + + @classmethod + def _validate_google_user_data(cls, google_user_data: dict) -> None: + validation_errors = {} + + if not google_user_data.get("google_id"): + validation_errors["google_id"] = ValidationErrors.MISSING_GOOGLE_ID + + if not google_user_data.get("email"): + validation_errors["email"] = ValidationErrors.MISSING_EMAIL + + if not google_user_data.get("name"): + validation_errors["name"] = ValidationErrors.MISSING_NAME + + if validation_errors: + raise DRFValidationError(validation_errors) diff --git a/todo/urls.py b/todo/urls.py index 8f622d0e..94aab903 100644 --- a/todo/urls.py +++ b/todo/urls.py @@ -1,10 +1,21 @@ from django.urls import path from todo.views.task import TaskListView, TaskDetailView from todo.views.health import HealthView - +from todo.views.auth import ( + GoogleLoginView, + GoogleCallbackView, + GoogleRefreshView, + GoogleLogoutView, + GoogleAuthStatusView, +) urlpatterns = [ path("tasks", TaskListView.as_view(), name="tasks"), path("tasks/", TaskDetailView.as_view(), name="task_detail"), path("health", HealthView.as_view(), name="health"), + path("auth/google/login/", GoogleLoginView.as_view()), + path("auth/google/callback/", GoogleCallbackView.as_view()), + path("auth/google/status/", GoogleAuthStatusView.as_view()), + path("auth/google/refresh/", GoogleRefreshView.as_view()), + path("auth/google/logout/", GoogleLogoutView.as_view()), ] diff --git a/todo/utils/google_jwt_utils.py b/todo/utils/google_jwt_utils.py new file mode 100644 index 00000000..46bf1b4d --- /dev/null +++ b/todo/utils/google_jwt_utils.py @@ -0,0 +1,125 @@ +import jwt +from datetime import datetime, timedelta, timezone +from django.conf import settings + +from todo.exceptions.google_auth_exceptions import ( + GoogleTokenExpiredError, + GoogleTokenInvalidError, + GoogleRefreshTokenExpiredError, +) + +from todo.constants.messages import AuthErrorMessages, ApiErrors + + +def generate_google_access_token(user_data: dict) -> str: + """ + Generate access token for Google authenticated user + """ + try: + now = datetime.now(timezone.utc) + expiry = now + timedelta(seconds=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"]) + + payload = { + "iss": "todo-app-google-auth", + "exp": int(expiry.timestamp()), + "iat": int(now.timestamp()), + "sub": user_data["google_id"], + "user_id": user_data["user_id"], + "google_id": user_data["google_id"], + "email": user_data["email"], + "name": user_data["name"], + "token_type": "access", + } + + token = jwt.encode( + payload=payload, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] + ) + + return token + + except Exception: + raise GoogleTokenInvalidError(ApiErrors.GOOGLE_API_ERROR) + + +def generate_google_refresh_token(user_data: dict) -> str: + """ + Generate refresh token for Google authenticated user + """ + try: + now = datetime.now(timezone.utc) + expiry = now + timedelta(seconds=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"]) + + payload = { + "iss": "todo-app-google-auth", + "exp": int(expiry.timestamp()), + "iat": int(now.timestamp()), + "sub": user_data["google_id"], + "user_id": user_data["user_id"], + "google_id": user_data["google_id"], + "email": user_data["email"], + "token_type": "refresh", + } + + token = jwt.encode( + payload=payload, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithm=settings.GOOGLE_JWT["ALGORITHM"] + ) + + return token + + except Exception: + raise GoogleTokenInvalidError(ApiErrors.GOOGLE_API_ERROR) + + +def validate_google_access_token(token: str) -> dict: + """ + Validate Google access token + """ + try: + payload = jwt.decode( + jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] + ) + + if payload.get("token_type") != "access": + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + + return payload + + except jwt.ExpiredSignatureError: + raise GoogleTokenExpiredError() + except jwt.InvalidTokenError: + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + + +def validate_google_refresh_token(token: str) -> dict: + """ + Validate Google refresh token + """ + try: + payload = jwt.decode( + jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] + ) + + if payload.get("token_type") != "refresh": + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + + return payload + + except jwt.ExpiredSignatureError: + raise GoogleRefreshTokenExpiredError() + except jwt.InvalidTokenError: + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) + + +def generate_google_token_pair(user_data: dict) -> dict: + """ + Generate both access and refresh tokens + """ + access_token = generate_google_access_token(user_data) + refresh_token = generate_google_refresh_token(user_data) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "Bearer", + "expires_in": settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"], + } diff --git a/todo/auth/jwt_utils.py b/todo/utils/jwt_utils.py similarity index 100% rename from todo/auth/jwt_utils.py rename to todo/utils/jwt_utils.py diff --git a/todo/views/auth.py b/todo/views/auth.py new file mode 100644 index 00000000..15fb63a2 --- /dev/null +++ b/todo/views/auth.py @@ -0,0 +1,415 @@ +# Temporary file for testing purpose. Post frontend integration, this file will be removed and auth2.py will be renamed as auth.py + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework import status +from django.http import HttpResponseRedirect, HttpResponse +from django.conf import settings + +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, + validate_google_access_token, + generate_google_access_token, +) +from todo.exceptions.google_auth_exceptions import ( + GoogleAuthException, + GoogleTokenInvalidError, + GoogleRefreshTokenExpiredError, + GoogleUserNotFoundException, +) +from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource +from todo.constants.messages import ApiErrors, AuthErrorMessages, AppMessages + + +class GoogleLoginView(APIView): + def get(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url) + request.session["oauth_state"] = state + + if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": + return Response({"authUrl": auth_url, "state": state}) + + return HttpResponseRedirect(auth_url) + + except GoogleAuthException as e: + error_response = ApiErrorResponse( + statusCode=400, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_auth"}, + title=ApiErrors.GOOGLE_AUTH_FAILED, + detail=str(e), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "oauth_initialization"}, + title=ApiErrors.UNEXPECTED_ERROR, + detail=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class GoogleCallbackView(APIView): + def get(self, request): + """ + TEMPORARY: Process callback directly without redirecting to frontend + """ + try: + if "error" in request.query_params: + error = request.query_params.get("error") + return HttpResponse(f""" +

❌ OAuth Error

+

Error: {error}

+ Try Again + """) + + code = request.query_params.get("code") + state = request.query_params.get("state") + + if not code: + return HttpResponse(""" +

❌ Missing Authorization Code

+

No authorization code received from Google

+ Try Again + """) + + stored_state = request.session.get("oauth_state") + if not stored_state or stored_state != state: + return HttpResponse(f""" +

❌ Invalid State Parameter

+

Stored state: {stored_state}

+

Received state: {state}

+

This might be a security issue.

+ Try Again + """) + + return self._handle_callback_directly(code, request) + + except Exception as e: + return HttpResponse(f""" +

❌ Authentication Failed

+

Error: {ApiErrors.AUTHENTICATION_FAILED.format(str(e))}

+ Try Again + """) + + def _handle_callback_directly(self, code, request): + try: + from todo.services.google_oauth_service import GoogleOAuthService + from todo.services.user_service import UserService + from todo.utils.google_jwt_utils import generate_google_token_pair + + google_data = GoogleOAuthService.handle_callback(code) + + user = UserService.create_or_update_user(google_data) + + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.googleId, + "email": user.emailId, + "name": user.name, + } + ) + + response = HttpResponse(f""" + + ✅ Login Successful + +

✅ Google OAuth Login Successful!

+ +

🧑‍💻 User Info:

+
    +
  • ID: {user.id}
  • +
  • Name: {user.name}
  • +
  • Email: {user.emailId}
  • +
  • Google ID: {user.googleId}
  • +
+ +

🍪 Authentication Cookies Set:

+
    +
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • +
  • Refresh Token: ext-refresh (expires in 7 days)
  • +
+ +

🧪 Test Other Endpoints:

+ + +

Google OAuth integration is working perfectly!

+ + + """) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + + except Exception as e: + return HttpResponse(f""" +

❌ OAuth Processing Failed

+

Error: {ApiErrors.AUTHENTICATION_FAILED.format(str(e))}

+

Code received: {code[:20]}...

+ Try Again + """) + + 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"), + } + + def _set_auth_cookies(self, response, tokens): + config = self._get_cookie_config() + + response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) + + response.set_cookie( + "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config + ) + + def post(self, request): + pass + + +class GoogleAuthStatusView(APIView): + def get(self, request: Request): + try: + access_token = request.COOKIES.get("ext-access") + + if not access_token: + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.NO_ACCESS_TOKEN, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=AuthErrorMessages.NO_ACCESS_TOKEN, + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + payload = validate_google_access_token(access_token) + user = UserService.get_user_by_id(payload["user_id"]) + + return Response( + { + "authenticated": True, + "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + } + ) + + except (GoogleTokenInvalidError, GoogleUserNotFoundException) as e: + error_response = ApiErrorResponse( + statusCode=401, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=str(e), + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + except Exception: + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.TOKEN_INVALID, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=AuthErrorMessages.TOKEN_INVALID, + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +class GoogleRefreshView(APIView): + def get(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + refresh_token = request.COOKIES.get("ext-refresh") + + if not refresh_token: + return self._handle_missing_token(redirect_url) + + 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({"success": True, "message": AppMessages.TOKEN_REFRESHED}) + + 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 (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError): + return self._handle_expired_token(redirect_url) + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=ApiErrors.TOKEN_REFRESH_FAILED, + detail=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + 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"), + } + + def _handle_missing_token(self, redirect_url): + error_response = ApiErrorResponse( + statusCode=401, + message="No refresh token found", + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail="No refresh token found", + ) + ], + ) + + response = Response( + data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + self._clear_auth_cookies(response) + return response + + def _handle_expired_token(self, redirect_url, error_detail): + error_response = ApiErrorResponse( + statusCode=401, + message="Refresh token expired", + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, + detail=error_detail, + ) + ], + ) + + response = Response( + data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + self._clear_auth_cookies(response) + return response + + def _clear_auth_cookies(self, response): + domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") + response.delete_cookie("ext-access", domain=domain) + response.delete_cookie("ext-refresh", domain=domain) + + +class GoogleLogoutView(APIView): + def get(self, request: Request): + return self._handle_logout(request) + + def post(self, request: Request): + return self._handle_logout(request) + + def _handle_logout(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + + wants_json = ( + request.headers.get("Accept") == "application/json" + or request.data.get("format") == "json" + or request.method == "POST" + ) + + if wants_json: + response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) + else: + redirect_url = redirect_url or "/" + response = HttpResponseRedirect(redirect_url) + + domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") + response.delete_cookie("ext-access", domain=domain) + response.delete_cookie("ext-refresh", domain=domain) + + return response + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.LOGOUT_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "logout"}, + title=ApiErrors.LOGOUT_FAILED, + detail=ApiErrors.LOGOUT_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/todo/views/auth2.py b/todo/views/auth2.py new file mode 100644 index 00000000..4e9e6c18 --- /dev/null +++ b/todo/views/auth2.py @@ -0,0 +1,420 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework import status +from django.http import HttpResponseRedirect +from django.conf import settings + +from todo.services.google_oauth_service import GoogleOAuthService +from todo.services.user_service import UserService +from todo.utils.google_jwt_utils import ( + generate_google_token_pair, + validate_google_refresh_token, + validate_google_access_token, + generate_google_access_token, +) +from todo.exceptions.google_auth_exceptions import ( + GoogleAuthException, + GoogleTokenInvalidError, + GoogleRefreshTokenExpiredError, + GoogleAPIException, + GoogleUserNotFoundException, +) +from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource +from todo.constants.messages import ApiErrors, AuthErrorMessages, AppMessages + + +class GoogleLoginView(APIView): + def get(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url) + request.session["oauth_state"] = state + + if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": + return Response({"authUrl": auth_url, "state": state}) + + return HttpResponseRedirect(auth_url) + + except GoogleAuthException as e: + error_response = ApiErrorResponse( + statusCode=400, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_auth"}, + title=ApiErrors.GOOGLE_AUTH_FAILED, + detail=str(e), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "oauth_initialization"}, + title=ApiErrors.UNEXPECTED_ERROR, + detail=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class GoogleCallbackView(APIView): + def get(self, request: Request): + code = request.query_params.get("code") + state = request.query_params.get("state") + error = request.query_params.get("error") + + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + + if error: + 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") + + def post(self, request: Request): + try: + code = request.data.get("code") + state = request.data.get("state") + + if not code: + error_response = ApiErrorResponse( + statusCode=400, + message=ApiErrors.INVALID_AUTH_CODE, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "code"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_AUTH_CODE, + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + stored_state = request.session.get("oauth_state") + if not stored_state or stored_state != state: + error_response = ApiErrorResponse( + statusCode=400, + message=ApiErrors.INVALID_STATE_PARAMETER, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "state"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_STATE_PARAMETER, + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + google_data = GoogleOAuthService.handle_callback(code) + user = UserService.create_or_update_user(google_data) + + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.googleId, + "email": user.emailId, + "name": user.name, + } + ) + + response = Response( + { + "success": True, + "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + } + ) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + + except (GoogleAPIException, GoogleUserNotFoundException) as e: + error_response = ApiErrorResponse( + statusCode=500, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_auth"}, + title=ApiErrors.AUTHENTICATION_FAILED, + detail=str(e), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "authentication"}, + title=ApiErrors.AUTHENTICATION_FAILED, + detail=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + 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"), + } + + def _set_auth_cookies(self, response, tokens): + config = self._get_cookie_config() + + response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) + + response.set_cookie( + "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config + ) + + +class GoogleAuthStatusView(APIView): + def get(self, request: Request): + try: + access_token = request.COOKIES.get("ext-access") + + if not access_token: + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.NO_ACCESS_TOKEN, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=AuthErrorMessages.NO_ACCESS_TOKEN, + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + payload = validate_google_access_token(access_token) + user = UserService.get_user_by_id(payload["user_id"]) + + return Response( + { + "authenticated": True, + "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + } + ) + + except (GoogleTokenInvalidError, GoogleUserNotFoundException) as e: + error_response = ApiErrorResponse( + statusCode=401, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=str(e), + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + except Exception: + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.TOKEN_INVALID, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.INVALID_TOKEN_TITLE, + detail=AuthErrorMessages.TOKEN_INVALID, + ) + ], + ) + return Response( + data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +class GoogleRefreshView(APIView): + def get(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + refresh_token = request.COOKIES.get("ext-refresh") + + if not refresh_token: + return self._handle_missing_token(redirect_url) + + 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({"success": True, "message": AppMessages.TOKEN_REFRESHED}) + + 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 (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError): + return self._handle_expired_token(redirect_url) + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=ApiErrors.TOKEN_REFRESH_FAILED, + detail=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + 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"), + } + + def _handle_missing_token(self, redirect_url): + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.NO_REFRESH_TOKEN, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=AuthErrorMessages.NO_REFRESH_TOKEN, + ) + ], + ) + + response_data = {"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)} + + if redirect_url: + response_data["redirectUrl"] = redirect_url + + response = Response(data=response_data, status=status.HTTP_401_UNAUTHORIZED) + self._clear_auth_cookies(response) + return response + + def _handle_expired_token(self, redirect_url, error_detail): + error_response = ApiErrorResponse( + statusCode=401, + message=AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, + detail=error_detail, + ) + ], + ) + + response_data = {"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)} + + if redirect_url: + response_data["redirectUrl"] = redirect_url + + response = Response(data=response_data, status=status.HTTP_401_UNAUTHORIZED) + self._clear_auth_cookies(response) + return response + + def _clear_auth_cookies(self, response): + domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") + response.delete_cookie("ext-access", domain=domain) + response.delete_cookie("ext-refresh", domain=domain) + + +class GoogleLogoutView(APIView): + def get(self, request: Request): + return self._handle_logout(request) + + def post(self, request: Request): + return self._handle_logout(request) + + def _handle_logout(self, request: Request): + try: + redirect_url = request.query_params.get("redirectURL") + + wants_json = ( + request.headers.get("Accept") == "application/json" + or request.data.get("format") == "json" + or request.method == "POST" + ) + + if wants_json: + response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) + else: + redirect_url = redirect_url or "/" + response = HttpResponseRedirect(redirect_url) + + domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") + response.delete_cookie("ext-access", domain=domain) + response.delete_cookie("ext-refresh", domain=domain) + + return response + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.LOGOUT_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "logout"}, + title=ApiErrors.LOGOUT_FAILED, + detail=ApiErrors.LOGOUT_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index b8d3b6f3..bd3c0e7f 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -18,7 +18,6 @@ MONGODB_URI = os.getenv("MONGODB_URI") DB_NAME = os.getenv("DB_NAME") -# Application definition INSTALLED_APPS = [ "corsheaders", "rest_framework", @@ -27,6 +26,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -44,6 +44,19 @@ USE_TZ = True +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" +SESSION_COOKIE_AGE = 3600 +SESSION_SAVE_EVERY_REQUEST = False + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "oauth-sessions", + } +} + + REST_FRAMEWORK = { "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", @@ -63,22 +76,14 @@ ], } -# JWT Verification Settings - JWT_AUTH = { "ALGORITHM": "RS256", "PUBLIC_KEY": os.getenv("RDS_PUBLIC_KEY"), } -# Cookie Settings - JWT_COOKIE_SETTINGS = { - "RDS_SESSION_COOKIE_NAME": os.getenv( - "RDS_SESSION_COOKIE_NAME", "rds-session-development" - ), - "RDS_SESSION_V2_COOKIE_NAME": os.getenv( - "RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development" - ), + "RDS_SESSION_COOKIE_NAME": os.getenv("RDS_SESSION_COOKIE_NAME", "rds-session-development"), + "RDS_SESSION_V2_COOKIE_NAME": os.getenv("RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development"), "COOKIE_DOMAIN": os.getenv("COOKIE_DOMAIN", None), "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "False").lower() == "true", "COOKIE_HTTPONLY": True, @@ -86,6 +91,32 @@ "COOKIE_PATH": "/", } +GOOGLE_OAUTH = { + "CLIENT_ID": os.getenv("GOOGLE_OAUTH_CLIENT_ID"), + "CLIENT_SECRET": os.getenv("GOOGLE_OAUTH_CLIENT_SECRET"), + "REDIRECT_URI": os.getenv("GOOGLE_OAUTH_REDIRECT_URI"), + "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")), +} + +GOOGLE_COOKIE_SETTINGS = { + "ACCESS_COOKIE_NAME": os.getenv("GOOGLE_ACCESS_COOKIE_NAME", "ext-access"), + "REFRESH_COOKIE_NAME": os.getenv("GOOGLE_REFRESH_COOKIE_NAME", "ext-refresh"), + "COOKIE_DOMAIN": os.getenv("COOKIE_DOMAIN", None), + "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "False").lower() == "true", + "COOKIE_HTTPONLY": True, + "COOKIE_SAMESITE": os.getenv("COOKIE_SAMESITE", "Lax"), + "COOKIE_PATH": "/", +} + +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:4000") + # RDS Backend Integration MAIN_APP = { "RDS_BACKEND_BASE_URL": os.getenv("RDS_BACKEND_BASE_URL", "http://localhost:3000"), @@ -103,4 +134,9 @@ "/v1/health", "/api/docs", "/static/", + "/v1/auth/google/login", + "/v1/auth/google/callback", + "/v1/auth/google/logout", + "/v1/auth/google/status", + "/v1/auth/google/refresh", ] diff --git a/todo_project/settings/development.py b/todo_project/settings/development.py index 0bb85a49..2bf94a84 100644 --- a/todo_project/settings/development.py +++ b/todo_project/settings/development.py @@ -4,6 +4,14 @@ DEBUG = True ALLOWED_HOSTS = ["*"] +GOOGLE_OAUTH.update( + { + "REDIRECT_URI": "http://localhost:8000/v1/auth/google/callback", + } +) + +FRONTEND_URL = "http://localhost:4000" + JWT_COOKIE_SETTINGS.update( { "RDS_SESSION_COOKIE_NAME": "rds-session-development", @@ -12,6 +20,15 @@ } ) +GOOGLE_COOKIE_SETTINGS.update( + { + "COOKIE_DOMAIN": None, + "COOKIE_SECURE": False, + "COOKIE_SAMESITE": "Lax", + } +) + + MAIN_APP.update( { "RDS_BACKEND_BASE_URL": "http://localhost:3000", @@ -23,3 +40,17 @@ CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +SESSION_COOKIE_SECURE = False +SESSION_COOKIE_SAMESITE = "Lax" diff --git a/todo_project/settings/staging.py b/todo_project/settings/staging.py index b778d9b7..478faf9f 100644 --- a/todo_project/settings/staging.py +++ b/todo_project/settings/staging.py @@ -4,6 +4,15 @@ DEBUG = False ALLOWED_HOSTS = ["staging-api.realdevsquad.com", "services.realdevsquad.com"] +GOOGLE_OAUTH.update( + { + "REDIRECT_URI": "https://services.realdevsquad.com/staging-todo/v1/auth/google/callback", + } +) + +FRONTEND_URL = "https://staging-todo.realdevsquad.com" + + JWT_COOKIE_SETTINGS.update( { "RDS_SESSION_COOKIE_NAME": "rds-session-staging", @@ -13,6 +22,14 @@ } ) +GOOGLE_COOKIE_SETTINGS.update( + { + "COOKIE_DOMAIN": ".realdevsquad.com", + "COOKIE_SECURE": True, + "COOKIE_SAMESITE": "None", + } +) + MAIN_APP.update( { "RDS_BACKEND_BASE_URL": "https://staging-api.realdevsquad.com", @@ -20,14 +37,28 @@ ) # Staging CORS settings +MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") + CORS_ALLOW_CREDENTIALS = True CORS_ALLOWED_ORIGINS = [ "https://staging-todo.realdevsquad.com", ] +CORS_ALLOWED_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + # Security settings for staging SECURE_SSL_REDIRECT = True SESSION_COOKIE_SECURE = True +SESSION_COOKIE_DOMAIN = ".realdevsquad.com" +SESSION_COOKIE_SAMESITE = "None" CSRF_COOKIE_SECURE = True - -MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") From 8ea44e6b9db79080ec6cdb90afb4ed69642f8e37 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Fri, 13 Jun 2025 02:13:19 +0530 Subject: [PATCH 06/17] resolved pr comments --- requirements.txt | 3 +- todo/constants/messages.py | 19 -------- todo/exceptions/exception_handler.py | 57 ++++++----------------- todo/exceptions/google_auth_exceptions.py | 34 +++++++------- todo/middlewares/jwt_auth.py | 5 +- todo/models/user.py | 10 ++-- todo/repositories/user_repository.py | 10 ++-- todo/views/auth.py | 15 ++++-- todo/views/auth2.py | 18 +++++-- todo_project/settings/development.py | 18 +++++-- todo_project/settings/staging.py | 19 ++++++-- 11 files changed, 99 insertions(+), 109 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1bf46481..93cf49b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,4 +23,5 @@ virtualenv==20.27.0 django-cors-headers==4.7.0 cryptography==45.0.3 PyJWT==2.10.1 -requests==2.32.3 \ No newline at end of file +requests==2.32.3 +email-validator==2.2.0 \ No newline at end of file diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 2681a3bb..cac4bf78 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -59,25 +59,6 @@ class ValidationErrors: MISSING_NAME = "Name is required" -# Auth messages -class AuthErrorMessages: - TOKEN_MISSING = "Authentication token is required" - TOKEN_EXPIRED = "Authentication token has expired" - TOKEN_INVALID = "Invalid authentication token" - AUTHENTICATION_REQUIRED = "Authentication required" - TOKEN_EXPIRED_TITLE = "Token Expired" - INVALID_TOKEN_TITLE = "Invalid Token" - - -# Auth messages -class AuthErrorMessages: - TOKEN_MISSING = "Authentication token is required" - TOKEN_EXPIRED = "Authentication token has expired" - TOKEN_INVALID = "Invalid authentication token" - AUTHENTICATION_REQUIRED = "Authentication required" - TOKEN_EXPIRED_TITLE = "Token Expired" - INVALID_TOKEN_TITLE = "Invalid Token" - # Auth messages class AuthErrorMessages: TOKEN_MISSING = "Authentication token is required" diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index 81144d5c..b01480a9 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -46,121 +46,99 @@ def handle_exception(exc, context): error_list = [] status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - determined_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED if isinstance(exc, TokenExpiredError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, TokenMissingError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, TokenInvalidError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleTokenExpiredError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleTokenInvalidError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleRefreshTokenExpiredError): status_code = status.HTTP_401_UNAUTHORIZED - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleAuthException): status_code = status.HTTP_400_BAD_REQUEST - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.PARAMETER: "google_auth"}, title=ApiErrors.GOOGLE_AUTH_FAILED, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleAPIException): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.PARAMETER: "google_api"}, title=ApiErrors.GOOGLE_API_ERROR, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, GoogleUserNotFoundException): status_code = status.HTTP_404_NOT_FOUND - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.PARAMETER: "user_id"}, title=ApiErrors.RESOURCE_NOT_FOUND_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, TaskNotFoundException): status_code = status.HTTP_404_NOT_FOUND - detail_message_str = str(exc) - determined_message = detail_message_str error_list.append( ApiErrorDetail( source={ApiErrorSource.PATH: "task_id"} if task_id else None, title=ApiErrors.RESOURCE_NOT_FOUND_TITLE, - detail=detail_message_str, + detail=str(exc), ) ) elif isinstance(exc, BsonInvalidId): status_code = status.HTTP_400_BAD_REQUEST - determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT error_list.append( ApiErrorDetail( source={ApiErrorSource.PATH: "task_id"} if task_id else None, @@ -175,7 +153,6 @@ def handle_exception(exc, context): and (exc.args[0] == ValidationErrors.INVALID_TASK_ID_FORMAT or exc.args[0] == "Invalid ObjectId format") ): status_code = status.HTTP_400_BAD_REQUEST - determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT error_list.append( ApiErrorDetail( source={ApiErrorSource.PATH: "task_id"} if task_id else None, @@ -192,7 +169,6 @@ def handle_exception(exc, context): ) elif isinstance(exc, DRFValidationError): status_code = status.HTTP_400_BAD_REQUEST - determined_message = "Invalid request" error_list = format_validation_errors(exc.detail) if not error_list and exc.detail: error_list.append(ApiErrorDetail(detail=str(exc.detail), title=ApiErrors.VALIDATION_ERROR)) @@ -202,23 +178,20 @@ def handle_exception(exc, context): status_code = response.status_code if isinstance(response.data, dict) and "detail" in response.data: detail_str = str(response.data["detail"]) - determined_message = detail_str error_list.append(ApiErrorDetail(detail=detail_str, title=detail_str)) elif isinstance(response.data, list): for item_error in response.data: - error_list.append(ApiErrorDetail(detail=str(item_error), title=determined_message)) + error_list.append(ApiErrorDetail(detail=str(item_error), title=str(exc))) else: error_list.append( ApiErrorDetail( detail=str(response.data) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, - title=determined_message, + title=str(exc), ) ) else: error_list.append( - ApiErrorDetail( - detail=str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, title=determined_message - ) + ApiErrorDetail(detail=str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, title=str(exc)) ) if not error_list and not ( @@ -226,11 +199,11 @@ def handle_exception(exc, context): ): default_detail_str = str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR - error_list.append(ApiErrorDetail(detail=default_detail_str, title=determined_message)) + error_list.append(ApiErrorDetail(detail=default_detail_str, title=str(exc))) final_response_data = ApiErrorResponse( statusCode=status_code, - message=determined_message, + message=str(exc) if not error_list else error_list[0].detail, errors=error_list, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) diff --git a/todo/exceptions/google_auth_exceptions.py b/todo/exceptions/google_auth_exceptions.py index e5b87a79..d3c48760 100644 --- a/todo/exceptions/google_auth_exceptions.py +++ b/todo/exceptions/google_auth_exceptions.py @@ -1,37 +1,37 @@ from todo.constants.messages import AuthErrorMessages, ApiErrors, RepositoryErrors -class GoogleAuthException(Exception): - def __init__(self, message: str = ApiErrors.GOOGLE_AUTH_FAILED): +class BaseGoogleException(Exception): + def __init__(self, message: str): self.message = message super().__init__(self.message) -class GoogleTokenExpiredError(Exception): +class GoogleAuthException(BaseGoogleException): + def __init__(self, message: str = ApiErrors.GOOGLE_AUTH_FAILED): + super().__init__(message) + + +class GoogleTokenExpiredError(BaseGoogleException): def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_EXPIRED): - self.message = message - super().__init__(self.message) + super().__init__(message) -class GoogleTokenInvalidError(Exception): +class GoogleTokenInvalidError(BaseGoogleException): def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_INVALID): - self.message = message - super().__init__(self.message) + super().__init__(message) -class GoogleRefreshTokenExpiredError(Exception): +class GoogleRefreshTokenExpiredError(BaseGoogleException): def __init__(self, message: str = AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED): - self.message = message - super().__init__(self.message) + super().__init__(message) -class GoogleAPIException(Exception): +class GoogleAPIException(BaseGoogleException): def __init__(self, message: str = ApiErrors.GOOGLE_API_ERROR): - self.message = message - super().__init__(self.message) + super().__init__(message) -class GoogleUserNotFoundException(Exception): +class GoogleUserNotFoundException(BaseGoogleException): def __init__(self, message: str = RepositoryErrors.USER_NOT_FOUND): - self.message = message - super().__init__(self.message) + super().__init__(message) diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index 881d044d..ce90138f 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -6,6 +6,7 @@ from todo.utils.google_jwt_utils import validate_google_access_token from todo.exceptions.auth_exceptions import TokenMissingError, TokenExpiredError, TokenInvalidError from todo.exceptions.google_auth_exceptions import GoogleTokenExpiredError, GoogleTokenInvalidError +from todo.constants.messages import AuthErrorMessages, ApiErrors class JWTAuthenticationMiddleware: @@ -26,14 +27,14 @@ def __call__(self, request): if auth_success: return self.get_response(request) else: - return self._unauthorized_response("Authentication required") + return self._unauthorized_response(AuthErrorMessages.AUTHENTICATION_REQUIRED) except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: return self._handle_rds_auth_error(e) except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: return self._handle_google_auth_error(e) except Exception: - return self._unauthorized_response("Authentication failed") + return self._unauthorized_response(ApiErrors.AUTHENTICATION_FAILED.format("")) def _try_authentication(self, request) -> bool: if self._try_google_auth(request): diff --git a/todo/models/user.py b/todo/models/user.py index 1d5491c1..905263a1 100644 --- a/todo/models/user.py +++ b/todo/models/user.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, EmailStr from typing import ClassVar from datetime import datetime, timezone @@ -13,8 +13,8 @@ class UserModel(Document): collection_name: ClassVar[str] = "users" - googleId: str - emailId: str + google_id: str + email_id: EmailStr name: str - createdAt: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updatedAt: datetime | None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/todo/repositories/user_repository.py b/todo/repositories/user_repository.py index cd4c355e..06d476b8 100644 --- a/todo/repositories/user_repository.py +++ b/todo/repositories/user_repository.py @@ -31,15 +31,15 @@ def create_or_update(cls, user_data: dict) -> UserModel: google_id = user_data["google_id"] result = collection.find_one_and_update( - {"googleId": google_id}, + {"google_id": google_id}, { "$set": { - "emailId": user_data["email"], + "email_id": user_data["email"], "name": user_data["name"], - "updatedAt": now, - "lastLoginAt": now, + "updated_at": now, + "last_login_at": now, }, - "$setOnInsert": {"googleId": google_id, "createdAt": now}, + "$setOnInsert": {"google_id": google_id, "created_at": now}, }, upsert=True, return_document=True, diff --git a/todo/views/auth.py b/todo/views/auth.py index 15fb63a2..8f149994 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -126,8 +126,8 @@ def _handle_callback_directly(self, code, request): tokens = generate_google_token_pair( { "user_id": str(user.id), - "google_id": user.googleId, - "email": user.emailId, + "google_id": user.google_id, + "email": user.email_id, "name": user.name, } ) @@ -142,8 +142,8 @@ def _handle_callback_directly(self, code, request):
  • ID: {user.id}
  • Name: {user.name}
  • -
  • Email: {user.emailId}
  • -
  • Google ID: {user.googleId}
  • +
  • Email: {user.email_id}
  • +
  • Google ID: {user.google_id}

🍪 Authentication Cookies Set:

@@ -227,7 +227,12 @@ def get(self, request: Request): return Response( { "authenticated": True, - "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, } ) diff --git a/todo/views/auth2.py b/todo/views/auth2.py index 4e9e6c18..3f245c39 100644 --- a/todo/views/auth2.py +++ b/todo/views/auth2.py @@ -129,8 +129,8 @@ def post(self, request: Request): tokens = generate_google_token_pair( { "user_id": str(user.id), - "google_id": user.googleId, - "email": user.emailId, + "google_id": user.google_id, + "email": user.email_id, "name": user.name, } ) @@ -138,7 +138,12 @@ def post(self, request: Request): response = Response( { "success": True, - "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, } ) @@ -228,7 +233,12 @@ def get(self, request: Request): return Response( { "authenticated": True, - "user": {"id": str(user.id), "email": user.emailId, "name": user.name, "googleId": user.googleId}, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, } ) diff --git a/todo_project/settings/development.py b/todo_project/settings/development.py index 2bf94a84..c4f6ac07 100644 --- a/todo_project/settings/development.py +++ b/todo_project/settings/development.py @@ -4,13 +4,24 @@ DEBUG = True ALLOWED_HOSTS = ["*"] +# Service ports configuration +SERVICE_PORTS = { + "BACKEND": 3000, + "AUTH": 8000, + "FRONTEND": 4000, +} + +# Base URL configuration +BASE_URL = "http://localhost" + + GOOGLE_OAUTH.update( { - "REDIRECT_URI": "http://localhost:8000/v1/auth/google/callback", + "REDIRECT_URI": f"{BASE_URL}:{SERVICE_PORTS['AUTH']}/v1/auth/google/callback", } ) -FRONTEND_URL = "http://localhost:4000" +FRONTEND_URL = f"{BASE_URL}:{SERVICE_PORTS['FRONTEND']}" JWT_COOKIE_SETTINGS.update( { @@ -28,10 +39,9 @@ } ) - MAIN_APP.update( { - "RDS_BACKEND_BASE_URL": "http://localhost:3000", + "RDS_BACKEND_BASE_URL": f"{BASE_URL}:{SERVICE_PORTS['BACKEND']}", } ) diff --git a/todo_project/settings/staging.py b/todo_project/settings/staging.py index 478faf9f..d800bca3 100644 --- a/todo_project/settings/staging.py +++ b/todo_project/settings/staging.py @@ -4,14 +4,23 @@ DEBUG = False ALLOWED_HOSTS = ["staging-api.realdevsquad.com", "services.realdevsquad.com"] +# Service domains configuration +SERVICE_DOMAINS = { + "RDS_API": "staging-api.realdevsquad.com", + "AUTH": "services.realdevsquad.com", + "FRONTEND": "staging-todo.realdevsquad.com", +} + +# Base URL configuration +BASE_URL = "https://" + GOOGLE_OAUTH.update( { - "REDIRECT_URI": "https://services.realdevsquad.com/staging-todo/v1/auth/google/callback", + "REDIRECT_URI": f"{BASE_URL}{SERVICE_DOMAINS['AUTH']}/staging-todo/v1/auth/google/callback", } ) -FRONTEND_URL = "https://staging-todo.realdevsquad.com" - +FRONTEND_URL = f"{BASE_URL}{SERVICE_DOMAINS['FRONTEND']}" JWT_COOKIE_SETTINGS.update( { @@ -32,7 +41,7 @@ MAIN_APP.update( { - "RDS_BACKEND_BASE_URL": "https://staging-api.realdevsquad.com", + "RDS_BACKEND_BASE_URL": f"{BASE_URL}{SERVICE_DOMAINS['RDS_API']}", } ) @@ -41,7 +50,7 @@ CORS_ALLOW_CREDENTIALS = True CORS_ALLOWED_ORIGINS = [ - "https://staging-todo.realdevsquad.com", + f"{BASE_URL}{SERVICE_DOMAINS['FRONTEND']}", ] CORS_ALLOWED_HEADERS = [ From 5a4c2109767d4e7d1d9afa9fe819c2871c5e6907 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Fri, 13 Jun 2025 02:20:24 +0530 Subject: [PATCH 07/17] resolved pr comments --- todo/views/auth.py | 4 ++-- todo/views/auth2.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/todo/views/auth.py b/todo/views/auth.py index 8f149994..80f8575f 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -299,8 +299,8 @@ def get(self, request: Request): return response - except (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError): - return self._handle_expired_token(redirect_url) + except (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError) as e: + return self._handle_expired_token(redirect_url, str(e)) except Exception as e: error_response = ApiErrorResponse( statusCode=500, diff --git a/todo/views/auth2.py b/todo/views/auth2.py index 3f245c39..d6ff4cfc 100644 --- a/todo/views/auth2.py +++ b/todo/views/auth2.py @@ -305,8 +305,8 @@ def get(self, request: Request): return response - except (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError): - return self._handle_expired_token(redirect_url) + except (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError) as e: + return self._handle_expired_token(redirect_url, str(e)) except Exception as e: error_response = ApiErrorResponse( statusCode=500, From 48c3546cb5a9bee59aec57f6856eecfb3a1e0177 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Sat, 14 Jun 2025 02:36:20 +0530 Subject: [PATCH 08/17] resolved bot comments --- todo/views/auth.py | 166 +++++++++++++++-- todo/views/auth2.py | 430 -------------------------------------------- 2 files changed, 152 insertions(+), 444 deletions(-) delete mode 100644 todo/views/auth2.py diff --git a/todo/views/auth.py b/todo/views/auth.py index 80f8575f..f1ced78a 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -1,5 +1,3 @@ -# Temporary file for testing purpose. Post frontend integration, this file will be removed and auth2.py will be renamed as auth.py - from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.request import Request @@ -13,6 +11,7 @@ validate_google_refresh_token, validate_google_access_token, generate_google_access_token, + generate_google_token_pair, ) from todo.exceptions.google_auth_exceptions import ( GoogleAuthException, @@ -71,10 +70,17 @@ def get(self, request: Request): class GoogleCallbackView(APIView): - def get(self, request): - """ - TEMPORARY: Process callback directly without redirecting to frontend - """ + """ + This class has two implementations: + 1. Current active implementation (temporary) - For testing and development + 2. Commented implementation - For frontend integration (to be used later) + + The temporary implementation processes the OAuth callback directly and shows a success page. + The frontend implementation will redirect to the frontend and process the callback via POST request. + """ + + # Temporary implementation for testing and development + def get(self, request: Request): try: if "error" in request.query_params: error = request.query_params.get("error") @@ -115,12 +121,7 @@ def get(self, request): def _handle_callback_directly(self, code, request): try: - from todo.services.google_oauth_service import GoogleOAuthService - from todo.services.user_service import UserService - from todo.utils.google_jwt_utils import generate_google_token_pair - google_data = GoogleOAuthService.handle_callback(code) - user = UserService.create_or_update_user(google_data) tokens = generate_google_token_pair( @@ -198,6 +199,143 @@ def _set_auth_cookies(self, response, tokens): def post(self, request): pass + # Frontend integration implementation (to be used later) + """ + class GoogleCallbackView(APIView): + def get(self, request: Request): + code = request.query_params.get("code") + state = request.query_params.get("state") + error = request.query_params.get("error") + + frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" + + if error: + 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") + + def post(self, request: Request): + try: + code = request.data.get("code") + state = request.data.get("state") + + if not code: + error_response = ApiErrorResponse( + statusCode=400, + message=ApiErrors.INVALID_AUTH_CODE, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "code"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_AUTH_CODE, + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + stored_state = request.session.get("oauth_state") + if not stored_state or stored_state != state: + error_response = ApiErrorResponse( + statusCode=400, + message=ApiErrors.INVALID_STATE_PARAMETER, + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "state"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_STATE_PARAMETER, + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + ) + + google_data = GoogleOAuthService.handle_callback(code) + user = UserService.create_or_update_user(google_data) + + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.google_id, + "email": user.email_id, + "name": user.name, + } + ) + + response = Response( + { + "success": True, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, + } + ) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + + except (GoogleAPIException, GoogleUserNotFoundException) as e: + error_response = ApiErrorResponse( + statusCode=500, + message=str(e), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "google_auth"}, + title=ApiErrors.AUTHENTICATION_FAILED, + detail=str(e), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + except Exception as e: + error_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), + errors=[ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "authentication"}, + title=ApiErrors.AUTHENTICATION_FAILED, + detail=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), + ) + ], + ) + return Response( + data=error_response.model_dump(mode="json", exclude_none=True), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + 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"), + } + + def _set_auth_cookies(self, response, tokens): + config = self._get_cookie_config() + + response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) + + response.set_cookie( + "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config + ) + """ + class GoogleAuthStatusView(APIView): def get(self, request: Request): @@ -330,12 +468,12 @@ def _get_cookie_config(self): def _handle_missing_token(self, redirect_url): error_response = ApiErrorResponse( statusCode=401, - message="No refresh token found", + message=AuthErrorMessages.NO_REFRESH_TOKEN, errors=[ ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail="No refresh token found", + detail=AuthErrorMessages.NO_REFRESH_TOKEN, ) ], ) @@ -350,7 +488,7 @@ def _handle_missing_token(self, redirect_url): def _handle_expired_token(self, redirect_url, error_detail): error_response = ApiErrorResponse( statusCode=401, - message="Refresh token expired", + message=AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED, errors=[ ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, diff --git a/todo/views/auth2.py b/todo/views/auth2.py deleted file mode 100644 index d6ff4cfc..00000000 --- a/todo/views/auth2.py +++ /dev/null @@ -1,430 +0,0 @@ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework import status -from django.http import HttpResponseRedirect -from django.conf import settings - -from todo.services.google_oauth_service import GoogleOAuthService -from todo.services.user_service import UserService -from todo.utils.google_jwt_utils import ( - generate_google_token_pair, - validate_google_refresh_token, - validate_google_access_token, - generate_google_access_token, -) -from todo.exceptions.google_auth_exceptions import ( - GoogleAuthException, - GoogleTokenInvalidError, - GoogleRefreshTokenExpiredError, - GoogleAPIException, - GoogleUserNotFoundException, -) -from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource -from todo.constants.messages import ApiErrors, AuthErrorMessages, AppMessages - - -class GoogleLoginView(APIView): - def get(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url) - request.session["oauth_state"] = state - - if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": - return Response({"authUrl": auth_url, "state": state}) - - return HttpResponseRedirect(auth_url) - - except GoogleAuthException as e: - error_response = ApiErrorResponse( - statusCode=400, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "google_auth"}, - title=ApiErrors.GOOGLE_AUTH_FAILED, - detail=str(e), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST - ) - - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "oauth_initialization"}, - title=ApiErrors.UNEXPECTED_ERROR, - detail=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -class GoogleCallbackView(APIView): - def get(self, request: Request): - code = request.query_params.get("code") - state = request.query_params.get("state") - error = request.query_params.get("error") - - frontend_callback = f"{settings.FRONTEND_URL}/auth/callback" - - if error: - 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") - - def post(self, request: Request): - try: - code = request.data.get("code") - state = request.data.get("state") - - if not code: - error_response = ApiErrorResponse( - statusCode=400, - message=ApiErrors.INVALID_AUTH_CODE, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "code"}, - title=ApiErrors.VALIDATION_ERROR, - detail=ApiErrors.INVALID_AUTH_CODE, - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST - ) - - stored_state = request.session.get("oauth_state") - if not stored_state or stored_state != state: - error_response = ApiErrorResponse( - statusCode=400, - message=ApiErrors.INVALID_STATE_PARAMETER, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "state"}, - title=ApiErrors.VALIDATION_ERROR, - detail=ApiErrors.INVALID_STATE_PARAMETER, - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST - ) - - google_data = GoogleOAuthService.handle_callback(code) - user = UserService.create_or_update_user(google_data) - - tokens = generate_google_token_pair( - { - "user_id": str(user.id), - "google_id": user.google_id, - "email": user.email_id, - "name": user.name, - } - ) - - response = Response( - { - "success": True, - "user": { - "id": str(user.id), - "email": user.email_id, - "name": user.name, - "google_id": user.google_id, - }, - } - ) - - self._set_auth_cookies(response, tokens) - request.session.pop("oauth_state", None) - - return response - - except (GoogleAPIException, GoogleUserNotFoundException) as e: - error_response = ApiErrorResponse( - statusCode=500, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "google_auth"}, - title=ApiErrors.AUTHENTICATION_FAILED, - detail=str(e), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "authentication"}, - title=ApiErrors.AUTHENTICATION_FAILED, - detail=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - 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"), - } - - def _set_auth_cookies(self, response, tokens): - config = self._get_cookie_config() - - response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) - - response.set_cookie( - "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config - ) - - -class GoogleAuthStatusView(APIView): - def get(self, request: Request): - try: - access_token = request.COOKIES.get("ext-access") - - if not access_token: - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.NO_ACCESS_TOKEN, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_ACCESS_TOKEN, - ) - ], - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - payload = validate_google_access_token(access_token) - user = UserService.get_user_by_id(payload["user_id"]) - - return Response( - { - "authenticated": True, - "user": { - "id": str(user.id), - "email": user.email_id, - "name": user.name, - "google_id": user.google_id, - }, - } - ) - - except (GoogleTokenInvalidError, GoogleUserNotFoundException) as e: - error_response = ApiErrorResponse( - statusCode=401, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=str(e), - ) - ], - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - except Exception: - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.TOKEN_INVALID, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=AuthErrorMessages.TOKEN_INVALID, - ) - ], - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - -class GoogleRefreshView(APIView): - def get(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - refresh_token = request.COOKIES.get("ext-refresh") - - if not refresh_token: - return self._handle_missing_token(redirect_url) - - 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({"success": True, "message": AppMessages.TOKEN_REFRESHED}) - - 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 (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError) as e: - return self._handle_expired_token(redirect_url, str(e)) - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=ApiErrors.TOKEN_REFRESH_FAILED, - detail=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - 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"), - } - - def _handle_missing_token(self, redirect_url): - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.NO_REFRESH_TOKEN, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_REFRESH_TOKEN, - ) - ], - ) - - response_data = {"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)} - - if redirect_url: - response_data["redirectUrl"] = redirect_url - - response = Response(data=response_data, status=status.HTTP_401_UNAUTHORIZED) - self._clear_auth_cookies(response) - return response - - def _handle_expired_token(self, redirect_url, error_detail): - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, - detail=error_detail, - ) - ], - ) - - response_data = {"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)} - - if redirect_url: - response_data["redirectUrl"] = redirect_url - - response = Response(data=response_data, status=status.HTTP_401_UNAUTHORIZED) - self._clear_auth_cookies(response) - return response - - def _clear_auth_cookies(self, response): - domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") - response.delete_cookie("ext-access", domain=domain) - response.delete_cookie("ext-refresh", domain=domain) - - -class GoogleLogoutView(APIView): - def get(self, request: Request): - return self._handle_logout(request) - - def post(self, request: Request): - return self._handle_logout(request) - - def _handle_logout(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - - wants_json = ( - request.headers.get("Accept") == "application/json" - or request.data.get("format") == "json" - or request.method == "POST" - ) - - if wants_json: - response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) - else: - redirect_url = redirect_url or "/" - response = HttpResponseRedirect(redirect_url) - - domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") - response.delete_cookie("ext-access", domain=domain) - response.delete_cookie("ext-refresh", domain=domain) - - return response - - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.LOGOUT_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "logout"}, - title=ApiErrors.LOGOUT_FAILED, - detail=ApiErrors.LOGOUT_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) From 5915f746d3e964f808696e55369aafb8275bd176 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 16 Jun 2025 01:58:53 +0530 Subject: [PATCH 09/17] removed exceptions from views file --- todo/views/auth.py | 309 ++++++++++++++++----------------------------- 1 file changed, 109 insertions(+), 200 deletions(-) diff --git a/todo/views/auth.py b/todo/views/auth.py index f1ced78a..99bd4be6 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -14,59 +14,23 @@ generate_google_token_pair, ) from todo.exceptions.google_auth_exceptions import ( - GoogleAuthException, GoogleTokenInvalidError, - GoogleRefreshTokenExpiredError, GoogleUserNotFoundException, ) from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource -from todo.constants.messages import ApiErrors, AuthErrorMessages, AppMessages +from todo.constants.messages import AuthErrorMessages, AppMessages class GoogleLoginView(APIView): def get(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url) - request.session["oauth_state"] = state - - if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": - return Response({"authUrl": auth_url, "state": state}) + redirect_url = request.query_params.get("redirectURL") + auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url) + request.session["oauth_state"] = state - return HttpResponseRedirect(auth_url) - - except GoogleAuthException as e: - error_response = ApiErrorResponse( - statusCode=400, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "google_auth"}, - title=ApiErrors.GOOGLE_AUTH_FAILED, - detail=str(e), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST - ) + if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": + return Response({"authUrl": auth_url, "state": state}) - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "oauth_initialization"}, - title=ApiErrors.UNEXPECTED_ERROR, - detail=ApiErrors.OAUTH_INITIALIZATION_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + return HttpResponseRedirect(auth_url) class GoogleCallbackView(APIView): @@ -81,103 +45,86 @@ class GoogleCallbackView(APIView): # Temporary implementation for testing and development def get(self, request: Request): - try: - if "error" in request.query_params: - error = request.query_params.get("error") - return HttpResponse(f""" -

❌ OAuth Error

-

Error: {error}

- Try Again - """) - - code = request.query_params.get("code") - state = request.query_params.get("state") - - if not code: - return HttpResponse(""" -

❌ Missing Authorization Code

-

No authorization code received from Google

- Try Again - """) - - stored_state = request.session.get("oauth_state") - if not stored_state or stored_state != state: - return HttpResponse(f""" -

❌ Invalid State Parameter

-

Stored state: {stored_state}

-

Received state: {state}

-

This might be a security issue.

- Try Again - """) - - return self._handle_callback_directly(code, request) - - except Exception as e: + if "error" in request.query_params: + error = request.query_params.get("error") return HttpResponse(f""" -

❌ Authentication Failed

-

Error: {ApiErrors.AUTHENTICATION_FAILED.format(str(e))}

+

❌ OAuth Error

+

Error: {error}

Try Again """) - def _handle_callback_directly(self, code, request): - try: - google_data = GoogleOAuthService.handle_callback(code) - user = UserService.create_or_update_user(google_data) - - tokens = generate_google_token_pair( - { - "user_id": str(user.id), - "google_id": user.google_id, - "email": user.email_id, - "name": user.name, - } - ) + code = request.query_params.get("code") + state = request.query_params.get("state") - response = HttpResponse(f""" - - ✅ Login Successful - -

✅ Google OAuth Login Successful!

- -

🧑‍💻 User Info:

-
    -
  • ID: {user.id}
  • -
  • Name: {user.name}
  • -
  • Email: {user.email_id}
  • -
  • Google ID: {user.google_id}
  • -
- -

🍪 Authentication Cookies Set:

-
    -
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • -
  • Refresh Token: ext-refresh (expires in 7 days)
  • -
- -

🧪 Test Other Endpoints:

- - -

Google OAuth integration is working perfectly!

- - + if not code: + return HttpResponse(""" +

❌ Missing Authorization Code

+

No authorization code received from Google

+ Try Again """) - self._set_auth_cookies(response, tokens) - request.session.pop("oauth_state", None) - - return response - - except Exception as e: + stored_state = request.session.get("oauth_state") + if not stored_state or stored_state != state: return HttpResponse(f""" -

❌ OAuth Processing Failed

-

Error: {ApiErrors.AUTHENTICATION_FAILED.format(str(e))}

-

Code received: {code[:20]}...

+

❌ Invalid State Parameter

+

Stored state: {stored_state}

+

Received state: {state}

+

This might be a security issue.

Try Again """) + return self._handle_callback_directly(code, request) + + def _handle_callback_directly(self, code, request): + google_data = GoogleOAuthService.handle_callback(code) + user = UserService.create_or_update_user(google_data) + + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.google_id, + "email": user.email_id, + "name": user.name, + } + ) + + response = HttpResponse(f""" + + ✅ Login Successful + +

✅ Google OAuth Login Successful!

+ +

🧑‍💻 User Info:

+
    +
  • ID: {user.id}
  • +
  • Name: {user.name}
  • +
  • Email: {user.email_id}
  • +
  • Google ID: {user.google_id}
  • +
+ +

🍪 Authentication Cookies Set:

+
    +
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • +
  • Refresh Token: ext-refresh (expires in 7 days)
  • +
+ +

🧪 Test Other Endpoints:

+ + +

Google OAuth integration is working perfectly!

+ + + """) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + def _get_cookie_config(self): return { "path": "/", @@ -411,50 +358,30 @@ def get(self, request: Request): class GoogleRefreshView(APIView): def get(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - refresh_token = request.COOKIES.get("ext-refresh") + redirect_url = request.query_params.get("redirectURL") + refresh_token = request.COOKIES.get("ext-refresh") - if not refresh_token: - return self._handle_missing_token(redirect_url) + if not refresh_token: + return self._handle_missing_token(redirect_url) - payload = validate_google_refresh_token(refresh_token) + 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({"success": True, "message": AppMessages.TOKEN_REFRESHED}) + 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) - config = self._get_cookie_config() - response.set_cookie( - "ext-access", new_access_token, max_age=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"], **config - ) + response = Response({"success": True, "message": AppMessages.TOKEN_REFRESHED}) - return response + config = self._get_cookie_config() + response.set_cookie( + "ext-access", new_access_token, max_age=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"], **config + ) - except (GoogleTokenInvalidError, GoogleRefreshTokenExpiredError) as e: - return self._handle_expired_token(redirect_url, str(e)) - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=ApiErrors.TOKEN_REFRESH_FAILED, - detail=ApiErrors.TOKEN_REFRESH_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + return response def _get_cookie_config(self): return { @@ -519,40 +446,22 @@ def post(self, request: Request): return self._handle_logout(request) def _handle_logout(self, request: Request): - try: - redirect_url = request.query_params.get("redirectURL") - - wants_json = ( - request.headers.get("Accept") == "application/json" - or request.data.get("format") == "json" - or request.method == "POST" - ) + redirect_url = request.query_params.get("redirectURL") - if wants_json: - response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) - else: - redirect_url = redirect_url or "/" - response = HttpResponseRedirect(redirect_url) + wants_json = ( + request.headers.get("Accept") == "application/json" + or request.data.get("format") == "json" + or request.method == "POST" + ) - domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") - response.delete_cookie("ext-access", domain=domain) - response.delete_cookie("ext-refresh", domain=domain) + if wants_json: + response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) + else: + redirect_url = redirect_url or "/" + response = HttpResponseRedirect(redirect_url) - return response + domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") + response.delete_cookie("ext-access", domain=domain) + response.delete_cookie("ext-refresh", domain=domain) - except Exception as e: - error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.LOGOUT_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "logout"}, - title=ApiErrors.LOGOUT_FAILED, - detail=ApiErrors.LOGOUT_FAILED.format(str(e)), - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + return response From 8936274f84552a68d06523acd6f37e6da835bc78 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 16 Jun 2025 02:15:37 +0530 Subject: [PATCH 10/17] refactored auth views --- todo/views/auth.py | 308 +++++++++++++++------------------------------ 1 file changed, 101 insertions(+), 207 deletions(-) diff --git a/todo/views/auth.py b/todo/views/auth.py index 99bd4be6..eccc57a4 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -13,10 +13,7 @@ generate_google_access_token, generate_google_token_pair, ) -from todo.exceptions.google_auth_exceptions import ( - GoogleTokenInvalidError, - GoogleUserNotFoundException, -) + from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource from todo.constants.messages import AuthErrorMessages, AppMessages @@ -125,6 +122,9 @@ def _handle_callback_directly(self, code, request): return response + def post(self, request): + pass + def _get_cookie_config(self): return { "path": "/", @@ -136,19 +136,15 @@ def _get_cookie_config(self): def _set_auth_cookies(self, response, tokens): config = self._get_cookie_config() - response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) - response.set_cookie( "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config ) - def post(self, request): - pass - # Frontend integration implementation (to be used later) - """ - class GoogleCallbackView(APIView): +# Frontend integration implementation (to be used later) +""" +class GoogleCallbackViewFrontend(APIView): def get(self, request: Request): code = request.query_params.get("code") state = request.query_params.get("state") @@ -164,106 +160,75 @@ def get(self, request: Request): return HttpResponseRedirect(f"{frontend_callback}?error=missing_parameters") def post(self, request: Request): - try: - code = request.data.get("code") - state = request.data.get("state") - - if not code: - error_response = ApiErrorResponse( - statusCode=400, - message=ApiErrors.INVALID_AUTH_CODE, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "code"}, - title=ApiErrors.VALIDATION_ERROR, - detail=ApiErrors.INVALID_AUTH_CODE, - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST - ) + code = request.data.get("code") + state = request.data.get("state") - stored_state = request.session.get("oauth_state") - if not stored_state or stored_state != state: - error_response = ApiErrorResponse( - statusCode=400, - message=ApiErrors.INVALID_STATE_PARAMETER, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "state"}, - title=ApiErrors.VALIDATION_ERROR, - detail=ApiErrors.INVALID_STATE_PARAMETER, - ) - ], - ) - return Response( - data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST + if not code: + formatted_errors = [ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "code"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_AUTH_CODE, ) - - google_data = GoogleOAuthService.handle_callback(code) - user = UserService.create_or_update_user(google_data) - - tokens = generate_google_token_pair( - { - "user_id": str(user.id), - "google_id": user.google_id, - "email": user.email_id, - "name": user.name, - } - ) - - response = Response( - { - "success": True, - "user": { - "id": str(user.id), - "email": user.email_id, - "name": user.name, - "google_id": user.google_id, - }, - } - ) - - self._set_auth_cookies(response, tokens) - request.session.pop("oauth_state", None) - - return response - - except (GoogleAPIException, GoogleUserNotFoundException) as e: + ] error_response = ApiErrorResponse( - statusCode=500, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "google_auth"}, - title=ApiErrors.AUTHENTICATION_FAILED, - detail=str(e), - ) - ], + statusCode=400, + message=ApiErrors.INVALID_AUTH_CODE, + errors=formatted_errors ) return Response( data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST ) - except Exception as e: + stored_state = request.session.get("oauth_state") + if not stored_state or stored_state != state: + formatted_errors = [ + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: "state"}, + title=ApiErrors.VALIDATION_ERROR, + detail=ApiErrors.INVALID_STATE_PARAMETER, + ) + ] error_response = ApiErrorResponse( - statusCode=500, - message=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.PARAMETER: "authentication"}, - title=ApiErrors.AUTHENTICATION_FAILED, - detail=ApiErrors.AUTHENTICATION_FAILED.format(str(e)), - ) - ], + statusCode=400, + message=ApiErrors.INVALID_STATE_PARAMETER, + errors=formatted_errors ) return Response( data=error_response.model_dump(mode="json", exclude_none=True), - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST ) + google_data = GoogleOAuthService.handle_callback(code) + user = UserService.create_or_update_user(google_data) + + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.google_id, + "email": user.email_id, + "name": user.name, + } + ) + + response = Response( + { + "success": True, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, + } + ) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + def _get_cookie_config(self): return { "path": "/", @@ -275,94 +240,68 @@ def _get_cookie_config(self): def _set_auth_cookies(self, response, tokens): config = self._get_cookie_config() - response.set_cookie("ext-access", tokens["access_token"], max_age=tokens["expires_in"], **config) - response.set_cookie( "ext-refresh", tokens["refresh_token"], max_age=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"], **config ) - """ +""" class GoogleAuthStatusView(APIView): def get(self, request: Request): - try: - access_token = request.COOKIES.get("ext-access") - - if not access_token: - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.NO_ACCESS_TOKEN, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_ACCESS_TOKEN, - ) - ], - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - payload = validate_google_access_token(access_token) - user = UserService.get_user_by_id(payload["user_id"]) - - return Response( - { - "authenticated": True, - "user": { - "id": str(user.id), - "email": user.email_id, - "name": user.name, - "google_id": user.google_id, - }, - } - ) + access_token = request.COOKIES.get("ext-access") - except (GoogleTokenInvalidError, GoogleUserNotFoundException) as e: + if not access_token: + formatted_errors = [ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=AuthErrorMessages.NO_ACCESS_TOKEN, + ) + ] error_response = ApiErrorResponse( - statusCode=401, - message=str(e), - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=str(e), - ) - ], + statusCode=401, message=AuthErrorMessages.NO_ACCESS_TOKEN, errors=formatted_errors ) return Response( data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, status=status.HTTP_401_UNAUTHORIZED, ) - except Exception: - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.TOKEN_INVALID, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.INVALID_TOKEN_TITLE, - detail=AuthErrorMessages.TOKEN_INVALID, - ) - ], - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) + payload = validate_google_access_token(access_token) + user = UserService.get_user_by_id(payload["user_id"]) + + return Response( + { + "authenticated": True, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, + } + ) class GoogleRefreshView(APIView): def get(self, request: Request): - redirect_url = request.query_params.get("redirectURL") refresh_token = request.COOKIES.get("ext-refresh") if not refresh_token: - return self._handle_missing_token(redirect_url) + formatted_errors = [ + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=AuthErrorMessages.NO_REFRESH_TOKEN, + ) + ] + error_response = ApiErrorResponse( + statusCode=401, message=AuthErrorMessages.NO_REFRESH_TOKEN, errors=formatted_errors + ) + return Response( + data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, + status=status.HTTP_401_UNAUTHORIZED, + ) payload = validate_google_refresh_token(refresh_token) @@ -392,51 +331,6 @@ def _get_cookie_config(self): "samesite": settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_SAMESITE", "Lax"), } - def _handle_missing_token(self, redirect_url): - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.NO_REFRESH_TOKEN, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_REFRESH_TOKEN, - ) - ], - ) - - response = Response( - data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - self._clear_auth_cookies(response) - return response - - def _handle_expired_token(self, redirect_url, error_detail): - error_response = ApiErrorResponse( - statusCode=401, - message=AuthErrorMessages.GOOGLE_REFRESH_TOKEN_EXPIRED, - errors=[ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.TOKEN_EXPIRED_TITLE, - detail=error_detail, - ) - ], - ) - - response = Response( - data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - self._clear_auth_cookies(response) - return response - - def _clear_auth_cookies(self, response): - domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") - response.delete_cookie("ext-access", domain=domain) - response.delete_cookie("ext-refresh", domain=domain) - class GoogleLogoutView(APIView): def get(self, request: Request): From 9e0a41159047a49d90bbaf2fd108381c091106a2 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Mon, 16 Jun 2025 02:23:11 +0530 Subject: [PATCH 11/17] refactored unused field --- todo/repositories/user_repository.py | 1 - 1 file changed, 1 deletion(-) diff --git a/todo/repositories/user_repository.py b/todo/repositories/user_repository.py index 06d476b8..e4bddd34 100644 --- a/todo/repositories/user_repository.py +++ b/todo/repositories/user_repository.py @@ -37,7 +37,6 @@ def create_or_update(cls, user_data: dict) -> UserModel: "email_id": user_data["email"], "name": user_data["name"], "updated_at": now, - "last_login_at": now, }, "$setOnInsert": {"google_id": google_id, "created_at": now}, }, From 4101eb49013f0f0646c2104b5b3cbbb29e98f5a5 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 17 Jun 2025 02:09:26 +0530 Subject: [PATCH 12/17] resolved review comments and added status code to api responses --- .env.example | 2 +- todo/exceptions/exception_handler.py | 2 +- todo/middlewares/jwt_auth.py | 39 ++-- todo/models/user.py | 2 +- todo/repositories/user_repository.py | 7 +- todo/services/google_oauth_service.py | 6 +- todo/services/user_service.py | 2 +- todo/tests/integration/base_mongo_test.py | 1 + .../tests/integration/test_task_detail_api.py | 18 +- todo/tests/integration/test_tasks_delete.py | 15 +- todo/tests/testcontainers/mongo_container.py | 9 +- todo/tests/testcontainers/shared_mongo.py | 1 + todo/urls.py | 10 +- todo/utils/jwt_utils.py | 6 +- todo/views/auth.py | 178 +++++++++++------- todo_project/db/config.py | 4 +- todo_project/settings/base.py | 10 +- 17 files changed, 185 insertions(+), 127 deletions(-) diff --git a/.env.example b/.env.example index 923c824f..99b0ef3a 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ 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_SECRET_KEY=generate-secret-key \ No newline at end of file diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index b01480a9..8f2488e0 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -93,7 +93,7 @@ def handle_exception(exc, context): ) ) elif isinstance(exc, GoogleRefreshTokenExpiredError): - status_code = status.HTTP_401_UNAUTHORIZED + status_code = status.HTTP_403_FORBIDDEN error_list.append( ApiErrorDetail( source={ApiErrorSource.HEADER: "Authorization"}, diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index ce90138f..4220ea9a 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -1,18 +1,18 @@ from django.conf import settings -from django.http import JsonResponse from rest_framework import status +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.exceptions.auth_exceptions import TokenMissingError, TokenExpiredError, TokenInvalidError from todo.exceptions.google_auth_exceptions import GoogleTokenExpiredError, GoogleTokenInvalidError from todo.constants.messages import AuthErrorMessages, ApiErrors +from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail class JWTAuthenticationMiddleware: def __init__(self, get_response) -> None: self.get_response = get_response - self.rds_cookie_name = settings.JWT_COOKIE_SETTINGS["RDS_SESSION_V2_COOKIE_NAME"] def __call__(self, request): @@ -27,14 +27,24 @@ def __call__(self, request): if auth_success: return self.get_response(request) else: - return self._unauthorized_response(AuthErrorMessages.AUTHENTICATION_REQUIRED) + error_response = ApiErrorResponse( + statusCode=status.HTTP_401_UNAUTHORIZED, + message=AuthErrorMessages.AUTHENTICATION_REQUIRED, + errors=[ApiErrorDetail(detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + ) + return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: return self._handle_rds_auth_error(e) except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: return self._handle_google_auth_error(e) except Exception: - return self._unauthorized_response(ApiErrors.AUTHENTICATION_FAILED.format("")) + error_response = ApiErrorResponse( + statusCode=status.HTTP_401_UNAUTHORIZED, + message=ApiErrors.AUTHENTICATION_FAILED.format(""), + errors=[ApiErrorDetail(detail=ApiErrors.AUTHENTICATION_FAILED.format(""), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + ) + return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def _try_authentication(self, request) -> bool: if self._try_google_auth(request): @@ -63,8 +73,8 @@ def _try_google_auth(self, request) -> bool: return True - except (GoogleTokenExpiredError, GoogleTokenInvalidError): - return False + except (GoogleTokenExpiredError, GoogleTokenInvalidError) as e: + raise e except Exception: return False @@ -92,17 +102,20 @@ def _is_public_path(self, path: str) -> bool: return any(path.startswith(public_path) for public_path in settings.PUBLIC_PATHS) def _handle_rds_auth_error(self, exception): - return JsonResponse( - {"error": True, "message": str(exception), "auth_type": "rds"}, status=status.HTTP_401_UNAUTHORIZED + error_response = ApiErrorResponse( + statusCode=status.HTTP_401_UNAUTHORIZED, + message=str(exception), + errors=[ApiErrorDetail(detail=str(exception), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], ) + return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def _handle_google_auth_error(self, exception): - return JsonResponse( - {"error": True, "message": str(exception), "auth_type": "google"}, status=status.HTTP_401_UNAUTHORIZED + error_response = ApiErrorResponse( + statusCode=status.HTTP_401_UNAUTHORIZED, + message=str(exception), + errors=[ApiErrorDetail(detail=str(exception), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], ) - - def _unauthorized_response(self, message: str): - return JsonResponse({"error": True, "message": message}, status=status.HTTP_401_UNAUTHORIZED) + return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def is_google_user(request) -> bool: diff --git a/todo/models/user.py b/todo/models/user.py index 905263a1..e72021a5 100644 --- a/todo/models/user.py +++ b/todo/models/user.py @@ -17,4 +17,4 @@ class UserModel(Document): email_id: EmailStr name: str created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime | None = None diff --git a/todo/repositories/user_repository.py b/todo/repositories/user_repository.py index e4bddd34..8d309d47 100644 --- a/todo/repositories/user_repository.py +++ b/todo/repositories/user_repository.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone from typing import Optional +from pymongo.collection import ReturnDocument from todo.models.user import UserModel from todo.models.common.pyobjectid import PyObjectId @@ -20,8 +21,8 @@ def get_by_id(cls, user_id: str) -> Optional[UserModel]: object_id = PyObjectId(user_id) doc = collection.find_one({"_id": object_id}) return UserModel(**doc) if doc else None - except Exception: - raise GoogleUserNotFoundException() + except Exception as e: + raise GoogleUserNotFoundException() from e @classmethod def create_or_update(cls, user_data: dict) -> UserModel: @@ -41,7 +42,7 @@ def create_or_update(cls, user_data: dict) -> UserModel: "$setOnInsert": {"google_id": google_id, "created_at": now}, }, upsert=True, - return_document=True, + return_document=ReturnDocument.AFTER, ) if not result: diff --git a/todo/services/google_oauth_service.py b/todo/services/google_oauth_service.py index d01bf73f..52c157fa 100644 --- a/todo/services/google_oauth_service.py +++ b/todo/services/google_oauth_service.py @@ -13,13 +13,13 @@ class GoogleOAuthService: GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" @classmethod - def get_authorization_url(cls, redirect_url: str = None) -> tuple[str, str]: + def get_authorization_url(cls, redirect_url: str | None = None) -> tuple[str, str]: try: state = secrets.token_urlsafe(32) params = { "client_id": settings.GOOGLE_OAUTH["CLIENT_ID"], - "redirect_uri": settings.GOOGLE_OAUTH["REDIRECT_URI"], + "redirect_uri": redirect_url or settings.GOOGLE_OAUTH["REDIRECT_URI"], "response_type": "code", "scope": " ".join(settings.GOOGLE_OAUTH["SCOPES"]), "access_type": "offline", @@ -50,7 +50,7 @@ def handle_callback(cls, authorization_code: str) -> dict: except Exception as e: if isinstance(e, GoogleAPIException): raise - raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) + raise GoogleAPIException(ApiErrors.GOOGLE_API_ERROR) from e @classmethod def _exchange_code_for_tokens(cls, code: str) -> dict: diff --git a/todo/services/user_service.py b/todo/services/user_service.py index ff7cff44..1acb6d76 100644 --- a/todo/services/user_service.py +++ b/todo/services/user_service.py @@ -14,7 +14,7 @@ def create_or_update_user(cls, google_user_data: dict) -> UserModel: except (GoogleUserNotFoundException, GoogleAPIException, DRFValidationError): raise except Exception as e: - raise GoogleAPIException(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format(str(e))) + raise GoogleAPIException(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format(str(e))) from e @classmethod def get_user_by_id(cls, user_id: str) -> UserModel: diff --git a/todo/tests/integration/base_mongo_test.py b/todo/tests/integration/base_mongo_test.py index 3b106fc0..5e034622 100644 --- a/todo/tests/integration/base_mongo_test.py +++ b/todo/tests/integration/base_mongo_test.py @@ -3,6 +3,7 @@ from todo.tests.testcontainers.shared_mongo import get_shared_mongo_container from todo_project.db.config import DatabaseManager + class BaseMongoTestCase(TransactionTestCase): @classmethod def setUpClass(cls): diff --git a/todo/tests/integration/test_task_detail_api.py b/todo/tests/integration/test_task_detail_api.py index 4c34556e..0cb37ef1 100644 --- a/todo/tests/integration/test_task_detail_api.py +++ b/todo/tests/integration/test_task_detail_api.py @@ -20,7 +20,7 @@ def setUp(self): self.client = APIClient() def test_get_task_by_id_success(self): - url = reverse('task_detail', args=[self.existing_task_id]) + url = reverse("task_detail", args=[self.existing_task_id]) response = self.client.get(url) self.assertEqual(response.status_code, HTTPStatus.OK) data = response.json()["data"] @@ -29,11 +29,10 @@ def test_get_task_by_id_success(self): self.assertEqual(data["priority"], "MEDIUM") self.assertEqual(data["status"], self.task_doc["status"]) self.assertEqual(data["displayId"], self.task_doc["displayId"]) - self.assertEqual(data["createdBy"]["id"], - self.task_doc["createdBy"]) + self.assertEqual(data["createdBy"]["id"], self.task_doc["createdBy"]) def test_get_task_by_id_not_found(self): - url = reverse('task_detail', args=[self.non_existent_id]) + url = reverse("task_detail", args=[self.non_existent_id]) response = self.client.get(url) self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) @@ -46,15 +45,12 @@ def test_get_task_by_id_not_found(self): self.assertEqual(error["detail"], error_message) def test_get_task_by_id_invalid_format(self): - url = reverse('task_detail', args=[self.invalid_task_id]) + url = reverse("task_detail", args=[self.invalid_task_id]) response = self.client.get(url) self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) data = response.json() self.assertEqual(data["statusCode"], 400) - self.assertEqual( - data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT) + self.assertEqual(data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT) self.assertEqual(data["errors"][0]["source"]["path"], "task_id") - self.assertEqual(data["errors"][0]["title"], - ApiErrors.VALIDATION_ERROR) - self.assertEqual(data["errors"][0]["detail"], - ValidationErrors.INVALID_TASK_ID_FORMAT) + self.assertEqual(data["errors"][0]["title"], ApiErrors.VALIDATION_ERROR) + self.assertEqual(data["errors"][0]["detail"], ValidationErrors.INVALID_TASK_ID_FORMAT) diff --git a/todo/tests/integration/test_tasks_delete.py b/todo/tests/integration/test_tasks_delete.py index 0f89cccb..099d7ec5 100644 --- a/todo/tests/integration/test_tasks_delete.py +++ b/todo/tests/integration/test_tasks_delete.py @@ -20,12 +20,12 @@ def setUp(self): self.client = APIClient() def test_delete_task_success(self): - url = reverse('task_detail', args=[self.existing_task_id]) + url = reverse("task_detail", args=[self.existing_task_id]) response = self.client.delete(url) self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) def test_delete_task_not_found(self): - url = reverse('task_detail', args=[self.non_existent_id]) + url = reverse("task_detail", args=[self.non_existent_id]) response = self.client.delete(url) self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) response_data = response.json() @@ -37,15 +37,12 @@ def test_delete_task_not_found(self): self.assertEqual(error["detail"], error_message) def test_delete_task_invalid_id_format(self): - url = reverse('task_detail', args=[self.invalid_task_id]) + url = reverse("task_detail", args=[self.invalid_task_id]) response = self.client.delete(url) self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) data = response.json() self.assertEqual(data["statusCode"], 400) - self.assertEqual( - data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT) + self.assertEqual(data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT) self.assertEqual(data["errors"][0]["source"]["path"], "task_id") - self.assertEqual(data["errors"][0]["title"], - ApiErrors.VALIDATION_ERROR) - self.assertEqual(data["errors"][0]["detail"], - ValidationErrors.INVALID_TASK_ID_FORMAT) + self.assertEqual(data["errors"][0]["title"], ApiErrors.VALIDATION_ERROR) + self.assertEqual(data["errors"][0]["detail"], ValidationErrors.INVALID_TASK_ID_FORMAT) diff --git a/todo/tests/testcontainers/mongo_container.py b/todo/tests/testcontainers/mongo_container.py index 0a405b6b..4ff3cd7c 100644 --- a/todo/tests/testcontainers/mongo_container.py +++ b/todo/tests/testcontainers/mongo_container.py @@ -18,11 +18,9 @@ def start(self): mapped_port = self.get_exposed_port(27017) container_ip = self._container.attrs["NetworkSettings"]["IPAddress"] member_host = f"{container_ip}:27017" - initiate_js = json.dumps( - {"_id": "rs0", "members": [{"_id": 0, "host": member_host}]}) + initiate_js = json.dumps({"_id": "rs0", "members": [{"_id": 0, "host": member_host}]}) wait_for_logs(self, r"Waiting for connections", timeout=20) - cmd = ["mongosh", "--quiet", "--host", "localhost", "--port", - "27017", "--eval", f"rs.initiate({initiate_js})"] + cmd = ["mongosh", "--quiet", "--host", "localhost", "--port", "27017", "--eval", f"rs.initiate({initiate_js})"] exit_code, output = self.exec(cmd) if exit_code != 0: raise RuntimeError( @@ -46,5 +44,4 @@ def _wait_for_primary(self, timeout=10): except Exception as e: print(f"Waiting for PRIMARY: {e}") time.sleep(0.5) - raise TimeoutError( - "Timed out waiting for replica set to become PRIMARY.") + raise TimeoutError("Timed out waiting for replica set to become PRIMARY.") diff --git a/todo/tests/testcontainers/shared_mongo.py b/todo/tests/testcontainers/shared_mongo.py index be28d8d4..61dbf066 100644 --- a/todo/tests/testcontainers/shared_mongo.py +++ b/todo/tests/testcontainers/shared_mongo.py @@ -3,6 +3,7 @@ _mongo_container = None + def _cleanup_mongo_container(): global _mongo_container if _mongo_container is not None: diff --git a/todo/urls.py b/todo/urls.py index 94aab903..d370c6af 100644 --- a/todo/urls.py +++ b/todo/urls.py @@ -13,9 +13,9 @@ path("tasks", TaskListView.as_view(), name="tasks"), path("tasks/", TaskDetailView.as_view(), name="task_detail"), path("health", HealthView.as_view(), name="health"), - path("auth/google/login/", GoogleLoginView.as_view()), - path("auth/google/callback/", GoogleCallbackView.as_view()), - path("auth/google/status/", GoogleAuthStatusView.as_view()), - path("auth/google/refresh/", GoogleRefreshView.as_view()), - path("auth/google/logout/", GoogleLogoutView.as_view()), + path("auth/google/login/", GoogleLoginView.as_view(), name="google_login"), + path("auth/google/callback/", GoogleCallbackView.as_view(), name="google_callback"), + path("auth/google/status/", GoogleAuthStatusView.as_view(), name="google_status"), + 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/jwt_utils.py b/todo/utils/jwt_utils.py index 9ac41fb3..afead6d5 100644 --- a/todo/utils/jwt_utils.py +++ b/todo/utils/jwt_utils.py @@ -1,6 +1,6 @@ import jwt from django.conf import settings -from todo.exceptions.auth_exceptions import TokenExpiredError, TokenInvalidError +from todo.exceptions.auth_exceptions import TokenExpiredError, TokenInvalidError, TokenMissingError def verify_jwt_token(token: str) -> dict: @@ -18,13 +18,13 @@ def verify_jwt_token(token: str) -> dict: TokenInvalidError: If token is invalid """ if not token or not token.strip(): - raise TokenInvalidError() + raise TokenMissingError() try: public_key = settings.JWT_AUTH["PUBLIC_KEY"] algorithm = settings.JWT_AUTH["ALGORITHM"] - if not public_key: + if not public_key or not algorithm: raise TokenInvalidError() payload = jwt.decode( diff --git a/todo/views/auth.py b/todo/views/auth.py index eccc57a4..e6753285 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -25,7 +25,14 @@ def get(self, request: Request): request.session["oauth_state"] = state if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": - return Response({"authUrl": auth_url, "state": state}) + return Response({ + "statusCode": status.HTTP_200_OK, + "message": "Google OAuth URL generated successfully", + "data": { + "authUrl": auth_url, + "state": state + } + }) return HttpResponseRedirect(auth_url) @@ -44,31 +51,32 @@ class GoogleCallbackView(APIView): def get(self, request: Request): if "error" in request.query_params: error = request.query_params.get("error") - return HttpResponse(f""" -

❌ OAuth Error

-

Error: {error}

- Try Again - """) + error_response = ApiErrorResponse( + statusCode=status.HTTP_400_BAD_REQUEST, + message="OAuth Error", + errors=[ApiErrorDetail(detail=error, title="OAuth Error")] + ) + return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) code = request.query_params.get("code") state = request.query_params.get("state") if not code: - return HttpResponse(""" -

❌ Missing Authorization Code

-

No authorization code received from Google

- Try Again - """) + error_response = ApiErrorResponse( + statusCode=status.HTTP_400_BAD_REQUEST, + message="Missing Authorization Code", + errors=[ApiErrorDetail(detail="No authorization code received from Google", title="Missing Authorization Code")] + ) + return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) stored_state = request.session.get("oauth_state") if not stored_state or stored_state != state: - return HttpResponse(f""" -

❌ Invalid State Parameter

-

Stored state: {stored_state}

-

Received state: {state}

-

This might be a security issue.

- Try Again - """) + error_response = ApiErrorResponse( + statusCode=status.HTTP_400_BAD_REQUEST, + message="Invalid State Parameter", + errors=[ApiErrorDetail(detail="This might be a security issue", title="Invalid State Parameter")] + ) + return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) return self._handle_callback_directly(code, request) @@ -85,46 +93,66 @@ def _handle_callback_directly(self, code, request): } ) - response = HttpResponse(f""" - - ✅ Login Successful - -

✅ Google OAuth Login Successful!

- -

🧑‍💻 User Info:

-
    -
  • ID: {user.id}
  • -
  • Name: {user.name}
  • -
  • Email: {user.email_id}
  • -
  • Google ID: {user.google_id}
  • -
- -

🍪 Authentication Cookies Set:

-
    -
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • -
  • Refresh Token: ext-refresh (expires in 7 days)
  • -
- -

🧪 Test Other Endpoints:

- - -

Google OAuth integration is working perfectly!

- - - """) + wants_json = ( + "application/json" in request.headers.get("Accept", "").lower() + or request.query_params.get("format") == "json" + ) + + if wants_json: + 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"] + } + } + }) + else: + response = HttpResponse(f""" + + ✅ Login Successful + +

✅ Google OAuth Login Successful!

+ +

🧑‍💻 User Info:

+
    +
  • ID: {user.id}
  • +
  • Name: {user.name}
  • +
  • Email: {user.email_id}
  • +
  • Google ID: {user.google_id}
  • +
+ +

🍪 Authentication Cookies Set:

+
    +
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • +
  • Refresh Token: ext-refresh (expires in 7 days)
  • +
+ +

🧪 Test Other Endpoints:

+ + +

Google OAuth integration is working perfectly!

+ + + """) self._set_auth_cookies(response, tokens) request.session.pop("oauth_state", None) return response - def post(self, request): - pass - def _get_cookie_config(self): return { "path": "/", @@ -270,17 +298,19 @@ def get(self, request: Request): payload = validate_google_access_token(access_token) user = UserService.get_user_by_id(payload["user_id"]) - return Response( - { + return Response({ + "statusCode": status.HTTP_200_OK, + "message": "Authentication status retrieved successfully", + "data": { "authenticated": True, "user": { "id": str(user.id), "email": user.email_id, "name": user.name, "google_id": user.google_id, - }, + } } - ) + }) class GoogleRefreshView(APIView): @@ -313,7 +343,13 @@ def get(self, request: Request): } new_access_token = generate_google_access_token(user_data) - response = Response({"success": True, "message": AppMessages.TOKEN_REFRESHED}) + response = Response({ + "statusCode": status.HTTP_200_OK, + "message": AppMessages.TOKEN_REFRESHED, + "data": { + "success": True + } + }) config = self._get_cookie_config() response.set_cookie( @@ -343,19 +379,35 @@ def _handle_logout(self, request: Request): redirect_url = request.query_params.get("redirectURL") wants_json = ( - request.headers.get("Accept") == "application/json" - or request.data.get("format") == "json" + "application/json" in request.headers.get("Accept", "").lower() + or request.query_params.get("format") == "json" or request.method == "POST" ) if wants_json: - response = Response({"success": True, "message": AppMessages.GOOGLE_LOGOUT_SUCCESS}) + 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) - domain = settings.GOOGLE_COOKIE_SETTINGS.get("COOKIE_DOMAIN") - response.delete_cookie("ext-access", domain=domain) - response.delete_cookie("ext-refresh", domain=domain) + response.delete_cookie("ext-access", path="/") + response.delete_cookie("ext-refresh", path="/") + response.delete_cookie(settings.SESSION_COOKIE_NAME, path="/") + request.session.flush() return response + + 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"), + } diff --git a/todo_project/db/config.py b/todo_project/db/config.py index 226ac045..b779f9cf 100644 --- a/todo_project/db/config.py +++ b/todo_project/db/config.py @@ -39,9 +39,9 @@ def check_database_health(self): except ConnectionFailure as e: logger.error(f"Failed to establish database connection: {e}") return False - + @classmethod def reset(cls): if cls.__instance is not None and cls.__instance._database_client is not None: cls.__instance._database_client.close() - cls.__instance = None \ No newline at end of file + cls.__instance = None diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index bd3c0e7f..e5738722 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -11,7 +11,7 @@ ) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False ALLOWED_HOSTS = [] @@ -27,10 +27,10 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.common.CommonMiddleware", "todo.middlewares.jwt_auth.JWTAuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "todo_project.urls" @@ -78,14 +78,14 @@ JWT_AUTH = { "ALGORITHM": "RS256", - "PUBLIC_KEY": os.getenv("RDS_PUBLIC_KEY"), + "PUBLIC_KEY": os.getenv("RDS_PUBLIC_KEY") or "", } JWT_COOKIE_SETTINGS = { "RDS_SESSION_COOKIE_NAME": os.getenv("RDS_SESSION_COOKIE_NAME", "rds-session-development"), "RDS_SESSION_V2_COOKIE_NAME": os.getenv("RDS_SESSION_V2_COOKIE_NAME", "rds-session-v2-development"), "COOKIE_DOMAIN": os.getenv("COOKIE_DOMAIN", None), - "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "False").lower() == "true", + "COOKIE_SECURE": os.getenv("COOKIE_SECURE", "True").lower() == "true", "COOKIE_HTTPONLY": True, "COOKIE_SAMESITE": os.getenv("COOKIE_SAMESITE", "None"), "COOKIE_PATH": "/", From a18d7003c98919adcc7e71e006f50850a01e855c Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 17 Jun 2025 03:00:41 +0530 Subject: [PATCH 13/17] resolved pr comments --- todo/dto/responses/error_response.py | 1 + todo/exceptions/exception_handler.py | 28 +++ todo/exceptions/google_auth_exceptions.py | 5 + todo/middlewares/jwt_auth.py | 20 +- todo/utils/google_jwt_utils.py | 15 -- todo/utils/jwt_utils.py | 14 +- todo/views/auth.py | 265 ++++++++++------------ todo_project/settings/staging.py | 2 +- 8 files changed, 173 insertions(+), 177 deletions(-) diff --git a/todo/dto/responses/error_response.py b/todo/dto/responses/error_response.py index 64cee740..359aa13e 100644 --- a/todo/dto/responses/error_response.py +++ b/todo/dto/responses/error_response.py @@ -20,3 +20,4 @@ class ApiErrorResponse(BaseModel): statusCode: int message: str errors: List[ApiErrorDetail] + authenticated: bool | None = None diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index 8f2488e0..7de468b2 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -65,6 +65,13 @@ def handle_exception(exc, context): detail=str(exc), ) ) + final_response_data = ApiErrorResponse( + statusCode=status_code, + message=str(exc) if not error_list else error_list[0].detail, + errors=error_list, + authenticated=False + ) + return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, TokenInvalidError): status_code = status.HTTP_401_UNAUTHORIZED error_list.append( @@ -74,6 +81,13 @@ def handle_exception(exc, context): detail=str(exc), ) ) + final_response_data = ApiErrorResponse( + statusCode=status_code, + message=str(exc) if not error_list else error_list[0].detail, + errors=error_list, + authenticated=False + ) + return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleTokenExpiredError): status_code = status.HTTP_401_UNAUTHORIZED error_list.append( @@ -83,6 +97,13 @@ def handle_exception(exc, context): detail=str(exc), ) ) + final_response_data = ApiErrorResponse( + statusCode=status_code, + message=str(exc) if not error_list else error_list[0].detail, + errors=error_list, + authenticated=False + ) + return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleTokenInvalidError): status_code = status.HTTP_401_UNAUTHORIZED error_list.append( @@ -92,6 +113,13 @@ def handle_exception(exc, context): detail=str(exc), ) ) + final_response_data = ApiErrorResponse( + statusCode=status_code, + message=str(exc) if not error_list else error_list[0].detail, + errors=error_list, + authenticated=False + ) + return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleRefreshTokenExpiredError): status_code = status.HTTP_403_FORBIDDEN error_list.append( diff --git a/todo/exceptions/google_auth_exceptions.py b/todo/exceptions/google_auth_exceptions.py index d3c48760..60fcfbcc 100644 --- a/todo/exceptions/google_auth_exceptions.py +++ b/todo/exceptions/google_auth_exceptions.py @@ -17,6 +17,11 @@ def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_EXPIRED): super().__init__(message) +class GoogleTokenMissingError(BaseGoogleException): + def __init__(self, message: str = AuthErrorMessages.NO_ACCESS_TOKEN): + super().__init__(message) + + class GoogleTokenInvalidError(BaseGoogleException): def __init__(self, message: str = AuthErrorMessages.GOOGLE_TOKEN_INVALID): super().__init__(message) diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index 4220ea9a..07b08457 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -30,7 +30,10 @@ def __call__(self, request): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=AuthErrorMessages.AUTHENTICATION_REQUIRED, - errors=[ApiErrorDetail(detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + errors=[ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=AuthErrorMessages.AUTHENTICATION_REQUIRED + )], ) return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) @@ -42,7 +45,10 @@ def __call__(self, request): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=ApiErrors.AUTHENTICATION_FAILED.format(""), - errors=[ApiErrorDetail(detail=ApiErrors.AUTHENTICATION_FAILED.format(""), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + errors=[ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=AuthErrorMessages.AUTHENTICATION_REQUIRED + )], ) return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) @@ -105,7 +111,10 @@ def _handle_rds_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail(detail=str(exception), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + errors=[ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=str(exception) + )], ) return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) @@ -113,7 +122,10 @@ def _handle_google_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail(detail=str(exception), title=AuthErrorMessages.AUTHENTICATION_REQUIRED)], + errors=[ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=str(exception) + )], ) return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) diff --git a/todo/utils/google_jwt_utils.py b/todo/utils/google_jwt_utils.py index 46bf1b4d..008ba6bf 100644 --- a/todo/utils/google_jwt_utils.py +++ b/todo/utils/google_jwt_utils.py @@ -12,9 +12,6 @@ def generate_google_access_token(user_data: dict) -> str: - """ - Generate access token for Google authenticated user - """ try: now = datetime.now(timezone.utc) expiry = now + timedelta(seconds=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"]) @@ -42,9 +39,6 @@ def generate_google_access_token(user_data: dict) -> str: def generate_google_refresh_token(user_data: dict) -> str: - """ - Generate refresh token for Google authenticated user - """ try: now = datetime.now(timezone.utc) expiry = now + timedelta(seconds=settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"]) @@ -71,9 +65,6 @@ def generate_google_refresh_token(user_data: dict) -> str: def validate_google_access_token(token: str) -> dict: - """ - Validate Google access token - """ try: payload = jwt.decode( jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] @@ -91,9 +82,6 @@ def validate_google_access_token(token: str) -> dict: def validate_google_refresh_token(token: str) -> dict: - """ - Validate Google refresh token - """ try: payload = jwt.decode( jwt=token, key=settings.GOOGLE_JWT["SECRET_KEY"], algorithms=[settings.GOOGLE_JWT["ALGORITHM"]] @@ -111,9 +99,6 @@ def validate_google_refresh_token(token: str) -> dict: def generate_google_token_pair(user_data: dict) -> dict: - """ - Generate both access and refresh tokens - """ access_token = generate_google_access_token(user_data) refresh_token = generate_google_refresh_token(user_data) diff --git a/todo/utils/jwt_utils.py b/todo/utils/jwt_utils.py index afead6d5..157b4f6c 100644 --- a/todo/utils/jwt_utils.py +++ b/todo/utils/jwt_utils.py @@ -4,19 +4,7 @@ def verify_jwt_token(token: str) -> dict: - """ - Verify and decode the JWT token using the RSA public key. - - Args: - token (str): The JWT token to verify - - Returns: - dict: The decoded token payload - - Raises: - TokenExpiredError: If token has expired - TokenInvalidError: If token is invalid - """ + if not token or not token.strip(): raise TokenMissingError() diff --git a/todo/views/auth.py b/todo/views/auth.py index e6753285..76deb331 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -14,8 +14,14 @@ generate_google_token_pair, ) -from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource from todo.constants.messages import AuthErrorMessages, AppMessages +from todo.exceptions.google_auth_exceptions import ( + GoogleAuthException, + GoogleTokenExpiredError, + GoogleTokenInvalidError, + GoogleTokenMissingError, + GoogleAPIException, +) class GoogleLoginView(APIView): @@ -47,111 +53,98 @@ class GoogleCallbackView(APIView): The frontend implementation will redirect to the frontend and process the callback via POST request. """ - # Temporary implementation for testing and development def get(self, request: Request): if "error" in request.query_params: error = request.query_params.get("error") - error_response = ApiErrorResponse( - statusCode=status.HTTP_400_BAD_REQUEST, - message="OAuth Error", - errors=[ApiErrorDetail(detail=error, title="OAuth Error")] - ) - return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) + raise GoogleAuthException(error) code = request.query_params.get("code") state = request.query_params.get("state") if not code: - error_response = ApiErrorResponse( - statusCode=status.HTTP_400_BAD_REQUEST, - message="Missing Authorization Code", - errors=[ApiErrorDetail(detail="No authorization code received from Google", title="Missing Authorization Code")] - ) - return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) + raise GoogleAuthException("No authorization code received from Google") stored_state = request.session.get("oauth_state") if not stored_state or stored_state != state: - error_response = ApiErrorResponse( - statusCode=status.HTTP_400_BAD_REQUEST, - message="Invalid State Parameter", - errors=[ApiErrorDetail(detail="This might be a security issue", title="Invalid State Parameter")] - ) - return Response(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_400_BAD_REQUEST) + raise GoogleAuthException("Invalid state parameter") return self._handle_callback_directly(code, request) def _handle_callback_directly(self, code, request): - google_data = GoogleOAuthService.handle_callback(code) - user = UserService.create_or_update_user(google_data) - - tokens = generate_google_token_pair( - { - "user_id": str(user.id), - "google_id": user.google_id, - "email": user.email_id, - "name": user.name, - } - ) - - wants_json = ( - "application/json" in request.headers.get("Accept", "").lower() - or request.query_params.get("format") == "json" - ) + try: + google_data = GoogleOAuthService.handle_callback(code) + user = UserService.create_or_update_user(google_data) - if wants_json: - 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"] - } + tokens = generate_google_token_pair( + { + "user_id": str(user.id), + "google_id": user.google_id, + "email": user.email_id, + "name": user.name, } - }) - else: - response = HttpResponse(f""" - - ✅ Login Successful - -

✅ Google OAuth Login Successful!

- -

🧑‍💻 User Info:

-
    -
  • ID: {user.id}
  • -
  • Name: {user.name}
  • -
  • Email: {user.email_id}
  • -
  • Google ID: {user.google_id}
  • -
- -

🍪 Authentication Cookies Set:

-
    -
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • -
  • Refresh Token: ext-refresh (expires in 7 days)
  • -
- -

🧪 Test Other Endpoints:

- - -

Google OAuth integration is working perfectly!

- - - """) + ) - self._set_auth_cookies(response, tokens) - request.session.pop("oauth_state", None) + wants_json = ( + "application/json" in request.headers.get("Accept", "").lower() + or request.query_params.get("format") == "json" + ) - return response + if wants_json: + 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"] + } + } + }) + else: + response = HttpResponse(f""" + + ✅ Login Successful + +

✅ Google OAuth Login Successful!

+ +

🧑‍💻 User Info:

+
    +
  • ID: {user.id}
  • +
  • Name: {user.name}
  • +
  • Email: {user.email_id}
  • +
  • Google ID: {user.google_id}
  • +
+ +

🍪 Authentication Cookies Set:

+
    +
  • Access Token: ext-access (expires in {tokens['expires_in']} seconds)
  • +
  • Refresh Token: ext-refresh (expires in 7 days)
  • +
+ +

🧪 Test Other Endpoints:

+ + +

Google OAuth integration is working perfectly!

+ + + """) + + self._set_auth_cookies(response, tokens) + request.session.pop("oauth_state", None) + + return response + except Exception as e: + raise GoogleAPIException(str(e)) def _get_cookie_config(self): return { @@ -240,17 +233,22 @@ def post(self, request: Request): } ) - response = Response( - { - "success": True, + response = Response({ + "statusCode": status.HTTP_200_OK, + "message": AppMessages.GOOGLE_LOGIN_SUCCESS, + "data": { "user": { "id": str(user.id), - "email": user.email_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"] + } } - ) + }) self._set_auth_cookies(response, tokens) request.session.pop("oauth_state", None) @@ -280,23 +278,13 @@ def get(self, request: Request): access_token = request.COOKIES.get("ext-access") if not access_token: - formatted_errors = [ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_ACCESS_TOKEN, - ) - ] - error_response = ApiErrorResponse( - statusCode=401, message=AuthErrorMessages.NO_ACCESS_TOKEN, errors=formatted_errors - ) - return Response( - data={"authenticated": False, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) + raise GoogleTokenMissingError(AuthErrorMessages.NO_ACCESS_TOKEN) - payload = validate_google_access_token(access_token) - user = UserService.get_user_by_id(payload["user_id"]) + try: + payload = validate_google_access_token(access_token) + user = UserService.get_user_by_id(payload["user_id"]) + except Exception as e: + raise GoogleTokenInvalidError(str(e)) return Response({ "statusCode": status.HTTP_200_OK, @@ -318,45 +306,34 @@ def get(self, request: Request): refresh_token = request.COOKIES.get("ext-refresh") if not refresh_token: - formatted_errors = [ - ApiErrorDetail( - source={ApiErrorSource.HEADER: "Authorization"}, - title=AuthErrorMessages.AUTHENTICATION_REQUIRED, - detail=AuthErrorMessages.NO_REFRESH_TOKEN, - ) - ] - error_response = ApiErrorResponse( - statusCode=401, message=AuthErrorMessages.NO_REFRESH_TOKEN, errors=formatted_errors - ) - return Response( - data={"requiresLogin": True, **error_response.model_dump(mode="json", exclude_none=True)}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - 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 + 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) - config = self._get_cookie_config() - response.set_cookie( - "ext-access", new_access_token, max_age=settings.GOOGLE_JWT["ACCESS_TOKEN_LIFETIME"], **config - ) + response = Response({ + "statusCode": status.HTTP_200_OK, + "message": AppMessages.TOKEN_REFRESHED, + "data": { + "success": True + } + }) - return response + 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 { diff --git a/todo_project/settings/staging.py b/todo_project/settings/staging.py index d800bca3..df435a79 100644 --- a/todo_project/settings/staging.py +++ b/todo_project/settings/staging.py @@ -1,7 +1,7 @@ # Staging specific settings from .base import * -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["staging-api.realdevsquad.com", "services.realdevsquad.com"] # Service domains configuration From 114f5fe4f2bd1af26406f8bc8e056d0d7f4f53c4 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 17 Jun 2025 03:15:52 +0530 Subject: [PATCH 14/17] resolved pr comments --- todo/exceptions/exception_handler.py | 26 ++++++++++++++++++++++---- todo/services/google_oauth_service.py | 1 - 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index 7de468b2..5db3796e 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -18,6 +18,7 @@ GoogleRefreshTokenExpiredError, GoogleAPIException, GoogleUserNotFoundException, + GoogleTokenMissingError ) @@ -69,7 +70,7 @@ def handle_exception(exc, context): statusCode=status_code, message=str(exc) if not error_list else error_list[0].detail, errors=error_list, - authenticated=False + authenticated=False, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, TokenInvalidError): @@ -85,7 +86,24 @@ def handle_exception(exc, context): statusCode=status_code, message=str(exc) if not error_list else error_list[0].detail, errors=error_list, - authenticated=False + authenticated=False, + ) + return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) + + elif isinstance(exc, GoogleTokenMissingError): + status_code = status.HTTP_401_UNAUTHORIZED + error_list.append( + ApiErrorDetail( + source={ApiErrorSource.HEADER: "Authorization"}, + title=AuthErrorMessages.AUTHENTICATION_REQUIRED, + detail=str(exc), + ) + ) + final_response_data = ApiErrorResponse( + statusCode=status_code, + message=str(exc) if not error_list else error_list[0].detail, + errors=error_list, + authenticated=False, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleTokenExpiredError): @@ -101,7 +119,7 @@ def handle_exception(exc, context): statusCode=status_code, message=str(exc) if not error_list else error_list[0].detail, errors=error_list, - authenticated=False + authenticated=False, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleTokenInvalidError): @@ -117,7 +135,7 @@ def handle_exception(exc, context): statusCode=status_code, message=str(exc) if not error_list else error_list[0].detail, errors=error_list, - authenticated=False + authenticated=False, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) elif isinstance(exc, GoogleRefreshTokenExpiredError): diff --git a/todo/services/google_oauth_service.py b/todo/services/google_oauth_service.py index 52c157fa..2e1ca176 100644 --- a/todo/services/google_oauth_service.py +++ b/todo/services/google_oauth_service.py @@ -54,7 +54,6 @@ def handle_callback(cls, authorization_code: str) -> dict: @classmethod def _exchange_code_for_tokens(cls, code: str) -> dict: - """Exchange authorization code for tokens""" try: data = { "client_id": settings.GOOGLE_OAUTH["CLIENT_ID"], From 6e84d9f1758933faba2b456df9e2e44f6b4c28be Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 17 Jun 2025 15:05:25 +0530 Subject: [PATCH 15/17] Tests for Authentication feature (#80) * fix: add auth to pass tests * tests: auth * lint and format * add test creds in test.yml for testing purpose * auth view tests * wip * fix tests based on latest pull of test containers * fixed tests based on updated response structure * rebased on updated auth and fixed tests --- .github/workflows/test.yml | 12 + todo/middlewares/jwt_auth.py | 46 +-- todo/tests/fixtures/user.py | 18 ++ .../tests/integration/test_task_detail_api.py | 25 +- todo/tests/integration/test_tasks_delete.py | 25 +- .../unit/exceptions/test_exception_handler.py | 39 +-- todo/tests/unit/middlewares/__init__.py | 1 + todo/tests/unit/middlewares/test_jwt_auth.py | 130 +++++++++ todo/tests/unit/models/test_user.py | 55 ++++ .../unit/repositories/test_user_repository.py | 94 ++++++ .../services/test_google_oauth_service.py | 138 +++++++++ todo/tests/unit/services/test_user_service.py | 87 ++++++ todo/tests/unit/views/test_auth.py | 270 ++++++++++++++++++ todo/tests/unit/views/test_task.py | 43 ++- todo/utils/jwt_utils.py | 1 - todo/views/auth.py | 91 +++--- 16 files changed, 970 insertions(+), 105 deletions(-) create mode 100644 todo/tests/fixtures/user.py create mode 100644 todo/tests/unit/middlewares/__init__.py create mode 100644 todo/tests/unit/middlewares/test_jwt_auth.py create mode 100644 todo/tests/unit/models/test_user.py create mode 100644 todo/tests/unit/repositories/test_user_repository.py create mode 100644 todo/tests/unit/services/test_google_oauth_service.py create mode 100644 todo/tests/unit/services/test_user_service.py create mode 100644 todo/tests/unit/views/test_auth.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac912af7..1f8144b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,18 @@ jobs: runs-on: ubuntu-latest if: ${{ !contains(github.event.pull_request.title, '[skip tests]') }} + 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" + steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index 07b08457..e12e47c7 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -30,12 +30,16 @@ def __call__(self, request): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=AuthErrorMessages.AUTHENTICATION_REQUIRED, - errors=[ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), - detail=AuthErrorMessages.AUTHENTICATION_REQUIRED - )], + errors=[ + ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, + ) + ], + ) + return JsonResponse( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED ) - return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e: return self._handle_rds_auth_error(e) @@ -45,12 +49,16 @@ def __call__(self, request): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=ApiErrors.AUTHENTICATION_FAILED.format(""), - errors=[ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), - detail=AuthErrorMessages.AUTHENTICATION_REQUIRED - )], + errors=[ + ApiErrorDetail( + title=ApiErrors.AUTHENTICATION_FAILED.format(""), + detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, + ) + ], + ) + return JsonResponse( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED ) - return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def _try_authentication(self, request) -> bool: if self._try_google_auth(request): @@ -111,23 +119,21 @@ def _handle_rds_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), - detail=str(exception) - )], + errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))], + ) + return JsonResponse( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED ) - return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def _handle_google_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), - detail=str(exception) - )], + errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))], + ) + return JsonResponse( + data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED ) - return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED) def is_google_user(request) -> bool: diff --git a/todo/tests/fixtures/user.py b/todo/tests/fixtures/user.py new file mode 100644 index 00000000..2dfffbe4 --- /dev/null +++ b/todo/tests/fixtures/user.py @@ -0,0 +1,18 @@ +from datetime import datetime, timezone + +users_db_data = [ + { + "google_id": "123456789", + "email_id": "test@example.com", + "name": "Test User", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, + { + "google_id": "987654321", + "email_id": "another@example.com", + "name": "Another User", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, +] diff --git a/todo/tests/integration/test_task_detail_api.py b/todo/tests/integration/test_task_detail_api.py index 0cb37ef1..72ea5075 100644 --- a/todo/tests/integration/test_task_detail_api.py +++ b/todo/tests/integration/test_task_detail_api.py @@ -1,13 +1,31 @@ from http import HTTPStatus -from bson import ObjectId from django.urls import reverse -from rest_framework.test import APIClient +from bson import ObjectId + from todo.tests.fixtures.task import tasks_db_data from todo.tests.integration.base_mongo_test import BaseMongoTestCase from todo.constants.messages import ApiErrors, ValidationErrors +from todo.utils.google_jwt_utils import generate_google_token_pair + + +class AuthenticatedMongoTestCase(BaseMongoTestCase): + def setUp(self): + super().setUp() + self._setup_auth_cookies() + + def _setup_auth_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"] -class TaskDetailAPIIntegrationTest(BaseMongoTestCase): +class TaskDetailAPIIntegrationTest(AuthenticatedMongoTestCase): def setUp(self): super().setUp() self.db.tasks.delete_many({}) # Clear tasks to avoid DuplicateKeyError @@ -17,7 +35,6 @@ def setUp(self): self.existing_task_id = str(self.task_doc["_id"]) self.non_existent_id = str(ObjectId()) self.invalid_task_id = "invalid-task-id" - self.client = APIClient() def test_get_task_by_id_success(self): url = reverse("task_detail", args=[self.existing_task_id]) diff --git a/todo/tests/integration/test_tasks_delete.py b/todo/tests/integration/test_tasks_delete.py index 099d7ec5..915b9301 100644 --- a/todo/tests/integration/test_tasks_delete.py +++ b/todo/tests/integration/test_tasks_delete.py @@ -1,13 +1,31 @@ from http import HTTPStatus -from bson import ObjectId from django.urls import reverse -from rest_framework.test import APIClient +from bson import ObjectId + from todo.tests.fixtures.task import tasks_db_data from todo.tests.integration.base_mongo_test import BaseMongoTestCase from todo.constants.messages import ValidationErrors, ApiErrors +from todo.utils.google_jwt_utils import generate_google_token_pair + + +class AuthenticatedMongoTestCase(BaseMongoTestCase): + def setUp(self): + super().setUp() + self._setup_auth_cookies() + + def _setup_auth_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"] -class TaskDeleteAPIIntegrationTest(BaseMongoTestCase): +class TaskDeleteAPIIntegrationTest(AuthenticatedMongoTestCase): def setUp(self): super().setUp() self.db.tasks.delete_many({}) @@ -17,7 +35,6 @@ def setUp(self): self.existing_task_id = str(task_doc["_id"]) self.non_existent_id = str(ObjectId()) self.invalid_task_id = "invalid-task-id" - self.client = APIClient() def test_delete_task_success(self): url = reverse("task_detail", args=[self.existing_task_id]) diff --git a/todo/tests/unit/exceptions/test_exception_handler.py b/todo/tests/unit/exceptions/test_exception_handler.py index 11f5c7c6..a5fa84bf 100644 --- a/todo/tests/unit/exceptions/test_exception_handler.py +++ b/todo/tests/unit/exceptions/test_exception_handler.py @@ -14,23 +14,24 @@ class ExceptionHandlerTests(TestCase): @patch("todo.exceptions.exception_handler.format_validation_errors") def test_returns_400_for_validation_error(self, mock_format_validation_errors: Mock): - validation_error = DRFValidationError(detail={"field": ["error message"]}) - mock_format_validation_errors.return_value = [ - ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"}) - ] - - response = handle_exception(validation_error, {}) - - self.assertIsInstance(response, Response) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - expected_response = { - "statusCode": 400, - "message": "Invalid request", - "errors": [{"source": {"parameter": "field"}, "detail": "error message"}], - } - self.assertDictEqual(response.data, expected_response) - - mock_format_validation_errors.assert_called_once_with(validation_error.detail) + error_detail = {"field": ["error message"]} + exception = DRFValidationError(detail=error_detail) + request = Mock() + + with patch("todo.exceptions.exception_handler.format_validation_errors") as mock_format: + mock_format.return_value = [ + ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"}) + ] + response = handle_exception(exception, {"request": request}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_response = { + "statusCode": 400, + "message": "error message", + "errors": [{"source": {"parameter": "field"}, "detail": "error message"}], + } + self.assertDictEqual(response.data, expected_response) + mock_format.assert_called_once_with(error_detail) def test_custom_handler_formats_generic_exception(self): request = None @@ -51,9 +52,9 @@ def test_custom_handler_formats_generic_exception(self): expected_detail_obj_in_list = ApiErrorDetail( detail=error_message if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, - title=ApiErrors.UNEXPECTED_ERROR_OCCURRED, + title=error_message, ) - expected_main_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED + expected_main_message = ApiErrors.INTERNAL_SERVER_ERROR self.assertEqual(response.data.get("statusCode"), status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertEqual(response.data.get("message"), expected_main_message) diff --git a/todo/tests/unit/middlewares/__init__.py b/todo/tests/unit/middlewares/__init__.py new file mode 100644 index 00000000..9d445f48 --- /dev/null +++ b/todo/tests/unit/middlewares/__init__.py @@ -0,0 +1 @@ +# This file is required for Python to recognize this directory as a package diff --git a/todo/tests/unit/middlewares/test_jwt_auth.py b/todo/tests/unit/middlewares/test_jwt_auth.py new file mode 100644 index 00000000..0b3e8a0d --- /dev/null +++ b/todo/tests/unit/middlewares/test_jwt_auth.py @@ -0,0 +1,130 @@ +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 + +from todo.middlewares.jwt_auth import JWTAuthenticationMiddleware, is_google_user, is_rds_user, get_current_user_info +from todo.constants.messages import AuthErrorMessages + + +class JWTAuthenticationMiddlewareTests(TestCase): + def setUp(self): + self.get_response = Mock(return_value=JsonResponse({"data": "test"})) + self.middleware = JWTAuthenticationMiddleware(self.get_response) + self.request = Mock(spec=HttpRequest) + self.request.path = "/v1/tasks" + self.request.headers = {} + self.request.COOKIES = {} + settings.PUBLIC_PATHS = ["/v1/auth/google/login"] + + def test_public_path_authentication_bypass(self): + """Test that requests to public paths bypass authentication""" + self.request.path = "/v1/auth/google/login" + response = self.middleware(self.request) + self.get_response.assert_called_once_with(self.request) + self.assertEqual(response.status_code, 200) + + @patch("todo.middlewares.jwt_auth.JWTAuthenticationMiddleware._try_google_auth") + def test_google_auth_success(self, mock_google_auth): + """Test successful Google authentication""" + mock_google_auth.return_value = True + self.request.COOKIES = {"ext-access": "google_token"} + response = self.middleware(self.request) + mock_google_auth.assert_called_once_with(self.request) + self.get_response.assert_called_once_with(self.request) + self.assertEqual(response.status_code, 200) + + @patch("todo.middlewares.jwt_auth.JWTAuthenticationMiddleware._try_rds_auth") + def test_rds_auth_success(self, mock_rds_auth): + """Test successful RDS authentication""" + mock_rds_auth.return_value = True + self.request.COOKIES = {"rds_session_v2": "valid_token"} + response = self.middleware(self.request) + mock_rds_auth.assert_called_once_with(self.request) + self.get_response.assert_called_once_with(self.request) + self.assertEqual(response.status_code, 200) + + @patch("todo.middlewares.jwt_auth.JWTAuthenticationMiddleware._try_google_auth") + def test_google_token_expired(self, mock_google_auth): + """Test handling of expired Google token""" + mock_google_auth.return_value = False + self.request.COOKIES = {"ext-access": "expired_token"} + response = self.middleware(self.request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + response_data = json.loads(response.content) + self.assertEqual(response_data["message"], AuthErrorMessages.AUTHENTICATION_REQUIRED) + + @patch("todo.middlewares.jwt_auth.JWTAuthenticationMiddleware._try_rds_auth") + def test_rds_token_invalid(self, mock_rds_auth): + """Test handling of invalid RDS token""" + mock_rds_auth.return_value = False + self.request.COOKIES = {"rds_session_v2": "invalid_token"} + response = self.middleware(self.request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + response_data = json.loads(response.content) + self.assertEqual(response_data["message"], AuthErrorMessages.AUTHENTICATION_REQUIRED) + + def test_no_tokens_provided(self): + """Test handling of request with no tokens""" + response = self.middleware(self.request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + response_data = json.loads(response.content) + self.assertEqual(response_data["message"], AuthErrorMessages.AUTHENTICATION_REQUIRED) + + +class AuthUtilityFunctionsTests(TestCase): + def setUp(self): + self.request = Mock(spec=HttpRequest) + + def test_is_google_user(self): + """Test checking if request is from Google user""" + self.request.auth_type = "google" + self.assertTrue(is_google_user(self.request)) + + self.request.auth_type = None + self.assertFalse(is_google_user(self.request)) + + self.request.auth_type = "rds" + self.assertFalse(is_google_user(self.request)) + + def test_is_rds_user(self): + """Test checking if request is from RDS user""" + self.request.auth_type = "rds" + self.assertTrue(is_rds_user(self.request)) + + self.request.auth_type = None + self.assertFalse(is_rds_user(self.request)) + + self.request.auth_type = "google" + self.assertFalse(is_rds_user(self.request)) + + def test_get_current_user_info_google(self): + """Test getting user info for Google user""" + self.request.user_id = "google_user_123" + self.request.auth_type = "google" + self.request.google_id = "google_123" + self.request.user_email = "test@example.com" + self.request.user_name = "Test User" + user_info = get_current_user_info(self.request) + self.assertEqual(user_info["user_id"], "google_user_123") + self.assertEqual(user_info["auth_type"], "google") + self.assertEqual(user_info["google_id"], "google_123") + self.assertEqual(user_info["email"], "test@example.com") + self.assertEqual(user_info["name"], "Test User") + + def test_get_current_user_info_rds(self): + """Test getting user info for RDS user""" + self.request.user_id = "rds_user_123" + self.request.auth_type = "rds" + self.request.user_role = "admin" + user_info = get_current_user_info(self.request) + self.assertEqual(user_info["user_id"], "rds_user_123") + self.assertEqual(user_info["auth_type"], "rds") + self.assertEqual(user_info["role"], "admin") + + def test_get_current_user_info_no_user_id(self): + """Test getting user info when no user ID is present""" + user_info = get_current_user_info(self.request) + self.assertIsNone(user_info) diff --git a/todo/tests/unit/models/test_user.py b/todo/tests/unit/models/test_user.py new file mode 100644 index 00000000..85f5828e --- /dev/null +++ b/todo/tests/unit/models/test_user.py @@ -0,0 +1,55 @@ +from unittest import TestCase +from datetime import datetime, timezone +from pydantic_core._pydantic_core import ValidationError +from todo.models.user import UserModel +from todo.tests.fixtures.user import users_db_data + + +class UserModelTest(TestCase): + def setUp(self) -> None: + self.valid_user_data = users_db_data[0] + + def test_user_model_instantiates_with_valid_data(self): + user = UserModel(**self.valid_user_data) + + self.assertEqual(user.google_id, self.valid_user_data["google_id"]) + self.assertEqual(user.email_id, self.valid_user_data["email_id"]) + self.assertEqual(user.name, self.valid_user_data["name"]) + self.assertEqual(user.created_at, self.valid_user_data["created_at"]) + self.assertEqual(user.updated_at, self.valid_user_data["updated_at"]) + + def test_user_model_throws_error_when_missing_required_fields(self): + required_fields = ["google_id", "email_id", "name"] + + for field in required_fields: + with self.subTest(f"missing field: {field}"): + incomplete_data = self.valid_user_data.copy() + incomplete_data.pop(field, None) + + with self.assertRaises(ValidationError) as context: + UserModel(**incomplete_data) + + error_fields = [e["loc"][0] for e in context.exception.errors()] + self.assertIn(field, error_fields) + + def test_user_model_throws_error_when_invalid_email(self): + invalid_data = self.valid_user_data.copy() + invalid_data["email_id"] = "invalid-email" + + with self.assertRaises(ValidationError) as context: + UserModel(**invalid_data) + + error_fields = [e["loc"][0] for e in context.exception.errors()] + self.assertIn("email_id", error_fields) + + def test_user_model_sets_default_timestamps(self): + minimal_data = { + "google_id": self.valid_user_data["google_id"], + "email_id": self.valid_user_data["email_id"], + "name": self.valid_user_data["name"], + } + user = UserModel(**minimal_data) + + self.assertIsInstance(user.created_at, datetime) + self.assertIsNone(user.updated_at) + self.assertLessEqual(user.created_at, datetime.now(timezone.utc)) diff --git a/todo/tests/unit/repositories/test_user_repository.py b/todo/tests/unit/repositories/test_user_repository.py new file mode 100644 index 00000000..3a93c7ba --- /dev/null +++ b/todo/tests/unit/repositories/test_user_repository.py @@ -0,0 +1,94 @@ +from unittest import TestCase +from unittest.mock import patch, MagicMock +from bson import ObjectId + +from todo.repositories.user_repository import UserRepository +from todo.models.user import UserModel +from todo.models.common.pyobjectid import PyObjectId +from todo.exceptions.google_auth_exceptions import GoogleUserNotFoundException, GoogleAPIException +from todo.tests.fixtures.user import users_db_data +from todo.constants.messages import RepositoryErrors + + +class UserRepositoryTests(TestCase): + def setUp(self) -> None: + self.valid_user_data = {"google_id": "123456789", "email": "test@example.com", "name": "Test User"} + self.user_model = UserModel(**users_db_data[0]) + self.mock_collection = MagicMock() + self.mock_db_manager = MagicMock() + self.mock_db_manager.get_collection.return_value = self.mock_collection + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_get_by_id_success(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + user_id = str(ObjectId()) + self.mock_collection.find_one.return_value = users_db_data[0] + + result = UserRepository.get_by_id(user_id) + + self.mock_collection.find_one.assert_called_once_with({"_id": PyObjectId(user_id)}) + self.assertIsInstance(result, UserModel) + self.assertEqual(result.google_id, users_db_data[0]["google_id"]) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_get_by_id_not_found(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + user_id = str(ObjectId()) + self.mock_collection.find_one.return_value = None + + result = UserRepository.get_by_id(user_id) + self.assertIsNone(result) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_get_by_id_database_error(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + user_id = str(ObjectId()) + self.mock_collection.find_one.side_effect = Exception("Database error") + + with self.assertRaises(GoogleUserNotFoundException): + UserRepository.get_by_id(user_id) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_create_or_update_success(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + self.mock_collection.find_one_and_update.return_value = users_db_data[0] + + result = UserRepository.create_or_update(self.valid_user_data) + + self.mock_collection.find_one_and_update.assert_called_once() + call_args = self.mock_collection.find_one_and_update.call_args[0] + self.assertEqual(call_args[0], {"google_id": self.valid_user_data["google_id"]}) + self.assertIsInstance(result, UserModel) + self.assertEqual(result.google_id, users_db_data[0]["google_id"]) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_create_or_update_no_result(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + self.mock_collection.find_one_and_update.return_value = None + + with self.assertRaises(GoogleAPIException) as context: + UserRepository.create_or_update(self.valid_user_data) + self.assertIn(RepositoryErrors.USER_OPERATION_FAILED, str(context.exception)) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_create_or_update_database_error(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + self.mock_collection.find_one_and_update.side_effect = Exception("Database error") + + with self.assertRaises(GoogleAPIException) as context: + UserRepository.create_or_update(self.valid_user_data) + self.assertIn(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format("Database error"), str(context.exception)) + + @patch("todo.repositories.user_repository.DatabaseManager") + def test_create_or_update_sets_timestamps(self, mock_db_manager): + mock_db_manager.return_value = self.mock_db_manager + self.mock_collection.find_one_and_update.return_value = users_db_data[0] + + UserRepository.create_or_update(self.valid_user_data) + + call_args = self.mock_collection.find_one_and_update.call_args[0] + update_doc = call_args[1] + self.assertIn("$set", update_doc) + self.assertIn("updated_at", update_doc["$set"]) + self.assertIn("$setOnInsert", update_doc) + self.assertIn("created_at", update_doc["$setOnInsert"]) diff --git a/todo/tests/unit/services/test_google_oauth_service.py b/todo/tests/unit/services/test_google_oauth_service.py new file mode 100644 index 00000000..3f39b0df --- /dev/null +++ b/todo/tests/unit/services/test_google_oauth_service.py @@ -0,0 +1,138 @@ +from unittest import TestCase +from unittest.mock import patch, MagicMock +from urllib.parse import urlencode + +from todo.services.google_oauth_service import GoogleOAuthService +from todo.exceptions.google_auth_exceptions import GoogleAPIException, GoogleAuthException +from todo.constants.messages import ApiErrors + + +class GoogleOAuthServiceTests(TestCase): + def setUp(self) -> None: + self.mock_settings = { + "GOOGLE_OAUTH": { + "CLIENT_ID": "test-client-id", + "CLIENT_SECRET": "test-client-secret", + "REDIRECT_URI": "http://localhost:3000/auth/callback", + "SCOPES": ["email", "profile"], + } + } + self.valid_user_info = {"id": "123456789", "email": "test@example.com", "name": "Test User"} + self.valid_tokens = {"access_token": "test-access-token", "refresh_token": "test-refresh-token"} + + @patch("todo.services.google_oauth_service.settings") + @patch("todo.services.google_oauth_service.secrets") + def test_get_authorization_url_success(self, mock_secrets, mock_settings): + mock_settings.configure_mock(**self.mock_settings) + mock_secrets.token_urlsafe.return_value = "test-state" + + auth_url, state = GoogleOAuthService.get_authorization_url() + + self.assertEqual(state, "test-state") + expected_params = { + "client_id": self.mock_settings["GOOGLE_OAUTH"]["CLIENT_ID"], + "redirect_uri": self.mock_settings["GOOGLE_OAUTH"]["REDIRECT_URI"], + "response_type": "code", + "scope": " ".join(self.mock_settings["GOOGLE_OAUTH"]["SCOPES"]), + "access_type": "offline", + "prompt": "consent", + "state": state, + } + expected_url = f"{GoogleOAuthService.GOOGLE_AUTH_URL}?{urlencode(expected_params)}" + self.assertEqual(auth_url, expected_url) + + @patch("todo.services.google_oauth_service.settings") + def test_get_authorization_url_error(self, mock_settings): + mock_settings.configure_mock(**self.mock_settings) + mock_settings.GOOGLE_OAUTH = None + + with self.assertRaises(GoogleAuthException) as context: + GoogleOAuthService.get_authorization_url() + self.assertIn(ApiErrors.GOOGLE_AUTH_FAILED, str(context.exception)) + + @patch("todo.services.google_oauth_service.GoogleOAuthService._exchange_code_for_tokens") + @patch("todo.services.google_oauth_service.GoogleOAuthService._get_user_info") + def test_handle_callback_success(self, mock_get_user_info, mock_exchange_tokens): + mock_exchange_tokens.return_value = self.valid_tokens + mock_get_user_info.return_value = self.valid_user_info + + result = GoogleOAuthService.handle_callback("test-code") + + self.assertEqual(result["google_id"], self.valid_user_info["id"]) + self.assertEqual(result["email"], self.valid_user_info["email"]) + self.assertEqual(result["name"], self.valid_user_info["name"]) + mock_exchange_tokens.assert_called_once_with("test-code") + mock_get_user_info.assert_called_once_with(self.valid_tokens["access_token"]) + + @patch("todo.services.google_oauth_service.GoogleOAuthService._exchange_code_for_tokens") + def test_handle_callback_token_error(self, mock_exchange_tokens): + mock_exchange_tokens.side_effect = GoogleAPIException(ApiErrors.TOKEN_EXCHANGE_FAILED) + + with self.assertRaises(GoogleAPIException) as context: + GoogleOAuthService.handle_callback("test-code") + self.assertIn(ApiErrors.TOKEN_EXCHANGE_FAILED, str(context.exception)) + + @patch("todo.services.google_oauth_service.requests.post") + @patch("todo.services.google_oauth_service.settings") + def test_exchange_code_for_tokens_success(self, mock_settings, mock_post): + mock_settings.configure_mock(**self.mock_settings) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = self.valid_tokens + mock_post.return_value = mock_response + + result = GoogleOAuthService._exchange_code_for_tokens("test-code") + + self.assertEqual(result, self.valid_tokens) + mock_post.assert_called_once() + call_args = mock_post.call_args[1] + self.assertEqual(call_args["data"]["code"], "test-code") + self.assertEqual(call_args["data"]["client_id"], "test-client-id") + self.assertEqual(call_args["data"]["client_secret"], "test-client-secret") + + @patch("todo.services.google_oauth_service.requests.post") + @patch("todo.services.google_oauth_service.settings") + def test_exchange_code_for_tokens_error_response(self, mock_settings, mock_post): + mock_settings.configure_mock(**self.mock_settings) + mock_response = MagicMock() + mock_response.status_code = 400 + mock_post.return_value = mock_response + + with self.assertRaises(GoogleAPIException) as context: + GoogleOAuthService._exchange_code_for_tokens("test-code") + self.assertIn(ApiErrors.TOKEN_EXCHANGE_FAILED, str(context.exception)) + + @patch("todo.services.google_oauth_service.requests.get") + def test_get_user_info_success(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = self.valid_user_info + mock_get.return_value = mock_response + + result = GoogleOAuthService._get_user_info("test-token") + + self.assertEqual(result, self.valid_user_info) + mock_get.assert_called_once() + call_args = mock_get.call_args[1] + self.assertEqual(call_args["headers"]["Authorization"], "Bearer test-token") + + @patch("todo.services.google_oauth_service.requests.get") + def test_get_user_info_missing_fields(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "123"} + mock_get.return_value = mock_response + + with self.assertRaises(GoogleAPIException) as context: + GoogleOAuthService._get_user_info("test-token") + self.assertIn(ApiErrors.MISSING_USER_INFO_FIELDS.format("email, name"), str(context.exception)) + + @patch("todo.services.google_oauth_service.requests.get") + def test_get_user_info_error_response(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_get.return_value = mock_response + + with self.assertRaises(GoogleAPIException) as context: + GoogleOAuthService._get_user_info("test-token") + self.assertIn(ApiErrors.USER_INFO_FETCH_FAILED.format("HTTP error"), str(context.exception)) diff --git a/todo/tests/unit/services/test_user_service.py b/todo/tests/unit/services/test_user_service.py new file mode 100644 index 00000000..14183775 --- /dev/null +++ b/todo/tests/unit/services/test_user_service.py @@ -0,0 +1,87 @@ +from unittest import TestCase +from unittest.mock import patch +from rest_framework.exceptions import ValidationError as DRFValidationError + +from todo.services.user_service import UserService +from todo.models.user import UserModel +from todo.exceptions.google_auth_exceptions import GoogleUserNotFoundException, GoogleAPIException +from todo.tests.fixtures.user import users_db_data +from todo.constants.messages import ValidationErrors, RepositoryErrors + + +class UserServiceTests(TestCase): + def setUp(self) -> None: + self.valid_google_user_data = {"google_id": "123456789", "email": "test@example.com", "name": "Test User"} + self.user_model = UserModel(**users_db_data[0]) + + @patch("todo.services.user_service.UserRepository") + def test_create_or_update_user_success(self, mock_repository): + mock_repository.create_or_update.return_value = self.user_model + + result = UserService.create_or_update_user(self.valid_google_user_data) + + mock_repository.create_or_update.assert_called_once_with(self.valid_google_user_data) + self.assertEqual(result, self.user_model) + + @patch("todo.services.user_service.UserRepository") + def test_create_or_update_user_validation_error(self, mock_repository): + invalid_data = {"google_id": "123"} + + with self.assertRaises(DRFValidationError) as context: + UserService.create_or_update_user(invalid_data) + self.assertIn(ValidationErrors.MISSING_EMAIL, str(context.exception.detail)) + self.assertIn(ValidationErrors.MISSING_NAME, str(context.exception.detail)) + mock_repository.create_or_update.assert_not_called() + + @patch("todo.services.user_service.UserRepository") + def test_create_or_update_user_repository_error(self, mock_repository): + mock_repository.create_or_update.side_effect = Exception("Database error") + + with self.assertRaises(GoogleAPIException) as context: + UserService.create_or_update_user(self.valid_google_user_data) + self.assertIn(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format("Database error"), str(context.exception)) + + @patch("todo.services.user_service.UserRepository") + def test_get_user_by_id_success(self, mock_repository): + mock_repository.get_by_id.return_value = self.user_model + + result = UserService.get_user_by_id("123") + + mock_repository.get_by_id.assert_called_once_with("123") + self.assertEqual(result, self.user_model) + + @patch("todo.services.user_service.UserRepository") + def test_get_user_by_id_not_found(self, mock_repository): + mock_repository.get_by_id.return_value = None + + with self.assertRaises(GoogleUserNotFoundException): + UserService.get_user_by_id("123") + mock_repository.get_by_id.assert_called_once_with("123") + + def test_validate_google_user_data_success(self): + try: + UserService._validate_google_user_data(self.valid_google_user_data) + except DRFValidationError: + self.fail("ValidationError raised unexpectedly!") + + def test_validate_google_user_data_missing_fields(self): + test_cases = [ + {"email": "test@example.com", "name": "Test User"}, + {"google_id": "123", "name": "Test User"}, + {"google_id": "123", "email": "test@example.com"}, + ] + + for invalid_data in test_cases: + with self.subTest(f"Testing missing field in {invalid_data}"): + with self.assertRaises(DRFValidationError) as context: + UserService._validate_google_user_data(invalid_data) + + error_dict = context.exception.detail + self.assertTrue(len(error_dict) > 0) + + if "google_id" not in invalid_data: + self.assertIn(ValidationErrors.MISSING_GOOGLE_ID, str(error_dict)) + if "email" not in invalid_data: + self.assertIn(ValidationErrors.MISSING_EMAIL, str(error_dict)) + if "name" not in invalid_data: + self.assertIn(ValidationErrors.MISSING_NAME, str(error_dict)) diff --git a/todo/tests/unit/views/test_auth.py b/todo/tests/unit/views/test_auth.py new file mode 100644 index 00000000..b1545896 --- /dev/null +++ b/todo/tests/unit/views/test_auth.py @@ -0,0 +1,270 @@ +from rest_framework.test import APISimpleTestCase, APIClient, APIRequestFactory +from rest_framework.reverse import reverse +from rest_framework import status +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 + + +class GoogleLoginViewTests(APISimpleTestCase): + def setUp(self): + super().setUp() + self.client = APIClient() + 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): + 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) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, mock_auth_url) + mock_get_auth_url.assert_called_once_with(None) + + @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") + def test_get_returns_json_for_json_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) + + response = self.client.get(self.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(None) + + @patch("todo.services.google_oauth_service.GoogleOAuthService.get_authorization_url") + def test_get_with_redirect_url(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) + redirect_url = "http://localhost:3000/callback" + + response = self.client.get(f"{self.url}?redirectURL={redirect_url}") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, mock_auth_url) + mock_get_auth_url.assert_called_once_with(redirect_url) + + +class GoogleCallbackViewTests(APISimpleTestCase): + def setUp(self): + super().setUp() + self.client = APIClient() + self.url = reverse("google_callback") + self.factory = APIRequestFactory() + self.view = GoogleCallbackView.as_view() + + def test_get_returns_error_for_oauth_error(self): + error = "access_denied" + request = self.factory.get(f"{self.url}?error={error}") + + response = self.view(request) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["message"], error) + self.assertEqual(response.data["errors"][0]["detail"], error) + + def test_get_returns_error_for_missing_code(self): + request = self.factory.get(self.url) + + response = self.view(request) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["message"], "No authorization code received from Google") + self.assertEqual(response.data["errors"][0]["detail"], "No authorization code received from Google") + + def test_get_returns_error_for_invalid_state(self): + request = self.factory.get(f"{self.url}?code=test_code&state=invalid_state") + request.session = {"oauth_state": "different_state"} + + response = self.view(request) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["message"], "Invalid state parameter") + self.assertEqual(response.data["errors"][0]["detail"], "Invalid state parameter") + + @patch("todo.services.google_oauth_service.GoogleOAuthService.handle_callback") + @patch("todo.services.user_service.UserService.create_or_update_user") + def test_get_handles_callback_successfully(self, mock_create_user, mock_handle_callback): + mock_google_data = { + "id": "test_google_id", + "email": "test@example.com", + "name": "Test User", + } + 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"] + type(mock_user).id = PropertyMock(return_value=ObjectId(user_id)) + + mock_handle_callback.return_value = mock_google_data + mock_create_user.return_value = mock_user + + request = self.factory.get(f"{self.url}?code=test_code&state=test_state") + request.session = {"oauth_state": "test_state"} + + response = self.view(request) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("✅ Google OAuth Login Successful!", response.content.decode()) + self.assertIn(str(mock_user.id), response.content.decode()) + self.assertIn(mock_user.name, response.content.decode()) + self.assertIn(mock_user.email_id, response.content.decode()) + self.assertIn(mock_user.google_id, response.content.decode()) + self.assertIn("ext-access", response.cookies) + self.assertIn("ext-refresh", response.cookies) + self.assertNotIn("oauth_state", request.session) + + +class GoogleAuthStatusViewTests(APISimpleTestCase): + def setUp(self): + super().setUp() + self.client = APIClient() + self.url = reverse("google_status") + + def test_get_returns_401_when_no_access_token(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.data["message"], AuthErrorMessages.NO_ACCESS_TOKEN) + self.assertEqual(response.data["authenticated"], False) + self.assertEqual(response.data["statusCode"], status.HTTP_401_UNAUTHORIZED) + + @patch("todo.utils.google_jwt_utils.validate_google_access_token") + @patch("todo.services.user_service.UserService.get_user_by_id") + def test_get_returns_user_info_when_authenticated(self, mock_get_user, mock_validate_token): + user_id = str(ObjectId()) + user_data = { + "user_id": user_id, + "google_id": "test_google_id", + "email": "test@example.com", + "name": "Test User", + } + mock_validate_token.return_value = user_data + + mock_user = Mock() + mock_user.id = ObjectId(user_id) + mock_user.google_id = "test_google_id" + mock_user.email_id = "test@example.com" + mock_user.name = "Test User" + type(mock_user).id = PropertyMock(return_value=ObjectId(user_id)) + + mock_get_user.return_value = mock_user + + tokens = generate_google_token_pair(user_data) + self.client.cookies["ext-access"] = tokens["access_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"]["user"]["id"], user_id) + self.assertEqual(response.data["data"]["user"]["email"], mock_user.email_id) + self.assertEqual(response.data["data"]["user"]["name"], mock_user.name) + self.assertEqual(response.data["data"]["user"]["google_id"], mock_user.google_id) + + +class GoogleRefreshViewTests(APISimpleTestCase): + 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) + + +class GoogleLogoutViewTests(APISimpleTestCase): + def setUp(self): + super().setUp() + 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): + 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.cookies.get("ext-access").value, "") + self.assertEqual(response.cookies.get("ext-refresh").value, "") + + def test_post_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.post(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, "") diff --git a/todo/tests/unit/views/test_task.py b/todo/tests/unit/views/test_task.py index a05f0c05..48d066fc 100644 --- a/todo/tests/unit/views/test_task.py +++ b/todo/tests/unit/views/test_task.py @@ -20,11 +20,31 @@ from todo.constants.messages import ValidationErrors, ApiErrors from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail from rest_framework.exceptions import ValidationError as DRFValidationError +from todo.utils.google_jwt_utils import generate_google_token_pair -class TaskViewTests(APISimpleTestCase): +class AuthenticatedTestCase(APISimpleTestCase): def setUp(self): + super().setUp() self.client = APIClient() + self._setup_auth_cookies() + + def _setup_auth_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"] + + +class TaskViewTests(AuthenticatedTestCase): + def setUp(self): + super().setUp() self.url = reverse("tasks") self.valid_params = {"page": 1, "limit": 10} @@ -59,7 +79,7 @@ def test_get_tasks_returns_400_for_invalid_query_params(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) expected_response = { "statusCode": 400, - "message": "Invalid request", + "message": "A valid integer is required.", "errors": [ {"source": {"parameter": "page"}, "detail": "A valid integer is required."}, {"source": {"parameter": "limit"}, "detail": "limit must be greater than or equal to 1"}, @@ -130,7 +150,7 @@ def test_get_single_task_unexpected_error(self, mock_get_task_by_id: Mock): self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertEqual(response.data["statusCode"], status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertEqual(response.data["message"], ApiErrors.UNEXPECTED_ERROR_OCCURRED) + self.assertEqual(response.data["message"], ApiErrors.INTERNAL_SERVER_ERROR) self.assertEqual(response.data["errors"][0]["detail"], ApiErrors.INTERNAL_SERVER_ERROR) mock_get_task_by_id.assert_called_once_with(task_id) @@ -193,9 +213,9 @@ def test_get_tasks_with_non_numeric_parameters(self): self.assertTrue("page" in error_detail or "limit" in error_detail) -class CreateTaskViewTests(APISimpleTestCase): +class CreateTaskViewTests(AuthenticatedTestCase): def setUp(self): - self.client = APIClient() + super().setUp() self.url = reverse("tasks") self.valid_payload = { @@ -299,13 +319,14 @@ def test_create_task_returns_500_on_internal_error(self, mock_create_task): try: response = self.client.post(self.url, data=self.valid_payload, format="json") self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertIn("An unexpected error occurred", str(response.data)) + self.assertEqual(response.data["message"], ApiErrors.INTERNAL_SERVER_ERROR) except Exception as e: self.assertEqual(str(e), "Database exploded") -class TaskDeleteViewTests(APISimpleTestCase): +class TaskDeleteViewTests(AuthenticatedTestCase): def setUp(self): + super().setUp() self.valid_task_id = str(ObjectId()) self.url = reverse("task_detail", kwargs={"task_id": self.valid_task_id}) @@ -333,9 +354,9 @@ def test_delete_task_returns_400_for_invalid_id_format(self, mock_delete_task: M self.assertIn(ValidationErrors.INVALID_TASK_ID_FORMAT, response.data["message"]) -class TaskDetailViewPatchTests(APISimpleTestCase): +class TaskDetailViewPatchTests(AuthenticatedTestCase): def setUp(self): - self.client = APIClient() + super().setUp() self.task_id_str = str(ObjectId()) self.task_url = reverse("task_detail", args=[self.task_id_str]) self.future_date = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() @@ -484,7 +505,7 @@ def test_patch_task_service_raises_drf_validation_error( self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data["statusCode"], status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["message"], "Invalid request") + self.assertEqual(response.data["message"], service_error_detail["labels"][0]) self.assertIn( "labels", @@ -539,7 +560,7 @@ def test_patch_task_service_raises_unhandled_exception( self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertEqual(response.data["statusCode"], status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertEqual(response.data["message"], ApiErrors.UNEXPECTED_ERROR_OCCURRED) + self.assertEqual(response.data["message"], ApiErrors.INTERNAL_SERVER_ERROR) self.assertEqual(response.data["errors"][0]["detail"], ApiErrors.INTERNAL_SERVER_ERROR) with patch.object(settings, "DEBUG", True): diff --git a/todo/utils/jwt_utils.py b/todo/utils/jwt_utils.py index 157b4f6c..d7fbb423 100644 --- a/todo/utils/jwt_utils.py +++ b/todo/utils/jwt_utils.py @@ -4,7 +4,6 @@ def verify_jwt_token(token: str) -> dict: - if not token or not token.strip(): raise TokenMissingError() diff --git a/todo/views/auth.py b/todo/views/auth.py index 76deb331..1e02e2d1 100644 --- a/todo/views/auth.py +++ b/todo/views/auth.py @@ -31,14 +31,13 @@ def get(self, request: Request): request.session["oauth_state"] = state if request.headers.get("Accept") == "application/json" or request.query_params.get("format") == "json": - return Response({ - "statusCode": status.HTTP_200_OK, - "message": "Google OAuth URL generated successfully", - "data": { - "authUrl": auth_url, - "state": state + return Response( + { + "statusCode": status.HTTP_200_OK, + "message": "Google OAuth URL generated successfully", + "data": {"authUrl": auth_url, "state": state}, } - }) + ) return HttpResponseRedirect(auth_url) @@ -90,22 +89,24 @@ def _handle_callback_directly(self, code, request): ) if wants_json: - 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, + 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"], + }, }, - "tokens": { - "access_token_expires_in": tokens["expires_in"], - "refresh_token_expires_in": settings.GOOGLE_JWT["REFRESH_TOKEN_LIFETIME"] - } } - }) + ) else: response = HttpResponse(f""" @@ -286,19 +287,21 @@ def get(self, request: Request): except Exception as e: raise GoogleTokenInvalidError(str(e)) - return Response({ - "statusCode": status.HTTP_200_OK, - "message": "Authentication status retrieved successfully", - "data": { - "authenticated": True, - "user": { - "id": str(user.id), - "email": user.email_id, - "name": user.name, - "google_id": user.google_id, - } + return Response( + { + "statusCode": status.HTTP_200_OK, + "message": "Authentication status retrieved successfully", + "data": { + "authenticated": True, + "user": { + "id": str(user.id), + "email": user.email_id, + "name": user.name, + "google_id": user.google_id, + }, + }, } - }) + ) class GoogleRefreshView(APIView): @@ -318,13 +321,9 @@ def get(self, request: Request): } new_access_token = generate_google_access_token(user_data) - response = Response({ - "statusCode": status.HTTP_200_OK, - "message": AppMessages.TOKEN_REFRESHED, - "data": { - "success": True - } - }) + response = Response( + {"statusCode": status.HTTP_200_OK, "message": AppMessages.TOKEN_REFRESHED, "data": {"success": True}} + ) config = self._get_cookie_config() response.set_cookie( @@ -362,13 +361,13 @@ def _handle_logout(self, request: Request): ) if wants_json: - response = Response({ - "statusCode": status.HTTP_200_OK, - "message": AppMessages.GOOGLE_LOGOUT_SUCCESS, - "data": { - "success": True + 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) From da7abf9c60d3bea065bd762eb49e3f0401c1d41a Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 17 Jun 2025 23:31:48 +0530 Subject: [PATCH 16/17] fixed workflow file --- .github/workflows/test.yml | 1 - todo/exceptions/exception_handler.py | 4 ++-- todo/utils/jwt_utils.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af563ce2..1f8144b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.11.*" - python-version: "3.11.*" - name: Install dependencies run: | diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py index 5db3796e..85632f93 100644 --- a/todo/exceptions/exception_handler.py +++ b/todo/exceptions/exception_handler.py @@ -18,7 +18,7 @@ GoogleRefreshTokenExpiredError, GoogleAPIException, GoogleUserNotFoundException, - GoogleTokenMissingError + GoogleTokenMissingError, ) @@ -89,7 +89,7 @@ def handle_exception(exc, context): authenticated=False, ) return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code) - + elif isinstance(exc, GoogleTokenMissingError): status_code = status.HTTP_401_UNAUTHORIZED error_list.append( diff --git a/todo/utils/jwt_utils.py b/todo/utils/jwt_utils.py index 157b4f6c..d7fbb423 100644 --- a/todo/utils/jwt_utils.py +++ b/todo/utils/jwt_utils.py @@ -4,7 +4,6 @@ def verify_jwt_token(token: str) -> dict: - if not token or not token.strip(): raise TokenMissingError() From a2a8c41225965031c8b963a07d7b40f155e4e2fe Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Wed, 18 Jun 2025 00:26:09 +0530 Subject: [PATCH 17/17] refactor based on ai pr reviews --- todo/middlewares/jwt_auth.py | 10 +++++----- todo/tests/unit/exceptions/test_exception_handler.py | 3 +-- todo/tests/unit/middlewares/test_jwt_auth.py | 2 ++ todo/tests/unit/models/test_user.py | 2 +- todo/tests/unit/services/test_google_oauth_service.py | 5 ++++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/todo/middlewares/jwt_auth.py b/todo/middlewares/jwt_auth.py index e12e47c7..8559d404 100644 --- a/todo/middlewares/jwt_auth.py +++ b/todo/middlewares/jwt_auth.py @@ -32,7 +32,7 @@ def __call__(self, request): message=AuthErrorMessages.AUTHENTICATION_REQUIRED, errors=[ ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), + title=ApiErrors.AUTHENTICATION_FAILED, detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, ) ], @@ -48,10 +48,10 @@ def __call__(self, request): except Exception: error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, - message=ApiErrors.AUTHENTICATION_FAILED.format(""), + message=ApiErrors.AUTHENTICATION_FAILED, errors=[ ApiErrorDetail( - title=ApiErrors.AUTHENTICATION_FAILED.format(""), + title=ApiErrors.AUTHENTICATION_FAILED, detail=AuthErrorMessages.AUTHENTICATION_REQUIRED, ) ], @@ -119,7 +119,7 @@ def _handle_rds_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))], + errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED, detail=str(exception))], ) return JsonResponse( data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED @@ -129,7 +129,7 @@ def _handle_google_auth_error(self, exception): error_response = ApiErrorResponse( statusCode=status.HTTP_401_UNAUTHORIZED, message=str(exception), - errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))], + errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED, detail=str(exception))], ) return JsonResponse( data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED diff --git a/todo/tests/unit/exceptions/test_exception_handler.py b/todo/tests/unit/exceptions/test_exception_handler.py index a5fa84bf..b660b1ee 100644 --- a/todo/tests/unit/exceptions/test_exception_handler.py +++ b/todo/tests/unit/exceptions/test_exception_handler.py @@ -12,8 +12,7 @@ class ExceptionHandlerTests(TestCase): - @patch("todo.exceptions.exception_handler.format_validation_errors") - def test_returns_400_for_validation_error(self, mock_format_validation_errors: Mock): + def test_returns_400_for_validation_error(self): error_detail = {"field": ["error message"]} exception = DRFValidationError(detail=error_detail) request = Mock() diff --git a/todo/tests/unit/middlewares/test_jwt_auth.py b/todo/tests/unit/middlewares/test_jwt_auth.py index 0b3e8a0d..2681898c 100644 --- a/todo/tests/unit/middlewares/test_jwt_auth.py +++ b/todo/tests/unit/middlewares/test_jwt_auth.py @@ -17,7 +17,9 @@ 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/models/test_user.py b/todo/tests/unit/models/test_user.py index 85f5828e..23e58ddc 100644 --- a/todo/tests/unit/models/test_user.py +++ b/todo/tests/unit/models/test_user.py @@ -1,6 +1,6 @@ from unittest import TestCase from datetime import datetime, timezone -from pydantic_core._pydantic_core import ValidationError +from pydantic import ValidationError from todo.models.user import UserModel from todo.tests.fixtures.user import users_db_data diff --git a/todo/tests/unit/services/test_google_oauth_service.py b/todo/tests/unit/services/test_google_oauth_service.py index 3f39b0df..b312d2ee 100644 --- a/todo/tests/unit/services/test_google_oauth_service.py +++ b/todo/tests/unit/services/test_google_oauth_service.py @@ -125,7 +125,10 @@ def test_get_user_info_missing_fields(self, mock_get): with self.assertRaises(GoogleAPIException) as context: GoogleOAuthService._get_user_info("test-token") - self.assertIn(ApiErrors.MISSING_USER_INFO_FIELDS.format("email, name"), str(context.exception)) + error_msg = str(context.exception) + self.assertIn(ApiErrors.MISSING_USER_INFO_FIELDS.split(":")[0], error_msg) + for field in ("email", "name"): + self.assertIn(field, error_msg) @patch("todo.services.google_oauth_service.requests.get") def test_get_user_info_error_response(self, mock_get):