From ff895905586dacc4b81e103881dbcf59b66784e6 Mon Sep 17 00:00:00 2001 From: Alessandro Toppi Date: Tue, 13 Sep 2022 17:39:18 +0200 Subject: [PATCH] auth: Add JWT-based user API key fetch. This adds a new endpoint /jwt/fetch_api_key that accepts a JWT and can be used to fetch API keys for a certain user. The target realm is inferred from the request and the user email is part of the JWT. A JSON containing an user API key, delivery email and (optionally) raw user profile data is returned in response. The profile data in the response is optional and can be retrieved by setting the POST param "include_profile" to "true" (default=false). Co-authored-by: Mateusz Mandera --- api_docs/changelog.md | 8 ++ docs/overview/changelog.md | 4 + docs/production/authentication-methods.md | 23 ++++ zerver/tests/test_auth_backends.py | 161 +++++++++++++++++++++- zerver/views/auth.py | 45 +++++- zproject/default_settings.py | 2 +- zproject/prod_settings_template.py | 21 +++ zproject/urls.py | 7 + 8 files changed, 267 insertions(+), 4 deletions(-) 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