Skip to content

Commit 5bb4ec1

Browse files
feat(auth): Implemented google and rds based authentication for todo-app [skip tests] (#81)
* feat(auth): Integrated RDS Auth * feat(auth): Integrated RDS Auth * fix: env.example * refactor: changes based on ai pr review * feat(auth): Implemented google authentication for todo-app * resolved pr comments * resolved pr comments * resolved bot comments * removed exceptions from views file * refactored auth views * refactored unused field * resolved review comments and added status code to api responses * resolved pr comments * resolved pr comments
1 parent d0875e5 commit 5bb4ec1

28 files changed

+1432
-55
lines changed

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,10 @@ ENV='DEVELOPMENT'
22
SECRET_KEY='unique-secret'
33
ALLOWED_HOSTS='localhost,127.0.0.1'
44
MONGODB_URI='mongodb://localhost:27017'
5-
DB_NAME='todo-app'
5+
DB_NAME='db-name'
6+
RDS_BACKEND_BASE_URL='http://localhost:3000'
7+
RDS_PUBLIC_KEY="public-key-here"
8+
GOOGLE_OAUTH_CLIENT_ID="google-client-id"
9+
GOOGLE_OAUTH_CLIENT_SECRET="client-secret"
10+
GOOGLE_OAUTH_REDIRECT_URI="environment-url/auth/google/callback"
11+
GOOGLE_JWT_SECRET_KEY=generate-secret-key

.github/workflows/test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
jobs:
88
build:
99
runs-on: ubuntu-latest
10+
if: ${{ !contains(github.event.pull_request.title, '[skip tests]') }}
1011

1112
steps:
1213
- name: Checkout code
@@ -15,7 +16,7 @@ jobs:
1516
- name: Set up Python
1617
uses: actions/setup-python@v5
1718
with:
18-
python-version: '3.11.*'
19+
python-version: "3.11.*"
1920

2021
- name: Install dependencies
2122
run: |

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ ignore = []
4646
fixable = ["ALL"]
4747
unfixable = []
4848

49+
[tool.ruff.lint.per-file-ignores]
50+
"todo_project/settings/*.py" = ["F403", "F405"]
51+
4952
[tool.ruff.format]
5053
# Like Black, use double quotes for strings.
5154
quote-style = "double"

requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ ruff==0.7.1
2020
sqlparse==0.5.1
2121
typing_extensions==4.12.2
2222
virtualenv==20.27.0
23+
django-cors-headers==4.7.0
24+
cryptography==45.0.3
25+
PyJWT==2.10.1
26+
requests==2.32.3
27+
email-validator==2.2.0
2328
testcontainers[mongodb]==4.10.0

todo/constants/messages.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
# Application Messages
22
class AppMessages:
33
TASK_CREATED = "Task created successfully"
4+
GOOGLE_LOGIN_SUCCESS = "Successfully logged in with Google"
5+
GOOGLE_LOGOUT_SUCCESS = "Successfully logged out"
6+
TOKEN_REFRESHED = "Access token refreshed successfully"
47

58

69
# Repository error messages
710
class RepositoryErrors:
811
TASK_CREATION_FAILED = "Failed to create task: {0}"
912
DB_INIT_FAILED = "Failed to initialize database: {0}"
13+
USER_NOT_FOUND = "User not found: {0}"
14+
USER_OPERATION_FAILED = "User operation failed"
15+
USER_CREATE_UPDATE_FAILED = "User create/update failed: {0}"
1016

1117

1218
# API error messages
@@ -23,6 +29,17 @@ class ApiErrors:
2329
TASK_NOT_FOUND = "Task with ID {0} not found."
2430
TASK_NOT_FOUND_GENERIC = "Task not found."
2531
RESOURCE_NOT_FOUND_TITLE = "Resource Not Found"
32+
GOOGLE_AUTH_FAILED = "Google authentication failed"
33+
GOOGLE_API_ERROR = "Google API error"
34+
INVALID_AUTH_CODE = "Invalid authorization code"
35+
TOKEN_EXCHANGE_FAILED = "Failed to exchange authorization code"
36+
MISSING_USER_INFO_FIELDS = "Missing user info fields: {0}"
37+
USER_INFO_FETCH_FAILED = "Failed to get user info: {0}"
38+
OAUTH_INITIALIZATION_FAILED = "OAuth initialization failed: {0}"
39+
AUTHENTICATION_FAILED = "Authentication failed: {0}"
40+
INVALID_STATE_PARAMETER = "Invalid state parameter"
41+
TOKEN_REFRESH_FAILED = "Token refresh failed: {0}"
42+
LOGOUT_FAILED = "Logout failed: {0}"
2643

2744

2845
# Validation error messages
@@ -37,3 +54,22 @@ class ValidationErrors:
3754
INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format."
3855
FUTURE_STARTED_AT = "The start date cannot be set in the future."
3956
INVALID_LABELS_STRUCTURE = "Labels must be provided as a list or tuple of ObjectId strings."
57+
MISSING_GOOGLE_ID = "Google ID is required"
58+
MISSING_EMAIL = "Email is required"
59+
MISSING_NAME = "Name is required"
60+
61+
62+
# Auth messages
63+
class AuthErrorMessages:
64+
TOKEN_MISSING = "Authentication token is required"
65+
TOKEN_EXPIRED = "Authentication token has expired"
66+
TOKEN_INVALID = "Invalid authentication token"
67+
AUTHENTICATION_REQUIRED = "Authentication required"
68+
TOKEN_EXPIRED_TITLE = "Token Expired"
69+
INVALID_TOKEN_TITLE = "Invalid Token"
70+
GOOGLE_TOKEN_EXPIRED = "Google access token has expired"
71+
GOOGLE_REFRESH_TOKEN_EXPIRED = "Google refresh token has expired, please login again"
72+
GOOGLE_TOKEN_INVALID = "Invalid Google token"
73+
MISSING_REQUIRED_PARAMETER = "Missing required parameter: {0}"
74+
NO_ACCESS_TOKEN = "No access token"
75+
NO_REFRESH_TOKEN = "No refresh token found"

todo/dto/responses/error_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ class ApiErrorResponse(BaseModel):
2020
statusCode: int
2121
message: str
2222
errors: List[ApiErrorDetail]
23+
authenticated: bool | None = None

todo/exceptions/auth_exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from todo.constants.messages import AuthErrorMessages
2+
3+
4+
class TokenMissingError(Exception):
5+
def __init__(self, message: str = AuthErrorMessages.TOKEN_MISSING):
6+
self.message = message
7+
super().__init__(self.message)
8+
9+
10+
class TokenExpiredError(Exception):
11+
def __init__(self, message: str = AuthErrorMessages.TOKEN_EXPIRED):
12+
self.message = message
13+
super().__init__(self.message)
14+
15+
16+
class TokenInvalidError(Exception):
17+
def __init__(self, message: str = AuthErrorMessages.TOKEN_INVALID):
18+
self.message = message
19+
super().__init__(self.message)

todo/exceptions/exception_handler.py

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,18 @@
88
from bson.errors import InvalidId as BsonInvalidId
99

1010
from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorResponse, ApiErrorSource
11-
from todo.constants.messages import ApiErrors, ValidationErrors
11+
from todo.constants.messages import ApiErrors, ValidationErrors, AuthErrorMessages
1212
from todo.exceptions.task_exceptions import TaskNotFoundException
13+
from .auth_exceptions import TokenExpiredError, TokenMissingError, TokenInvalidError
14+
from .google_auth_exceptions import (
15+
GoogleAuthException,
16+
GoogleTokenExpiredError,
17+
GoogleTokenInvalidError,
18+
GoogleRefreshTokenExpiredError,
19+
GoogleAPIException,
20+
GoogleUserNotFoundException,
21+
GoogleTokenMissingError
22+
)
1323

1424

1525
def format_validation_errors(errors) -> List[ApiErrorDetail]:
@@ -37,22 +47,144 @@ def handle_exception(exc, context):
3747

3848
error_list = []
3949
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
40-
determined_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED
4150

42-
if isinstance(exc, TaskNotFoundException):
51+
if isinstance(exc, TokenExpiredError):
52+
status_code = status.HTTP_401_UNAUTHORIZED
53+
error_list.append(
54+
ApiErrorDetail(
55+
source={ApiErrorSource.HEADER: "Authorization"},
56+
title=AuthErrorMessages.TOKEN_EXPIRED_TITLE,
57+
detail=str(exc),
58+
)
59+
)
60+
elif isinstance(exc, TokenMissingError):
61+
status_code = status.HTTP_401_UNAUTHORIZED
62+
error_list.append(
63+
ApiErrorDetail(
64+
source={ApiErrorSource.HEADER: "Authorization"},
65+
title=AuthErrorMessages.AUTHENTICATION_REQUIRED,
66+
detail=str(exc),
67+
)
68+
)
69+
final_response_data = ApiErrorResponse(
70+
statusCode=status_code,
71+
message=str(exc) if not error_list else error_list[0].detail,
72+
errors=error_list,
73+
authenticated=False,
74+
)
75+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
76+
elif isinstance(exc, TokenInvalidError):
77+
status_code = status.HTTP_401_UNAUTHORIZED
78+
error_list.append(
79+
ApiErrorDetail(
80+
source={ApiErrorSource.HEADER: "Authorization"},
81+
title=AuthErrorMessages.INVALID_TOKEN_TITLE,
82+
detail=str(exc),
83+
)
84+
)
85+
final_response_data = ApiErrorResponse(
86+
statusCode=status_code,
87+
message=str(exc) if not error_list else error_list[0].detail,
88+
errors=error_list,
89+
authenticated=False,
90+
)
91+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
92+
93+
elif isinstance(exc, GoogleTokenMissingError):
94+
status_code = status.HTTP_401_UNAUTHORIZED
95+
error_list.append(
96+
ApiErrorDetail(
97+
source={ApiErrorSource.HEADER: "Authorization"},
98+
title=AuthErrorMessages.AUTHENTICATION_REQUIRED,
99+
detail=str(exc),
100+
)
101+
)
102+
final_response_data = ApiErrorResponse(
103+
statusCode=status_code,
104+
message=str(exc) if not error_list else error_list[0].detail,
105+
errors=error_list,
106+
authenticated=False,
107+
)
108+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
109+
elif isinstance(exc, GoogleTokenExpiredError):
110+
status_code = status.HTTP_401_UNAUTHORIZED
111+
error_list.append(
112+
ApiErrorDetail(
113+
source={ApiErrorSource.HEADER: "Authorization"},
114+
title=AuthErrorMessages.TOKEN_EXPIRED_TITLE,
115+
detail=str(exc),
116+
)
117+
)
118+
final_response_data = ApiErrorResponse(
119+
statusCode=status_code,
120+
message=str(exc) if not error_list else error_list[0].detail,
121+
errors=error_list,
122+
authenticated=False,
123+
)
124+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
125+
elif isinstance(exc, GoogleTokenInvalidError):
126+
status_code = status.HTTP_401_UNAUTHORIZED
127+
error_list.append(
128+
ApiErrorDetail(
129+
source={ApiErrorSource.HEADER: "Authorization"},
130+
title=AuthErrorMessages.INVALID_TOKEN_TITLE,
131+
detail=str(exc),
132+
)
133+
)
134+
final_response_data = ApiErrorResponse(
135+
statusCode=status_code,
136+
message=str(exc) if not error_list else error_list[0].detail,
137+
errors=error_list,
138+
authenticated=False,
139+
)
140+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
141+
elif isinstance(exc, GoogleRefreshTokenExpiredError):
142+
status_code = status.HTTP_403_FORBIDDEN
143+
error_list.append(
144+
ApiErrorDetail(
145+
source={ApiErrorSource.HEADER: "Authorization"},
146+
title=AuthErrorMessages.TOKEN_EXPIRED_TITLE,
147+
detail=str(exc),
148+
)
149+
)
150+
elif isinstance(exc, GoogleAuthException):
151+
status_code = status.HTTP_400_BAD_REQUEST
152+
error_list.append(
153+
ApiErrorDetail(
154+
source={ApiErrorSource.PARAMETER: "google_auth"},
155+
title=ApiErrors.GOOGLE_AUTH_FAILED,
156+
detail=str(exc),
157+
)
158+
)
159+
elif isinstance(exc, GoogleAPIException):
160+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
161+
error_list.append(
162+
ApiErrorDetail(
163+
source={ApiErrorSource.PARAMETER: "google_api"},
164+
title=ApiErrors.GOOGLE_API_ERROR,
165+
detail=str(exc),
166+
)
167+
)
168+
elif isinstance(exc, GoogleUserNotFoundException):
169+
status_code = status.HTTP_404_NOT_FOUND
170+
error_list.append(
171+
ApiErrorDetail(
172+
source={ApiErrorSource.PARAMETER: "user_id"},
173+
title=ApiErrors.RESOURCE_NOT_FOUND_TITLE,
174+
detail=str(exc),
175+
)
176+
)
177+
elif isinstance(exc, TaskNotFoundException):
43178
status_code = status.HTTP_404_NOT_FOUND
44-
detail_message_str = str(exc)
45-
determined_message = detail_message_str
46179
error_list.append(
47180
ApiErrorDetail(
48181
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
49182
title=ApiErrors.RESOURCE_NOT_FOUND_TITLE,
50-
detail=detail_message_str,
183+
detail=str(exc),
51184
)
52185
)
53186
elif isinstance(exc, BsonInvalidId):
54187
status_code = status.HTTP_400_BAD_REQUEST
55-
determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT
56188
error_list.append(
57189
ApiErrorDetail(
58190
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
@@ -67,7 +199,6 @@ def handle_exception(exc, context):
67199
and (exc.args[0] == ValidationErrors.INVALID_TASK_ID_FORMAT or exc.args[0] == "Invalid ObjectId format")
68200
):
69201
status_code = status.HTTP_400_BAD_REQUEST
70-
determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT
71202
error_list.append(
72203
ApiErrorDetail(
73204
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
@@ -84,7 +215,6 @@ def handle_exception(exc, context):
84215
)
85216
elif isinstance(exc, DRFValidationError):
86217
status_code = status.HTTP_400_BAD_REQUEST
87-
determined_message = "Invalid request"
88218
error_list = format_validation_errors(exc.detail)
89219
if not error_list and exc.detail:
90220
error_list.append(ApiErrorDetail(detail=str(exc.detail), title=ApiErrors.VALIDATION_ERROR))
@@ -94,35 +224,32 @@ def handle_exception(exc, context):
94224
status_code = response.status_code
95225
if isinstance(response.data, dict) and "detail" in response.data:
96226
detail_str = str(response.data["detail"])
97-
determined_message = detail_str
98227
error_list.append(ApiErrorDetail(detail=detail_str, title=detail_str))
99228
elif isinstance(response.data, list):
100229
for item_error in response.data:
101-
error_list.append(ApiErrorDetail(detail=str(item_error), title=determined_message))
230+
error_list.append(ApiErrorDetail(detail=str(item_error), title=str(exc)))
102231
else:
103232
error_list.append(
104233
ApiErrorDetail(
105234
detail=str(response.data) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
106-
title=determined_message,
235+
title=str(exc),
107236
)
108237
)
109238
else:
110239
error_list.append(
111-
ApiErrorDetail(
112-
detail=str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, title=determined_message
113-
)
240+
ApiErrorDetail(detail=str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, title=str(exc))
114241
)
115242

116243
if not error_list and not (
117244
isinstance(exc, ValueError) and hasattr(exc, "args") and exc.args and isinstance(exc.args[0], ApiErrorResponse)
118245
):
119246
default_detail_str = str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR
120247

121-
error_list.append(ApiErrorDetail(detail=default_detail_str, title=determined_message))
248+
error_list.append(ApiErrorDetail(detail=default_detail_str, title=str(exc)))
122249

123250
final_response_data = ApiErrorResponse(
124251
statusCode=status_code,
125-
message=determined_message,
252+
message=str(exc) if not error_list else error_list[0].detail,
126253
errors=error_list,
127254
)
128255
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)

0 commit comments

Comments
 (0)