From dc90d54b085cc33f29c7de5dcea576987e4d92cd Mon Sep 17 00:00:00 2001 From: Dinesh Date: Tue, 9 Jun 2020 15:34:21 +0530 Subject: [PATCH] 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 --- docs/development/authentication.md | 20 ++ docs/production/authentication-methods.md | 42 +++++ static/styles/portico/portico-signin.scss | 23 +++ templates/zerver/accounts_home.html | 3 +- templates/zerver/apple-error.md | 17 ++ templates/zerver/login.html | 3 +- zerver/context_processors.py | 2 + zerver/migrations/0283_apple_auth.py | 19 ++ zerver/models.py | 2 +- zerver/openapi/zulip.yaml | 4 + zerver/tests/fixtures/apple/jwk | 11 ++ zerver/tests/fixtures/apple/private_key.pem | 14 ++ .../fixtures/apple/token_gen_private_key | 16 ++ zerver/tests/test_auth_backends.py | 176 +++++++++++++++++- zerver/views/auth.py | 3 +- zproject/backends.py | 159 +++++++++++++++- zproject/default_settings.py | 8 + zproject/dev_settings.py | 1 + zproject/prod_settings_template.py | 13 ++ zproject/settings.py | 9 + zproject/test_settings.py | 9 + 21 files changed, 541 insertions(+), 13 deletions(-) create mode 100644 templates/zerver/apple-error.md create mode 100644 zerver/migrations/0283_apple_auth.py create mode 100644 zerver/tests/fixtures/apple/jwk create mode 100644 zerver/tests/fixtures/apple/private_key.pem create mode 100644 zerver/tests/fixtures/apple/token_gen_private_key diff --git a/docs/development/authentication.md b/docs/development/authentication.md index 21447181c0..5f6294ed8c 100644 --- a/docs/development/authentication.md +++ b/docs/development/authentication.md @@ -83,6 +83,26 @@ details worth understanding: ID as `social_auth_gitlab_key` and the Secret as `social_auth_gitlab_secret`. +### Apple + +* Visit https://developer.apple.com/account/resources/, + Enable App ID and Create a Services ID with the instructions in + https://help.apple.com/developer-account/?lang=en#/dev1c0e25352 . + When prompted for a "Return URL", enter + `http://zulipdev.com:9991/complete/apple/` . + +* [Create a Sign in with Apple private key](https://help.apple.com/developer-account/?lang=en#/dev77c875b7e) + +* In `dev-secrets.conf`, set + * `social_auth_apple_services_id` to your + "Services ID" (eg. com.application.your). + * `social_auth_apple_bundle_id` to "Bundle ID". This is + only required if you are testing Apple auth on iOS. + * `social_auth_apple_key` to your "Key ID". + * `social_auth_apple_team` to your "Team ID". +* Put the private key file you got from apple at the path + `zproject/dev_apple.key`. + ### SAML * Sign up for a [developer Okta account](https://developer.okta.com/). diff --git a/docs/production/authentication-methods.md b/docs/production/authentication-methods.md index 834b8bc7e1..596288d128 100644 --- a/docs/production/authentication-methods.md +++ b/docs/production/authentication-methods.md @@ -547,6 +547,48 @@ to debug. sees the cookie, treats them as logged in, and proceeds to serve them the main app page normally. +## Sign in with Apple + +Zulip supports using the web flow for Sign in with Apple on +self-hosted servers. To do so, you'll need to do the following: + +1. Visit [the Apple Developer site][apple-developer] and [Create a +Services ID.][apple-create-services-id]. When prompted for a "Return +URL", enter `https://zulip.example.com/complete/apple/` (using the +domain for your server). + +1. Create a [Sign in with Apple private key][apple-create-private-key]. + +1. Store the resulting private key at + `/etc/zulip/apple/zulip-private-key.key`. Be sure to set + permissions correctly: + + ``` + chown -R zulip:zulip /etc/zulip/apple/ + chmod 640 /etc/zulip/apple/zulip-private-key.key + ``` + +1. Configure the "Apple authentication" section of + `/etc/zulip/settings.py`. Use the "Services ID" as + `SOCIAL_AUTH_APPLE_SERVICES_ID`, "Bundle ID" as + `SOCIAL_AUTH_APPLE_BUNDLE_ID`, "Key ID" as `SOCIAL_AUTH_APPLE_KEY` + and "Team ID" as `SOCIAL_AUTH_APPLE_TEAM` in `settings.py` file. + +1. In the Apple developer site, configure the domains your Zulip +server uses when sending outgoing email notifications (this is +required for your Zulip server to deliver emails to the many Apple +users who use their privacy-protecting forwarding service). See the +"Email Relay Service" subsection of [this page][apple-get-started] for +more information. See Zulip's [outgoing email +documentation][outgoing-email] for details on what From addresses +Zulip uses when sending outgoing emails. + +[apple-create-services-id]: https://help.apple.com/developer-account/?lang=en#/dev1c0e25352 +[apple-developer]: https://developer.apple.com/account/resources/ +[apple-create-private-key]: https://help.apple.com/developer-account/?lang=en#/dev77c875b7e +[apple-get-started]: https://developer.apple.com/sign-in-with-apple/get-started/ +[outgoing-email]: ../production/email.md + ## Adding more authentication backends Adding an integration with any of the more than 100 authentication diff --git a/static/styles/portico/portico-signin.scss b/static/styles/portico/portico-signin.scss index 569e2c0d1f..c456b1a310 100644 --- a/static/styles/portico/portico-signin.scss +++ b/static/styles/portico/portico-signin.scss @@ -699,6 +699,29 @@ button.login-social-button { } } +/* +Apple is very particular about the appearance of its authentication +buttons, which means we cannot use our generic label+icon system for +their authentication backend while complying with their policies. + +So here we use the buttons provided by Apple, which include the sign +in text (Sign in with Apple/Continue with Apple). To make these +consistent with other buttons, we request buttons of size 328x50 and +the following styling sets the appropriate width and height to make it +fit with other other social authentication buttons. We also set the +font size to zero to hide the text our own code in the +login/registration pages would generate, to avoid extra conditionals +in the HTML templates. +*/ +button#login_auth_button_apple, +button#register_auth_button_apple { + width: 328px; + height: 50px; + background-size: auto 100%; + background-position: 0px 100%; + font-size: 0px; +} + #find-account-section { text-decoration: none; font-weight: 600; diff --git a/templates/zerver/accounts_home.html b/templates/zerver/accounts_home.html index ff26a89183..7a1a76606b 100644 --- a/templates/zerver/accounts_home.html +++ b/templates/zerver/accounts_home.html @@ -78,7 +78,8 @@ page can be easily identified in it's respective JavaScript file -->
diff --git a/templates/zerver/apple-error.md b/templates/zerver/apple-error.md new file mode 100644 index 0000000000..14fde1bb32 --- /dev/null +++ b/templates/zerver/apple-error.md @@ -0,0 +1,17 @@ +You are using the **Apple auth backend**, but it is not +properly configured. Please check the following: + +* You have registered `{{ root_domain_uri }}/complete/apple/` + as the callback URL for your Services ID in Apple's developer console. You can + enable "Sign In with Apple" for an app at + [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/). + +* You have set `SOCIAL_AUTH_APPLE_SERVICES_ID`, + `SOCIAL_AUTH_APPLE_BUNDLE_ID`, `SOCIAL_AUTH_APPLE_TEAM`, + `SOCIAL_AUTH_APPLE_KEY` and `SOCIAL_AUTH_APPLE_TEAM` in `{{ + settings_path }}` and stored the private key provided by Apple at + `/etc/zulip/apple/zulip-private-key.key` on the Zulip server, with + proper permissions set. + +* Navigate back to the login page and attempt the "Sign in with Apple" + flow again. diff --git a/templates/zerver/login.html b/templates/zerver/login.html index 882f730192..8410d45d91 100644 --- a/templates/zerver/login.html +++ b/templates/zerver/login.html @@ -114,7 +114,8 @@ page can be easily identified in it's respective JavaScript file. --> diff --git a/zerver/context_processors.py b/zerver/context_processors.py index 1be1f62116..d6ae401bb8 100644 --- a/zerver/context_processors.py +++ b/zerver/context_processors.py @@ -12,6 +12,7 @@ from zproject.backends import ( require_email_format_usernames, auth_enabled_helper, AUTH_BACKEND_NAME_MAP, + AppleAuthBackend, ) from zerver.decorator import get_client_name from zerver.lib.send_email import FromAddress @@ -163,6 +164,7 @@ def login_context(request: HttpRequest) -> Dict[str, Any]: 'password_auth_enabled': password_auth_enabled(realm), 'any_social_backend_enabled': any_social_backend_enabled(realm), 'two_factor_authentication_enabled': settings.TWO_FACTOR_AUTHENTICATION_ENABLED, + 'apple_locale': AppleAuthBackend.get_apple_locale(request.LANGUAGE_CODE), } if realm is not None and realm.description: diff --git a/zerver/migrations/0283_apple_auth.py b/zerver/migrations/0283_apple_auth.py new file mode 100644 index 0000000000..65b04082fe --- /dev/null +++ b/zerver/migrations/0283_apple_auth.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.13 on 2020-06-09 09:37 + +import bitfield.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('zerver', '0282_remove_zoom_video_chat'), + ] + + operations = [ + migrations.AlterField( + model_name='realm', + name='authentication_methods', + field=bitfield.models.BitField(['Google', 'Email', 'GitHub', 'LDAP', 'Dev', 'RemoteUser', 'AzureAD', 'SAML', 'GitLab', 'Apple'], default=2147483647), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index c1451f43f9..6a585b1575 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -132,7 +132,7 @@ class Realm(models.Model): INVITES_STANDARD_REALM_DAILY_MAX = 3000 MESSAGE_VISIBILITY_LIMITED = 10000 AUTHENTICATION_FLAGS = ['Google', 'Email', 'GitHub', 'LDAP', 'Dev', - 'RemoteUser', 'AzureAD', 'SAML', 'GitLab'] + 'RemoteUser', 'AzureAD', 'SAML', 'GitLab', 'Apple'] SUBDOMAIN_FOR_ROOT_DOMAIN = '' # User-visible display name and description used on e.g. the organization homepage diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index f3102dbc5f..a396591f36 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -3177,6 +3177,10 @@ paths: description: | Whether the user can authenticate using their gitlab account. type: boolean + apple: + description: | + Whether the user can authenticate using their apple account. + type: boolean google: description: | Whether the user can authenticate using their google account. diff --git a/zerver/tests/fixtures/apple/jwk b/zerver/tests/fixtures/apple/jwk new file mode 100644 index 0000000000..1f8919d4c5 --- /dev/null +++ b/zerver/tests/fixtures/apple/jwk @@ -0,0 +1,11 @@ +{ + "keys": [ + {"alg": "RS256", + "e": "AQAB", + "kid":"SOMEKID", + "kty": "RSA", + "n": "yKavMIaUiNqoCwVaZcLMkSmkMHBXAGLBqExdJxfINypjcXSpIItpUctsv8EWs8j9nKphgGjZTGQU1pGNb59OpMZnrgAjqZ6AGFCyJPlNhABzBX6qwsFOrQNRxHALQU20QXzqqt1hPefWoLoh5fUuFeUOK2Mp8DHMs-0EoznSWsU" + } + ] +} + diff --git a/zerver/tests/fixtures/apple/private_key.pem b/zerver/tests/fixtures/apple/private_key.pem new file mode 100644 index 0000000000..6dec1eaf61 --- /dev/null +++ b/zerver/tests/fixtures/apple/private_key.pem @@ -0,0 +1,14 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDID8g9mX4QhBstI0asSOwAbetxN13PaA5YoVLsQgp8SoAoGCCqGSM49 +AwEHoUQDQgAEQDCysAPobKehaA/R5mKepHOnr7y/nXifgsDXkYK9qEj6SM0cZ2oR +f3pQlwPrd+3i4DB9RSu1Ok8cAkACpJfu+g== +-----END EC PRIVATE KEY----- + +This key is generated using + +$ openssl ecparam -genkey -name prime256v1 -noout -out private_key.pem + +and isn't used anywhere. so, it's safe to have this checked into version control. +It is generated to avoid internal functions, for e.g. `generate_client_secret`, +of python-social-auth from failing because of a private key missing. + diff --git a/zerver/tests/fixtures/apple/token_gen_private_key b/zerver/tests/fixtures/apple/token_gen_private_key new file mode 100644 index 0000000000..e1f3871136 --- /dev/null +++ b/zerver/tests/fixtures/apple/token_gen_private_key @@ -0,0 +1,16 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDIpq8whpSI2qgLBVplwsyRKaQwcFcAYsGoTF0nF8g3KmNxdKkg +i2lRy2y/wRazyP2cqmGAaNlMZBTWkY1vn06kxmeuACOpnoAYULIk+U2EAHMFfqrC +wU6tA1HEcAtBTbRBfOqq3WE959aguiHl9S4V5Q4rYynwMcyz7QSjOdJaxQIDAQAB +AoGASSh1IbU//PH0aShHgGjZG2haZArhvdNEFq/ZGwLRzkNXRKuraqFKAjewa+3j +8CMtTOzWZfJUoESxUFZ7giJMkqQMb7HLdPr8z/PKQ5fVCvuBv891hgyO5da6/tAr +GAJ6xR5ZfWlY2206/Jfi0jBanBZjz+wbTa4jQma7H9zuXoECQQDthcHSB81hjVCy +DGL/NKQGJ1YpMdJuvx31chrsi7GqCjaFtU4gztzeVcWK9YJbJ1p33i0t7XbQO9si +cQb+jwxhAkEA2EKi7d5rqooTtiaSvWtp+88i+TxpnA5kYtrxP/CQykyzOHKHRWb4 +YCkldmy5GsMoOPXFtKOjGEvrvmEDvAFI5QJAAtLDMgbrtwwh+GvTRWtPw8715Dl2 +YeCdr4wyq7shWn8SlNZJ3nP3BiGI3pT6frDiD2ixqskWz3TWrvse9SmoIQJAVNko +Na2rjnioLTJLJnhrV7m4XhM+2FSpPEPsnYqUNFsNghslSayRzKC4KxOTOJXTRS3g +iPQe/FxlPQexQGU8pQJAaXAbZ5Eq5ACherQ5Wv4jVuG1T4W2SFbiVpAn/czEw0Ox +q89vbIHyHBhvBuNFAd/W22weHZrb0qfX1dCE+osGiw== +-----END RSA PRIVATE KEY----- + diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index b7e0679656..a18b3c8581 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -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 diff --git a/zerver/views/auth.py b/zerver/views/auth.py index f78efbebf2..e0da3d2964 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -501,7 +501,7 @@ def start_social_login(request: HttpRequest, backend: str, extra_arg: Optional[s extra_url_params = {'idp': extra_arg} # TODO: Add AzureAD also. - if backend in ["github", "google", "gitlab"]: + if backend in ["github", "google", "gitlab", "apple"]: key_setting = "SOCIAL_AUTH_" + backend.upper() + "_KEY" secret_setting = "SOCIAL_AUTH_" + backend.upper() + "_SECRET" if not (getattr(settings, key_setting) and getattr(settings, secret_setting)): @@ -986,6 +986,7 @@ def saml_sp_metadata(request: HttpRequest, **kwargs: Any) -> HttpResponse: # no def config_error_view(request: HttpRequest, error_category_name: str) -> HttpResponse: contexts = { + 'apple': {'social_backend_name': 'apple', 'has_markdown_file': True}, 'google': {'social_backend_name': 'google', 'has_markdown_file': True}, 'github': {'social_backend_name': 'github', 'has_markdown_file': True}, 'gitlab': {'social_backend_name': 'gitlab', 'has_markdown_file': True}, diff --git a/zproject/backends.py b/zproject/backends.py index aec572a368..ec410d41b7 100644 --- a/zproject/backends.py +++ b/zproject/backends.py @@ -15,6 +15,7 @@ import binascii import copy import logging +import jwt import magic import ujson from abc import ABC, abstractmethod @@ -37,6 +38,8 @@ from django.http import HttpResponse, HttpResponseRedirect, HttpRequest from django.shortcuts import render from django.urls import reverse from django.utils.translation import ugettext as _ +from jwt.algorithms import RSAAlgorithm +from jwt.exceptions import PyJWTError from lxml.etree import XMLSyntaxError from requests import HTTPError from onelogin.saml2.errors import OneLogin_Saml2_Error @@ -48,9 +51,11 @@ from social_core.backends.azuread import AzureADOAuth2 from social_core.backends.gitlab import GitLabOAuth2 from social_core.backends.base import BaseAuth from social_core.backends.google import GoogleOAuth2 +from social_core.backends.apple import AppleIdAuth from social_core.backends.saml import SAMLAuth from social_core.pipeline.partial import partial -from social_core.exceptions import AuthFailed, SocialAuthBaseException +from social_core.exceptions import AuthFailed, SocialAuthBaseException, \ + AuthMissingParameter, AuthStateForbidden from zerver.decorator import client_is_exempt_from_rate_limiting from zerver.lib.actions import do_create_user, do_reactivate_user, do_deactivate_user, \ @@ -124,6 +129,9 @@ def github_auth_enabled(realm: Optional[Realm]=None) -> bool: def gitlab_auth_enabled(realm: Optional[Realm]=None) -> bool: return auth_enabled_helper(['GitLab'], realm) +def apple_auth_enabled(realm: Optional[Realm]=None) -> bool: + return auth_enabled_helper(['Apple'], realm) + def saml_auth_enabled(realm: Optional[Realm]=None) -> bool: return auth_enabled_helper(['SAML'], realm) @@ -1168,9 +1176,17 @@ def social_associate_user_helper(backend: BaseAuth, return_data: Dict[str, Any], last_name = kwargs['details'].get('last_name', '') if full_name is None: if not first_name and not last_name: - # If we add support for any of the social auth backends that - # don't provide this feature, we'll need to add code here. - raise AssertionError("Social auth backend doesn't provide name") + # We need custom code here for any social auth backends + # that don't provide name details feature. + if (backend.name == 'apple'): + # Apple authentication provides the user's name only + # the very first time a user tries to login. So if + # the user aborts login or otherwise is doing this the + # second time, we won't have any name data. We handle + # this by setting full_name to be the empty string. + full_name = "" + else: + raise AssertionError("Social auth backend doesn't provide name") if full_name: return_data["full_name"] = full_name @@ -1500,6 +1516,141 @@ class GoogleAuthBackend(SocialAuthMixin, GoogleOAuth2): verified_emails.append(details["email"]) return verified_emails +@external_auth_method +class AppleAuthBackend(SocialAuthMixin, AppleIdAuth): + """ + Authentication backend for "Sign in with Apple". This supports two flows: + 1. The web flow, usable in a browser, like our other social auth methods. + It is a slightly modified Oauth2 authorization flow, where the response + returning the access_token also contains a JWT id_token containing the user's + identity, signed with Apple's private keys. + https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse + 2. The native flow, intended for users on an Apple device. In the native flow, + the device handles authentication of the user with Apple's servers and ends up + with the JWT id_token (like in the web flow). The client-side details aren't + relevant to us; the app will simply provide the id_token to the server, + which can verify its signature and process it like in the web flow. + + So far we only implement the web flow. + """ + sort_order = 10 + name = "apple" + auth_backend_name = "Apple" + + # Apple only sends `name` in its response the first time a user + # tries to sign up, so we won't have it in consecutive attempts. + # But if Apple does send us the user's name, it will be validated, + # so it's appropriate to set full_name_validated here. + full_name_validated = True + REDIS_EXPIRATION_SECONDS = 60*10 + + @staticmethod + def get_apple_locale(django_language_code: str) -> str: + ''' + Get the suitable apple supported locale with language code + for the Sign in / Continue with Apple buttons it provides. + ''' + # The following is a list of locale values supported by Apple to send + # as params to URL which renders "Sign in with Apple" and "Continue with Apple" + # buttons. Gathered from + # https://developer.apple.com/documentation/signinwithapplejs/incorporating_sign_in_with_apple_into_other_platforms . + supported_locales = ['ar_SA', 'ca_ES', 'cs_CZ', 'da_DK', 'de_DE', + 'el_GR', 'en_US', 'es_ES', 'fi_FI', 'fr_FR', + 'hr_HR', 'hu_HU', 'id_ID', 'it_IT', 'iw_IL', + 'ja_JP', 'ko_KR', 'ms_MY', 'nl_NL', 'no_NO', + 'pl_PL', 'pt_PT', 'ro_RO', 'ru_RU', 'sk_SK', + 'sv_SE', 'th_TH', 'tr_TR', 'uk_UA', 'vi_VI', + 'zh_CN'] + for locale in supported_locales: + if django_language_code in locale: + return locale + return 'en_US' + + # This method replaces a method from python-social-auth; it is adapted to store + # the state_token data in redis. + def get_or_create_state(self) -> str: + '''Creates the Oauth2 state parameter in first step of the flow, + before redirecting the user to the IdP (aka Apple). + + Apple will send the user back to us with a POST + request. Normally, we rely on being able to store certain + parameters in the user's session and use them after the + redirect. But because we've configured our session cookies to + use the Django default of in SameSite Lax mode, the browser + won't send the session cookies to our server in delivering the + POST request coming from Apple. + + To work around this, we replace python-social-auth's default + session-based storage with storing the parameters in redis + under a random token derived from the state. That will allow + us to validate the state and retrieve the params after the + redirect - by querying redis for the key derived from the + state sent in the POST redirect. + ''' + request_data = self.strategy.request_data().dict() + data_to_store = { + key: request_data[key] for key in self.standard_relay_params + if key in request_data + } + + # Generate a random string of 32 alphanumeric characters. + state = self.state_token() + put_dict_in_redis(redis_client, 'apple_auth_{token}', + data_to_store, self.REDIS_EXPIRATION_SECONDS, + token=state) + return state + + # This method replaces a method from python-social-auth; it is + # adapted to retrieve the data stored in redis, transferring to + # the session so that it can be accessed by common + # python-social-auth code. + def validate_state(self) -> Optional[str]: + request_state = self.get_request_state() + + if not request_state: + self.logger.info("Sign in with Apple failed: missing state parameter.") + raise AuthMissingParameter(self, 'state') + + formatted_request_state = "apple_auth_" + request_state + redis_data = get_dict_from_redis(redis_client, "apple_auth_{token}", + formatted_request_state) + if redis_data is None: + self.logger.info("Sign in with Apple failed: bad state token.") + raise AuthStateForbidden(self) + + for param, value in redis_data.items(): + if param in self.standard_relay_params: + self.strategy.session_set(param, value) + return request_state + + def decode_id_token(self, id_token: str) -> Dict[str, Any]: + '''Decode and validate JWT token from Apple and return payload including user data. + + We override this method from upstream python-social-auth, for two reasons: + * To improve error handling (correctly raising AuthFailed; see comment below). + * To facilitate this to support the native flow, where + the Apple-generated id_token is signed for "Bundle ID" + audience instead of "Services ID". + + It is likely that small upstream tweaks could make it possible + to make this function a thin wrapper around the upstream + method; we may want to submit a PR to achieve that. + ''' + audience = self.setting("SERVICES_ID") + + try: + kid = jwt.get_unverified_header(id_token).get('kid') + public_key = RSAAlgorithm.from_jwk(self.get_apple_jwk(kid)) + decoded = jwt.decode(id_token, key=public_key, + audience=audience, algorithm="RS256") + except PyJWTError: + # Changed from upstream python-social-auth to raise + # AuthFailed, which is more appropriate than upstream's + # AuthCanceled, for this case. + raise AuthFailed(self, "Token validation failed") + + return decoded + @external_auth_method class SAMLAuthBackend(SocialAuthMixin, SAMLAuth): auth_backend_name = "SAML" diff --git a/zproject/default_settings.py b/zproject/default_settings.py index ede89c14db..f897a32cc1 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -70,6 +70,14 @@ SAML_REQUIRE_LIMIT_TO_SUBDOMAINS = False # Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production. GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None +# Apple: +SOCIAL_AUTH_APPLE_SERVICES_ID = get_secret('social_auth_apple_services_id', development_only=True) +SOCIAL_AUTH_APPLE_BUNDLE_ID = get_secret('social_auth_apple_bundle_id', development_only=True) +SOCIAL_AUTH_APPLE_KEY = get_secret('social_auth_apple_key', development_only=True) +SOCIAL_AUTH_APPLE_TEAM = get_secret('social_auth_apple_team', development_only=True) +SOCIAL_AUTH_APPLE_SCOPE = ['name', 'email'] +SOCIAL_AUTH_APPLE_EMAIL_AS_USERNAME = True + # Other auth SSO_APPEND_DOMAIN: Optional[str] = None diff --git a/zproject/dev_settings.py b/zproject/dev_settings.py index 6b4b7ca387..18566c9b5a 100644 --- a/zproject/dev_settings.py +++ b/zproject/dev_settings.py @@ -50,6 +50,7 @@ AUTHENTICATION_BACKENDS = ( 'zproject.backends.SAMLAuthBackend', # 'zproject.backends.AzureADAuthBackend', 'zproject.backends.GitLabAuthBackend', + 'zproject.backends.AppleAuthBackend', ) EXTERNAL_URI_SCHEME = "http://" diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index e84b04e342..e483c7656c 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -121,6 +121,7 @@ AUTHENTICATION_BACKENDS = ( # 'zproject.backends.GitHubAuthBackend', # GitHub auth, setup below # 'zproject.backends.GitLabAuthBackend', # GitLab auth, setup below # 'zproject.backends.AzureADAuthBackend', # Microsoft Azure Active Directory auth, setup below + # 'zproject.backends.AppleAuthBackend', # Apple auth, setup below # 'zproject.backends.SAMLAuthBackend', # SAML, setup below # 'zproject.backends.ZulipLDAPAuthBackend', # LDAP, setup below # 'zproject.backends.ZulipRemoteUserBackend', # Local SSO, setup docs on readthedocs @@ -279,6 +280,18 @@ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { "emailAddress": ZULIP_ADMINISTRATOR, } +######## +# Apple authentication ("Sign in with Apple"). +# +# Configure the below settings by following the instructions here: +# +# https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#sign-in-with-apple +# +#SOCIAL_AUTH_APPLE_SERVICES_ID = "" +#SOCIAL_AUTH_APPLE_BUNDLE_ID = "" +#SOCIAL_AUTH_APPLE_TEAM = "" +#SOCIAL_AUTH_APPLE_KEY = "" + ######## # Azure Active Directory OAuth. # diff --git a/zproject/settings.py b/zproject/settings.py index d571eb9004..b21fb524fb 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -1019,6 +1019,15 @@ SOCIAL_AUTH_FIELDS_STORED_IN_SESSION = ['subdomain', 'is_signup', 'mobile_flow_o 'multiuse_object_key'] SOCIAL_AUTH_LOGIN_ERROR_URL = '/login/' +# CLIENT is required by PSA's internal implementation. We name it +# SERVICES_ID to make things more readable in the configuration +# and our own custom backend code. +SOCIAL_AUTH_APPLE_CLIENT = SOCIAL_AUTH_APPLE_SERVICES_ID +if PRODUCTION: + SOCIAL_AUTH_APPLE_SECRET = get_from_file_if_exists("/etc/zulip/apple/zulip-private-key.key") +else: + SOCIAL_AUTH_APPLE_SECRET = get_from_file_if_exists("zproject/dev_apple.key") + SOCIAL_AUTH_GITHUB_SECRET = get_secret('social_auth_github_secret') SOCIAL_AUTH_GITLAB_SECRET = get_secret('social_auth_gitlab_secret') SOCIAL_AUTH_GITHUB_SCOPE = ['user:email'] diff --git a/zproject/test_settings.py b/zproject/test_settings.py index 3989d7c189..0168b79064 100644 --- a/zproject/test_settings.py +++ b/zproject/test_settings.py @@ -173,6 +173,15 @@ SOCIAL_AUTH_GITLAB_SECRET = "secret" SOCIAL_AUTH_GOOGLE_KEY = "key" SOCIAL_AUTH_GOOGLE_SECRET = "secret" SOCIAL_AUTH_SUBDOMAIN = 'auth' +SOCIAL_AUTH_APPLE_SERVICES_ID = 'com.zulip.chat' +SOCIAL_AUTH_APPLE_BUNDLE_ID = 'com.zulip.bundle.id' +SOCIAL_AUTH_APPLE_CLIENT = 'com.zulip.chat' +SOCIAL_AUTH_APPLE_KEY = 'KEYISKEY' +SOCIAL_AUTH_APPLE_TEAM = 'TEAMSTRING' +SOCIAL_AUTH_APPLE_SECRET = get_from_file_if_exists("zerver/tests/fixtures/apple/private_key.pem") + +APPLE_JWK = get_from_file_if_exists("zerver/tests/fixtures/apple/jwk") +APPLE_ID_TOKEN_GENERATION_KEY = get_from_file_if_exists("zerver/tests/fixtures/apple/token_gen_private_key") VIDEO_ZOOM_CLIENT_ID = "client_id" VIDEO_ZOOM_CLIENT_SECRET = "client_secret"