diff --git a/apps/authentication/backends/saml2/__init__.py b/apps/authentication/backends/saml2/__init__.py new file mode 100644 index 000000000..bbdbdb814 --- /dev/null +++ b/apps/authentication/backends/saml2/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# +from .backends import * diff --git a/apps/authentication/backends/saml2/backends.py b/apps/authentication/backends/saml2/backends.py new file mode 100644 index 000000000..0cacdf920 --- /dev/null +++ b/apps/authentication/backends/saml2/backends.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db import transaction + +from common.utils import get_logger +from authentication.errors import reason_choices, reason_user_invalid +from .signals import ( + saml2_user_authenticated, saml2_user_authentication_failed, + saml2_create_or_update_user +) + +__all__ = ['SAML2Backend'] + +logger = get_logger(__file__) + + +class SAML2Backend(ModelBackend): + @staticmethod + def user_can_authenticate(user): + is_valid = getattr(user, 'is_valid', None) + return is_valid or is_valid is None + + @transaction.atomic + def get_or_create_from_saml_data(self, request, **saml_user_data): + log_prompt = "Get or Create user [SAML2Backend]: {}" + logger.debug(log_prompt.format('start')) + + user, created = get_user_model().objects.get_or_create( + username=saml_user_data['username'], defaults=saml_user_data + ) + logger.debug(log_prompt.format("user: {}|created: {}".format(user, created))) + + logger.debug(log_prompt.format("Send signal => saml2 create or update user")) + saml2_create_or_update_user.send( + sender=self, request=request, user=user, created=created, attrs=saml_user_data + ) + return user, created + + def authenticate(self, request, saml_user_data=None, **kwargs): + log_prompt = "Process authenticate [SAML2AuthCodeBackend]: {}" + logger.debug(log_prompt.format('Start')) + if saml_user_data is None: + logger.debug(log_prompt.format('saml_user_data is missing')) + return None + + username = saml_user_data.get('username') + if not username: + logger.debug(log_prompt.format('username is missing')) + return None + + user, created = self.get_or_create_from_saml_data(request, **saml_user_data) + + if self.user_can_authenticate(user): + logger.debug(log_prompt.format('SAML2 user login success')) + saml2_user_authenticated.send( + sender=self, request=request, user=user, created=created + ) + return user + else: + logger.debug(log_prompt.format('SAML2 user login failed')) + saml2_user_authentication_failed.send( + sender=self, request=request, username=username, + reason=reason_choices.get(reason_user_invalid) + ) + return None diff --git a/apps/authentication/backends/saml2/settings.py b/apps/authentication/backends/saml2/settings.py new file mode 100644 index 000000000..1f5b91f9b --- /dev/null +++ b/apps/authentication/backends/saml2/settings.py @@ -0,0 +1,12 @@ +from django.conf import settings +from onelogin.saml2.settings import OneLogin_Saml2_Settings + + +class JmsSaml2Settings(OneLogin_Saml2_Settings): + def get_sp_key(self): + key = getattr(settings, 'SAML2_SP_KEY_CONTENT', '') + return key + + def get_sp_cert(self): + cert = getattr(settings, 'SAML2_SP_CERT_CONTENT', '') + return cert diff --git a/apps/authentication/backends/saml2/signals.py b/apps/authentication/backends/saml2/signals.py new file mode 100644 index 000000000..42252f4d0 --- /dev/null +++ b/apps/authentication/backends/saml2/signals.py @@ -0,0 +1,6 @@ +from django.dispatch import Signal + + +saml2_create_or_update_user = Signal(providing_args=('user', 'created', 'request', 'attrs')) +saml2_user_authenticated = Signal(providing_args=('user', 'created', 'request')) +saml2_user_authentication_failed = Signal(providing_args=('request', 'username', 'reason')) diff --git a/apps/authentication/backends/saml2/urls.py b/apps/authentication/backends/saml2/urls.py new file mode 100644 index 000000000..d354fff58 --- /dev/null +++ b/apps/authentication/backends/saml2/urls.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +from django.urls import path + +from . import views + + +urlpatterns = [ + path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'), + path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'), + path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'), + path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'), +] diff --git a/apps/authentication/backends/saml2/views.py b/apps/authentication/backends/saml2/views.py new file mode 100644 index 000000000..26c88b5e2 --- /dev/null +++ b/apps/authentication/backends/saml2/views.py @@ -0,0 +1,269 @@ +import json +import os + +from django.views import View +from django.contrib import auth as auth +from django.urls import reverse +from django.conf import settings +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponseRedirect, HttpResponse, HttpResponseServerError + +from onelogin.saml2.auth import OneLogin_Saml2_Auth +from onelogin.saml2.errors import OneLogin_Saml2_Error +from onelogin.saml2.idp_metadata_parser import ( + OneLogin_Saml2_IdPMetadataParser as IdPMetadataParse, + dict_deep_merge +) + +from .settings import JmsSaml2Settings + +from common.utils import get_logger + +logger = get_logger(__file__) + + +class PrepareRequestMixin: + @staticmethod + def prepare_django_request(request): + result = { + 'https': 'on' if request.is_secure() else 'off', + 'http_host': request.META['HTTP_HOST'], + 'script_name': request.META['PATH_INFO'], + 'get_data': request.GET.copy(), + 'post_data': request.POST.copy() + } + return result + + @staticmethod + def get_idp_settings(): + idp_metadata_xml = settings.SAML2_IDP_METADATA_XML + idp_metadata_url = settings.SAML2_IDP_METADATA_URL + logger.debug('Start getting IDP configuration') + + try: + xml_idp_settings = IdPMetadataParse.parse(idp_metadata_xml) + except Exception as err: + xml_idp_settings = None + logger.warning('Failed to get IDP metadata XML settings, error: %s', str(err)) + + try: + url_idp_settings = IdPMetadataParse.parse_remote( + idp_metadata_url, timeout=20 + ) + except Exception as err: + url_idp_settings = None + logger.warning('Failed to get IDP metadata URL settings, error: %s', str(err)) + + idp_settings = url_idp_settings or xml_idp_settings + + if idp_settings is None: + msg = 'Unable to resolve IDP settings. ' + tip = 'Please contact your administrator to check system settings,' \ + 'or login using other methods.' + logger.error(msg) + raise OneLogin_Saml2_Error(msg + tip, OneLogin_Saml2_Error.SETTINGS_INVALID) + + logger.debug('IDP settings obtained successfully') + return idp_settings + + @staticmethod + def get_attribute_consuming_service(): + attr_mapping = settings.SAML2_RENAME_ATTRIBUTES + name_prefix = settings.SITE_URL + if attr_mapping and isinstance(attr_mapping, dict): + attr_list = [ + { + "name": '{}/{}'.format(name_prefix, sp_key), + "friendlyName": idp_key, "isRequired": True + } + for idp_key, sp_key in attr_mapping.items() + ] + request_attribute_template = { + "attributeConsumingService": { + "isDefault": False, + "serviceName": "JumpServer", + "serviceDescription": "JumpServer", + "requestedAttributes": attr_list + } + } + return request_attribute_template + else: + return {} + + @staticmethod + def get_advanced_settings(): + other_settings = {} + other_settings_path = settings.SAML2_OTHER_SETTINGS_PATH + if os.path.exists(other_settings_path): + with open(other_settings_path, 'r') as json_data: + try: + other_settings = json.loads(json_data.read()) + except Exception as error: + logger.error('Get other settings error: %s', error) + + default = { + "organization": { + "en": { + "name": "JumpServer", + "displayname": "JumpServer", + "url": "https://jumpserver.org/" + } + } + } + default.update(other_settings) + return default + + def get_sp_settings(self): + sp_host = settings.SITE_URL + attrs = self.get_attribute_consuming_service() + sp_settings = { + 'sp': { + 'entityId': f"{sp_host}{reverse('authentication:saml2:saml2-login')}", + 'assertionConsumerService': { + 'url': f"{sp_host}{reverse('authentication:saml2:saml2-callback')}", + }, + 'singleLogoutService': { + 'url': f"{sp_host}{reverse('authentication:saml2:saml2-logout')}" + } + } + } + sp_settings['sp'].update(attrs) + advanced_settings = self.get_advanced_settings() + sp_settings.update(advanced_settings) + return sp_settings + + def get_saml2_settings(self): + sp_settings = self.get_sp_settings() + idp_settings = self.get_idp_settings() + saml2_settings = dict_deep_merge(sp_settings, idp_settings) + return saml2_settings + + def init_saml_auth(self, request): + request = self.prepare_django_request(request) + _settings = self.get_saml2_settings() + saml_instance = OneLogin_Saml2_Auth( + request, old_settings=_settings, custom_base_path=settings.SAML_FOLDER + ) + return saml_instance + + @staticmethod + def value_to_str(attr): + if isinstance(attr, str): + return attr + elif isinstance(attr, list) and len(attr) > 0: + return str(attr[0]) + + def get_attributes(self, saml_instance): + user_attrs = {} + real_key_index = len(settings.SITE_URL) + 1 + attrs = saml_instance.get_attributes() + + for attr, value in attrs.items(): + attr = attr[real_key_index:] + user_attrs[attr] = self.value_to_str(value) + return user_attrs + + +class Saml2AuthRequestView(View, PrepareRequestMixin): + + def get(self, request): + log_prompt = "Process GET requests [SAML2AuthRequestView]: {}" + logger.debug(log_prompt.format('Start')) + + try: + saml_instance = self.init_saml_auth(request) + except OneLogin_Saml2_Error as error: + logger.error(log_prompt.format('Init saml auth error: %s' % error)) + return HttpResponse(error, status=412) + + next_url = settings.AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT + url = saml_instance.login(return_to=next_url) + logger.debug(log_prompt.format('Redirect login url')) + return HttpResponseRedirect(url) + + +class Saml2EndSessionView(View, PrepareRequestMixin): + http_method_names = ['get', 'post', ] + + def get(self, request): + log_prompt = "Process GET requests [SAML2EndSessionView]: {}" + logger.debug(log_prompt.format('Start')) + return self.post(request) + + def post(self, request): + log_prompt = "Process POST requests [SAML2EndSessionView]: {}" + logger.debug(log_prompt.format('Start')) + + logout_url = settings.LOGOUT_REDIRECT_URL or '/' + + if request.user.is_authenticated: + logger.debug(log_prompt.format('Log out the current user: {}'.format(request.user))) + auth.logout(request) + + if settings.SAML2_LOGOUT_COMPLETELY: + saml_instance = self.init_saml_auth(request) + logger.debug(log_prompt.format('Log out IDP user session synchronously')) + return HttpResponseRedirect(saml_instance.logout()) + + logger.debug(log_prompt.format('Redirect logout url')) + return HttpResponseRedirect(logout_url) + + +class Saml2AuthCallbackView(View, PrepareRequestMixin): + + def post(self, request): + log_prompt = "Process POST requests [SAML2AuthCallbackView]: {}" + post_data = request.POST + + try: + saml_instance = self.init_saml_auth(request) + except OneLogin_Saml2_Error as error: + logger.error(log_prompt.format('Init saml auth error: %s' % error)) + return HttpResponse(error, status=412) + + request_id = None + if 'AuthNRequestID' in request.session: + request_id = request.session['AuthNRequestID'] + + logger.debug(log_prompt.format('Process saml response')) + saml_instance.process_response(request_id=request_id) + errors = saml_instance.get_errors() + + if not errors: + if 'AuthNRequestID' in request.session: + del request.session['AuthNRequestID'] + + logger.debug(log_prompt.format('Process authenticate')) + saml_user_data = self.get_attributes(saml_instance) + user = auth.authenticate(request=request, saml_user_data=saml_user_data) + if user and user.is_valid: + logger.debug(log_prompt.format('Login: {}'.format(user))) + auth.login(self.request, user) + + logger.debug(log_prompt.format('Redirect')) + next_url = saml_instance.redirect_to(post_data.get('RelayState', '/')) + return HttpResponseRedirect(next_url) + logger.error(log_prompt.format('Saml response has error: %s' % str(errors))) + return HttpResponseRedirect(settings.AUTH_SAML2_AUTHENTICATION_FAILURE_REDIRECT_URI) + + @csrf_exempt + def dispatch(self, *args, **kwargs): + return super().dispatch(*args, **kwargs) + + +class Saml2AuthMetadataView(View, PrepareRequestMixin): + + def get(self, _): + saml_settings = self.get_sp_settings() + saml_settings = JmsSaml2Settings( + settings=saml_settings, sp_validation_only=True, + custom_base_path=settings.SAML_FOLDER + ) + metadata = saml_settings.get_sp_metadata() + errors = saml_settings.validate_metadata(metadata) + + if len(errors) == 0: + resp = HttpResponse(content=metadata, content_type='text/xml') + else: + resp = HttpResponseServerError(content=', '.join(errors)) + return resp diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index 942739531..5cdd07984 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -7,6 +7,10 @@ from django.dispatch import receiver from django_cas_ng.signals import cas_user_authenticated from jms_oidc_rp.signals import openid_user_login_failed, openid_user_login_success + +from authentication.backends.saml2.signals import ( + saml2_user_authenticated, saml2_user_authentication_failed +) from .signals import post_auth_success, post_auth_failed @@ -42,3 +46,15 @@ def on_oidc_user_login_failed(sender, username, request, reason, **kwargs): def on_cas_user_login_success(sender, request, user, **kwargs): request.session['auth_backend'] = settings.AUTH_BACKEND_CAS post_auth_success.send(sender, user=user, request=request) + + +@receiver(saml2_user_authenticated) +def on_saml2_user_login_success(sender, request, user, **kwargs): + request.session['auth_backend'] = settings.AUTH_BACKEND_SAML2 + post_auth_success.send(sender, user=user, request=request) + + +@receiver(saml2_user_authentication_failed) +def on_saml2_user_login_failed(sender, request, username, reason, **kwargs): + request.session['auth_backend'] = settings.AUTH_BACKEND_SAML2 + post_auth_failed.send(sender, username=username, request=request, reason=reason) diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index a976b624f..53944f759 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -56,5 +56,6 @@ urlpatterns = [ # openid path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')), path('openid/', include(('jms_oidc_rp.urls', 'authentication'), namespace='openid')), + path('saml2/', include(('authentication.backends.saml2.urls', 'authentication'), namespace='saml2')), path('captcha/', include('captcha.urls')), ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index f8c760b43..ecccf2375 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -48,7 +48,6 @@ class UserLoginView(mixins.AuthMixin, FormView): return None next_url = request.GET.get('next') or '/' auth_type = '' - if settings.AUTH_OPENID: auth_type = 'OIDC' openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) @@ -62,7 +61,13 @@ class UserLoginView(mixins.AuthMixin, FormView): else: cas_auth_url = None - if not any([openid_auth_url, cas_auth_url]): + if settings.AUTH_SAML2: + auth_type = 'saml2' + saml2_auth_url = reverse(settings.SAML2_LOGIN_URL_NAME) + f'?next={next_url}' + else: + saml2_auth_url = None + + if not any([openid_auth_url, cas_auth_url, saml2_auth_url]): return None login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower() @@ -72,8 +77,10 @@ class UserLoginView(mixins.AuthMixin, FormView): auth_url = cas_auth_url elif login_redirect in ['openid', 'oidc'] and openid_auth_url: auth_url = openid_auth_url + elif login_redirect in ['saml2'] and saml2_auth_url: + auth_url = saml2_auth_url else: - auth_url = openid_auth_url or cas_auth_url + auth_url = openid_auth_url or cas_auth_url or saml2_auth_url if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED: redirect_url = auth_url @@ -166,6 +173,12 @@ class UserLoginView(mixins.AuthMixin, FormView): 'url': reverse('authentication:cas:cas-login'), 'logo': static('img/login_cas_logo.png') }, + { + 'name': 'SAML2', + 'enabled': settings.AUTH_SAML2, + 'url': reverse('authentication:saml2:saml2-login'), + 'logo': static('img/login_cas_logo.png') + }, { 'name': _('WeCom'), 'enabled': settings.AUTH_WECOM, @@ -292,6 +305,8 @@ class UserLogoutView(TemplateView): return settings.AUTH_OPENID_AUTH_LOGOUT_URL_NAME elif 'CAS' in backend: return settings.CAS_LOGOUT_URL_NAME + elif 'saml2' in backend: + return settings.SAML2_LOGOUT_URL_NAME return None def get(self, request, *args, **kwargs): diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index b41cb64b5..9bffc38fa 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -229,6 +229,19 @@ class Config(dict): 'AUTH_SSO': False, 'AUTH_SSO_AUTHKEY_TTL': 60 * 15, + # SAML2 认证 + 'AUTH_SAML2': False, + 'SAML2_LOGOUT_COMPLETELY': True, + 'AUTH_SAML2_ALWAYS_UPDATE_USER': True, + 'SAML2_RENAME_ATTRIBUTES': {'uid': 'username', 'email': 'email'}, + 'SAML2_OTHER_SETTINGS_PATH': '', + 'SAML2_IDP_METADATA_URL': '', + 'SAML2_IDP_METADATA_XML': '', + 'SAML2_SP_KEY_CONTENT': '', + 'SAML2_SP_CERT_CONTENT': '', + 'AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT': '/', + 'AUTH_SAML2_AUTHENTICATION_FAILURE_REDIRECT_URI': '/', + # 企业微信 'AUTH_WECOM': False, 'WECOM_CORPID': '', @@ -246,7 +259,7 @@ class Config(dict): 'FEISHU_APP_ID': '', 'FEISHU_APP_SECRET': '', - 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS + 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2 'LOGIN_REDIRECT_MSG_ENABLED': True, 'SMS_ENABLED': False, diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index a43307ab1..42ba9958a 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -4,7 +4,7 @@ import os import ldap from django.utils.translation import ugettext_lazy as _ -from ..const import CONFIG, PROJECT_DIR +from ..const import CONFIG, PROJECT_DIR, BASE_DIR # OTP settings OTP_ISSUER_NAME = CONFIG.OTP_ISSUER_NAME @@ -122,7 +122,16 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU FEISHU_APP_ID = CONFIG.FEISHU_APP_ID FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET - +# Saml2 auth +AUTH_SAML2 = CONFIG.AUTH_SAML2 +AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT = CONFIG.AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT +AUTH_SAML2_AUTHENTICATION_FAILURE_REDIRECT_URI = CONFIG.AUTH_SAML2_AUTHENTICATION_FAILURE_REDIRECT_URI +AUTH_SAML2_ALWAYS_UPDATE_USER = CONFIG.AUTH_SAML2_ALWAYS_UPDATE_USER +SAML2_LOGOUT_COMPLETELY = CONFIG.SAML2_LOGOUT_COMPLETELY +SAML2_RENAME_ATTRIBUTES = CONFIG.SAML2_RENAME_ATTRIBUTES +SAML2_OTHER_SETTINGS_PATH = CONFIG.SAML2_OTHER_SETTINGS_PATH +SAML2_LOGIN_URL_NAME = "authentication:saml2:saml2-login" +SAML2_LOGOUT_URL_NAME = "authentication:saml2:saml2-logout" # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION @@ -141,6 +150,7 @@ AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication' AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication' AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication' AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication' +AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend' AUTHENTICATION_BACKENDS = [ @@ -156,7 +166,11 @@ if AUTH_OPENID: AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE) if AUTH_RADIUS: AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS) +if AUTH_SAML2: + AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_SAML2) ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH ONLY_ALLOW_AUTH_FROM_SOURCE = CONFIG.ONLY_ALLOW_AUTH_FROM_SOURCE + +SAML_FOLDER = os.path.join(BASE_DIR, 'authentication', 'backends', 'saml2') diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py index 2047faa08..02fb9004c 100644 --- a/apps/settings/api/settings.py +++ b/apps/settings/api/settings.py @@ -32,6 +32,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'radius': serializers.RadiusSettingSerializer, 'cas': serializers.CASSettingSerializer, 'sso': serializers.SSOSettingSerializer, + 'saml2': serializers.SAML2SettingSerializer, 'clean': serializers.CleaningSerializer, 'other': serializers.OtherSettingSerializer, 'sms': serializers.SMSSettingSerializer, diff --git a/apps/settings/models.py b/apps/settings/models.py index 128693bb9..f92e09f34 100644 --- a/apps/settings/models.py +++ b/apps/settings/models.py @@ -97,6 +97,7 @@ class Setting(models.Model): 'AUTH_OPENID': [settings.AUTH_BACKEND_OIDC_CODE, settings.AUTH_BACKEND_OIDC_PASSWORD], 'AUTH_RADIUS': [settings.AUTH_BACKEND_RADIUS], 'AUTH_CAS': [settings.AUTH_BACKEND_CAS], + 'AUTH_SAML2': [settings.AUTH_BACKEND_SAML2], } setting_backends = backends_map[name] auth_backends = settings.AUTHENTICATION_BACKENDS @@ -130,6 +131,10 @@ class Setting(models.Model): def refresh_AUTH_OPENID(cls): cls.refresh_authentications('AUTH_OPENID') + @classmethod + def refresh_AUTH_SAML2(cls): + cls.refresh_authentications('AUTH_SAML2') + def refresh_keycloak_to_openid_if_need(self): watch_config_names = [ 'AUTH_OPENID', 'AUTH_OPENID_REALM_NAME', 'AUTH_OPENID_SERVER_URL', diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py index 4a2f77ebe..c675f4070 100644 --- a/apps/settings/serializers/auth/__init__.py +++ b/apps/settings/serializers/auth/__init__.py @@ -8,3 +8,4 @@ from .wecom import * from .sso import * from .base import * from .sms import * +from .saml2 import * diff --git a/apps/settings/serializers/auth/base.py b/apps/settings/serializers/auth/base.py index 2873225b7..d4ea37f36 100644 --- a/apps/settings/serializers/auth/base.py +++ b/apps/settings/serializers/auth/base.py @@ -14,6 +14,7 @@ class AuthSettingSerializer(serializers.Serializer): AUTH_FEISHU = serializers.BooleanField(default=False, label=_('FeiShu Auth')) AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth')) AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth")) + AUTH_SAML2 = serializers.BooleanField(default=False, label=_("SAML2 Auth")) FORGOT_PASSWORD_URL = serializers.CharField( required=False, allow_blank=True, max_length=1024, label=_("Forgot password url") diff --git a/apps/settings/serializers/auth/saml2.py b/apps/settings/serializers/auth/saml2.py new file mode 100644 index 000000000..724bcf17a --- /dev/null +++ b/apps/settings/serializers/auth/saml2.py @@ -0,0 +1,30 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'SAML2SettingSerializer', +] + + +class SAML2SettingSerializer(serializers.Serializer): + AUTH_SAML2 = serializers.BooleanField( + default=False, required=False, label=_('Enable SAML2 Auth') + ) + SAML2_IDP_METADATA_URL = serializers.URLField( + allow_blank=True, required=False, label=_('IDP Metadata URL') + ) + SAML2_IDP_METADATA_XML = serializers.CharField( + allow_blank=True, required=False, label=_('IDP Metadata XML') + ) + SAML2_SP_KEY_CONTENT = serializers.CharField( + allow_blank=True, required=False, + write_only=True, label=_('SP Private Key') + ) + SAML2_SP_CERT_CONTENT = serializers.CharField( + allow_blank=True, required=False, + write_only=True, label=_('SP Public Cert') + ) + SAML2_RENAME_ATTRIBUTES = serializers.DictField(required=False, label=_('Rename attr')) + SAML2_LOGOUT_COMPLETELY = serializers.BooleanField(required=False, label=_('Logout completely')) + AUTH_SAML2_ALWAYS_UPDATE_USER = serializers.BooleanField(required=False, label=_('Always update user')) diff --git a/apps/users/migrations/0038_auto_20211209_1140.py b/apps/users/migrations/0038_auto_20211209_1140.py new file mode 100644 index 000000000..231ac4a48 --- /dev/null +++ b/apps/users/migrations/0038_auto_20211209_1140.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.13 on 2021-12-09 03:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0037_user_secret_key'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='source', + field=models.CharField(choices=[('local', 'Local'), ('ldap', 'LDAP/AD'), ('openid', 'OpenID'), ('radius', 'Radius'), ('cas', 'CAS'), ('saml2', 'SAML2')], default='local', max_length=30, verbose_name='Source'), + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index de01e068b..d0158f19f 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -528,6 +528,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): openid = 'openid', 'OpenID' radius = 'radius', 'Radius' cas = 'cas', 'CAS' + saml2 = 'saml2', 'SAML2' SOURCE_BACKEND_MAPPING = { Source.local: [ @@ -538,6 +539,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): Source.openid: [settings.AUTH_BACKEND_OIDC_PASSWORD, settings.AUTH_BACKEND_OIDC_CODE], Source.radius: [settings.AUTH_BACKEND_RADIUS], Source.cas: [settings.AUTH_BACKEND_CAS], + Source.saml2: [settings.AUTH_BACKEND_SAML2], } id = models.UUIDField(default=uuid.uuid4, primary_key=True) diff --git a/apps/users/signals_handler.py b/apps/users/signals_handler.py index 9831367fc..3e2ceb1f6 100644 --- a/apps/users/signals_handler.py +++ b/apps/users/signals_handler.py @@ -10,6 +10,7 @@ from django.db.models.signals import post_save from jms_oidc_rp.signals import openid_create_or_update_user +from authentication.backends.saml2.signals import saml2_create_or_update_user from common.utils import get_logger from .signals import post_user_create from .models import User, UserPasswordHistory @@ -18,6 +19,28 @@ from .models import User, UserPasswordHistory logger = get_logger(__file__) +def user_authenticated_handle(user, created, source, attrs=None, **kwargs): + if created and settings.ONLY_ALLOW_EXIST_USER_AUTH: + user.delete() + raise PermissionDenied(f'Not allow non-exist user auth: {user.username}') + if created: + user.source = source + user.save() + elif not created and settings.AUTH_SAML2_ALWAYS_UPDATE_USER: + attr_whitelist = ('user', 'username', 'email', 'phone', 'comment') + logger.debug( + "Receive saml2 user updated signal: {}, " + "Update user info: {}," + "(Update only properties in the whitelist. [{}])" + "".format(user, str(attrs), ','.join(attr_whitelist)) + ) + if attrs is not None: + for key, value in attrs.items(): + if key in attr_whitelist and value: + setattr(user, key, value) + user.save() + + @receiver(post_save, sender=User) def save_passwd_change(sender, instance: User, **kwargs): passwds = UserPasswordHistory.objects.filter(user=instance).order_by('-date_created')\ @@ -44,12 +67,14 @@ def on_user_create(sender, user=None, **kwargs): @receiver(cas_user_authenticated) def on_cas_user_authenticated(sender, user, created, **kwargs): - if created and settings.ONLY_ALLOW_EXIST_USER_AUTH: - user.delete() - raise PermissionDenied(f'Not allow non-exist user auth: {user.username}') - if created: - user.source = user.Source.cas.value - user.save() + source = user.Source.cas.value + user_authenticated_handle(user, created, source) + + +@receiver(saml2_create_or_update_user) +def on_saml2_create_or_update_user(sender, user, created, attrs, **kwargs): + source = user.Source.saml2.value + user_authenticated_handle(user, created, source, attrs, **kwargs) @receiver(populate_user) diff --git a/requirements/alpine_requirements.txt b/requirements/alpine_requirements.txt index f64e3baf9..52a6fb63b 100644 --- a/requirements/alpine_requirements.txt +++ b/requirements/alpine_requirements.txt @@ -1 +1 @@ -gcc make python3-dev python3 libffi-dev mariadb-dev libc-dev libffi-dev krb5-dev openldap-dev jpeg-dev linux-headers sshpass openssh-client +gcc make python3-dev python3 libffi-dev mariadb-dev libc-dev krb5-dev openldap-dev jpeg-dev linux-headers sshpass openssh-client build-base libressl libffi-dev libressl-dev libxslt-dev libxml2-dev xmlsec-dev xmlsec diff --git a/requirements/deb_requirements.txt b/requirements/deb_requirements.txt index 6edb70062..53aa37322 100644 --- a/requirements/deb_requirements.txt +++ b/requirements/deb_requirements.txt @@ -1 +1 @@ -g++ make iputils-ping default-libmysqlclient-dev libpq-dev libffi-dev libldap2-dev libsasl2-dev sshpass +g++ make iputils-ping default-libmysqlclient-dev libpq-dev libffi-dev libldap2-dev libsasl2-dev sshpass pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl diff --git a/requirements/mac_requirements.sh b/requirements/mac_requirements.sh index 66f28d130..575e6d98c 100644 --- a/requirements/mac_requirements.sh +++ b/requirements/mac_requirements.sh @@ -1,7 +1,7 @@ #!/bin/bash echo "安装依赖" -brew install libtiff libjpeg webp little-cms2 openssl gettext git git-lfs mysql +brew install libtiff libjpeg webp little-cms2 openssl gettext git git-lfs mysql libxml2 libxmlsec1 pkg-config echo "安装依赖的插件" git lfs install diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 26a93217c..e7e2b2a02 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -122,3 +122,4 @@ geoip2==4.4.0 html2text==2020.1.16 python-novaclient==11.0.1 pyzipper==0.3.5 +python3-saml==1.12.0 diff --git a/requirements/rpm_requirements.txt b/requirements/rpm_requirements.txt index a11e46125..360f5e026 100644 --- a/requirements/rpm_requirements.txt +++ b/requirements/rpm_requirements.txt @@ -1 +1 @@ -gcc-c++ sshpass mariadb-devel openldap-devel +gcc-c++ sshpass mariadb-devel openldap-devel libxml2-devel xmlsec1-devel xmlsec1-openssl-devel libtool-ltdl-devel