Explorar el Código

Merge branch 'master' into imprv/add-reveal-growi_renderer-plugin

Yuto Iwata hace 7 años
padre
commit
ebb9afab46

+ 1 - 0
CHANGES.md

@@ -4,6 +4,7 @@ CHANGES
 ## 3.3.4-RC
 ## 3.3.4-RC
 
 
 * Fix: `/_api/revisions.get` doesn't populate author data correctly
 * Fix: `/_api/revisions.get` doesn't populate author data correctly
+* Fix: Wrong OAuth callback url are shown at admin page
 
 
 ## 3.3.3
 ## 3.3.3
 
 

+ 12 - 20
README.md

@@ -74,7 +74,7 @@ See also [weseek/growi-docker-compose][docker-compose]
 On-premise
 On-premise
 ----------
 ----------
 
 
-[**Migration Guide from Crowi** is here](https://github.com/weseek/growi/wiki/Migration-Guide-from-Crowi).
+[**Migration Guide from Crowi** is here](https://docs.growi.org/guide/migration-guide/from-crowi-onpremise.html).
 
 
 ### Dependencies
 ### Dependencies
 
 
@@ -83,7 +83,7 @@ On-premise
 - yarn
 - yarn
 - MongoDB 3.x
 - MongoDB 3.x
 
 
-See [confirmed versions](https://github.com/weseek/growi/wiki/Developers-Guide#versions-confirmed-to-work).
+See [confirmed versions](https://docs.growi.org/dev/startup/dev-env.html#versions-confirmed-to-work).
 
 
 #### Optional Dependencies
 #### Optional Dependencies
 
 
@@ -114,7 +114,7 @@ export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi
 npm start
 npm start
 ```
 ```
 
 
-For more info, see [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide) and [Crowi documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
+For more info, see [Developers Guide](https://docs.growi.org/dev/).
 
 
 #### Command details
 #### Command details
 
 
@@ -145,9 +145,7 @@ yarn add growi-plugin-lsx
 npm start
 npm start
 ```
 ```
 
 
-
-
-For more info, see [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide) on Wiki.
+For more info, see [Developers Guide](https://docs.growi.org/dev/) on docs.growi.org.
 
 
 
 
 Environment Variables
 Environment Variables
@@ -172,7 +170,7 @@ Environment Variables
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
 * **Option to integrate with external systems**
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
-        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
+        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
@@ -191,10 +189,8 @@ Environment Variables
 Documentation
 Documentation
 ==============
 ==============
 
 
-* [github wiki pages](https://github.com/weseek/growi/wiki)
-  * [Questions and Answers](https://github.com/weseek/growi/wiki/Questions-and-Answers)
-  * [Migration Guide from Crowi](https://github.com/weseek/growi/wiki/Migration-Guide-from-Crowi)
-  * [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide)
+- [GROWI Docs](https://docs.growi.org/)
+
 
 
 Contribution
 Contribution
 ============
 ============
@@ -212,24 +208,20 @@ Missing a Feature?
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
 Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 
 
-* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed. 
+* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed.  
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
 
 Translation
 Translation
 --------------
 --------------
 
 
-### for GROWI system
+We have some Transifex Projects.
 
 
-We have [the Transifex Project for GROWI](https://www.transifex.com/weseek-inc/growi).  
-Please join to our team!
-
-### for documents
+* [GROWI (Internationalize)](https://www.transifex.com/weseek-inc/growi)
+* [GROWI Docs (Internationalize)](https://www.transifex.com/weseek-inc/growi-docs)
 
 
-*We have [Gitbook site](https://docs.growi.org), but currently Gitbook doesn't support Multi-langage.*  
--> https://docs.gitbook.com/v2-changes/important-differences#multi-language-books
+Please join to our team!
 
 
-*We have to wait until it is implemented.*
 
 
 Language on GitHub
 Language on GitHub
 ------------------
 ------------------

+ 19 - 6
resource/locales/en-US/translation.json

@@ -101,6 +101,10 @@
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Sign out": "Logout",
 
 
+  "form_validation": {
+    "required": "<code>%s</code> is required"
+  },
+
   "installer": {
   "installer": {
     "setup": "Setup",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "create_initial_account": "Create an initial account",
@@ -382,6 +386,8 @@
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "Use env var if empty": "Use env var <code>%s</code> if empty",
     "Use env var if empty": "Use env var <code>%s</code> if empty",
+    "Use default if both are empty": "If both ​​are empty, the default value <code>%s</code> is used.",
+    "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "ldap": {
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
       "bind_mode": "Binding Mode",
@@ -414,15 +420,12 @@
     },
     },
     "SAML": {
     "SAML": {
       "name": "SAML",
       "name": "SAML",
-      "entry_point": "Entry Point",
-      "issuer": "Issuer",
-      "First Name": "First Name",
-      "Last Name": "Last Name",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
       "mapping_detail": "Specification of mappings for %s when creating new users",
       "mapping_detail": "Specification of mappings for %s when creating new users",
-      "cert_detail1": "PEM-encoded X.509 signing certificate to validate the response from IdP",
-      "cert_detail2": "Use env var <code>SAML_CERT</code> if empty, and no validation is processed if the variable is also undefined"
+      "cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
+      "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
+      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "Register for %s",
       "register": "Register for %s",
@@ -457,6 +460,16 @@
         "github": "How to configure GitHub OAuth?",
         "github": "How to configure GitHub OAuth?",
         "twitter": "How to configure Twitter OAuth?"
         "twitter": "How to configure Twitter OAuth?"
       }
       }
+    },
+    "form_item_name": {
+      "security:passport-saml:entryPoint": "Entry point",
+      "security:passport-saml:issuer": "Issuer",
+      "security:passport-saml:cert": "Certificate",
+      "security:passport-saml:attrMapId": "ID",
+      "security:passport-saml:attrMapUsername": "Username",
+      "security:passport-saml:attrMapMail": "Mail Address",
+      "security:passport-saml:attrMapFirstName": "First Name",
+      "security:passport-saml:attrMapLastName": "Last Name"
     }
     }
 	},
 	},
 
 

+ 19 - 6
resource/locales/ja/translation.json

@@ -118,6 +118,10 @@
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
 
 
+  "form_validation": {
+    "required": "<code>%s</code> に値を入力してください"
+  },
+
   "installer": {
   "installer": {
     "setup": "セットアップ",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "create_initial_account": "最初のアカウントの作成",
@@ -395,6 +399,8 @@
     "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
+    "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
     "ldap": {
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
       "bind_mode": "Bind モード",
@@ -427,15 +433,12 @@
     },
     },
     "SAML": {
     "SAML": {
       "name": "SAML",
       "name": "SAML",
-      "entry_point": "エントリーポイント",
-      "issuer": "発行者",
-      "First Name": "姓",
-      "Last Name": "名",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
-      "cert_detail1": "IdP からのレスポンスの validation を行うための、PEMエンコードされた X.509 証明書",
-      "cert_detail2": "空の場合は環境変数 <code>SAML_CERT</code> を利用し、そちらも存在しない場合は validation 自体を行いません"
+      "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
+      "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "%sに登録",
       "register": "%sに登録",
@@ -470,6 +473,16 @@
         "github": "GitHub OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
         "twitter": "Twitter OAuth の設定方法"
         "twitter": "Twitter OAuth の設定方法"
       }
       }
+    },
+    "form_item_name": {
+      "security:passport-saml:entryPoint": "エントリーポイント",
+      "security:passport-saml:issuer": "発行者",
+      "security:passport-saml:cert": "証明書",
+      "security:passport-saml:attrMapId": "ID",
+      "security:passport-saml:attrMapUsername": "ユーザー名",
+      "security:passport-saml:attrMapMail": "メールアドレス",
+      "security:passport-saml:attrMapFirstName": "姓",
+      "security:passport-saml:attrMapLastName": "名"
     }
     }
   },
   },
   "markdown_setting": {
   "markdown_setting": {

+ 24 - 0
src/client/styles/scss/_admin.scss

@@ -79,6 +79,14 @@
     .btn.active[data-active-class="primary"] {
     .btn.active[data-active-class="primary"] {
       @include active-color($btn-primary-color, $btn-primary-bg, $btn-primary-border);
       @include active-color($btn-primary-color, $btn-primary-bg, $btn-primary-border);
     }
     }
+
+    // disabled btn-group styles
+    &.btn-group-disabled {
+      .btn:hover {
+        background-color: unset;
+        cursor: not-allowed;
+      }
+    }
   }
   }
 
 
   // theme selector
   // theme selector
@@ -117,4 +125,20 @@
       }
       }
     }
     }
   }
   }
+
+  .authentication-settings-table {
+    table-layout: fixed;
+
+    .item-name {
+      width: 150px;
+    }
+
+    td.unused {
+      opacity: 0.5;
+    }
+
+    &.use-only-env-vars .from-env-vars {
+      background-color: rgba($info, 0.1);
+    }
+  }
 }
 }

+ 4 - 1
src/server/crowi/express-init.js

@@ -45,7 +45,10 @@ module.exports = function(crowi, app) {
       detection: {
       detection: {
         order: ['userSettingDetector', 'header', 'navigator'],
         order: ['userSettingDetector', 'header', 'navigator'],
       },
       },
-      overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler
+      overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler,
+
+      // change nsSeparator from ':' to '::' because ':' is used in config keys and these are used in i18n keys
+      nsSeparator: '::'
     });
     });
 
 
   app.use(helmet());
   app.use(helmet());

+ 6 - 6
src/server/form/admin/securityPassportSaml.js

@@ -4,12 +4,12 @@ const form = require('express-form');
 const field = form.field;
 const field = form.field;
 
 
 module.exports = form(
 module.exports = form(
-  field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-saml:entryPoint]').trim().required().isUrl(),
-  field('settingForm[security:passport-saml:issuer]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapId]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapUsername]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapMail]').trim().required(),
+  field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:entryPoint]').trim().isUrl(),
+  field('settingForm[security:passport-saml:issuer]').trim(),
+  field('settingForm[security:passport-saml:attrMapId]').trim(),
+  field('settingForm[security:passport-saml:attrMapUsername]').trim(),
+  field('settingForm[security:passport-saml:attrMapMail]').trim(),
   field('settingForm[security:passport-saml:attrMapFirstName]').trim(),
   field('settingForm[security:passport-saml:attrMapFirstName]').trim(),
   field('settingForm[security:passport-saml:attrMapLastName]').trim(),
   field('settingForm[security:passport-saml:attrMapLastName]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),

+ 0 - 15
src/server/models/config.js

@@ -322,11 +322,6 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
-  configSchema.statics.isEnabledPassportSaml = function(config) {
-    const key = 'security:passport-saml:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
   configSchema.statics.isEnabledPassportGoogle = function(config) {
   configSchema.statics.isEnabledPassportGoogle = function(config) {
     const key = 'security:passport-google:isEnabled';
     const key = 'security:passport-google:isEnabled';
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
@@ -342,16 +337,6 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
-  configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
-    const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isSameEmailTreatedAsIdenticalUser = function(config, providerType) {
-    const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
-    return getValueForCrowiNS(config, key);
-  };
-
   configSchema.statics.isUploadable = function(config) {
   configSchema.statics.isUploadable = function(config) {
     const method = process.env.FILE_UPLOAD || 'aws';
     const method = process.env.FILE_UPLOAD || 'aws';
 
 

+ 20 - 3
src/server/routes/admin.js

@@ -1070,18 +1070,19 @@ module.exports = function(crowi, app) {
   actions.api.securityPassportSamlSetting = async(req, res) => {
   actions.api.securityPassportSamlSetting = async(req, res) => {
     const form = req.form.settingForm;
     const form = req.form.settingForm;
 
 
+    validateSamlSettingForm(req.form, req.t);
+
     if (!req.form.isValid) {
     if (!req.form.isValid) {
       return res.json({status: false, message: req.form.errors.join('\n')});
       return res.json({status: false, message: req.form.errors.join('\n')});
     }
     }
 
 
     debug('form content', form);
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
 
 
     // reset strategy
     // reset strategy
     await crowi.passportService.resetSamlStrategy();
     await crowi.passportService.resetSamlStrategy();
     // setup strategy
     // setup strategy
-    if (Config.isEnabledPassportSaml(config)) {
+    if (crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled')) {
       try {
       try {
         await crowi.passportService.setupSamlStrategy(true);
         await crowi.passportService.setupSamlStrategy(true);
       }
       }
@@ -1489,6 +1490,22 @@ module.exports = function(crowi, app) {
     }, callback);
     }, callback);
   }
   }
 
 
+  /**
+   * validate setting form values for SAML
+   *
+   * This validation checks, for the value of each mandatory items,
+   * whether it from the environment variables is empty and form value to update it is empty.
+   */
+  function validateSamlSettingForm(form, t) {
+    for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
+      const formValue = form.settingForm[key];
+      if (crowi.configManager.getConfigFromEnvVars('crowi', key) === null && formValue === '') {
+        const formItemName = t(`security_setting.form_item_name.${key}`);
+        form.errors.push(t('form_validation.required', formItemName));
+      }
+    }
+  }
+
   return actions;
   return actions;
 };
 };
 
 

+ 8 - 8
src/server/routes/login-passport.js

@@ -5,7 +5,6 @@ module.exports = function(crowi, app) {
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , passport = require('passport')
     , passport = require('passport')
     , config = crowi.getConfig()
     , config = crowi.getConfig()
-    , Config = crowi.model('Config')
     , ExternalAccount = crowi.model('ExternalAccount')
     , ExternalAccount = crowi.model('ExternalAccount')
     , passportService = crowi.passportService
     , passportService = crowi.passportService
     ;
     ;
@@ -355,11 +354,11 @@ module.exports = function(crowi, app) {
   const loginPassportSamlCallback = async(req, res) => {
   const loginPassportSamlCallback = async(req, res) => {
     const providerId = 'saml';
     const providerId = 'saml';
     const strategyName = 'saml';
     const strategyName = 'saml';
-    const attrMapId = config.crowi['security:passport-saml:attrMapId'];
-    const attrMapUsername = config.crowi['security:passport-saml:attrMapUsername'];
-    const attrMapMail = config.crowi['security:passport-saml:attrMapMail'];
-    const attrMapFirstName = config.crowi['security:passport-saml:attrMapFirstName'] || 'firstName';
-    const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
+    const attrMapId = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapId');
+    const attrMapUsername = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapUsername');
+    const attrMapMail = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapMail');
+    const attrMapFirstName = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapFirstName') || 'firstName';
+    const attrMapLastName = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapLastName') || 'lastName';
 
 
     let response;
     let response;
     try {
     try {
@@ -428,8 +427,9 @@ module.exports = function(crowi, app) {
 
 
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
     // get option
     // get option
-    const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
-    const isSameEmailTreatedAsIdenticalUser = Config.isSameEmailTreatedAsIdenticalUser(config, providerId);
+    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
     try {
     try {
       // find or register(create) user
       // find or register(create) user
       const externalAccount = await ExternalAccount.findOrRegister(
       const externalAccount = await ExternalAccount.findOrRegister(

+ 56 - 0
src/server/service/config-loader.js

@@ -110,6 +110,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   type:    ,
   //   default:
   //   default:
   // },
   // },
+  SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
+    ns:      'crowi',
+    key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
+    type:    TYPES.BOOLEAN,
+    default: false
+  },
+  SAML_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-saml:isEnabled',
+    type:    TYPES.BOOLEAN,
+    default: null
+  },
   SAML_ENTRY_POINT: {
   SAML_ENTRY_POINT: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:entryPoint',
     key:     'security:passport-saml:entryPoint',
@@ -128,6 +140,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null
     default: null
   },
   },
+  SAML_ATTR_MAPPING_ID: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapId',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_USERNAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapUsername',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_MAIL: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapMail',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_FIRST_NAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapFirstName',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_LAST_NAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapLastName',
+    type:    TYPES.STRING,
+    default: null
+  },
   SAML_CERT: {
   SAML_CERT: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:cert',
     key:     'security:passport-saml:cert',
@@ -153,6 +195,20 @@ class ConfigLoader {
     let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
     let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
     mergedConfigFromDB = Object.assign({'markdown': this.configModel.getDefaultMarkdownConfigsObject()}, mergedConfigFromDB);
     mergedConfigFromDB = Object.assign({'markdown': this.configModel.getDefaultMarkdownConfigsObject()}, mergedConfigFromDB);
 
 
+
+    // In getConfig API, only null is used as a value to indicate that a config is not set.
+    // So, if a value loaded from the database is emtpy,
+    // it is converted to null because an empty string is used as the same meaning in the config model.
+    // By this processing, whether a value is loaded from the database or from the environment variable,
+    // only null indicates a config is not set.
+    for (const namespace of Object.keys(mergedConfigFromDB)) {
+      for (const key of Object.keys(mergedConfigFromDB[namespace])) {
+        if (mergedConfigFromDB[namespace][key] === '') {
+          mergedConfigFromDB[namespace][key] = null;
+        }
+      }
+    }
+
     return {
     return {
       fromDB: mergedConfigFromDB,
       fromDB: mergedConfigFromDB,
       fromEnvVars: configFromEnvVars
       fromEnvVars: configFromEnvVars

+ 117 - 43
src/server/service/config-manager.js

@@ -1,6 +1,18 @@
 const ConfigLoader = require('../service/config-loader')
 const ConfigLoader = require('../service/config-loader')
   , debug = require('debug')('growi:service:ConfigManager');
   , debug = require('debug')('growi:service:ConfigManager');
 
 
+const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
+  'security:passport-saml:isEnabled',
+  'security:passport-saml:entryPoint',
+  'security:passport-saml:issuer',
+  'security:passport-saml:attrMapId',
+  'security:passport-saml:attrMapUsername',
+  'security:passport-saml:attrMapMail',
+  'security:passport-saml:attrMapFirstName',
+  'security:passport-saml:attrMapLastName',
+  'security:passport-saml:cert'
+];
+
 class ConfigManager {
 class ConfigManager {
 
 
   constructor(configModel) {
   constructor(configModel) {
@@ -21,24 +33,82 @@ class ConfigManager {
   /**
   /**
    * get a config specified by namespace & key
    * get a config specified by namespace & key
    *
    *
-   * Basically, search a specified config from configs loaded from database at first
-   * and then from configs loaded from env vars.
+   * Basically, this searches a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables.
+   *
+   * In some case, this search method changes.
    *
    *
-   * In some case, this search method changes.(not yet implemented)
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
    */
    */
   getConfig(namespace, key) {
   getConfig(namespace, key) {
+    if (this.searchOnlyFromEnvVarConfigs('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')) {
+      return this.searchInSAMLUseOnlyEnvMode(namespace, key);
+    }
+
     return this.defaultSearch(namespace, key);
     return this.defaultSearch(namespace, key);
   }
   }
 
 
   /**
   /**
-   * private api
+   * get a config specified by namespace & key from configs loaded from the database
    *
    *
-   * Search a specified config from configs loaded from database at first
-   * and then from configs loaded from env vars.
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  getConfigFromDB(namespace, key) {
+    return this.searchOnlyFromDBConfigs(namespace, key);
+  }
+
+  /**
+   * get a config specified by namespace & key from configs loaded from the environment variables
    *
    *
-   * the followings are the meanings of each special return value.
-   * - null:      a specified config is not set.
-   * - undefined: a specified config does not exist.
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  getConfigFromEnvVars(namespace, key) {
+    return this.searchOnlyFromEnvVarConfigs(namespace, key);
+  }
+
+  /**
+   * update configs in the same namespace
+   *
+   * Specified values are encoded by convertInsertValue.
+   * In it, an empty string is converted to null that indicates a config is not set.
+   *
+   * For example:
+   * ```
+   *  updateConfigsInTheSameNamespace(
+   *   'some namespace',
+   *   {
+   *    'some key 1': 'value 1',
+   *    'some key 2': 'value 2',
+   *    ...
+   *   }
+   *  );
+   * ```
+   */
+  async updateConfigsInTheSameNamespace(namespace, configs) {
+    let queries = [];
+    for (const key of Object.keys(configs)) {
+      queries.push({
+        updateOne: {
+          filter: { ns: namespace, key: key },
+          update: { ns: namespace, key: key, value: this.convertInsertValue(configs[key]) },
+          upsert: true
+        }
+      });
+    }
+    await this.configModel.bulkWrite(queries);
+
+    await this.loadConfigs();
+  }
+
+  /*
+   * All of the methods below are private APIs.
+   */
+
+  /**
+   * search a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables
    */
    */
   defaultSearch(namespace, key) {
   defaultSearch(namespace, key) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
@@ -63,9 +133,44 @@ class ConfigManager {
     }
     }
   }
   }
 
 
+  /**
+   * For the configs specified by KEYS_FOR_SAML_USE_ONLY_ENV_OPTION,
+   * this searches only from configs loaded from the environment variables.
+   * For the other configs, this searches as the same way to defaultSearch.
+   */
+  searchInSAMLUseOnlyEnvMode(namespace, key) {
+    if (namespace === 'crowi' && KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)) {
+      return this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      return this.defaultSearch(namespace, key);
+    }
+  }
+
+  /**
+   * search a specified config from configs loaded from the database
+   */
+  searchOnlyFromDBConfigs(namespace, key) {
+    if (!this.configExistsInDB(namespace, key)) {
+      return undefined;
+    }
+
+    return this.configObject.fromDB[namespace][key];
+  }
+
+  /**
+   * search a specified config from configs loaded from the environment variables
+   */
+  searchOnlyFromEnvVarConfigs(namespace, key) {
+    if (!this.configExistsInEnvVars(namespace, key)) {
+      return undefined;
+    }
+
+    return this.configObject.fromEnvVars[namespace][key];
+  }
+
   /**
   /**
    * check whether a specified config exists in configs loaded from the database
    * check whether a specified config exists in configs loaded from the database
-   * @returns {boolean}
    */
    */
   configExistsInDB(namespace, key) {
   configExistsInDB(namespace, key) {
     if (this.configObject.fromDB[namespace] === undefined) {
     if (this.configObject.fromDB[namespace] === undefined) {
@@ -77,7 +182,6 @@ class ConfigManager {
 
 
   /**
   /**
    * check whether a specified config exists in configs loaded from the environment variables
    * check whether a specified config exists in configs loaded from the environment variables
-   * @returns {boolean}
    */
    */
   configExistsInEnvVars(namespace, key) {
   configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
@@ -87,38 +191,8 @@ class ConfigManager {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
   }
 
 
-  /**
-   * update configs by a iterable object consisting of several objects with ns, key, value fields
-   *
-   * For example:
-   * ```
-   *  updateConfigs(
-   *   [{
-   *     ns:    'some namespace 1',
-   *     key:   'some key 1',
-   *     value: 'some value 1'
-   *   }, {
-   *     ns:    'some namespace 2',
-   *     key:   'some key 2',
-   *     value: 'some value 2'
-   *   }]
-   *  );
-   * ```
-   */
-  async updateConfigs(configs) {
-    const results = [];
-    for (const config of configs) {
-      results.push(
-        this.configModel.findOneAndUpdate(
-          { ns: config.ns, key: config.key },
-          { ns: config.ns, key: config.key, value: JSON.stringify(config.value) },
-          { upsert: true, }
-        ).exec()
-      );
-    }
-    await Promise.all(results);
-
-    await this.loadConfigs();
+  convertInsertValue(value) {
+    return JSON.stringify(value === '' ? null : value);
   }
   }
 }
 }
 
 

+ 44 - 8
src/server/service/passport.js

@@ -53,6 +53,19 @@ class PassportService {
      * the flag whether serializer/deserializer are set up successfully
      * the flag whether serializer/deserializer are set up successfully
      */
      */
     this.isSerializerSetup = false;
     this.isSerializerSetup = false;
+
+    /**
+     * the keys of mandatory configs for SAML
+     */
+    this.mandatoryConfigKeysForSaml = [
+      'security:passport-saml:isEnabled',
+      'security:passport-saml:entryPoint',
+      'security:passport-saml:issuer',
+      'security:passport-saml:cert',
+      'security:passport-saml:attrMapId',
+      'security:passport-saml:attrMapUsername',
+      'security:passport-saml:attrMapMail'
+    ];
   }
   }
 
 
   /**
   /**
@@ -427,8 +440,8 @@ class PassportService {
     }
     }
 
 
     const config = this.crowi.config;
     const config = this.crowi.config;
-    const Config = this.crowi.model('Config');
-    const isSamlEnabled = Config.isEnabledPassportSaml(config);
+    const configManager = this.crowi.configManager;
+    const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
 
 
     // when disabled
     // when disabled
     if (!isSamlEnabled) {
     if (!isSamlEnabled) {
@@ -437,12 +450,13 @@ class PassportService {
 
 
     debug('SamlStrategy: setting up..');
     debug('SamlStrategy: setting up..');
     passport.use(new SamlStrategy({
     passport.use(new SamlStrategy({
-      entryPoint: config.crowi['security:passport-saml:entryPoint'] || process.env.SAML_ENTRY_POINT,
-      callbackUrl: (config.crowi['app:siteUrl'] != null)
-        ? `${config.crowi['app:siteUrl']}/passport/saml/callback`                                 // auto-generated with v3.2.4 and above
-        : config.crowi['security:passport-saml:callbackUrl'] || process.env.SAML_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
-      issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
-      cert: config.crowi['security:passport-saml:cert'] || process.env.SAML_CERT,
+      entryPoint: configManager.getConfig('crowi', 'security:passport-saml:entryPoint'),
+      callbackUrl:
+        (config.crowi['app:siteUrl'] != null)
+          ? `${config.crowi['app:siteUrl']}/passport/saml/callback`                 // auto-generated with v3.2.4 and above
+          : configManager.getConfig('crowi', 'security:passport-saml:callbackUrl'),    // DEPRECATED: backward compatible with v3.2.3 and below
+      issuer: configManager.getConfig('crowi', 'security:passport-saml:issuer'),
+      cert: configManager.getConfig('crowi', 'security:passport-saml:cert'),
     }, function(profile, done) {
     }, function(profile, done) {
       if (profile) {
       if (profile) {
         return done(null, profile);
         return done(null, profile);
@@ -467,6 +481,19 @@ class PassportService {
     this.isSamlStrategySetup = false;
     this.isSamlStrategySetup = false;
   }
   }
 
 
+  /**
+   * return the keys of the configs mandatory for SAML whose value are empty.
+   */
+  getSamlMissingMandatoryConfigKeys() {
+    const missingRequireds = [];
+    for (const key of this.mandatoryConfigKeysForSaml) {
+      if (this.crowi.configManager.getConfig('crowi', key) === null) {
+        missingRequireds.push(key);
+      }
+    }
+    return missingRequireds;
+  }
+
   /**
   /**
    * setup serializer and deserializer
    * setup serializer and deserializer
    *
    *
@@ -494,6 +521,15 @@ class PassportService {
     this.isSerializerSetup = true;
     this.isSerializerSetup = true;
   }
   }
 
 
+  isSameUsernameTreatedAsIdenticalUser(providerType) {
+    const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
+    return this.crowi.configManager.getConfig('crowi', key);
+  }
+
+  isSameEmailTreatedAsIdenticalUser(providerType) {
+    const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
+    return this.crowi.configManager.getConfig('crowi', key);
+  }
 }
 }
 
 
 module.exports = PassportService;
 module.exports = PassportService;

+ 24 - 0
src/server/util/swigFunctions.js

@@ -42,6 +42,26 @@ module.exports = function(crowi, app, req, locals) {
     return fontSize;
     return fontSize;
   };
   };
 
 
+  /**
+   * @see ConfigManager#getConfig
+   */
+  locals.getConfig = function(namespace, key) {
+    return crowi.configManager.getConfig(namespace, key);
+  };
+
+  /**
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  locals.getConfigFromDB = function(namespace, key) {
+    return crowi.configManager.getConfigFromDB(namespace, key);
+  };
+  /**
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  locals.getConfigFromEnvVars = function(namespace, key) {
+    return crowi.configManager.getConfigFromEnvVars(namespace, key);
+  };
+
   /**
   /**
    * return app title
    * return app title
    */
    */
@@ -120,6 +140,10 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
     return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
   };
   };
 
 
+  locals.getSamlMissingMandatoryConfigKeys = function() {
+    return crowi.passportService.getSamlMissingMandatoryConfigKeys();
+  };
+
   locals.googleLoginEnabled = function() {
   locals.googleLoginEnabled = function() {
     // return false if Passport is enabled
     // return false if Passport is enabled
     // because official crowi mechanism is not used.
     // because official crowi mechanism is not used.

+ 2 - 1
src/server/views/admin/widget/passport/github.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
   {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
   {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
   {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/github/callback' %}
+  {% set siteUrl = settingForm['app:siteUrl'] || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/github/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>

+ 2 - 1
src/server/views/admin/widget/passport/google-oauth.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
   {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
   {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
   {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/google/callback' %}
+  {% set siteUrl = settingForm['app:siteUrl'] || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/google/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>

+ 329 - 135
src/server/views/admin/widget/passport/saml.html

@@ -3,95 +3,321 @@
   <legend class="alert-anchor">{{ t("security_setting.SAML.name") }} {{ t("security_setting.configuration") }}</legend>
   <legend class="alert-anchor">{{ t("security_setting.SAML.name") }} {{ t("security_setting.configuration") }}</legend>
 
 
   {% set nameForIsSamlEnabled = "settingForm[security:passport-saml:isEnabled]" %}
   {% set nameForIsSamlEnabled = "settingForm[security:passport-saml:isEnabled]" %}
-  {% set isSamlEnabled = settingForm['security:passport-saml:isEnabled'] %}
+  {% set isSamlEnabled  = getConfig('crowi', 'security:passport-saml:isEnabled') %}
+  {% set useOnlyEnvVars = getConfig('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions') %}
+  {% set siteUrl = settingForm['app:siteUrl'] || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/saml/callback' %}
+
+  {% if useOnlyEnvVars %}
+    <p class="alert alert-info">
+      {{ t("security_setting.SAML.note for the only env option", "SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS") }}
+    </p>
+  {% endif %}
 
 
   <div class="form-group">
   <div class="form-group">
-    <label for="{{nameForIsSamlEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.SAML.name") }}</label>
+    <label class="col-xs-3 control-label">{{ t("security_setting.SAML.name") }}</label>
     <div class="col-xs-6">
     <div class="col-xs-6">
-      <div class="btn-group btn-toggle" data-toggle="buttons">
+      <div class="btn-group btn-toggle {% if useOnlyEnvVars %}btn-group-disabled{% endif %}" data-toggle="buttons">
         <label class="btn btn-default btn-rounded btn-outline {% if isSamlEnabled %}active{% endif %}" data-active-class="primary">
         <label class="btn btn-default btn-rounded btn-outline {% if isSamlEnabled %}active{% endif %}" data-active-class="primary">
-          <input name="{{nameForIsSamlEnabled}}" value="true" type="radio"
-              {% if true === isSamlEnabled %}checked{% endif %}> ON
+          <input name="{{nameForIsSamlEnabled}}"
+                 value="true"
+                 type="radio"
+                 {% if true === isSamlEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> ON
         </label>
         </label>
         <label class="btn btn-default btn-rounded btn-outline {% if !isSamlEnabled %}active{% endif %}" data-active-class="default">
         <label class="btn btn-default btn-rounded btn-outline {% if !isSamlEnabled %}active{% endif %}" data-active-class="default">
-          <input name="{{nameForIsSamlEnabled}}" value="false" type="radio"
-              {% if !isSamlEnabled %}checked{% endif %}> OFF
+          <input name="{{nameForIsSamlEnabled}}"
+                 value="false"
+                 type="radio"
+                 {% if !isSamlEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> OFF
         </label>
         </label>
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>
-  <fieldset id="passport-saml-hide-when-disabled" {%if !isSamlEnabled %}style="display: none;"{% endif %}>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:entryPoint]" class="col-xs-3 control-label">{{ t("security_setting.SAML.entry_point") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-saml:entryPoint]" value="{{ settingForm['security:passport-saml:entryPoint'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.Use env var if empty", "SAML_ENTRY_POINT") }}
-          </small>
-        </p>
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+      <input class="form-control"
+             type="text"
+             value="{{ callbackUrl }}"
+             readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'SAML Identity') }}</p>
+      {% if !settingForm['app:siteUrl'] %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
       </div>
       </div>
+      {% endif %}
     </div>
     </div>
+  </div>
 
 
-    <div class="form-group">
-      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" value="{% if settingForm['app:siteUrl'] %}{{ settingForm['app:siteUrl'] }}{% else %}[INVALID] {% endif %}/passport/saml/callback" readonly>
-        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'SAML Identity') }}</p>
-        {% if !settingForm['app:siteUrl'] %}
-        <div class="alert alert-danger">
-          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
-        </div>
-        {% endif %}
-      </div>
-    </div>
+  <fieldset id="passport-saml-hide-when-disabled" {%if !isSamlEnabled %}style="display: none;"{% endif %}>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:issuer]" class="col-xs-3 control-label">{{ t("security_setting.SAML.issuer") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-saml:issuer]" value="{{ settingForm['security:passport-saml:issuer'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
-          </small>
-        </p>
-      </div>
+    {% set missingMandatoryConfigKeys = getSamlMissingMandatoryConfigKeys() %}
+    {% if missingMandatoryConfigKeys.length !== 0 %}
+    <div class="alert alert-danger">
+      {{ t("security_setting.missing mandatory configs") }}
+      <ul>
+        {% for missingMandatoryConfigKey in missingMandatoryConfigKeys %}
+        <li>{{ t("security_setting.form_item_name." + missingMandatoryConfigKey) }}</li>
+        {% endfor %}
+      </ul>
     </div>
     </div>
+    {% endif %}
+
+    <h4>Basic Settings</h4>
+    <table class="table authentication-settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:entryPoint") }}</th>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[security:passport-saml:entryPoint]"
+                   value="{{ getConfigFromDB('crowi', 'security:passport-saml:entryPoint') || '' }}"
+                   {% if useOnlyEnvVars %}readonly{% endif %}>
+          </td>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint') || '' }}"
+                   readonly>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_ENTRY_POINT") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:issuer") }}</th>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[security:passport-saml:issuer]"
+                   value="{{ getConfigFromDB('crowi', 'security:passport-saml:issuer') || '' }}"
+                   {% if useOnlyEnvVars %}readonly{% endif %}>
+          </td>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:issuer') || '' }}"
+                   readonly>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_ISSUER") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:cert") }}</th>
+          <td>
+            <textarea class="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      name="settingForm[security:passport-saml:cert]"
+                      {% if useOnlyEnvVars %}readonly{% endif %}
+            >{{ getConfigFromDB('crowi', 'security:passport-saml:cert') || '' }}</textarea>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.cert_detail") }}
+              </small>
+            </p>
+            <p>
+              <small>
+                e.g.
+                <pre>-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
+UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
+...
+crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
+pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+-----END CERTIFICATE-----</pre>
+              </small>
+            </p>
+          </td>
+          <td>
+            <textarea class="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      readonly
+            >{{ getConfigFromEnvVars('crowi', 'security:passport-saml:cert') || '' }}</textarea>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_CERT") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+      </tbody>
+    </table>
 
 
     <h4>Attribute Mapping</h4>
     <h4>Attribute Mapping</h4>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">Identifier</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.id_detail") }}
-          </small>
-        </p>
-      </div>
-    </div>
+    <table class="table authentication-settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapId") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapId]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapId') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.id_detail") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_ID") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapUsername") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapUsername]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.username_detail") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_USERNAME") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapMail") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapMail]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapMail') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("Email")) }}
+            </small>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_MAIL") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapFirstName") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapFirstName]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("security_setting.form_item_name.security:passport-saml:attrMapFirstName")) }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_FIRST_NAME") }}<br>
+              {{ t("security_setting.Use default if both are empty", "firstName") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapLastName") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapLastName]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("security_setting.form_item_name.security:passport-saml:attrMapLastName")) }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_LAST_NAME") }}<br>
+              {{ t("security_setting.Use default if both are empty", "lastName") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      </tbody>
+    </table>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.username_detail") }}
-          </small>
-        </p>
-      </div>
-    </div>
+    <h4>Attribute Mapping Options</h4>
 
 
     <div class="form-group">
     <div class="form-group">
-      <div class="col-xs-6 col-xs-offset-3">
+      <div class="col-xs-offset-1">
         <div class="checkbox checkbox-info">
         <div class="checkbox checkbox-info">
-          <input type="checkbox" id="bindByUserName-SAML" name="settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]" value="1"
-              {% if settingForm['security:passport-saml:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <input id="bindByUserName-SAML"
+                 type="checkbox"
+                 name="settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]"
+                 value="1"
+                 {% if getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser') %}checked{% endif %} />
           <label for="bindByUserName-SAML">
           <label for="bindByUserName-SAML">
             {{ t("security_setting.Treat username matching as identical", "username") }}
             {{ t("security_setting.Treat username matching as identical", "username") }}
           </label>
           </label>
@@ -105,23 +331,13 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapMail]" class="col-xs-3 control-label">Mail</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapMail]" value="{{ settingForm['security:passport-saml:attrMapMail'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("Email")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <div class="form-group">
-      <div class="col-xs-6 col-xs-offset-3">
+      <div class="col-xs-offset-1">
         <div class="checkbox checkbox-info">
         <div class="checkbox checkbox-info">
-          <input type="checkbox" id="bindByEmail-SAML" name="settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]" value="1"
-              {% if settingForm['security:passport-saml:isSameEmailTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <input id="bindByEmail-SAML"
+                 type="checkbox"
+                 name="settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]"
+                 value="1"
+                 {% if getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser') %}checked{% endif %} />
           <label for="bindByEmail-SAML">
           <label for="bindByEmail-SAML">
             {{ t("security_setting.Treat email matching as identical", "email") }}
             {{ t("security_setting.Treat email matching as identical", "email") }}
           </label>
           </label>
@@ -134,59 +350,6 @@
       </div>
       </div>
     </div>
     </div>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">{{ t("security_setting.SAML.First Name") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: firstName"
-            name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.First Name")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">{{ t("security_setting.SAML.Last Name") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: lastName"
-            name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.Last Name")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <h4>Options</h4>
-
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:cert]" class="col-xs-3 control-label">Certificate</label>
-      <div class="col-xs-6">
-        <textarea class="form-control input-sm" type="text" rows="5" name="settingForm[security:passport-saml:cert]">{{ settingForm['security:passport-saml:cert'] || '' }}</textarea>
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.cert_detail1") }}<br>
-            {{ t("security_setting.SAML.cert_detail2") }}
-          </small>
-        </p>
-        <p>
-          <small>
-            e.g.
-            <pre>-----BEGIN CERTIFICATE-----
-MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
-UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
-...
-crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
-pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
------END CERTIFICATE-----</pre>
-          </small>
-        </p>
-      </div>
-    </div>
-
   </fieldset>
   </fieldset>
 
 
   <div class="form-group" id="btn-update">
   <div class="form-group" id="btn-update">
@@ -199,6 +362,10 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
 </form>
 </form>
 
 
 <script>
 <script>
+  $('.btn-group-disabled').on('click', '.btn', function() {
+    return false;
+  });
+
   $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
   $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
     const isEnabled = ($(this).val() === "true");
     const isEnabled = ($(this).val() === "true");
 
 
@@ -209,5 +376,32 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       $('#passport-saml-hide-when-disabled').hide(400);
       $('#passport-saml-hide-when-disabled').hide(400);
     }
     }
   });
   });
+
+
+  /**
+   * The following script sets the class name 'unused' to the cell in from-env-vars column
+   * when the value of the corresponding cell from the database is not empty.
+   * It is used to indicate that the system does not use a value from the environment variables by setting a css style.
+   * This behavior is disabled when the system is in the use-only-env-vars mode.
+   */
+  $('.authentication-settings-table:not(.use-only-env-vars) tbody tr').each(function(_, element) {
+    const inputElemFromDB      = $('td:nth-of-type(1) input[type="text"], td:nth-of-type(1) textarea', element);
+    const inputElemFromEnvVars = $('td:nth-of-type(2) input[type="text"], td:nth-of-type(2) textarea', element);
+
+    // initialize
+    addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars);
+
+    // set keyup event handler
+    inputElemFromDB.keyup(function () { addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) });
+  });
+
+  function addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) {
+    if (inputElemFromDB.val() === '') {
+      inputElemFromEnvVars.parent().removeClass('unused');
+    }
+    else {
+      inputElemFromEnvVars.parent().addClass('unused');
+    }
+  };
 </script>
 </script>
 
 

+ 2 - 1
src/server/views/admin/widget/passport/twitter.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
   {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
   {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
   {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/twitter/callback' %}
+  {% set siteUrl = settingForm['app:siteUrl'] || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/twitter/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>