diff --git a/CHANGELOG.md b/CHANGELOG.md index 124f74d..f312fa6 100644 --- a/CHANGELOG.md +++ b/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 ### Added diff --git a/eventify/settings.py b/eventify/settings.py index 0f0e0da..037eb8f 100644 --- a/eventify/settings.py +++ b/eventify/settings.py @@ -196,3 +196,9 @@ SIMPLE_JWT = { 'USER_ID_FIELD': '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', '') diff --git a/mobile_api/tests.py b/mobile_api/tests.py index 7ce503c..308abee 100644 --- a/mobile_api/tests.py +++ b/mobile_api/tests.py @@ -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) diff --git a/mobile_api/views/user.py b/mobile_api/views/user.py index 75a590a..6441ed7 100644 --- a/mobile_api/views/user.py +++ b/mobile_api/views/user.py @@ -431,12 +431,22 @@ class GoogleLoginView(View): from google.oauth2 import id_token as google_id_token from google.auth.transport import requests as google_requests + from django.conf import settings + data = json.loads(request.body) token = data.get('id_token') if not token: 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') if not email: return JsonResponse({'error': 'Email not found in Google token'}, status=400) diff --git a/requirements-docker.txt b/requirements-docker.txt index 29174d3..76c1b4b 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -7,3 +7,4 @@ gunicorn==21.2.0 django-extensions==3.2.3 psycopg2-binary==2.9.9 djangorestframework-simplejwt==5.3.1 +google-auth>=2.0.0