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

Merge remote-tracking branch 'origin/master' into fix/http-strategy

# Conflicts:
#	src/server/models/config.js
#	src/server/routes/index.js
#	src/server/util/swigFunctions.js
#	src/server/views/admin/security.html
#	src/server/views/login.html
#	src/server/views/widget/alert_breaking_changes.html
Yuki Takei 6 лет назад
Родитель
Сommit
deed0cf2b9

+ 1 - 1
CHANGES.md

@@ -6,7 +6,7 @@
 
 * GROWI no longer supports
     * Basic Authentication
-    * Crowi Classic Authentication
+    * Crowi Classic Authentication Mechanism
     * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
 
 Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html

+ 1 - 0
package.json

@@ -116,6 +116,7 @@
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-google-auth": "^1.0.2",
+    "passport-http": "^0.3.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",

+ 16 - 12
resource/locales/en-US/translation.json

@@ -186,7 +186,7 @@
   "Re-enter new password": "Re-enter new password",
   "Password is not set": "Password is not set",
 
-  "Security Settings": "Security Settings",
+  "security_settings": "Security Settings",
 
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
@@ -261,7 +261,8 @@
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
-    "outdated": "Page is updated someone and now outdated. "
+    "outdated": "Page is updated someone and now outdated.",
+    "user_not_admin": "Only admin user can delete completely"
   },
 
   "modal_rename": {
@@ -282,15 +283,13 @@
   "Delete Completely": "Delete Completely",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "Delete Page",
-      "Delete recursively": "Delete recursively",
-      "Delete completely": "Delete completely"
-    },
-    "help": {
-      "recursively": "Delete children of under <code>%s</code> recursively",
-      "completely": "Delete completely instead of putting it into trash"
-    }
+    "delete_page": "Delete Page",
+    "deleting_page": "Deleting Page",
+    "delete_recursively": "Delete child pages under %s recursively.",
+    "delete_completely": "Delete Completely",
+    "delete_completely_restriction": "You have no admin to delete completely.",
+    "recursively": "Delete children of under <code>%s</code> recursively.",
+    "completely": "Delete completely instead of putting it into trash."
   },
 
   "modal_duplicate": {
@@ -431,7 +430,7 @@
   },
 
   "security_setting": {
-		"Basic authentication": "Basic authentication",
+		"Basic authentication": "Basic Authentication",
 		"Security settings": "Security settings",
 		"Guest users access": "Guest users access",
 		"Register limitation": "Register limitation",
@@ -449,6 +448,11 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User Group",
     "page_listing_2_desc": "Show pages that are restricted by User Group when listing/searching",
+    "complete_deletion": "Restrict Complete Deletion of Pages",
+    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "admin_only": "Admin Only",
+    "admin_and_author": "Admin and Author",
+    "anyone": "Anyone",
 
 		"Authentication mechanism settings": "Authentication Mechanism Settings",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the %s",

+ 15 - 12
resource/locales/ja/translation.json

@@ -186,7 +186,7 @@
   "Re-enter new password": "(確認用)",
   "Password is not set": "パスワードが設定されていません",
 
-  "Security Settings": "セキュリティ設定",
+  "security_settings": "セキュリティ設定",
 
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
@@ -261,7 +261,8 @@
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
-    "outdated": "ページが他のユーザーによって更新されました。"
+    "outdated": "ページが他のユーザーによって更新されました。",
+    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
 
   "modal_rename": {
@@ -282,15 +283,13 @@
   "Delete Completely": "完全削除",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "ページを削除する",
-      "Delete recursively": "全ての子ページも削除",
-      "Delete completely": "完全削除"
-    },
-    "help": {
-      "recursively": "<code>%s</code> 配下のページも削除します",
-      "completely": "ゴミ箱を経由せず、完全に削除します"
-    }
+    "delete_page": "ページを削除する",
+    "deleting_page": "ページパス",
+    "delete_recursively": "全ての子ページも削除",
+    "delete_completely": "完全削除",
+    "delete_completely_restriction": "完全削除をするための権限がありません。",
+    "recursively": "<code>%s</code> 配下のページも削除します",
+    "completely": "ゴミ箱を経由せず、完全に削除します"
   },
 
   "modal_duplicate": {
@@ -432,7 +431,6 @@
 
   "security_setting": {
     "Basic authentication": "Basic認証",
-    "Security settings": "セキュリティ設定",
     "Guest users access": "ゲストユーザーのアクセス",
     "Register limitation": "登録の制限",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -449,6 +447,11 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "complete_deletion": "完全削除制限機能",
+    "complete_deletion_explain": "完全削除できるユーザーを制限します。",
+    "admin_only": "管理者のみ可能",
+    "admin_and_author": "管理者とページ作者が可能",
+    "anyone": "誰でも可能",
 
     "Authentication mechanism settings":"認証機構設定",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",

+ 28 - 16
src/client/styles/agile-admin/inverse/colors/antarctic.scss

@@ -16,20 +16,23 @@ $active-nav-tabs-bgcolor: $themecolor;
 $logo-mark-fill: $themelight;
 $wikilinktext: lighten($themecolor, 5%);
 $wikilinktext-hover: lighten($wikilinktext, 15%);
-$inline-code-color: darken($subthemecolor, 5%);
-$inline-code-bg: lighten($subthemecolor, 70%);
+$inline-code-color: #c7254e;
+$inline-code-bg: #f9f2f4;
 $border: $subthemecolor;
 $border-original: $subthemecolor;
 $navbar-border: $themecolor;
-$background-color: rgba($color: $themelight, $alpha: 0.8);
-$info:$subthemecolor;
+$background-color: rgba(
+  $color: $themelight,
+  $alpha: 0.8,
+);
+$info: $subthemecolor;
 
 @import 'apply-colors';
 @import 'apply-colors-light';
 
 // change color of highlighted header in wiki (default: orange)
 .code-line,
-ul>.text-muted {
+ul > .text-muted {
   color: $subthemecolor;
 }
 
@@ -50,7 +53,7 @@ ul>.text-muted {
 }
 
 // add background-image
-.main-container>#wrapper>#page-wrapper,
+.main-container > #wrapper > #page-wrapper,
 .page-editor-preview-container {
   background-image: url('/images/themes/antarctic/bg.svg');
   background-attachment: fixed;
@@ -99,7 +102,7 @@ header.affix {
   }
 }
 
-#wrapper>.navbar>.navbar-header {
+#wrapper > .navbar > .navbar-header {
   border-bottom: 4px solid $accentcolor;
 }
 
@@ -111,7 +114,7 @@ header.affix {
   .page-comment-main {
     box-shadow: 0px 3px 15px rgba(0, 0, 0, 0.2);
 
-    .page-comment-creator>a {
+    .page-comment-creator > a {
       border-bottom: 1px double $subthemecolor;
     }
   }
@@ -131,7 +134,7 @@ header.affix {
     }
 
     .nav.nav-tabs {
-      >li.active>a {
+      > li.active > a {
         background: $themecolor;
         border-bottom: solid 1px $themecolor;
         border-bottom-color: $themecolor;
@@ -144,21 +147,23 @@ header.affix {
  * Tabs
  */
 
-.nav.nav-tabs>li.active>a {
+.nav.nav-tabs > li.active > a {
   color: $themelight;
 }
 
 .text-info,
 body:not(.on-edit) .nav.nav-tabs {
-  >li>a {
+  > li > a {
     color: $subthemecolor;
   }
 
-  >li.active>a {
+  > li.active > a {
     color: $themelight;
-    background: linear-gradient(rgba($active-nav-tabs-bgcolor, 0) 50%,
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 50%,
       rgba($active-nav-tabs-bgcolor, 0) 90%,
-      $active-nav-tabs-bgcolor 100%); // overwrite only the bottom pixel
+      $active-nav-tabs-bgcolor 100%
+    ); // overwrite only the bottom pixel
     background-color: $themecolor;
   }
 }
@@ -199,7 +204,6 @@ body:not(.on-edit) .nav.nav-tabs {
       ul {
         padding-left: 5px;
       }
-
     }
   }
 }
@@ -208,7 +212,7 @@ body:not(.on-edit) .nav.nav-tabs {
  *  Login page
  */
 
-.login-page>#wrapper>#page-wrapper {
+.login-page > #wrapper > #page-wrapper {
   background-image: url('/images/themes/antarctic/topimage.svg');
   background-attachment: fixed;
   background-position: center center;
@@ -222,3 +226,11 @@ body:not(.on-edit) .nav.nav-tabs {
     }
   }
 }
+
+/*
+ *  for Hightlight-js
+ */
+
+.hljs-ln {
+  background-color: transparent;
+}

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

@@ -294,6 +294,7 @@ Crowi.prototype.setupPassport = async function() {
     this.passportService.setupTwitterStrategy();
     this.passportService.setupOidcStrategy();
     this.passportService.setupSamlStrategy();
+    this.passportService.setupBasicStrategy();
   }
   catch (err) {
     logger.error(err);

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

@@ -4,6 +4,7 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[customize:isEnabledTimeline]').trim().toBooleanStrict(),
+  field('settingForm[customize:isEnabledDeleteCompletely]').trim().toBooleanStrict(),
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
   field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),

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

@@ -10,4 +10,5 @@ module.exports = form(
   field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
   field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
   field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
+  field('settingForm[security:pageCompleteDeletionAuthority]'),
 );

+ 9 - 0
src/server/form/admin/securityPassportBasic.js

@@ -0,0 +1,9 @@
+const form = require('express-form');
+
+const field = form.field;
+
+module.exports = form(
+  field('settingForm[security:passport-basic:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-basic:id]').trim(),
+  field('settingForm[security:passport-basic:password]').trim(),
+);

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

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

+ 2 - 0
src/server/models/config.js

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
 
       'security:list-policy:hideRestrictedByOwner' : false,
       'security:list-policy:hideRestrictedByGroup' : false,
+      'security:pageCompleteDeletionAuthority' : undefined,
 
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
@@ -67,6 +68,7 @@ module.exports = function(crowi) {
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
       'security:passport-oidc:isEnabled' : false,
+      'security:passport-basic:isEnabled' : false,
 
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',

+ 12 - 0
src/server/models/user.js

@@ -198,6 +198,18 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.methods.canDeleteCompletely = function(creatorId) {
+    const pageCompleteDeletionAuthority = crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (pageCompleteDeletionAuthority == null || this.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      return (this._id.equals(creatorId));
+    }
+
+    return false;
+  };
+
   userSchema.methods.updateApiToken = function(callback) {
     const self = this;
 

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

@@ -959,6 +959,34 @@ module.exports = function(crowi, app) {
     return res.json({ status: true });
   };
 
+  actions.api.securityPassportBasicSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetBasicStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportBasic(config)) {
+      try {
+        await crowi.passportService.setupBasicStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetBasicStrategy();
+        return res.json({ status: false, message: err.message });
+      }
+    }
+
+    return res.json({ status: true });
+  };
+
   actions.api.securityPassportGoogleSetting = async(req, res) => {
     const form = req.form.settingForm;
 

+ 2 - 0
src/server/routes/index.js

@@ -60,6 +60,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/general'       , loginRequired() , adminRequired , form.admin.securityGeneral, admin.api.securitySetting);
   app.post('/_api/admin/security/passport-ldap' , loginRequired() , adminRequired , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
   app.post('/_api/admin/security/passport-saml' , loginRequired() , adminRequired , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
+  app.post('/_api/admin/security/passport-basic' , loginRequired() , adminRequired , csrf, form.admin.securityPassportBasic, admin.api.securityPassportBasicSetting);
 
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired() , adminRequired , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
@@ -71,6 +72,7 @@ module.exports = function(crowi, app) {
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
   app.get('/passport/oidc'                        , loginPassport.loginWithOidc);
   app.get('/passport/saml'                        , loginPassport.loginWithSaml);
+  app.get('/passport/basic'                       , loginPassport.loginWithBasic);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback);

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

@@ -455,6 +455,51 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * middleware that login with BasicStrategy
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
+  const loginWithBasic = async(req, res, next) => {
+    if (!passportService.isBasicStrategySetup) {
+      debug('BasicStrategy has not been set up');
+      req.flash('warningMessage', 'Basic has not been set up');
+      return next();
+    }
+
+    const providerId = 'basic';
+    const strategyName = 'basic';
+    let userId;
+
+    try {
+      userId = await promisifiedPassportAuthentication(strategyName, req, res);
+    }
+    catch (err) {
+      // display prompt in browser
+      res.setHeader('WWW-Authenticate', 'Basic realm="Users"');
+      res.sendStatus(401).end();
+      return;
+    }
+
+    const userInfo = {
+      id: userId,
+      username: userId,
+      name: userId,
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+    await req.logIn(user, (err) => {
+      if (err) { return next() }
+      return loginSuccess(req, res, user);
+    });
+  };
+
   const promisifiedPassportAuthentication = (strategyName, req, res) => {
     return new Promise((resolve, reject) => {
       passport.authenticate(strategyName, (err, response, info) => {
@@ -530,6 +575,7 @@ module.exports = function(crowi, app) {
     loginWithTwitter,
     loginWithOidc,
     loginWithSaml,
+    loginWithBasic,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,

+ 3 - 0
src/server/routes/page.js

@@ -954,6 +954,9 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
+        if (!req.user.canDeleteCompletely(page.creator)) {
+          return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
+        }
         if (isRecursively) {
           page = await Page.completelyDeletePageRecursively(page, req.user, options);
         }

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

@@ -9,6 +9,7 @@ const TwitterStrategy = require('passport-twitter').Strategy;
 const OidcStrategy = require('openid-client').Strategy;
 const SamlStrategy = require('passport-saml').Strategy;
 const OIDCIssuer = require('openid-client').Issuer;
+const BasicStrategy = require('passport-http').BasicStrategy;
 
 /**
  * the service class of Passport
@@ -58,6 +59,11 @@ class PassportService {
      */
     this.isSamlStrategySetup = false;
 
+    /**
+     * the flag whether BasicStrategy is set up successfully
+     */
+    this.isBasicStrategySetup = false;
+
     /**
      * the flag whether serializer/deserializer are set up successfully
      */
@@ -583,6 +589,54 @@ class PassportService {
     return missingRequireds;
   }
 
+  /**
+   * reset BasicStrategy
+   *
+   * @memberof PassportService
+   */
+  resetBasicStrategy() {
+    debug('BasicStrategy: reset');
+    passport.unuse('basic');
+    this.isBasicStrategySetup = false;
+  }
+
+  /**
+   * setup BasicStrategy
+   *
+   * @memberof PassportService
+   */
+  setupBasicStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isBasicStrategySetup) {
+      throw new Error('BasicStrategy has already been set up');
+    }
+
+    const configManager = this.crowi.configManager;
+    const isBasicEnabled = configManager.getConfig('crowi', 'security:passport-basic:isEnabled');
+
+    // when disabled
+    if (!isBasicEnabled) {
+      return;
+    }
+
+    debug('BasicStrategy: setting up..');
+
+    const configId = configManager.getConfig('crowi', 'security:passport-basic:id');
+    const configPassword = configManager.getConfig('crowi', 'security:passport-basic:password');
+
+    passport.use(new BasicStrategy(
+      (userId, password, done) => {
+        if (userId !== configId || password !== configPassword) {
+          return done(null, false, { message: 'Incorrect credentials.' });
+        }
+        return done(null, userId);
+      },
+    ));
+
+    this.isBasicStrategySetup = true;
+    debug('BasicStrategy: setup is done');
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 27 - 3
src/server/views/admin/security.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitle(t('Security settings')) }} · {% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitle(t('security_settings')) }} · {% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('Security settings') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('security_settings') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -38,7 +38,7 @@
 
       <form action="/_api/admin/security/general" method="post" class="form-horizontal" id="generalSetting" role="form">
         <fieldset>
-        <legend class="alert-anchor">{{ t('security_setting.Security settings') }}</legend>
+        <legend class="alert-anchor">{{ t('security_settings') }}</legend>
 
           <div class="form-group">
             <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('Guest users access') }}</label>
@@ -114,6 +114,23 @@
             </div>
           </div>
 
+          <div class="form-group">
+            {% set configName = 'settingForm[security:pageCompleteDeletionAuthority]' %}
+            {% set configValue = getConfig('crowi','security:pageCompleteDeletionAuthority') %}
+            <label for="{{configName}}" class="col-xs-3 control-label">{{ t('security_setting.complete_deletion') }}</label>
+            <div class="col-xs-6">
+              <select class="form-control selectpicker" name="settingForm[security:pageCompleteDeletionAuthority]" value="{{ configValue }}">
+                <option value="adminOnly" {% if configValue =="adiminOnly" %}selected{% endif %}>{{ t('security_setting.admin_only') }}</option>
+                <option value="adminAndAuthor" {% if configValue == "adminAndAuthor" %}selected{% endif %}>{{ t('security_setting.admin_and_author') }}</option>
+                <option value=null {% if configValue == null  %}selected{% endif %}>{{ t('security_setting.anyone') }}</option>
+              </select>
+
+              <p class="help-block small">
+                {{ t('security_setting.complete_deletion_explain') }}
+              </p>
+            </div>
+          </div>
+
           <div class="form-group">
             <div class="col-xs-offset-3 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
@@ -162,6 +179,9 @@
             <li>
               <a href="#passport-oidc" data-toggle="tab" role="tab"><i class="fa fa-openid"></i> OIDC</a>
             </li>
+            <li>
+              <a href="#passport-basic" data-toggle="tab" role="tab"><i class="fa fa-sign-in"></i> Basic</a>
+            </li>
             <li class="tbd">
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
@@ -196,6 +216,10 @@
               {% include './widget/passport/github.html' %}
             </div>
 
+            <div id="passport-basic" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/basic.html' %}
+            </div>
+
           </div><!-- /.tab-content -->
         </div>
 

+ 1 - 1
src/server/views/admin/widget/menu.html

@@ -4,7 +4,7 @@
 <ul class="nav nav-pills nav-stacked">
   <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="icon-fw icon-home"></i> {{ t('Management Wiki Home') }}</a></li>
   <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="icon-fw icon-settings"></i> {{ t('App Settings') }}</a></li>
-  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="icon-fw icon-shield"></i> {{ t('Security Settings') }}</a></li>
+  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="icon-fw icon-shield"></i> {{ t('security_settings') }}</a></li>
   <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="icon-fw icon-note"></i> {{ t('Markdown Settings') }}</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-download"></i> {{ t('Import Data') }}</a></li>

+ 62 - 0
src/server/views/admin/widget/passport/basic.html

@@ -0,0 +1,62 @@
+<form action="/_api/admin/security/passport-basic" method="post" class="form-horizontal passportStrategy" id="basicSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.Basic authentication") }} {{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsbasicEnabled = "settingForm[security:passport-basic:isEnabled]" %}
+  {% set isbasicEnabled = settingForm['security:passport-basic:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsbasicEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.Basic authentication") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isbasicEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsbasicEnabled}}" value="true" type="radio"
+              {% if true === isbasicEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isbasicEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsbasicEnabled}}" value="false" type="radio"
+              {% if !isbasicEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-basic-hide-when-disabled" {%if !isbasicEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-basic:id]" class="col-xs-3 control-label">ID</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-basic:id]" value="{{ settingForm['security:passport-basic:id'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-basic:password]" class="col-xs-3 control-label">{{ t("Password") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-basic:password]" value="{{ settingForm['security:passport-basic:password'] || '' }}">
+      </div>
+    </div>
+
+  </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
+</form>
+
+<script>
+  $('input[name="settingForm[security:passport-basic:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-basic-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-basic-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 12 - 1
src/server/views/login.html

@@ -136,7 +136,8 @@
           getConfig('crowi', 'security:passport-facebook:isEnabled') ||
           getConfig('crowi', 'security:passport-twitter:isEnabled')||
           getConfig('crowi', 'security:passport-oidc:isEnabled') ||
-          getConfig('crowi', 'security:passport-saml:isEnabled')
+          getConfig('crowi', 'security:passport-saml:isEnabled') ||
+          getConfig('crowi', 'security:passport-basic:isEnabled')
         ) %}
         <hr class="mb-1">
         <div class="collapse collapse-oauth collapse-anchor">
@@ -201,6 +202,16 @@
               <div class="small text-right">with SAML</div>
             </form>
             {% endif %}
+            {% if getConfig('crowi', 'security:passport-basic:isEnabled') %}
+            <form role="form" action="/passport/basic" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="basic">
+                <span class="btn-label"><i class="fa fa-key"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">with HTTP Basic</div>
+            </form>
+            {% endif %}
           </div>{# ./d-flex flex-row flex-wrap #}
           <div class="spacer"></div>
         </div>

+ 13 - 10
src/server/views/modal/delete.html

@@ -8,15 +8,15 @@
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
           <div class="modal-title">
             {% if page.isDeleted() %}
-            <i class="icon-fw icon-fire"></i> {{ t('modal_delete.label.Delete completely') }}
+            <i class="icon-fw icon-fire"></i> {{ t('modal_delete.delete_completely') }}
             {% else %}
-            <i class="icon-fw icon-trash"></i> {{ t('modal_delete.label.Delete Page') }}
+            <i class="icon-fw icon-trash"></i> {{ t('modal_delete.delete_page') }}
             {% endif %}
           </div>
         </div>
         <div class="modal-body">
           <div class="form-group">
-            <label for="">Deleting page:</label><br>
+            <label for="">{{ t('modal_delete.deleting_page') }}:</label><br>
             <code>{{ page.path }}</code>
           </div>
 
@@ -25,17 +25,20 @@
           {% if page.grant != 2 %}
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
-            <label for="cbDeleteRecursively">{{ t('modal_delete.label.Delete recursively') }}</label>
-            <p class="help-block"> {{ t('modal_delete.help.recursively', page.path) }}
+            <label for="cbDeleteRecursively">{{ t('modal_delete.delete_recursively') }}</label>
+            <p class="help-block"> {{ t('modal_delete.recursively', page.path) }}
             </p>
           </div>
           {% endif %}
           {% if not page.isDeleted() %}
           <div class="checkbox checkbox-danger">
-            <input name="completely" id="cbDeleteCompletely" value="1"  type="checkbox">
-              <label for="cbDeleteCompletely" class="text-danger">{{ t('modal_delete.label.Delete completely') }}</label>
-              <p class="help-block"> {{ t('modal_delete.help.completely') }}
-              </p>
+          <input name="completely" id="cbDeleteCompletely" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} value="1"  type="checkbox">
+            <label for="cbDeleteCompletely" class="text-danger">{{ t('modal_delete.delete_completely') }}</label>
+            {% if !user.canDeleteCompletely(page.creator._id) %}
+              <p class="bg-danger text-white p-2 mt-2"> <i class="icon-ban" ></i>{{ t('modal_delete.delete_completely_restriction') }}</p>
+            {% else %}
+            <p class="help-block"> {{ t('modal_delete.completely') }}</p>
+            {% endif %}
           </div>
           {% endif %}
         </div>
@@ -51,7 +54,7 @@
                 <input type="hidden" name="completely" value="true">
                 <button type="submit" class="m-l-10 btn btn-sm btn-danger delete-button">
                   <i class="icon-fire" aria-hidden="true"></i>
-                  {{ t('Delete Completely') }}
+                  {{ t('delete_completely') }}
                 </button>
               {% else %}
                 <button type="submit" class="m-l-10 btn btn-sm btn-default delete-button">

+ 0 - 1
src/server/views/widget/alert_breaking_changes.html

@@ -3,6 +3,5 @@
 {% if getConfig('crowi', 'security:basicName') || getConfig('crowi', 'security:basicSecret') %}
 <div class="myadmin-alert alert alert-warning mb-0">
   <i class="icon-exclamation"></i>
-  {{ t("breaking_changes.v346_using_basic_auth", '<a href="/admin/security">' + t('Security settings') + '<i class="icon-login"></i></a>') }}
 </div>
 {% endif %}

+ 3 - 0
src/server/views/widget/modal/page-api-error-messages.html

@@ -2,6 +2,9 @@
   <span class="text-danger msg msg-notfound_or_forbidden">
     <strong><i class="icon-fw icon-ban"></i>{{ t('page_api_error.notfound_or_forbidden') }}</strong>
   </span>
+  <span class="text-danger msg msg-user_not_admin">
+    <strong><i class="icon-fw icon-ban"></i>{{ t('page_api_error.user_not_admin') }}</strong>
+  </span>
   <span class="text-danger msg msg-already_exists">
     <strong><i class="icon-fw icon-ban"></i>{{ t('page_api_error.already_exists') }}</strong>
     <small id="linkToNewPage"></small>

+ 2 - 2
src/server/views/widget/page_alerts.html

@@ -25,10 +25,10 @@
       {% if page.isDeleted() and user %}
       <ul class="list-inline">
         <li>
-          <a href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</a>
+          <button href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
         </li>
         <li>
-          <a href="#" class="btn btn-danger btn-rounded btn-sm" data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</a>
+            <button href="#" class="btn btn-danger btn-rounded btn-sm" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</button>
         </li>
       </ul>{# /.pull-right #}
       {% endif %}

+ 7 - 0
yarn.lock

@@ -8713,6 +8713,13 @@ passport-google-auth@^1.0.2:
     googleapis "^16.0.0"
     passport-strategy "1.x"
 
+passport-http@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/passport-http/-/passport-http-0.3.0.tgz#8ee53d4380be9c60df2151925029826f77115603"
+  integrity sha1-juU9Q4C+nGDfIVGSUCmCb3cRVgM=
+  dependencies:
+    passport-strategy "1.x.x"
+
 passport-ldapauth@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/passport-ldapauth/-/passport-ldapauth-2.0.0.tgz#42dff004417185d0a4d9f776a3eed8d4731fd689"