Просмотр исходного кода

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

Yuto Iwata 7 лет назад
Родитель
Сommit
ebb9afab46

+ 1 - 0
CHANGES.md

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

+ 12 - 20
README.md

@@ -74,7 +74,7 @@ See also [weseek/growi-docker-compose][docker-compose]
 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
 
@@ -83,7 +83,7 @@ On-premise
 - yarn
 - 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
 
@@ -114,7 +114,7 @@ export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi
 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
 
@@ -145,9 +145,7 @@ yarn add growi-plugin-lsx
 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
@@ -172,7 +170,7 @@ Environment Variables
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
 * **Option to integrate with external systems**
     * 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.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
@@ -191,10 +189,8 @@ Environment Variables
 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
 ============
@@ -212,24 +208,20 @@ Missing a Feature?
 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.
 
-* 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.
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
 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
 ------------------

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

@@ -101,6 +101,10 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
 
+  "form_validation": {
+    "required": "<code>%s</code> is required"
+  },
+
   "installer": {
     "setup": "Setup",
     "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_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 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": {
       "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",
@@ -414,15 +420,12 @@
     },
     "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",
       "username_detail": "Specification of mappings for <code>username</code> 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": {
       "register": "Register for %s",
@@ -457,6 +460,16 @@
         "github": "How to configure GitHub 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": "削除済みページ",
   "Sign out": "ログアウト",
 
+  "form_validation": {
+    "required": "<code>%s</code> に値を入力してください"
+  },
+
   "installer": {
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
@@ -395,6 +399,8 @@
     "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
+    "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -427,15 +433,12 @@
     },
     "SAML": {
       "name": "SAML",
-      "entry_point": "エントリーポイント",
-      "issuer": "発行者",
-      "First Name": "姓",
-      "Last Name": "名",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "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": {
       "register": "%sに登録",
@@ -470,6 +473,16 @@
         "github": "GitHub 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": {

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

@@ -79,6 +79,14 @@
     .btn.active[data-active-class="primary"] {
       @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
@@ -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: {
         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());

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

@@ -4,12 +4,12 @@ const form = require('express-form');
 const field = form.field;
 
 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:attrMapLastName]').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);
   };
 
-  configSchema.statics.isEnabledPassportSaml = function(config) {
-    const key = 'security:passport-saml:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
   configSchema.statics.isEnabledPassportGoogle = function(config) {
     const key = 'security:passport-google:isEnabled';
     return getValueForCrowiNS(config, key);
@@ -342,16 +337,6 @@ module.exports = function(crowi) {
     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) {
     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) => {
     const form = req.form.settingForm;
 
+    validateSamlSettingForm(req.form, req.t);
+
     if (!req.form.isValid) {
       return res.json({status: false, message: req.form.errors.join('\n')});
     }
 
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
 
     // reset strategy
     await crowi.passportService.resetSamlStrategy();
     // setup strategy
-    if (Config.isEnabledPassportSaml(config)) {
+    if (crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled')) {
       try {
         await crowi.passportService.setupSamlStrategy(true);
       }
@@ -1489,6 +1490,22 @@ module.exports = function(crowi, app) {
     }, 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;
 };
 

+ 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')
     , passport = require('passport')
     , config = crowi.getConfig()
-    , Config = crowi.model('Config')
     , ExternalAccount = crowi.model('ExternalAccount')
     , passportService = crowi.passportService
     ;
@@ -355,11 +354,11 @@ module.exports = function(crowi, app) {
   const loginPassportSamlCallback = async(req, res) => {
     const providerId = '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;
     try {
@@ -428,8 +427,9 @@ module.exports = function(crowi, app) {
 
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
     // 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 {
       // find or register(create) user
       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:    ,
   //   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: {
     ns:      'crowi',
     key:     'security:passport-saml:entryPoint',
@@ -128,6 +140,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     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: {
     ns:      'crowi',
     key:     'security:passport-saml:cert',
@@ -153,6 +195,20 @@ class ConfigLoader {
     let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
     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 {
       fromDB: mergedConfigFromDB,
       fromEnvVars: configFromEnvVars

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

@@ -1,6 +1,18 @@
 const ConfigLoader = require('../service/config-loader')
   , 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 {
 
   constructor(configModel) {
@@ -21,24 +33,82 @@ class ConfigManager {
   /**
    * 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) {
+    if (this.searchOnlyFromEnvVarConfigs('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')) {
+      return this.searchInSAMLUseOnlyEnvMode(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) {
     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
-   * @returns {boolean}
    */
   configExistsInDB(namespace, key) {
     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
-   * @returns {boolean}
    */
   configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
@@ -87,38 +191,8 @@ class ConfigManager {
     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
      */
     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.model('Config');
-    const isSamlEnabled = Config.isEnabledPassportSaml(config);
+    const configManager = this.crowi.configManager;
+    const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
 
     // when disabled
     if (!isSamlEnabled) {
@@ -437,12 +450,13 @@ class PassportService {
 
     debug('SamlStrategy: setting up..');
     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) {
       if (profile) {
         return done(null, profile);
@@ -467,6 +481,19 @@ class PassportService {
     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
    *
@@ -494,6 +521,15 @@ class PassportService {
     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;

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

@@ -42,6 +42,26 @@ module.exports = function(crowi, app, req, locals) {
     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
    */
@@ -120,6 +140,10 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
   };
 
+  locals.getSamlMissingMandatoryConfigKeys = function() {
+    return crowi.passportService.getSamlMissingMandatoryConfigKeys();
+  };
+
   locals.googleLoginEnabled = function() {
     // return false if Passport is enabled
     // 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 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">
     <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 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">
     <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>
 
   {% 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">
-    <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="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">
-          <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 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>
       </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>
+      {% endif %}
     </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>
+    {% 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>
 
-    <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="col-xs-6 col-xs-offset-3">
+      <div class="col-xs-offset-1">
         <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">
             {{ t("security_setting.Treat username matching as identical", "username") }}
           </label>
@@ -105,23 +331,13 @@
     </div>
 
     <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">
-          <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">
             {{ t("security_setting.Treat email matching as identical", "email") }}
           </label>
@@ -134,59 +350,6 @@
       </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>
 
   <div class="form-group" id="btn-update">
@@ -199,6 +362,10 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
 </form>
 
 <script>
+  $('.btn-group-disabled').on('click', '.btn', function() {
+    return false;
+  });
+
   $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
     const isEnabled = ($(this).val() === "true");
 
@@ -209,5 +376,32 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       $('#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>
 

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

@@ -4,7 +4,8 @@
 
   {% set nameForIsTwitterEnabled = "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">
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>