security: fix GoogleLoginView audience check + replace Clerk with direct GIS flow
- verify_oauth2_token now passes GOOGLE_CLIENT_ID as third arg (audience check)
- fail-closed: returns 503 if GOOGLE_CLIENT_ID env var is not set
- add GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '') to settings
- replace ClerkLoginViewTests with GoogleLoginViewTests (4 cases)
- update requirements-docker.txt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -5,6 +5,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-04-10
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **`GoogleLoginView` audience-check fix** (`POST /api/user/google-login/`) — **CRITICAL security patch**
|
||||||
|
- `verify_oauth2_token(token, google_requests.Request())` was called **without** the third `audience` argument, meaning any valid Google-signed ID token from *any* OAuth client was accepted — token spoofing from external apps was trivially possible
|
||||||
|
- Fixed to `verify_oauth2_token(token, google_requests.Request(), settings.GOOGLE_CLIENT_ID)` — only tokens whose `aud` claim matches our registered Client ID are now accepted
|
||||||
|
- Added fail-closed guard: if `settings.GOOGLE_CLIENT_ID` is empty the view returns HTTP 503 instead of silently accepting all tokens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Removed Clerk scaffolding** — the `@clerk/react` broker approach added in a prior iteration has been replaced with direct Google Identity Services (GIS) ID-token flow on the frontend. Simpler architecture: one trust boundary instead of three.
|
||||||
|
- Removed `ClerkLoginView`, `_clerk_jwks_client`, `_get_clerk_jwks_client()` from `mobile_api/views/user.py`
|
||||||
|
- Removed `path('user/clerk-login/', ...)` from `mobile_api/urls.py`
|
||||||
|
- Removed `CLERK_JWKS_URL` / `CLERK_ISSUER` / `CLERK_SECRET_KEY` from `eventify/settings.py`; replaced with `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')`
|
||||||
|
- Removed `PyJWT[crypto]>=2.8.0` and `requests>=2.31.0` from `requirements.txt` + `requirements-docker.txt` (no longer needed; `google-auth>=2.0.0` handles verification)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Settings**: `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')` in `eventify/settings.py`
|
||||||
|
- **Tests**: `mobile_api/tests.py::GoogleLoginViewTests` — 4 cases: valid token creates user (audience arg verified), missing `id_token` → 400, `ValueError` (wrong sig / wrong aud) → 401, existing user reuses DRF token
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- The consumer SPA (`app.eventifyplus.com`) now loads the Google Identity Services script dynamically and POSTs a Google ID token to the existing `/api/user/google-login/` endpoint. Django is the sole session authority. `localStorage.event_token` / `event_user` are unchanged.
|
||||||
|
- Deploy requirement: set `GOOGLE_CLIENT_ID` in the Django container `.env` **before** deploying — without it the view returns 503 (fail-closed by design).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.9.0] — 2026-04-07
|
## [1.9.0] — 2026-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -196,3 +196,9 @@ SIMPLE_JWT = {
|
|||||||
'USER_ID_FIELD': 'id',
|
'USER_ID_FIELD': 'id',
|
||||||
'USER_ID_CLAIM': 'user_id',
|
'USER_ID_CLAIM': 'user_id',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Google OAuth (Sign in with Google via GIS ID-token flow) -----------
|
||||||
|
# The Client ID is public (safe in VITE_* env vars and the SPA bundle).
|
||||||
|
# There is NO client secret — we use the ID-token flow, not auth-code flow.
|
||||||
|
# Set the SAME value in the Django container .env and in SPA .env.local.
|
||||||
|
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')
|
||||||
|
|||||||
@@ -1,3 +1,89 @@
|
|||||||
from django.test import TestCase
|
"""Unit tests for GoogleLoginView.
|
||||||
|
|
||||||
# Create your tests here.
|
Run with:
|
||||||
|
python manage.py test mobile_api.tests
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(GOOGLE_CLIENT_ID='test-client-id.apps.googleusercontent.com')
|
||||||
|
class GoogleLoginViewTests(TestCase):
|
||||||
|
url = '/api/user/google-login/'
|
||||||
|
|
||||||
|
def _valid_idinfo(self, email='new.user@example.com'):
|
||||||
|
return {
|
||||||
|
'email': email,
|
||||||
|
'given_name': 'New',
|
||||||
|
'family_name': 'User',
|
||||||
|
'aud': 'test-client-id.apps.googleusercontent.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_valid_token_creates_user(self, mock_verify):
|
||||||
|
mock_verify.return_value = self._valid_idinfo('fresh@example.com')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'fake.google.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['email'], 'fresh@example.com')
|
||||||
|
self.assertEqual(body['role'], 'customer')
|
||||||
|
self.assertTrue(body['token'])
|
||||||
|
|
||||||
|
user = User.objects.get(email='fresh@example.com')
|
||||||
|
self.assertTrue(Token.objects.filter(user=user).exists())
|
||||||
|
# Confirm audience was passed to verify_oauth2_token
|
||||||
|
_, call_kwargs = mock_verify.call_args[0], mock_verify.call_args
|
||||||
|
self.assertEqual(mock_verify.call_args[0][2], 'test-client-id.apps.googleusercontent.com')
|
||||||
|
|
||||||
|
def test_missing_id_token_returns_400(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertEqual(resp.json()['error'], 'id_token is required')
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_invalid_token_returns_401(self, mock_verify):
|
||||||
|
mock_verify.side_effect = ValueError('Token audience mismatch')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'tampered.or.wrong-aud.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
self.assertEqual(resp.json()['error'], 'Invalid Google token')
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_existing_user_reuses_token(self, mock_verify):
|
||||||
|
existing = User.objects.create_user(
|
||||||
|
username='returning@example.com',
|
||||||
|
email='returning@example.com',
|
||||||
|
password='irrelevant',
|
||||||
|
role='customer',
|
||||||
|
)
|
||||||
|
existing_auth_token = Token.objects.create(user=existing)
|
||||||
|
mock_verify.return_value = self._valid_idinfo('returning@example.com')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'returning.user.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()['token'], existing_auth_token.key)
|
||||||
|
# No duplicate user created
|
||||||
|
self.assertEqual(User.objects.filter(email='returning@example.com').count(), 1)
|
||||||
|
|||||||
@@ -431,12 +431,22 @@ class GoogleLoginView(View):
|
|||||||
from google.oauth2 import id_token as google_id_token
|
from google.oauth2 import id_token as google_id_token
|
||||||
from google.auth.transport import requests as google_requests
|
from google.auth.transport import requests as google_requests
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
token = data.get('id_token')
|
token = data.get('id_token')
|
||||||
if not token:
|
if not token:
|
||||||
return JsonResponse({'error': 'id_token is required'}, status=400)
|
return JsonResponse({'error': 'id_token is required'}, status=400)
|
||||||
|
|
||||||
idinfo = google_id_token.verify_oauth2_token(token, google_requests.Request())
|
if not settings.GOOGLE_CLIENT_ID:
|
||||||
|
log("error", "GOOGLE_CLIENT_ID not configured", request=request)
|
||||||
|
return JsonResponse({'error': 'Google login temporarily unavailable'}, status=503)
|
||||||
|
|
||||||
|
idinfo = google_id_token.verify_oauth2_token(
|
||||||
|
token,
|
||||||
|
google_requests.Request(),
|
||||||
|
settings.GOOGLE_CLIENT_ID,
|
||||||
|
)
|
||||||
email = idinfo.get('email')
|
email = idinfo.get('email')
|
||||||
if not email:
|
if not email:
|
||||||
return JsonResponse({'error': 'Email not found in Google token'}, status=400)
|
return JsonResponse({'error': 'Email not found in Google token'}, status=400)
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ gunicorn==21.2.0
|
|||||||
django-extensions==3.2.3
|
django-extensions==3.2.3
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
djangorestframework-simplejwt==5.3.1
|
djangorestframework-simplejwt==5.3.1
|
||||||
|
google-auth>=2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user