diff --git a/api_docs/changelog.md b/api_docs/changelog.md index fca6945518..4bcf7e91c2 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,14 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 7.0 +**Feature level 160** + +* [`POST /api/v1/jwt/fetch_api_key`]: New API endpoint to fetch API + keys using JSON Web Token (JWT) authentication. +* [`accounts/login/jwt/`]: Adjusted format of requests to this + previously undocumented, optional endpoint for + JWT authentication log in support. + **Feature level 159** * [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events), diff --git a/docs/overview/changelog.md b/docs/overview/changelog.md index aa59a79f77..105b303c59 100644 --- a/docs/overview/changelog.md +++ b/docs/overview/changelog.md @@ -21,6 +21,10 @@ log][commit-log] for an up-to-date list of all changes. [documentation](../production/upload-backends.md#s3-local-caching). - Removed the `application_server.no_serve_uploads` setting in `/etc/zulip/zulip.conf`, as all uploads requests go through Zulip now. +- Installations using the previously undocumented [JWT authentication + feature](../production/authentication-methods.md#jwt) will need + to make minor adjustments in the format of JWT requests; see the + documentation for details on the new format. ## Zulip 6.x series diff --git a/docs/production/authentication-methods.md b/docs/production/authentication-methods.md index dcbe82df35..35abc4196f 100644 --- a/docs/production/authentication-methods.md +++ b/docs/production/authentication-methods.md @@ -864,6 +864,29 @@ assumes the name is correct, and new users will not be presented with a registration form unless they need to accept Terms of Service for the server (i.e. `TERMS_OF_SERVICE_VERSION` is set). +## JWT + +Zulip supports using JSON Web Tokens (JWT) authentication in two ways: + +1. Obtaining a logged in session by making a POST request to + `/accounts/login/jwt/`. This allows a separate application to + integrate with Zulip via having a button that directly takes the user + to Zulip and logs them in. +2. Fetching a user's API key by making a POST request to + `/api/v1/jwt/fetch_api_key`. This allows a separate application to + integrate with Zulip by [making API + requests](https://zulip.com/api/) on behalf of any user in a Zulip + organization. + +In both cases, the request should be made by sending an HTTP `POST` +request with the JWT in the `token` parameter, with the JWT payload +having the structure `{"email": ""}`. + +In order to use JWT authentication with Zulip, one must first +configure the JWT secret and algorithm via `JWT_AUTH_KEYS` in +`/etc/zulip/settings.py`; see the inline comment documentation in that +file for details. + ## Adding more authentication backends Adding an integration with any of the more than 100 authentication diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 65bd0390c3..33fbdd0ff8 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -87,7 +87,7 @@ from zerver.lib.test_helpers import ( ) from zerver.lib.types import Validator from zerver.lib.upload.base import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar -from zerver.lib.users import get_all_api_keys +from zerver.lib.users import get_all_api_keys, get_api_key, get_raw_user_data from zerver.lib.utils import assert_is_not_none from zerver.lib.validator import ( check_bool, @@ -6789,5 +6789,164 @@ class LDAPBackendTest(ZulipTestCase): ) +class JWTFetchAPIKeyTest(ZulipTestCase): + def setUp(self) -> None: + super().setUp() + self.email = self.example_email("hamlet") + self.realm = get_realm("zulip") + self.user_profile = get_user_by_delivery_email(self.email, self.realm) + self.api_key = get_api_key(self.user_profile) + self.raw_user_data = get_raw_user_data( + self.user_profile.realm, + self.user_profile, + target_user=self.user_profile, + client_gravatar=False, + user_avatar_url_field_optional=False, + include_custom_profile_fields=False, + )[self.user_profile.id] + + def test_success(self) -> None: + payload = {"email": self.email} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_success(result) + data = result.json() + self.assertEqual(data["api_key"], self.api_key) + self.assertEqual(data["email"], self.email) + self.assertNotIn("user", data) + + def test_success_with_profile_false(self) -> None: + payload = {"email": self.email} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token, "include_profile": "false"} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_success(result) + data = result.json() + self.assertEqual(data["api_key"], self.api_key) + self.assertEqual(data["email"], self.email) + self.assertNotIn("user", data) + + def test_success_with_profile_true(self) -> None: + payload = {"email": self.email} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token, "include_profile": "true"} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_success(result) + data = result.json() + self.assertEqual(data["api_key"], self.api_key) + self.assertEqual(data["email"], self.email) + self.assertIn("user", data) + self.assertEqual(data["user"], self.raw_user_data) + + def test_invalid_subdomain_from_request_failure(self) -> None: + with mock.patch("zerver.views.auth.get_realm_from_request", return_value=None): + result = self.client_post("/api/v1/jwt/fetch_api_key") + self.assert_json_error_contains(result, "Invalid subdomain", 404) + + def test_jwt_key_not_found_failure(self) -> None: + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + with mock.patch( + "zerver.views.auth.get_realm_from_request", return_value=get_realm("zephyr") + ): + result = self.client_post("/api/v1/jwt/fetch_api_key") + self.assert_json_error_contains( + result, "JWT authentication is not enabled for this organization", 400 + ) + + def test_missing_jwt_payload_failure(self) -> None: + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + result = self.client_post("/api/v1/jwt/fetch_api_key") + self.assert_json_error_contains(result, "No JSON web token passed in request", 400) + + def test_invalid_jwt_signature_failure(self) -> None: + payload = {"email": self.email} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, "wrong_key", algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Bad JSON web token", 400) + + def test_invalid_jwt_format_failure(self) -> None: + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + req_data = {"token": "bad_jwt_token"} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Bad JSON web token", 400) + + def test_missing_email_in_jwt_failure(self) -> None: + payload = {"bar": "baz"} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains( + result, "No email specified in JSON web token claims", 400 + ) + + def test_empty_email_in_jwt_failure(self) -> None: + payload = {"email": ""} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Your username or password is incorrect", 401) + + def test_user_not_found_failure(self) -> None: + payload = {"email": self.nonreg_email("alice")} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Your username or password is incorrect", 401) + + def test_inactive_user_failure(self) -> None: + payload = {"email": self.email} + do_deactivate_user(self.user_profile, acting_user=None) + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Account is deactivated", 401) + + def test_inactive_realm_failure(self) -> None: + payload = {"email": self.email} + do_deactivate_realm(self.user_profile.realm, acting_user=None) + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "This organization has been deactivated", 401) + + def test_invalid_realm_for_user_failure(self) -> None: + payload = {"email": self.mit_email("starnine")} + with self.settings(JWT_AUTH_KEYS={"zulip": {"key": "key1", "algorithms": ["HS256"]}}): + key = settings.JWT_AUTH_KEYS["zulip"]["key"] + [algorithm] = settings.JWT_AUTH_KEYS["zulip"]["algorithms"] + web_token = jwt.encode(payload, key, algorithm) + req_data = {"token": web_token} + result = self.client_post("/api/v1/jwt/fetch_api_key", req_data) + self.assert_json_error_contains(result, "Invalid subdomain", 404) + + # Don't load the base class as a test: https://bugs.python.org/issue17519. del SocialAuthBase diff --git a/zerver/views/auth.py b/zerver/views/auth.py index 19d399599d..92bdd5bd3d 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -67,9 +67,9 @@ from zerver.lib.sessions import set_expirable_session_var from zerver.lib.subdomains import get_subdomain, is_subdomain_root_or_alias from zerver.lib.url_encoding import append_url_query_string from zerver.lib.user_agent import parse_user_agent -from zerver.lib.users import get_api_key, is_2fa_verified +from zerver.lib.users import get_api_key, get_raw_user_data, is_2fa_verified from zerver.lib.utils import has_api_key_format -from zerver.lib.validator import validate_login_email +from zerver.lib.validator import check_bool, validate_login_email from zerver.models import ( MultiuseInvite, PreregistrationUser, @@ -921,10 +921,51 @@ def get_api_key_fetch_authenticate_failure(return_data: Dict[str, bool]) -> Json return PasswordAuthDisabledError() if return_data.get("password_reset_needed"): return PasswordResetRequiredError() + if return_data.get("invalid_subdomain"): + raise InvalidSubdomainError() return AuthenticationFailedError() +@csrf_exempt +@require_post +@has_request_variables +def jwt_fetch_api_key( + request: HttpRequest, + include_profile: bool = REQ(default=False, json_validator=check_bool), +) -> HttpResponse: + remote_email, realm = get_email_and_realm_from_jwt_authentication_request(request) + return_data: Dict[str, bool] = {} + + user_profile = authenticate( + username=remote_email, realm=realm, return_data=return_data, use_dummy_backend=True + ) + if user_profile is None: + raise get_api_key_fetch_authenticate_failure(return_data) + + assert isinstance(user_profile, UserProfile) + + api_key = process_api_key_fetch_authenticate_result(request, user_profile) + + result: Dict[str, Any] = { + "api_key": api_key, + "email": user_profile.delivery_email, + } + + if include_profile: + members = get_raw_user_data( + realm, + user_profile, + target_user=user_profile, + client_gravatar=False, + user_avatar_url_field_optional=False, + include_custom_profile_fields=False, + ) + result["user"] = members[user_profile.id] + + return json_success(request, data=result) + + @csrf_exempt @require_post @has_request_variables diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 5e08512ba9..9ea8e029fd 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -449,7 +449,7 @@ TERMS_OF_SERVICE_MESSAGE: Optional[str] = None # Hostname used for Zulip's statsd logging integration. STATSD_HOST = "" -# Configuration for JWT auth. +# Configuration for JWT auth (sign in and API key fetch) JWT_AUTH_KEYS: Dict[str, JwtAuthKey] = {} # https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-SERVER_EMAIL diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index 502020b4e6..d32c4cc265 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -534,6 +534,27 @@ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { ## "example.com"), otherwise leave this as None. # SSO_APPEND_DOMAIN = None +## JWT authentication. +## +## JWT authentication is supported both to transparently log users +## into Zulip or to fetch users' API keys. The JWT secret key and +## algorithm must be configured here. +## +## See https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#jwt +# JWT_AUTH_KEYS: Dict[str, Any] = { +# # Subdomain for which this JWT configuration will apply. +# "zulip": { +# # Shared secret key used to validate jwt tokens, which should be stored +# # in zulip-secrets.conf and is read by the get_secret call below. +# # The key needs to be securely, randomly generated. Note that if you're +# # using the default HS256 algorithm, per RFC 7518, the key needs +# # to have at least 256 bits of entropy. +# "key": get_secret("jwt_auth_key"), +# # Algorithm with which the JWT token are signed. +# "algorithms": ["HS256"], +# } +# } + ################ ## Service configuration diff --git a/zproject/urls.py b/zproject/urls.py index 5216a53d11..9dcc103f27 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -26,6 +26,7 @@ from zerver.views.auth import ( api_fetch_api_key, api_get_server_settings, json_fetch_api_key, + jwt_fetch_api_key, log_into_subdomain, login_page, logout_then_login, @@ -746,6 +747,12 @@ urls += [ urls += [path("", include("social_django.urls", namespace="social"))] urls += [path("saml/metadata.xml", saml_sp_metadata)] +# This view accepts a JWT containing an email and returns an API key +# and the details for a single user. +urls += [ + path("api/v1/jwt/fetch_api_key", jwt_fetch_api_key), +] + # SCIM2 from django_scim import views as scim_views