auth: Migrate google auth to python-social-auth.

This replaces the two custom Google authentication backends originally
written in 2012 with using the shared python-social-auth codebase that
we already use for the GitHub authentication backend.  These are:

* GoogleMobileOauth2Backend, the ancient code path for mobile
  authentication last used by the EOL original Zulip Android app.

* The `finish_google_oauth2` code path in zerver/views/auth.py, which
  was the webapp (and modern mobile app) Google authentication code
  path.

This change doesn't fix any known bugs; its main benefit is that we
get to remove hundreds of lines of security-sensitive semi-duplicated
code, replacing it with a widely trusted, high quality third-party
library.
This commit is contained in:
Harshit Bansal 2019-02-02 15:51:26 +00:00 committed by Tim Abbott
parent 5fc37c5f9b
commit bf14a0af4d
22 changed files with 146 additions and 732 deletions

View File

@ -26,7 +26,7 @@ creating the initial realm and user. You can disable it after that.
With just a few lines of configuration, your Zulip server can
authenticate users with any of several single-sign-on (SSO)
authentication providers:
* Google accounts, with `GoogleMobileOauth2Backend`
* Google accounts, with `GoogleAuthBackend`
* GitHub accounts, with `GitHubAuthBackend`
* Microsoft Azure Active Directory, with `AzureADAuthBackend`

View File

@ -32,17 +32,17 @@ Here are the full procedures for dev:
services" > "Credentials". Create a "Project" which will correspond
to your dev environment.
* Navigate to "APIs & services" > "Library", and find the "Google+
API". Choose "Enable".
* Navigate to "APIs & services" > "Library", and find the "Identity
Toolkit API". Choose "Enable".
* Return to "Credentials", and select "Create credentials". Choose
"OAuth client ID", and follow prompts to create a consent screen, etc.
For "Authorized redirect URIs", fill in
`https://zulipdev.com:9991/accounts/login/google/done/` .
`http://zulipdev.com:9991/complete/google/` .
* You should get a client ID and a client secret. Copy them. In
`dev-secrets.conf`, set `google_auth2_client_id` to the client ID
and `google_oauth2_client_secret` to the client secret.
`dev-secrets.conf`, set `social_auth_google_key` to the client ID
and `social_auth_google_secret` to the client secret.
### GitHub

View File

@ -46,9 +46,6 @@ django-statsd-mozilla==0.4.0
# Needed for Android push notifications
python-gcm==0.4
# Needed for Google Apps mobile auth
google-api-python-client==1.7.4
# Needed for the email mirror
html2text==2018.1.9
httplib2==0.12.3
@ -72,8 +69,6 @@ markdown-include==0.5.1
# Needed for mock objects in decorators
mock==2.0.0
oauth2client==4.1.3
# Needed to access rabbitmq
# See #8466 for why we're not using the latest version.
pika==0.13.0

View File

@ -29,7 +29,7 @@ beautifulsoup4==4.7.1
boto3==1.9.183 # via moto
boto==2.49.0
botocore==1.12.183 # via boto3, moto, s3transfer
cachetools==3.1.1 # via google-auth, premailer
cachetools==3.1.1 # via premailer
cchardet==2.1.4
certifi==2019.3.9 # via requests
cffi==1.12.3 # via argon2-cffi, cryptography
@ -62,9 +62,6 @@ ecdsa==0.13.2 # via python-jose
fakeldap==0.6.1
first==2.0.2 # via pip-tools
gitlint==0.11.0
google-api-python-client==1.7.4
google-auth-httplib2==0.0.3 # via google-api-python-client
google-auth==1.6.3 # via google-api-python-client, google-auth-httplib2
h2==2.6.2 # via hyper
hpack==3.0.0 # via h2
html2text==2018.1.9
@ -95,7 +92,6 @@ mock==2.0.0
moto==1.3.7
mypy-extensions==0.4.1
mypy==0.670
oauth2client==4.1.3
oauthlib==3.0.1 # via requests-oauthlib, social-auth-core
packaging==19.0 # via sphinx
parsel==1.5.1 # via scrapy
@ -115,8 +111,8 @@ ptyprocess==0.6.0 # via pexpect
py3dns==3.2.0
pyahocorasick==1.4.0
pyaml==19.4.1 # via moto
pyasn1-modules==0.2.5 # via google-auth, oauth2client, python-ldap, service-identity
pyasn1==0.4.5 # via oauth2client, pyasn1-modules, python-ldap, rsa, service-identity
pyasn1-modules==0.2.5 # via python-ldap, service-identity
pyasn1==0.4.5 # via pyasn1-modules, python-ldap, service-identity
pycodestyle==2.5.0
pycparser==2.19 # via cffi
pycryptodome==3.8.2 # via python-jose
@ -150,7 +146,6 @@ regex==2019.6.8
requests-oauthlib==1.0.0 # via python-twitter, social-auth-core
requests[security]==2.22.0 # via aws-xray-sdk, docker, hypchat, matrix-client, moto, premailer, pyoembed, python-digitalocean, python-gcm, python-twitter, requests-oauthlib, responses, social-auth-core, sphinx, stripe, twilio
responses==0.10.6 # via moto
rsa==4.0 # via google-auth, oauth2client
s3transfer==0.2.1 # via boto3
scrapy==1.6.0
service-identity==18.1.0 # via scrapy
@ -178,7 +173,6 @@ twilio==6.26.2
twisted==19.2.1
typed-ast==1.3.5 # via mypy
typing==3.6.6
uritemplate==3.0.0 # via google-api-python-client
urllib3==1.25.3 # via botocore, requests, transifex-client
virtualenv-clone==0.5.3
w3lib==1.20.0 # via parsel, scrapy

View File

@ -22,7 +22,7 @@ babel==2.7.0 # via django-phonenumber-field
backcall==0.1.0 # via ipython
beautifulsoup4==4.7.1
boto==2.49.0
cachetools==3.1.1 # via google-auth, premailer
cachetools==3.1.1 # via premailer
cchardet==2.1.4
certifi==2019.3.9 # via requests
cffi==1.12.3 # via argon2-cffi, cryptography
@ -45,9 +45,6 @@ django-statsd-mozilla==0.4.0
django-two-factor-auth==1.8.0
django-webpack-loader==0.6.0
django==1.11.22
google-api-python-client==1.7.4
google-auth-httplib2==0.0.3 # via google-api-python-client
google-auth==1.6.3 # via google-api-python-client, google-auth-httplib2
h2==2.6.2 # via hyper
hpack==3.0.0 # via h2
html2text==2018.1.9
@ -69,7 +66,6 @@ markupsafe==1.1.1 # via jinja2
matrix-client==0.3.2
mock==2.0.0
mypy_extensions==0.4.1
oauth2client==4.1.3
oauthlib==3.0.1 # via requests-oauthlib, social-auth-core
parso==0.5.0 # via jedi
pbr==5.3.1 # via mock
@ -85,8 +81,8 @@ psycopg2==2.8.2
ptyprocess==0.6.0 # via pexpect
py3dns==3.2.0
pyahocorasick==1.4.0
pyasn1-modules==0.2.5 # via google-auth, oauth2client, python-ldap
pyasn1==0.4.5 # via oauth2client, pyasn1-modules, python-ldap, rsa
pyasn1-modules==0.2.5 # via python-ldap
pyasn1==0.4.5 # via pyasn1-modules, python-ldap
pycparser==2.19 # via cffi
pygments==2.3.1
pyjwt==1.7.1
@ -107,7 +103,6 @@ redis==2.10.6
regex==2019.6.8
requests-oauthlib==1.0.0 # via python-twitter, social-auth-core
requests[security]==2.22.0 # via hypchat, matrix-client, premailer, pyoembed, python-gcm, python-twitter, requests-oauthlib, social-auth-core, stripe, twilio
rsa==4.0 # via google-auth, oauth2client
simplegeneric==0.8.1 # via ipython
six==1.12.0
social-auth-app-django==3.1.0
@ -122,7 +117,6 @@ tornado==4.5.3
traitlets==4.3.2 # via ipython
twilio==6.26.2
typing==3.6.6
uritemplate==3.0.0 # via google-api-python-client
urllib3==1.25.3 # via requests
uwsgi==2.0.17.1
virtualenv-clone==0.5.3

View File

@ -118,8 +118,7 @@ $(function () {
https://stackoverflow.com/questions/5283395/url-hash-is-persisting-between-redirects */
var email_formaction = $("#login_form").attr('action');
$("#login_form").attr('action', email_formaction + '/' + window.location.hash);
$("#google_login_form input[name='next']").attr('value', '/' + window.location.hash);
$("#social_login_form input[name='next']").attr('value', '/' + window.location.hash);
$(".social_login_form input[name='next']").attr('value', '/' + window.location.hash);
var sso_address = $("#sso-login").attr('href');
$("#sso-login").attr('href', sso_address + window.location.hash);

View File

@ -621,8 +621,9 @@ button.login-social-button:active {
box-shadow: 0px 1px 1px hsla(0, 0%, 0%, 0.3);
}
button.login-google-button {
.google-wrapper button.login-social-button {
background-image: url('/static/images/landing-page/logos/googl_e-icon.png');
width: 100%;
}
.github-wrapper::before {

View File

@ -73,18 +73,6 @@ page can be easily identified in it's respective JavaScript file -->
{% endif %}
{% endif %}
{% if google_auth_enabled %}
<div class="login-social">
<form class="form-inline" action="{{ url('zerver.views.auth.start_google_oauth2') }}" method="get">
<input type='hidden' name='is_signup' value='1' />
<input type='hidden' name='multiuse_object_key' value='{{ multiuse_object_key }}' />
<button class="login-social-button login-google-button full-width">
{{ _('Sign up with %(identity_provider)s', identity_provider="Google") }}
</button>
</form>
</div>
{% endif %}
{% for backend in social_backends %}
<div class="login-social">
<form class="form-inline {{ backend.name }}-wrapper" action="{{ backend.signup_url }}" method="get">

View File

@ -50,9 +50,9 @@
{% if google_error %}
{% if development_environment %}
{{ render_markdown_path('zerver/google-error.md', {"root_domain_uri": root_domain_uri, "settings_path": secrets_path, "secrets_path": secrets_path, "client_id_key_name": "google_oauth2_client_id"}) }}
{{ render_markdown_path('zerver/google-error.md', {"root_domain_uri": root_domain_uri, "settings_path": secrets_path, "secrets_path": secrets_path, "client_id_key_name": "social_auth_google_key"}) }}
{% else %}
{{ render_markdown_path('zerver/google-error.md', {"root_domain_uri": root_domain_uri, "settings_path": settings_path, "secrets_path": secrets_path, "client_id_key_name": "GOOGLE_OAUTH2_CLIENT_ID"}) }}
{{ render_markdown_path('zerver/google-error.md', {"root_domain_uri": root_domain_uri, "settings_path": settings_path, "secrets_path": secrets_path, "client_id_key_name": "SOCIAL_AUTH_GOOGLE_KEY"}) }}
{% endif %}
{% endif %}

View File

@ -1,13 +1,13 @@
You are using the **Google auth backend**, but it is not properly
configured. Please check the following:
* You have created a Google Oauth2 client and enabled the Google+ API.
* You have created a Google Oauth2 client and enabled the Identity Toolkit API.
You can create OAuth2 apps at [the Google developer console](https://console.developers.google.com).
* You have configured your OAuth2 client to allow redirects to your
server's Google auth URL: `{{ root_domain_uri }}/accounts/login/google/done/`.
* You have set `{{ client_id_key_name }}` in `{{ settings_path }}` and
`google_oauth2_client_secret` in `{{ secrets_path }}`.
`social_auth_google_secret` in `{{ secrets_path }}`.
* Navigate back to the login page and attempt the Google auth flow again.

View File

@ -140,20 +140,9 @@
{% endif %} <!-- if password_auth_enabled -->
{% if google_auth_enabled %}
<div class="login-social">
<form id='google_login_form' class="form-inline" action="{{ url('zerver.views.auth.start_google_oauth2') }}" method="get">
<input type="hidden" name="next" value="{{ next }}">
<button class="login-social-button login-google-button full-width">
{{ _('Log in with %(identity_provider)s', identity_provider="Google") }}
</button>
</form>
</div>
{% endif %}
{% for backend in social_backends %}
<div class="login-social">
<form id='social_login_form' class="form-inline {{ backend.name }}-wrapper" action="{{ backend.login_url }}" method="get">
<form class="social_login_form form-inline {{ backend.name }}-wrapper" action="{{ backend.login_url }}" method="get">
<input type="hidden" name="next" value="{{ next }}">
<button class="login-social-button">
{{ _('Log in with %(identity_provider)s', identity_provider=backend.display_name) }}

View File

@ -26,4 +26,4 @@ LATEST_RELEASE_ANNOUNCEMENT = "https://blog.zulip.org/2019/03/01/zulip-2-0-relea
# historical commits sharing the same major version, in which case a
# minor version bump suffices.
PROVISION_VERSION = '40.0'
PROVISION_VERSION = '41.0'

View File

@ -7,7 +7,6 @@ from django_auth_ldap.backend import LDAPBackend, _LDAPUser
from django.test.client import RequestFactory
from django.utils.timezone import now as timezone_now
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from oauth2client.crypt import AppIdentityError
from django.core import signing
from django.urls import reverse
@ -51,9 +50,9 @@ from zerver.signals import JUST_CREATED_THRESHOLD
from confirmation.models import Confirmation, create_confirmation_link
from zproject.backends import ZulipDummyBackend, EmailAuthBackend, \
GoogleMobileOauth2Backend, ZulipRemoteUserBackend, ZulipLDAPAuthBackend, \
GoogleAuthBackend, ZulipRemoteUserBackend, ZulipLDAPAuthBackend, \
ZulipLDAPUserPopulator, DevAuthBackend, GitHubAuthBackend, ZulipAuthMixin, \
dev_auth_enabled, password_auth_enabled, github_auth_enabled, \
dev_auth_enabled, password_auth_enabled, github_auth_enabled, google_auth_enabled, \
require_email_format_usernames, AUTH_BACKEND_NAME_MAP, \
ZulipLDAPConfigurationError, ZulipLDAPExceptionOutsideDomain, \
ZulipLDAPException, query_ldap, sync_user_from_ldap, SocialAuthMixin
@ -251,7 +250,7 @@ class AuthBackendTest(ZulipTestCase):
result = self.client_get('/register/')
self.assert_in_success_response(["No authentication backends are enabled"], result)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleMobileOauth2Backend',))
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleAuthBackend',))
def test_any_backend_enabled(self) -> None:
# testing to avoid false error messages.
@ -261,46 +260,6 @@ class AuthBackendTest(ZulipTestCase):
result = self.client_get('/register/')
self.assert_not_in_success_response(["No authentication backends are enabled"], result)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleMobileOauth2Backend',))
def test_google_backend(self) -> None:
user_profile = self.example_user('hamlet')
email = user_profile.email
backend = GoogleMobileOauth2Backend()
payload = dict(email_verified=True,
email=email)
with mock.patch('apiclient.sample_tools.client.verify_id_token', return_value=payload):
self.verify_backend(backend,
good_kwargs=dict(google_oauth2_token="",
realm=get_realm("zulip")),
bad_kwargs=dict(google_oauth2_token="",
realm=get_realm("zephyr")))
# Verify valid_attestation parameter is set correctly
unverified_payload = dict(email_verified=False)
with mock.patch('apiclient.sample_tools.client.verify_id_token',
return_value=unverified_payload):
ret = dict() # type: Dict[str, str]
result = backend.authenticate(
google_oauth2_token="", realm=get_realm("zulip"), return_data=ret)
self.assertIsNone(result)
self.assertFalse(ret["valid_attestation"])
nonexistent_user_payload = dict(email_verified=True, email="invalid@zulip.com")
with mock.patch('apiclient.sample_tools.client.verify_id_token',
return_value=nonexistent_user_payload):
ret = dict()
result = backend.authenticate(
google_oauth2_token="", realm=get_realm("zulip"), return_data=ret)
self.assertIsNone(result)
self.assertTrue(ret["valid_attestation"])
with mock.patch('apiclient.sample_tools.client.verify_id_token',
side_effect=AppIdentityError):
ret = dict()
result = backend.authenticate(
google_oauth2_token="", realm=get_realm("zulip"), return_data=ret)
self.assertIsNone(result)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
def test_ldap_backend(self) -> None:
user_profile = self.example_user('hamlet')
@ -373,7 +332,8 @@ class AuthBackendTest(ZulipTestCase):
bad_kwargs=dict(remote_user=username,
realm=get_realm('zephyr')))
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GitHubAuthBackend',))
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GitHubAuthBackend',
'zproject.backends.GoogleAuthBackend'))
def test_social_auth_backends(self) -> None:
user = self.example_user('hamlet')
token_data_dict = {
@ -389,7 +349,27 @@ class AuthBackendTest(ZulipTestCase):
dict(email="ignored@example.com",
verified=False),
]
google_email_data = dict(email=user.email,
name=user.full_name,
email_verified=True)
backends_to_test = {
'google': {
'urls': [
{
'url': "https://accounts.google.com/o/oauth2/token",
'method': httpretty.POST,
'status': 200,
'body': json.dumps(token_data_dict),
},
{
'url': "https://www.googleapis.com/oauth2/v3/userinfo",
'method': httpretty.GET,
'status': 200,
'body': json.dumps(google_email_data),
},
],
'backend': GoogleAuthBackend,
},
'github': {
'urls': [
{
@ -435,6 +415,9 @@ class AuthBackendTest(ZulipTestCase):
result = orig_authenticate(backend, **kwargs)
return result
def patched_get_verified_emails(*args: Any, **kwargs: Any) -> Any:
return google_email_data['email']
for backend_name in backends_to_test:
httpretty.enable(allow_net_connect=False)
urls = backends_to_test[backend_name]['urls'] # type: List[Dict[str, Any]]
@ -447,8 +430,13 @@ class AuthBackendTest(ZulipTestCase):
backend_class = backends_to_test[backend_name]['backend']
backend = backend_class()
backend.strategy = DjangoStrategy(storage=BaseDjangoStorage())
orig_authenticate = backend_class.authenticate
backend.authenticate = patched_authenticate
orig_get_verified_emails = backend_class.get_verified_emails
if backend_name == "google":
backend.get_verified_emails = patched_get_verified_emails
good_kwargs = dict(backend=backend, strategy=backend.strategy,
storage=backend.strategy.storage,
response=token_data_dict,
@ -464,21 +452,10 @@ class AuthBackendTest(ZulipTestCase):
good_kwargs=good_kwargs,
bad_kwargs=bad_kwargs)
backend.authenticate = orig_authenticate
backend.get_verified_emails = orig_get_verified_emails
httpretty.disable()
httpretty.reset()
class ResponseMock:
def __init__(self, status_code: int, data: Any) -> None:
self.status_code = status_code
self.data = data
def json(self) -> str:
return self.data
@property
def text(self) -> str:
return "Response text"
class SocialAuthBase(ZulipTestCase):
"""This is a base class for testing social-auth backends. These
methods are often overriden by subclasses:
@ -509,6 +486,11 @@ class SocialAuthBase(ZulipTestCase):
from social_core.backends.utils import load_backends
load_backends(settings.AUTHENTICATION_BACKENDS, force_load=True)
def register_extra_endpoints(self,
account_data_dict: Dict[str, str],
**extra_data: Any) -> None:
pass
def social_auth_test(self, account_data_dict: Dict[str, str],
*, subdomain: Optional[str]=None,
mobile_flow_otp: Optional[str]=None,
@ -1170,123 +1152,35 @@ class GitHubAuthBackendTest(SocialAuthBase):
mock_warning.assert_called_once_with("Social auth (GitHub) failed because user has no verified"
" emails associated with the account")
class GoogleOAuthTest(ZulipTestCase):
def google_oauth2_test(self, token_response: ResponseMock, account_response: ResponseMock,
*, subdomain: Optional[str]=None,
mobile_flow_otp: Optional[str]=None,
is_signup: Optional[str]=None,
next: str='',
multiuse_object_key: str='') -> HttpResponse:
url = "/accounts/login/google/"
params = {}
headers = {}
if subdomain is not None:
headers['HTTP_HOST'] = subdomain + ".testserver"
if mobile_flow_otp is not None:
params['mobile_flow_otp'] = mobile_flow_otp
headers['HTTP_USER_AGENT'] = "ZulipAndroid"
if is_signup is not None:
params['is_signup'] = is_signup
params['next'] = next
params['multiuse_object_key'] = multiuse_object_key
if len(params) > 0:
url += "?%s" % (urllib.parse.urlencode(params),)
class GoogleAuthBackendTest(SocialAuthBase):
__unittest_skip__ = False
result = self.client_get(url, **headers)
if result.status_code != 302 or '/accounts/login/google/send/' not in result.url:
return result
BACKEND_CLASS = GoogleAuthBackend
CLIENT_KEY_SETTING = "SOCIAL_AUTH_GOOGLE_KEY"
LOGIN_URL = "/accounts/login/social/google"
SIGNUP_URL = "/accounts/register/social/google"
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/auth"
ACCESS_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"
USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
AUTH_FINISH_URL = "/complete/google/"
CONFIG_ERROR_URL = "/config-error/google"
# Now do the /google/send/ request
result = self.client_get(result.url, **headers)
self.assertEqual(result.status_code, 302)
if 'google' not in result.url:
return result
def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]:
return dict(email=email, name=name, email_verified=True)
self.client.cookies = result.cookies
# Now extract the CSRF token from the redirect URL
parsed_url = urllib.parse.urlparse(result.url)
csrf_state = urllib.parse.parse_qs(parsed_url.query)['state']
with mock.patch("requests.post", return_value=token_response), (
mock.patch("requests.get", return_value=account_response)):
result = self.client_get("/accounts/login/google/done/",
dict(state=csrf_state), **headers)
return result
class GoogleSubdomainLoginTest(GoogleOAuthTest):
def test_google_oauth2_start(self) -> None:
result = self.client_get('/accounts/login/google/', subdomain="zulip")
self.assertEqual(result.status_code, 302)
parsed_url = urllib.parse.urlparse(result.url)
subdomain = urllib.parse.parse_qs(parsed_url.query)['subdomain']
self.assertEqual(subdomain, ['zulip'])
def test_google_oauth2_success(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=self.example_email("hamlet"))
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response,
subdomain='zulip', next='/user_uploads/image')
data = load_subdomain_token(result)
self.assertEqual(data['email'], self.example_email("hamlet"))
self.assertEqual(data['name'], 'Full Name')
self.assertEqual(data['subdomain'], 'zulip')
self.assertEqual(data['next'], '/user_uploads/image')
self.assertEqual(result.status_code, 302)
parsed_url = urllib.parse.urlparse(result.url)
uri = "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc,
parsed_url.path)
self.assertTrue(uri.startswith('http://zulip.testserver/accounts/login/subdomain/'))
def test_user_cannot_log_without_verified_email(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=False,
email=self.example_email("hamlet"))
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response,
def test_social_auth_email_not_verified(self) -> None:
account_data_dict = dict(email=self.email, name=self.name)
with mock.patch('logging.warning') as mock_warning:
result = self.social_auth_test(account_data_dict,
subdomain='zulip')
self.assertEqual(result.status_code, 400)
def test_google_oauth2_mobile_success(self) -> None:
self.user_profile = self.example_user('hamlet')
self.user_profile.date_joined = timezone_now() - datetime.timedelta(seconds=JUST_CREATED_THRESHOLD + 1)
self.user_profile.save()
mobile_flow_otp = '1234abcd' * 8
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=self.user_profile.email)
account_response = ResponseMock(200, account_data)
self.assertEqual(len(mail.outbox), 0)
with self.settings(SEND_LOGIN_EMAILS=True):
# Verify that the right thing happens with an invalid-format OTP
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
mobile_flow_otp="1234")
self.assert_json_error(result, "Invalid OTP")
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
mobile_flow_otp="invalido" * 8)
self.assert_json_error(result, "Invalid OTP")
# Now do it correctly
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
mobile_flow_otp=mobile_flow_otp)
self.assertEqual(result.status_code, 302)
redirect_url = result['Location']
parsed_url = urllib.parse.urlparse(redirect_url)
query_params = urllib.parse.parse_qs(parsed_url.query)
self.assertEqual(parsed_url.scheme, 'zulip')
self.assertEqual(query_params["realm"], ['http://zulip.testserver'])
self.assertEqual(query_params["email"], [self.user_profile.email])
encrypted_api_key = query_params["otp_encrypted_api_key"][0]
hamlet_api_keys = get_all_api_keys(self.user_profile)
self.assertIn(otp_decrypt_api_key(encrypted_api_key, mobile_flow_otp), hamlet_api_keys)
self.assertEqual(len(mail.outbox), 1)
self.assertIn('Zulip on Android', mail.outbox[0].body)
self.assertEqual(result.url, "/login/")
mock_warning.assert_called_once_with("Social auth (Google) failed "
"because user has no verified emails")
def test_google_auth_enabled(self) -> None:
with self.settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleAuthBackend',)):
self.assertTrue(google_auth_enabled())
def get_log_into_subdomain(self, data: Dict[str, Any], *, key: Optional[str]=None, subdomain: str='zulip') -> HttpResponse:
token = signing.dumps(data, salt=_subdomain_token_salt, key=key)
@ -1474,33 +1368,6 @@ class GoogleSubdomainLoginTest(GoogleOAuthTest):
result = self.get_log_into_subdomain(data)
self.assert_in_success_response(["You need an invitation to join this organization."], result)
def test_user_cannot_log_into_nonexisting_realm(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=self.example_email("hamlet"))
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response,
subdomain='nonexistent')
self.assert_in_response("There is no Zulip organization hosted at this subdomain.",
result)
self.assertEqual(result.status_code, 404)
def test_user_cannot_log_into_wrong_subdomain(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=self.example_email("hamlet"))
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response,
subdomain='zephyr')
self.assertEqual(result.status_code, 302)
self.assertTrue(result.url.startswith("http://zephyr.testserver/accounts/login/subdomain/"))
result = self.client_get(result.url.replace('http://zephyr.testserver', ''),
subdomain="zephyr")
self.assert_in_success_response(['Your email address, hamlet@zulip.com, is not in one of the domains ',
'that are allowed to register for accounts in this organization.'], result)
def test_user_cannot_log_into_wrong_subdomain_with_cookie(self) -> None:
data = {'name': 'Full Name',
'email': self.example_email("hamlet"),
@ -1510,216 +1377,6 @@ class GoogleSubdomainLoginTest(GoogleOAuthTest):
mock_warning.assert_called_with("Login attempt on invalid subdomain")
self.assertEqual(result.status_code, 400)
def test_google_oauth2_registration(self) -> None:
"""If the user doesn't exist yet, Google auth can be used to register an account"""
email = "newuser@zulip.com"
realm = get_realm("zulip")
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=email)
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
is_signup='1')
data = load_subdomain_token(result)
name = 'Full Name'
self.assertEqual(data['email'], email)
self.assertEqual(data['name'], name)
self.assertEqual(data['subdomain'], 'zulip')
self.assertEqual(result.status_code, 302)
parsed_url = urllib.parse.urlparse(result.url)
uri = "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc,
parsed_url.path)
self.assertTrue(uri.startswith('http://zulip.testserver/accounts/login/subdomain/'))
result = self.client_get(result.url)
self.assertEqual(result.status_code, 302)
confirmation = Confirmation.objects.all().first()
confirmation_key = confirmation.confirmation_key
self.assertIn('do_confirm/' + confirmation_key, result.url)
result = self.client_get(result.url)
self.assert_in_response('action="/accounts/register/"', result)
data = {"from_confirmation": "1",
"full_name": name,
"key": confirmation_key}
result = self.client_post('/accounts/register/', data)
self.assert_in_response("We just need you to do one last thing", result)
# Verify that the user is asked for name but not password
self.assert_not_in_success_response(['id_password'], result)
self.assert_in_success_response(['id_full_name'], result)
# Click confirm registration button.
result = self.client_post(
'/accounts/register/',
{'full_name': name,
'key': confirmation_key,
'terms': True})
self.assertEqual(result.status_code, 302)
user_profile = get_user(email, realm)
self.assert_logged_in_user_id(user_profile.id)
def test_google_oauth2_registration_using_multiuse_invite(self) -> None:
"""If the user doesn't exist yet, Google auth can be used to register an account"""
email = "newuser@zulip.com"
realm = get_realm("zulip")
realm.invite_required = True
realm.save()
stream_names = ["new_stream_1", "new_stream_2"]
streams = []
for stream_name in set(stream_names):
stream = ensure_stream(realm, stream_name)
streams.append(stream)
referrer = self.example_user("hamlet")
multiuse_obj = MultiuseInvite.objects.create(realm=realm, referred_by=referrer)
multiuse_obj.streams.set(streams)
link = create_confirmation_link(multiuse_obj, realm.host, Confirmation.MULTIUSE_INVITE)
multiuse_confirmation = Confirmation.objects.all().last()
multiuse_object_key = multiuse_confirmation.confirmation_key
input_element = "name=\'multiuse_object_key\' value=\'{}\' /".format(multiuse_object_key)
response = self.client_get(link)
self.assert_in_success_response([input_element], response)
# First, try to signup for closed realm without using an invitation
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name="Full Name",
email_verified=True,
email=email)
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
is_signup='1', multiuse_object_key="")
result = self.client_get(result.url)
# Verify that we're unable to signup, since this is a closed realm
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["Sign up"], result)
result = self.google_oauth2_test(token_response, account_response, subdomain='zulip',
is_signup='1', multiuse_object_key=multiuse_object_key)
data = load_subdomain_token(result)
name = 'Full Name'
self.assertEqual(data['name'], name)
self.assertEqual(data['subdomain'], 'zulip')
self.assertEqual(data['multiuse_object_key'], multiuse_object_key)
self.assertEqual(result.status_code, 302)
parsed_url = urllib.parse.urlparse(result.url)
uri = "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc,
parsed_url.path)
self.assertTrue(uri.startswith('http://zulip.testserver/accounts/login/subdomain/'))
result = self.client_get(result.url)
self.assertEqual(result.status_code, 302)
confirmation = Confirmation.objects.all().last()
confirmation_key = confirmation.confirmation_key
self.assertIn('do_confirm/' + confirmation_key, result.url)
result = self.client_get(result.url)
self.assert_in_response('action="/accounts/register/"', result)
data = {"from_confirmation": "1",
"full_name": name,
"key": confirmation_key}
result = self.client_post('/accounts/register/', data)
self.assert_in_response("We just need you to do one last thing", result)
# Verify that the user is asked for name but not password
self.assert_not_in_success_response(['id_password'], result)
self.assert_in_success_response(['id_full_name'], result)
# Click confirm registration button.
result = self.client_post(
'/accounts/register/',
{'full_name': name,
'key': confirmation_key,
'terms': True})
self.assertEqual(result.status_code, 302)
user_profile = get_user(email, realm)
self.assert_logged_in_user_id(user_profile.id)
self.assertEqual(sorted(self.get_streams(email, realm)), stream_names)
class GoogleLoginTest(GoogleOAuthTest):
@override_settings(ROOT_DOMAIN_LANDING_PAGE=True)
def test_google_oauth2_subdomains_homepage(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_data = dict(name=dict(formatted="Full Name"),
emails=[dict(type="account",
value=self.example_email("hamlet"))])
account_response = ResponseMock(200, account_data)
result = self.google_oauth2_test(token_response, account_response, subdomain="")
self.assertEqual(result.status_code, 302)
self.assertIn('subdomain=1', result.url)
def test_google_oauth2_400_token_response(self) -> None:
token_response = ResponseMock(400, {})
with mock.patch("logging.warning") as m:
result = self.google_oauth2_test(token_response, ResponseMock(500, {}))
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
"User error converting Google oauth2 login to token: Response text")
def test_google_oauth2_500_token_response(self) -> None:
token_response = ResponseMock(500, {})
with mock.patch("logging.error") as m:
result = self.google_oauth2_test(token_response, ResponseMock(500, {}))
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
"Could not convert google oauth2 code to access_token: Response text")
def test_google_oauth2_400_account_response(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_response = ResponseMock(400, {})
with mock.patch("logging.warning") as m:
result = self.google_oauth2_test(token_response, account_response)
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
"Google login failed making info API call: Response text")
def test_google_oauth2_500_account_response(self) -> None:
token_response = ResponseMock(200, {'access_token': "unique_token"})
account_response = ResponseMock(500, {})
with mock.patch("logging.error") as m:
result = self.google_oauth2_test(token_response, account_response)
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
"Google login failed making API call: Response text")
def test_google_oauth2_error_access_denied(self) -> None:
result = self.client_get("/accounts/login/google/done/?error=access_denied")
self.assertEqual(result.status_code, 302)
path = urllib.parse.urlparse(result.url).path
self.assertEqual(path, "/")
def test_google_oauth2_error_other(self) -> None:
with mock.patch("logging.warning") as m:
result = self.client_get("/accounts/login/google/done/?error=some_other_error")
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
"Error from google oauth2 login: some_other_error")
def test_google_oauth2_missing_csrf(self) -> None:
with mock.patch("logging.warning") as m:
result = self.client_get("/accounts/login/google/done/")
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
'Missing Google oauth2 CSRF state')
def test_google_oauth2_csrf_malformed(self) -> None:
with mock.patch("logging.warning") as m:
result = self.client_get("/accounts/login/google/done/?state=badstate")
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
'Missing Google oauth2 CSRF state')
def test_google_oauth2_csrf_badstate(self) -> None:
with mock.patch("logging.warning") as m:
result = self.client_get("/accounts/login/google/done/?state=badstate:otherbadstate:more::::")
self.assertEqual(result.status_code, 400)
self.assertEqual(m.call_args_list[0][0][0],
'Google oauth2 CSRF error')
class JSONFetchAPIKeyTest(ZulipTestCase):
def setUp(self) -> None:
self.user_profile = self.example_user('hamlet')
@ -1769,49 +1426,6 @@ class FetchAPIKeyTest(ZulipTestCase):
password="wrong"))
self.assert_json_error(result, "Your username or password is incorrect.", 403)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleMobileOauth2Backend',),
SEND_LOGIN_EMAILS=True)
def test_google_oauth2_token_success(self) -> None:
self.assertEqual(len(mail.outbox), 0)
self.user_profile.date_joined = timezone_now() - datetime.timedelta(seconds=JUST_CREATED_THRESHOLD + 1)
self.user_profile.save()
with mock.patch(
'apiclient.sample_tools.client.verify_id_token',
return_value={
"email_verified": True,
"email": self.example_email("hamlet"),
}):
result = self.client_post("/api/v1/fetch_api_key",
dict(username="google-oauth2-token",
password="token"))
self.assert_json_success(result)
self.assertEqual(len(mail.outbox), 1)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleMobileOauth2Backend',))
def test_google_oauth2_token_failure(self) -> None:
payload = dict(email_verified=False)
with mock.patch('apiclient.sample_tools.client.verify_id_token', return_value=payload):
result = self.client_post("/api/v1/fetch_api_key",
dict(username="google-oauth2-token",
password="token"))
self.assert_json_error(result, "Your username or password is incorrect.", 403)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.GoogleMobileOauth2Backend',))
def test_google_oauth2_token_unregistered(self) -> None:
with mock.patch(
'apiclient.sample_tools.client.verify_id_token',
return_value={
"email_verified": True,
"email": "nobody@zulip.com",
}):
result = self.client_post("/api/v1/fetch_api_key",
dict(username="google-oauth2-token",
password="token"))
self.assert_json_error(
result,
"This user is not registered; do so from a browser.",
403)
def test_password_auth_disabled(self) -> None:
with mock.patch('zproject.backends.password_auth_enabled', return_value=False):
result = self.client_post("/api/v1/fetch_api_key",
@ -1984,7 +1598,7 @@ class FetchAuthBackends(ZulipTestCase):
result[backend_name] = backend_name in expected_backends
return result
backends = [GoogleMobileOauth2Backend(), DevAuthBackend()]
backends = [GoogleAuthBackend(), DevAuthBackend()]
with mock.patch('django.contrib.auth.get_backends', return_value=backends):
result = self.client_get("/api/v1/get_auth_backends")
self.assert_json_success(result)

View File

@ -364,32 +364,32 @@ class AboutPageTest(ZulipTestCase):
self.assertEqual(split_by(flat_list, 3, None), expected_result)
class ConfigErrorTest(ZulipTestCase):
@override_settings(GOOGLE_OAUTH2_CLIENT_ID=None)
@override_settings(SOCIAL_AUTH_GOOGLE_KEY=None)
def test_google(self) -> None:
result = self.client_get("/accounts/login/google/")
result = self.client_get("/accounts/login/social/google")
self.assertEqual(result.status_code, 302)
self.assertEqual(result.url, '/config-error/google')
result = self.client_get(result.url)
self.assert_in_success_response(["google_oauth2_client_id"], result)
self.assert_in_success_response(["google_oauth2_client_secret"], result)
self.assert_in_success_response(["social_auth_google_key"], result)
self.assert_in_success_response(["social_auth_google_secret"], result)
self.assert_in_success_response(["zproject/dev-secrets.conf"], result)
self.assert_not_in_success_response(["GOOGLE_OAUTH2_CLIENT_ID"], result)
self.assert_not_in_success_response(["SOCIAL_AUTH_GOOGLE_KEY"], result)
self.assert_not_in_success_response(["zproject/dev_settings.py"], result)
self.assert_not_in_success_response(["/etc/zulip/settings.py"], result)
self.assert_not_in_success_response(["/etc/zulip/zulip-secrets.conf"], result)
@override_settings(GOOGLE_OAUTH2_CLIENT_ID=None)
@override_settings(SOCIAL_AUTH_GOOGLE_KEY=None)
@override_settings(DEVELOPMENT=False)
def test_google_production_error(self) -> None:
result = self.client_get("/accounts/login/google/")
result = self.client_get("/accounts/login/social/google")
self.assertEqual(result.status_code, 302)
self.assertEqual(result.url, '/config-error/google')
result = self.client_get(result.url)
self.assert_in_success_response(["GOOGLE_OAUTH2_CLIENT_ID"], result)
self.assert_in_success_response(["SOCIAL_AUTH_GOOGLE_KEY"], result)
self.assert_in_success_response(["/etc/zulip/settings.py"], result)
self.assert_in_success_response(["google_oauth2_client_secret"], result)
self.assert_in_success_response(["social_auth_google_secret"], result)
self.assert_in_success_response(["/etc/zulip/zulip-secrets.conf"], result)
self.assert_not_in_success_response(["google_oauth2_client_id"], result)
self.assert_not_in_success_response(["social_auth_google_key"], result)
self.assert_not_in_success_response(["zproject/dev_settings.py"], result)
self.assert_not_in_success_response(["zproject/dev-secrets.conf"], result)

View File

@ -1649,7 +1649,7 @@ class EventsRegisterTest(ZulipTestCase):
'zproject.backends.DevAuthBackend',
'zproject.backends.EmailAuthBackend',
'zproject.backends.GitHubAuthBackend',
'zproject.backends.GoogleMobileOauth2Backend',
'zproject.backends.GoogleAuthBackend',
'zproject.backends.ZulipLDAPAuthBackend',
)
return self.settings(AUTHENTICATION_BACKENDS=backends)

View File

@ -9,7 +9,6 @@ from zerver.decorator import require_post, \
process_client, do_login, log_view_func
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import SimpleTemplateResponse
from django.middleware.csrf import get_token
from django.shortcuts import redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
@ -41,12 +40,8 @@ from zproject.backends import password_auth_enabled, dev_auth_enabled, \
AUTH_BACKEND_NAME_MAP, auth_enabled_helper
from version import ZULIP_VERSION
import hashlib
import hmac
import jwt
import logging
import requests
import time
from two_factor.forms import BackupTokenForm
from two_factor.views import LoginView as BaseTwoFactorLoginView
@ -319,16 +314,6 @@ def remote_user_jwt(request: HttpRequest) -> HttpResponse:
return login_or_register_remote_user(request, email, user_profile, remote_user)
def google_oauth2_csrf(request: HttpRequest, value: str) -> str:
# In Django 1.10, get_token returns a salted token which changes
# every time get_token is called.
from django.middleware.csrf import _unsalt_cipher_token
token = _unsalt_cipher_token(get_token(request))
return hmac.new(token.encode('utf-8'), value.encode("utf-8"), hashlib.sha256).hexdigest()
def reverse_on_root(viewname: str, *args: str, **kwargs: str) -> str:
return settings.ROOT_DOMAIN_URI + reverse(viewname, args=args, kwargs=kwargs)
def oauth_redirect_to_root(request: HttpRequest, url: str,
sso_type: str, is_signup: bool=False) -> HttpResponse:
main_site_uri = settings.ROOT_DOMAIN_URI + url
@ -359,20 +344,14 @@ def oauth_redirect_to_root(request: HttpRequest, url: str,
return redirect(main_site_uri + '?' + urllib.parse.urlencode(params))
def start_google_oauth2(request: HttpRequest) -> HttpResponse:
url = reverse('zerver.views.auth.send_oauth_request_to_google')
if not (settings.GOOGLE_OAUTH2_CLIENT_ID and settings.GOOGLE_OAUTH2_CLIENT_SECRET):
return redirect_to_config_error("google")
is_signup = bool(request.GET.get('is_signup'))
return oauth_redirect_to_root(request, url, 'google', is_signup=is_signup)
def start_social_login(request: HttpRequest, backend: str) -> HttpResponse:
backend_url = reverse('social:begin', args=[backend])
if (backend == "github") and not (settings.SOCIAL_AUTH_GITHUB_KEY and
settings.SOCIAL_AUTH_GITHUB_SECRET):
return redirect_to_config_error("github")
if (backend == "google") and not (settings.SOCIAL_AUTH_GOOGLE_KEY and
settings.SOCIAL_AUTH_GOOGLE_SECRET):
return redirect_to_config_error("google")
# TODO: Add a similar block of AzureAD.
return oauth_redirect_to_root(request, backend_url, 'social')
@ -381,114 +360,6 @@ def start_social_signup(request: HttpRequest, backend: str) -> HttpResponse:
backend_url = reverse('social:begin', args=[backend])
return oauth_redirect_to_root(request, backend_url, 'social', is_signup=True)
def send_oauth_request_to_google(request: HttpRequest) -> HttpResponse:
subdomain = request.GET.get('subdomain', '')
is_signup = request.GET.get('is_signup', '')
next = request.GET.get('next', '')
mobile_flow_otp = request.GET.get('mobile_flow_otp', '0')
multiuse_object_key = request.GET.get('multiuse_object_key', '')
if ((settings.ROOT_DOMAIN_LANDING_PAGE and subdomain == '') or
not Realm.objects.filter(string_id=subdomain).exists()):
return redirect_to_subdomain_login_url()
google_uri = 'https://accounts.google.com/o/oauth2/auth?'
cur_time = str(int(time.time()))
csrf_state = '%s:%s:%s:%s:%s:%s' % (cur_time, subdomain, mobile_flow_otp, is_signup,
next, multiuse_object_key)
# Now compute the CSRF hash with the other parameters as an input
csrf_state += ":%s" % (google_oauth2_csrf(request, csrf_state),)
params = {
'response_type': 'code',
'client_id': settings.GOOGLE_OAUTH2_CLIENT_ID,
'redirect_uri': reverse_on_root('zerver.views.auth.finish_google_oauth2'),
'scope': 'profile email',
'state': csrf_state,
'prompt': 'select_account',
}
return redirect(google_uri + urllib.parse.urlencode(params))
@log_view_func
def finish_google_oauth2(request: HttpRequest) -> HttpResponse:
error = request.GET.get('error')
if error == 'access_denied':
return redirect('/')
elif error is not None:
logging.warning('Error from google oauth2 login: %s' % (request.GET.get("error"),))
return HttpResponse(status=400)
csrf_state = request.GET.get('state')
if csrf_state is None or len(csrf_state.split(':')) != 7:
logging.warning('Missing Google oauth2 CSRF state')
return HttpResponse(status=400)
(csrf_data, hmac_value) = csrf_state.rsplit(':', 1)
if hmac_value != google_oauth2_csrf(request, csrf_data):
logging.warning('Google oauth2 CSRF error')
return HttpResponse(status=400)
cur_time, subdomain, mobile_flow_otp, is_signup, next, multiuse_object_key = csrf_data.split(':')
if mobile_flow_otp == '0':
mobile_flow_otp = None
is_signup = bool(is_signup == '1')
resp = requests.post(
'https://www.googleapis.com/oauth2/v3/token',
data={
'code': request.GET.get('code'),
'client_id': settings.GOOGLE_OAUTH2_CLIENT_ID,
'client_secret': settings.GOOGLE_OAUTH2_CLIENT_SECRET,
'redirect_uri': reverse_on_root('zerver.views.auth.finish_google_oauth2'),
'grant_type': 'authorization_code',
},
)
if resp.status_code == 400:
logging.warning('User error converting Google oauth2 login to token: %s' % (resp.text,))
return HttpResponse(status=400)
elif resp.status_code != 200:
logging.error('Could not convert google oauth2 code to access_token: %s' % (resp.text,))
return HttpResponse(status=400)
access_token = resp.json()['access_token']
resp = requests.get(
'https://www.googleapis.com/oauth2/v3/userinfo',
params={'access_token': access_token}
)
if resp.status_code == 400:
logging.warning('Google login failed making info API call: %s' % (resp.text,))
return HttpResponse(status=400)
elif resp.status_code != 200:
logging.error('Google login failed making API call: %s' % (resp.text,))
return HttpResponse(status=400)
body = resp.json()
if not body['email_verified']:
logging.error('Google oauth2 account email not verified.')
return HttpResponse(status=400)
# Extract the user info from the Google response
full_name = body['name']
email_address = body['email']
try:
realm = Realm.objects.get(string_id=subdomain)
except Realm.DoesNotExist: # nocoverage
return redirect_to_subdomain_login_url()
if mobile_flow_otp is not None:
# When request was not initiated from subdomain.
user_profile = authenticate_remote_user(realm, email_address)
return login_or_register_remote_user(request, email_address, user_profile,
full_name,
mobile_flow_otp=mobile_flow_otp,
is_signup=is_signup,
redirect_to=next)
return redirect_and_log_into_subdomain(
realm, full_name, email_address, is_signup=is_signup,
redirect_to=next, multiuse_object_key=multiuse_object_key)
def authenticate_remote_user(realm: Realm, email_address: str) -> UserProfile:
if email_address is None:
# No need to authenticate if email address is None. We already
@ -847,17 +718,10 @@ def api_fetch_api_key(request: HttpRequest, username: str=REQ(), password: str=R
return_data = {} # type: Dict[str, bool]
subdomain = get_subdomain(request)
realm = get_realm(subdomain)
if username == "google-oauth2-token":
# This code path is auth for the legacy Android app
user_profile = authenticate(google_oauth2_token=password,
realm=realm,
return_data=return_data)
else:
if not ldap_auth_enabled(realm=get_realm_from_request(request)):
# In case we don't authenticate against LDAP, check for a valid
# email. LDAP backend can authenticate against a non-email.
validate_login_email(username)
user_profile = authenticate(username=username,
password=password,
realm=realm,
@ -872,11 +736,6 @@ def api_fetch_api_key(request: HttpRequest, username: str=REQ(), password: str=R
return json_error(_("Password auth is disabled in your team."),
data={"reason": "password auth disabled"}, status=403)
if user_profile is None:
if return_data.get("valid_attestation"):
# We can leak that the user is unregistered iff
# they present a valid authentication string for the user.
return json_error(_("This user is not registered; do so from a browser."),
data={"reason": "unregistered"}, status=403)
return json_error(_("Your username or password is incorrect."),
data={"reason": "incorrect_creds"}, status=403)

View File

@ -31,6 +31,7 @@ from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2,
GithubTeamOAuth2
from social_core.backends.azuread import AzureADOAuth2
from social_core.backends.base import BaseAuth
from social_core.backends.google import GoogleOAuth2
from social_core.backends.oauth import BaseOAuth2
from social_core.pipeline.partial import partial
from social_core.exceptions import AuthFailed, SocialAuthBaseException
@ -194,45 +195,6 @@ class EmailAuthBackend(ZulipAuthMixin):
return user_profile
return None
class GoogleMobileOauth2Backend(ZulipAuthMixin):
"""
Google Apps authentication for the legacy Android app.
DummyAuthBackend is what's actually used for our modern Google auth,
both for web and mobile (the latter via the mobile_flow_otp feature).
Allows a user to sign in using a Google-issued OAuth2 token.
Ref:
https://developers.google.com/+/mobile/android/sign-in#server-side_access_for_your_app
https://developers.google.com/accounts/docs/CrossClientAuth#offlineAccess
"""
def authenticate(self, *, google_oauth2_token: str, realm: Realm,
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
# We lazily import apiclient as part of optimizing the base
# import time for a Zulip management command, since it's only
# used in this one code path and takes 30-50ms to import.
from apiclient.sample_tools import client as googleapiclient
from oauth2client.crypt import AppIdentityError
if return_data is None:
return_data = {}
if not google_auth_enabled(realm=realm):
return_data["google_auth_disabled"] = True
return None
try:
token_payload = googleapiclient.verify_id_token(google_oauth2_token, settings.GOOGLE_CLIENT_ID)
except AppIdentityError:
return None
if token_payload["email_verified"] not in (True, "true"):
return_data["valid_attestation"] = False
return None
return_data["valid_attestation"] = True
return common_get_active_user(token_payload["email"], realm, return_data)
class ZulipRemoteUserBackend(RemoteUserBackend):
"""Authentication backend that reads the Apache REMOTE_USER variable.
Used primarily in enterprise environments with an SSO solution
@ -966,14 +928,26 @@ class AzureADAuthBackend(SocialAuthMixin, AzureADOAuth2):
sort_order = 50
auth_backend_name = "AzureAD"
class GoogleAuthBackend(SocialAuthMixin, GoogleOAuth2):
sort_order = 150
auth_backend_name = "Google"
name = "google"
def get_verified_emails(self, *args: Any, **kwargs: Any) -> List[str]:
verified_emails = [] # type: List[str]
details = kwargs["response"]
email_verified = details.get("email_verified")
if email_verified:
verified_emails.append(details["email"])
return verified_emails
AUTH_BACKEND_NAME_MAP = {
'Dev': DevAuthBackend,
'Email': EmailAuthBackend,
'Google': GoogleMobileOauth2Backend,
'LDAP': ZulipLDAPAuthBackend,
'RemoteUser': ZulipRemoteUserBackend,
} # type: Dict[str, Any]
OAUTH_BACKEND_NAMES = ["Google"] # type: List[str]
OAUTH_BACKEND_NAMES = [] # type: List[str]
SOCIAL_AUTH_BACKENDS = [] # type: List[BaseOAuth2]
# Authomatically add all of our social auth backends to relevant data structures.
@ -982,3 +956,7 @@ for social_auth_subclass in SocialAuthMixin.__subclasses__():
if issubclass(social_auth_subclass, BaseOAuth2):
OAUTH_BACKEND_NAMES.append(social_auth_subclass.auth_backend_name)
SOCIAL_AUTH_BACKENDS.append(social_auth_subclass)
# Provide this alternative name for backwards compatibility with
# installations that had the old backend enabled.
GoogleMobileOauth2Backend = GoogleAuthBackend

View File

@ -43,7 +43,7 @@ AUTHENTICATION_BACKENDS = (
'zproject.backends.DevAuthBackend',
'zproject.backends.EmailAuthBackend',
'zproject.backends.GitHubAuthBackend',
'zproject.backends.GoogleMobileOauth2Backend',
'zproject.backends.GoogleAuthBackend',
# 'zproject.backends.AzureADAuthBackend',
)

View File

@ -112,7 +112,7 @@ EXTERNAL_HOST = 'zulip.example.com'
# initial realm and user.
AUTHENTICATION_BACKENDS = (
'zproject.backends.EmailAuthBackend', # Email and password; just requires SMTP setup
# 'zproject.backends.GoogleMobileOauth2Backend', # Google Apps, setup below
# 'zproject.backends.GoogleAuthBackend', # Google auth, setup below
# 'zproject.backends.GitHubAuthBackend', # GitHub auth, setup below
# 'zproject.backends.AzureADAuthBackend', # Microsoft Azure Active Directory auth, setup below
# 'zproject.backends.ZulipLDAPAuthBackend', # LDAP, setup below
@ -129,7 +129,7 @@ AUTHENTICATION_BACKENDS = (
# correspond to your Zulip instance.
#
# (2) Navigate to "APIs & services" > "Library", and find the
# "Google+ API". Choose "Enable".
# "Identity Toolkit API". Choose "Enable".
#
# (3) Return to "Credentials", and select "Create credentials".
# Choose "OAuth client ID", and follow prompts to create a consent
@ -138,9 +138,9 @@ AUTHENTICATION_BACKENDS = (
# based on your value for EXTERNAL_HOST.
#
# (4) You should get a client ID and a client secret. Copy them.
# Use the client ID as `GOOGLE_OAUTH2_CLIENT_ID` here, and put the
# client secret in zulip-secrets.conf as `google_oauth2_client_secret`.
#GOOGLE_OAUTH2_CLIENT_ID = <your client ID from Google>
# Use the client ID as `SOCIAL_AUTH_GOOGLE_KEY` here, and put the
# client secret in zulip-secrets.conf as `social_auth_google_secret`.
#SOCIAL_AUTH_GOOGLE_KEY = <your client ID from Google>
########
# GitHub OAuth.

View File

@ -154,11 +154,13 @@ DEFAULT_SETTINGS = {
# Social auth; we support providing values for some of these
# settings in zulip-secrets.conf instead of settings.py in development.
'SOCIAL_AUTH_GITHUB_KEY': get_secret('social_auth_github_key', development_only=True),
'GOOGLE_OAUTH2_CLIENT_ID': get_secret('google_oauth2_client_id', development_only=True),
'SOCIAL_AUTH_GITHUB_ORG_NAME': None,
'SOCIAL_AUTH_GITHUB_TEAM_ID': None,
'SOCIAL_AUTH_SUBDOMAIN': None,
'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET': get_secret('azure_oauth2_secret'),
'SOCIAL_AUTH_GOOGLE_KEY': get_secret('social_auth_google_key', development_only=True),
# Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production.
'GOOGLE_OAUTH2_CLIENT_ID': None,
# Other auth
'SSO_APPEND_DOMAIN': None,
@ -769,8 +771,6 @@ if LOCAL_UPLOADS_DIR is not None:
# https://cloud.google.com/console/project/apps~zulip-android/apiui/credential
ANDROID_GCM_API_KEY = get_secret("android_gcm_api_key")
GOOGLE_OAUTH2_CLIENT_SECRET = get_secret('google_oauth2_client_secret')
DROPBOX_APP_KEY = get_secret("dropbox_app_key")
MAILCHIMP_API_KEY = get_secret("mailchimp_api_key")
@ -1348,6 +1348,14 @@ SOCIAL_AUTH_GITHUB_ORG_SECRET = SOCIAL_AUTH_GITHUB_SECRET
SOCIAL_AUTH_GITHUB_TEAM_KEY = SOCIAL_AUTH_GITHUB_KEY
SOCIAL_AUTH_GITHUB_TEAM_SECRET = SOCIAL_AUTH_GITHUB_SECRET
SOCIAL_AUTH_GOOGLE_SECRET = get_secret('social_auth_google_secret')
# Fallback to google-oauth settings in case social auth settings for
# google are missing; this is for backwards-compatibility with older
# Zulip versions where /etc/zulip/settings.py has not been migrated yet.
GOOGLE_OAUTH2_CLIENT_SECRET = get_secret('google_oauth2_client_secret')
SOCIAL_AUTH_GOOGLE_KEY = SOCIAL_AUTH_GOOGLE_KEY or GOOGLE_OAUTH2_CLIENT_ID
SOCIAL_AUTH_GOOGLE_SECRET = SOCIAL_AUTH_GOOGLE_SECRET or GOOGLE_OAUTH2_CLIENT_SECRET
SOCIAL_AUTH_PIPELINE = [
'social_core.pipeline.social_auth.social_details',
'zproject.backends.social_auth_associate_user',

View File

@ -155,6 +155,8 @@ GOOGLE_OAUTH2_CLIENT_SECRET = "secret"
SOCIAL_AUTH_GITHUB_KEY = "key"
SOCIAL_AUTH_GITHUB_SECRET = "secret"
SOCIAL_AUTH_GOOGLE_KEY = "key"
SOCIAL_AUTH_GOOGLE_SECRET = "secret"
SOCIAL_AUTH_SUBDOMAIN = 'www'
# By default two factor authentication is disabled in tests.

View File

@ -423,13 +423,6 @@ i18n_urls = [
url(r'^accounts/register/social/([\w,-]+)$',
zerver.views.auth.start_social_signup,
name='signup-social'),
url(r'^accounts/login/google/$', zerver.views.auth.start_google_oauth2,
name='zerver.views.auth.start_google_oauth2'),
url(r'^accounts/login/google/send/$',
zerver.views.auth.send_oauth_request_to_google,
name='zerver.views.auth.send_oauth_request_to_google'),
url(r'^accounts/login/google/done/$', zerver.views.auth.finish_google_oauth2,
name='zerver.views.auth.finish_google_oauth2'),
url(r'^accounts/login/subdomain/([^/]+)$', zerver.views.auth.log_into_subdomain,
name='zerver.views.auth.log_into_subdomain'),
url(r'^accounts/login/local/$', zerver.views.auth.dev_direct_login,