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

Merge pull request #597 from weseek/feat/SSO

Feat/sso
Yuki Takei 7 лет назад
Родитель
Сommit
568af0aa60

+ 1 - 0
package.json

@@ -104,6 +104,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
+    "passport-saml": "^0.35.0",
     "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",

+ 7 - 0
resource/locales/en-US/translation.json

@@ -309,6 +309,7 @@
     "recommended": "Recommended",
     "username_email_password": "Username, Email and Password authentication",
     "ldap_auth": "LDAP authentication",
+    "saml_auth": "SAML authentication",
     "google_auth2": "Google OAuth authentication",
     "google_auth2_by_crowi_desc": "However, this feature does not create new users, butit only makes it possible to login to the existing user who set up the association.",
     "facebook_auth2": "Facebook OAuth authentication",
@@ -374,6 +375,12 @@
       "group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
       "test_config": "Test Saved Configuration"
     },
+    "SAML": {
+      "name": "SAML",
+      "entry_point": "Entry Point",
+      "issuer": "Issuer",
+      "mapping_detail": "Specification of mappings for %s when creating new users"
+    },
     "OAuth": {
       "register": "Register for %s",
       "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",

+ 7 - 0
resource/locales/ja/translation.json

@@ -328,6 +328,7 @@
     "recommended": "推奨",
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "ldap_auth": "LDAP 認証",
+    "saml_auth": "SAML 認証",
     "google_auth2": "Google OAuth 認証",
     "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
     "facebook_auth2": "Facebook OAuth 認証",
@@ -392,6 +393,12 @@
       "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
       "test_config": "ログインテスト"
     },
+    "SAML": {
+      "name": "SAML",
+      "entry_point": "エントリーポイント",
+      "issuer": "発行者",
+      "mapping_detail": "新規ユーザーの%sに関連付ける属性"
+    },
     "OAuth": {
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",

+ 8 - 0
src/client/styles/scss/_login.scss

@@ -176,6 +176,14 @@
       background-color: #555;
     }
   }
+  .btn-login-oauth.fcbtn#saml {
+    .btn-label {
+      background-color: rgba(#55a79a, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
   .btn-register.fcbtn {
     .btn-label {
       background-color: rgba($brand-success, 0.4);

+ 1 - 0
src/server/crowi/index.js

@@ -282,6 +282,7 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupTwitterStrategy();
+    this.passportService.setupSamlStrategy();
   }
   catch (err) {
     logger.error(err);

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

@@ -0,0 +1,17 @@
+'use strict';
+
+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(),
+  field('settingForm[security:passport-saml:callbackUrl]').trim(),
+  field('settingForm[security:passport-saml:issuer]').trim(),
+  field('settingForm[security:passport-saml:attrMapId]'),
+  field('settingForm[security:passport-saml:attrMapUsername]'),
+  field('settingForm[security:passport-saml:attrMapMail]'),
+  field('settingForm[security:passport-saml:attrMapFirstName]'),
+  field('settingForm[security:passport-saml:attrMapLastName]'),
+  field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 1 - 0
src/server/form/index.js

@@ -21,6 +21,7 @@ module.exports = {
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
+    securityPassportSaml: require('./admin/securityPassportSaml'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),

+ 6 - 1
src/server/models/config.js

@@ -282,6 +282,11 @@ 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);
@@ -292,7 +297,7 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
-   configSchema.statics.isEnabledPassportTwitter = function(config) {
+  configSchema.statics.isEnabledPassportTwitter = function(config) {
     const key = 'security:passport-twitter:isEnabled';
     return getValueForCrowiNS(config, key);
   };

+ 28 - 0
src/server/routes/admin.js

@@ -1046,6 +1046,34 @@ module.exports = function(crowi, app) {
       });
   };
 
+  actions.api.securityPassportSamlSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    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();
+
+    // reset strategy
+    await crowi.passportService.resetSamlStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportSaml(config)) {
+      try {
+        await crowi.passportService.setupSamlStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetSamlStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
   actions.api.securityPassportGoogleSetting = async(req, res) => {
     const form = req.form.settingForm;
 

+ 6 - 3
src/server/routes/index.js

@@ -67,22 +67,25 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/google'        , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityGoogle, admin.api.securitySetting);
   app.post('/_api/admin/security/mechanism'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityMechanism, admin.api.securitySetting);
   app.post('/_api/admin/security/passport-ldap' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
+  app.post('/_api/admin/security/passport-saml' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
 
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
-  app.post('/_api/admin/security/passport-twitter' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
+  app.post('/_api/admin/security/passport-twitter', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
+  app.get('/passport/saml'                        , loginPassport.loginWithSaml);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
-  app.get('/passport/twitter/callback'             , loginPassport.loginPassportTwitterCallback);
+  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback);
+  app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); //change form name
-  app.post('/admin/markdown/xss-setting'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
+  app.post('/admin/markdown/xss-setting'      , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
 
   // markdown admin
   app.get('/admin/customize'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.customize.index);

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

@@ -311,6 +311,43 @@ module.exports = function(crowi, app) {
     });
   };
 
+  const loginWithSaml = function(req, res, next) {
+    if (!passportService.isSamlStrategySetup) {
+      debug('SamlStrategy has not been set up');
+      req.flash('warningMessage', 'SamlStrategy has not been set up');
+      return next();
+    }
+
+    passport.authenticate('saml')(req, res);
+  };
+
+  const loginPassportSamlCallback = async(req, res, next) => {
+    const providerId = 'saml';
+    const strategyName = 'saml';
+    const attrMapId = config.crowi['security:passport-saml:attrMapId'] || 'id';
+    const attrMapUsername = config.crowi['security:passport-saml:attrMapUsername'] || 'userName';
+    const attrMapFirstName = config.crowi['security:passport-saml:attrMapFirstName'] || 'firstName';
+    const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response[attrMapId],
+      'username': response[attrMapUsername],
+      'name': `${response[attrMapFirstName]} ${response[attrMapLastName]}`,
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
 
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
     return new Promise((resolve, reject) => {
@@ -372,8 +409,10 @@ module.exports = function(crowi, app) {
     loginWithGoogle,
     loginWithGitHub,
     loginWithTwitter,
+    loginWithSaml,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,
+    loginPassportSamlCallback,
   };
 };

+ 63 - 5
src/server/service/passport.js

@@ -5,6 +5,7 @@ const LdapStrategy = require('passport-ldapauth');
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
 const TwitterStrategy = require('passport-twitter').Strategy;
+const SamlStrategy = require('passport-saml').Strategy;
 
 /**
  * the service class of Passport
@@ -33,6 +34,21 @@ class PassportService {
      */
     this.isGoogleStrategySetup = false;
 
+    /**
+     * the flag whether GitHubStrategy is set up successfully
+     */
+    this.isGitHubStrategySetup = false;
+
+    /**
+     * the flag whether TwitterStrategy is set up successfully
+     */
+    this.isTwitterStrategySetup = false;
+
+    /**
+     * the flag whether SamlStrategy is set up successfully
+     */
+    this.isSamlStrategySetup = false;
+
     /**
      * the flag whether serializer/deserializer are set up successfully
      */
@@ -271,7 +287,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isGoogleEnabled = Config.isEnabledPassportGoogle(config);
 
     // when disabled
@@ -317,7 +332,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isGitHubEnabled = Config.isEnabledPassportGitHub(config);
 
     // when disabled
@@ -343,8 +357,9 @@ class PassportService {
     this.isGitHubStrategySetup = true;
     debug('GitHubStrategy: setup is done');
   }
+
   /**
-   * reset GoogleStrategy
+   * reset GitHubStrategy
    *
    * @memberof PassportService
    */
@@ -362,7 +377,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isTwitterEnabled = Config.isEnabledPassportTwitter(config);
 
     // when disabled
@@ -390,7 +404,7 @@ class PassportService {
   }
 
   /**
-   * reset GoogleStrategy
+   * reset TwitterStrategy
    *
    * @memberof PassportService
    */
@@ -400,6 +414,50 @@ class PassportService {
     this.isTwitterStrategySetup = false;
   }
 
+  setupSamlStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isSamlStrategySetup) {
+      throw new Error('SamlStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    const isSamlEnabled = Config.isEnabledPassportSaml(config);
+
+    // when disabled
+    if (!isSamlEnabled) {
+      return;
+    }
+
+    debug('SamlStrategy: setting up..');
+    passport.use(new SamlStrategy({
+      path: config.crowi['security:passport-saml:path'] || process.env.SAML_CALLBACK_URI,
+      entryPoint: config.crowi['security:passport-saml:entryPoint'] || process.env.SAML_ENTRY_POINT,
+      issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
+    }, function(profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isSamlStrategySetup = true;
+    debug('SamlStrategy: setup is done');
+  }
+
+  /**
+   * reset SamlStrategy
+   *
+   * @memberof PassportService
+   */
+  resetSamlStrategy() {
+    debug('SamlStrategy: reset');
+    passport.unuse('saml');
+    this.isSamlStrategySetup = false;
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 6 - 1
src/server/util/swigFunctions.js

@@ -81,6 +81,11 @@ module.exports = function(crowi, app, req, locals) {
     return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && !passportService.isLdapStrategySetup;
   };
 
+  locals.passportSamlLoginEnabled = function() {
+    let config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
+  };
+
   locals.googleLoginEnabled = function() {
     // return false if Passport is enabled
     // because official crowi mechanism is not used.
@@ -103,7 +108,7 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.passportTwitterLoginEnabled = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
   };
 

+ 9 - 1
src/server/views/admin/security.html

@@ -130,6 +130,7 @@
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
                 <li>{{ t("security_setting.ldap_auth") }}</li>
+                <li>{{ t("security_setting.saml_auth") }}</li>
                 <li>{{ t("security_setting.google_auth2") }}</li>
                 <li>{{ t("security_setting.github_auth2") }}</li>
                 <li>{{ t("security_setting.twitter_auth2") }}</li>
@@ -245,6 +246,9 @@
             <li class="active">
               <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
+            <li>
+              <a href="#passport-saml" data-toggle="tab" role="tab"><i class="fa fa-key"></i> SAML</a>
+            </li>
             <li>
               <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
             </li>
@@ -264,6 +268,10 @@
               {% include './widget/passport/ldap.html' with { settingForm: settingForm } %}
             </div>
 
+            <div id="passport-saml" class="tab-pane" role="tabpanel" >
+              {% include './widget/passport/saml.html' %}
+            </div>
+
             <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
               {% include './widget/passport/google-oauth.html' %}
             </div>
@@ -288,7 +296,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
+    $('#generalSetting, #samlSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

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

@@ -0,0 +1,157 @@
+<form action="/_api/admin/security/passport-saml" method="post" class="form-horizontal passportStrategy" id="samlSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <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'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsSamlEnabled}}" 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">
+        <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
+        </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
+        </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>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-saml:callbackUrl]" value="{{ settingForm['security:passport-saml:callbackUrl'] || '' }}"
+            placeholder="http(s)://${growi.host}/passport/saml/callback">
+        <p class="help-block">
+          Input <code>http(s)://${growi.host}/passport/saml/callback</code><br>
+          <small>
+            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <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>
+    </div>
+
+    <h4>Attribute Mapping</h4>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">User ID</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" placeholder="Default: id"
+            name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "User ID") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <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" placeholder="Default: username"
+            name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "Username") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <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 %} />
+          <label for="bindByUserName-SAML">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">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", "First Name") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">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", "Last Name") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+  </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
+</form>
+
+<script>
+  $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-saml-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-saml-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 11 - 2
src/server/views/login.html

@@ -144,8 +144,7 @@
           </form>
         </div>
         {% endif %}
-
-        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() %}
+        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() || passportSamlLoginEnabled() %}
         <hr class="mb-1">
         <div class="collapse collapse-oauth collapse-anchor">
           <div class="spacer"></div>
@@ -189,6 +188,16 @@
               <div class="small text-right">by Twitter Account</div>
             </form>
             {% endif %}
+            {% if passportSamlLoginEnabled() %}
+            <form role="form" action="/passport/saml" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="saml">
+                <span class="btn-label"><i class="fa fa-key"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">with SAML</div>
+            </form>
+            {% endif %}
           </div>{# ./d-flex flex-row flex-wrap #}
           <div class="spacer"></div>
         </div>

+ 66 - 9
yarn.lock

@@ -551,18 +551,18 @@ async@^0.9.0:
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
 
+async@^2.1.5, async@^2.4.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
+  dependencies:
+    lodash "^4.17.10"
+
 async@^2.3.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4"
   dependencies:
     lodash "^4.14.0"
 
-async@^2.4.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
-  dependencies:
-    lodash "^4.17.10"
-
 async@~0.2.6:
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -5811,6 +5811,10 @@ node-fetch@^1.0.1:
     encoding "^0.1.11"
     is-stream "^1.0.1"
 
+node-forge@^0.7.0:
+  version "0.7.6"
+  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
+
 node-forge@^0.7.1:
   version "0.7.1"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300"
@@ -6380,7 +6384,20 @@ passport-oauth2@1.x.x:
     uid2 "0.0.x"
     utils-merge "1.x.x"
 
-passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
+passport-saml@^0.35.0:
+  version "0.35.0"
+  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-0.35.0.tgz#06a4952bde9e003923e80efa5c6faffcf7d4f7e0"
+  dependencies:
+    debug "^3.1.0"
+    passport-strategy "*"
+    q "^1.5.0"
+    xml-crypto "^0.10.1"
+    xml-encryption "^0.11.0"
+    xml2js "0.4.x"
+    xmlbuilder "^9.0.4"
+    xmldom "0.1.x"
+
+passport-strategy@*, passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
 
@@ -6985,7 +7002,7 @@ punycode@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
 
-q@^1.0.1, q@^1.1.2:
+q@^1.0.1, q@^1.1.2, q@^1.5.0:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
 
@@ -9144,6 +9161,23 @@ x-xss-protection@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.1.0.tgz#4f1898c332deb1e7f2be1280efb3e2c53d69c1a7"
 
+xml-crypto@^0.10.1:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8"
+  dependencies:
+    xmldom "=0.1.19"
+    xpath.js ">=0.0.3"
+
+xml-encryption@^0.11.0:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-0.11.2.tgz#c217f5509547e34b500b829f2c0bca85cca73a21"
+  dependencies:
+    async "^2.1.5"
+    ejs "^2.5.6"
+    node-forge "^0.7.0"
+    xmldom "~0.1.15"
+    xpath "0.0.27"
+
 xml2js@0.4.17:
   version "0.4.17"
   resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.17.tgz#17be93eaae3f3b779359c795b419705a8817e868"
@@ -9151,20 +9185,43 @@ xml2js@0.4.17:
     sax ">=0.6.0"
     xmlbuilder "^4.1.0"
 
+xml2js@0.4.x:
+  version "0.4.19"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~9.0.1"
+
 xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.2.1.tgz#aa58a3041a066f90eaa16c2f5389ff19f3f461a5"
   dependencies:
     lodash "^4.0.0"
 
-xmldom@0.1.x:
+xmlbuilder@^9.0.4, xmlbuilder@~9.0.1:
+  version "9.0.7"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+
+xmldom@0.1.x, xmldom@~0.1.15:
   version "0.1.27"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
 
+xmldom@=0.1.19:
+  version "0.1.19"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
+
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
+xpath.js@>=0.0.3:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1"
+
+xpath@0.0.27:
+  version "0.0.27"
+  resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92"
+
 xss@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.3.tgz#d04bd2558fd6c29c46113824d5e8b2a910054e23"