diff --git a/frontend/src/pages/org-admin/file-item.js b/frontend/src/pages/org-admin/file-item.js new file mode 100644 index 0000000000..af021f68fa --- /dev/null +++ b/frontend/src/pages/org-admin/file-item.js @@ -0,0 +1,50 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Row, Col, Label, Button } from 'reactstrap'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + postFile: PropTypes.func.isRequired, + displayName: PropTypes.string.isRequired, +}; + +class OrgSamlConfigPostFile extends Component { + + constructor(props) { + super(props); + this.fileInput = React.createRef(); + } + + uploadFile = () => { + if (!this.fileInput.current.files.length) { + return; + } + const file = this.fileInput.current.files[0]; + this.props.postFile(file); + } + + openFileInput = () => { + this.fileInput.current.click(); + } + + render() { + const { displayName } = this.props; + return ( + + + + + + + + + + + + ); + } +} + +OrgSamlConfigPostFile.propTypes = propTypes; + +export default OrgSamlConfigPostFile; diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index e70eacf82b..2abc8cc2b8 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -33,6 +33,7 @@ import OrgLogs from './org-logs'; import OrgLogsFileAudit from './org-logs-file-audit'; import OrgLogsFileUpdate from './org-logs-file-update'; import OrgLogsPermAudit from './org-logs-perm-audit'; +import OrgSAMLConfig from './org-saml-config'; import '../../css/layout.css'; import '../../css/toolbar.css'; @@ -114,6 +115,7 @@ class Org extends React.Component { + diff --git a/frontend/src/pages/org-admin/input-item.js b/frontend/src/pages/org-admin/input-item.js new file mode 100644 index 0000000000..699f8bb2e4 --- /dev/null +++ b/frontend/src/pages/org-admin/input-item.js @@ -0,0 +1,40 @@ +import React, { Component, Fragment } from 'react'; +import { Input, Row, Col, Label } from 'reactstrap'; +import PropTypes from 'prop-types'; + +const propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + changeValue: PropTypes.func.isRequired, + displayName: PropTypes.string.isRequired, +}; + +class OrgSamlConfigInput extends Component { + + constructor(props) { + super(props); + } + + inputValue = (e) => { + this.props.changeValue(e); + } + + render() { + const { value, displayName } = this.props; + return ( + + + + + + + {this.newInput = input;}} value={value} onChange={this.inputValue}/> + + + + ); + } +} + +OrgSamlConfigInput.propTypes = propTypes; + +export default OrgSamlConfigInput; diff --git a/frontend/src/pages/org-admin/org-saml-config.js b/frontend/src/pages/org-admin/org-saml-config.js new file mode 100644 index 0000000000..5a21d26cfe --- /dev/null +++ b/frontend/src/pages/org-admin/org-saml-config.js @@ -0,0 +1,280 @@ +import React, { Fragment, Component } from 'react'; +import { Row, Col, Label, Button } from 'reactstrap'; +import MainPanelTopbar from './main-panel-topbar'; +import toaster from '../../components/toast'; +import Loading from '../../components/loading'; +import { gettext, orgID, serviceURL } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import Section from './section'; +import InputItem from './input-item'; +import FileItem from './file-item'; + +class OrgSAMLConfig extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + samlConfigID: '', + newUrlPrefix: '', + orgUrlPrefix: '', + metadataUrl: '', + singleSignOnService: '', + singleLogoutService: '', + validDays: '', + isBtnsShown: false, + }; + } + + toggleBtns = () => { + this.setState({isBtnsShown: !this.state.isBtnsShown}); + } + + hideBtns = () => { + if (!this.state.isBtnsShown) return; + + if (this.state.newUrlPrefix !== this.state.orgUrlPrefix) { + this.setState({newUrlPrefix: this.state.orgUrlPrefix}); + } + this.toggleBtns(); + } + + onSubmit = () => { + const newUrlPrefix = this.state.newUrlPrefix.trim(); + if (newUrlPrefix !== this.state.orgUrlPrefix) { + this.updateUrlPrefix(newUrlPrefix); + } + this.toggleBtns(); + } + + inputOrgUrlPrefix = (e) => { + this.setState({newUrlPrefix: e.target.value}); + } + + inputMetadataUrl = (e) => { + this.setState({metadataUrl: e.target.value}); + } + + inputSingleSignOnService = (e) => { + this.setState({singleSignOnService: e.target.value}); + } + + inputSingleLogoutService = (e) => { + this.setState({singleLogoutService: e.target.value}); + } + + inputValidDays = (e) => { + this.setState({validDays: e.target.value}); + } + + componentDidMount() { + seafileAPI.orgAdminGetUrlPrefix(orgID).then((res) => { + this.setState({ + newUrlPrefix: res.data.org_url_prefix, + orgUrlPrefix: res.data.org_url_prefix, + }); + seafileAPI.orgAdminGetSamlConfig(orgID).then((res) => { + this.setState({ + loading: false, + samlConfigID: res.data.saml_config.id || '', + metadataUrl: res.data.saml_config.metadata_url || '', + singleSignOnService: res.data.saml_config.single_sign_on_service || '', + singleLogoutService: res.data.saml_config.single_logout_service || '', + validDays: res.data.saml_config.valid_days || '', + }); + }).catch(error => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true), + }); + }); + }).catch(error => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true), + }); + }); + } + + updateUrlPrefix = (newUrlPrefix) => { + seafileAPI.orgAdminUpdateUrlPrefix(orgID, newUrlPrefix).then((res) => { + this.setState({ + newUrlPrefix: res.data.org_url_prefix, + orgUrlPrefix: res.data.org_url_prefix, + }); + toaster.success(gettext('Success')); + }).catch((error) => { + this.setState({newUrlPrefix: this.state.orgUrlPrefix}); + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + postIdpCertificate = (file) => { + seafileAPI.orgAdminUploadIdpCertificate(orgID, file).then(() => { + toaster.success(gettext('Success')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + postIdpMetadataXml = (file) => { + seafileAPI.orgAdminUploadIdpMetadataXml(orgID, file).then(() => { + toaster.success(gettext('Success')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + addSamlConfig = () => { + const { metadataUrl, singleSignOnService, singleLogoutService, validDays } = this.state; + seafileAPI.orgAdminAddSamlConfig(orgID, metadataUrl, singleSignOnService, singleLogoutService, validDays).then((res) => { + this.setState({ + samlConfigID: res.data.saml_config.id, + metadataUrl: res.data.saml_config.metadata_url, + singleSignOnService: res.data.saml_config.single_sign_on_service, + singleLogoutService: res.data.saml_config.single_logout_service, + validDays: res.data.saml_config.valid_days, + }); + toaster.success(gettext('Success')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + updateSamlConfig = () => { + const { metadataUrl, singleSignOnService, singleLogoutService, validDays } = this.state; + seafileAPI.orgAdminUpdateSamlConfig(orgID, metadataUrl, singleSignOnService, singleLogoutService, validDays).then((res) => { + this.setState({ + samlConfigID: res.data.saml_config.id, + metadataUrl: res.data.saml_config.metadata_url, + singleSignOnService: res.data.saml_config.single_sign_on_service, + singleLogoutService: res.data.saml_config.single_logout_service, + validDays: res.data.saml_config.valid_days, + }); + toaster.success(gettext('Success')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + deleteSamlConfig = () => { + seafileAPI.orgAdminDeleteSamlConfig(orgID).then(() => { + this.setState({ + samlConfigID: '', + metadataUrl: '', + singleSignOnService: '', + singleLogoutService: '', + validDays: '', + }); + toaster.success(gettext('Success')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + render() { + const { loading, errorMsg, samlConfigID, newUrlPrefix, metadataUrl, singleSignOnService, singleLogoutService, validDays, isBtnsShown } = this.state; + + return ( + + +
+
+
+

{gettext('SAML config')}

+
+
+ {loading && } + {errorMsg &&

{errorMsg}

} + {(!loading && !errorMsg) && + +
+ + + + + + + {`${serviceURL}/org/custom/`} {this.newInput = input;}} value={newUrlPrefix} onChange={this.inputOrgUrlPrefix} onFocus={this.toggleBtns} onBlur={this.hideBtns}> +

+ {gettext('The custom part of the URL should be 6 to 20 characters, and can only contain alphanumeric characters and hyphens.')} +

+ + {isBtnsShown && + + + + + } +
+
+
+
+ + + + + + + {samlConfigID ? + + + + + + + + : + + + } + + +
+
+ + + + +
+
+ } +
+
+
+
+ ); + } +} + +export default OrgSAMLConfig; diff --git a/frontend/src/pages/org-admin/section.js b/frontend/src/pages/org-admin/section.js new file mode 100644 index 0000000000..db1ebf2226 --- /dev/null +++ b/frontend/src/pages/org-admin/section.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + headingText: PropTypes.string.isRequired, + children: PropTypes.object.isRequired +}; + +class Section extends Component { + + constructor(props) { + super(props); + } + + render() { + const { headingText, children} = this.props; + return ( +
+

{headingText}

+ {children} +
+ ); + } +} + +Section.propTypes = propTypes; + +export default Section; diff --git a/frontend/src/pages/org-admin/side-panel.js b/frontend/src/pages/org-admin/side-panel.js index 8215371a3d..e479a8b982 100644 --- a/frontend/src/pages/org-admin/side-panel.js +++ b/frontend/src/pages/org-admin/side-panel.js @@ -86,6 +86,12 @@ class SidePanel extends React.Component { {gettext('Logs')} +
  • + this.tabItemClick('SAML config')} > + + {gettext('SAML config')} + +
  • diff --git a/seahub/adfs_auth/backends.py b/seahub/adfs_auth/backends.py index 9308d19d30..f8f1023b63 100644 --- a/seahub/adfs_auth/backends.py +++ b/seahub/adfs_auth/backends.py @@ -147,6 +147,14 @@ class Saml2Backend(ModelBackend): if create_unknown_user: activate_after_creation = getattr(settings, 'SAML_ACTIVATE_USER_AFTER_CREATION', True) user = User.objects.create_user(email=username, is_active=activate_after_creation) + # create org user + url_prefix = kwargs.get('url_prefix', None) + if url_prefix: + org = ccnet_api.get_org_by_url_prefix(url_prefix) + if org: + org_id = org.org_id + ccnet_api.add_org_user(org_id, username, 0) + if not activate_after_creation: notify_admins_on_activate_request(username) elif settings.NOTIFY_ADMIN_AFTER_REGISTRATION: diff --git a/seahub/adfs_auth/urls.py b/seahub/adfs_auth/urls.py new file mode 100644 index 0000000000..5caba4067e --- /dev/null +++ b/seahub/adfs_auth/urls.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from django.conf.urls import url, include + +from seahub.adfs_auth.views import assertion_consumer_service, org_multi_adfs + +urlpatterns = [ + url(r'^$', org_multi_adfs, name="org_multi_adfs"), + url(r'^saml2/acs/$', assertion_consumer_service, name='org_saml2_acs'), + url(r'^saml2/', include('djangosaml2.urls')), +] diff --git a/seahub/adfs_auth/utils.py b/seahub/adfs_auth/utils.py new file mode 100644 index 0000000000..9a379deb98 --- /dev/null +++ b/seahub/adfs_auth/utils.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +import os +import re +import logging + +import saml2 +from saml2 import saml +from saml2.config import SPConfig +from django.utils.translation import gettext as _ + +from seaserv import ccnet_api + +from seahub.utils import render_error +from seahub.organizations.models import OrgSAMLConfig +try: + from seahub.settings import ENABLE_MULTI_ADFS, SP_SERVICE_URL, ATTRIBUTE_MAP_DIR, CERTS_DIR, XMLSEC_BINARY +except ImportError: + ENABLE_MULTI_ADFS = False + SP_SERVICE_URL = '' + ATTRIBUTE_MAP_DIR = '' + CERTS_DIR = '' + XMLSEC_BINARY = '' + +logger = logging.getLogger(__name__) + + +def settings_check(func): + def _decorated(request): + error = False + if not ENABLE_MULTI_ADFS: + logger.error('Feature not enabled.') + error = True + else: + if not SP_SERVICE_URL or not ATTRIBUTE_MAP_DIR or not CERTS_DIR or not XMLSEC_BINARY: + logger.error('ADFS login relevant settings invalid.') + error = True + if error: + return render_error(request, _('Error, please contact administrator.')) + return func(request) + return _decorated + + +@settings_check +def config_settings_loader(request): + # get url_prefix + url_prefix = None + reg = re.search(r'org/custom/([a-z_0-9-]+)', request.path) + if reg: + url_prefix = reg.group(1) + + # get org_id + org = ccnet_api.get_org_by_url_prefix(url_prefix) + if not org: + return render_error(request, 'Failed to get org %s ' % url_prefix) + org_id = org.org_id + + # get org saml_config + org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) + if not org_saml_config: + return render_error(request, 'Failed to get org %s saml_config' % org_id) + metadata_url = org_saml_config.metadata_url + single_sign_on_service = org_saml_config.single_sign_on_service + single_logout_service = org_saml_config.single_logout_service + valid_days = int(org_saml_config.valid_days) + + # get org_sp_service_url + org_sp_service_url = SP_SERVICE_URL.rstrip('/') + '/' + url_prefix + + # generate org certs dir + org_certs_dir = os.path.join(CERTS_DIR, str(org_id)) + + # generate org saml_config + saml_config = { + 'entityid': org_sp_service_url + '/saml2/metadata/', + 'attribute_map_dir': ATTRIBUTE_MAP_DIR, + 'xmlsec_binary': XMLSEC_BINARY, + 'allow_unknown_attributes': True, + 'service': { + 'sp': { + 'allow_unsolicited': True, + 'want_response_signed': False, + 'name_id_format': saml.NAMEID_FORMAT_EMAILADDRESS, + 'endpoints': { + 'assertion_consumer_service': [(org_sp_service_url + '/saml2/acs/', saml2.BINDING_HTTP_POST)], + 'single_logout_service': [ + (org_sp_service_url + '/saml2/ls/', saml2.BINDING_HTTP_REDIRECT), + (org_sp_service_url + '/saml2/ls/post', saml2.BINDING_HTTP_POST), + ], + }, + 'required_attributes': ["uid"], + 'idp': { + metadata_url: { + 'single_sign_on_service': { + saml2.BINDING_HTTP_REDIRECT: single_sign_on_service, + }, + 'single_logout_service': { + saml2.BINDING_HTTP_REDIRECT: single_logout_service, + }, + }, + }, + }, + }, + 'metadata': { + 'local': [os.path.join(org_certs_dir, 'idp_federation_metadata.xml')], + }, + 'debug': 1, + 'key_file': '', + 'cert_file': os.path.join(org_certs_dir, 'idp.crt'), + 'encryption_keypairs': [{ + 'key_file': os.path.join(org_certs_dir, 'sp.key'), + 'cert_file': os.path.join(org_certs_dir, 'sp.crt'), + }], + 'valid_for': valid_days * 24, # how long is our metadata valid, unit is hour + } + + conf = SPConfig() + conf.load(saml_config) + return conf diff --git a/seahub/adfs_auth/views.py b/seahub/adfs_auth/views.py index 58d8009bec..5794bffa5b 100644 --- a/seahub/adfs_auth/views.py +++ b/seahub/adfs_auth/views.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re import logging from django.conf import settings @@ -105,10 +106,17 @@ def assertion_consumer_service(request, if callable(create_unknown_user): create_unknown_user = create_unknown_user() + # get url_prefix + url_prefix = None + reg = re.search(r'org/custom/([a-z_0-9-]+)', request.path) + if reg: + url_prefix = reg.group(1) + logger.debug('Trying to authenticate the user') user = auth.authenticate(session_info=session_info, attribute_mapping=attribute_mapping, - create_unknown_user=create_unknown_user) + create_unknown_user=create_unknown_user, + url_prefix=url_prefix) if user is None: logger.error('The user is None') return HttpResponseForbidden("Permission denied") @@ -175,3 +183,8 @@ def auth_complete(request): update_sudo_mode_ts(request) return resp + + +def org_multi_adfs(request): + if getattr(settings, 'ENABLE_MULTI_ADFS', False): + return HttpResponseRedirect(request.path.rstrip('/') + '/saml2/login/') diff --git a/seahub/organizations/api/admin/saml_config.py b/seahub/organizations/api/admin/saml_config.py new file mode 100644 index 0000000000..578e1a92a6 --- /dev/null +++ b/seahub/organizations/api/admin/saml_config.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +import os +import re +import logging + +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.authentication import SessionAuthentication +from django.utils.translation import gettext as _ + +from seaserv import ccnet_api + +from seahub.api2.permissions import IsProVersion, IsOrgAdminUser +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.utils import api_error +from seahub.organizations.utils import get_ccnet_db_name, update_org_url_prefix +from seahub.organizations.models import OrgSAMLConfig +try: + from seahub.settings import CERTS_DIR +except ImportError: + CERTS_DIR = '' + +logger = logging.getLogger(__name__) + + +class OrgUploadIdPCertificateView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def post(self, request, org_id): + # argument check + idp_certificate = request.FILES.get('idp_certificate', None) + if not idp_certificate: + error_msg = 'idp_certificate not found.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if idp_certificate.name != 'idp.crt': + error_msg = 'idp_certificate invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not CERTS_DIR: + error_msg = 'CERTS_DIR invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + if not ccnet_api.get_org_by_id(int(org_id)): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + org_certs_dir = os.path.join(CERTS_DIR, str(org_id)) + try: + if not os.path.exists(org_certs_dir): + os.makedirs(org_certs_dir) + + cert_file_path = os.path.join(org_certs_dir, 'idp.crt') + with open(cert_file_path, 'wb') as fd: + fd.write(idp_certificate.read()) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + +class OrgUploadIdPMetadataXMLView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def post(self, request, org_id): + # argument check + idp_metadata_xml = request.FILES.get('idp_metadata_xml', None) + if not idp_metadata_xml: + error_msg = 'idp_metadata_xml not found.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if idp_metadata_xml.name != 'idp_federation_metadata.xml': + error_msg = 'idp_metadata_xml invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not CERTS_DIR: + error_msg = 'CERTS_DIR invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + if not ccnet_api.get_org_by_id(int(org_id)): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + org_certs_dir = os.path.join(CERTS_DIR, str(org_id)) + try: + if not os.path.exists(org_certs_dir): + os.makedirs(org_certs_dir) + + cert_file_path = os.path.join(org_certs_dir, 'idp_federation_metadata.xml') + with open(cert_file_path, 'wb') as fd: + fd.write(idp_metadata_xml.read()) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + +class OrgSAMLConfigView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + # resource check + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # get config + org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) + if not org_saml_config: + return Response({'saml_config': {}}) + + return Response({'saml_config': org_saml_config.to_dict()}) + + def post(self, request, org_id): + # argument check + metadata_url = request.data.get('metadata_url', None) + single_sign_on_service = request.data.get('single_sign_on_service', None) + single_logout_service = request.data.get('single_logout_service', None) + valid_days = request.data.get('valid_days', None) + if not metadata_url or not single_sign_on_service or not single_logout_service or not valid_days: + return api_error(status.HTTP_400_BAD_REQUEST, 'argument invalid.') + + # resource check + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # add an org saml config + try: + saml_comfig = OrgSAMLConfig.objects.add_or_update_saml_config( + org_id, metadata_url, single_sign_on_service, single_logout_service, valid_days + ) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'saml_config': saml_comfig.to_dict()}) + + def put(self, request, org_id): + # argument check + metadata_url = request.data.get('metadata_url', None) + single_sign_on_service = request.data.get('single_sign_on_service', None) + single_logout_service = request.data.get('single_logout_service', None) + valid_days = request.data.get('valid_days', None) + if not metadata_url and not single_sign_on_service and not single_logout_service and not valid_days: + return api_error(status.HTTP_400_BAD_REQUEST, 'argument invalid.') + + # resource check + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # update config + try: + saml_comfig = OrgSAMLConfig.objects.add_or_update_saml_config( + org_id, metadata_url, single_sign_on_service, single_logout_service, valid_days + ) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'saml_config': saml_comfig.to_dict()}) + + def delete(self, request, org_id): + # resource check + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # delete saml config + try: + OrgSAMLConfig.objects.filter(org_id=org_id).delete() + except OrgSAMLConfig.DoesNotExist: + return Response({'success': True}) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + +class OrgUrlPrefixView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + # resource check + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + org_url_prefix = org.url_prefix + return Response({'org_url_prefix': org_url_prefix}) + + def put(self, request, org_id): + # argument check + org_url_prefix = request.data.get('org_url_prefix', None) + if not org_url_prefix: + error_msg = 'org_url_prefix invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + reg = re.match(r'^[a-z0-9-]{6,20}$', org_url_prefix) + if not reg: + error_msg = _('org_url_prefix should be 6 to 20 characters, and can only contain alphanumeric characters and hyphens.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if ccnet_api.get_org_by_url_prefix(org_url_prefix) is not None: + error_msg = 'url_prefix %s is duplicated.' % org_url_prefix + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # get ccnet db_name + db_name, error_msg = get_ccnet_db_name() + if error_msg: + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + update_org_url_prefix(db_name, org_id, org_url_prefix) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'org_url_prefix': org_url_prefix}) diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py index 72036f9235..2b07aef7c5 100644 --- a/seahub/organizations/api_urls.py +++ b/seahub/organizations/api_urls.py @@ -25,6 +25,9 @@ from .api.admin.devices import OrgAdminDevices, OrgAdminDevicesErrors from .api.admin.statistics import OrgFileOperationsView, OrgTotalStorageView, \ OrgActiveUsersView, OrgSystemTrafficView, OrgUserTrafficView, \ OrgUserTrafficExcelView, OrgUserStorageExcelView +from .api.admin.saml_config import OrgUploadIdPCertificateView, OrgUploadIdPMetadataXMLView, OrgSAMLConfigView, \ + OrgUrlPrefixView + urlpatterns = [ url(r'^(?P\d+)/admin/statistics/file-operations/$', @@ -49,6 +52,19 @@ urlpatterns = [ OrgUserStorageExcelView.as_view(), name='api-v2.1-org-admin-statistics-user-storage-excel'), + url(r'^(?P\d+)/admin/saml-idp-certificate/$', + OrgUploadIdPCertificateView.as_view(), + name='api-v2.1-org-admin-saml-idp-certificate'), + url(r'^(?P\d+)/admin/saml-idp-metadata-xml/$', + OrgUploadIdPMetadataXMLView.as_view(), + name='api-v2.1-org-admin-saml-idp-metadata-xml'), + url(r'^(?P\d+)/admin/saml-config/$', + OrgSAMLConfigView.as_view(), + name='api-v2.1-org-admin-saml-config'), + url(r'^(?P\d+)/admin/url-prefix/$', + OrgUrlPrefixView.as_view(), + name='api-v2.1-org-admin-url-prefix'), + url(r'^(?P\d+)/admin/devices/$', OrgAdminDevices.as_view(), name='api-v2.1-org-admin-devices'), url(r'^(?P\d+)/admin/devices-errors/$', OrgAdminDevicesErrors.as_view(), name='api-v2.1-org-admin-devices-errors'), diff --git a/seahub/organizations/migrations/0004_orgsamlconfig.py b/seahub/organizations/migrations/0004_orgsamlconfig.py new file mode 100644 index 0000000000..e58a123468 --- /dev/null +++ b/seahub/organizations/migrations/0004_orgsamlconfig.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.14 on 2022-12-08 12:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0003_auto_20190116_0323'), + ] + + operations = [ + migrations.CreateModel( + name='OrgSAMLConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('org_id', models.IntegerField(unique=True)), + ('metadata_url', models.TextField()), + ('single_sign_on_service', models.TextField()), + ('single_logout_service', models.TextField()), + ('valid_days', models.IntegerField()), + ], + options={ + 'db_table': 'org_saml_config', + }, + ), + ] diff --git a/seahub/organizations/models.py b/seahub/organizations/models.py index 2d9deb4ee6..ff082b4bb1 100644 --- a/seahub/organizations/models.py +++ b/seahub/organizations/models.py @@ -25,6 +25,7 @@ class OrgMemberQuotaManager(models.Manager): q.save(using=self._db) return q + class OrgMemberQuota(models.Model): org_id = models.IntegerField(db_index=True) quota = models.IntegerField() @@ -50,7 +51,7 @@ class OrgSettingsManager(models.Manager): if role in get_available_roles(): return role else: - logger.warn('Role %s is not valid' % role) + logger.warning('Role %s is not valid' % role) return DEFAULT_ORG def add_or_update(self, org, role=None): @@ -64,7 +65,7 @@ class OrgSettingsManager(models.Manager): if role in get_available_roles(): settings.role = role else: - logger.warn('Role %s is not valid' % role) + logger.warning('Role %s is not valid' % role) settings.save(using=self._db) return settings @@ -75,3 +76,57 @@ class OrgSettings(models.Model): role = models.CharField(max_length=100, null=True, blank=True) objects = OrgSettingsManager() + + +class OrgSAMLConfigManager(models.Manager): + + def add_or_update_saml_config( + self, org_id, metadata_url, single_sign_on_service, + single_logout_service, valid_days + ): + try: + saml_config = self.get(org_id=org_id) + except OrgSAMLConfig.DoesNotExist: + saml_config = self.model(org_id=org_id) + + if metadata_url: + saml_config.metadata_url = metadata_url + if single_sign_on_service: + saml_config.single_sign_on_service = single_sign_on_service + if single_logout_service: + saml_config.single_logout_service = single_logout_service + if valid_days: + saml_config.valid_days = valid_days + + saml_config.save(using=self._db) + return saml_config + + def get_config_by_org_id(self, org_id): + try: + config = self.get(org_id=org_id) + return config + except OrgSAMLConfig.DoesNotExist: + return None + + +class OrgSAMLConfig(models.Model): + org_id = models.IntegerField(unique=True) + metadata_url = models.TextField() + single_sign_on_service = models.TextField() + single_logout_service = models.TextField() + valid_days = models.IntegerField() + + objects = OrgSAMLConfigManager() + + class Meta: + db_table = 'org_saml_config' + + def to_dict(self): + return { + 'id': self.pk, + 'org_id': self.org_id, + 'metadata_url': self.metadata_url, + 'single_sign_on_service': self.single_sign_on_service, + 'single_logout_service': self.single_logout_service, + 'valid_days': self.valid_days, + } diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py index 2defabd852..9a6d080ca6 100644 --- a/seahub/organizations/urls.py +++ b/seahub/organizations/urls.py @@ -38,4 +38,6 @@ urlpatterns = [ url(r'^departmentadmin/$', react_fake_view, name='org_department_admin'), url(r'^departmentadmin/groups/(?P\d+)/', react_fake_view, name='org_department_admin'), url(r'^associate/(?P.+)/$', org_associate, name='org_associate'), + + url(r'^samlconfig/$', react_fake_view, name='saml_config'), ] diff --git a/seahub/organizations/utils.py b/seahub/organizations/utils.py index b438a8843b..195a50b21e 100644 --- a/seahub/organizations/utils.py +++ b/seahub/organizations/utils.py @@ -1,9 +1,37 @@ # Copyright (c) 2012-2016 Seafile Ltd. +import os +import configparser + +from django.db import connection from django.core.cache import cache from django.urls import reverse from seahub.utils import gen_token, get_service_url + +def get_ccnet_db_name(): + if 'CCNET_CONF_DIR' not in os.environ: + error_msg = 'Environment variable CCNET_CONF_DIR is not define.' + return None, error_msg + + ccnet_conf_path = os.path.join(os.environ['CCNET_CONF_DIR'], 'ccnet.conf') + config = configparser.ConfigParser() + config.read(ccnet_conf_path) + + if config.has_section('Database'): + db_name = config.get('Database', 'DB', fallback='ccnet') + else: + db_name = 'ccnet' + + return db_name, None + + +def update_org_url_prefix(db_name, org_id, url_prefix): + sql = """UPDATE %s.Organization SET url_prefix=%%s WHERE org_id=%%s""" % db_name + with connection.cursor() as cursor: + cursor.execute(sql, (url_prefix, org_id)) + + def get_or_create_invitation_link(org_id): """Invitation link for an org. Users will be redirected to WeChat QR page. Mainly used in docs.seafile.com. diff --git a/seahub/settings.py b/seahub/settings.py index 795ebcd46a..57de74b85b 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -295,6 +295,8 @@ ENABLE_CAS = False ENABLE_ADFS_LOGIN = False +ENABLE_MULTI_ADFS = False + ENABLE_OAUTH = False ENABLE_WATERMARK = False @@ -948,7 +950,7 @@ if ENABLE_OAUTH or ENABLE_WORK_WEIXIN or ENABLE_WEIXIN or ENABLE_DINGTALK: if ENABLE_CAS: AUTHENTICATION_BACKENDS += ('seahub.django_cas_ng.backends.CASBackend',) -if ENABLE_ADFS_LOGIN: +if ENABLE_ADFS_LOGIN or ENABLE_MULTI_ADFS: AUTHENTICATION_BACKENDS += ('seahub.adfs_auth.backends.Saml2Backend',) ##################### diff --git a/seahub/urls.py b/seahub/urls.py index 0a7de016a7..0585e46736 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -868,6 +868,13 @@ if HAS_OFFICE_CONVERTER: url(r'^office-convert/status/$', office_convert_query_status, name='office_convert_query_status'), ] +if getattr(settings, 'ENABLE_MULTI_ADFS', False): + from seahub.adfs_auth.views import auth_complete + urlpatterns += [ + url(r'^org/custom/[a-z_0-9-]+/', include(('seahub.adfs_auth.urls', 'adfs_auth'), namespace='adfs_auth')), + url(r'^saml2/complete/$', auth_complete, name='org_saml2_complete'), + ] + if getattr(settings, 'ENABLE_ADFS_LOGIN', False): from seahub.adfs_auth.views import assertion_consumer_service, \ auth_complete