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 <mateusz.mandera@zulip.com>
This commit is contained in:
Alessandro Toppi 2022-09-13 17:39:18 +02:00 committed by Tim Abbott
parent 7c7ca61e9f
commit ff89590558
8 changed files with 267 additions and 4 deletions

View File

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

View File

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

View File

@ -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": "<target user 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

View File

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

View File

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

View File

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

View File

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

View File

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