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

Merge branch 'master' into feat/drawio-integration

Koki Oyatsu 6 лет назад
Родитель
Сommit
cf71c510c8
44 измененных файлов с 657 добавлено и 192 удалено
  1. 9 1
      CHANGES.md
  2. 1 0
      config/logger/config.dev.js
  3. 9 3
      resource/locales/en-US/translation.json
  4. 9 3
      resource/locales/ja/translation.json
  5. 2 2
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  6. 2 2
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  7. 1 1
      src/client/js/components/BookmarkButton.jsx
  8. 1 1
      src/client/js/components/LikeButton.jsx
  9. 1 2
      src/client/js/components/Navbar/PersonalDropdown.jsx
  10. 22 11
      src/client/js/components/Page/RevisionPath.jsx
  11. 1 1
      src/client/js/components/PageAttachment.jsx
  12. 1 1
      src/client/js/components/PageComment/Comment.jsx
  13. 1 3
      src/client/js/components/PageComment/CommentEditor.jsx
  14. 2 3
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  15. 1 1
      src/client/js/components/PageComments.jsx
  16. 1 1
      src/client/js/components/RecentCreated/RecentCreated.jsx
  17. 7 0
      src/client/js/legacy/crowi.js
  18. 20 10
      src/client/js/services/AppContainer.js
  19. 1 1
      src/client/styles/scss/style-app.scss
  20. 5 3
      src/linter-checker/test.js
  21. 5 3
      src/linter-checker/test.scss
  22. 4 0
      src/server/crowi/express-init.js
  23. 1 0
      src/server/form/admin/securityPassportSaml.js
  24. 3 1
      src/server/middleware/access-token-parser.js
  25. 1 1
      src/server/middleware/login-required.js
  26. 65 0
      src/server/middleware/safe-redirect.js
  27. 23 2
      src/server/models/page.js
  28. 10 2
      src/server/routes/admin.js
  29. 2 2
      src/server/routes/index.js
  30. 20 31
      src/server/routes/login-passport.js
  31. 27 46
      src/server/routes/login.js
  32. 4 1
      src/server/routes/logout.js
  33. 7 1
      src/server/service/config-loader.js
  34. 2 1
      src/server/service/config-manager.js
  35. 114 0
      src/server/service/passport.js
  36. 50 0
      src/server/views/admin/widget/passport/saml.html
  37. 1 1
      src/server/views/installer.html
  38. 7 2
      src/server/views/layout/layout.html
  39. 4 1
      src/server/views/widget/not_found_tabs.html
  40. 28 4
      src/server/views/widget/page_tabs.html
  41. 28 4
      src/server/views/widget/page_tabs_kibela.html
  42. 4 4
      src/test/middleware/login-required.test.js
  43. 108 0
      src/test/middleware/safe-redirect.test.js
  44. 42 35
      yarn.lock

+ 9 - 1
CHANGES.md

@@ -1,15 +1,23 @@
 # CHANGES
 
-## v3.6.9-RC
+## v3.6.10
 
 *
 
+## v3.6.9
+
+* Improvement: Redirection when login/logout
+* Improvement: Add home icon before '/'
+* Fix: Client crashed when the first login
+    * Introduced by 3.6.8
+
 ## v3.6.8
 
 * Improvement: Show page history side-by-side
 * Improvement: Optimize markdown rendering
 * Improvement: Reactify admin pages (Navigation)
 * Fix: Reply comments collapsed are broken
+    * Introduced by 3.6.7
 * Support: Update libs
     * cross-env
     * mkdirp

+ 1 - 0
config/logger/config.dev.js

@@ -14,6 +14,7 @@ module.exports = {
   'growi:models:external-account': 'debug',
   // 'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
+  'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   'growi:lib:search': 'debug',

+ 9 - 3
resource/locales/en-US/translation.json

@@ -46,6 +46,7 @@
   "Timeline View": "Timeline",
   "History": "History",
   "Presentation Mode": "Presentation",
+  "Not available for guest": "Not available for guest",
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
@@ -123,7 +124,8 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "form_validation": {
-    "required": "<code>%s</code> is required"
+    "required": "<code>%s</code> is required",
+    "invalid_syntax": "The syntax of <code>%s</code> is invalid."
   },
   "installer": {
     "setup": "Setup",
@@ -455,7 +457,10 @@
       "mapping_detail": "Specification of mappings for %s when creating new users",
       "cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
-      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
+      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> .",
+      "attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
+      "attr_based_login_control_rule_detail": "A rule is written in the form of concatenating <code>attribute name = value</code> with <code>|</code> and <code>&</code>. The or operator (|) has a lower precedence than the and operator (&). If operator precedence is equal, left to right associativity is used.",
+      "attr_based_login_control_rule_example": "For example, if a rule is <code>Department = A | Department = B & Position = Leader</code>, users with <code>Department</code> as <code>A</code> or users with <code>Department</code> as <code>B</code> and <code>Position</code> as <code>Leader</code> <strong>can</strong> sign in."
     },
     "Basic": {
       "name": "Basic Authentication",
@@ -515,7 +520,8 @@
       "security:passport-saml:attrMapUsername": "Username",
       "security:passport-saml:attrMapMail": "Mail Address",
       "security:passport-saml:attrMapFirstName": "First Name",
-      "security:passport-saml:attrMapLastName": "Last Name"
+      "security:passport-saml:attrMapLastName": "Last Name",
+      "security:passport-saml:ABLCRule": "Rule"
     }
   },
   "notification_setting": {

+ 9 - 3
resource/locales/ja/translation.json

@@ -46,6 +46,7 @@
   "Timeline View": "タイムライン表示",
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
+  "Not available for guest": "ゲストユーザーは利用できません",
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
@@ -122,7 +123,8 @@
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "form_validation": {
-    "required": "<code>%s</code> に値を入力してください"
+    "required": "<code>%s</code> に値を入力してください",
+    "invalid_syntax": "<code>%s</code> の構文が不正です"
   },
   "installer": {
     "setup": "セットアップ",
@@ -449,7 +451,10 @@
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
-      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください",
+      "attr_based_login_control_detail": "SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。",
+      "attr_based_login_control_rule_detail": "<code>属性名 = 値</code> を <code>|</code>(論理和)、 <code>&</code>(論理積) で連結した形式で記述してください。演算子の優先順位は論理積が論理和より高く、各演算子の結合規則は左から右です。",
+      "attr_based_login_control_rule_example": "例えば <code>Department=A | Department=B & Position=Leader</code> だと <code>Department</code> が <code>A</code> の場合, もしくは<code>Department</code> が <code>B</code> かつ <code>Position</code> が <code>Leader</code> の場合にログインを<strong>許可</strong>します。"
     },
     "Basic": {
       "name": "Basic 認証",
@@ -498,7 +503,8 @@
       "security:passport-saml:attrMapUsername": "ユーザー名",
       "security:passport-saml:attrMapMail": "メールアドレス",
       "security:passport-saml:attrMapFirstName": "姓",
-      "security:passport-saml:attrMapLastName": "名"
+      "security:passport-saml:attrMapLastName": "名",
+      "security:passport-saml:ABLCRule": "ルール"
     }
   },
   "notification_setting": {

+ 2 - 2
src/client/js/components/Admin/Users/RemoveAdminButton.jsx

@@ -51,11 +51,11 @@ class RemoveAdminButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderRemoveAdminBtn()
+        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
           : this.renderRemoveAdminAlert()}
       </Fragment>
     );

+ 2 - 2
src/client/js/components/Admin/Users/StatusSuspendedButton.jsx

@@ -50,11 +50,11 @@ class StatusSuspendedButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderSuspendedBtn()
+        {user.username !== currentUsername ? this.renderSuspendedBtn()
           : this.renderSuspendedAlert()}
       </Fragment>
     );

+ 1 - 1
src/client/js/components/BookmarkButton.jsx

@@ -56,7 +56,7 @@ export default class BookmarkButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me != null;
+    return this.props.crowi.currentUserId != null;
   }
 
   render() {

+ 1 - 1
src/client/js/components/LikeButton.jsx

@@ -37,7 +37,7 @@ class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUserId != null;
   }
 
   render() {

+ 1 - 2
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -11,8 +11,7 @@ import UserPicture from '../User/UserPicture';
 const PersonalDropdown = (props) => {
 
   const { t, appContainer } = props;
-  const username = appContainer.me;
-  const user = appContainer.findUser(username);
+  const user = appContainer.currentUser || {};
 
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;

+ 22 - 11
src/client/js/components/Page/RevisionPath.jsx

@@ -100,9 +100,6 @@ class RevisionPath extends React.Component {
 
   render() {
     // define styles
-    const rootStyle = {
-      marginRight: '0.2em',
-    };
     const separatorStyle = {
       marginLeft: '0.2em',
       marginRight: '0.2em',
@@ -115,6 +112,26 @@ class RevisionPath extends React.Component {
     const { isInTrash } = this.state;
     const pageLength = this.state.pages.length;
 
+    const rootElement = isInTrash
+      ? (
+        <>
+          <span className="path-segment">
+            <a href="/trash"><i className="icon-trash"></i></a>
+          </span>
+          <span className="separator" style={separatorStyle}><a href="/">/</a></span>
+        </>
+      )
+      : (
+        <>
+          <span className="path-segment">
+            <a href="/">
+              <i className="icon-home"></i>
+              <span className="separator" style={separatorStyle}>/</span>
+            </a>
+          </span>
+        </>
+      );
+
     const afterElements = [];
     this.state.pages.forEach((page, index) => {
       const isLastElement = (index === pageLength - 1);
@@ -136,14 +153,8 @@ class RevisionPath extends React.Component {
 
     return (
       <span className="d-flex align-items-center">
-        { isInTrash && (
-          <span className="path-segment">
-            <a href="/trash"><i className="icon-trash"></i></a>
-          </span>
-        ) }
-        <span className="separator" style={isInTrash ? separatorStyle : rootStyle}>
-          <a href="/">/</a>
-        </span>
+
+        {rootElement}
         {afterElements}
 
         <CopyDropdown t={this.props.t} pagePath={this.props.pagePath} pageId={this.props.pageId} buttonStyle={buttonStyle}></CopyDropdown>

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -89,7 +89,7 @@ class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUser != null;
   }
 
   render() {

+ 1 - 1
src/client/js/components/PageComment/Comment.jsx

@@ -80,7 +80,7 @@ class Comment extends React.PureComponent {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.appContainer.me;
+    return this.props.comment.creator.username === this.props.appContainer.currentUsername;
   }
 
   isCurrentRevision() {

+ 1 - 3
src/client/js/components/PageComment/CommentEditor.jsx

@@ -212,8 +212,6 @@ class CommentEditor extends React.Component {
 
   render() {
     const { appContainer, commentContainer } = this.props;
-    const username = appContainer.me;
-    const user = appContainer.findUser(username);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
@@ -236,7 +234,7 @@ class CommentEditor extends React.Component {
         <div className="comment-form">
           { isBaloonStyle && (
             <div className="comment-form-user">
-              <UserPicture user={user} />
+              <UserPicture user={appContainer.currentUser} />
             </div>
           ) }
           <div className="comment-form-main">

+ 2 - 3
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -27,9 +27,8 @@ class CommentEditorLazyRenderer extends React.Component {
 
   render() {
     const { appContainer } = this.props;
-    const username = appContainer.me;
-    const isLoggedIn = username != null;
-    const user = appContainer.findUser(username);
+    const user = appContainer.currentUser;
+    const isLoggedIn = user != null;
 
     const layoutType = this.props.appContainer.getConfig().layoutType;
     const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);

+ 1 - 1
src/client/js/components/PageComments.jsx

@@ -128,7 +128,7 @@ class PageComments extends React.Component {
   renderThread(comment, replies) {
     const commentId = comment._id;
     const showEditor = this.state.showEditorIds.has(commentId);
-    const isLoggedIn = this.props.appContainer.me != null;
+    const isLoggedIn = this.props.appContainer.currentUser != null;
 
     let rootClassNames = 'page-comment-thread';
     if (replies.length === 0) {

+ 1 - 1
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -37,7 +37,7 @@ class RecentCreated extends React.Component {
     const { appContainer, pageContainer } = this.props;
     const { pageId } = pageContainer.state;
 
-    const userId = appContainer.me;
+    const userId = appContainer.currentUserId;
     const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 

+ 7 - 0
src/client/js/legacy/crowi.js

@@ -596,6 +596,11 @@ $(() => {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
 
+  // do nothing if user is guest
+  if (appContainer.currentUser == null) {
+    return;
+  }
+
   // hash on page
   if (window.location.hash) {
     if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
@@ -619,7 +624,9 @@ window.addEventListener('load', (e) => {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
+});
 
+window.addEventListener('load', (e) => {
   const crowi = window.crowi;
   if (crowi && crowi.users && crowi.users.length !== 0) {
     const totalUsers = crowi.users.length;

+ 20 - 10
src/client/js/services/AppContainer.js

@@ -35,13 +35,17 @@ export default class AppContainer extends Container {
 
     const body = document.querySelector('body');
 
-    this.me = body.dataset.currentUsername || null; // will be initialized with null when data is empty string
     this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
     this.isLoggedin = document.querySelector('.main-container.nologin') == null;
 
-    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+    this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
+
+    const currentUserElem = document.getElementById('growi-current-user');
+    if (currentUserElem != null) {
+      this.currentUser = JSON.parse(currentUserElem.textContent);
+    }
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -112,6 +116,20 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
   }
 
+  get currentUserId() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser._id;
+  }
+
+  get currentUsername() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser.username;
+  }
+
   /**
    * @return {Object} window.Crowi (js/legacy/crowi.js)
    */
@@ -276,14 +294,6 @@ export default class AppContainer extends Container {
     return users;
   }
 
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
-    }
-
-    return null;
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 1 - 1
src/client/styles/scss/style-app.scss

@@ -48,7 +48,7 @@
 /*
  * for Guest User Mode
  */
-.dropdown-disabled {
+.dropdown-toggle.dropdown-toggle-disabled {
   cursor: not-allowed;
 }
 

+ 5 - 3
src/linter-checker/test.js

@@ -1,13 +1,15 @@
 /*
  * VSCode の Eslint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .eslilntignore ファイル中の `/src/linter-checker/**` 行を消す
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - constructor で eslint(space-before-blocks)
  *   - ファイル末尾の ";" で eslint(eol-last)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - "constructor() {" のように間にスペースが入る
  *   - ファイル末尾に空行が入る
  *

+ 5 - 3
src/linter-checker/test.scss

@@ -1,13 +1,15 @@
 /*
  * VSCode の Stylelint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)
  *   - ul で stylelint(selector-combinator-space-after)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - color が background の上の行にくる
  *   - ul と li の間にスペースが入る
  *

+ 4 - 0
src/server/crowi/express-init.js

@@ -19,6 +19,8 @@ module.exports = function(crowi, app) {
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
 
+  const safeRedirect = require('../middleware/safe-redirect')();
+
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
 
@@ -113,6 +115,8 @@ module.exports = function(crowi, app) {
 
   app.use(flash());
 
+  app.use(safeRedirect);
+
   const middlewares = require('../util/middlewares')(crowi, app);
 
   app.use(middlewares.swigFilters(swig));

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

@@ -14,4 +14,5 @@ module.exports = form(
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:ABLCRule]').trim(),
 );

+ 3 - 1
src/server/middleware/access-token-parser.js

@@ -16,7 +16,9 @@ module.exports = (crowi) => {
     logger.debug('accessToken is', accessToken);
 
     const user = await User.findUserByApiToken(accessToken);
-    req.user = user;
+    // transforming attributes
+    // see User model
+    req.user = user.toObject();
     req.skipCsrfVerify = true;
 
     logger.debug('Access token parsed: skipCsrfVerify');

+ 1 - 1
src/server/middleware/login-required.js

@@ -42,7 +42,7 @@ module.exports = (crowi, isGuestAllowed = false) => {
       return res.sendStatus(403);
     }
 
-    req.session.jumpTo = req.originalUrl;
+    req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');
   };
 

+ 65 - 0
src/server/middleware/safe-redirect.js

@@ -0,0 +1,65 @@
+/**
+ * Redirect with prevention from Open Redirect
+ *
+ * Usage: app.use(require('middleware/safe-redirect')(['example.com', 'some.example.com:8080']))
+ */
+
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:safe-redirect');
+
+/**
+ * Check whether the redirect url host is in specified whitelist
+ * @param {Array<string>} whitelistOfHosts
+ * @param {string} redirectToFqdn
+ */
+function isInWhitelist(whitelistOfHosts, redirectToFqdn) {
+  if (whitelistOfHosts == null || whitelistOfHosts.length === 0) {
+    return false;
+  }
+
+  const redirectUrl = new URL(redirectToFqdn);
+  return whitelistOfHosts.includes(redirectUrl.hostname) || whitelistOfHosts.includes(redirectUrl.host);
+}
+
+
+module.exports = (whitelistOfHosts) => {
+
+  return function(req, res, next) {
+
+    // extend res object
+    res.safeRedirect = function(redirectTo) {
+      if (redirectTo == null) {
+        return res.redirect('/');
+      }
+
+      try {
+        // check inner redirect
+        const redirectUrl = new URL(redirectTo, `${req.protocol}://${req.get('host')}`);
+        if (redirectUrl.hostname === req.hostname) {
+          logger.debug(`Requested redirect URL (${redirectTo}) is local.`);
+          return res.redirect(redirectUrl.href);
+        }
+        logger.debug(`Requested redirect URL (${redirectTo}) is NOT local.`);
+
+        // check whitelisted redirect
+        const isWhitelisted = isInWhitelist(whitelistOfHosts, redirectTo);
+        if (isWhitelisted) {
+          logger.debug(`Requested redirect URL (${redirectTo}) is in whitelist.`, `whitelist=${whitelistOfHosts}`);
+          return res.redirect(redirectTo);
+        }
+        logger.debug(`Requested redirect URL (${redirectTo}) is NOT in whitelist.`, `whitelist=${whitelistOfHosts}`);
+      }
+      catch (err) {
+        logger.warn(`Requested redirect URL (${redirectTo}) is invalid.`, err);
+      }
+
+      logger.warn(`Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`);
+      return res.redirect('/');
+    };
+
+    next();
+
+  };
+
+};

+ 23 - 2
src/server/models/page.js

@@ -214,7 +214,7 @@ class PageQueryBuilder {
     return this;
   }
 
-  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup) {
+  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },
       { grant: GRANT_PUBLIC },
@@ -831,6 +831,27 @@ module.exports = function(crowi) {
     return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
 
+  /**
+   * Add condition that filter pages by viewer
+   *  by considering Config
+   *
+   * @param {PageQueryBuilder} builder
+   * @param {User} user
+   * @param {boolean} showAnyoneKnowsLink
+   */
+  async function addConditionToFilteringByViewerToEdit(builder, user) {
+    validateCrowi();
+
+    // determine UserGroup condition
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
+  }
+
   /**
    * export addConditionToFilteringByViewerForList as static method
    */
@@ -1038,7 +1059,7 @@ module.exports = function(crowi) {
     builder.addConditionToExcludeRedirect();
 
     // add grant conditions
-    await addConditionToFilteringByViewerForList(builder, user);
+    await addConditionToFilteringByViewerToEdit(builder, user);
 
     // get all pages that the specified user can update
     const pages = await builder.query.exec();

+ 10 - 2
src/server/routes/admin.js

@@ -781,8 +781,9 @@ module.exports = function(crowi, app) {
   /**
    * validate setting form values for SAML
    *
-   * This validation checks, for the value of each mandatory items,
-   * whether it from the environment variables is empty and form value to update it is empty.
+   * - For the value of each mandatory items,
+   *     check whether it from the environment variables is empty and form value to update it is empty
+   * - validate the syntax of a attribute-based login control rule
    */
   function validateSamlSettingForm(form, t) {
     for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
@@ -792,6 +793,13 @@ module.exports = function(crowi, app) {
         form.errors.push(t('form_validation.required', formItemName));
       }
     }
+
+    const rule = form.settingForm['security:passport-saml:ABLCRule'];
+    // Empty string disables attribute-based login control.
+    // So, when rule is empty string, validation is passed.
+    if (rule !== '' && (rule == null || crowi.passportService.parseABLCRule(rule) == null)) {
+      form.errors.push(t('form_validation.invalid_syntax', t('security_setting.form_item_name.security:passport-saml:ABLCRule')));
+    }
   }
 
   return actions;

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

@@ -47,14 +47,14 @@ module.exports = function(crowi, app) {
   }
 
   app.get('/login/error/:reason'     , login.error);
-  app.get('/login'                   , middlewares.applicationInstalled    , login.login);
+  app.get('/login'                   , middlewares.applicationInstalled     , login.preLogin, login.login);
   app.get('/login/invited'           , login.invited);
   app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
   app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
 
   app.post('/register'               , form.register                        , csrf, login.register);
-  app.get('/register'                , middlewares.applicationInstalled    , login.register);
+  app.get('/register'                , middlewares.applicationInstalled     , login.preLogin, login.register);
   app.get('/logout'                  , logout.logout);
 
   app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);

+ 20 - 31
src/server/routes/login-passport.js

@@ -4,7 +4,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login-passport');
   const logger = require('@alias/logger')('growi:routes:login-passport');
   const passport = require('passport');
-  const { URL } = require('url');
   const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
 
@@ -22,25 +21,10 @@ module.exports = function(crowi, app) {
       }
     });
 
-    const jumpTo = req.session.jumpTo;
-    if (jumpTo) {
-      req.session.jumpTo = null;
-
-      // prevention from open redirect
-      try {
-        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
-        if (redirectUrl.hostname === req.hostname) {
-          return res.redirect(redirectUrl);
-        }
-        logger.warn('Requested redirect URL is invalid, redirect to root page');
-      }
-      catch (err) {
-        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
-        return res.redirect('/');
-      }
-    }
-
-    return res.redirect('/');
+    const { redirectTo } = req.session;
+    // remove session.redirectTo
+    delete req.session.redirectTo;
+    return res.safeRedirect(redirectTo);
   };
 
   /**
@@ -48,8 +32,8 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  const loginFailure = (req, res, next) => {
-    req.flash('errorMessage', 'Sign in failure.');
+  const loginFailure = (req, res, message) => {
+    req.flash('errorMessage', message || 'Sign in failure.');
     return res.redirect('/login');
   };
 
@@ -250,7 +234,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -270,7 +254,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -301,7 +285,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -312,7 +296,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -343,7 +327,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -354,7 +338,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -390,7 +374,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       debug(err);
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -403,7 +387,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     // login
@@ -461,6 +445,11 @@ module.exports = function(crowi, app) {
 
     const user = await externalAccount.getPopulatedUser();
 
+    // Attribute-based Login Control
+    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
+      return loginFailure(req, res, 'Sign in failure due to insufficient privileges.');
+    }
+
     // login
     req.logIn(user, (err) => {
       if (err != null) {
@@ -503,7 +492,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();

+ 27 - 46
src/server/routes/login.js

@@ -15,7 +15,9 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   const loginSuccess = function(req, res, userData) {
-    req.user = req.session.user = userData;
+    // transforming attributes
+    // see User model
+    req.user = req.session.user = userData.toObject();
 
     // update lastLoginAt
     userData.updateLastLoginAt(new Date(), (err, uData) => {
@@ -28,30 +30,10 @@ module.exports = function(crowi, app) {
       return res.redirect('/me/password');
     }
 
-    const jumpTo = req.session.jumpTo;
-    if (jumpTo) {
-      req.session.jumpTo = null;
-
-      // prevention from open redirect
-      try {
-        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
-        if (redirectUrl.hostname === req.hostname) {
-          return res.redirect(redirectUrl);
-        }
-        logger.warn('Requested redirect URL is invalid, redirect to root page');
-      }
-      catch (err) {
-        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
-        return res.redirect('/');
-      }
-    }
-
-    return res.redirect('/');
-  };
-
-  const loginFailure = function(req, res) {
-    req.flash('warningMessage', 'Sign in failure.');
-    return res.redirect('/login');
+    const { redirectTo } = req.session;
+    // remove session.redirectTo
+    delete req.session.redirectTo;
+    return res.safeRedirect(redirectTo);
   };
 
   actions.error = function(req, res) {
@@ -72,30 +54,29 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.login = function(req, res) {
-    const loginForm = req.body.loginForm;
+  actions.preLogin = function(req, res, next) {
+    // user has already logged in
+    if (req.user != null) {
+      const { redirectTo } = req.session;
+      // remove session.redirectTo
+      delete req.session.redirectTo;
+      return res.safeRedirect(redirectTo);
+    }
 
-    if (req.method == 'POST' && req.form.isValid) {
-      const username = loginForm.username;
-      const password = loginForm.password;
-
-      // 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);
-      });
+    // set referer to 'redirectTo'
+    if (req.session.redirectTo == null && req.headers.referer != null) {
+      req.session.redirectTo = req.headers.referer;
     }
-    else { // method GET
-      if (req.form) {
-        debug(req.form.errors);
-      }
-      return res.render('login', {
-      });
+
+    next();
+  }
+
+  actions.login = function(req, res) {
+    if (req.form) {
+      debug(req.form.errors);
     }
+
+    return res.render('login', {});
   };
 
   actions.register = function(req, res) {

+ 4 - 1
src/server/routes/logout.js

@@ -2,7 +2,10 @@ module.exports = function(crowi, app) {
   return {
     logout(req, res) {
       req.session.destroy();
-      return res.redirect('/');
+
+      // redirect
+      const redirectTo = req.headers.referer || '/';
+      return res.safeRedirect(redirectTo);
     },
   };
 };

+ 7 - 1
src/server/service/config-loader.js

@@ -247,6 +247,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  SAML_ABLC_RULE: {
+    ns:      'crowi',
+    key:     'security:passport-saml:ABLCRule',
+    type:    TYPES.STRING,
+    default: null,
+  },
   GCS_API_KEY_JSON_PATH: {
     ns:      'crowi',
     key:     'gcs:apiKeyJsonPath',
@@ -288,7 +294,7 @@ class ConfigLoader {
     };
 
     // In getConfig API, only null is used as a value to indicate that a config is not set.
-    // So, if a value loaded from the database is emtpy,
+    // So, if a value loaded from the database is empty,
     // it is converted to null because an empty string is used as the same meaning in the config model.
     // By this processing, whether a value is loaded from the database or from the environment variable,
     // only null indicates a config is not set.

+ 2 - 1
src/server/service/config-manager.js

@@ -15,6 +15,7 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:attrMapFirstName',
   'security:passport-saml:attrMapLastName',
   'security:passport-saml:cert',
+  'security:passport-saml:ABLCRule',
 ];
 
 class ConfigManager {
@@ -66,7 +67,7 @@ class ConfigManager {
   }
 
   /**
-   * get a config specified by namespace and regular expresssion
+   * get a config specified by namespace and regular expression
    */
   getConfigByRegExp(namespace, regexp) {
     const result = {};

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

@@ -598,6 +598,120 @@ class PassportService {
     return missingRequireds;
   }
 
+  /**
+   * Verify that a SAML response meets the attribute-base login control rule
+   */
+  verifySAMLResponseByABLCRule(response) {
+    const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
+    if (rule == null) {
+      return true;
+    }
+
+    const expr = this.parseABLCRule(rule);
+    if (expr == null) {
+      return false;
+    }
+    debug({ 'Parsed Rule': JSON.stringify(expr, null, 2) });
+
+    const attributes = this.extractAttributesFromSAMLResponse(response);
+    debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+
+    let evaluatedExpr = false;
+    for (const orOp of expr) {
+      let evaluatedOrOp = true;
+      for (const andOp of orOp) {
+        if (attributes[andOp[0]] == null) {
+          evaluatedOrOp = false;
+          break;
+        }
+        evaluatedOrOp = evaluatedOrOp && attributes[andOp[0]].includes(andOp[1]);
+      }
+      evaluatedExpr = evaluatedExpr || evaluatedOrOp;
+    }
+
+    return evaluatedExpr;
+  }
+
+  /**
+   * Parse a rule string for the attribute-based login control
+   *
+   * The syntax rules are as follows.
+   * <attr> and <value> are any characters except "|", "&", "=".
+   *
+   * ## Syntax
+   *    <expr>   ::= <or_op> | <or_op> "|" <expr>
+   *    <or_op>  ::= <and_op> | <and_op> "&" <or_op>
+   *    <and_op> ::= <attr> "=" <value>
+   *
+   * ## Example
+   *  In:  "Department = A | Department = B & Position = Leader"
+   *  Out:
+   *    [
+   *      [
+   *        ["Department", "A"]
+   *      ],
+   *      [
+   *        ["Department","B"],
+   *        ["Position","Leader"]
+   *      ]
+   *    ]
+   *
+   *   In:  Invalid syntax string like a "This is a & bad & rule string."
+   *   Out: null
+   */
+  parseABLCRule(rule) {
+    let expr = rule.split('|');
+    expr = expr.map(orOp => orOp.trim().split('&'));
+    expr = expr.map(orOp => orOp.map(andOp => andOp.trim().split('=')));
+    expr = expr.map(orOp => orOp.map(andOp => andOp.map(v => v.trim())));
+    for (const orOp of expr) {
+      for (const andOp of orOp) {
+        if (andOp.length !== 2) {
+          return null;
+        }
+      }
+    }
+    return expr;
+  }
+
+
+  /**
+   * Extract attributes from a SAML response
+   *
+   * The format of extracted attributes is the following.
+   *
+   * {
+   *    "attribute_name1": ["value1", "value2", ...],
+   *    "attribute_name2": ["value1", "value2", ...],
+   *    ...
+   * }
+   */
+  extractAttributesFromSAMLResponse(response) {
+    const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
+    if (attributeStatement == null || attributeStatement[0] == null) {
+      return {};
+    }
+
+    const attributes = attributeStatement[0].Attribute;
+    if (attributes == null) {
+      return {};
+    }
+
+    const result = {};
+    for (const attribute of attributes) {
+      const name = attribute.$.Name;
+      const attributeValues = attribute.AttributeValue.map(v => v._);
+      if (result[name] == null) {
+        result[name] = attributeValues;
+      }
+      else {
+        result[name] = result[name].concat(attributeValues);
+      }
+    }
+
+    return result;
+  }
+
   /**
    * reset BasicStrategy
    *

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

@@ -349,6 +349,56 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       </div>
     </div>
 
+    <h4>Attribute-based Login Control</h4>
+
+    <p class="help-block">
+      <small>
+        {{ t("security_setting.SAML.attr_based_login_control_detail") }}
+      </small>
+    </p>
+
+    <table class="table settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+      <tr>
+        <th>
+          {{ t("security_setting.form_item_name.security:passport-saml:ABLCRule") }}
+        </th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:ABLCRule]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.attr_based_login_control_rule_detail") }}<br>
+              {{ t("security_setting.SAML.attr_based_login_control_rule_example") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ABLC_RULE") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      </tbody>
+    </table>
+
   </fieldset>
 
   <div class="form-group" id="btn-update">

+ 1 - 1
src/server/views/installer.html

@@ -86,7 +86,7 @@
 </body>
 {% endblock %}
 
-<script type="application/json" id="crowi-context-hydrate">
+<script type="application/json" id="growi-context-hydrate">
 {{ local_config|json|safe|preventXss }}
 </script>
 

+ 7 - 2
src/server/views/layout/layout.html

@@ -63,7 +63,6 @@
 <body
   class="main-container content-wrapper {% block html_base_css %}{% endblock %}
       {% if !getConfig('crowi', 'customize:layout') || 'crowi' === getConfig('crowi', 'customize:layout') %}crowi{% elseif !getConfig('crowi', 'customize:layout') || 'kibela' === getConfig('crowi', 'customize:layout') %}kibela{% else %}growi{% endif %}"
-  data-me="{{ user._id.toString() }}"
   data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
@@ -193,10 +192,16 @@
 </body>
 {% endblock %}
 
-<script type="application/json" id="crowi-context-hydrate">
+<script type="application/json" id="growi-context-hydrate">
 {{ local_config|json|safe|preventXss }}
 </script>
 
+{% if user != null %}
+  <script type="application/json" id="growi-current-user">
+  {{ user|json|safe|preventXss }}
+  </script>
+{% endif %}
+
 {% block custom_script %}
 <script>
   {{ customizeService.getCustomScript() }}

+ 4 - 1
src/server/views/widget/not_found_tabs.html

@@ -7,7 +7,10 @@
 
   {% if !isTrashPage() and !page.isDeleted() %}
   <li class="nav-main-left-tab">
-    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %} class="edit-button edit-button-disabled" data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}" {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Create') }}
     </a>
   </li>

+ 28 - 4
src/server/views/widget/page_tabs.html

@@ -12,13 +12,25 @@
 
   {% if !isTrashPage() %}
   <li class="nav-main-left-tab nav-tab-edit">
-    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
   {% if isHackmdSetup() %}
   <li class="nav-main-left-tab nav-tab-hackmd">
-    <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="{% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#hackmd" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
     </a>
   </li>
@@ -31,7 +43,13 @@
   {% if !isTrashPage() %}
     {% if page.isPortal() %}
     <li class="nav-main-right-tab dropdown pull-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
@@ -47,7 +65,13 @@
     </li>
     {% else %}
     <li class="nav-main-right-tab dropdown pull-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">

+ 28 - 4
src/server/views/widget/page_tabs_kibela.html

@@ -12,13 +12,25 @@
 
   {% if !isTrashPage() %}
   <li class="nav-main-left-tab nav-tab-edit">
-    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
   {% if isHackmdSetup() %}
   <li class="nav-main-left-tab nav-tab-hackmd">
-    <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="{% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#hackmd" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
     </a>
   </li>
@@ -31,7 +43,13 @@
   {% if !isTrashPage() %}
     {% if page.isPortal() %}
     <li class="nav-main-right-tab dropdown pull-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
@@ -44,7 +62,13 @@
     </li>
     {% else %}
     <li class="nav-main-right-tab dropdown pull-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">

+ 4 - 4
src/test/middleware/login-required.test.js

@@ -101,7 +101,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe('original url 1');
+      expect(req.session.redirectTo).toBe('original url 1');
     });
 
     test('pass user who logged in', () => {
@@ -119,7 +119,7 @@ describe('loginRequired', () => {
       expect(res.redirect).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalledTimes(1);
       expect(result).toBe('next');
-      expect(req.session.jumpTo).toBe(undefined);
+      expect(req.session.redirectTo).toBe(undefined);
     });
 
     /* eslint-disable indent */
@@ -142,7 +142,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith(expectedPath);
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe(undefined);
+      expect(req.session.redirectTo).toBe(undefined);
     });
     /* eslint-disable indent */
 
@@ -163,7 +163,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe('original url 1');
+      expect(req.session.redirectTo).toBe('original url 1');
     });
 
   });

+ 108 - 0
src/test/middleware/safe-redirect.test.js

@@ -0,0 +1,108 @@
+/* eslint-disable arrow-body-style */
+
+describe('safeRedirect', () => {
+  let safeRedirect;
+
+  const whitelistOfHosts = [
+    'white1.example.com:8080',
+    'white2.example.com',
+  ];
+
+  beforeEach(async(done) => {
+    safeRedirect = require('@server/middleware/safe-redirect')(whitelistOfHosts);
+    done();
+  });
+
+  describe('res.safeRedirect', () => {
+    // setup req/res/next
+    const req = {
+      protocol: 'http',
+      hostname: 'example.com',
+      get: jest.fn().mockReturnValue('example.com'),
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+    };
+    const next = jest.fn();
+
+    test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('//evil.example.com');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to \'/\' because specified host without port is not in whitelist', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white1.example.com/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified local url', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified local url (fqdn)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://example.com/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white1.example.com:8080/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white2.example.com:8080/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+  });
+
+});

+ 42 - 35
yarn.lock

@@ -2165,7 +2165,7 @@ async@1.5.2, async@^1.4.0:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
 
-async@2.6.1, async@^2.1.5:
+async@2.6.1:
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
   dependencies:
@@ -4100,16 +4100,17 @@ debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, de
   dependencies:
     ms "2.0.0"
 
-debug@3.1.0, debug@=3.1.0, debug@^3.1.0, debug@~3.1.0:
+debug@3.1.0, debug@=3.1.0, debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
   dependencies:
     ms "2.0.0"
 
-debug@^3.2.6:
+debug@^3.1.0, debug@^3.2.6:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
   dependencies:
     ms "^2.1.1"
 
@@ -4487,10 +4488,6 @@ ee-first@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
 
-ejs@^2.5.6:
-  version "2.5.7"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.7.tgz#cc872c168880ae3c7189762fd5ffc00896c9518a"
-
 ejs@^2.6.1:
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0"
@@ -4809,7 +4806,7 @@ esa-nodejs@^0.0.7:
     superagent "^1.2.0"
     superagent-no-cache "^0.1.0"
 
-escape-html@~1.0.3:
+escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
 
@@ -8145,7 +8142,7 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-lodash@4.17.15, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15:
+lodash@4.17.15, lodash@^4.17.10, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15:
   version "4.17.15"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
   integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
@@ -8162,7 +8159,7 @@ lodash@^4.15.0:
   version "4.17.5"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
 
-lodash@^4.17.10, lodash@^4.17.5:
+lodash@^4.17.5:
   version "4.17.10"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"
 
@@ -8949,6 +8946,7 @@ mquery@3.2.0:
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
 ms@^2.0.0, ms@^2.1.1:
   version "2.1.1"
@@ -9088,6 +9086,7 @@ node-fetch@^2.2.0, node-fetch@^2.3.0:
 node-forge@^0.7.0:
   version "0.7.6"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
+  integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==
 
 node-forge@^0.8.1:
   version "0.8.4"
@@ -10050,16 +10049,17 @@ passport-oauth2@1.x.x:
     utils-merge "1.x.x"
 
 passport-saml@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-1.0.0.tgz#3931bfb7046e85840e3b04691c619411082bf2f5"
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935"
+  integrity sha512-54ecY/A6UEsyCehJws6a+J6THvwtYnGl9cnAUxx5DjsuKgZrDs0tSy58K4hCk1XG/LOcdQSF1TR3xlRXgTULhA==
   dependencies:
     debug "^3.1.0"
     passport-strategy "*"
     q "^1.5.0"
-    xml-crypto "^1.0.2"
-    xml-encryption "^0.11.0"
+    xml-crypto "^1.4.0"
+    xml-encryption "^1.0.0"
     xml2js "0.4.x"
-    xmlbuilder "^9.0.4"
+    xmlbuilder "^11.0.0"
     xmldom "0.1.x"
 
 passport-strategy@*, passport-strategy@1.x.x, passport-strategy@^1.0.0:
@@ -11956,6 +11956,7 @@ sax@1.2.1:
 sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
 
 saxes@^3.1.9:
   version "3.1.11"
@@ -14382,19 +14383,20 @@ xdg-basedir@^4.0.0:
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
   integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
 
-xml-crypto@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-1.0.2.tgz#248df860b1e3f7326e61bcbd00c234886b0d6e3b"
+xml-crypto@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-1.4.0.tgz#de1cec8cd31cbd689cd90d3d6e8a27d4ae807de7"
+  integrity sha512-K8FRdRxICVulK4WhiTUcJrRyAIJFPVOqxfurA3x/JlmXBTxy+SkEENF6GeRt7p/rB6WSOUS9g0gXNQw5n+407g==
   dependencies:
     xmldom "0.1.27"
-    xpath.js ">=0.0.3"
+    xpath "0.0.27"
 
-xml-encryption@^0.11.0:
-  version "0.11.2"
-  resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-0.11.2.tgz#c217f5509547e34b500b829f2c0bca85cca73a21"
+xml-encryption@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-1.0.0.tgz#fe50d3bbbe2ae06876d6aa95aa3bf958284e1612"
+  integrity sha512-xTqcgKPN3XOswvDPXrhtyvWZ96IFcO9Azv3vS060kOpBsK5T7OxbQDxb59bPLl4b4c2IgmSZC3kJB0n5WPr2Mw==
   dependencies:
-    async "^2.1.5"
-    ejs "^2.5.6"
+    escape-html "^1.0.3"
     node-forge "^0.7.0"
     xmldom "~0.1.15"
     xpath "0.0.27"
@@ -14412,11 +14414,12 @@ xml2js@0.4.17:
     xmlbuilder "^4.1.0"
 
 xml2js@0.4.x:
-  version "0.4.19"
-  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  version "0.4.23"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
+  integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
   dependencies:
     sax ">=0.6.0"
-    xmlbuilder "~9.0.1"
+    xmlbuilder "~11.0.0"
 
 xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   version "4.2.1"
@@ -14424,9 +14427,10 @@ xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   dependencies:
     lodash "^4.0.0"
 
-xmlbuilder@^9.0.4, xmlbuilder@~9.0.1:
-  version "9.0.7"
-  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+xmlbuilder@^11.0.0, xmlbuilder@~11.0.0:
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
+  integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
 
 xmlchars@^2.1.1:
   version "2.2.0"
@@ -14440,21 +14444,24 @@ xmldoc@^1.1.2:
   dependencies:
     sax "^1.2.1"
 
-xmldom@0.1.27, xmldom@0.1.x, xmldom@~0.1.15:
+xmldom@0.1.27:
   version "0.1.27"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
+  integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk=
+
+xmldom@0.1.x, xmldom@~0.1.15:
+  version "0.1.31"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
 
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
-xpath.js@>=0.0.3:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1"
-
 xpath@0.0.27:
   version "0.0.27"
   resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92"
+  integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==
 
 xss@^1.0.6:
   version "1.0.6"