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

@@ -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/).

View File

@@ -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

View File

@@ -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;

View File

@@ -78,7 +78,8 @@ page can be easily identified in it's respective JavaScript file -->
<form class="form-inline" action="{{ backend.signup_url }}" method="get">
<input type='hidden' name='multiuse_object_key' value='{{ multiuse_object_key }}' />
<button id="register_{{ backend.button_id_suffix }}" class="login-social-button full-width"
{% if backend.display_icon %} style="background-image:url({{ backend.display_icon }})" {% endif %}>
{% if backend.display_name == "Apple" %} style="background-image:url(https://appleid.cdn-apple.com/appleid/button/?width=328&height=50&color=white&locale={{ apple_locale }}&type=continue)"
{% elif backend.display_icon %} style="background-image:url({{ backend.display_icon }})" {% endif %}>
{{ _('Sign up with %(identity_provider)s', identity_provider=backend.display_name) }}
</button>
</form>

View File

@@ -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.

View File

@@ -114,7 +114,8 @@ page can be easily identified in it's respective JavaScript file. -->
<form class="social_login_form form-inline" action="{{ backend.login_url }}" method="get">
<input type="hidden" name="next" value="{{ next }}">
<button id="login_{{ backend.button_id_suffix }}" class="login-social-button"
{% if backend.display_icon %} style="background-image:url({{ backend.display_icon }})" {% endif %}>
{% if backend.display_name == "Apple" %} style="background-image: url(https://appleid.cdn-apple.com/appleid/button/?width=328&height=50&color=white&locale={{ apple_locale }});"
{% elif backend.display_icon %} style="background-image:url({{ backend.display_icon }})" {% endif %}>
{{ _('Log in with %(identity_provider)s', identity_provider=backend.display_name) }}
</button>
</form>

View File

@@ -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:

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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.

11
zerver/tests/fixtures/apple/jwk vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"keys": [
{"alg": "RS256",
"e": "AQAB",
"kid":"SOMEKID",
"kty": "RSA",
"n": "yKavMIaUiNqoCwVaZcLMkSmkMHBXAGLBqExdJxfINypjcXSpIItpUctsv8EWs8j9nKphgGjZTGQU1pGNb59OpMZnrgAjqZ6AGFCyJPlNhABzBX6qwsFOrQNRxHALQU20QXzqqt1hPefWoLoh5fUuFeUOK2Mp8DHMs-0EoznSWsU"
}
]
}

View File

@@ -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.

View File

@@ -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-----

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

View File

@@ -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},

View File

@@ -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"

View File

@@ -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

View File

@@ -50,6 +50,7 @@ AUTHENTICATION_BACKENDS = (
'zproject.backends.SAMLAuthBackend',
# 'zproject.backends.AzureADAuthBackend',
'zproject.backends.GitLabAuthBackend',
'zproject.backends.AppleAuthBackend',
)
EXTERNAL_URI_SCHEME = "http://"

View File

@@ -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 = "<your Services ID>"
#SOCIAL_AUTH_APPLE_BUNDLE_ID = "<your Bundle ID>"
#SOCIAL_AUTH_APPLE_TEAM = "<your Team ID>"
#SOCIAL_AUTH_APPLE_KEY = "<your Key ID>"
########
# Azure Active Directory OAuth.
#

View File

@@ -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']

View File

@@ -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"