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

属性によるログイン制限を実装

utsushiiro 6 лет назад
Родитель
Сommit
b29840bd22

+ 2 - 1
resource/locales/ja/translation.json

@@ -449,7 +449,8 @@
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
-      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください",
+      "attr_based_login_control_expr_detail": "<code>属性名=値</code> を <code>|</code>(論理和)、 <code>&</code>(論理積) で連結した形式で記述してください。演算子の優先順位は論理積が論理和より高く、結合規則は左から右です。<br>例えば <code>Department=A | Department=B & Position=Leader</code> だと <code>Department</code> が <code>A</code> の場合, もしくは<code>Department</code> が <code>B</code> かつ <code>Position</code> が <code>Leader</code> の場合にログインを<strong>許可</strong>します。"
     },
     },
     "Basic": {
     "Basic": {
       "name": "Basic 認証",
       "name": "Basic 認証",

+ 1 - 0
src/server/form/admin/securityPassportSaml.js

@@ -14,4 +14,5 @@ module.exports = form(
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:ABLCRule]').trim(),
 );
 );

+ 12 - 2
src/server/routes/admin.js

@@ -781,8 +781,11 @@ module.exports = function(crowi, app) {
   /**
   /**
    * validate setting form values for SAML
    * 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.
+   * The following are checked.
+   * 
+   * - For the value of each mandatory items, 
+   *     check whether it from the environment variables is empty and form value to update it is empty.
+   * - validate the syntax of a attribute-based login control rule
    */
    */
   function validateSamlSettingForm(form, t) {
   function validateSamlSettingForm(form, t) {
     for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
     for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
@@ -792,6 +795,13 @@ module.exports = function(crowi, app) {
         form.errors.push(t('form_validation.required', formItemName));
         form.errors.push(t('form_validation.required', formItemName));
       }
       }
     }
     }
+
+    const rule = form.settingForm["security:passport-saml:ABLCRule"];
+    // Empty string disables attribute-based login control.
+    // So, when rule is empty string, validation is passed.
+    if (rule !== "" && rule != null && crowi.passportService.parseABLCRule(rule) === null) {
+      form.errors.push("Rule syntax is invalid");
+    }
   }
   }
 
 
   return actions;
   return actions;

+ 5 - 0
src/server/routes/login-passport.js

@@ -461,6 +461,11 @@ module.exports = function(crowi, app) {
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
+    // Attribute-based Login Control
+    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
+      return loginFailure(req, res);
+    }
+
     // login
     // login
     req.logIn(user, (err) => {
     req.logIn(user, (err) => {
       if (err != null) {
       if (err != null) {

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

@@ -247,6 +247,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  SAML_ABLC_RULE: {
+    ns:      'crowi',
+    key:     'security:passport-saml:ABLCRule',
+    type:    TYPES.STRING,
+    default: null,
+  },
   GCS_API_KEY_JSON_PATH: {
   GCS_API_KEY_JSON_PATH: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'gcs:apiKeyJsonPath',
     key:     'gcs:apiKeyJsonPath',

+ 1 - 0
src/server/service/config-manager.js

@@ -15,6 +15,7 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:attrMapFirstName',
   'security:passport-saml:attrMapFirstName',
   'security:passport-saml:attrMapLastName',
   'security:passport-saml:attrMapLastName',
   'security:passport-saml:cert',
   'security:passport-saml:cert',
+  'security:passport-saml:ABLCRule'
 ];
 ];
 
 
 class ConfigManager {
 class ConfigManager {

+ 113 - 0
src/server/service/passport.js

@@ -598,6 +598,119 @@ class PassportService {
     return missingRequireds;
     return missingRequireds;
   }
   }
 
 
+  /**
+   * Verify that a SAML response meets the attribute-base login control rule
+   */
+  verifySAMLResponseByABLCRule(response) {
+    const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
+    if (rule == null) {
+      return true;
+    }
+
+    const expr = this.parseABLCRule(rule);
+    if (expr === null) {
+      return false;
+    }
+    debug({"Parsed Rule": JSON.stringify(expr, null, 2)});
+
+    const attributes = this.extractAttributesFromSAMLResponse(response);
+    debug({"Extracted Attributes": JSON.stringify(attributes, null, 2)});
+
+    let evaluated_expr = false
+    for (const or_op of expr) {
+      let evaluated_or_op = true;
+      for (const and_op of or_op) {
+        if (attributes[and_op[0]] == null) {
+          evaluated_or_op = false
+          break;
+        }
+        evaluated_or_op = evaluated_or_op && attributes[and_op[0]].includes(and_op[1])
+      }
+      evaluated_expr = evaluated_expr || evaluated_or_op;
+    }
+
+    return evaluated_expr;
+  }
+
+  /**
+   * Parse a rule string for the attribute-based login control
+   * 
+   * The syntax rules are as follows.
+   * <attr> and <value> are any characters except "|", "&", "=".
+   * 
+   * ## Syntax
+   *    <expr>   ::= <or_op> | <or_op> "|" <expr>
+   *    <or_op>  ::= <and_op> | <and_op> "&" <or_op>
+   *    <and_op> ::= <attr> "=" <value>
+   * 
+   * ## Example
+   *  In:  "Department = A | Department = B & Position = Leader"
+   *  Out: 
+   *    [
+   *      [
+   *        ["Department", "A"]
+   *      ],
+   *      [
+   *        ["Department","B"],
+   *        ["Position","Leader"]
+   *      ]
+   *    ]
+   * 
+   *   In:  Invalid syntax string like a "This is a & bad & rule string."
+   *   Out: null
+   */
+  parseABLCRule(rule) {
+    let expr = rule.split("|");
+    expr = expr.map(or_op => or_op.trim().split("&"));
+    expr = expr.map(or_op => or_op.map(and_op => and_op.trim().split("=")));
+    expr = expr.map(or_op => or_op.map(and_op => and_op.map(v => v.trim())));
+    for (const or_op of expr) {
+      for (const and_op of or_op) {
+        if (and_op.length !== 2) {
+          return null;
+        }
+      }
+    }
+    return expr;
+  }
+
+
+  /**
+   * Extract attributes from a SAML response
+   * 
+   * The format of extracted attributes is the following.
+   * 
+   * {
+   *    "attribute_name1": ["value1", "value2", ...],
+   *    "attribute_name2": ["value1", "value2", ...],
+   *    ...
+   * }
+   */
+  extractAttributesFromSAMLResponse(response) {
+    const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
+    if (attributeStatement == null || attributeStatement[0] == null) {
+      return {};
+    }
+
+    const attributes = attributeStatement[0].Attribute;
+    if (attributes == null) {
+      return {};
+    }
+
+    const result = {};
+    for (const attribute of attributes) {
+      const name = attribute.$.Name;
+      const attributeValues = attribute.AttributeValue.map(v => v._)
+      if (result[name] == null) {
+        result[name] = attributeValues;
+      }else {
+        result[name] = result[name].concat(attributeValues)
+      }
+    }
+    
+    return result;
+  }
+
   /**
   /**
    * reset BasicStrategy
    * reset BasicStrategy
    *
    *

+ 49 - 0
src/server/views/admin/widget/passport/saml.html

@@ -349,6 +349,55 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       </div>
       </div>
     </div>
     </div>
 
 
+    <h4>Attribute-based Login Control</h4>
+
+    <p class="help-block">
+      <small>
+        SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。<br>
+      </small>
+    </p>
+
+    <table class="table 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>
+          Rule
+        </th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:ABLCRule]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.attr_based_login_control_expr_detail") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ABLC_RULE") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      </tbody>
+    </table>
+
   </fieldset>
   </fieldset>
 
 
   <div class="form-group" id="btn-update">
   <div class="form-group" id="btn-update">