فهرست منبع

Merge pull request #207 from weseek/feat/ldap-auth

Feat/ldap auth
Yuki Takei 8 سال پیش
والد
کامیت
8721c3befc
37فایلهای تغییر یافته به همراه2022 افزوده شده و 104 حذف شده
  1. 4 2
      config/env.dev.js
  2. 16 0
      lib/crowi/dev.js
  3. 9 4
      lib/crowi/index.js
  4. 20 0
      lib/form/admin/securityPassportLdap.js
  5. 1 0
      lib/form/index.js
  6. 1 1
      lib/locales/en-US/translation.json
  7. 1 1
      lib/locales/ja/translation.json
  8. 13 0
      lib/models/config.js
  9. 148 0
      lib/models/external-account.js
  10. 1 0
      lib/models/index.js
  11. 42 6
      lib/models/user.js
  12. 93 0
      lib/routes/admin.js
  13. 13 1
      lib/routes/index.js
  14. 131 15
      lib/routes/login-passport.js
  15. 138 0
      lib/routes/me.js
  16. 172 2
      lib/service/passport.js
  17. 2 1
      lib/util/middlewares.js
  18. 33 0
      lib/util/swigFunctions.js
  19. 131 0
      lib/views/admin/external-accounts.html
  20. 120 52
      lib/views/admin/security.html
  21. 5 1
      lib/views/admin/users.html
  22. 1 1
      lib/views/admin/widget/menu.html
  23. 6 0
      lib/views/admin/widget/passport/facebook.html
  24. 6 0
      lib/views/admin/widget/passport/github.html
  25. 82 0
      lib/views/admin/widget/passport/google-oauth.html
  26. 261 0
      lib/views/admin/widget/passport/ldap.html
  27. 6 0
      lib/views/admin/widget/passport/twitter.html
  28. 44 3
      lib/views/login.html
  29. 1 0
      lib/views/me/api_token.html
  30. 251 0
      lib/views/me/external-accounts.html
  31. 3 2
      lib/views/me/index.html
  32. 3 2
      lib/views/me/password.html
  33. 72 0
      lib/views/widget/passport/ldap-association-tester.html
  34. 2 0
      package.json
  35. 4 1
      resource/css/_admin.scss
  36. 2 1
      resource/js/components/User/UserPicture.js
  37. 184 8
      yarn.lock

+ 4 - 2
config/env.dev.js

@@ -11,12 +11,14 @@ module.exports = {
   // filters for debug
   DEBUG: [
     // 'express:*',
+    // 'crowi:*',
     // 'crowi:crowi',
+    'crowi:crowi:dev',
     'crowi:crowi:express-init',
+    'crowi:models:external-account',
     // 'crowi:routes:login',
     'crowi:routes:login-passport',
-    // 'crowi:service:PassportService',
-    // 'crowi:*',
+    'crowi:service:PassportService',
     // 'crowi:routes:page',
     // 'crowi:plugins:*',
     // 'crowi:InterceptorManager',

+ 16 - 0
lib/crowi/dev.js

@@ -4,6 +4,7 @@ const webpack = require('webpack');
 const helpers = require('./helpers');
 
 const swig = require('swig-templates');
+const onHeaders = require('on-headers')
 const LRWebSocketServer = require('livereload-server/lib/server');
 
 class CrowiDev {
@@ -68,14 +69,28 @@ class CrowiDev {
    * @memberOf CrowiDev
    */
   setup(server, app) {
+    this.setupHeaderDebugger(app);
     this.setupEasyLiveReload(app);
   }
 
+  setupHeaderDebugger(app) {
+    debug('setupHeaderDebugger');
+
+    app.use((req, res, next) => {
+      onHeaders(res, () => {
+        debug('HEADERS GOING TO BE WRITTEN');
+      });
+      next();
+    });
+  }
+
   setupEasyLiveReload(app) {
     if (!helpers.hasProcessFlag('livereload')) {
       return;
     }
 
+    debug('setupEasyLiveReload');
+
     const livereload = require('easy-livereload');
     app.use(livereload({
       watchDirs: [
@@ -93,6 +108,7 @@ class CrowiDev {
         && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
 
       const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
+      debug('loading Plugins for development', pluginNames);
 
       // merge and remove duplicates
       if (pluginNames.length > 0) {

+ 9 - 4
lib/crowi/index.js

@@ -36,6 +36,7 @@ function Crowi (rootdir, env)
   this.searcher = null;
   this.mailer = {};
   this.interceptorManager = {};
+  this.passportService = null;
 
   this.tokens = null;
 
@@ -262,11 +263,15 @@ Crowi.prototype.setupPassport = function() {
 
   debug('Passport is enabled');
 
+  // initialize service
   const PassportService = require('../service/passport');
-
-  const passportService = new PassportService(this);
-  passportService.setupLocalStrategy();
-  passportService.setupSerializer();
+  if (this.passportService == null) {
+    this.passportService = new PassportService(this);
+  }
+  this.passportService.setupSerializer();
+  // setup strategies
+  this.passportService.setupLocalStrategy();
+  this.passportService.setupLdapStrategy();
 
   return Promise.resolve();
 }

+ 20 - 0
lib/form/admin/securityPassportLdap.js

@@ -0,0 +1,20 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-ldap:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-ldap:serverUrl]').trim()
+      // https://regex101.com/r/E0UL6D/1
+      .is(/^ldaps?:\/\/([^\/\s]+)\/([^\/\s]+)$/, 'Server URL is invalid. <small><a href="https://regex101.com/r/E0UL6D/1">&gt;&gt; Regex</a></small>'),
+  field('settingForm[security:passport-ldap:isUserBind]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-ldap:bindDN]').trim()
+      // https://regex101.com/r/jK8lpO/1
+      .is(/^(,?[^,=\s]+=[^,=\s]+){1,}$/, 'Bind DN is invalid. <small><a href="https://regex101.com/r/jK8lpO/1">&gt;&gt; Regex</a></small>'),
+  field('settingForm[security:passport-ldap:bindDNPassword]'),
+  field('settingForm[security:passport-ldap:searchFilter]'),
+  field('settingForm[security:passport-ldap:attrMapUsername]')
+);
+

+ 1 - 0
lib/form/index.js

@@ -18,6 +18,7 @@ module.exports = {
     securityGeneral: require('./admin/securityGeneral'),
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
+    securityPassportLdap: require('./admin/securityPassportLdap'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),

+ 1 - 1
lib/locales/en-US/translation.json

@@ -125,7 +125,7 @@
   "Current password": "Current password",
   "New password": "New password",
   "Re-enter new password": "Re-enter new password",
-  "Please set a password": "Please set a password",
+  "Password is not set": "Password is not set",
   "You can sign in with email and password": "You can sign in with <code>%s</code> and password",
 
   "API Settings": "API Settings",

+ 1 - 1
lib/locales/ja/translation.json

@@ -124,7 +124,7 @@
   "Current password": "現在のパスワード",
   "New password": "新しいパスワード",
   "Re-enter new password": "(確認用)",
-  "Please set a password": "パスワードを設定してください",
+  "Password is not set": "パスワードが設定されていません",
   "You can sign in with email and password": "<code>%s</code> と設定されたパスワードの組み合わせでログイン可能になります。",
 
   "API Settings": "API設定",

+ 13 - 0
lib/models/config.js

@@ -54,6 +54,13 @@ module.exports = function(crowi) {
       'security:registrationWhiteList' : [],
 
       'security:isEnabledPassport' : false,
+      'security:passport-ldap:isEnabled' : false,
+      'security:passport-ldap:serverUrl' : undefined,
+      'security:passport-ldap:isUserBind' : undefined,
+      'security:passport-ldap:bindDN' : undefined,
+      'security:passport-ldap:bindDNPassword' : undefined,
+      'security:passport-ldap:searchFilter' : undefined,
+      'security:passport-ldap:attrMapUsername' : undefined,
 
       'aws:bucket'          : 'crowi',
       'aws:region'          : 'ap-northeast-1',
@@ -245,6 +252,12 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.isEnabledPassportLdap = function(config)
+  {
+    const key = 'security:passport-ldap:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config)
   {
     var method = crowi.env.FILE_UPLOAD || 'aws';

+ 148 - 0
lib/models/external-account.js

@@ -0,0 +1,148 @@
+const debug = require('debug')('crowi:models:external-account');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
+const uniqueValidator = require('mongoose-unique-validator');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  providerType: { type: String, required: true },
+  accountId: { type: String, required: true },
+  user: { type: ObjectId, ref: 'User', required: true },
+  createdAt: { type: Date, default: Date.now, required: true },
+});
+// compound index
+schema.index({ providerType: 1, accountId: 1}, { unique: true });
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * ExternalAccount Class
+ *
+ * @class ExternalAccount
+ */
+class ExternalAccount {
+
+  /**
+   * limit items num for pagination
+   *
+   * @readonly
+   * @static
+   * @memberof ExternalAccount
+   */
+  static get DEFAULT_LIMIT() {
+    return 50;
+  }
+
+  static set crowi(crowi) {
+    this._crowi = crowi;
+  }
+
+  static get crowi() {
+    return this._crowi;
+  }
+
+  /**
+   * get the populated user entity
+   *
+   * @returns Promise<User>
+   * @memberof ExternalAccount
+   */
+  getPopulatedUser() {
+    return this.populate('user').execPopulate()
+      .then((account) => {
+        return account.user;
+      })
+  }
+
+  /**
+   * find an account or register if not found
+   *
+   * @static
+   * @param {string} providerType
+   * @param {string} accountId
+   * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
+   * @returns {Promise<ExternalAccount>}
+   * @memberof ExternalAccount
+   */
+  static findOrRegister(providerType, accountId, usernameToBeRegistered) {
+
+    return this.findOne({ providerType, accountId })
+      .then((account) => {
+        // found
+        if (account != null) {
+          debug(`ExternalAccount '${accountId}' is found `, account);
+          return account;
+        }
+        // not found
+        else {
+          debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
+
+          const User = ExternalAccount.crowi.model('User');
+
+          return User.count({username: usernameToBeRegistered})
+            .then((count) => {
+              // throw Exception when count is not zero
+              if (count > 0) {
+                throw new DuplicatedUsernameException(`username '${usernameToBeRegistered}' has already been existed`);
+              }
+
+              // create user with STATUS_ACTIVE
+              return User.createUser('', usernameToBeRegistered, undefined, undefined, undefined, User.STATUS_ACTIVE);
+            })
+            .then((user) => {
+              return this.create({ providerType: 'ldap', accountId, user: user._id });
+            });
+        }
+      });
+
+  }
+
+  /**
+   * find all entities with pagination
+   *
+   * @see https://github.com/edwardhotchkiss/mongoose-paginate
+   *
+   * @static
+   * @param {any} opts mongoose-paginate options object
+   * @returns {Promise<any>} mongoose-paginate result object
+   * @memberof ExternalAccount
+   */
+  static findAllWithPagination(opts) {
+    const query = {};
+    const options = Object.assign({ populate: 'user' }, opts);
+    if (options.sort == null) {
+      options.sort = {accountId: 1, createdAt: 1};
+    }
+    if (options.limit == null) {
+      options.limit = ExternalAccount.DEFAULT_LIMIT;
+    }
+
+    return this.paginate(query, options)
+      .catch((err) => {
+        debug('Error on pagination:', err);
+      });
+  }
+
+}
+
+/**
+ * The Exception class thrown when User.username is duplicated when creating user
+ *
+ * @class DuplicatedUsernameException
+ */
+class DuplicatedUsernameException {
+  constructor(message) {
+    this.name = this.constructor.name;
+    this.message = message;
+  }
+}
+
+module.exports = function(crowi) {
+  ExternalAccount.crowi = crowi;
+  schema.loadClass(ExternalAccount);
+  return mongoose.model('ExternalAccount', schema);
+}

+ 1 - 0
lib/models/index.js

@@ -3,6 +3,7 @@
 module.exports = {
   Page: require('./page'),
   User: require('./user'),
+  ExternalAccount: require('./external-account'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),

+ 42 - 6
lib/models/user.js

@@ -31,8 +31,8 @@ module.exports = function(crowi) {
     isGravatarEnabled: { type: Boolean, default: false },
     googleId: String,
     name: { type: String },
-    username: { type: String, index: true },
-    email: { type: String, required: true, unique: true },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
     introduction: { type: String },
     password: String,
     apiToken: String,
@@ -666,26 +666,62 @@ module.exports = function(crowi) {
     );
   };
 
-  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
+  userSchema.statics.createUserByEmailAndPasswordAndStatus = function(name, username, email, password, lang, status, callback) {
     var User = this
       , newUser = new User();
 
     newUser.name = name;
     newUser.username = username;
     newUser.email = email;
-    newUser.setPassword(password);
-    newUser.lang = lang;
+    if (password != null) {
+      newUser.setPassword(password);
+    }
+    if (lang != null) {
+      newUser.lang = lang;
+    }
     newUser.createdAt = Date.now();
-    newUser.status = decideUserStatusOnRegistration();
+    newUser.status = status || decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (err) {
+        debug('createUserByEmailAndPassword failed: ', err);
+        return callback(err);
+      }
+
       if (userData.status == STATUS_ACTIVE) {
         userEvent.emit('activated', userData);
       }
       return callback(err, userData);
     });
+  }
+
+  /**
+   * A wrapper function of createUserByEmailAndPasswordAndStatus
+   *
+   * @return {Promise<User>}
+   */
+  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
+    this.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, undefined, callback);
   };
 
+  /**
+   * A wrapper function of createUserByEmailAndPasswordAndStatus
+   *
+   * @return {Promise<User>}
+   */
+  userSchema.statics.createUser = function(name, username, email, password, lang, status) {
+    const User = this;
+
+    return new Promise((resolve, reject) => {
+      User.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, status, (err, userData) => {
+        if (err) {
+          return reject(err);
+        }
+        return resolve(userData);
+      });
+    });
+  }
+
   userSchema.statics.createUserPictureFilePath = function(user, name) {
     var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
 

+ 93 - 0
lib/routes/admin.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
     , models = crowi.models
     , Page = models.Page
     , User = models.User
+    , ExternalAccount = models.ExternalAccount
     , Config = models.Config
     , PluginUtils = require('../plugins/plugin-utils')
     , pluginUtils = new PluginUtils()
@@ -426,6 +427,16 @@ module.exports = function(crowi, app) {
         });
       });
     })
+    .then((userData) => {
+      // remove all External Accounts
+      ExternalAccount.remove({user: userData})
+      .then((err) => {
+        if (err) {
+          throw new Error(err.message);
+        }
+        return userData;
+      })
+    })
     .then((userData) => {
       return Page.removePageByPath(`/user/${username}`)
         .then(() => userData);
@@ -471,6 +482,37 @@ module.exports = function(crowi, app) {
     });
   }
 
+  actions.externalAccount = {};
+  actions.externalAccount.index = function(req, res) {
+    const page = parseInt(req.query.page) || 1;
+
+    ExternalAccount.findAllWithPagination({page})
+      .then((result) => {
+        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+
+        return res.render('admin/external-accounts', {
+          accounts: result.docs,
+          pager: pager
+        })
+      });
+  };
+
+  actions.externalAccount.remove = function(req, res) {
+    const accountId = req.params.id;
+
+    ExternalAccount.findOneAndRemove({accountId})
+      .then((result) => {
+        if (result == null) {
+          req.flash('errorMessage', '削除に失敗しました。');
+          return res.redirect('/admin/users/external-accounts');
+        }
+        else {
+          req.flash('successMessage', `外部アカウント '${accountId}' を削除しました`);
+          return res.redirect('/admin/users/external-accounts');
+        }
+      });
+  };
+
   actions.api = {};
   actions.api.appSetting = function(req, res) {
     var form = req.form.settingForm;
@@ -508,6 +550,31 @@ module.exports = function(crowi, app) {
     }
   };
 
+  actions.api.securityPassportLdapSetting = function(req, res) {
+    var form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    return saveSettingAsync(form)
+      .then(() => {
+        const config = crowi.getConfig();
+
+        // reset strategy
+        crowi.passportService.resetLdapStrategy();
+        // setup strategy
+        if (Config.isEnabledPassportLdap(config)) {
+          crowi.passportService.setupLdapStrategy(true);
+        }
+        return;
+      })
+      .then(() => {
+        res.json({status: true});
+      });
+  };
+
   actions.api.customizeSetting = function(req, res) {
     var form = req.form.settingForm;
 
@@ -571,6 +638,13 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * save settings, update config cache, and response json
+   *
+   * @param {any} req
+   * @param {any} res
+   * @param {any} form
+   */
   function saveSetting(req, res, form)
   {
     Config.updateNamespaceByArray('crowi', form, function(err, config) {
@@ -579,6 +653,25 @@ module.exports = function(crowi, app) {
     });
   }
 
+  /**
+   * save settings, update config cache ONLY. (this method don't response json)
+   *
+   * @param {any} form
+   * @returns
+   */
+  function saveSettingAsync(form) {
+    return new Promise((resolve, reject) => {
+      Config.updateNamespaceByArray('crowi', form, (err, config) => {
+        if (err) {
+          return reject(err)
+        };
+
+        Config.updateConfigCache('crowi', config);
+        return resolve();
+      });
+    });
+  }
+
   function validateMailSetting(req, form, callback)
   {
     var mailer = crowi.mailer;

+ 13 - 1
lib/routes/index.js

@@ -37,7 +37,8 @@ module.exports = function(crowi, app) {
 
   // switch POST /login route
   if (Config.isEnabledPassport(config)) {
-    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLdap, loginPassport.loginWithLocal, loginPassport.loginFailure);
+    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+    app.post('/_api/login/testLdap'  , loginRequired(crowi, app) , form.login , loginPassport.testLdapCredentials);
   }
   else {
     app.post('/login'                , form.login                           , csrf, login.login);
@@ -62,6 +63,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/general'       , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.securityGeneral, admin.api.securitySetting);
   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-ldap-test' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportLdap, admin.api.securitySetting);
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
@@ -100,10 +103,19 @@ module.exports = function(crowi, app) {
   // new route patterns from here:
   app.post('/_api/admin/users.resetPassword'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.resetPassword);
 
+  app.get('/admin/users/external-accounts'               , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.index);
+  app.post('/admin/users/external-accounts/:id/remove'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.remove);
+
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/apiToken'              , loginRequired(crowi, app) , me.apiToken);
   app.post('/me'                      , form.me.user              , loginRequired(crowi, app) , me.index);
+  // external-accounts
+  if (Config.isEnabledPassport(config)) {
+    app.get('/me/external-accounts'                         , loginRequired(crowi, app) , me.externalAccounts.list);
+    app.post('/me/external-accounts/disassociate'           , loginRequired(crowi, app) , me.externalAccounts.disassociate);
+    app.post('/me/external-accounts/associateLdap'          , loginRequired(crowi, app) , form.login , me.externalAccounts.associateLdap);
+  }
   app.post('/me/password'             , form.me.password          , loginRequired(crowi, app) , me.password);
   app.post('/me/imagetype'            , form.me.imagetype         , loginRequired(crowi, app) , me.imagetype);
   app.post('/me/apiToken'             , form.me.apiToken          , loginRequired(crowi, app) , me.apiToken);

+ 131 - 15
lib/routes/login-passport.js

@@ -4,7 +4,10 @@ module.exports = function(crowi, app) {
   var debug = require('debug')('crowi:routes:login-passport')
     , passport = require('passport')
     , config = crowi.getConfig()
-    , Config = crowi.model('Config');
+    , Config = crowi.model('Config')
+    , ExternalAccount = crowi.model('ExternalAccount')
+    , passportService = crowi.passportService
+    ;
 
   /**
    * success handler
@@ -34,19 +37,130 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  const loginFailure = (req, res) => {
-    req.flash('warningMessage', 'Sign in failure.');
+  const loginFailure = (req, res, next) => {
+    req.flash('errorMessage', 'Sign in failure.');
     return res.redirect('/login');
   };
 
-
+  /**
+   * middleware that login with LdapStrategy
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
   const loginWithLdap = (req, res, next) => {
-    // TODO impl with vesse/passport-ldapauth
-    return next();
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return next();
+    }
+
+    const loginForm = req.body.loginForm;
+
+    if (!req.form.isValid) {
+      debug("invalid form");
+      return res.render('login', {
+      });
+    }
+
+    passport.authenticate('ldapauth', (err, ldapAccountInfo, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      debug('--- authenticate with LdapStrategy ---');
+      debug('ldapAccountInfo', ldapAccountInfo);
+      debug('info', info);
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        req.flash('warningMessage', 'LDAP Server Error occured.');
+        return next(); // pass and the flash message is displayed when all of authentications are failed.
+      }
+
+      // authentication failure
+      if (!ldapAccountInfo) { return next(); }
+
+      /*
+       * authentication success
+       */
+      // it is guaranteed that username that is input from form can be acquired
+      // because this processes after authentication
+      const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+
+      const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
+      const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
+
+      // find or register(create) user
+      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered)
+        .then((externalAccount) => {
+          return externalAccount.getPopulatedUser();
+        })
+        .then((user) => {
+          // login
+          req.logIn(user, (err) => {
+            if (err) { return next(); }
+            else {
+              return loginSuccess(req, res, user);
+            }
+          });
+        })
+        .catch((err) => {
+          debug('findOrRegister error: ', err);
+          if (err.name != null && err.name === 'DuplicatedUsernameException') {
+            req.flash('isDuplicatedUsernameExceptionOccured', true);
+            return next();
+          }
+        });
+
+    })(req, res, next);
+  }
+
+  /**
+   * middleware that test credentials with LdapStrategy
+   *
+   * @param {*} req
+   * @param {*} res
+   */
+  const testLdapCredentials = (req, res) => {
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return res.json({
+        status: 'warning',
+        message: 'LdapStrategy has not been set up',
+      });
+    }
+
+    const loginForm = req.body.loginForm;
+
+    passport.authenticate('ldapauth', (err, user, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        return res.json({
+          status: 'warning',
+          message: 'LDAP Server Error occured.',
+        });
+      }
+      if (info && info.message) {
+        return res.json({
+          status: 'warning',
+          message: info.message,
+        });
+      }
+      if (user) {
+        return res.json({
+          status: 'success',
+          message: 'Successfully authenticated.',
+        });
+      }
+    })(req, res, () => {});
   }
 
   /**
-   * login with LocalStrategy action
+   * middleware that login with LocalStrategy
    * @param {*} req
    * @param {*} res
    * @param {*} next
@@ -60,27 +174,29 @@ module.exports = function(crowi, app) {
     }
 
     passport.authenticate('local', (err, user, info) => {
-      debug('---authentication with LocalStrategy start---');
+      debug('--- authenticate with LocalStrategy ---');
       debug('user', user);
       debug('info', info);
 
-      if (err) { return next(err); }
+      if (err) {  // DB Error
+        console.log('Database Server Error: ', err);
+        req.flash('warningMessage', 'Database Server Error occured.');
+        return next(); // pass and the flash message is displayed when all of authentications are failed.
+      }
       if (!user) { return next(); }
       req.logIn(user, (err) => {
-        if (err != null) {
-          debug(err);
-          return next();
+        if (err) { return next(); }
+        else {
+          return loginSuccess(req, res, user);
         }
-        return loginSuccess(req, res, user);
       });
-
-      debug('---authentication with LocalStrategy end---');
     })(req, res, next);
   }
 
   return {
     loginFailure,
     loginWithLdap,
+    testLdapCredentials,
     loginWithLocal,
   };
 };

+ 138 - 0
lib/routes/me.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , config = crowi.getConfig()
     , Page = models.Page
     , User = models.User
+    , ExternalAccount = models.ExternalAccount
     , Revision = models.Revision
     //, pluginService = require('../service/plugin')
     , actions = {}
@@ -84,10 +85,14 @@ module.exports = function(crowi, app) {
       var email = userForm.email;
       var lang= userForm.lang;
 
+      /*
+       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+       *
       if (!User.isEmailValid(email)) {
         req.form.errors.push('You can\'t update to that email address');
         return res.render('me/index', {});
       }
+      */
 
       User.findOneAndUpdate(
         { email: userData.email },                  // query
@@ -107,10 +112,14 @@ module.exports = function(crowi, app) {
         });
 
     } else { // method GET
+      /*
+       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+       *
       /// そのうちこのコードはいらなくなるはず
       if (!userData.isEmailSet()) {
         req.flash('warningMessage', 'メールアドレスが設定されている必要があります');
       }
+      */
 
       return res.render('me/index', {
       });
@@ -147,15 +156,144 @@ module.exports = function(crowi, app) {
     });
   }
 
+  actions.externalAccounts = {};
+  actions.externalAccounts.list = function(req, res) {
+    const userData = req.user;
+
+    let renderVars = {};
+    ExternalAccount.find({user: userData})
+      .then((externalAccounts) => {
+        renderVars.externalAccounts = externalAccounts;
+        return;
+      })
+      .then(() => {
+        if (req.method == 'POST' && req.form.isValid) {
+          // TODO impl
+          return res.render('me/external-accounts', renderVars);
+        }
+        else { // method GET
+          return res.render('me/external-accounts', renderVars);
+        }
+      });
+  }
+
+  actions.externalAccounts.disassociate = function(req, res) {
+    const userData = req.user;
+
+    const redirectWithFlash = (type, msg) => {
+      req.flash(type, msg);
+      return res.redirect('/me/external-accounts');
+    }
+
+    if (req.body == null) {
+      redirectWithFlash('errorMessage', 'Invalid form.');
+    }
+
+    // make sure password set or this user has two or more ExternalAccounts
+    new Promise((resolve, reject) => {
+      if (userData.password != null) {
+        resolve(true);
+      }
+      else {
+        ExternalAccount.count({user: userData})
+          .then((count) => {
+            resolve(count > 1)
+          });
+      }
+    })
+    .then((isDisassociatable) => {
+      if (!isDisassociatable) {
+        let e = new Error();
+        e.name = 'couldntDisassociateError';
+        throw e;
+      }
+
+      const providerType = req.body.providerType;
+      const accountId = req.body.accountId;
+
+      return ExternalAccount.findOneAndRemove({providerType, accountId, user: userData});
+    })
+    .then((account) => {
+      if (account == null) {
+        return redirectWithFlash('errorMessage', 'ExternalAccount not found.');
+      }
+      else {
+        return redirectWithFlash('successMessage', 'Successfully disassociated.');
+      }
+    })
+    .catch((err) => {
+      if (err) {
+        if (err.name == 'couldntDisassociateError') {
+          return redirectWithFlash('couldntDisassociateError', true);
+        }
+        else {
+          return redirectWithFlash('errorMessage', err.message);
+        }
+      }
+    });
+
+  }
+
+  actions.externalAccounts.associateLdap = function(req, res) {
+    const passport = require('passport');
+    const passportService = crowi.passportService;
+
+    const redirectWithFlash = (type, msg) => {
+      req.flash(type, msg);
+      return res.redirect('/me/external-accounts');
+    }
+
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return redirectWithFlash('warning', 'LdapStrategy has not been set up');
+    }
+
+    const loginForm = req.body.loginForm;
+
+    passport.authenticate('ldapauth', (err, user, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        return redirectWithFlash('warningMessage', 'LDAP Server Error occured.');
+      }
+      if (info && info.message) {
+        return redirectWithFlash('warningMessage', info.message);
+      }
+      if (user) {
+        // create ExternalAccount
+        const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+        const user = req.user;
+
+        ExternalAccount.create({ providerType: 'ldap', accountId: ldapAccountId, user: user._id })
+          .then(() => {
+            return redirectWithFlash('successMessage', 'Successfully added.');
+          })
+          .catch((err) => {
+            return redirectWithFlash('errorMessage', err.message);
+          });
+
+      }
+    })(req, res, () => {});
+
+
+  }
+
   actions.password = function(req, res) {
     var passwordForm = req.body.mePassword;
     var userData = req.user;
 
+    /*
+      * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+      *
     // パスワードを設定する前に、emailが設定されている必要がある (schemaを途中で変更したため、最初の方の人は登録されていないかもしれないため)
     // そのうちこのコードはいらなくなるはず
     if (!userData.isEmailSet()) {
       return res.redirect('/me');
     }
+    */
 
     if (req.method == 'POST' && req.form.isValid) {
       var newPassword = passwordForm.newPassword;

+ 172 - 2
lib/service/passport.js

@@ -1,6 +1,7 @@
 const debug = require('debug')('crowi:service:PassportService');
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
+const LdapStrategy = require('passport-ldapauth');
 
 /**
  * the service class of Passport
@@ -13,6 +14,32 @@ class PassportService {
 
   constructor(crowi) {
     this.crowi = crowi;
+
+    /**
+     * the flag whether LocalStrategy is set up successfully
+     */
+    this.isLocalStrategySetup = false;
+
+    /**
+     * the flag whether LdapStrategy is set up successfully
+     */
+    this.isLdapStrategySetup = false;
+
+    /**
+     * the flag whether serializer/deserializer are set up successfully
+     */
+    this.isSerializerSetup = false;
+  }
+
+  /**
+   * reset LocalStrategy
+   *
+   * @memberof PassportService
+   */
+  resetLocalStrategy() {
+    debug('LocalStrategy: reset');
+    passport.unuse('local');
+    this.isLocalStrategySetup = false;
   }
 
   /**
@@ -21,7 +48,12 @@ class PassportService {
    * @memberof PassportService
    */
   setupLocalStrategy() {
-    debug('setup LocalStrategy');
+    // check whether the strategy has already been set up
+    if (this.isLocalStrategySetup) {
+      throw new Error('LocalStrategy has already been set up');
+    }
+
+    debug('LocalStrategy: setting up..');
 
     const User = this.crowi.model('User');
 
@@ -42,6 +74,136 @@ class PassportService {
         });
       }
     ));
+
+    this.isLocalStrategySetup = true;
+    debug('LocalStrategy: setup is done');
+  }
+
+  /**
+   * reset LdapStrategy
+   *
+   * @memberof PassportService
+   */
+  resetLdapStrategy() {
+    debug('LdapStrategy: reset');
+    passport.unuse('ldapauth');
+    this.isLdapStrategySetup = false;
+  }
+
+  /**
+   * Asynchronous configuration retrieval
+   *
+   * @memberof PassportService
+   */
+  setupLdapStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isLdapStrategySetup) {
+      throw new Error('LdapStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    const isLdapEnabled = Config.isEnabledPassportLdap(config);
+
+    // when disabled
+    if (!isLdapEnabled) {
+      return;
+    }
+
+    debug('LdapStrategy: setting up..');
+
+    passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, {passReqToCallback: true}),
+      (req, ldapAccountInfo, done) => {
+        debug("LDAP authentication has succeeded", ldapAccountInfo);
+        done(null, ldapAccountInfo);
+      }
+    ));
+
+    this.isLdapStrategySetup = true;
+    debug('LdapStrategy: setup is done');
+  }
+
+  /**
+   * return attribute name for mapping to username of Crowi DB
+   *
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAttrNameMappedToUsername() {
+    const config = this.crowi.config;
+    return config.crowi['security:passport-ldap:attrMapUsername'] || 'uid';
+  }
+
+  /**
+   * CAUTION: this method is capable to use only when `req.body.loginForm` is not null
+   *
+   * @param {any} req
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAccountIdFromReq(req) {
+    return req.body.loginForm.username;
+  }
+
+  /**
+   * Asynchronous configuration retrieval
+   * @see https://github.com/vesse/passport-ldapauth#asynchronous-configuration-retrieval
+   *
+   * @param {object} config
+   * @param {object} opts
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapConfigurationFunc(config, opts) {
+    // get configurations
+    const isUserBind      = config.crowi['security:passport-ldap:isUserBind'];
+    const serverUrl       = config.crowi['security:passport-ldap:serverUrl'];
+    const bindDN          = config.crowi['security:passport-ldap:bindDN'];
+    const bindCredentials = config.crowi['security:passport-ldap:bindDNPassword'];
+    const searchFilter    = config.crowi['security:passport-ldap:searchFilter'] || '(uid={{username}})';
+
+    // parse serverUrl
+    // see: https://regex101.com/r/0tuYBB/1
+    const match = serverUrl.match(/(ldaps?:\/\/[^\/]+)\/(.*)?/);
+    if (match == null || match.length < 1) {
+      debug('LdapStrategy: serverUrl is invalid');
+      return;
+    }
+    const url = match[1];
+    const searchBase = match[2] || '';
+
+    debug(`LdapStrategy: url=${url}`);
+    debug(`LdapStrategy: searchBase=${searchBase}`);
+    debug(`LdapStrategy: isUserBind=${isUserBind}`);
+    if (!isUserBind) {
+      debug(`LdapStrategy: bindDN=${bindDN}`);
+      debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
+    }
+    debug(`LdapStrategy: searchFilter=${searchFilter}`);
+
+    return (req, callback) => {
+      // get credentials from form data
+      const loginForm = req.body.loginForm;
+      if (!req.form.isValid) {
+        return callback({ message: 'Incorrect credentials.' });
+      }
+
+      // user bind
+      const fixedBindDN = (isUserBind) ?
+          bindDN.replace(/{{username}}/, loginForm.username):
+          bindDN;
+      const fixedBindCredentials = (isUserBind) ? loginForm.password : bindCredentials;
+
+      process.nextTick(() => {
+        const mergedOpts = Object.assign({
+          usernameField: PassportService.USERNAME_FIELD,
+          passwordField: PassportService.PASSWORD_FIELD,
+          server: { url, bindDN: fixedBindDN, bindCredentials: fixedBindCredentials, searchBase, searchFilter },
+        }, opts);
+        debug('ldap configuration: ', mergedOpts);
+        callback(null, mergedOpts);
+      });
+    };
   }
 
   /**
@@ -50,7 +212,12 @@ class PassportService {
    * @memberof PassportService
    */
   setupSerializer() {
-    debug('setup serializer and deserializer');
+    // check whether the serializer/deserializer have already been set up
+    if (this.isSerializerSetup) {
+      throw new Error('serializer/deserializer have already been set up');
+    }
+
+    debug('setting up serializer and deserializer');
 
     const User = this.crowi.model('User');
 
@@ -62,7 +229,10 @@ class PassportService {
         done(err, user);
       });
     });
+
+    this.isSerializerSetup = true;
   }
+
 }
 
 module.exports = PassportService;

+ 2 - 1
lib/util/middlewares.js

@@ -78,7 +78,8 @@ exports.swigFilters = function(app, swig) {
 
   // define a function for Gravatar
   const generateGravatarSrc = function(user) {
-    const hash = md5(user.email.trim().toLowerCase());
+    const email = user.email || '';
+    const hash = md5(email.trim().toLowerCase());
     return `https://gravatar.com/avatar/${hash}`;
   };
 

+ 33 - 0
lib/util/swigFunctions.js

@@ -3,6 +3,7 @@ module.exports = function(crowi, app, req, locals) {
     , Page = crowi.model('Page')
     , Config = crowi.model('Config')
     , User = crowi.model('User')
+    , passportService = crowi.passportService
   ;
 
   locals.nodeVersion = function() {
@@ -24,6 +25,38 @@ module.exports = function(crowi, app, req, locals) {
     return req.csrfToken;
   };
 
+  /**
+   * return true if enabled
+   */
+  locals.isEnabledPassport = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config);
+  }
+
+  /**
+   * return true if local strategy has been setup successfully
+   *  used whether restarting the server needed
+   */
+  locals.isPassportLocalStrategySetup = function() {
+    return passportService != null && passportService.isLocalStrategySetup;
+  }
+
+  /**
+   * return true if enabled and strategy has been setup successfully
+   */
+  locals.isLdapSetup = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && passportService.isLdapStrategySetup;
+  }
+
+  /**
+   * return true if enabled but strategy has some problem
+   */
+  locals.isLdapSetupFailed = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && !passportService.isLdapStrategySetup;
+  }
+
   locals.googleLoginEnabled = function() {
     var config = crowi.getConfig()
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];

+ 131 - 0
lib/views/admin/external-accounts.html

@@ -0,0 +1,131 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}外部アカウント管理 · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">ユーザー管理/外部アカウント管理</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set wmessage = req.flash('warningMessage') %}
+  {% if wmessage.length %}
+  <div class="alert alert-warning">
+    {{ wmessage }}
+  </div>
+  {% endif %}
+
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'external-account'} %}
+    </div>
+
+    <div class="col-md-9">
+      <p>
+        <a class="btn btn-default" href="/admin/users">
+          <i class="fa fa-arrow-left" aria-hidden="true"></i>
+          ユーザー管理に戻る
+        </a>
+      </p>
+
+      <h2>外部アカウント一覧</h2>
+
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="120px">Authentication Provider</th>
+            <th><code>accountId</code></th>
+            <th>関連付けられているユーザーの <code>username</code></th>
+            <th>
+              パスワード設定
+              <a class="text-muted"
+                  data-toggle="popover" data-placement="top"
+                  data-trigger="hover focus" tabindex="0" role="button" {# dismiss settings #}
+                  data-animation="false" data-html="true"
+                  data-content="<small>関連付けられているユーザーがパスワードを設定しているかどうかを表示します</small>">
+                <small>
+                  <i class="fa fa-info-circle" aria-hidden="true"></i>
+                </small>
+              </a>
+            </th>
+            <th width="100px">作成日</th>
+            <th width="90px">操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for account in accounts %}
+          <tr>
+            <td>{{ account.providerType }}</td>
+            <td>
+              <strong>{{ account.accountId }}</strong>
+            </td>
+            <td>
+              <strong>{{ account.user.username }}</strong>
+            </td>
+            <td>
+              {% if account.user.password != null %}
+              <span class="label label-info">
+                設定済み
+              </span>
+              {% else %}
+              <span class="label label-warning">
+                未設定
+              </span>
+              {% endif %}
+            </td>
+            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>
+              <div class="btn-group admin-user-menu">
+
+                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                  編集
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">編集メニュー</li>
+                  <li class="dropdown-button">
+                    <form action="/admin/users/external-accounts/{{ account.accountId }}/remove" method="post">
+                      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                      <button type="submit" class="btn btn-block btn-danger">削除する</button>
+                    </form>
+                  </li>
+                </ul>{# end of .dropdown-menu #}
+
+
+
+              </div>{# end of .btn-group #}
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+      {% include '../widget/pager.html' with {path: "/admin/users/external-accounts", pager: pager} %}
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 120 - 52
lib/views/admin/security.html

@@ -93,7 +93,7 @@
           <div class="form-group">
             <div class="col-xs-offset-3 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
             </div>
           </div>
 
@@ -102,8 +102,8 @@
 
       <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal" id="mechanismSetting" role="form">
         <fieldset>
-        <legend>認証機構設定</legend>
-          <p class="alert alert-info"><b>NOTE: </b>Reboot the server and apply the changes</p>
+          <legend>認証機構選択</legend>
+          <p class="alert alert-info"><b>NOTE: </b>Restarting the server is needed if you switch the auth mechanism.</p>
           <div class="form-group">
             <div class="col-xs-6">
               <h4>
@@ -126,7 +126,7 @@
               </h4>
               <ul>
                 <li>Username, E-mail and Password authentication</li>
-                <li class="text-muted">(TBD) <del>LDAP authentication</del></li>
+                <li>LDAP authentication</li>
                 <li class="text-muted">(TBD) <del>Google OAuth2 authentication</del></li>
                 <li class="text-muted">(TBD) <del>Facebook OAuth2 authentication</del></li>
                 <li class="text-muted">(TBD) <del>Twitter OAuth authentication</del></li>
@@ -138,65 +138,133 @@
           <div class="form-group">
             <div class="col-xs-offset-5 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
             </div>
           </div>
+        </fieldset>
       </form>
 
-      <form action="/_api/admin/security/google" method="post" class="form-horizontal officialCrowiMechanism" id="googleSetting" role="form"
-          {% if true === settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
-        <fieldset>
-          <h3>Google 設定</h3>
-          <p class="well">
-            Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
-            から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+
+      <div class="auth-mechanism-configurations">
+
+        <legend>認証機構設定</legend>
+
+        {% set isOfficialConfigurationVisible = !isEnabledPassport() %}
+        <div class="official-crowi-auth-settings" {% if !isOfficialConfigurationVisible %}style="display: none;"{% endif %}>
+          {% set isRestartingServerNeeded = isPassportLocalStrategySetup() %}
+          <p class="alert alert-warning"
+              {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
+            <b>
+              <i class="fa fa-exclamation-circle" aria-hidden="true"></i>
+              Restarting the server is needed.
+            </b>
+            The server is running with Passport authentication mechanism.
           </p>
 
-          <ol class="help-block">
-            <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
-            <li>プロジェクトを作成していない場合は作成してください</li>
-            <li>「認証情報を作成」-> OAuthクライアントID</li>
-            <ol>
-              <li>「ウェブアプリケーション」を選択</li>
-              <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
-              (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
-            </ol>
-          </ol>
+          <form action="/_api/admin/security/google" method="post" class="form-horizontal " id="googleSetting" role="form"
+              {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
 
-          <div class="form-group">
-            <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
+            <fieldset>
+              <h4>Google 設定</h4>
+              <p class="well">
+                Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
+                から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+              </p>
+
+              <ol class="help-block">
+                <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
+                <li>プロジェクトを作成していない場合は作成してください</li>
+                <li>「認証情報を作成」-> OAuthクライアントID</li>
+                <ol>
+                  <li>「ウェブアプリケーション」を選択</li>
+                  <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
+                  (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
+                </ol>
+              </ol>
+
+              <div class="form-group">
+                <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
+                <div class="col-xs-6">
+                  <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
+                </div>
+              </div>
+
+              <div class="form-group">
+                <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
+                <div class="col-xs-6">
+                  <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
+                </div>
+              </div>
+
+              <div class="form-group">
+                <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>
+
+            </fieldset>
+          </form>
+        </div>
+
+        {#
+         # passport settings nav
+         #}
+        {% set isPassportConfigurationVisible = settingForm['security:isEnabledPassport'] %}
+        <div class="passport-settings" {% if !isPassportConfigurationVisible %}style="display: none;"{% endif %}>
+
+          {% set isRestartingServerNeeded = !isPassportLocalStrategySetup() %}
+          <p class="alert alert-warning"
+              {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
+            <b>
+              <i class="fa fa-exclamation-circle" aria-hidden="true"></i>
+              Restarting the server is needed.
+            </b>
+            The server is running with Official Crowi authentication mechanism.
+          </p>
+          <ul class="nav nav-tabs" role="tablist" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+            <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-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google OAuth</a>
+            </li>
+            <li>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> Facebook</a>
+            </li>
+            <li>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
+            </li>
+            <li>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> Github</a>
+            </li>
+          </ul>
+
+          <div class="tab-content" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+            <div id="passport-ldap" class="tab-pane active" role="tabpanel" >
+              {% include './widget/passport/ldap.html' with { settingForm: settingForm } %}
             </div>
-          </div>
 
-          <div class="form-group">
-            <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
+            <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/google-oauth.html' %}
             </div>
-          </div>
 
-          <div class="form-group">
-            <div class="col-xs-offset-3 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/facebook.html' %}
             </div>
-          </div>
 
-        </fieldset>
-      </form>
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/twitter.html' %}
+            </div>
 
-      <form action="/_api/admin/security/ldap" method="post" class="form-horizontal passportStrategy" id="ldapSetting" role="form"
-          {% if !settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
-        <fieldset>
-          <h3>[Passport] LDAP Strategy</h3>
-          <p class="well">
-            (TBD)
-          </p>
+            <div id="passport-github" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/github.html' %}
+            </div>
 
-        </fieldset>
-      </form>
+          </div><!-- /.tab-content -->
+        </div>
+
+      </div><!-- /.auth-mechanism-configurations -->
 
     </div>
   </div>
@@ -255,12 +323,12 @@
       const isEnabledPassport = ($(this).val() === "true");
 
       if (isEnabledPassport) {
-        $('form.officialCrowiMechanism').hide(400);
-        $('form.passportStrategy').show(400);
+        $('.official-crowi-auth-settings').hide(400);
+        $('.passport-settings').show(400);
       }
       else {
-        $('form.officialCrowiMechanism').show(400);
-        $('form.passportStrategy').hide(400);
+        $('.official-crowi-auth-settings').show(400);
+        $('.passport-settings').hide(400);
       }
     });
   </script>

+ 5 - 1
lib/views/admin/users.html

@@ -33,7 +33,11 @@
 
     <div class="col-md-9">
       <p>
-        <button  data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">新規ユーザーの招待</button>
+        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">新規ユーザーの招待</button>
+        <a class="btn btn-default" href="/admin/users/external-accounts">
+          <i class="fa fa-user-plus" aria-hidden="true"></i>
+          外部アカウントの管理
+        </a>
       </p>
       <form role="form" action="/admin/user/invite" method="post">
         <div id="inviteUserForm" class="collapse">

+ 1 - 1
lib/views/admin/widget/menu.html

@@ -8,7 +8,7 @@
   <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="fa fa-pencil"></i> Markdown設定</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="fa fa-object-group"></i> カスタマイズ</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-  <li class="{% if current == 'user'%}active{% endif %}"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
+  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
   {% if searchConfigured() %}
   <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="fa fa-search"></i> 検索管理</a></li>
   {% endif %}

+ 6 - 0
lib/views/admin/widget/passport/facebook.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="facebookOauthSetting" role="form">
+  <fieldset>
+    <legend>Facebook OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 6 - 0
lib/views/admin/widget/passport/github.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="githubOauthSetting" role="form">
+  <fieldset>
+    <legend>Github OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 82 - 0
lib/views/admin/widget/passport/google-oauth.html

@@ -0,0 +1,82 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="googleOauthSetting" role="form">
+  <fieldset>
+    <legend>Google OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>
+
+{% if false %}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
+</h4>
+
+<ol id="collapseHelpForApp" class="collapse">
+  <li>
+    Register Slack App
+    <ol>
+      <li>
+        Create App from <a href="https://api.slack.com/applications/new">this link</a>, and fill the form out as below:
+        <dl class="dl-horizontal">
+          <dt>App Name</dt> <dd><code>crowi-plus</code> </dd>
+          <dt>Development Slack Team</dt> <dd>Select the team you want to notify to.</dd>
+        </dl>
+      </li>
+      <li><strong>Save</strong> it.</li>
+    </ol>
+  </li>
+  <li>
+    Get App Credentials
+    <ol>
+      <li>Go To "Basic Information" page and make a note "Client ID" and "Client Secret".</li>
+    </ol>
+  </li>
+  <li>
+    Set Redirect URLs
+    <ol>
+      <li>Go to "OAuth &amp; Permissions" page.</li>
+      <li>Add <code><script>document.write(location.origin);</script>/admin/notification/slackAuth</code> .</li>
+      <li>Don't forget to <strong>save</strong>.</li>
+    </ol>
+  </li>
+  <li>
+    Set Permission Scopes to the App
+    <ol>
+      <li>Go to "OAuth &amp; Permissions" page.</li>
+      <li>Add "Send messages as crowi-plus"(<code>chat:write:bot</code>).</li>
+      <li>Don't forget to <strong>save</strong>.</li>
+    </ol>
+  </li>
+  <li>
+    Create a bot user
+    <ol>
+      <li>Go to "Bot Users" page and add.</li>
+    </ol>
+  </li>
+  <li>
+    Install the app
+    <ol>
+      <li>Go to "Install App to Your Team" page and install.</li>
+    </ol>
+  </li>
+  <li>
+    (At Team) Approve the app
+    <ol>
+      <li>Go to the management Apps page for the team you installed the app and approve crowi-plus.</li>
+    </ol>
+  </li>
+  <li>
+    (At Team) Invite the bot to your team
+    <ol>
+      <li>Invite the user you created in <code>4. Add a bot user</code> to the channel you notify to.</li>
+    </ol>
+  </li>
+  <li>
+    (At crowi-plus) Input "clientId" and "clientSecret" and submit on this page.
+  </li>
+  <li>
+    (At crowi-plus) Click "Connect to Slack" button to start OAuth process.
+  </li>
+</ol>
+{% endif %}

+ 261 - 0
lib/views/admin/widget/passport/ldap.html

@@ -0,0 +1,261 @@
+<form action="/_api/admin/security/passport-ldap" method="post" class="form-horizontal" id="ldapSetting" role="form">
+
+  <fieldset>
+    <legend>LDAP Configuration</legend>
+
+    {% set nameForIsLdapEnabled = "settingForm[security:passport-ldap:isEnabled]" %}
+    {% set isLdapEnabled = settingForm['security:passport-ldap:isEnabled'] %}
+    <div class="form-group">
+      <label for="{{nameForIsLdapEnabled}}" class="col-xs-3 control-label">Use LDAP</label>
+      <div class="col-xs-6">
+        <div class="btn-group btn-toggle" data-toggle="buttons">
+          <label class="btn btn-default {% if isLdapEnabled %}active{% endif %}" data-active-class="primary">
+            <input name="{{nameForIsLdapEnabled}}" value="true" type="radio"
+                {% if true === isLdapEnabled %}checked{% endif %}> Enable
+          </label>
+          <label class="btn btn-default {% if !isLdapEnabled %}active{% endif %}" data-active-class="primary">
+            <input name="{{nameForIsLdapEnabled}}" value="false" type="radio"
+                {% if !isLdapEnabled %}checked{% endif %}> Disable
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="passport-ldap-hide-when-disabled" {%if !isLdapEnabled %}style="display: none;"{% endif %}>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:serverUrl]" class="col-xs-3 control-label">Server URL</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:serverUrl]" value="{{ settingForm['security:passport-ldap:serverUrl'] || '' }}">
+          <p class="help-block">
+            <small>
+              The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.<br>
+              Example: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
+            </small>
+          </p>
+        </div>
+      </div>
+
+      {% set nameForIsUserBind = "settingForm[security:passport-ldap:isUserBind]" %}
+      {% set isUserBind = settingForm['security:passport-ldap:isUserBind'] %}
+      <div class="form-group">
+        <label for="{{nameForIsUserBind}}" class="col-xs-3 control-label">Binding Mode</label>
+        <div class="col-xs-6">
+          <div class="btn-group btn-toggle" data-toggle="buttons">
+            <label class="btn btn-default {% if !isUserBind %}active{% endif %}" data-active-class="primary">
+              <input name="{{nameForIsUserBind}}" value="false" type="radio"
+                  {% if !isUserBind %}checked{% endif %}> Manager Bind
+            </label>
+            <label class="btn btn-default {% if isUserBind %}active{% endif %}" data-active-class="primary">
+              <input name="{{nameForIsUserBind}}" value="true" type="radio"
+                  {% if isUserBind %}checked{% endif %}> User Bind
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:bindDN]" class="col-xs-3 control-label">Bind DN</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:bindDN]" value="{{ settingForm['security:passport-ldap:bindDN'] || '' }}">
+          <p class="help-block passport-ldap-managerbind" {% if isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The DN of the account that authenticates and queries the directory service
+            </small>
+          </p>
+          <p class="help-block passport-ldap-userbind" {% if !isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The query used to bind with the directory service.<br>
+              Use <code>{% raw %}{{username}}{% endraw %}</code> to reference the username entered in the login page.<br>
+              Example: <code>uid={% raw %}{{username}}{% endraw %},dc=domain,dc=com</code><br>
+            </small>
+          </p>
+          </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:bindDNPassword]" class="col-xs-3 control-label">Bind DN Password</label>
+        <div class="col-xs-6">
+          <input class="form-control passport-ldap-managerbind" type="text" {% if isUserBind %}style="display: none;"{% endif %}
+              name="settingForm[security:passport-ldap:bindDNPassword]" value="{{ settingForm['security:passport-ldap:bindDNPassword'] || '' }}">
+          <p class="help-block passport-ldap-managerbind">
+            <small>
+              The password for the Bind DN account.
+            </small>
+          </p>
+          <p class="help-block passport-ldap-userbind" {% if !isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The password that is entered in the login page will be used to bind.
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:searchFilter]" class="col-xs-3 control-label">Search Filter</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text" placeholder="Default: (uid={% raw %}{{username}}{% endraw %})"
+              name="settingForm[security:passport-ldap:searchFilter]" value="{{ settingForm['security:passport-ldap:searchFilter'] || '' }}">
+          <p class="help-block">
+            <small>
+              The query used to locate the authenticated user.<br>
+              Use <code>{% raw %}{{username}}{% endraw %}</code> to reference the username entered in the login page.<br>
+              If empty, the filter <code>(uid={% raw %}{{username}}{% endraw %})</code> is used.<br>
+              <br>
+              Example to match with 'uid' or 'mail': <code>(|(uid={% raw %}{{username}}{% endraw %})(mail={% raw %}{{username}}{% endraw %}))</code>
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <h4>Attribute Mapping</h4>
+
+      <p class="well well-sm">Specification of mappings when creating new users</p>
+
+      <div class="form-group">
+          <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">username</label>
+          <div class="col-xs-6">
+            <input class="form-control" type="text" placeholder="Default: uid"
+                name="settingForm[security:passport-ldap:attrMapUsername]" value="{{ settingForm['security:passport-ldap:attrMapUsername'] || '' }}">
+          </div>
+        </div>
+
+    </div><!-- /.passport-ldap-configurations -->
+
+    <div class="form-group">
+      <div class="col-xs-offset-3 col-xs-6">
+        <button type="submit" class="btn btn-primary">{# the first element is the default button to submit #}
+          {{ t('Update') }}
+        </button>
+        <button type="button"
+            class="btn btn-default passport-ldap-hide-when-disabled"
+            data-target="#test-ldap-account" data-toggle="modal"
+            {%if !isLdapEnabled %}style="display: none;"{% endif %}>
+
+          Test Saved Configuration
+        </button>
+      </div>
+    </div>
+  </fieldset>
+  <input type="hidden" name="_csrf" value="{{ csrf() }}">
+
+  <script>
+    // switch display according to on / off of radio buttons
+    $('input[name="{{nameForIsLdapEnabled}}"]:radio').change(function() {
+      const isEnabled = ($(this).val() === "true");
+
+      if (isEnabled) {
+        $('.passport-ldap-hide-when-disabled').show(400);
+      }
+      else {
+        $('.passport-ldap-hide-when-disabled').hide(400);
+      }
+    });
+
+    // switch display according to on / off of radio buttons
+    $('input[name="{{nameForIsUserBind}}"]:radio').change(function() {
+      const isUserBind = ($(this).val() === "true");
+
+      if (isUserBind) {
+        $('input.passport-ldap-managerbind').hide();
+        $('.help-block.passport-ldap-managerbind').hide();
+        $('.help-block.passport-ldap-userbind').show();
+      }
+      else {
+        $('input.passport-ldap-managerbind').show();
+        $('.help-block.passport-ldap-managerbind').show();
+        $('.help-block.passport-ldap-userbind').hide();
+      }
+    });
+
+    // store which button is clicked when submit
+    var submittedButton;
+    $('button[type="submit"]').click(function() {
+      submittedButton = $(this);
+    });
+    $('#ldapSetting, #ldapTest').each(function() {
+      $(this).submit(function()
+      {
+        function showMessage(formId, msg, status) {
+          $('#' + formId + ' .alert').remove();
+
+          if (!status) {
+            status = 'success';
+          }
+          var $message = $('<p class="alert"></p>');
+          $message.addClass('alert-' + status);
+          $message.html(msg.replace('\n', '<br>'));
+          $message.insertAfter('#' + formId + ' legend');
+
+          if (status == 'success') {
+            setTimeout(function()
+            {
+              $message.fadeOut({
+                complete: function() {
+                  $message.remove();
+                }
+              });
+            }, 5000);
+          }
+        }
+
+        var $form = $(this);
+        var $id = $form.attr('id');
+        var $button = submittedButton;
+        var $action = $button.attr('formaction') || $form.attr('action');
+        $button.attr('disabled', 'disabled');
+        var jqxhr = $.post($action, $form.serialize(), function(data)
+        {
+          if (data.status) {
+            const message = data.message || '更新しました';
+            showMessage($id, message);
+          } else {
+            showMessage($id, data.message, 'danger');
+          }
+        })
+        .fail(function() {
+          showMessage($id, 'エラーが発生しました', 'danger');
+        })
+        .always(function() {
+          $button.prop('disabled', false);
+        });
+        return false;
+      });
+    });
+    </script>
+
+</form>
+
+<div class="modal test-ldap-account" id="test-ldap-account">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">{{ t('Test LDAP Account') }}</h4>
+      </div>
+
+      <div class="modal-body">
+
+        {% include '../../../widget/passport/ldap-association-tester.html' %}
+
+      </div><!-- /.modal-body -->
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+
+  <script>
+    /**
+     * associate (submit the form)
+     */
+    function associateLdap() {
+      var $form = $('#formLdapAssociationContainer > form');
+      var $action = '/me/external-accounts/associateLdap';
+      $form.attr('action', $action);
+      $form.submit();
+    }
+  </script>
+
+</div><!-- /.modal -->

+ 6 - 0
lib/views/admin/widget/passport/twitter.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="twitterOauthSetting" role="form">
+  <fieldset>
+    <legend>Twitter OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 44 - 3
lib/views/login.html

@@ -21,6 +21,29 @@
     <h2>{{ t('Sign in') }}</h2>
 
     <div id="login-form-errors">
+      {% if isLdapSetupFailed() %}
+      <div class="alert alert-warning">
+        LDAP is enabled but the configuration has something wrong.<br>
+        <small>(set the environment variables <code>DEBUG=crowi:service:PassportService</code> and get the logs)</small>
+      </div>
+      {% endif %}
+
+      {#
+       # The case that there already exists a user whose username matches ID of the newly created LDAP user
+       # https://github.com/weseek/crowi-plus/issues/193
+       #}
+      {% set isDuplicatedUsernameExceptionOccured = req.flash('isDuplicatedUsernameExceptionOccured') %}
+      {% if isDuplicatedUsernameExceptionOccured != null %}
+      <div class="alert alert-warning">
+        <i class="fa fa-fw fa-info-circle"></i>
+        <strong>DuplicatedUsernameException occured</strong>
+        <p>
+          Your LDAP authentication was succeess, but a new user could not be created.
+          See the issue <a href="https://github.com/weseek/crowi-plus/issues/193">#193</a>.
+        </p>
+      </div>
+      {% endif %}
+
       {% set success = req.flash('successMessage') %}
       {% if success.length %}
       <div class="alert alert-success">
@@ -30,9 +53,20 @@
 
       {% set warn = req.flash('warningMessage') %}
       {% if warn.length %}
+      {% for w in warn %}
+      <div class="alert alert-warning">
+        {{ w }}
+      </div>
+      {% endfor %}
+      {% endif %}
+
+      {% set error = req.flash('errorMessage') %}
+      {% if error.length %}
+      {% for e in error %}
       <div class="alert alert-danger">
-        {{ warn }}
+        {{ e }}
       </div>
+      {% endfor %}
       {% endif %}
 
       {% if req.form.errors.length > 0 %}
@@ -47,12 +81,19 @@
     </div>
     <form role="form" action="/login" method="post">
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <span class="input-group-addon"><i class="fa fa-fw fa-user"></i></span>
         <input type="text" class="form-control" placeholder="Username or E-mail" name="loginForm[username]">
+        {% if isLdapSetup() %}
+        <span class="input-group-addon">
+          <small class="text-primary">
+            <i class="fa fa-fw fa-check-circle"></i> LDAP
+          </small>
+        </span>
+        {% endif %}
       </div>
 
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-key"></i></span>
+        <span class="input-group-addon"><i class="fa fa-fw fa-key"></i></span>
         <input type="password" class="form-control" placeholder="Password" name="loginForm[password]">
       </div>
 

+ 1 - 0
lib/views/me/api_token.html

@@ -16,6 +16,7 @@
 
   <ul class="nav nav-tabs">
     <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li class="active"><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>

+ 251 - 0
lib/views/me/external-accounts.html

@@ -0,0 +1,251 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}{{ t('Password Settings') }} · {{ path }}{% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">{{ t('User Settings') }}</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+  <ul class="nav nav-tabs">
+    <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li class="active"><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
+    <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
+    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
+  </ul>
+
+  <div class="tab-content">
+
+  {% set couldntDisassociateError = req.flash('couldntDisassociateError') %}
+  {% if couldntDisassociateError != null %}
+  <div class="alert alert-danger">
+    <b>Couldn't disassociate External Account</b><br>
+    You have not set a password and have only one External Account.
+  </div>
+  {% endif %}
+
+  {% set error = req.flash('errorMessage') %}
+  {% if error.length %}
+  {% for e in error %}
+  <div class="alert alert-danger">
+    <b>Server Error occured:</b><br>
+    {{ e }}
+  </div>
+  {% endfor %}
+  {% endif %}
+
+  {% set warn = req.flash('warningMessage') %}
+  {% if warn.length %}
+  {% for w in warn %}
+  <div class="alert alert-warning">
+    {{ w }}
+  </div>
+  {% endfor %}
+  {% endif %}
+
+  {% set message = req.flash('successMessage') %}
+  {% if message.length %}
+  <div class="alert alert-success">
+    <b>{{ message }}</b>
+  </div>
+  {% endif %}
+
+
+
+  <legend style="line-height: 1.7em;">
+    <button class="btn btn-default btn-sm pull-right" data-target="#create-external-account" data-toggle="modal">
+      <i class="fa fa-plus-circle" aria-hidden="true"></i>
+      Add
+    </button>
+    {{ t('External Accounts') }}
+  </legend>
+
+  <div class="row">
+    <div class="col-md-12">
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="120px">Authentication Provider</th>
+            <th>
+              <code>accountId</code>
+            </th>
+            <th width="200px">{{ t('Created') }}</th>
+            <th width="150px">{{ t('Admin') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for account in externalAccounts %}
+          <tr>
+            <td>{{ account.providerType }}</td>
+            <td>
+              <strong>{{ account.accountId }}</strong>
+            </td>
+            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td class="text-center">
+              <button class="btn btn-default btn-sm btn-danger"
+                  data-toggle="modal" data-target="#diassociate-external-account" data-provider-type="{{ account.providerType }}" data-account-id="{{ account.accountId }}">
+                <i class="fa fa-unlink"></i>
+                {{ t('Diassociate') }}
+              </button>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  </div>
+
+  {# modal #}
+  <style>
+    .modal.create-external-account .modal-dialog {
+      width: 750px;
+    }
+  </style>
+  <div class="modal create-external-account" id="create-external-account">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title">{{ t('Create External Account') }}</h4>
+        </div>
+
+        <div class="modal-body">
+
+          <ul class="nav nav-tabs passport-settings" role="tablist">
+            <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-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google OAuth</a>
+            </li>
+            <li>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> Facebook</a>
+            </li>
+            <li>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
+            </li>
+            <li>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> Github</a>
+            </li>
+          </ul>
+
+          <div class="tab-content passport-settings">
+            <div id="passport-ldap" class="tab-pane active" role="tabpanel" >
+              <div id="formLdapAssociationContainer">
+                {% include '../widget/passport/ldap-association-tester.html' %}
+                <div class="clearfix">
+                  <button type="button" class="btn btn-primary pull-right" onclick="associateLdap()">
+                    <i class="fa fa-plus-circle" aria-hidden="true"></i>
+                    {{ t('Add') }}
+                  </button>
+                </div>
+              </div>
+            </div>
+
+            <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-github" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+          </div><!-- /.tab-content -->
+
+        </div><!-- /.modal-body -->
+
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+
+    <script>
+      /**
+       * associate (submit the form)
+       */
+      function associateLdap() {
+        var $form = $('#formLdapAssociationContainer > form');
+        var $action = '/me/external-accounts/associateLdap';
+        $form.attr('action', $action);
+        $form.submit();
+      }
+    </script>
+
+  </div><!-- /.modal -->
+
+  <div class="modal diassociate-external-account" id="diassociate-external-account">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title">{{ t('Diassociate External Account') }}</h4>
+        </div>
+
+        <div class="modal-body">
+          <div class="row">
+            <div class="col-md-12">
+              <p><b>
+                Are you sure to diassociate the
+                <span class="diassociate-provider-type"></span> account
+                <code class="diassociate-account-id"></code>?
+              </b></p>
+            </div>
+          </div>
+        </div>
+
+        <div class="modal-footer">
+          <form action="/me/external-accounts/disassociate" method="post">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <input type="hidden" name="providerType">
+            <input type="hidden" name="accountId">
+            <button type="button" class="btn btn-sm btn-default" data-dismiss="modal">
+              {{ t('Cancel') }}
+            </button>
+            <button type="submit" class="btn btn-sm btn-danger">
+              <i class="fa fa-unlink"></i>
+              {{ t('Diassociate') }}
+            </button>
+          </form>
+        </div>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+
+    <script>
+      $('#diassociate-external-account').on('show.bs.modal', function (event) {
+        var modal = $(this);
+        var button = $(event.relatedTarget); // Button that triggered the modal
+        // get data-*
+        var providerType = button.data('provider-type');
+        var accountId = button.data('account-id');
+        // set labels
+        modal.find('.diassociate-provider-type').text(providerType);
+        modal.find('.diassociate-account-id').text(accountId);
+        // set hidden inputs
+        modal.find('input:hidden[name="providerType"]').val(providerType);
+        modal.find('input:hidden[name="accountId"]').val(accountId);
+      })
+    </script>
+  </div><!-- /.modal -->
+
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 3 - 2
lib/views/me/index.html

@@ -15,6 +15,7 @@
 
   <ul class="nav nav-tabs">
     <li class="active"><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
@@ -56,10 +57,10 @@
           <input class="form-control" type="text" name="userForm[name]" value="{{ user.name }}" required>
         </div>
       </div>
-      <div class="form-group {% if not user.email %}has-error{% endif %}">
+      <div class="form-group">
         <label for="userForm[email]" class="col-sm-2 control-label">{{ t('Email') }}</label>
         <div class="col-sm-4">
-          <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}" required>
+          <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}">
         </div>
         <div class="col-sm-offset-2 col-sm-10">
           {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}

+ 3 - 2
lib/views/me/password.html

@@ -15,6 +15,7 @@
 
   <ul class="nav nav-tabs">
     <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li class="active"><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
@@ -22,8 +23,8 @@
   <div class="tab-content">
 
   {% if not user.password %}
-  <div class="alert alert-danger">
-    {{ t('Please set a password') }}
+  <div class="alert alert-warning">
+    {{ t('Password is not set') }}
   </div>
   {% endif %}
 

+ 72 - 0
lib/views/widget/passport/ldap-association-tester.html

@@ -0,0 +1,72 @@
+<form id="formTestLdapCredentials" method="post" class="form-horizontal" role="form">
+  <div class="alert-container"></div>
+  <fieldset>
+    <div class="form-group">
+      <label for="username" class="col-xs-3 control-label">{{ t('Username') }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" name="loginForm[username]">
+      </div>
+    </div>
+    <div class="form-group">
+      <label for="password" class="col-xs-3 control-label">{{ t('Password') }}</label>
+      <div class="col-xs-6">
+        <input class="form-control col-xs-4" type="password" name="loginForm[password]">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <button type="button" class="btn btn-default col-xs-offset-5 col-xs-2" onclick="testLdapCredentials()">{{ t('Test') }}</button>
+    </div>
+
+  </fieldset>
+
+  <script>
+    /**
+     * test association (ajax)
+     */
+    function testLdapCredentials() {
+      function showMessage(formId, msg, status) {
+        $('#' + formId + ' .alert-container .alert').remove();
+
+        var $message = $('<p class="alert"></p>');
+        $message.addClass('alert-' + status);
+        $message.html(msg.replace('\n', '<br>'));
+        $message.appendTo('#' + formId + '> .alert-container');
+
+        if (status == 'success') {
+          setTimeout(function()
+          {
+            $message.fadeOut({
+              complete: function() {
+                $message.remove();
+              }
+            });
+          }, 5000);
+        }
+      }
+
+      var $form = $('#formTestLdapCredentials');
+      var $action = '/_api/login/testLdap';
+      var $id = $form.attr('id');
+      var $button = $('button', this);
+      $button.attr('disabled', 'disabled');
+
+      var jqxhr = $.post($action, $form.serialize(), function(data)
+        {
+          if (!data.status) {
+            showMessage($id, 'data.status not found', 'danger');
+          }
+          else {
+            showMessage($id, data.message, data.status);
+          }
+        })
+        .fail(function() {
+          showMessage($id, 'エラーが発生しました', 'danger');
+        })
+        .always(function() {
+          $button.prop('disabled', false);
+        });
+        return false;
+    }
+  </script>
+</form>

+ 2 - 0
package.json

@@ -104,6 +104,7 @@
     "normalize-path": "^2.1.1",
     "optimize-js-plugin": "0.0.4",
     "passport": "^0.4.0",
+    "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
     "plantuml-encoder": "^1.2.4",
@@ -135,6 +136,7 @@
     "mocha": "^4.0.0",
     "morgan": "^1.8.2",
     "node-dev": "^3.1.3",
+    "on-headers": "^1.0.1",
     "sinon": "^4.0.0",
     "sinon-chai": "^2.13.0",
     "webpack-dll-bundles-plugin": "^1.0.0-beta.5"

+ 4 - 1
resource/css/_admin.scss

@@ -43,9 +43,12 @@
   }
 
   .passport-logo {
-    background: url("/images/admin/security/passport_logo.svg") center left no-repeat;
     padding: 4px;
     height: 32px;
     background-color: black;
   }
+
+  .auth-mechanism-configurations {
+    min-height: 800px;
+  }
 } // }}}

+ 2 - 1
resource/js/components/User/UserPicture.js

@@ -17,7 +17,8 @@ export default class UserPicture extends React.Component {
   }
 
   generateGravatarSrc(user) {
-    const hash = md5(user.email.trim().toLowerCase());
+    const email = user.email || '';
+    const hash = md5(email.trim().toLowerCase());
     return `https://gravatar.com/avatar/${hash}`;
   }
 

+ 184 - 8
yarn.lock

@@ -23,6 +23,50 @@
     winston "^2.1.1"
     ws "^1.0.1"
 
+"@types/express-serve-static-core@*":
+  version "4.0.53"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.0.53.tgz#1723a35d1447f2c55e13c8721eab3448e42f4d82"
+  dependencies:
+    "@types/node" "*"
+
+"@types/express@*":
+  version "4.0.37"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.0.37.tgz#625ac3765169676e01897ca47011c26375784971"
+  dependencies:
+    "@types/express-serve-static-core" "*"
+    "@types/serve-static" "*"
+
+"@types/ldapjs@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@types/ldapjs/-/ldapjs-1.0.1.tgz#89e70067150e1f5163df85bbf36eed9b94b8af0a"
+  dependencies:
+    "@types/node" "*"
+
+"@types/mime@*":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
+
+"@types/node@*":
+  version "8.0.46"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.46.tgz#6e1766b2d0ed06631d5b5f87bb8e72c8dbb6888e"
+
+"@types/node@^7.0.21", "@types/node@^7.0.23":
+  version "7.0.46"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.46.tgz#c3dedd25558c676b3d6303e51799abb9c3f8f314"
+
+"@types/passport@^0.3.3":
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/@types/passport/-/passport-0.3.4.tgz#82929c7427091ba73273fcb963fdef8056bddbe7"
+  dependencies:
+    "@types/express" "*"
+
+"@types/serve-static@*":
+  version "1.7.32"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.7.32.tgz#0f6732e4dab0813771dd8fc8fe14940f34728b4c"
+  dependencies:
+    "@types/express-serve-static-core" "*"
+    "@types/mime" "*"
+
 abbrev@1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -198,10 +242,14 @@ asn1.js@^4.0.0:
     inherits "^2.0.1"
     minimalistic-assert "^1.0.0"
 
-asn1@~0.2.3:
+asn1@0.2.3, asn1@~0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
 
+assert-plus@0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.1.5.tgz#ee74009413002d84cec7219c6ac811812e723160"
+
 assert-plus@1.0.0, assert-plus@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
@@ -859,6 +907,12 @@ backo2@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
 
+backoff@^2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f"
+  dependencies:
+    precond "0.2"
+
 balanced-match@^0.4.2:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
@@ -903,6 +957,10 @@ bcrypt-pbkdf@^1.0.0:
   dependencies:
     tweetnacl "^0.14.3"
 
+bcryptjs@^2.4.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
+
 better-assert@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
@@ -1101,6 +1159,15 @@ builtin-status-codes@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
 
+bunyan@^1.8.3:
+  version "1.8.12"
+  resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.12.tgz#f150f0f6748abdd72aeae84f04403be2ef113797"
+  optionalDependencies:
+    dtrace-provider "~0.8"
+    moment "^2.10.6"
+    mv "~2"
+    safe-json-stringify "~1"
+
 busboy@^0.2.11:
   version "0.2.14"
   resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
@@ -1698,7 +1765,7 @@ d@1:
   dependencies:
     es5-ext "^0.10.9"
 
-dashdash@^1.12.0:
+dashdash@^1.12.0, dashdash@^1.14.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
   dependencies:
@@ -1841,6 +1908,18 @@ double-ended-queue@^2.1.0-0:
   version "2.1.0-0"
   resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
 
+dtrace-provider@^0.7.0:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.7.1.tgz#c06b308f2f10d5d5838aec9c571e5d588dc71d04"
+  dependencies:
+    nan "^2.3.3"
+
+dtrace-provider@~0.8:
+  version "0.8.5"
+  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.5.tgz#98ebba221afac46e1c39fd36858d8f9367524b92"
+  dependencies:
+    nan "^2.3.3"
+
 duplexer@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
@@ -2229,6 +2308,10 @@ extglob@^0.3.1:
   dependencies:
     is-extglob "^1.0.0"
 
+extsprintf@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.2.0.tgz#5ad946c22f5b32ba7f8cd7426711c6e8a3fc2529"
+
 extsprintf@1.3.0, extsprintf@^1.2.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@@ -2512,6 +2595,16 @@ glob@7.1.2, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@~7.1.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^6.0.1:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
@@ -3203,6 +3296,38 @@ lcid@^1.0.0:
   dependencies:
     invert-kv "^1.0.0"
 
+ldap-filter@0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/ldap-filter/-/ldap-filter-0.2.2.tgz#f2b842be0b86da3352798505b31ebcae590d77d0"
+  dependencies:
+    assert-plus "0.1.5"
+
+ldapauth-fork@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/ldapauth-fork/-/ldapauth-fork-4.0.2.tgz#f87d55908ba4917cca06d8ed6e173cdd65e908c9"
+  dependencies:
+    "@types/ldapjs" "^1.0.0"
+    "@types/node" "^7.0.21"
+    bcryptjs "^2.4.0"
+    ldapjs "^1.0.1"
+    lru-cache "^4.0.2"
+
+ldapjs@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/ldapjs/-/ldapjs-1.0.1.tgz#352b812ae74b0a8e96549a4b896060eee1b9a546"
+  dependencies:
+    asn1 "0.2.3"
+    assert-plus "^1.0.0"
+    backoff "^2.5.0"
+    bunyan "^1.8.3"
+    dashdash "^1.14.0"
+    ldap-filter "0.2.2"
+    once "^1.4.0"
+    vasync "^1.6.4"
+    verror "^1.8.1"
+  optionalDependencies:
+    dtrace-provider "^0.7.0"
+
 livereload-js@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.2.2.tgz#6c87257e648ab475bc24ea257457edcc1f8d0bc2"
@@ -3479,7 +3604,7 @@ loud-rejection@^1.0.0:
     currently-unhandled "^0.4.1"
     signal-exit "^3.0.0"
 
-lru-cache@^4.0.1:
+lru-cache@^4.0.1, lru-cache@^4.0.2:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55"
   dependencies:
@@ -3647,7 +3772,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 
-minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
+"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   dependencies:
@@ -3697,6 +3822,10 @@ mocha@^4.0.0:
     mkdirp "0.5.1"
     supports-color "4.4.0"
 
+moment@^2.10.6:
+  version "2.19.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167"
+
 mongodb-core@2.1.17:
   version "2.1.17"
   resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.1.17.tgz#a418b337a14a14990fb510b923dee6a813173df8"
@@ -3791,7 +3920,15 @@ muri@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/muri/-/muri-1.3.0.tgz#aeccf3db64c56aa7c5b34e00f95b7878527a4721"
 
-nan@^2.3.0, nan@^2.3.2:
+mv@~2:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2"
+  dependencies:
+    mkdirp "~0.5.1"
+    ncp "~2.0.0"
+    rimraf "~2.4.0"
+
+nan@^2.3.0, nan@^2.3.2, nan@^2.3.3:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46"
 
@@ -3799,6 +3936,10 @@ native-promise-only@^0.8.1:
   version "0.8.1"
   resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
 
+ncp@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+
 ndjson@^1.4.3:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/ndjson/-/ndjson-1.5.0.tgz#ae603b36b134bcec347b452422b0bf98d5832ec8"
@@ -4091,7 +4232,7 @@ on-finished@^2.3.0, on-finished@~2.3.0:
   dependencies:
     ee-first "1.1.1"
 
-on-headers@~1.0.1:
+on-headers@^1.0.1, on-headers@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7"
 
@@ -4229,13 +4370,22 @@ parseurl@~1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
 
+passport-ldapauth@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/passport-ldapauth/-/passport-ldapauth-2.0.0.tgz#42dff004417185d0a4d9f776a3eed8d4731fd689"
+  dependencies:
+    "@types/node" "^7.0.23"
+    "@types/passport" "^0.3.3"
+    ldapauth-fork "^4.0.1"
+    passport-strategy "^1.0.0"
+
 passport-local@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee"
   dependencies:
     passport-strategy "1.x.x"
 
-passport-strategy@1.x.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"
 
@@ -4642,6 +4792,10 @@ postcss@^6.0.1:
     source-map "^0.6.1"
     supports-color "^4.4.0"
 
+precond@0.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac"
+
 prepend-http@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
@@ -5211,6 +5365,12 @@ rimraf@2, rimraf@^2.5.1, rimraf@^2.6.1:
   dependencies:
     glob "^7.0.5"
 
+rimraf@~2.4.0:
+  version "2.4.5"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da"
+  dependencies:
+    glob "^6.0.1"
+
 ripemd160@^2.0.0, ripemd160@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7"
@@ -5234,6 +5394,10 @@ safe-buffer@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
 
+safe-json-stringify@~1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz#81a098f447e4bbc3ff3312a243521bc060ef5911"
+
 samsam@1.x, samsam@^1.1.3:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
@@ -5953,11 +6117,17 @@ vary@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
 
+vasync@^1.6.4:
+  version "1.6.4"
+  resolved "https://registry.yarnpkg.com/vasync/-/vasync-1.6.4.tgz#dfe93616ad0e7ae801b332a9d88bfc5cdc8e1d1f"
+  dependencies:
+    verror "1.6.0"
+
 vendors@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22"
 
-verror@1.10.0:
+verror@1.10.0, verror@^1.8.1:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
   dependencies:
@@ -5965,6 +6135,12 @@ verror@1.10.0:
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
+verror@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.6.0.tgz#7d13b27b1facc2e2da90405eb5ea6e5bdd252ea5"
+  dependencies:
+    extsprintf "1.2.0"
+
 vlq@^0.2.1:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"