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) &&
+
+
+
+
+
+
+
+
+
+ {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