Explorar o código

Merge branch 'master' into feat/ldap-auth

# Conflicts:
#	config/env.dev.js
#	lib/routes/index.js
Yuki Takei %!s(int64=8) %!d(string=hai) anos
pai
achega
9caac121ed

+ 2 - 4
CHANGES.md

@@ -4,11 +4,9 @@ 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
+* Improvement: Ensure to be able to login with both of username or email
+* Fix: The problem that couldn't update user data in /me
 * Support: Upgrade outdated libs
 
 ## 2.0.9

+ 5 - 3
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,9 +11,11 @@ module.exports = {
   // filters for debug
   DEBUG: [
     // 'express:*',
-    'crowi:routes:login',
+    // 'crowi:crowi',
+    'crowi:crowi:express-init',
+    // 'crowi:routes:login',
     'crowi:routes:login-passport',
-    'crowi:service:PassportService',
+    // 'crowi:service:PassportService',
     // 'crowi:*',
     // 'crowi:routes:page',
     // 'crowi:plugins:*',

+ 15 - 6
lib/crowi/express-init.js

@@ -19,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')
     ;
 
@@ -47,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')
@@ -98,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();
     }
@@ -113,8 +113,11 @@ module.exports = function(crowi, app) {
   });
 
   // passport
-  app.use(passport.initialize());
-  app.use(passport.session());
+  if (Config.isEnabledPassport(config)) {
+    debug('initialize Passport')
+    app.use(passport.initialize());
+    app.use(passport.session());
+  }
 
   app.use(flash());
 
@@ -122,8 +125,14 @@ module.exports = function(crowi, app) {
   app.use(middleware.swigFunctions(crowi, app));
 
   app.use(middleware.csrfKeyGenerator(crowi, app));
-  // app.use(middleware.loginChecker(crowi, app));
-  app.use(middleware.loginCheckerForPassport(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));
 };

+ 11 - 1
lib/crowi/index.js

@@ -252,6 +252,16 @@ Crowi.prototype.getInterceptorManager = function() {
 }
 
 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);
@@ -380,7 +390,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'),

+ 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';

+ 6 - 13
lib/models/user.js

@@ -2,6 +2,7 @@ module.exports = function(crowi) {
   var debug = require('debug')('crowi:models:user')
     , mongoose = require('mongoose')
     , mongoosePaginate = require('mongoose-paginate')
+    , uniqueValidator = require('mongoose-unique-validator')
     , crypto = require('crypto')
     , async = require('async')
     , ObjectId = mongoose.Schema.Types.ObjectId
@@ -31,7 +32,7 @@ module.exports = function(crowi) {
     googleId: String,
     name: { type: String },
     username: { type: String, index: true },
-    email: { type: String, required: true, index: true  },
+    email: { type: String, required: true, unique: true },
     introduction: { type: String },
     password: String,
     apiToken: String,
@@ -46,6 +47,7 @@ module.exports = function(crowi) {
     admin: { type: Boolean, default: 0, index: true  }
   });
   userSchema.plugin(mongoosePaginate);
+  userSchema.plugin(uniqueValidator);
 
   userEvent.on('activated', userEvent.onActivated);
 
@@ -129,16 +131,6 @@ module.exports = function(crowi) {
     return false;
   };
 
-  userSchema.methods.update = function(name, email, lang, callback) {
-    this.name = name;
-    this.email = email;
-    this.lang = lang;
-
-    this.save(function(err, userData) {
-      return callback(err, userData);
-    });
-  };
-
   userSchema.methods.updateLastLoginAt = function(lastLoginAt, callback) {
     this.lastLoginAt = lastLoginAt;
     this.save(function(err, userData) {
@@ -255,12 +247,13 @@ module.exports = function(crowi) {
     debug('Delete User', this);
 
     const now = new Date();
+    const deletedLabel = `deleted_at_${now.getTime()}`;
 
     this.status = STATUS_DELETED;
-    this.username = `deleted_at_${now.getTime()}`;
+    this.username = deletedLabel;
     this.password = '';
     this.name = '';
-    this.email = 'deleted@deleted';
+    this.email = `${deletedLabel}@deleted`;
     this.googleId = null;
     this.isGravatarEnabled = false;
     this.image = null;

+ 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;
 

+ 18 - 4
lib/routes/index.js

@@ -19,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);
@@ -31,8 +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);
-  app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLdap, loginPassport.loginWithLocal, loginPassport.loginFailure);
+
+  // 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);
@@ -43,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);

+ 6 - 10
lib/routes/me.js

@@ -89,26 +89,22 @@ module.exports = function(crowi, app) {
         return res.render('me/index', {});
       }
 
-      User.findOne({email: email}, (err, existingUserData) => {
-        if (existingUserData) {
-          debug('Email address was duplicated');
-          req.form.errors.push('It can not be changed to that mail address');
-          return res.render('me/index', {});
-        }
-
-        userData.update(name, email, lang, (err, userData) => {
+      User.findOneAndUpdate(
+        { email: userData.email },                  // query
+        { name, email, lang },                      // updating data
+        { runValidators: true, context: 'query' },  // for validation
+                                                    //   see https://www.npmjs.com/package/mongoose-unique-validator#find--updates -- 2017.09.24 Yuki Takei
+        (err) => {
           if (err) {
             Object.keys(err.errors).forEach((e) => {
               req.form.errors.push(err.errors[e].message);
             });
             return res.render('me/index', {});
           }
-
           req.i18n.changeLanguage(lang);
           req.flash('successMessage', req.t('Updated'));
           return res.redirect('/me');
         });
-      });
 
     } else { // method GET
       /// そのうちこのコードはいらなくなるはず

+ 4 - 0
lib/service/passport.js

@@ -22,6 +22,8 @@ class PassportService {
    * @memberof PassportService
    */
   setupLocalStrategy() {
+    debug('setup LocalStrategy');
+
     const User = this.crowi.model('User');
 
     passport.use(new LocalStrategy(
@@ -108,6 +110,8 @@ class PassportService {
    * @memberof PassportService
    */
   setupSerializer() {
+    debug('setup serializer and deserializer');
+
     const User = this.crowi.model('User');
 
     passport.serializeUser(function(user, done) {

+ 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 %}

+ 1 - 0
package.json

@@ -92,6 +92,7 @@
     "moment": "^2.18.0",
     "mongoose": "^4.11.1",
     "mongoose-paginate": "5.0.x",
+    "mongoose-unique-validator": "^1.0.6",
     "multer": "~1.3.0",
     "node-sass": "^4.5.0",
     "nodemailer": "^4.0.1",

+ 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;
+  }
 } // }}}

+ 12 - 1
yarn.lock

@@ -3992,7 +3992,11 @@ lodash.create@3.1.1:
     lodash._basecreate "^3.0.0"
     lodash._isiterateecall "^3.0.0"
 
-lodash.get@^4.4.2:
+lodash.foreach@^4.1.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
+
+lodash.get@^4.0.2, lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
 
@@ -4384,6 +4388,13 @@ mongoose-paginate@5.0.x:
   dependencies:
     bluebird "3.0.5"
 
+mongoose-unique-validator@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/mongoose-unique-validator/-/mongoose-unique-validator-1.0.6.tgz#fab31e68c1a5ba6f5b05da8e93842db55eb0a3b1"
+  dependencies:
+    lodash.foreach "^4.1.0"
+    lodash.get "^4.0.2"
+
 mongoose@^4.11.1:
   version "4.11.12"
   resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.11.12.tgz#48ebd5cad051f6ddfd46648b86a19c7fd30e36db"