mirror of https://github.com/zulip/zulip.git
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:
parent
7c7ca61e9f
commit
ff89590558
|
@ -20,6 +20,14 @@ format used by the Zulip server that they are interacting with.
|
||||||
|
|
||||||
## Changes in Zulip 7.0
|
## 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**
|
**Feature level 159**
|
||||||
|
|
||||||
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events),
|
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events),
|
||||||
|
|
|
@ -21,6 +21,10 @@ log][commit-log] for an up-to-date list of all changes.
|
||||||
[documentation](../production/upload-backends.md#s3-local-caching).
|
[documentation](../production/upload-backends.md#s3-local-caching).
|
||||||
- Removed the `application_server.no_serve_uploads` setting in
|
- Removed the `application_server.no_serve_uploads` setting in
|
||||||
`/etc/zulip/zulip.conf`, as all uploads requests go through Zulip now.
|
`/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
|
## Zulip 6.x series
|
||||||
|
|
||||||
|
|
|
@ -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
|
a registration form unless they need to accept Terms of Service for
|
||||||
the server (i.e. `TERMS_OF_SERVICE_VERSION` is set).
|
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 more authentication backends
|
||||||
|
|
||||||
Adding an integration with any of the more than 100 authentication
|
Adding an integration with any of the more than 100 authentication
|
||||||
|
|
|
@ -87,7 +87,7 @@ from zerver.lib.test_helpers import (
|
||||||
)
|
)
|
||||||
from zerver.lib.types import Validator
|
from zerver.lib.types import Validator
|
||||||
from zerver.lib.upload.base import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
|
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.utils import assert_is_not_none
|
||||||
from zerver.lib.validator import (
|
from zerver.lib.validator import (
|
||||||
check_bool,
|
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.
|
# Don't load the base class as a test: https://bugs.python.org/issue17519.
|
||||||
del SocialAuthBase
|
del SocialAuthBase
|
||||||
|
|
|
@ -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.subdomains import get_subdomain, is_subdomain_root_or_alias
|
||||||
from zerver.lib.url_encoding import append_url_query_string
|
from zerver.lib.url_encoding import append_url_query_string
|
||||||
from zerver.lib.user_agent import parse_user_agent
|
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.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 (
|
from zerver.models import (
|
||||||
MultiuseInvite,
|
MultiuseInvite,
|
||||||
PreregistrationUser,
|
PreregistrationUser,
|
||||||
|
@ -921,10 +921,51 @@ def get_api_key_fetch_authenticate_failure(return_data: Dict[str, bool]) -> Json
|
||||||
return PasswordAuthDisabledError()
|
return PasswordAuthDisabledError()
|
||||||
if return_data.get("password_reset_needed"):
|
if return_data.get("password_reset_needed"):
|
||||||
return PasswordResetRequiredError()
|
return PasswordResetRequiredError()
|
||||||
|
if return_data.get("invalid_subdomain"):
|
||||||
|
raise InvalidSubdomainError()
|
||||||
|
|
||||||
return AuthenticationFailedError()
|
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
|
@csrf_exempt
|
||||||
@require_post
|
@require_post
|
||||||
@has_request_variables
|
@has_request_variables
|
||||||
|
|
|
@ -449,7 +449,7 @@ TERMS_OF_SERVICE_MESSAGE: Optional[str] = None
|
||||||
# Hostname used for Zulip's statsd logging integration.
|
# Hostname used for Zulip's statsd logging integration.
|
||||||
STATSD_HOST = ""
|
STATSD_HOST = ""
|
||||||
|
|
||||||
# Configuration for JWT auth.
|
# Configuration for JWT auth (sign in and API key fetch)
|
||||||
JWT_AUTH_KEYS: Dict[str, JwtAuthKey] = {}
|
JWT_AUTH_KEYS: Dict[str, JwtAuthKey] = {}
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-SERVER_EMAIL
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-SERVER_EMAIL
|
||||||
|
|
|
@ -534,6 +534,27 @@ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {
|
||||||
## "example.com"), otherwise leave this as None.
|
## "example.com"), otherwise leave this as None.
|
||||||
# SSO_APPEND_DOMAIN = 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
|
## Service configuration
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ from zerver.views.auth import (
|
||||||
api_fetch_api_key,
|
api_fetch_api_key,
|
||||||
api_get_server_settings,
|
api_get_server_settings,
|
||||||
json_fetch_api_key,
|
json_fetch_api_key,
|
||||||
|
jwt_fetch_api_key,
|
||||||
log_into_subdomain,
|
log_into_subdomain,
|
||||||
login_page,
|
login_page,
|
||||||
logout_then_login,
|
logout_then_login,
|
||||||
|
@ -746,6 +747,12 @@ urls += [
|
||||||
urls += [path("", include("social_django.urls", namespace="social"))]
|
urls += [path("", include("social_django.urls", namespace="social"))]
|
||||||
urls += [path("saml/metadata.xml", saml_sp_metadata)]
|
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
|
# SCIM2
|
||||||
|
|
||||||
from django_scim import views as scim_views
|
from django_scim import views as scim_views
|
||||||
|
|
Loading…
Reference in New Issue