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

Merge pull request #170 from weseek/feat/use-passport

Feat/use passport
Yuki Takei 8 лет назад
Родитель
Сommit
fe860d518a

+ 5 - 0
CHANGES.md

@@ -1,6 +1,11 @@
 CHANGES
 ========
 
+## 2.1.0
+
+* Feat: Adopt Passport the authentication middleware
+* Improvement: Ensure to be able to login with both of username or email
+
 ## 2.0.10
 
 * Feat: Selective batch deletion in search result page

+ 6 - 1
config/env.dev.js

@@ -1,6 +1,6 @@
 module.exports = {
   NODE_ENV: 'development',
-  // FILE_UPLOAD: 'local',
+  FILE_UPLOAD: 'local',
   // MATHJAX: 1,
   // REDIS_URL: 'redis://localhost:6379/crowi',
   // ELASTICSEARCH_URI: 'http://localhost:9200/crowi',
@@ -11,6 +11,11 @@ module.exports = {
   // filters for debug
   DEBUG: [
     // 'express:*',
+    // 'crowi:crowi',
+    'crowi:crowi:express-init',
+    // 'crowi:routes:login',
+    'crowi:routes:login-passport',
+    // 'crowi:service:PassportService',
     // 'crowi:*',
     // 'crowi:routes:page',
     // 'crowi:plugins:*',

+ 19 - 3
lib/crowi/express-init.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , bodyParser     = require('body-parser')
     , cookieParser   = require('cookie-parser')
     , methodOverride = require('method-override')
+    , passport       = require('passport')
     , session        = require('express-session')
     , basicAuth      = require('basic-auth-connect')
     , flash          = require('connect-flash')
@@ -18,8 +19,10 @@ module.exports = function(crowi, app) {
     , i18nMiddleware = require('i18next-express-middleware')
     , i18nUserSettingDetector  = require('../util/i18nUserSettingDetector')
     , env            = crowi.node_env
+    , config         = crowi.getConfig()
     , middleware     = require('../util/middlewares')
 
+    , Config = crowi.model('Config')
     , User = crowi.model('User')
     ;
 
@@ -46,7 +49,6 @@ module.exports = function(crowi, app) {
   app.use(function(req, res, next) {
     var now = new Date()
       , baseUrl
-      , config = crowi.getConfig()
       , tzoffset = -(config.crowi['app:timezone'] || 9) * 60 // for datez
       , Page = crowi.model('Page')
       , User = crowi.model('User')
@@ -97,7 +99,6 @@ module.exports = function(crowi, app) {
 
   // Set basic auth middleware
   app.use(function(req, res, next) {
-    var config = crowi.getConfig();
     if (req.query.access_token || req.body.access_token) {
       return next();
     }
@@ -111,12 +112,27 @@ module.exports = function(crowi, app) {
     }
   });
 
+  // passport
+  if (Config.isEnabledPassport(config)) {
+    debug('initialize Passport')
+    app.use(passport.initialize());
+    app.use(passport.session());
+  }
+
   app.use(flash());
 
   app.use(middleware.swigFilters(app, swig));
   app.use(middleware.swigFunctions(crowi, app));
 
-  app.use(middleware.loginChecker(crowi, app));
+  app.use(middleware.csrfKeyGenerator(crowi, app));
+
+  // switch loginChecker
+  if (Config.isEnabledPassport(config)) {
+    app.use(middleware.loginCheckerForPassport(crowi, app));
+  }
+  else {
+    app.use(middleware.loginChecker(crowi, app));
+  }
 
   app.use(i18nMiddleware.handle(i18next));
 };

+ 23 - 1
lib/crowi/index.js

@@ -85,6 +85,8 @@ Crowi.prototype.init = function() {
     });
   }).then(function() {
     return self.scanRuntimeVersions();
+  }).then(function() {
+    return self.setupPassport();
   }).then(function() {
     return self.setupSearcher();
   }).then(function() {
@@ -249,6 +251,26 @@ Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
 }
 
+Crowi.prototype.setupPassport = function() {
+  const config = this.getConfig();
+  const Config = this.model('Config');
+
+  if (!Config.isEnabledPassport(config)) {
+    // disabled
+    return;
+  }
+
+  debug('Passport is enabled');
+
+  const PassportService = require('../service/passport');
+
+  const passportService = new PassportService(this);
+  passportService.setupLocalStrategy();
+  passportService.setupSerializer();
+
+  return Promise.resolve();
+}
+
 Crowi.prototype.setupSearcher = function() {
   var self = this;
   var searcherUri = this.env.ELASTICSEARCH_URI
@@ -367,7 +389,7 @@ Crowi.prototype.buildServer = function() {
   var Config = this.model('Config');
   var isEnabledPlugins = Config.isEnabledPlugins(this.config);
   if (isEnabledPlugins) {
-    debug('plugins enabled');
+    debug('Plugins are enabled');
     var PluginService = require('../plugins/plugin.service');
     var pluginService = new PluginService(this, app);
     pluginService.autoDetectAndLoadPlugins();

+ 0 - 0
lib/form/admin/sec.js → lib/form/admin/securityGeneral.js


+ 0 - 0
lib/form/admin/google.js → lib/form/admin/securityGoogle.js


+ 9 - 0
lib/form/admin/securityMechanism.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('settingForm[security:isEnabledPassport]').trim().toBooleanStrict()
+);
+

+ 3 - 2
lib/form/index.js

@@ -12,11 +12,12 @@ module.exports = {
   },
   admin: {
     app: require('./admin/app'),
-    sec: require('./admin/sec'),
     mail: require('./admin/mail'),
     aws: require('./admin/aws'),
-    google: require('./admin/google'),
     plugin: require('./admin/plugin'),
+    securityGeneral: require('./admin/securityGeneral'),
+    securityGoogle: require('./admin/securityGoogle'),
+    securityMechanism: require('./admin/securityMechanism'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),

+ 1 - 1
lib/form/login.js

@@ -4,6 +4,6 @@ var form = require('express-form')
   , field = form.field;
 
 module.exports = form(
-  field('loginForm.email').required(),
+  field('loginForm.username').required(),
   field('loginForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );

+ 10 - 0
lib/models/config.js

@@ -27,6 +27,8 @@ module.exports = function(crowi) {
     let config = getDefaultCrowiConfigs();
 
     // overwrite
+    config['app:title'] = 'crowi-plus';
+    config['security:isEnabledPassport'] = true;
     config['customize:behavior'] = 'crowi-plus';
     config['customize:layout'] = 'crowi-plus';
     config['customize:isSavedStatesOfTabChanges'] = false;
@@ -51,6 +53,8 @@ module.exports = function(crowi) {
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
+      'security:isEnabledPassport' : false,
+
       'aws:bucket'          : 'crowi',
       'aws:region'          : 'ap-northeast-1',
       'aws:accessKeyId'     : '',
@@ -235,6 +239,12 @@ module.exports = function(crowi) {
       });
   };
 
+  configSchema.statics.isEnabledPassport = function(config)
+  {
+    const key = 'security:isEnabledPassport';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config)
   {
     var method = crowi.env.FILE_UPLOAD || 'aws';

+ 11 - 0
lib/models/user.js

@@ -465,6 +465,17 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.statics.findUserByUsernameOrEmail = function(usernameOrEmail, password, callback) {
+    this.findOne()
+      .or([
+        {username: usernameOrEmail},
+        {email: usernameOrEmail},
+      ])
+      .exec((err, userData) => {
+        callback(err, userData);
+      });
+  };
+
   userSchema.statics.findUserByEmailAndPassword = function(email, password, callback) {
     var hashedPassword = generatePassword(password);
     this.findOne({email: email, password: hashedPassword}, function (err, userData) {

+ 20 - 1
lib/routes/admin.js

@@ -87,7 +87,15 @@ module.exports = function(crowi, app) {
   actions.app.settingUpdate = function(req, res) {
   };
 
-  // app.get('/admin/markdonw'                  , admin.markdonw.index);
+  // app.get('/admin/security'                  , admin.security.index);
+  actions.security = {};
+  actions.security.index = function(req, res) {
+    var settingForm;
+    settingForm = Config.setupCofigFormData('crowi', req.config);
+    return res.render('admin/security', { settingForm });
+  };
+
+  // app.get('/admin/markdown'                  , admin.markdown.index);
   actions.markdown = {};
   actions.markdown.index = function(req, res) {
     var config = crowi.getConfig();
@@ -497,6 +505,17 @@ module.exports = function(crowi, app) {
     }
   };
 
+  actions.api.securitySetting = function(req, res) {
+    var form = req.form.settingForm;
+
+    if (req.form.isValid) {
+      debug('form content', form);
+      return saveSetting(req, res, form);
+    } else {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+  };
+
   actions.api.customizeSetting = function(req, res) {
     var form = req.form.settingForm;
 

+ 19 - 3
lib/routes/index.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
     , form      = require('../form')
     , page      = require('./page')(crowi, app)
     , login     = require('./login')(crowi, app)
+    , loginPassport = require('./login-passport')(crowi, app)
     , logout    = require('./logout')(crowi, app)
     , me        = require('./me')(crowi, app)
     , admin     = require('./admin')(crowi, app)
@@ -18,6 +19,9 @@ module.exports = function(crowi, app) {
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser(crowi, app)
     , csrf      = middleware.csrfVerify(crowi, app)
+
+    , config    = crowi.getConfig()
+    , Config    = crowi.model('Config')
     ;
 
   app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.pageListShow);
@@ -30,7 +34,15 @@ module.exports = function(crowi, app) {
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
   app.get('/login/invited'           , login.invited);
   app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
-  app.post('/login'                  , form.login                           , csrf, login.login);
+
+  // switch POST /login route
+  if (Config.isEnabledPassport(config)) {
+    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLdap, loginPassport.loginWithLocal, loginPassport.loginFailure);
+  }
+  else {
+    app.post('/login'                , form.login                           , csrf, login.login);
+  }
+
   app.post('/register'               , form.register                        , csrf, login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
   app.post('/register/google'        , login.registerGoogle);
@@ -41,12 +53,16 @@ module.exports = function(crowi, app) {
   app.get('/admin'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
   app.get('/admin/app'                  , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
   app.post('/_api/admin/settings/app'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/sec'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
   app.post('/_api/admin/settings/mail'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.mail, admin.api.appSetting);
   app.post('/_api/admin/settings/aws'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.google, admin.api.appSetting);
   app.post('/_api/admin/settings/plugin', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.plugin, admin.api.appSetting);
 
+  // security admin
+  app.get('/admin/security'                     , loginRequired(crowi, app) , middleware.adminRequired() , admin.security.index);
+  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);
+
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting);

+ 78 - 0
lib/routes/login-passport.js

@@ -0,0 +1,78 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:login-passport')
+    , passport = require('passport')
+    , config = crowi.getConfig()
+    , Config = crowi.model('Config');
+
+  /**
+   * success handler
+   * @param {*} req
+   * @param {*} res
+   */
+  const loginSuccess = (req, res, user) => {
+    var jumpTo = req.session.jumpTo;
+    if (jumpTo) {
+      req.session.jumpTo = null;
+      return res.redirect(jumpTo);
+    } else {
+      return res.redirect('/');
+    }
+  };
+
+  /**
+   * failure handler
+   * @param {*} req
+   * @param {*} res
+   */
+  const loginFailure = (req, res) => {
+    req.flash('warningMessage', 'Sign in failure.');
+    return res.redirect('/login');
+  };
+
+
+  const loginWithLdap = (req, res, next) => {
+    // TODO impl with vesse/passport-ldapauth
+    return next();
+  }
+
+  /**
+   * login with LocalStrategy action
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
+  const loginWithLocal = (req, res, next) => {
+    const loginForm = req.body.loginForm;
+
+    if (!req.form.isValid) {
+      return res.render('login', {
+      });
+    }
+
+    passport.authenticate('local', (err, user, info) => {
+      debug('---authentication with LocalStrategy start---');
+      debug('user', user);
+      debug('info', info);
+
+      if (err) { return next(err); }
+      if (!user) { return next(); }
+      req.logIn(user, (err) => {
+        if (err != null) {
+          debug(err);
+          return next();
+        }
+        return loginSuccess(req, res, user);
+      });
+
+      debug('---authentication with LocalStrategy end---');
+    })(req, res, next);
+  }
+
+  return {
+    loginFailure,
+    loginWithLdap,
+    loginWithLocal,
+  };
+};

+ 14 - 12
lib/routes/login.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   var googleapis = require('googleapis')
     , debug = require('debug')('crowi:routes:login')
     , async    = require('async')
+    , passport = require('passport')
     , config = crowi.getConfig()
     , mailer = crowi.getMailer()
     , Page = crowi.model('Page')
@@ -75,21 +76,17 @@ module.exports = function(crowi, app) {
     var loginForm = req.body.loginForm;
 
     if (req.method == 'POST' && req.form.isValid) {
-      var email = loginForm.email;
+      var username = loginForm.username;
       var password = loginForm.password;
 
-      User.findUserByEmailAndPassword(email, password, function(err, userData) {
-        debug('on login findUserByEmailAndPassword', err, userData);
-        if (userData) {
-          userData.updateLastLoginAt(Date.now(), function(err, userData) {
-            if (err) {
-              debug(err);
-            }
-          });
-          loginSuccess(req, res, userData);
-        } else {
-          loginFailure(req, res);
+      // find user
+      User.findUserByUsernameOrEmail(username, password, (err, user) => {
+        if (err) { return loginFailure(req, res); }
+        // check existence and password
+        if (!user || !user.isPasswordValid(password)) {
+          return loginFailure(req, res);
         }
+        return loginSuccess(req, res, user);
       });
     } else { // method GET
       if (req.form) {
@@ -254,6 +251,11 @@ module.exports = function(crowi, app) {
                 });
               }
             } else {
+              // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
+              // cz. loginSuccess method doesn't work on it's own when using passport
+              //      because `req.login()` prepared by passport is not called.
+              req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
+
               return loginSuccess(req, res, userData);
             }
           }

+ 68 - 0
lib/service/passport.js

@@ -0,0 +1,68 @@
+const debug = require('debug')('crowi:service:PassportService');
+const passport = require('passport');
+const LocalStrategy = require('passport-local').Strategy;
+
+/**
+ * the service class of Passport
+ */
+class PassportService {
+
+  // see '/lib/form/login.js'
+  static get USERNAME_FIELD() { return 'loginForm[username]' }
+  static get PASSWORD_FIELD() { return 'loginForm[password]' }
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * setup LocalStrategy
+   *
+   * @memberof PassportService
+   */
+  setupLocalStrategy() {
+    debug('setup LocalStrategy');
+
+    const User = this.crowi.model('User');
+
+    passport.use(new LocalStrategy(
+      {
+        usernameField: PassportService.USERNAME_FIELD,
+        passwordField: PassportService.PASSWORD_FIELD,
+      },
+      (username, password, done) => {
+        // find user
+        User.findUserByUsernameOrEmail(username, password, (err, user) => {
+          if (err) { return done(err); }
+          // check existence and password
+          if (!user || !user.isPasswordValid(password)) {
+            return done(null, false, { message: 'Incorrect credentials.' });
+          }
+          return done(null, user);
+        });
+      }
+    ));
+  }
+
+  /**
+   * setup serializer and deserializer
+   *
+   * @memberof PassportService
+   */
+  setupSerializer() {
+    debug('setup serializer and deserializer');
+
+    const User = this.crowi.model('User');
+
+    passport.serializeUser(function(user, done) {
+      done(null, user.id);
+    });
+    passport.deserializeUser(function(id, done) {
+      User.findById(id, function(err, user) {
+        done(err, user);
+      });
+    });
+  }
+}
+
+module.exports = PassportService;

+ 16 - 2
lib/util/middlewares.js

@@ -1,15 +1,22 @@
 var debug = require('debug')('crowi:lib:middlewares');
 var md5 = require('md5');
 
-exports.loginChecker = function(crowi, app) {
+exports.csrfKeyGenerator = function(crowi, app) {
   return function(req, res, next) {
-    var User = crowi.model('User');
     var csrfKey = (req.session && req.session.id) || 'anon';
 
     if (req.csrfToken === null) {
       req.csrfToken = crowi.getTokens().create(csrfKey);
     }
 
+    next();
+  }
+}
+
+exports.loginChecker = function(crowi, app) {
+  return function(req, res, next) {
+    var User = crowi.model('User');
+
     // session に user object が入ってる
     if (req.session.user && '_id' in req.session.user) {
       User.findById(req.session.user._id, function(err, userData) {
@@ -29,6 +36,13 @@ exports.loginChecker = function(crowi, app) {
   };
 };
 
+exports.loginCheckerForPassport = function(crowi, app) {
+  return function(req, res, next) {
+    res.locals.user = req.user;
+    next();
+  };
+};
+
 exports.csrfVerify = function(crowi, app) {
   return function(req, res, next) {
     var token = req.body._csrf || req.query._csrf || null;

+ 1 - 108
lib/views/admin/app.html

@@ -81,70 +81,6 @@
       </fieldset>
       </form>
 
-      <form action="/_api/admin/settings/sec" method="post" class="form-horizontal" id="secSettingForm" role="form">
-      <fieldset>
-      <legend>セキュリティ設定</legend>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">Basic認証</label>
-          <div class="col-xs-3">
-            <label for="">ID</label>
-            <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}">
-          </div>
-          <div class="col-xs-3">
-            <label for="">パスワード</label>
-            <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}">
-          </div>
-          <div class="col-xs-offset-3 col-xs-9">
-            <p class="help-block">
-              Basic認証を設定すると、ページ全体に共通の認証がかかります。<br>
-              IDとパスワードは暗号化されずに送信されるのでご注意下さい。<br>
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">ゲストユーザーのアクセス</label>
-          <div class="col-xs-6">
-            <select class="form-control" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
-              {% for modeValue, modeLabel in consts.restrictGuestMode %}
-              <option value="{{ modeValue }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ modeLabel }}</option>
-              {% endfor %}
-            </select>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
-          <div class="col-xs-6">
-            <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
-              {% for modeValue, modeLabel in consts.registrationMode %}
-              <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
-              {% endfor %}
-            </select>
-            <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
-          <div class="col-xs-8">
-            <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
-            <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
-            1行に1メールアドレス入力してください。</p>
-          </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>
-        </div>
-
-      </fieldset>
-      </form>
-
       <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
       <fieldset>
       <legend>メールの設定</legend>
@@ -239,49 +175,6 @@
       </fieldset>
       </form>
 
-      <form action="/_api/admin/settings/google" method="post" class="form-horizontal" id="googleSettingForm" role="form">
-      <fieldset>
-      <legend>Google 設定</legend>
-        <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">更新</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
       <form action="/_api/admin/settings/plugin" method="post" class="form-horizontal" id="pluginSettingForm" role="form">
       <fieldset>
       <legend>プラグイン設定</legend>
@@ -317,7 +210,7 @@
   </div>
 
   <script>
-    $('#appSettingForm, #secSettingForm, #mailSettingForm, #awsSettingForm, #googleSettingForm, #pluginSettingForm').each(function() {
+    $('#appSettingForm, #mailSettingForm, #awsSettingForm, #pluginSettingForm').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 271 - 0
lib/views/admin/security.html

@@ -0,0 +1,271 @@
+{% 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">
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'security'} %}
+    </div>
+    <div class="col-md-9">
+
+      {% set smessage = req.flash('successMessage') %}
+      {% if smessage.length %}
+      <div class="alert alert-success">
+        {% for e in smessage %}
+          {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      {% set emessage = req.flash('errorMessage') %}
+      {% if emessage.length %}
+      <div class="alert alert-danger">
+        {% for e in emessage %}
+        {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      <form action="/_api/admin/security/general" method="post" class="form-horizontal" id="generalSetting" role="form">
+        <fieldset>
+        <legend>基本設定</legend>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">Basic認証</label>
+            <div class="col-xs-3">
+              <label for="">ID</label>
+              <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}">
+            </div>
+            <div class="col-xs-3">
+              <label for="">パスワード</label>
+              <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}">
+            </div>
+            <div class="col-xs-offset-3 col-xs-9">
+              <p class="help-block">
+                Basic認証を設定すると、ページ全体に共通の認証がかかります。<br>
+                IDとパスワードは暗号化されずに送信されるのでご注意下さい。<br>
+              </p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">ゲストユーザーのアクセス</label>
+            <div class="col-xs-6">
+              <select class="form-control" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
+                {% for modeValue, modeLabel in consts.restrictGuestMode %}
+                <option value="{{ modeValue }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+                {% endfor %}
+              </select>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
+            <div class="col-xs-6">
+              <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
+                {% for modeValue, modeLabel in consts.registrationMode %}
+                <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+                {% endfor %}
+              </select>
+              <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
+            <div class="col-xs-8">
+              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
+              <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
+              1行に1メールアドレス入力してください。</p>
+            </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>
+          </div>
+
+        </fieldset>
+      </form>
+
+      <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>
+          <div class="form-group">
+            <div class="col-xs-6">
+              <h4>
+                <input type="radio" name="settingForm[security:isEnabledPassport]" value="false"
+                    {% if !settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
+                Official Crowi authentication mechanism
+              </h4>
+              <ul>
+                <li>Username, E-mail and Password authentication</li>
+                <li>Google OAuth2 authentication</li>
+              </ul>
+            </div>
+            <div class="col-xs-6">
+              <h4>
+                <input type="radio" name="settingForm[security:isEnabledPassport]" value="true"
+                    {% if true === settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
+                <a href="http://passportjs.org/">
+                  <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> Passport
+                </a> authentication mechanism <small class="text-success">(Recommended)</small>
+              </h4>
+              <ul>
+                <li>Username, E-mail and Password authentication</li>
+                <li class="text-muted">(TBD) <del>LDAP authentication</del></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>
+                <li class="text-muted">(TBD) <del>Github OAuth2 authentication</del></li>
+              </ul>
+            </div>
+          </div>
+
+          <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>
+            </div>
+          </div>
+      </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 アカウントにコネクトして登録やログインが可能になります。
+          </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">更新</button>
+            </div>
+          </div>
+
+        </fieldset>
+      </form>
+
+      <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>
+
+        </fieldset>
+      </form>
+
+    </div>
+  </div>
+
+  <script>
+    $('#generalSetting, #googleSetting, #mechanismSetting').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 = $('button', this);
+        $button.attr('disabled', 'disabled');
+        var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
+          {
+            if (data.status) {
+              showMessage($id, '更新しました');
+            } else {
+              showMessage($id, data.message, 'danger');
+            }
+          })
+          .fail(function() {
+            showMessage($id, 'エラーが発生しました', 'danger');
+          })
+          .always(function() {
+            $button.prop('disabled', false);
+        });
+        return false;
+      });
+    });
+
+    // switch display according to on / off of radio buttons
+    $('input[name="settingForm[security:isEnabledPassport]"]:radio').change(function() {
+      const isEnabledPassport = ($(this).val() === "true");
+
+      if (isEnabledPassport) {
+        $('form.officialCrowiMechanism').hide(400);
+        $('form.passportStrategy').show(400);
+      }
+      else {
+        $('form.officialCrowiMechanism').show(400);
+        $('form.passportStrategy').hide(400);
+      }
+    });
+  </script>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}

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

@@ -4,6 +4,7 @@
 <ul class="nav nav-pills nav-stacked">
   <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
   <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
+  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="fa fa-shield"></i> セキュリティ設定</a></li>
   <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>

+ 8 - 7
lib/views/layout/single.html

@@ -17,12 +17,13 @@
 {% endblock %} {# layout_main #}
 
 {% block footer %}
-<div id="footer-container" class="footer">
-  <footer class="">
-    <p>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </p>
-  </footer>
+{% parent %}
+<div class="system-version">
+  <span>
+    <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+  </span>
+  <span>
+    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
+  </span>
 </div>
 {% endblock %}

+ 12 - 5
lib/views/login.html

@@ -21,10 +21,17 @@
     <h2>{{ t('Sign in') }}</h2>
 
     <div id="login-form-errors">
-      {% set message = req.flash('warningMessage') %}
-      {% if message.length %}
+      {% set success = req.flash('successMessage') %}
+      {% if success.length %}
+      <div class="alert alert-success">
+        {{ success }}
+      </div>
+      {% endif %}
+
+      {% set warn = req.flash('warningMessage') %}
+      {% if warn.length %}
       <div class="alert alert-danger">
-        {{ message }}
+        {{ warn }}
       </div>
       {% endif %}
 
@@ -40,8 +47,8 @@
     </div>
     <form role="form" action="/login" method="post">
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
-        <input type="text" class="form-control" placeholder="E-mail" name="loginForm[email]">
+        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <input type="text" class="form-control" placeholder="Username or E-mail" name="loginForm[username]">
       </div>
 
       <div class="input-group">

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.0.10-RC",
+  "version": "2.1.0-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -98,6 +98,8 @@
     "nodemailer-ses-transport": "~1.5.0",
     "normalize-path": "^2.1.1",
     "optimize-js-plugin": "0.0.4",
+    "passport": "^0.4.0",
+    "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
     "plantuml-encoder": "^1.2.4",
     "react": "^15.5.0",

+ 21 - 0
public/images/admin/security/passport-logo.svg

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="400px" height="500px" viewBox="0 0 400 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
+    <!-- Generator: Sketch 3.3.1 (12002) - http://www.bohemiancoding.com/sketch -->
+    <title>Group</title>
+    <desc>Created with Sketch.</desc>
+    <defs/>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
+        <g id="passport_logo_final" sketch:type="MSLayerGroup" transform="translate(-400.000000, -350.000000)">
+            <g id="Group" sketch:type="MSShapeGroup">
+                <g id="Shape">
+                    <g transform="translate(400.000000, 350.000000)">
+                        <path d="M200,0 C89.5,0 0,89.5 0,200 L100,200 C100,144.8 144.8,100 200,100 L200,0 L200,0 Z" fill="#D6FF00"/>
+                        <path d="M400,200 C400,89.5 310.5,0 200,0 L200,100 C255.2,100 300,144.8 300,200 L400,200 L400,200 Z" fill="#34E27A"/>
+                        <path d="M200,400 C310.5,400 400,310.5 400,200 L300,200 C300,255.2 255.2,300 200,300 L200,400 L200,400 Z" fill="#00B9F1"/>
+                        <path d="M100,400 L100,200 L0,200 L0,500 L200,500 L200,400 L100,400 Z" fill="#FFFFFF"/>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 6 - 0
resource/css/_admin.scss

@@ -42,4 +42,10 @@
     font-family: $font-family-monospace;
   }
 
+  .passport-logo {
+    background: url("/images/admin/security/passport_logo.svg") center left no-repeat;
+    padding: 4px;
+    height: 32px;
+    background-color: black;
+  }
 } // }}}

+ 21 - 0
yarn.lock

@@ -4800,6 +4800,23 @@ parseurl@~1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
 
+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:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
+
+passport@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
+  dependencies:
+    passport-strategy "1.x.x"
+    pause "0.0.1"
+
 path-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
@@ -4854,6 +4871,10 @@ pathval@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
 
+pause@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d"
+
 pbkdf2@^3.0.3:
   version "3.0.14"
   resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade"