feat: 支持saml2协议的单点登录,合并代码 (#7347)

* fix: 支持saml2协议的单点登录

* feat: 支持saml2协议的单点登录,合并代码

Co-authored-by: jiangweidong <weidong.jiang@fit2cloud.com>
pull/7348/head
fit2bot 2021-12-09 15:47:21 +08:00 committed by GitHub
parent 38c6d11af1
commit 3962af7c4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 529 additions and 16 deletions

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
#
from .backends import *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,3 +8,4 @@ from .wecom import *
from .sso import *
from .base import *
from .sms import *
from .saml2 import *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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