mirror of https://github.com/jumpserver/jumpserver
feat: 支持saml2协议的单点登录,合并代码 (#7347)
* fix: 支持saml2协议的单点登录 * feat: 支持saml2协议的单点登录,合并代码 Co-authored-by: jiangweidong <weidong.jiang@fit2cloud.com>pull/7348/head
parent
38c6d11af1
commit
3962af7c4f
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from .backends import *
|
|
@ -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
|
|
@ -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
|
|
@ -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'))
|
|
@ -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'),
|
||||
]
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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')),
|
||||
]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -8,3 +8,4 @@ from .wecom import *
|
|||
from .sso import *
|
||||
from .base import *
|
||||
from .sms import *
|
||||
from .saml2 import *
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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'))
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue