From 26646abe8cbcfc25e430887cc1d55bac51cc8795 Mon Sep 17 00:00:00 2001 From: Umair Khan Date: Fri, 14 Oct 2016 17:12:16 +0500 Subject: [PATCH] Authenticate subdomains using single Google OAuth entry. Previously, we used to create one Google OAuth callback url entry per subdomain. This commit allows us to authenticate subdomain users against a single Google OAuth callback url entry. --- zerver/tests/test_auth_backends.py | 4 +- zerver/views/__init__.py | 21 +++++++ zerver/views/auth.py | 88 +++++++++++++++++++++++++----- zproject/urls.py | 2 + 4 files changed, 99 insertions(+), 16 deletions(-) diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 0aab9079fe..2a723a206e 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -488,7 +488,7 @@ class ResponseMock(object): class GoogleLoginTest(ZulipTestCase): def google_oauth2_test(self, token_response, account_response): # type: (ResponseMock, ResponseMock) -> HttpResponse - result = self.client_get("/accounts/login/google/") + result = self.client_get("/accounts/login/google/send/") self.assertEquals(result.status_code, 302) # Now extract the CSRF token from the redirect URL parsed_url = urllib.parse.urlparse(result.url) @@ -647,7 +647,7 @@ class GoogleLoginTest(ZulipTestCase): def test_google_oauth2_csrf_badstate(self): # type: () -> None with mock.patch("logging.warning") as m: - result = self.client_get("/accounts/login/google/done/?state=badstate:otherbadstate") + result = self.client_get("/accounts/login/google/done/?state=badstate:otherbadstate:") self.assertEquals(result.status_code, 400) self.assertEquals(m.call_args_list[0][0][0], 'Google oauth2 CSRF error') diff --git a/zerver/views/__init__.py b/zerver/views/__init__.py index 842a447059..236c8286dd 100644 --- a/zerver/views/__init__.py +++ b/zerver/views/__init__.py @@ -51,6 +51,7 @@ from zproject.backends import password_auth_enabled from confirmation.models import Confirmation, RealmCreationKey, check_key_is_valid import requests +import ujson import calendar import datetime @@ -63,6 +64,26 @@ import logging from zproject.jinja2 import render_to_response +def redirect_and_log_into_subdomain(realm, full_name, email_address): + # type: (Realm, text_type, text_type) -> HttpResponse + subdomain_login_uri = ''.join([ + realm.uri, + reverse('zerver.views.auth.log_into_subdomain') + ]) + + domain = '.' + settings.EXTERNAL_HOST.split(':')[0] + response = redirect(subdomain_login_uri) + + data = {'name': full_name, 'email': email_address, 'subdomain': realm.subdomain} + # Creating a singed cookie so that it cannot be tampered with. + # Cookie and the signature expire in 15 seconds. + response.set_signed_cookie('subdomain.signature', + ujson.dumps(data), + expires=15, + domain=domain, + salt='zerver.views.auth') + return response + @require_post def accounts_register(request): # type: (HttpRequest) -> HttpResponse diff --git a/zerver/views/auth.py b/zerver/views/auth.py index bd6fbe2578..306ae50e48 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -11,17 +11,19 @@ from django.middleware.csrf import get_token from django.shortcuts import redirect from django.views.decorators.csrf import csrf_exempt from django.utils.translation import ugettext as _ +from django.core import signing from six import text_type from six.moves import urllib -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple from confirmation.models import Confirmation from zerver.forms import OurAuthenticationForm, WRONG_SUBDOMAIN_ERROR from zerver.lib.request import REQ, has_request_variables, JsonableError from zerver.lib.response import json_success, json_error from zerver.lib.utils import get_subdomain -from zerver.models import PreregistrationUser, UserProfile, remote_user_to_email -from zerver.views import create_homepage_form, create_preregistration_user +from zerver.models import PreregistrationUser, UserProfile, remote_user_to_email, Realm +from zerver.views import create_homepage_form, create_preregistration_user, \ + redirect_and_log_into_subdomain from zproject.backends import password_auth_enabled, dev_auth_enabled, google_auth_enabled from zproject.jinja2 import render_to_response @@ -31,6 +33,7 @@ import jwt import logging import requests import time +import ujson def maybe_send_to_registration(request, email, full_name=''): # type: (HttpRequest, text_type, text_type) -> HttpResponse @@ -149,24 +152,41 @@ def google_oauth2_csrf(request, value): def start_google_oauth2(request): # type: (HttpRequest) -> HttpResponse - uri = 'https://accounts.google.com/o/oauth2/auth?' + main_site_uri = ''.join(( + settings.EXTERNAL_URI_SCHEME, + settings.EXTERNAL_HOST, + reverse('zerver.views.auth.send_oauth_request_to_google'), + )) + params = {'subdomain': get_subdomain(request)} + return redirect(main_site_uri + '?' + urllib.parse.urlencode(params)) + +def send_oauth_request_to_google(request): + # type: (HttpRequest) -> HttpResponse + subdomain = request.GET.get('subdomain', '') + if settings.REALMS_HAVE_SUBDOMAINS: + if not subdomain or not Realm.objects.filter(subdomain=subdomain).count(): + return redirect_to_subdomain_login_url() + + google_uri = 'https://accounts.google.com/o/oauth2/auth?' cur_time = str(int(time.time())) - csrf_state = '{}:{}'.format( + csrf_state = '{}:{}:{}'.format( cur_time, - google_oauth2_csrf(request, cur_time), + google_oauth2_csrf(request, cur_time + subdomain), + subdomain ) + prams = { 'response_type': 'code', 'client_id': settings.GOOGLE_OAUTH2_CLIENT_ID, 'redirect_uri': ''.join(( settings.EXTERNAL_URI_SCHEME, - request.get_host(), + settings.EXTERNAL_HOST, reverse('zerver.views.auth.finish_google_oauth2'), )), 'scope': 'profile email', 'state': csrf_state, } - return redirect(uri + urllib.parse.urlencode(prams)) + return redirect(google_uri + urllib.parse.urlencode(prams)) def finish_google_oauth2(request): # type: (HttpRequest) -> HttpResponse @@ -178,12 +198,12 @@ def finish_google_oauth2(request): return HttpResponse(status=400) csrf_state = request.GET.get('state') - if csrf_state is None or len(csrf_state.split(':')) != 2: + if csrf_state is None or len(csrf_state.split(':')) != 3: logging.warning('Missing Google oauth2 CSRF state') return HttpResponse(status=400) - value, hmac_value = csrf_state.split(':') - if hmac_value != google_oauth2_csrf(request, value): + value, hmac_value, subdomain = csrf_state.split(':') + if hmac_value != google_oauth2_csrf(request, value + subdomain): logging.warning('Google oauth2 CSRF error') return HttpResponse(status=400) @@ -195,7 +215,7 @@ def finish_google_oauth2(request): 'client_secret': settings.GOOGLE_OAUTH2_CLIENT_SECRET, 'redirect_uri': ''.join(( settings.EXTERNAL_URI_SCHEME, - request.get_host(), + settings.EXTERNAL_HOST, reverse('zerver.views.auth.finish_google_oauth2'), )), 'grant_type': 'authorization_code', @@ -234,16 +254,56 @@ def finish_google_oauth2(request): else: logging.error('Google oauth2 account email not found: %s' % (body,)) return HttpResponse(status=400) + email_address = email['value'] + if not subdomain: + # When request was not initiated from subdomain. + user_profile, return_data = authenticate_remote_user(request, email_address) + invalid_subdomain = bool(return_data.get('invalid_subdomain')) + return login_or_register_remote_user(request, email_address, user_profile, + full_name, invalid_subdomain) + + try: + realm = Realm.objects.get(subdomain=subdomain) + except Realm.DoesNotExist: + return redirect_to_subdomain_login_url() + + return redirect_and_log_into_subdomain(realm, full_name, email_address) + +def authenticate_remote_user(request, email_address): + # type: (HttpRequest, str) -> Tuple[UserProfile, Dict[str, Any]] return_data = {} # type: Dict[str, bool] user_profile = authenticate(username=email_address, realm_subdomain=get_subdomain(request), use_dummy_backend=True, return_data=return_data) + return user_profile, return_data +def log_into_subdomain(request): + # type: (HttpRequest) -> HttpResponse + try: + # Discard state if older than 15 seconds + state = request.get_signed_cookie('subdomain.signature', + salt='zerver.views.auth', + max_age=15) + except KeyError: + logging.warning('Missing subdomain signature cookie.') + return HttpResponse(status=400) + except signing.BadSignature: + logging.warning('Subdomain cookie has bad signature.') + return HttpResponse(status=400) + + data = ujson.loads(state) + if data['subdomain'] != get_subdomain(request): + logging.warning('Login attemp on invalid subdomain') + return HttpResponse(status=400) + + email_address = data['email'] + full_name = data['name'] + user_profile, return_data = authenticate_remote_user(request, email_address) invalid_subdomain = bool(return_data.get('invalid_subdomain')) - return login_or_register_remote_user(request, email_address, user_profile, full_name, - invalid_subdomain) + return login_or_register_remote_user(request, email_address, user_profile, + full_name, invalid_subdomain) def login_page(request, **kwargs): # type: (HttpRequest, **Any) -> HttpResponse diff --git a/zproject/urls.py b/zproject/urls.py index d4304df2e1..8261f46640 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -37,7 +37,9 @@ i18n_urls = [ url(r'^accounts/login/sso/$', 'zerver.views.auth.remote_user_sso', name='login-sso'), url(r'^accounts/login/jwt/$', 'zerver.views.auth.remote_user_jwt', name='login-jwt'), url(r'^accounts/login/google/$', 'zerver.views.auth.start_google_oauth2'), + url(r'^accounts/login/google/send/$', 'zerver.views.auth.send_oauth_request_to_google'), url(r'^accounts/login/google/done/$', 'zerver.views.auth.finish_google_oauth2'), + url(r'^accounts/login/subdomain/$', 'zerver.views.auth.log_into_subdomain'), url(r'^accounts/login/local/$', 'zerver.views.auth.dev_direct_login'), # We have two entries for accounts/login to allow reverses on the Django # view we're wrapping to continue to function.