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 django_cas_ng.signals import cas_user_authenticated
|
||||||
|
|
||||||
from jms_oidc_rp.signals import openid_user_login_failed, openid_user_login_success
|
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
|
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):
|
def on_cas_user_login_success(sender, request, user, **kwargs):
|
||||||
request.session['auth_backend'] = settings.AUTH_BACKEND_CAS
|
request.session['auth_backend'] = settings.AUTH_BACKEND_CAS
|
||||||
post_auth_success.send(sender, user=user, request=request)
|
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
|
# openid
|
||||||
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
|
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
|
||||||
path('openid/', include(('jms_oidc_rp.urls', 'authentication'), namespace='openid')),
|
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')),
|
path('captcha/', include('captcha.urls')),
|
||||||
]
|
]
|
||||||
|
|
|
@ -48,7 +48,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
return None
|
return None
|
||||||
next_url = request.GET.get('next') or '/'
|
next_url = request.GET.get('next') or '/'
|
||||||
auth_type = ''
|
auth_type = ''
|
||||||
|
|
||||||
if settings.AUTH_OPENID:
|
if settings.AUTH_OPENID:
|
||||||
auth_type = 'OIDC'
|
auth_type = 'OIDC'
|
||||||
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME)
|
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME)
|
||||||
|
@ -62,7 +61,13 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
else:
|
else:
|
||||||
cas_auth_url = None
|
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
|
return None
|
||||||
|
|
||||||
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
||||||
|
@ -72,8 +77,10 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
auth_url = cas_auth_url
|
auth_url = cas_auth_url
|
||||||
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
||||||
auth_url = openid_auth_url
|
auth_url = openid_auth_url
|
||||||
|
elif login_redirect in ['saml2'] and saml2_auth_url:
|
||||||
|
auth_url = saml2_auth_url
|
||||||
else:
|
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:
|
if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED:
|
||||||
redirect_url = auth_url
|
redirect_url = auth_url
|
||||||
|
@ -166,6 +173,12 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
'url': reverse('authentication:cas:cas-login'),
|
'url': reverse('authentication:cas:cas-login'),
|
||||||
'logo': static('img/login_cas_logo.png')
|
'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'),
|
'name': _('WeCom'),
|
||||||
'enabled': settings.AUTH_WECOM,
|
'enabled': settings.AUTH_WECOM,
|
||||||
|
@ -292,6 +305,8 @@ class UserLogoutView(TemplateView):
|
||||||
return settings.AUTH_OPENID_AUTH_LOGOUT_URL_NAME
|
return settings.AUTH_OPENID_AUTH_LOGOUT_URL_NAME
|
||||||
elif 'CAS' in backend:
|
elif 'CAS' in backend:
|
||||||
return settings.CAS_LOGOUT_URL_NAME
|
return settings.CAS_LOGOUT_URL_NAME
|
||||||
|
elif 'saml2' in backend:
|
||||||
|
return settings.SAML2_LOGOUT_URL_NAME
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|
|
@ -229,6 +229,19 @@ class Config(dict):
|
||||||
'AUTH_SSO': False,
|
'AUTH_SSO': False,
|
||||||
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
|
'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,
|
'AUTH_WECOM': False,
|
||||||
'WECOM_CORPID': '',
|
'WECOM_CORPID': '',
|
||||||
|
@ -246,7 +259,7 @@ class Config(dict):
|
||||||
'FEISHU_APP_ID': '',
|
'FEISHU_APP_ID': '',
|
||||||
'FEISHU_APP_SECRET': '',
|
'FEISHU_APP_SECRET': '',
|
||||||
|
|
||||||
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
|
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2
|
||||||
'LOGIN_REDIRECT_MSG_ENABLED': True,
|
'LOGIN_REDIRECT_MSG_ENABLED': True,
|
||||||
|
|
||||||
'SMS_ENABLED': False,
|
'SMS_ENABLED': False,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import ldap
|
import ldap
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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 settings
|
||||||
OTP_ISSUER_NAME = CONFIG.OTP_ISSUER_NAME
|
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_ID = CONFIG.FEISHU_APP_ID
|
||||||
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
|
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
|
# Other setting
|
||||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
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_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
||||||
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
|
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
|
||||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
||||||
|
AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend'
|
||||||
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
@ -156,7 +166,11 @@ if AUTH_OPENID:
|
||||||
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE)
|
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE)
|
||||||
if AUTH_RADIUS:
|
if AUTH_RADIUS:
|
||||||
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_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_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH
|
||||||
ONLY_ALLOW_AUTH_FROM_SOURCE = CONFIG.ONLY_ALLOW_AUTH_FROM_SOURCE
|
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,
|
'radius': serializers.RadiusSettingSerializer,
|
||||||
'cas': serializers.CASSettingSerializer,
|
'cas': serializers.CASSettingSerializer,
|
||||||
'sso': serializers.SSOSettingSerializer,
|
'sso': serializers.SSOSettingSerializer,
|
||||||
|
'saml2': serializers.SAML2SettingSerializer,
|
||||||
'clean': serializers.CleaningSerializer,
|
'clean': serializers.CleaningSerializer,
|
||||||
'other': serializers.OtherSettingSerializer,
|
'other': serializers.OtherSettingSerializer,
|
||||||
'sms': serializers.SMSSettingSerializer,
|
'sms': serializers.SMSSettingSerializer,
|
||||||
|
|
|
@ -97,6 +97,7 @@ class Setting(models.Model):
|
||||||
'AUTH_OPENID': [settings.AUTH_BACKEND_OIDC_CODE, settings.AUTH_BACKEND_OIDC_PASSWORD],
|
'AUTH_OPENID': [settings.AUTH_BACKEND_OIDC_CODE, settings.AUTH_BACKEND_OIDC_PASSWORD],
|
||||||
'AUTH_RADIUS': [settings.AUTH_BACKEND_RADIUS],
|
'AUTH_RADIUS': [settings.AUTH_BACKEND_RADIUS],
|
||||||
'AUTH_CAS': [settings.AUTH_BACKEND_CAS],
|
'AUTH_CAS': [settings.AUTH_BACKEND_CAS],
|
||||||
|
'AUTH_SAML2': [settings.AUTH_BACKEND_SAML2],
|
||||||
}
|
}
|
||||||
setting_backends = backends_map[name]
|
setting_backends = backends_map[name]
|
||||||
auth_backends = settings.AUTHENTICATION_BACKENDS
|
auth_backends = settings.AUTHENTICATION_BACKENDS
|
||||||
|
@ -130,6 +131,10 @@ class Setting(models.Model):
|
||||||
def refresh_AUTH_OPENID(cls):
|
def refresh_AUTH_OPENID(cls):
|
||||||
cls.refresh_authentications('AUTH_OPENID')
|
cls.refresh_authentications('AUTH_OPENID')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def refresh_AUTH_SAML2(cls):
|
||||||
|
cls.refresh_authentications('AUTH_SAML2')
|
||||||
|
|
||||||
def refresh_keycloak_to_openid_if_need(self):
|
def refresh_keycloak_to_openid_if_need(self):
|
||||||
watch_config_names = [
|
watch_config_names = [
|
||||||
'AUTH_OPENID', 'AUTH_OPENID_REALM_NAME', 'AUTH_OPENID_SERVER_URL',
|
'AUTH_OPENID', 'AUTH_OPENID_REALM_NAME', 'AUTH_OPENID_SERVER_URL',
|
||||||
|
|
|
@ -8,3 +8,4 @@ from .wecom import *
|
||||||
from .sso import *
|
from .sso import *
|
||||||
from .base import *
|
from .base import *
|
||||||
from .sms 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_FEISHU = serializers.BooleanField(default=False, label=_('FeiShu Auth'))
|
||||||
AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth'))
|
AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth'))
|
||||||
AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth"))
|
AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth"))
|
||||||
|
AUTH_SAML2 = serializers.BooleanField(default=False, label=_("SAML2 Auth"))
|
||||||
FORGOT_PASSWORD_URL = serializers.CharField(
|
FORGOT_PASSWORD_URL = serializers.CharField(
|
||||||
required=False, allow_blank=True, max_length=1024,
|
required=False, allow_blank=True, max_length=1024,
|
||||||
label=_("Forgot password url")
|
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'
|
openid = 'openid', 'OpenID'
|
||||||
radius = 'radius', 'Radius'
|
radius = 'radius', 'Radius'
|
||||||
cas = 'cas', 'CAS'
|
cas = 'cas', 'CAS'
|
||||||
|
saml2 = 'saml2', 'SAML2'
|
||||||
|
|
||||||
SOURCE_BACKEND_MAPPING = {
|
SOURCE_BACKEND_MAPPING = {
|
||||||
Source.local: [
|
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.openid: [settings.AUTH_BACKEND_OIDC_PASSWORD, settings.AUTH_BACKEND_OIDC_CODE],
|
||||||
Source.radius: [settings.AUTH_BACKEND_RADIUS],
|
Source.radius: [settings.AUTH_BACKEND_RADIUS],
|
||||||
Source.cas: [settings.AUTH_BACKEND_CAS],
|
Source.cas: [settings.AUTH_BACKEND_CAS],
|
||||||
|
Source.saml2: [settings.AUTH_BACKEND_SAML2],
|
||||||
}
|
}
|
||||||
|
|
||||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
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 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 common.utils import get_logger
|
||||||
from .signals import post_user_create
|
from .signals import post_user_create
|
||||||
from .models import User, UserPasswordHistory
|
from .models import User, UserPasswordHistory
|
||||||
|
@ -18,6 +19,28 @@ from .models import User, UserPasswordHistory
|
||||||
logger = get_logger(__file__)
|
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)
|
@receiver(post_save, sender=User)
|
||||||
def save_passwd_change(sender, instance: User, **kwargs):
|
def save_passwd_change(sender, instance: User, **kwargs):
|
||||||
passwds = UserPasswordHistory.objects.filter(user=instance).order_by('-date_created')\
|
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)
|
@receiver(cas_user_authenticated)
|
||||||
def on_cas_user_authenticated(sender, user, created, **kwargs):
|
def on_cas_user_authenticated(sender, user, created, **kwargs):
|
||||||
if created and settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
source = user.Source.cas.value
|
||||||
user.delete()
|
user_authenticated_handle(user, created, source)
|
||||||
raise PermissionDenied(f'Not allow non-exist user auth: {user.username}')
|
|
||||||
if created:
|
|
||||||
user.source = user.Source.cas.value
|
@receiver(saml2_create_or_update_user)
|
||||||
user.save()
|
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)
|
@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
|
#!/bin/bash
|
||||||
|
|
||||||
echo "安装依赖"
|
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 "安装依赖的插件"
|
echo "安装依赖的插件"
|
||||||
git lfs install
|
git lfs install
|
||||||
|
|
|
@ -122,3 +122,4 @@ geoip2==4.4.0
|
||||||
html2text==2020.1.16
|
html2text==2020.1.16
|
||||||
python-novaclient==11.0.1
|
python-novaclient==11.0.1
|
||||||
pyzipper==0.3.5
|
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