auth: Add Sign in with Apple support.

This implementation overrides some of PSA's internal backend
functions to handle `state` value with redis as the standard
way doesn't work because of apple sending required details
in the form of POST request.

Includes a mixin test class that'll be useful for testing
Native auth flow.

Thanks to Mateusz Mandera for the idea of using redis and
other important work on this.

Documentation rewritten by tabbott.

Co-authored-by: Mateusz Mandera <mateusz.mandera@zulip.com>
This commit is contained in:
Dinesh
2020-06-09 15:34:21 +05:30
committed by Tim Abbott
parent f0f42f7a94
commit dc90d54b08
21 changed files with 541 additions and 13 deletions

View File

@@ -59,11 +59,11 @@ from zerver.signals import JUST_CREATED_THRESHOLD
from confirmation.models import Confirmation, create_confirmation_link
from zproject.backends import ZulipDummyBackend, EmailAuthBackend, \
from zproject.backends import ZulipDummyBackend, EmailAuthBackend, AppleAuthBackend, \
GoogleAuthBackend, ZulipRemoteUserBackend, ZulipLDAPAuthBackend, \
ZulipLDAPUserPopulator, DevAuthBackend, GitHubAuthBackend, GitLabAuthBackend, ZulipAuthMixin, \
dev_auth_enabled, password_auth_enabled, github_auth_enabled, gitlab_auth_enabled, \
google_auth_enabled, require_email_format_usernames, AUTH_BACKEND_NAME_MAP, \
apple_auth_enabled, google_auth_enabled, require_email_format_usernames, AUTH_BACKEND_NAME_MAP, \
ZulipLDAPConfigurationError, ZulipLDAPExceptionNoMatchingLDAPUser, ZulipLDAPExceptionOutsideDomain, \
ZulipLDAPException, query_ldap, sync_user_from_ldap, SocialAuthMixin, \
PopulateUserLDAPError, SAMLAuthBackend, saml_auth_enabled, email_belongs_to_ldap, \
@@ -1054,7 +1054,8 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
skip_registration_form: bool,
mobile_flow_otp: Optional[str]=None,
desktop_flow_otp: Optional[str]=None,
expect_confirm_registration_page: bool=False,) -> None:
expect_confirm_registration_page: bool=False,
expect_full_name_prepopulated: bool=True) -> None:
data = load_subdomain_token(result)
self.assertEqual(data['email'], email)
self.assertEqual(data['full_name'], name)
@@ -1090,8 +1091,9 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
# 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)
# Verify the name field gets correctly pre-populated:
self.assert_in_success_response([expected_final_name], result)
if expect_full_name_prepopulated:
# Verify the name field gets correctly pre-populated:
self.assert_in_success_response([expected_final_name], result)
# Click confirm registration button.
result = self.client_post(
@@ -1874,6 +1876,170 @@ class SAMLAuthBackendTest(SocialAuthBase):
"info"
)])
class AppleAuthMixin:
BACKEND_CLASS = AppleAuthBackend
CLIENT_KEY_SETTING = "SOCIAL_AUTH_APPLE_KEY"
AUTHORIZATION_URL = "https://appleid.apple.com/auth/authorize"
ACCESS_TOKEN_URL = "https://appleid.apple.com/auth/token"
AUTH_FINISH_URL = "/complete/apple/"
CONFIG_ERROR_URL = "/config-error/apple"
def generate_id_token(self, account_data_dict: Dict[str, str]) -> str:
payload = account_data_dict
# This setup is important because python-social-auth decodes `id_token`
# with `SOCIAL_AUTH_APPLE_CLIENT` as the `audience`
payload['aud'] = settings.SOCIAL_AUTH_APPLE_CLIENT
headers = {"kid": "SOMEKID"}
private_key = settings.APPLE_ID_TOKEN_GENERATION_KEY
id_token = jwt.encode(payload, private_key, algorithm='RS256',
headers=headers).decode('utf-8')
return id_token
def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]:
name_parts = name.split(' ')
first_name = name_parts[0]
last_name = ''
if (len(name_parts) > 0):
last_name = name_parts[-1]
name_dict = {'firstName': first_name, 'lastName': last_name}
return dict(email=email, name=name_dict, email_verified=True)
class AppleIdAuthBackendTest(AppleAuthMixin, SocialAuthBase):
__unittest_skip__ = False
LOGIN_URL = "/accounts/login/social/apple"
SIGNUP_URL = "/accounts/register/social/apple"
# This URL isn't used in the Apple auth flow, so we just set a
# dummy value to keep SocialAuthBase common code happy.
USER_INFO_URL = '/invalid-unused-url'
def social_auth_test_finish(self, result: HttpResponse,
account_data_dict: Dict[str, str],
expect_choose_email_screen: bool,
**headers: Any) -> HttpResponse:
parsed_url = urllib.parse.urlparse(result.url)
state = urllib.parse.parse_qs(parsed_url.query)['state']
self.client.session.flush()
result = self.client_post(self.AUTH_FINISH_URL,
dict(state=state), **headers)
return result
def register_extra_endpoints(self, requests_mock: responses.RequestsMock,
account_data_dict: Dict[str, str],
**extra_data: Any) -> None:
# This is an URL of an endpoint on Apple servers that returns
# the public keys to be used for verifying the signature
# on the JWT id_token.
requests_mock.add(
requests_mock.GET,
self.BACKEND_CLASS.JWK_URL,
status=200,
json=json.loads(settings.APPLE_JWK),
)
# This endpoint works a bit different that in standard Oauth2,
# so we need to remove the standard mock and set up our own.
#
# TODO: It might be cleaner to make this variable payload a
# generic feature of social_auth_test rather than doing the
# remove/remock approach here.
requests_mock.remove(
requests_mock.POST,
self.ACCESS_TOKEN_URL,
)
token_data_dict = {
'access_token': 'foobar',
'expires_in': time.time() + 60*5,
'id_token': self.generate_id_token(account_data_dict),
'token_type': 'bearer'
}
requests_mock.add(
requests_mock.POST,
self.ACCESS_TOKEN_URL,
match_querystring=False,
status=200,
body=json.dumps(token_data_dict),
)
def test_apple_auth_enabled(self) -> None:
with self.settings(AUTHENTICATION_BACKENDS=('zproject.backends.AppleAuthBackend',)):
self.assertTrue(apple_auth_enabled())
def test_get_apple_locale(self) -> None:
language_locale = [('ar', 'ar_SA'), ('ca', 'ca_ES'), ('cs', 'cs_CZ'),
('da', 'da_DK'), ('de', 'de_DE'), ('el', 'el_GR'),
('en', 'en_US'), ('es', 'es_ES'), ('fi', 'fi_FI'),
('fr', 'fr_FR'), ('hr', 'hr_HR'), ('hu', 'hu_HU'),
('id', 'id_ID'), ('it', 'it_IT'), ('iw', 'iw_IL'),
('ja', 'ja_JP'), ('ko', 'ko_KR'), ('ms', 'ms_MY'),
('nl', 'nl_NL'), ('no', 'no_NO'), ('pl', 'pl_PL'),
('pt', 'pt_PT'), ('ro', 'ro_RO'), ('ru', 'ru_RU'),
('sk', 'sk_SK'), ('sv', 'sv_SE'), ('th', 'th_TH'),
('tr', 'tr_TR'), ('uk', 'uk_UA'), ('vi', 'vi_VI'),
('zh', 'zh_CN')]
for language_code, locale in language_locale:
self.assertEqual(AppleAuthBackend.get_apple_locale(language_code), locale)
# return 'en_US' if invalid `language_code` is given.
self.assertEqual(AppleAuthBackend.get_apple_locale(':)'), 'en_US')
def test_auth_registration_with_no_name_sent_from_apple(self) -> None:
"""
Apple doesn't send the name in consecutive attempts if user registration
fails the first time. This tests verifies that the social pipeline is able
to handle the case of the backend not providing this information.
"""
email = "newuser@zulip.com"
subdomain = "zulip"
realm = get_realm("zulip")
account_data_dict = self.get_account_data_dict(email=email, name='')
result = self.social_auth_test(account_data_dict,
expect_choose_email_screen=True,
subdomain=subdomain, is_signup=True)
self.stage_two_of_registration(result, realm, subdomain, email, '', 'Full Name',
skip_registration_form=False,
expect_full_name_prepopulated=False)
def test_id_token_verification_failure(self) -> None:
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
with self.assertLogs(self.logger_string, level='INFO') as m:
with mock.patch("jwt.decode", side_effect=jwt.exceptions.PyJWTError):
result = self.social_auth_test(account_data_dict,
expect_choose_email_screen=True,
subdomain='zulip', is_signup=True)
self.assertEqual(result.status_code, 302)
self.assertEqual(result.url, "/login/")
self.assertEqual(m.output, [
self.logger_output("AuthFailed: Authentication failed: Token validation failed", "info"),
])
def test_validate_state(self) -> None:
with self.assertLogs(self.logger_string, level='INFO') as m:
# (1) check if auth fails if no state value is sent.
result = self.client_post('/complete/apple/')
self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url)
# (2) Check if auth fails when a state sent has no valid data stored in redis.
fake_state = "fa42e4ccdb630f0070c1daab70ad198d8786d4b639cd7a1b4db4d5a13c623060"
result = self.client_post('/complete/apple/', {'state': fake_state})
self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url)
self.assertEqual(m.output, [
self.logger_output("Sign in with Apple failed: missing state parameter.", "info"), # (1)
self.logger_output("Missing needed parameter state", "warning"),
self.logger_output("Sign in with Apple failed: bad state token.", "info"), # (2)
self.logger_output("Wrong state parameter given.", "warning"),
])
class GitHubAuthBackendTest(SocialAuthBase):
__unittest_skip__ = False