diff --git a/frontend/src/components/user-settings/social-login-saml.js b/frontend/src/components/user-settings/social-login-saml.js new file mode 100644 index 0000000000..fa9069872a --- /dev/null +++ b/frontend/src/components/user-settings/social-login-saml.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext, siteRoot } from '../../utils/constants'; +import ModalPortal from '../modal-portal'; + +const { + csrfToken, + isOrgContext, + orgID, + samlConnected, + enableMultiADFS, + orgSamlConnected, + socialNextPage +} = window.app.pageOptions; + +class SocialLoginSAML extends React.Component { + + constructor(props) { + super(props); + this.form = React.createRef(); + this.state = { + isConfirmDialogOpen: false + }; + } + + confirmDisconnect = () => { + this.setState({ + isConfirmDialogOpen: true + }); + }; + + disconnect = () => { + this.form.current.submit(); + }; + + toggleDialog = () => { + this.setState({ + isConfirmDialogOpen: !this.state.isConfirmDialogOpen + }); + }; + + render() { + let connectUrl = (enableMultiADFS && isOrgContext) ? `${siteRoot}org/custom/${orgID}/saml2/connect/?next=${encodeURIComponent(socialNextPage)}` : `${siteRoot}saml2/connect/?next=${encodeURIComponent(socialNextPage)}`; + let disconnectUrl = (orgSamlConnected && isOrgContext) ? `${siteRoot}org/custom/${orgID}/saml2/disconnect/?next=${encodeURIComponent(socialNextPage)}` : `${siteRoot}saml2/disconnect/?next=${encodeURIComponent(socialNextPage)}`; + + return ( + +
+

{gettext('Social Login')}

+

{'SAML'}

+ {(samlConnected || (orgSamlConnected && isOrgContext)) ? + : + {gettext('Connect')} + } +
+ {this.state.isConfirmDialogOpen && ( + + + {gettext('Disconnect')} + +

{gettext('Are you sure you want to disconnect?')}

+
+ +
+
+ + + + +
+
+ )} +
+ ); + } +} + +export default SocialLoginSAML; diff --git a/frontend/src/pages/org-admin/file-item.js b/frontend/src/pages/org-admin/file-item.js deleted file mode 100644 index 38208fcc02..0000000000 --- a/frontend/src/pages/org-admin/file-item.js +++ /dev/null @@ -1,50 +0,0 @@ -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 a34bf3c62e..eb42e5cb51 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -1,7 +1,7 @@ import React from 'react'; import ReactDom from 'react-dom'; import { Router } from '@gatsbyjs/reach-router'; -import { siteRoot } from '../../utils/constants'; +import { siteRoot, enableMultiADFS } from '../../utils/constants'; import SidePanel from './side-panel'; import OrgStatisticFile from './statistic/statistic-file'; @@ -117,7 +117,9 @@ class Org extends React.Component { - + {enableMultiADFS && + + } diff --git a/frontend/src/pages/org-admin/input-item.js b/frontend/src/pages/org-admin/input-item.js index c46c25af7f..90e617f772 100644 --- a/frontend/src/pages/org-admin/input-item.js +++ b/frontend/src/pages/org-admin/input-item.js @@ -6,6 +6,8 @@ import { gettext } from '../../utils/constants'; const propTypes = { value: PropTypes.string, domainVerified: PropTypes.bool, + isCertificate: PropTypes.bool, + changeType: PropTypes.string.isRequired, changeValue: PropTypes.func.isRequired, displayName: PropTypes.string.isRequired, }; @@ -43,9 +45,10 @@ class OrgSamlConfigInput extends Component { }; onSubmit = () => { + const changeType = this.props.changeType; const value = this.state.value.trim(); if (value != this.props.value) { - this.props.changeValue(value); + this.props.changeValue(changeType, value); } this.toggleBtns(); }; @@ -53,6 +56,8 @@ class OrgSamlConfigInput extends Component { render() { const { isBtnsShown, value } = this.state; const { displayName } = this.props; + let inputType = this.props.isCertificate ? 'textarea' : 'text'; + return ( @@ -61,13 +66,24 @@ class OrgSamlConfigInput extends Component { - + {this.props.domainVerified && } + {this.props.isCertificate && +

+ {gettext('Copy the IdP\'s certificate and paste it here. The certificate format is as follows:')} +
+ -----BEGIN CERTIFICATE----- +
+ xxxxxxxxxxxxxxxxxxxx +
+ -----END CERTIFICATE----- +

+ } {isBtnsShown && diff --git a/frontend/src/pages/org-admin/org-saml-config.js b/frontend/src/pages/org-admin/org-saml-config.js index b96cfb8af9..cb58279edb 100644 --- a/frontend/src/pages/org-admin/org-saml-config.js +++ b/frontend/src/pages/org-admin/org-saml-config.js @@ -8,8 +8,7 @@ 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'; +import OrgSamlConfigInput from './input-item'; class OrgSAMLConfig extends Component { @@ -19,60 +18,24 @@ class OrgSAMLConfig extends Component { loading: true, errorMsg: '', samlConfigID: '', - newUrlPrefix: '', - orgUrlPrefix: '', metadataUrl: '', domain: '', - dns_txt: '', - domain_verified: false, - isBtnsShown: false, + dnsTxt: '', + domainVerified: false, + idpCertificate: '', }; } - 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}); - }; - componentDidMount() { - seafileAPI.orgAdminGetUrlPrefix(orgID).then((res) => { + seafileAPI.orgAdminGetSamlConfig(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 || '', - domain: res.data.saml_config.domain || '', - dns_txt: res.data.saml_config.dns_txt || '', - domain_verified: res.data.saml_config.domain_verified || false, - }); - }).catch(error => { - this.setState({ - loading: false, - errorMsg: Utils.getErrorMsg(error, true), - }); + loading: false, + samlConfigID: res.data.saml_config.id, + metadataUrl: res.data.saml_config.metadata_url || '', + domain: res.data.saml_config.domain || '', + dnsTxt: res.data.saml_config.dns_txt || '', + domainVerified: res.data.saml_config.domain_verified || false, + idpCertificate: res.data.saml_config.idp_certificate || '', }); }).catch(error => { this.setState({ @@ -82,69 +45,22 @@ class OrgSAMLConfig extends Component { }); } - 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); - }); - }; + updateSamlConfig = (changeType, value) => { + let metadataUrl = null; + let domain = null; + let idpCertificate = null; + if (changeType === 'metadataUrl') metadataUrl = value; + if (changeType === 'domain') domain = value; + if (changeType === 'idpCertificate') idpCertificate = value; - postIdpCertificate = (file) => { - seafileAPI.orgAdminUploadIdpCertificate(orgID, file).then(() => { - toaster.success(gettext('Success')); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - }; - - updateSamlMetadataUrl = (metadataUrl) => { - seafileAPI.orgAdminUpdateSamlMetadataUrl(orgID, metadataUrl).then((res) => { + seafileAPI.orgAdminUpdateSamlConfig(orgID, metadataUrl, domain, idpCertificate).then((res) => { this.setState({ samlConfigID: res.data.saml_config.id, - metadataUrl: res.data.saml_config.metadata_url || '', + metadataUrl: res.data.saml_config.metadata_url, domain: res.data.saml_config.domain || '', - dns_txt: res.data.saml_config.dns_txt || '', - domain_verified: res.data.saml_config.domain_verified || false, - }); - toaster.success(gettext('Success')); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - }; - - updateSamlDomain = (domain) => { - seafileAPI.orgAdminUpdateSamlDomain(orgID, domain).then((res) => { - this.setState({ - samlConfigID: res.data.saml_config.id, - metadataUrl: res.data.saml_config.metadata_url || '', - domain: res.data.saml_config.domain || '', - dns_txt: res.data.saml_config.dns_txt || '', - domain_verified: res.data.saml_config.domain_verified || false, - }); - toaster.success(gettext('Success')); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - }; - - deleteSamlConfig = () => { - seafileAPI.orgAdminDeleteSamlConfig(orgID).then(() => { - this.setState({ - samlConfigID: '', - metadataUrl: '', - domain: '', - dns_txt: '', - domain_verified: false, + dnsTxt: res.data.saml_config.dns_txt || '', + domainVerified: res.data.saml_config.domain_verified || false, + idpCertificate: res.data.saml_config.idp_certificate || '', }); toaster.success(gettext('Success')); }).catch((error) => { @@ -156,7 +72,7 @@ class OrgSAMLConfig extends Component { verifyDomain = () => { const {domain} = this.state; seafileAPI.orgAdminVerifyDomain(orgID, domain).then((res) => { - this.setState({domain_verified: res.data.domain_verified}); + this.setState({domainVerified: res.data.domain_verified}); toaster.success(gettext('Success')); }).catch((error) => { let errMessage = Utils.getErrorMsg(error); @@ -164,24 +80,16 @@ class OrgSAMLConfig extends Component { }); }; - generateDnsTxt = () => { - seafileAPI.orgAdminCreateDnsTxt(orgID).then((res) => { - this.setState({dns_txt: res.data.dns_txt}); - toaster.success(gettext('Success')); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - }; - - onCopyDnsTxt = () => { - const {dns_txt} = this.state; - copy(dns_txt); - toaster.success(gettext('DNS TXT is copied to the clipboard.')); + onCopyValue = (value) => { + copy(value); + toaster.success(gettext('Copied')); }; render() { - const { loading, errorMsg, newUrlPrefix, metadataUrl, domain, dns_txt, domain_verified, isBtnsShown } = this.state; + const { loading, errorMsg, metadataUrl, domain, dnsTxt, domainVerified, idpCertificate } = this.state; + let entityID = `${serviceURL}/org/custom/${orgID}/saml2/metadata/`; + let acsURL = `${serviceURL}/org/custom/${orgID}/saml2/acs/`; + let logoutURL = `${serviceURL}/org/custom/${orgID}/saml2/ls/`; return ( @@ -196,41 +104,96 @@ class OrgSAMLConfig extends Component { {errorMsg &&

{errorMsg}

} {(!loading && !errorMsg) && -
+
+

{gettext('Use these values to configure your Identity Provider')}

- + - {`${serviceURL}/org/custom/`} -

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

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {isBtnsShown && - - - - - }
-
+
+

{gettext('Use information from your Identity Provider to configure Seafile')}

- - + +
+ +
+

{gettext('Create a DNS TXT record to confirm the ownership of your Email Domain.')}

+ + @@ -239,42 +202,27 @@ class OrgSAMLConfig extends Component { - - {(dns_txt && !domain_verified) && + + {(dnsTxt && !domainVerified) && - + } - {(!dns_txt && !domain_verified) && + {(dnsTxt && !domainVerified) &&

- {gettext('Generate a domain DNS TXT, copy it and add it to your domain\'s DNS records, then click the button to verify domain ownership.')} -

- } - {(dns_txt && !domain_verified) && -

- {gettext('You must verify domain ownership before Single Sign-On.')} + {gettext('Copy the domain DNS TXT and add it to your domain\'s DNS records, then click the button to verify domain ownership. You must verify the ownership of domain before Single Sign-On.')}

} - {(!dns_txt && !domain_verified) && - - } - {(dns_txt && !domain_verified) && + {(domain && dnsTxt && !domainVerified) && }
- -
- -
} diff --git a/frontend/src/pages/sys-admin/orgs/org-info.js b/frontend/src/pages/sys-admin/orgs/org-info.js index 3b8de80ded..238820b40a 100644 --- a/frontend/src/pages/sys-admin/orgs/org-info.js +++ b/frontend/src/pages/sys-admin/orgs/org-info.js @@ -52,7 +52,7 @@ class Content extends Component { } else if (errorMsg) { return

{errorMsg}

; } else { - const { org_name, users_count, max_user_number, groups_count, quota, quota_usage, enable_saml_login, url_prefix, metadata_url, domain } = this.props.orgInfo; + const { org_name, users_count, max_user_number, groups_count, quota, quota_usage, enable_saml_login, metadata_url, domain } = this.props.orgInfo; const { isSetQuotaDialogOpen, isSetNameDialogOpen, isSetMaxUserNumberDialogOpen } = this.state; return ( @@ -89,19 +89,25 @@ class Content extends Component {
{gettext('SAML Config')}
- {gettext('Custom SAML Login URL')} - {`${serviceURL}/org/custom/${url_prefix}`} + Identifier (Entity ID) + {`${serviceURL}/org/custom/${this.props.orgID}/saml2/metadata/`}
- {gettext('App Federation Metadata URL')} + Reply URL (Assertion Consumer Service URL) + {`${serviceURL}/org/custom/${this.props.orgID}/saml2/acs/`} + +
+
+ + SAML App Federation Metadata URL {metadata_url}
- {gettext('Email Domain')} + {gettext('Email Domain')} {domain}
@@ -141,6 +147,7 @@ Content.propTypes = { getDeviceErrorsListByPage: PropTypes.func, resetPerPage: PropTypes.func, curPerPage: PropTypes.number, + orgID: PropTypes.string, orgInfo: PropTypes.object, updateQuota: PropTypes.func.isRequired, updateName: PropTypes.func.isRequired, diff --git a/frontend/src/settings.js b/frontend/src/settings.js index f31bdec7b1..6b26827cfc 100644 --- a/frontend/src/settings.js +++ b/frontend/src/settings.js @@ -17,6 +17,7 @@ import EmailNotice from './components/user-settings/email-notice'; import TwoFactorAuthentication from './components/user-settings/two-factor-auth'; import SocialLogin from './components/user-settings/social-login'; import SocialLoginDingtalk from './components/user-settings/social-login-dingtalk'; +import SocialLoginSAML from './components/user-settings/social-login-saml'; import DeleteAccount from './components/user-settings/delete-account'; import './css/toolbar.css'; @@ -32,6 +33,9 @@ const { twoFactorAuthEnabled, enableWechatWork, enableDingtalk, + isOrgContext, + enableADFS, + enableMultiADFS, enableDeleteAccount } = window.app.pageOptions; @@ -48,8 +52,7 @@ class Settings extends React.Component { {show: true, href: '#lang-setting', text: gettext('Language')}, {show: isPro, href: '#email-notice', text: gettext('Email Notification')}, {show: twoFactorAuthEnabled, href: '#two-factor-auth', text: gettext('Two-Factor Authentication')}, - {show: enableWechatWork, href: '#social-auth', text: gettext('Social Login')}, - {show: enableDingtalk, href: '#social-auth', text: gettext('Social Login')}, + {show: (enableWechatWork || enableDingtalk || enableADFS || (enableMultiADFS || isOrgContext)), href: '#social-auth', text: gettext('Social Login')}, {show: enableDeleteAccount, href: '#del-account', text: gettext('Delete Account')}, ]; @@ -142,6 +145,7 @@ class Settings extends React.Component { {twoFactorAuthEnabled && } {enableWechatWork && } {enableDingtalk && } + {(enableADFS || (enableMultiADFS && isOrgContext)) && } {enableDeleteAccount && } diff --git a/scripts/migrate_idp_certificates.py b/scripts/migrate_idp_certificates.py new file mode 100644 index 0000000000..00f86b9364 --- /dev/null +++ b/scripts/migrate_idp_certificates.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +import os +import sys + +import pymysql +pymysql.install_as_MySQLdb() + +install_path = os.path.dirname(os.path.abspath(__file__)) +top_dir = os.path.dirname(install_path) +central_config_dir = os.path.join(top_dir, 'conf') +sys.path.insert(0, central_config_dir) + +try: + from seahub_settings import DATABASES +except ImportError: + raise RuntimeError("Can not import seahub settings.") + +try: + from seahub_settings import SAML_CERTS_DIR +except ImportError: + SAML_CERTS_DIR = '/opt/seafile/seahub-data/certs' + + +def init_db_connect(): + try: + db_conf = DATABASES['default'] + except KeyError: + raise RuntimeError('Failed to init seahub db, can not find db info in seahub settings.') + + if db_conf.get('ENGINE') != 'django.db.backends.mysql': + raise RuntimeError('Failed to init seahub db, only mysql db supported.') + + db_name = db_conf.get('NAME') + if not db_name: + raise RuntimeError('Failed to init seahub db, db name is not setted.') + + db_user = db_conf.get('USER') + if not db_user: + raise RuntimeError('Failed to init seahub db, db user is not setted.') + + db_passwd = db_conf.get('PASSWORD') + db_host = db_conf.get('HOST', '127.0.0.1') + db_port = int(db_conf.get('PORT', '3306')) + + try: + conn = pymysql.connect(host=db_host, port=db_port, user=db_user, + passwd=db_passwd, db=db_name, charset='utf8') + conn.autocommit(True) + cursor = conn.cursor() + return conn, cursor + except Exception as e: + raise Exception('Failed to init seahub db: %s.' % e) + + +def main(): + if not SAML_CERTS_DIR: + raise RuntimeError('SAML_CERTS_DIR is not set.') + + conn, cursor = init_db_connect() + query_sql = 'SELECT `org_id` FROM `org_saml_config`' + try: + cursor.execute(query_sql) + res = cursor.fetchall() + except Exception as e: + raise Exception('Failed to query org_id_list from org_saml_config: %s' % e) + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + org_id_list = list() + for org_id, *_ in res: + org_id_list.append(org_id) + + print('Start to migrate idp_certificates to database') + conn, cursor = init_db_connect() + try: + for org_id in org_id_list: + org_certs_dir = os.path.join(SAML_CERTS_DIR, str(org_id)) + cert_file_path = os.path.join(org_certs_dir, 'idp.crt') + if os.path.exists(org_certs_dir) and os.path.exists(cert_file_path): + with open(cert_file_path, 'r') as f: + idp_certificate = f.read() + + sql = 'UPDATE `org_saml_config` SET idp_certificate=%s WHERE org_id=%s' + cursor.execute(sql, (idp_certificate, org_id)) + except Exception as e: + raise Exception('Failed to migrate idp_certificate to database: %s' % e) + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + print('Successfully migrated idp_certificates to database.') + + +if __name__ == '__main__': + main() diff --git a/seahub/adfs_auth/backends.py b/seahub/adfs_auth/backends.py index 2bd8c9c8ca..adc5a4407c 100644 --- a/seahub/adfs_auth/backends.py +++ b/seahub/adfs_auth/backends.py @@ -33,7 +33,7 @@ from registration.models import notify_admins_on_activate_request, notify_admins logger = logging.getLogger(__name__) SAML_PROVIDER_IDENTIFIER = getattr(settings, 'SAML_PROVIDER_IDENTIFIER', 'saml') -SHIBBOLETH_AFFILIATION_ROLE_MAP = getattr(settings, 'SHIBBOLETH_AFFILIATION_ROLE_MAP', False) +SHIBBOLETH_AFFILIATION_ROLE_MAP = getattr(settings, 'SHIBBOLETH_AFFILIATION_ROLE_MAP', {}) class Saml2Backend(ModelBackend): @@ -44,7 +44,7 @@ class Saml2Backend(ModelBackend): user = None return user - def authenticate(self, session_info=None, attribute_mapping=None, create_unknown_user=True, **kwargs): + def authenticate(self, session_info=None, attribute_mapping=None, create_unknown_user=True, org_id=None, **kwargs): if session_info is None or attribute_mapping is None: logger.error('Session info or attribute mapping are None') return None @@ -88,12 +88,8 @@ class Saml2Backend(ModelBackend): return None # 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, user.username, 0) + if org_id and org_id > 0: + ccnet_api.add_org_user(org_id, user.username, 0) if not activate_after_creation: notify_admins_on_activate_request(user.username) diff --git a/seahub/adfs_auth/utils.py b/seahub/adfs_auth/utils.py index 0051c8dd23..f2c957bbd8 100644 --- a/seahub/adfs_auth/utils.py +++ b/seahub/adfs_auth/utils.py @@ -53,19 +53,17 @@ def settings_check(func): @settings_check def config_settings_loader(request): - # get url_prefix - url_prefix = '' - reg = re.search(r'org/custom/([a-z_0-9-]+)', request.path) - if reg: - url_prefix = reg.group(1) - # get org_id - org_id = -1 - org = ccnet_api.get_org_by_url_prefix(url_prefix) - if org: - org_id = org.org_id + org_id = None + reg = re.search(r'org/custom/(\d+)/saml2/', request.path) + if reg: + org_id = int(reg.group(1)) + + if org_id and org_id > 0: + org = ccnet_api.get_org_by_id(org_id) + if not org: + raise Exception('Cannot find an organization related to org_id %s.' % org_id) - if org_id != -1: org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) if not org_saml_config: raise Exception('Failed to get org %s saml_config' % org_id) @@ -73,7 +71,7 @@ def config_settings_loader(request): # get org remote_metadata_url remote_metadata_url = org_saml_config.metadata_url # get org sp_service_url - sp_service_url = get_service_url().rstrip('/') + '/org/custom/' + url_prefix + sp_service_url = get_service_url().rstrip('/') + '/org/custom/' + str(org_id) else: # get remote_metadata_url remote_metadata_url = REMOTE_METADATA_URL diff --git a/seahub/adfs_auth/views.py b/seahub/adfs_auth/views.py index 581a7f98a6..c2d0802fe9 100644 --- a/seahub/adfs_auth/views.py +++ b/seahub/adfs_auth/views.py @@ -13,12 +13,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -import re import logging +from urllib.parse import unquote, parse_qs, urlparse from django.urls import reverse -from django.http import HttpResponseRedirect, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.http import HttpResponseRedirect, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \ + HttpResponsePermanentRedirect from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_POST from django.views.decorators.csrf import csrf_exempt @@ -29,22 +29,29 @@ from saml2.metadata import entity_descriptor from djangosaml2.cache import IdentityCache, OutstandingQueriesCache from djangosaml2.conf import get_config from djangosaml2.signals import post_authenticated -from djangosaml2.utils import get_custom_setting -from seaserv import ccnet_api +from seaserv import ccnet_api, seafile_api from seahub import auth from seahub.auth import login as auth_login from seahub.auth.decorators import login_required from seahub import settings +from seahub.base.accounts import User +from seahub.auth.models import SocialAuthUser +from seahub.profile.models import Profile, DetailedProfile +from seahub.utils.licenseparse import user_number_over_limit +from seahub.utils.file_size import get_quota_from_string +from seahub.role_permissions.utils import get_enabled_role_permissions_by_role # Added by khorkin from seahub.base.sudo_mode import update_sudo_mode_ts -from seahub.utils.licenseparse import user_number_over_limit try: from seahub.settings import ORG_MEMBER_QUOTA_ENABLED except ImportError: ORG_MEMBER_QUOTA_ENABLED = False +SAML_PROVIDER_IDENTIFIER = getattr(settings, 'SAML_PROVIDER_IDENTIFIER', 'saml') +SAML_ATTRIBUTE_MAPPING = getattr(settings, 'SAML_ATTRIBUTE_MAPPING', {}) + logger = logging.getLogger('djangosaml2') @@ -53,7 +60,64 @@ def _set_subject_id(session, subject_id): session['_saml2_subject_id'] = code(subject_id) -def login(request): +def update_user_profile(user, attribute_mapping, attributes): + parse_result = {} + for saml_attr, django_attrs in list(attribute_mapping.items()): + try: + for attr in django_attrs: + parse_result[attr] = attributes[saml_attr][0] + except KeyError: + pass + + display_name = parse_result.get('display_name', '') + contact_email = parse_result.get('contact_email', '') + telephone = parse_result.get('telephone', '') + department = parse_result.get('department', '') + + # update profile + p = Profile.objects.get_profile_by_user(user.username) + if not p: + p = Profile.objects.add_or_update(user.username, '') + + if display_name: + p.nickname = display_name + if contact_email: + p.contact_email = contact_email + + p.save() + + # update detail_profile + d_p = DetailedProfile.objects.get_detailed_profile_by_user(user.username) + if not d_p: + d_p = DetailedProfile.objects.add_detailed_profile(user.username, '', '') + + if department: + d_p.department = department + if telephone: + d_p.telephone = telephone + + d_p.save() + + # update user role + role = parse_result.get('role', '') + if role: + User.objects.update_role(user.username, role) + + # update user role quota + role_quota = get_enabled_role_permissions_by_role(role)['role_quota'] + if role_quota: + quota = get_quota_from_string(role_quota) + seafile_api.set_role_quota(role, quota) + + +def login(request, org_id=None): + if org_id and int(org_id) > 0: + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + logger.error('Cannot find an organization related to org_id %s.' % org_id) + return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id) + next_url = settings.LOGIN_REDIRECT_URL if 'next' in request.GET: next_url = request.GET['next'] @@ -87,7 +151,7 @@ def login(request): @require_POST @csrf_exempt -def assertion_consumer_service(request, attribute_mapping=None, create_unknown_user=True): +def assertion_consumer_service(request, org_id=None, attribute_mapping=None, create_unknown_user=True): """SAML Authorization Response endpoint. The IdP will send its response to this view, which will process it using pysaml2 and log the user in using whatever SAML authentication backend has been enabled in @@ -96,7 +160,16 @@ def assertion_consumer_service(request, attribute_mapping=None, create_unknown_u """ if 'SAMLResponse' not in request.POST: return HttpResponseBadRequest('Missing "SAMLResponse" parameter in POST data.') - attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', None) + + org = None + if org_id and int(org_id) > 0: + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + logger.error('Cannot find an organization related to org_id %s.' % org_id) + return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id) + else: + org_id = -1 try: conf = get_config(None, request) @@ -123,35 +196,63 @@ def assertion_consumer_service(request, attribute_mapping=None, create_unknown_u session_id = response.session_id() oq_cache.delete(session_id) - - # authenticate the remote user session_info = response.session_info() + attribute_mapping = attribute_mapping or SAML_ATTRIBUTE_MAPPING + + # saml2 connect + relay_state = request.POST.get('RelayState', '/saml/complete/') + is_saml2_connect = parse_qs(urlparse(unquote(relay_state)).query).get('is_saml2_connect', [''])[0] + if is_saml2_connect == 'true': + if not request.user.is_authenticated: + return HttpResponseBadRequest('Failed to bind SAML, please login first.') + + # get uid and other attrs from session_info + name_id = session_info.get('name_id', '') + if not name_id: + logger.error('The name_id is not available. Could not determine user identifier.') + return HttpResponseBadRequest('Failed to bind SAML, please contact admin.') + + name_id = name_id.text + saml_user = SocialAuthUser.objects.get_by_provider_and_uid(SAML_PROVIDER_IDENTIFIER, name_id) + if saml_user: + return HttpResponseBadRequest('The SAML user has already been bound to another account.') + + # bind saml user + username = request.user.username + SocialAuthUser.objects.add(username, SAML_PROVIDER_IDENTIFIER, name_id) + + # update user's profile + attributes = session_info.get('ava', {}) + if attributes: + try: + update_user_profile(request.user, attribute_mapping, attributes) + except Exception as e: + logger.warning('Failed to update user\'s profile, error: %s' % e) + + # set subject_id, saml single logout need this + _set_subject_id(request.saml_session, session_info['name_id']) + + return HttpResponseRedirect(relay_state) # check user number limit by license if user_number_over_limit(): return HttpResponseForbidden('The number of users exceeds the license limit.') - # get url_prefix - url_prefix = '' - reg = re.search(r'org/custom/([a-z_0-9-]+)', request.path) - if reg: - url_prefix = reg.group(1) - org = ccnet_api.get_org_by_url_prefix(url_prefix) - if org: - # check user number limit by org member quota - org_id = org.org_id - org_members = len(ccnet_api.get_org_emailusers(org.url_prefix, -1, -1)) - if ORG_MEMBER_QUOTA_ENABLED: - from seahub.organizations.models import OrgMemberQuota - org_members_quota = OrgMemberQuota.objects.get_quota(org_id) - if org_members_quota is not None and org_members >= org_members_quota: - return HttpResponseForbidden('The number of users exceeds the organization quota.') + # check user number limit by org member quota + if org: + org_members = len(ccnet_api.get_org_emailusers(org.url_prefix, -1, -1)) + if ORG_MEMBER_QUOTA_ENABLED: + from seahub.organizations.models import OrgMemberQuota + org_members_quota = OrgMemberQuota.objects.get_quota(org_id) + if org_members_quota is not None and org_members >= org_members_quota: + return HttpResponseForbidden('The number of users exceeds the organization quota.') + # authenticate the remote user logger.debug('Trying to authenticate the user') user = auth.authenticate(session_info=session_info, attribute_mapping=attribute_mapping, create_unknown_user=create_unknown_user, - url_prefix=url_prefix) + org_id=org_id) if user is None: logger.error('The user is None') return HttpResponseForbidden("Permission denied") @@ -175,7 +276,14 @@ def assertion_consumer_service(request, attribute_mapping=None, create_unknown_u return HttpResponseRedirect(relay_state) -def metadata(request): +def metadata(request, org_id=None): + if org_id and int(org_id) > 0: + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + logger.error('Cannot find an organization related to org_id %s.' % org_id) + return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id) + try: sp_config = get_config(None, request) except Exception as e: @@ -188,6 +296,75 @@ def metadata(request): ) +@login_required +def saml2_connect(request, org_id=None): + if org_id and int(org_id) > 0: + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + logger.error('Cannot find an organization related to org_id %s.' % org_id) + return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id) + + if request.user.org.org_id != org_id: + logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_id)) + return HttpResponseBadRequest('Failed to bind SAML, please contact admin.') + + next_url = settings.LOGIN_REDIRECT_URL + if 'next' in request.GET: + next_url = request.GET['next'] + elif 'RelayState' in request.GET: + next_url = request.GET['RelayState'] + + if not url_has_allowed_host_and_scheme(next_url, None): + next_url = settings.LOGIN_REDIRECT_URL + next_url = next_url + '?is_saml2_connect=true' + + try: + sp_config = get_config(None, request) + except Exception as e: + logger.error(e) + return HttpResponseBadRequest('Failed to get ADFS/SAML config, please check your ADFS/SAML service.') + + saml_client = Saml2Client(sp_config) + session_id, info = saml_client.prepare_for_authenticate(relay_state=next_url) + oq_cache = OutstandingQueriesCache(request.saml_session) + oq_cache.set(session_id, next_url) + try: + headers = dict(info['headers']) + redirect_url = headers['Location'] + except KeyError: + redirect_url = info['url'] + except Exception as e: + logger.warning(e) + redirect_url = None + + return HttpResponseRedirect(redirect_url) + + +@login_required +def saml2_disconnect(request, org_id=None): + if org_id and int(org_id) > 0: + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id) + + if request.user.org.org_id != org_id: + logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_id)) + return HttpResponseBadRequest('Failed to disbind SAML, please contact admin.') + + username = request.user.username + if request.user.enc_password == '!': + return HttpResponseBadRequest('Failed to disbind SAML, please set a password first.') + profile = Profile.objects.get_profile_by_user(username) + if not profile or not profile.contact_email: + return HttpResponseBadRequest('Failed to disbind SAML, please set a contact email first.') + + SocialAuthUser.objects.delete_by_username_and_provider(username, SAML_PROVIDER_IDENTIFIER) + next_url = request.GET.get(auth.REDIRECT_FIELD_NAME, settings.LOGIN_REDIRECT_URL) + return HttpResponseRedirect(next_url) + + @login_required def auth_complete(request): from seahub.api2.utils import get_token_v1, get_token_v2 @@ -231,6 +408,17 @@ def auth_complete(request): return resp -def multi_adfs_login(request): - if getattr(settings, 'ENABLE_MULTI_ADFS', False): - return HttpResponseRedirect(request.path.rstrip('/') + '/saml2/login/') +# For compatibility with 10.0 version of URLs, replace url_prefix with org_id. +def adfs_compatible_view(request, url_prefix): + try: + org = ccnet_api.get_org_by_url_prefix(url_prefix) + except Exception as e: + logger.error(e) + return HttpResponseBadRequest('login failed, please contact admin') + + if not org: + logger.error('Cannot find an organization related to url_prefix %s.' % url_prefix) + return HttpResponseBadRequest('Cannot find an organization related to url_prefix %s.' % url_prefix) + + org_id = str(org.org_id) + return HttpResponsePermanentRedirect(request.path.replace(url_prefix, org_id)) diff --git a/seahub/api2/endpoints/admin/organizations.py b/seahub/api2/endpoints/admin/organizations.py index 2868d2ad78..9b2d6429ea 100644 --- a/seahub/api2/endpoints/admin/organizations.py +++ b/seahub/api2/endpoints/admin/organizations.py @@ -90,7 +90,6 @@ def get_org_detailed_info(org): org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) if org_saml_config: org_info['enable_saml_login'] = True - org_info['url_prefix'] = org.url_prefix org_info['metadata_url'] = org_saml_config.metadata_url org_info['domain'] = org_saml_config.domain diff --git a/seahub/api2/endpoints/admin/users.py b/seahub/api2/endpoints/admin/users.py index 464d949185..d6badc5076 100644 --- a/seahub/api2/endpoints/admin/users.py +++ b/seahub/api2/endpoints/admin/users.py @@ -62,7 +62,7 @@ from seahub.auth.models import SocialAuthUser from seahub.options.models import UserOptions from seahub.share.models import FileShare, UploadLinkShare -from seahub.settings import ENABLE_LDAP, LDAP_FILTER, ENABLE_SASL, SASL_MECHANISM +from seahub.settings import ENABLE_LDAP, LDAP_FILTER, ENABLE_SASL, SASL_MECHANISM, ENABLE_SSO_USER_CHANGE_PASSWORD try: from seahub.settings import LDAP_SERVER_URL, LDAP_BASE_DN, LDAP_ADMIN_DN, LDAP_ADMIN_PASSWORD, LDAP_LOGIN_ATTR @@ -1467,6 +1467,17 @@ class AdminUserResetPassword(APIView): error_msg = 'email invalid' return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + has_bind_social_auth = False + if SocialAuthUser.objects.filter(username=email).exists(): + has_bind_social_auth = True + + can_reset_password = True + if has_bind_social_auth and (not ENABLE_SSO_USER_CHANGE_PASSWORD): + can_reset_password = False + + if not can_reset_password: + return api_error(status.HTTP_400_BAD_REQUEST, _('Unable to reset password.')) + try: user = User.objects.get(email=email) except User.DoesNotExist as e: diff --git a/seahub/auth/views.py b/seahub/auth/views.py index fdc28712d7..cb958b6e99 100644 --- a/seahub/auth/views.py +++ b/seahub/auth/views.py @@ -20,6 +20,7 @@ from seaserv import seafile_api, ccnet_api from seahub.auth import REDIRECT_FIELD_NAME, get_backends from seahub.auth import login as auth_login +from seahub.auth.models import SocialAuthUser from seahub.auth.decorators import login_required from seahub.auth.forms import AuthenticationForm, CaptchaAuthenticationForm, \ PasswordResetForm, SetPasswordForm, PasswordChangeForm, \ @@ -33,7 +34,7 @@ from seahub.base.accounts import User, UNUSABLE_PASSWORD from seahub.options.models import UserOptions from seahub.profile.models import Profile from seahub.two_factor.views.login import is_device_remembered -from seahub.utils import is_ldap_user, get_site_name, is_valid_email +from seahub.utils import render_error, get_site_name, is_valid_email from seahub.utils.ip import get_remote_ip from seahub.utils.file_size import get_quota_from_string from seahub.utils.two_factor_auth import two_factor_auth_enabled, handle_two_factor_auth @@ -255,17 +256,20 @@ def logout(request, next_page=None, redirect_field_name=REDIRECT_FIELD_NAME): "Logs out the user and displays 'You are logged out' message." - if getattr(settings, 'ENABLE_MULTI_ADFS', False): - from seahub.utils import is_org_context + if getattr(settings, 'ENABLE_ADFS_LOGIN', False) or getattr(settings, 'ENABLE_MULTI_ADFS', False): try: saml_subject_id = decode(request.saml_session["_saml2_subject_id"]) + if saml_subject_id: + from seahub.utils import is_org_context + if is_org_context(request): + org_id = request.user.org.org_id + response = HttpResponseRedirect('/org/custom/%s/saml2/logout/' % str(org_id)) + else: + response = HttpResponseRedirect('/saml2/logout/') + response.delete_cookie('seahub_auth') + return response except Exception as e: logger.warning(e) - saml_subject_id = None - if saml_subject_id and is_org_context(request): - org_id = request.user.org.org_id - org = ccnet_api.get_org_by_id(org_id) - return HttpResponseRedirect('/org/custom/%s/saml2/logout/' % org.url_prefix) from seahub.auth import logout logout(request) @@ -338,6 +342,18 @@ def password_reset(request, is_admin_site=False, template_name='registration/pas email_template_name='registration/password_reset_email.html', password_reset_form=PasswordResetForm, token_generator=default_token_generator, post_reset_redirect=None): + + has_bind_social_auth = False + if SocialAuthUser.objects.filter(username=request.user.username).exists(): + has_bind_social_auth = True + + can_reset_password = True + if has_bind_social_auth and (not settings.ENABLE_SSO_USER_CHANGE_PASSWORD): + can_reset_password = False + + if not can_reset_password: + return render_error(request, _('Unable to reset password.')) + if post_reset_redirect is None: post_reset_redirect = reverse('auth_password_reset_done') if request.method == "POST": @@ -413,8 +429,16 @@ def password_change(request, template_name='registration/password_change_form.ht if post_change_redirect is None: post_change_redirect = reverse('auth_password_change_done') - if is_ldap_user(request.user): - messages.error(request, _("Can not update password, please contact LDAP admin.")) + has_bind_social_auth = False + if SocialAuthUser.objects.filter(username=request.user.username).exists(): + has_bind_social_auth = True + + can_change_password = True + if has_bind_social_auth and (not settings.ENABLE_SSO_USER_CHANGE_PASSWORD): + can_change_password = False + + if not can_change_password: + return render_error(request, _('Unable to change password.')) if settings.ENABLE_USER_SET_CONTACT_EMAIL: user_profile = Profile.objects.get_profile_by_user(request.user.username) @@ -423,10 +447,10 @@ def password_change(request, template_name='registration/password_change_form.ht password_change_form = SetContactEmailPasswordForm template_name = 'registration/password_set_form.html' - elif request.user.enc_password == UNUSABLE_PASSWORD: - # set password only - password_change_form = SetPasswordForm - template_name = 'registration/password_set_form.html' + if request.user.enc_password == UNUSABLE_PASSWORD: + # set password only + password_change_form = SetPasswordForm + template_name = 'registration/password_set_form.html' if request.method == "POST": form = password_change_form(user=request.user, data=request.POST) @@ -477,23 +501,23 @@ def multi_adfs_sso(request): try: org_saml_config = OrgSAMLConfig.objects.get_config_by_domain(domain) if not org_saml_config: - render_data['error_msg'] = "Cannot find a SAML/ADFS config for the organization related to domain %s." % domain + render_data['error_msg'] = "Cannot find a SAML config for the team related to domain %s." % domain return render(request, template_name, render_data) if not org_saml_config.domain_verified: - render_data['error_msg'] = "The domain %s has not been verified ownership, please login after verification." % domain + render_data['error_msg'] = \ + "The ownership of domain %s has not been verified. Please ask your team admin to verify it." % domain return render(request, template_name, render_data) org_id = org_saml_config.org_id org = ccnet_api.get_org_by_id(org_id) if not org: - render_data['error_msg'] = 'Cannot find an organization related to domain %s.' % domain + render_data['error_msg'] = "Cannot find a SAML config for the team related to domain %s." % domain return render(request, template_name, render_data) - url_prefix = org.url_prefix except Exception as e: logger.error(e) render_data['error_msg'] = 'Error, please contact administrator.' return render(request, template_name, render_data) - return HttpResponseRedirect('/org/custom/%s/saml2/login/' % url_prefix) + return HttpResponseRedirect('/org/custom/%s/saml2/login/' % str(org_id)) if request.method == "GET": return render(request, template_name, render_data) diff --git a/seahub/organizations/api/admin/saml_config.py b/seahub/organizations/api/admin/saml_config.py index 953ad32d2e..ceda33455b 100644 --- a/seahub/organizations/api/admin/saml_config.py +++ b/seahub/organizations/api/admin/saml_config.py @@ -1,18 +1,12 @@ # -*- coding: utf-8 -*- -import os -import re import uuid import subprocess import logging -from xml.etree import ElementTree -from urllib.parse import urlparse -import requests 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 @@ -20,55 +14,11 @@ 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 -from seahub import settings + logger = logging.getLogger(__name__) -CERTS_DIR = getattr(settings, 'SAML_CERTS_DIR', '/opt/seafile/seahub-data/certs') - - -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) - return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') - - return Response({'success': True}) - class OrgSAMLConfigView(APIView): @@ -90,33 +40,20 @@ class OrgSAMLConfigView(APIView): 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) - if not metadata_url: - return api_error(status.HTTP_400_BAD_REQUEST, 'metadata_url invalid.') - - # 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) - - # add or update saml/adfs login config - try: - saml_config = OrgSAMLConfig.objects.add_or_update_saml_config(org_id, metadata_url) - except Exception as e: - logger.error(e) - return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') - - return Response({'saml_config': saml_config.to_dict()}) - def put(self, request, org_id): # argument check + metadata_url = request.data.get('metadata_url', None) domain = request.data.get('domain', None) - if not domain: - return api_error(status.HTTP_400_BAD_REQUEST, 'domain invalid.') + idp_certificate = request.data.get('idp_certificate', None) + + if not metadata_url and not domain and not idp_certificate: + error_msg = 'metadata_url or domain or idp_certificate invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if idp_certificate and (not idp_certificate.startswith('-----BEGIN CERTIFICATE-----') + or not idp_certificate.endswith('-----END CERTIFICATE-----')): + error_msg = 'idp_certificate invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) # resource check org_id = int(org_id) @@ -125,16 +62,24 @@ class OrgSAMLConfigView(APIView): error_msg = 'Organization %s not found.' % org_id return api_error(status.HTTP_404_NOT_FOUND, error_msg) + # add or update saml config saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) if not saml_config: - error_msg = 'Cannot find a SAML/ADFS config for the organization %s.' % org.org_name - return api_error(status.HTTP_404_NOT_FOUND, error_msg) + saml_config = OrgSAMLConfig(org_id=org_id, metadata_url='') - # When the domain is updated, the domain ownership needs to be re-verified, so set dns_txt to None - try: + if metadata_url: + saml_config.metadata_url = metadata_url + + if idp_certificate: + saml_config.idp_certificate = idp_certificate + + if domain: saml_config.domain = domain - saml_config.dns_txt = None + saml_config.dns_txt = 'seatable-site-verification=' + str(uuid.uuid4().hex) + # When the domain is updated, the domain ownership needs to be re-verified, so set domain_verified to False saml_config.domain_verified = False + + try: saml_config.save() except Exception as e: logger.error(e) @@ -142,78 +87,6 @@ class OrgSAMLConfigView(APIView): return Response({'saml_config': saml_config.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) - return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') - - 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}) - class OrgVerifyDomain(APIView): @@ -221,32 +94,6 @@ class OrgVerifyDomain(APIView): throttle_classes = (UserRateThrottle,) permission_classes = (IsProVersion, IsOrgAdminUser) - def post(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) - - saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id) - if not saml_config: - error_msg = 'Cannot find a SAML/ADFS config for the organization %s.' % org.org_name - return api_error(status.HTTP_404_NOT_FOUND, error_msg) - - if saml_config.dns_txt: - return Response({'dns_txt': saml_config.dns_txt}) - - try: - dns_txt = 'seafile-site-verification=' + str(uuid.uuid4().hex) - saml_config.dns_txt = dns_txt - saml_config.save() - except Exception as e: - logger.error(e) - return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') - - return Response({'dns_txt': saml_config.dns_txt}) - def put(self, request, org_id): # argument check domain = request.data.get('domain', None) @@ -265,6 +112,10 @@ class OrgVerifyDomain(APIView): error_msg = 'Cannot find a SAML/ADFS config for the organization %s.' % org.org_name return api_error(status.HTTP_404_NOT_FOUND, error_msg) + if saml_config.domain != domain: + error_msg = 'Domain %s not belong organization %s.' % (domain, org.org_name) + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + if saml_config.domain_verified: return Response({'domain_verified': saml_config.domain_verified}) diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py index 3b00f793f2..6d0726953b 100644 --- a/seahub/organizations/api_urls.py +++ b/seahub/organizations/api_urls.py @@ -27,8 +27,7 @@ from .api.admin.logo import OrgAdminLogo from .api.admin.statistics import OrgFileOperationsView, OrgTotalStorageView, \ OrgActiveUsersView, OrgSystemTrafficView, OrgUserTrafficView, \ OrgUserTrafficExcelView, OrgUserStorageExcelView -from .api.admin.saml_config import OrgUploadIdPCertificateView, OrgSAMLConfigView, OrgUrlPrefixView, \ - OrgVerifyDomain +from .api.admin.saml_config import OrgSAMLConfigView, OrgVerifyDomain urlpatterns = [ @@ -54,15 +53,9 @@ urlpatterns = [ OrgUserStorageExcelView.as_view(), name='api-v2.1-org-admin-statistics-user-storage-excel'), - path('/admin/saml-idp-certificate/', - OrgUploadIdPCertificateView.as_view(), - name='api-v2.1-org-admin-saml-idp-certificate'), path('/admin/saml-config/', OrgSAMLConfigView.as_view(), name='api-v2.1-org-admin-saml-config'), - path('/admin/url-prefix/', - OrgUrlPrefixView.as_view(), - name='api-v2.1-org-admin-url-prefix'), path('/admin/verify-domain/', OrgVerifyDomain.as_view(), name='api-v2.1-org-admin-verify-domain'), diff --git a/seahub/organizations/models.py b/seahub/organizations/models.py index 969343c8e9..0838a59426 100644 --- a/seahub/organizations/models.py +++ b/seahub/organizations/models.py @@ -84,16 +84,6 @@ class OrgSettings(models.Model): class OrgSAMLConfigManager(models.Manager): - def add_or_update_saml_config(self, org_id, metadata_url): - try: - saml_config = self.get(org_id=org_id) - except OrgSAMLConfig.DoesNotExist: - saml_config = self.model(org_id=org_id) - - saml_config.metadata_url = metadata_url - 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) @@ -115,6 +105,7 @@ class OrgSAMLConfig(models.Model): domain = models.CharField(max_length=255, unique=True, null=True, blank=True) dns_txt = models.CharField(max_length=64, null=True, blank=True) domain_verified = models.BooleanField(default=False, db_index=True) + idp_certificate = models.TextField(null=True, blank=True) objects = OrgSAMLConfigManager() @@ -129,6 +120,7 @@ class OrgSAMLConfig(models.Model): 'domain': self.domain, 'dns_txt': self.dns_txt, 'domain_verified': self.domain_verified, + 'idp_certificate': self.idp_certificate, } diff --git a/seahub/organizations/utils.py b/seahub/organizations/utils.py index 558e6cea97..8e1bc7d71f 100644 --- a/seahub/organizations/utils.py +++ b/seahub/organizations/utils.py @@ -1,38 +1,10 @@ # 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(): - ccnet_conf_dir = os.environ.get('SEAFILE_CENTRAL_CONF_DIR') or os.environ.get('CCNET_CONF_DIR') - if not ccnet_conf_dir: - error_msg = 'Environment variable ccnet_conf_dir is not define.' - return None, error_msg - - ccnet_conf_path = os.path.join(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/profile/templates/profile/set_profile_react.html b/seahub/profile/templates/profile/set_profile_react.html index a79bed27f9..be5467cc6b 100644 --- a/seahub/profile/templates/profile/set_profile_react.html +++ b/seahub/profile/templates/profile/set_profile_react.html @@ -74,6 +74,23 @@ window.app.pageOptions = { socialNextPage: "{{ social_next_page|escapejs }}", {% endif %} + enableADFS: {% if enable_adfs %} true {% else %} false {% endif %}, + {% if enable_adfs %} + samlConnected: {% if saml_connected %} true {% else %} false {% endif %}, + socialNextPage: "{{ social_next_page|escapejs }}", + {% endif %} + + enableMultiADFS: {% if enable_multi_adfs %} true {% else %} false {% endif %}, + {% if enable_multi_adfs %} + orgSamlConnected: {% if org_saml_connected %} true {% else %} false {% endif %}, + socialNextPage: "{{ social_next_page|escapejs }}", + {% endif %} + + orgID: "{{ org_id }}", + + cloudMode: {% if cloud_mode %} true {% else %} false {% endif %}, + isOrgContext: {% if org is not None %} true {% else %} false {% endif %}, + enableDeleteAccount: {% if ENABLE_DELETE_ACCOUNT %} true {% else %} false {% endif %} }; diff --git a/seahub/profile/views.py b/seahub/profile/views.py index 422cfddf20..9587242a99 100644 --- a/seahub/profile/views.py +++ b/seahub/profile/views.py @@ -13,6 +13,7 @@ from seaserv import seafile_api from .forms import DetailedProfileForm from .models import Profile, DetailedProfile +from seahub.auth.models import SocialAuthUser from seahub.auth.decorators import login_required from seahub.utils import is_org_context, is_pro_version, is_valid_username from seahub.base.accounts import User, UNUSABLE_PASSWORD @@ -23,8 +24,12 @@ from seahub.utils import is_ldap_user, get_webdav_url from seahub.utils.two_factor_auth import has_two_factor_auth from seahub.views import get_owned_repo_list from seahub.work_weixin.utils import work_weixin_oauth_check -from seahub.settings import ENABLE_DELETE_ACCOUNT, ENABLE_UPDATE_USER_INFO +from seahub.settings import ENABLE_DELETE_ACCOUNT, ENABLE_UPDATE_USER_INFO, ENABLE_ADFS_LOGIN, ENABLE_MULTI_ADFS from seahub.dingtalk.settings import ENABLE_DINGTALK +try: + from seahub.settings import SAML_PROVIDER_IDENTIFIER +except ImportError as e: + SAML_PROVIDER_IDENTIFIER = 'saml' @login_required @@ -86,8 +91,6 @@ def edit_profile(request): if work_weixin_oauth_check(): enable_wechat_work = True - - from seahub.auth.models import SocialAuthUser from seahub.work_weixin.settings import WORK_WEIXIN_PROVIDER social_connected = SocialAuthUser.objects.filter( username=request.user.username, provider=WORK_WEIXIN_PROVIDER).count() > 0 @@ -97,13 +100,36 @@ def edit_profile(request): if ENABLE_DINGTALK: enable_dingtalk = True - from seahub.auth.models import SocialAuthUser social_connected_dingtalk = SocialAuthUser.objects.filter( username=request.user.username, provider='dingtalk').count() > 0 else: enable_dingtalk = False social_connected_dingtalk = False + if ENABLE_ADFS_LOGIN: + enable_adfs = True + saml_connected = SocialAuthUser.objects.filter( + username=request.user.username, provider=SAML_PROVIDER_IDENTIFIER).exists() + else: + enable_adfs = False + saml_connected = False + + if ENABLE_MULTI_ADFS and is_org_context(request): + enable_multi_adfs = True + org_saml_connected = SocialAuthUser.objects.filter( + username=request.user.username, provider=SAML_PROVIDER_IDENTIFIER).exists() + else: + enable_multi_adfs = False + org_saml_connected = False + + has_bind_social_auth = False + if SocialAuthUser.objects.filter(username=request.user.username).exists(): + has_bind_social_auth = True + + can_update_password = True + if has_bind_social_auth and (not settings.ENABLE_SSO_USER_CHANGE_PASSWORD): + can_update_password = False + WEBDAV_SECRET_SETTED = False if settings.ENABLE_WEBDAV_SECRET and \ UserOptions.objects.get_webdav_secret(username): @@ -121,7 +147,7 @@ def edit_profile(request): 'is_pro': is_pro_version(), 'is_ldap_user': is_ldap_user(request.user), 'two_factor_auth_enabled': show_two_factor_auth, - 'ENABLE_CHANGE_PASSWORD': settings.ENABLE_CHANGE_PASSWORD, + 'ENABLE_CHANGE_PASSWORD': can_update_password if has_bind_social_auth else settings.ENABLE_CHANGE_PASSWORD, 'ENABLE_GET_AUTH_TOKEN_BY_SESSION': settings.ENABLE_GET_AUTH_TOKEN_BY_SESSION, 'ENABLE_WEBDAV_SECRET': settings.ENABLE_WEBDAV_SECRET, 'WEBDAV_SECRET_SETTED': WEBDAV_SECRET_SETTED, @@ -139,6 +165,11 @@ def edit_profile(request): 'social_connected_dingtalk': social_connected_dingtalk, 'ENABLE_USER_SET_CONTACT_EMAIL': settings.ENABLE_USER_SET_CONTACT_EMAIL, 'user_unusable_password': request.user.enc_password == UNUSABLE_PASSWORD, + 'enable_adfs': enable_adfs, + 'saml_connected': saml_connected, + 'enable_multi_adfs': enable_multi_adfs, + 'org_saml_connected': org_saml_connected, + 'org_id': request.user.org and request.user.org.org_id or None, } if show_two_factor_auth: diff --git a/seahub/settings.py b/seahub/settings.py index 4e28a0775a..3f64593b8a 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -451,6 +451,9 @@ FORCE_PASSWORD_CHANGE = True # Enable a user to change password in 'settings' page. ENABLE_CHANGE_PASSWORD = True +# Enable a sso user to change password in 'settings' page. +ENABLE_SSO_USER_CHANGE_PASSWORD = True + # Enable a user to get auth token in 'settings' page. ENABLE_GET_AUTH_TOKEN_BY_SESSION = False diff --git a/seahub/templates/registration/multi_adfs_sso.html b/seahub/templates/registration/multi_adfs_sso.html index 43c0a9167a..1cb3244831 100644 --- a/seahub/templates/registration/multi_adfs_sso.html +++ b/seahub/templates/registration/multi_adfs_sso.html @@ -6,6 +6,7 @@ {% block header_css_class %}hide{% endblock %} {% block extra_base_style %} + {% endblock %} @@ -43,9 +44,9 @@ html, body, #wrapper { height:100%; }

{% endif %} - + - + {% include "snippets/policy_service_link.html" %} @@ -54,7 +55,7 @@ html, body, #wrapper { height:100%; } {% block extra_script %} {% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index e43994a6d8..70c86cde86 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -209,7 +209,6 @@ urlpatterns = [ path('mobile-login/', mobile_login, name="mobile_login"), path('sso/', sso, name='sso'), - path('multi_adfs_sso/', multi_adfs_sso, name='multi_adfs_sso'), path('jwt-sso/', jwt_sso, name='jwt_sso'), re_path(r'^shib-login/', shib_login, name="shib_login"), path('oauth/', include('seahub.oauth.urls')), @@ -915,11 +914,18 @@ if HAS_OFFICE_CONVERTER: if getattr(settings, 'ENABLE_MULTI_ADFS', False): from seahub.adfs_auth.views import * urlpatterns += [ - re_path(r'^org/custom/[a-z_0-9-]+/$', multi_adfs_login, name="multi_adfs_login"), - re_path(r'^org/custom/[a-z_0-9-]+/saml2/login/$', login, name='org_saml2_login'), - re_path(r'^org/custom/[a-z_0-9-]+/saml2/acs/$', assertion_consumer_service, name='org_saml2_acs'), - re_path(r'^org/custom/[a-z_0-9-]+/saml2/metadata/$', metadata, name='org_saml2_metadata'), - re_path(r'^org/custom/[a-z_0-9-]+/saml2/', include(('djangosaml2.urls', 'djangosaml2'), namespace='org')), + re_path(r'^multi_adfs_sso/$', multi_adfs_sso, name='multi_adfs_sso'), + re_path(r'^org/custom/(?P\d+)/saml2/login/$', login, name='org_saml2_login'), + re_path(r'^org/custom/(?P\d+)/saml2/acs/$', assertion_consumer_service, name='org_saml2_acs'), + re_path(r'^org/custom/(?P\d+)/saml2/metadata/$', metadata, name='org_saml2_metadata'), + re_path(r'^org/custom/(?P\d+)/saml2/connect/$', saml2_connect, name='org_saml2_connect'), + re_path(r'^org/custom/(?P\d+)/saml2/disconnect/$', saml2_disconnect, name='org_saml2_disconnect'), + re_path(r'^org/custom/(?P\d+)/saml2/', include(('djangosaml2.urls', 'djangosaml2'), namespace='org')), + re_path(r'^org/custom/(?P[a-z_0-9-]+)/saml2/login/$', adfs_compatible_view, name='login_compatible_view'), + re_path(r'^org/custom/(?P[a-z_0-9-]+)/saml2/acs/$', adfs_compatible_view, name='acs_compatible_view'), + re_path(r'^org/custom/(?P[a-z_0-9-]+)/saml2/metadata/$', adfs_compatible_view, name='metadate_compatible_view'), + re_path(r'^org/custom/(?P[a-z_0-9-]+)/saml2/ls/$', adfs_compatible_view, name='ls_compatible_view'), + re_path(r'^org/custom/(?P[a-z_0-9-]+)/saml2/ls/post/$', adfs_compatible_view, name='ls_post_compatible_view'), ] if getattr(settings, 'ENABLE_ADFS_LOGIN', False): @@ -928,6 +934,8 @@ if getattr(settings, 'ENABLE_ADFS_LOGIN', False): path('saml2/login/', login, name='saml2_login'), path('saml2/acs/', assertion_consumer_service, name='saml2_acs'), path('saml2/metadata/', metadata, name='saml2_metadata'), + path('saml2/connect/', saml2_connect, name='saml2_connect'), + path('saml2/disconnect/', saml2_disconnect, name='saml2_disconnect'), path('saml2/', include('djangosaml2.urls')), ]