Browse Source

Merge branch 'master' into feat/6982-textlint

Steven Fukase 4 years ago
parent
commit
bef356b3d6
54 changed files with 1085 additions and 163 deletions
  1. 2 0
      CHANGES.md
  2. 2 4
      packages/app/.env.development
  3. 1 0
      packages/app/package.json
  4. 8 0
      packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt
  5. 13 0
      packages/app/resource/locales/en_US/notifications/notActiveUser.txt
  6. 10 0
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  7. 19 1
      packages/app/resource/locales/en_US/translation.json
  8. 13 0
      packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt
  9. 10 0
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  10. 6 0
      packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt
  11. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  12. 6 0
      packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt
  13. 13 0
      packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt
  14. 10 0
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  15. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  16. 34 0
      packages/app/src/client/nologin.jsx
  17. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  18. 5 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  19. 22 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  20. 8 5
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  21. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  22. 4 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  23. 7 4
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  24. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  25. 7 0
      packages/app/src/components/LoginForm.jsx
  26. 96 0
      packages/app/src/components/PasswordResetExecutionForm.jsx
  27. 66 0
      packages/app/src/components/PasswordResetRequestForm.jsx
  28. 2 0
      packages/app/src/components/StickyStretchableScroller.jsx
  29. 65 0
      packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  30. 24 0
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.js
  31. 1 0
      packages/app/src/server/models/index.js
  32. 57 0
      packages/app/src/server/models/password-reset-order.js
  33. 12 4
      packages/app/src/server/models/slack-app-integration.js
  34. 115 0
      packages/app/src/server/routes/apiv3/forgot-password.js
  35. 2 0
      packages/app/src/server/routes/apiv3/index.js
  36. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  37. 30 27
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  38. 1 1
      packages/app/src/server/routes/apiv3/slack-integration.js
  39. 21 0
      packages/app/src/server/routes/forgot-password.js
  40. 13 0
      packages/app/src/server/routes/index.js
  41. 34 14
      packages/app/src/server/service/config-loader.ts
  42. 8 7
      packages/app/src/server/service/slack-integration.ts
  43. 45 0
      packages/app/src/server/views/forgot-password.html
  44. 54 0
      packages/app/src/server/views/forgot-password/error.html
  45. 2 0
      packages/app/src/server/views/login.html
  46. 7 0
      packages/app/src/server/views/login/error.html
  47. 48 0
      packages/app/src/server/views/reset-password.html
  48. 1 0
      packages/slack/src/index.ts
  49. 5 0
      packages/slack/src/interfaces/slackbot-types.ts
  50. 1 1
      packages/slackbot-proxy/package.json
  51. 13 9
      packages/slackbot-proxy/src/controllers/slack.ts
  52. 75 73
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  53. 20 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts
  54. 5 0
      yarn.lock

+ 2 - 0
CHANGES.md

@@ -8,6 +8,8 @@
 
 
 ### Updates
 ### Updates
 
 
+* Feature: Password resetting by user
+* Feature: User trigger notification and Global notification are available by new Slack integration
 * Improvement: Add attachment button in editor navbar
 * Improvement: Add attachment button in editor navbar
 * Fix: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
 * Fix: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
 * Fix: Encode spaces in page path in LinkEditModal
 * Fix: Encode spaces in page path in LinkEditModal

+ 2 - 4
packages/app/.env.development

@@ -20,9 +20,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DEV_HTTPS=true
 # DEV_HTTPS=true
 # FORCE_WIKI_MODE=private
 # FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
 # PROMSTER_ENABLED=true
-# SLACK_SIGNING_SECRET=''
-# SLACK_BOT_TOKEN=''
-SALT_FOR_GTOP_TOKEN="proxy"
-SALT_FOR_PTOG_TOKEN="growi"
+# SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET=''
+# SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345

+ 1 - 0
packages/app/package.json

@@ -92,6 +92,7 @@
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-mongo-sanitize": "^2.1.0",
+    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",

+ 8 - 0
packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt

@@ -0,0 +1,8 @@
+Password Reset Successful
+
+Hi {{ email }}
+
+Your password has been successfully reset.
+Please log in with your new password.
+
+Thank you,

+ 13 - 0
packages/app/resource/locales/en_US/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+Password Reset
+
+Hi, {{ email }}
+
+A request has been received to change the password from {{ appTitle }}.
+However, this email is not registerd. Please try again with different email.
+
+If you did not request a password reset, you can safely ignore this email.
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+Password Reset
+
+Hi, {{ email }}
+
+A request has been received to change the password your GROWI account {{ appTitle }}.
+To reset your password, click on the link below.
+
+{{ url }}
+
+If you did not request a password reset, you can safely ignore this email.

+ 19 - 1
packages/app/resource/locales/en_US/translation.json

@@ -604,7 +604,10 @@
     "Local": {
     "Local": {
       "name": "ID/Password",
       "name": "ID/Password",
       "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
       "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-      "enable_local": "Enable ID/Password"
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "Password reset by users",
+      "enable_password_reset_by_users": "Enable password reset by users",
+      "password_reset_desc": "when forgot password, users are able to reset it by themselves."
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "Enable LDAP",
       "enable_ldap": "Enable LDAP",
@@ -845,5 +848,20 @@
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
     "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
     "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
+  },
+  "forgot_password":{
+    "forgot_password": "Forgot Password?",
+    "send": "Send",
+    "return_to_login": "Return to login",
+    "reset_password": "Reset Password",
+    "sign_in_instead": "Sign in instead",
+    "password_reset_request_desc": "You can reset your password here.",
+    "password_reset_excecution_desc": "Enter a new password",
+    "new_password": "New Password",
+    "confirm_new_password": "Confirm the new password",
+    "email_is_required": "Email is required",
+    "success_to_send_email": "Success to send email",
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
+    "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   }
   }
 }
 }

+ 13 - 0
packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+パスワードリセット
+
+こんにちは、 {{ email }}
+
+{{ appTitle }} からパスワード再設定のリクエストがありましたが、このemailは登録されておりません。
+他のemailアドレスで再度お試しください。
+
+もしこのリクエストに心当たりがない場合は、このメールを無視してください。
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+パスワード リセット
+
+こんにちは, {{ email }}
+
+あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+パスワードをリセットするには、以下のリンクをクリックしてください。
+
+{{ url }}
+
+もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 6 - 0
packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt

@@ -0,0 +1,6 @@
+パスワードリセットに成功
+
+こんにちは、 {{ email }}
+
+あなたのパスワードは正常にリセットされました。
+新しいパスワードでログインしてください。

+ 19 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -603,7 +603,10 @@
     "Local": {
     "Local": {
       "name": "ID/Password",
       "name": "ID/Password",
       "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
       "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
-      "enable_local": "ID/Password を有効にする"
+      "enable_local": "ID/Password を有効にする",
+      "password_reset_by_users": "ユーザーによるパスワード再設定",
+      "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。"
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
       "enable_ldap": "LDAP を有効にする",
@@ -839,5 +842,20 @@
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
     "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
+  },
+  "forgot_password":{
+    "forgot_password": "パスワードをお忘れですか?",
+    "send": "送信",
+    "return_to_login": "ログイン画面に戻る",
+    "reset_password": "パスワード リセット",
+    "sign_in_instead": "ログインする",
+    "password_reset_request_desc": "ここからパスワードリセットできます",
+    "password_reset_excecution_desc": "新しいパスワードを入力してください",
+    "new_password": "新しいパスワード",
+    "confirm_new_password": "新しいパスワードの確認",
+    "email_is_required": "メールを入力してください",
+    "success_to_send_email": "メールを送信しました",
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
+    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   }
   }
 }
 }

+ 6 - 0
packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt

@@ -0,0 +1,6 @@
+密码重置成功
+
+嗨, {{email}}
+
+您的密码已成功重置。
+请使用您的新密码登录。

+ 13 - 0
packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+重设密码
+
+嗨,{{电子邮件}}
+
+已收到来自 {{appTitle}} 的更改密码请求。
+但是,此电子邮件未注册。请使用其他电子邮件重试。
+
+如果您没有要求重置密码,则可以放心地忽略此电子邮件。
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+重设密码
+
+嗨,{{ email }}
+
+已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+要重置密码,请单击下面的链接。
+
+{{ url }}
+
+如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 20 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -42,7 +42,7 @@
   "Update": "更新",
   "Update": "更新",
 	"Update Page": "更新本页",
 	"Update Page": "更新本页",
 	"Warning": "警告",
 	"Warning": "警告",
-	"Sign in": "登录",
+  "Sign in": "登录",
 	"Sign up is here": "注册",
 	"Sign up is here": "注册",
 	"Sign in is here": "登录",
 	"Sign in is here": "登录",
 	"Sign up": "注册",
 	"Sign up": "注册",
@@ -592,7 +592,10 @@
 		"Local": {
 		"Local": {
 			"name": "ID/Password",
 			"name": "ID/Password",
 			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
 			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-			"enable_local": "Enable ID/Password"
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "用户重置密码",
+      "enable_password_reset_by_users": "启用用户重置密码",
+      "password_reset_desc": "忘记密码时,用户可以自行重置"
 		},
 		},
 		"ldap": {
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
 			"enable_ldap": "Enable LDAP",
@@ -850,5 +853,20 @@
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
     "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
+  },
+  "forgot_password":{
+    "forgot_password": "忘记密码?",
+    "send": "发送",
+    "return_to_login": "返回登录",
+    "reset_password": "重设密码",
+    "sign_in_instead": "改为登录",
+    "password_reset_request_desc": "您可以在此处重置密码",
+    "password_reset_excecution_desc": "输入新的密码",
+    "new_password": "新密码",
+    "confirm_new_password": "确认新密码",
+    "email_is_required": "电子邮件是必需的",
+    "success_to_send_email": "我发了一封电子邮件",
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
+    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   }
   }
 }
 }

+ 34 - 0
packages/app/src/client/nologin.jsx

@@ -9,6 +9,8 @@ import AppContainer from '~/client/services/AppContainer';
 
 
 import InstallerForm from '../components/InstallerForm';
 import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
@@ -38,6 +40,7 @@ if (loginFormElem) {
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const registrationMode = loginFormElem.dataset.registrationMode;
+  const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
 
 
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
@@ -68,6 +71,7 @@ if (loginFormElem) {
           isRegistrationEnabled={isRegistrationEnabled}
           isRegistrationEnabled={isRegistrationEnabled}
           registrationMode={registrationMode}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           registrationWhiteList={registrationWhiteList}
+          isPasswordResetEnabled={isPasswordResetEnabled}
           isLocalStrategySetup={isLocalStrategySetup}
           isLocalStrategySetup={isLocalStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
@@ -77,3 +81,33 @@ if (loginFormElem) {
     loginFormElem,
     loginFormElem,
   );
   );
 }
 }
+
+// render PasswordResetRequestForm
+const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
+const appContainer = new AppContainer();
+appContainer.initApp();
+if (passwordResetRequestFormElem) {
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[appContainer]}>
+        <PasswordResetRequestForm />
+      </Provider>
+    </I18nextProvider>,
+    passwordResetRequestFormElem,
+  );
+}
+
+// render PasswordResetExecutionForm
+const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
+if (passwordResetExecutionFormElem) {
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[appContainer]}>
+        <PasswordResetExecutionForm />
+      </Provider>
+    </I18nextProvider>,
+    passwordResetExecutionFormElem,
+  );
+}

+ 12 - 1
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -22,6 +22,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: this.dummyRegistrationMode,
       registrationMode: this.dummyRegistrationMode,
       registrationWhiteList: [],
       registrationWhiteList: [],
       useOnlyEnvVars: false,
       useOnlyEnvVars: false,
+      isPasswordResetEnabled: false,
     };
     };
 
 
   }
   }
@@ -34,6 +35,7 @@ export default class AdminLocalSecurityContainer extends Container {
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         registrationWhiteList: localSetting.registrationWhiteList,
+        isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -66,14 +68,22 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ registrationWhiteList: value.split('\n') });
     this.setState({ registrationWhiteList: value.split('\n') });
   }
   }
 
 
+  /**
+   * Switch password reset enabled
+   */
+  switchIsPasswordResetEnabled() {
+    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+  }
+
   /**
   /**
    * update local security setting
    * update local security setting
    */
    */
   async updateLocalSecuritySetting() {
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       registrationWhiteList,
+      isPasswordResetEnabled,
     });
     });
 
 
     const { localSettingParams } = response.data;
     const { localSettingParams } = response.data;
@@ -81,6 +91,7 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({
     this.setState({
       registrationMode: localSettingParams.registrationMode,
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       registrationWhiteList: localSettingParams.registrationWhiteList,
+      isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
     });
     });
 
 
     return localSettingParams;
     return localSettingParams;

+ 5 - 2
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -4,9 +4,12 @@ import React, {
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
-  Card, CardBody, TabContent, TabPane,
+  TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -48,7 +51,7 @@ const SkeltonListItem = () => (
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const isCautionVisible = currentBotType === 'officialBot' || currentBotType === 'customBotWithProxy';
+  const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
 
   return (
   return (
     <li className="list-group-item">
     <li className="list-group-item">

+ 22 - 1
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -32,7 +32,7 @@ class LocalSecuritySettingContents extends React.Component {
 
 
   render() {
   render() {
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
 
 
     return (
     return (
@@ -157,6 +157,27 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
               </div>
             </div>
             </div>
 
 
+            <div className="row">
+              <label className="col-12 col-md-3 text-left text-md-right  col-form-label">{t('security_setting.Local.password_reset_by_users')}</label>
+              <div className="col-12 col-md-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isPasswordResetEnabled"
+                    checked={isPasswordResetEnabled}
+                    onChange={() => adminLocalSecurityContainer.switchIsPasswordResetEnabled()}
+                  />
+                  <label className="custom-control-label" htmlFor="isPasswordResetEnabled">
+                    {t('security_setting.Local.enable_password_reset_by_users')}
+                  </label>
+                </div>
+                <p className="form-text text-muted small">
+                  {t('security_setting.Local.password_reset_desc')}
+                </p>
+              </div>
+            </div>
+
             <div className="row my-3">
             <div className="row my-3">
               <div className="offset-3 col-6">
               <div className="offset-3 col-6">
                 <button
                 <button

+ 8 - 5
packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx

@@ -2,17 +2,18 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { SlackbotType } from '@growi/slack';
 
 
 const botDetails = {
 const botDetails = {
   officialBot: {
   officialBot: {
-    botType: 'officialBot',
+    botType: SlackbotType.OFFICIAL,
     botTypeCategory: 'official_bot',
     botTypeCategory: 'official_bot',
     setUp: 'easy',
     setUp: 'easy',
     multiWSIntegration: 'possible',
     multiWSIntegration: 'possible',
     securityControl: 'impossible',
     securityControl: 'impossible',
   },
   },
   customBotWithoutProxy: {
   customBotWithoutProxy: {
-    botType: 'customBotWithoutProxy',
+    botType: SlackbotType.CUSTOM_WITHOUT_PROXY,
     botTypeCategory: 'custom_bot',
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'without_proxy',
     supplementaryBotName: 'without_proxy',
     setUp: 'normal',
     setUp: 'normal',
@@ -20,7 +21,7 @@ const botDetails = {
     securityControl: 'possible',
     securityControl: 'possible',
   },
   },
   customBotWithProxy: {
   customBotWithProxy: {
-    botType: 'customBotWithProxy',
+    botType: SlackbotType.CUSTOM_WITH_PROXY,
     botTypeCategory: 'custom_bot',
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'with_proxy',
     supplementaryBotName: 'with_proxy',
     setUp: 'hard',
     setUp: 'hard',
@@ -32,6 +33,8 @@ const botDetails = {
 const BotTypeCard = (props) => {
 const BotTypeCard = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
+  const isBotTypeOfficial = props.botType === SlackbotType.OFFICIAL;
+
   return (
   return (
     <div
     <div
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
@@ -41,7 +44,7 @@ const BotTypeCard = (props) => {
     >
     >
       <div>
       <div>
         <h3 className={`card-header mb-0 py-3
         <h3 className={`card-header mb-0 py-3
-              ${props.botType === 'officialBot' ? 'd-flex align-items-center justify-content-center' : 'text-center'}
+              ${isBotTypeOfficial ? 'd-flex align-items-center justify-content-center' : 'text-center'}
               ${props.isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
               ${props.isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
         >
         >
           <span className="mr-2">
           <span className="mr-2">
@@ -49,7 +52,7 @@ const BotTypeCard = (props) => {
           </span>
           </span>
 
 
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
-          {props.botType === 'officialBot'
+          { isBotTypeOfficial
             ? (
             ? (
               <span className="badge badge-info mr-2">
               <span className="badge badge-info mr-2">
                 {t('admin:slack_integration.selecting_bot_types.recommended')}
                 {t('admin:slack_integration.selecting_bot_types.recommended')}

+ 3 - 3
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx

@@ -67,8 +67,8 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
             readOnly
             readOnly
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
-            {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACK_SIGNING_SECRET' }) }} />
+            {/* eslint-disable-next-line max-len, react/no-danger */}
+            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET' }) }} />
           </p>
           </p>
         </div>
         </div>
 
 
@@ -97,7 +97,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
             {/* eslint-disable-next-line react/no-danger */}
             {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACK_BOT_TOKEN' }) }} />
+            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_BOT_TOKEN' }) }} />
           </p>
           </p>
         </div>
         </div>
 
 

+ 4 - 1
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -109,7 +112,7 @@ const OfficialBotSettings = (props) => {
                 />
                 />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
-                botType="officialBot"
+                botType={SlackbotType.OFFICIAL}
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}

+ 7 - 4
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -12,7 +15,7 @@ import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import BotTypeCard from './BotTypeCard';
 import BotTypeCard from './BotTypeCard';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 
 
-const botTypes = ['officialBot', 'customBotWithoutProxy', 'customBotWithProxy'];
+const botTypes = Object.values(SlackbotType);
 
 
 const SlackIntegration = (props) => {
 const SlackIntegration = (props) => {
 
 
@@ -125,7 +128,7 @@ const SlackIntegration = (props) => {
   let settingsComponent = null;
   let settingsComponent = null;
 
 
   switch (currentBotType) {
   switch (currentBotType) {
-    case 'officialBot':
+    case SlackbotType.OFFICIAL:
       settingsComponent = (
       settingsComponent = (
         <OfficialBotSettings
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}
@@ -138,7 +141,7 @@ const SlackIntegration = (props) => {
         />
         />
       );
       );
       break;
       break;
-    case 'customBotWithoutProxy':
+    case SlackbotType.CUSTOM_WITHOUT_PROXY:
       settingsComponent = (
       settingsComponent = (
         <CustomBotWithoutProxySettings
         <CustomBotWithoutProxySettings
           slackBotTokenEnv={slackBotTokenEnv}
           slackBotTokenEnv={slackBotTokenEnv}
@@ -151,7 +154,7 @@ const SlackIntegration = (props) => {
         />
         />
       );
       );
       break;
       break;
-    case 'customBotWithProxy':
+    case SlackbotType.CUSTOM_WITH_PROXY:
       settingsComponent = (
       settingsComponent = (
         <CustomBotWithProxySettings
         <CustomBotWithProxySettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}

+ 5 - 2
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -3,6 +3,9 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -383,7 +386,7 @@ const WithProxyAccordions = (props) => {
     },
     },
   };
   };
 
 
-  const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
+  const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
 
 
   return (
   return (
     <div
     <div
@@ -416,7 +419,7 @@ const WithProxyAccordions = (props) => {
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 WithProxyAccordions.propTypes = {
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  botType: PropTypes.string.isRequired,
+  botType: PropTypes.oneOf(Object.values(SlackbotType)).isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,

+ 7 - 0
packages/app/src/components/LoginForm.jsx

@@ -251,6 +251,7 @@ class LoginForm extends React.Component {
       isLocalStrategySetup,
       isLocalStrategySetup,
       isLdapStrategySetup,
       isLdapStrategySetup,
       isRegistrationEnabled,
       isRegistrationEnabled,
+      isPasswordResetEnabled,
       objOfIsExternalAuthEnableds,
       objOfIsExternalAuthEnableds,
     } = this.props;
     } = this.props;
 
 
@@ -268,6 +269,11 @@ class LoginForm extends React.Component {
                 {isRegistrationEnabled && (
                 {isRegistrationEnabled && (
                   <div className="row">
                   <div className="row">
                     <div className="col-12 text-right py-2">
                     <div className="col-12 text-right py-2">
+                      {isPasswordResetEnabled && (
+                        <a href="/forgot-password" className="d-block link-switch mb-1">
+                          <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                        </a>
+                      )}
                       <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
                       <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
                         <i className="ti-check-box"></i> {t('Sign up is here')}
                         <i className="ti-check-box"></i> {t('Sign up is here')}
                       </a>
                       </a>
@@ -307,6 +313,7 @@ LoginForm.propTypes = {
   isRegistrationEnabled: PropTypes.bool,
   isRegistrationEnabled: PropTypes.bool,
   registrationMode: PropTypes.string,
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
   registrationWhiteList: PropTypes.array,
+  isPasswordResetEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,
   objOfIsExternalAuthEnableds: PropTypes.object,

+ 96 - 0
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '~/utils/logger';
+import { withUnstatedContainers } from './UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+const logger = loggerFactory('growi:passwordReset');
+
+
+const PasswordResetExecutionForm = (props) => {
+  const { t, appContainer } = props;
+
+  const [newPassword, setNewPassword] = useState('');
+  const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
+  const [validationErrorI18n, setValidationErrorI18n] = useState('');
+
+  // get token from URL
+  const pathname = window.location.pathname.split('/');
+  const token = pathname[2];
+
+  const changePassword = async(e) => {
+    e.preventDefault();
+
+    if (newPassword === '' || newPasswordConfirm === '') {
+      setValidationErrorI18n('personal_settings.password_is_not_set');
+      return;
+    }
+
+    if (newPassword !== newPasswordConfirm) {
+      setValidationErrorI18n('forgot_password.password_and_confirm_password_does_not_match');
+      return;
+    }
+
+    try {
+      await appContainer.apiv3Put('/forgot-password', {
+        token, newPassword, newPasswordConfirm,
+      });
+
+      setValidationErrorI18n('');
+
+      toastSuccess(t('toaster.update_successed', { target: t('Password') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+
+  };
+
+  return (
+    <form role="form" onSubmit={changePassword}>
+      <div className="form-group">
+        <div className="input-group">
+          <input
+            name="password"
+            placeholder={t('forgot_password.new_password')}
+            className="form-control"
+            type="password"
+            onChange={e => setNewPassword(e.target.value)}
+          />
+        </div>
+      </div>
+      <div className="form-group">
+        <div className="input-group">
+          <input
+            name="password"
+            placeholder={t('forgot_password.confirm_new_password')}
+            className="form-control"
+            type="password"
+            onChange={e => setNewPasswordConfirm(e.target.value)}
+          />
+        </div>
+        {validationErrorI18n !== '' && (
+          <p className="text-danger mt-2">{t(validationErrorI18n)}</p>
+        )}
+      </div>
+      <div className="form-group">
+        <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
+      </div>
+      <a href="/login">
+        <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
+      </a>
+    </form>
+  );
+};
+
+const PasswordResetExecutionFormWrapper = withUnstatedContainers(PasswordResetExecutionForm, [AppContainer]);
+
+PasswordResetExecutionForm.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PasswordResetExecutionFormWrapper);

+ 66 - 0
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AppContainer from '~/client/services/AppContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+const PasswordResetRequestForm = (props) => {
+  const { t, appContainer } = props;
+  const [email, setEmail] = useState('');
+
+  const changeEmail = (inputValue) => {
+    setEmail(inputValue);
+  };
+
+  const sendPasswordResetRequestMail = async(e) => {
+    e.preventDefault();
+    if (email === '') {
+      toastError('err', t('forgot_password.email_is_required'));
+      return;
+    }
+
+    try {
+      await appContainer.apiv3Post('/forgot-password', { email });
+      toastSuccess(t('forgot_password.success_to_send_email'));
+    }
+    catch (err) {
+      toastError('err', err);
+    }
+  };
+
+  return (
+    <form onSubmit={sendPasswordResetRequestMail}>
+      <div className="form-group">
+        <div className="input-group">
+          <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
+        </div>
+      </div>
+      <div className="form-group">
+        <button
+          className="btn btn-lg btn-primary btn-block"
+          type="submit"
+        >
+          {t('forgot_password.send')}
+        </button>
+      </div>
+      <a href="/login">
+        <i className="icon-login mr-1"></i>{t('forgot_password.return_to_login')}
+      </a>
+    </form>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordResetRequestFormWrapper = withUnstatedContainers(PasswordResetRequestForm, [AppContainer]);
+
+PasswordResetRequestForm.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PasswordResetRequestFormWrapper);

+ 2 - 0
packages/app/src/components/StickyStretchableScroller.jsx

@@ -89,6 +89,8 @@ const StickyStretchableScroller = (props) => {
       railVisible: true,
       railVisible: true,
       position: 'right',
       position: 'right',
       height: isScrollEnabled ? viewHeight : contentsHeight,
       height: isScrollEnabled ? viewHeight : contentsHeight,
+      wheelStep: 10,
+      allowPageScroll: true,
     });
     });
 
 
     // destroy
     // destroy

+ 65 - 0
packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -0,0 +1,65 @@
+import mongoose from 'mongoose';
+
+import Config from '~/server/models/config';
+import config from '^/config/migrate';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    await Config.bulkWrite([
+      {
+        updateOne: {
+          filter: { key: 'slackbot:proxyServerUri' },
+          update: { key: 'slackbot:proxyUri' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:token' },
+          update: { key: 'slackbot:withoutProxy:botToken' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:signingSecret' },
+          update: { key: 'slackbot:withoutProxy:signingSecret' },
+        },
+      },
+    ]);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    await Config.bulkWrite([
+      {
+        updateOne: {
+          filter: { key: 'slackbot:proxyUri' },
+          update: { key: 'slackbot:proxyServerUri' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:withoutProxy:botToken' },
+          update: { key: 'slackbot:token' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:withoutProxy:signingSecret' },
+          update: { key: 'slackbot:signingSecret' },
+        },
+      },
+    ]);
+
+    logger.info('Migration has successfully applied');
+  },
+};

+ 24 - 0
packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.js

@@ -0,0 +1,24 @@
+const createError = require('http-errors');
+
+module.exports = (crowi, app) => {
+  const PasswordResetOrder = crowi.model('PasswordResetOrder');
+
+  return async(req, res, next) => {
+    const token = req.params.token || req.body.token;
+
+    if (token == null) {
+      req.error = { key: 'token-not-found', message: 'Token not found' };
+    }
+
+    const passwordResetOrder = await PasswordResetOrder.findOne({ token });
+
+    // check if the token is valid
+    if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
+      req.error = { key: 'password-reset-order-is-not-appropriate', message: 'passwordResetOrder is null or expired or revoked' };
+    }
+
+    req.passwordResetOrder = passwordResetOrder;
+
+    return next();
+  };
+};

+ 1 - 0
packages/app/src/server/models/index.js

@@ -17,4 +17,5 @@ module.exports = {
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   ShareLink: require('./share-link'),
   ShareLink: require('./share-link'),
   SlackAppIntegration: require('./slack-app-integration'),
   SlackAppIntegration: require('./slack-app-integration'),
+  PasswordResetOrder: require('./password-reset-order'),
 };
 };

+ 57 - 0
packages/app/src/server/models/password-reset-order.js

@@ -0,0 +1,57 @@
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+const crypto = require('crypto');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new mongoose.Schema({
+  token: { type: String, required: true, unique: true },
+  email: { type: String, required: true },
+  relatedUser: { type: ObjectId, ref: 'User' },
+  isRevoked: { type: Boolean, default: false, required: true },
+  createdAt: { type: Date, default: Date.now, required: true },
+  expiredAt: { type: Date, default: Date.now() + 600000, required: true },
+});
+schema.plugin(uniqueValidator);
+
+class PasswordResetOrder {
+
+  static generateOneTimeToken() {
+    const buf = crypto.randomBytes(256);
+    const token = buf.toString('hex');
+
+    return token;
+  }
+
+  static async createPasswordResetOrder(email) {
+    let token;
+    let duplicateToken;
+
+    do {
+      token = this.generateOneTimeToken();
+      // eslint-disable-next-line no-await-in-loop
+      duplicateToken = await this.findOne({ token });
+    } while (duplicateToken != null);
+
+    const passwordResetOrderData = await this.create({ token, email });
+
+    return passwordResetOrderData;
+  }
+
+  isExpired() {
+    return this.expiredAt.getTime() < Date.now();
+  }
+
+  async revokeOneTimeToken() {
+    this.isRevoked = true;
+    return this.save();
+  }
+
+}
+
+module.exports = function(crowi) {
+  PasswordResetOrder.crowi = crowi;
+  schema.loadClass(PasswordResetOrder);
+  const model = mongoose.model('PasswordResetOrder', schema);
+  return model;
+};

+ 12 - 4
packages/app/src/server/models/slack-app-integration.js

@@ -11,12 +11,14 @@ const schema = new mongoose.Schema({
 
 
 class SlackAppIntegration {
 class SlackAppIntegration {
 
 
-  static generateAccessTokens() {
+  crowi;
+
+  static generateAccessTokens(saltForGtoP, saltForPtoG) {
     const now = new Date().getTime();
     const now = new Date().getTime();
     const hasher1 = crypto.createHash('sha512');
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
-    const tokenGtoP = hasher1.update(`gtop${now.toString()}${process.env.SALT_FOR_GTOP_TOKEN}`).digest('base64');
-    const tokenPtoG = hasher2.update(`ptog${now.toString()}${process.env.SALT_FOR_PTOG_TOKEN}`).digest('base64');
+    const tokenGtoP = hasher1.update(`gtop-${saltForGtoP}-${now.toString()}`).digest('base64');
+    const tokenPtoG = hasher2.update(`ptog-${saltForPtoG}-${now.toString()}`).digest('base64');
     return [tokenGtoP, tokenPtoG];
     return [tokenGtoP, tokenPtoG];
   }
   }
 
 
@@ -26,8 +28,12 @@ class SlackAppIntegration {
     let tokenPtoG;
     let tokenPtoG;
     let generateTokens;
     let generateTokens;
 
 
+    // get salt strings
+    const saltForGtoP = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForGtoP');
+    const saltForPtoG = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForPtoG');
+
     do {
     do {
-      generateTokens = this.generateAccessTokens();
+      generateTokens = this.generateAccessTokens(saltForGtoP, saltForPtoG);
       tokenGtoP = generateTokens[0];
       tokenGtoP = generateTokens[0];
       tokenPtoG = generateTokens[1];
       tokenPtoG = generateTokens[1];
       // eslint-disable-next-line no-await-in-loop
       // eslint-disable-next-line no-await-in-loop
@@ -41,7 +47,9 @@ class SlackAppIntegration {
 }
 }
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
+
   SlackAppIntegration.crowi = crowi;
   SlackAppIntegration.crowi = crowi;
+
   schema.loadClass(SlackAppIntegration);
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
   return mongoose.model('SlackAppIntegration', schema);
 };
 };

+ 115 - 0
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -0,0 +1,115 @@
+import rateLimit from 'express-rate-limit';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+const { body } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  const { appService, mailService, configManager } = crowi;
+  const PasswordResetOrder = crowi.model('PasswordResetOrder');
+  const User = crowi.model('User');
+  const path = require('path');
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const injectResetOrderByTokenMiddleware = require('../../middlewares/inject-reset-order-by-token-middleware')(crowi);
+
+  const validator = {
+    password: [
+      body('newPassword').isString().not().isEmpty()
+        .isLength({ min: 6 })
+        .withMessage('password must be at least 6 characters long'),
+      // checking if password confirmation matches password
+      body('newPasswordConfirm').isString().not().isEmpty()
+        .custom((value, { req }) => {
+          return (value === req.body.newPassword);
+        }),
+    ],
+  };
+
+  const apiLimiter = rateLimit({
+    windowMs: 15 * 60 * 1000, // 15 minutes
+    max: 5, // limit each IP to 5 requests per windowMs
+    message:
+      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+  });
+
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+    return mailService.send({
+      to: email,
+      subject: txtFileName,
+      template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+      vars: {
+        appTitle: appService.getAppTitle(),
+        email,
+        url,
+      },
+    });
+  }
+
+  router.post('/', async(req, res) => {
+    const { email } = req.body;
+    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+    const i18n = req.language || grobalLang;
+    const appUrl = appService.getSiteUrl();
+
+    try {
+      const user = await User.findOne({ email });
+
+      // when the user is not found or active
+      if (user == null || user.status !== 2) {
+        await sendPasswordResetEmail('notActiveUser', i18n, email, appUrl);
+        return res.apiv3();
+      }
+
+      const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
+      const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
+      const oneTimeUrl = url.href;
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      return res.apiv3();
+    }
+    catch (err) {
+      const msg = 'Error occurred during password reset request procedure';
+      logger.error(err);
+      return res.apiv3Err(msg);
+    }
+  });
+
+  router.put('/', apiLimiter, csrf, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, async(req, res) => {
+
+    if (req.error != null) {
+      return res.apiv3Err(req.error.message);
+    }
+
+    const { passwordResetOrder } = req;
+    const { email } = passwordResetOrder;
+    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+    const i18n = req.language || grobalLang;
+    const { newPassword } = req.body;
+
+    const user = await User.findOne({ email });
+
+    // when the user is not found or active
+    if (user == null || user.status !== 2) {
+      return res.apiv3Err('update-password-failed');
+    }
+
+    try {
+      const userData = await user.updatePassword(newPassword);
+      const serializedUserData = serializeUserSecurely(userData);
+      passwordResetOrder.revokeOneTimeToken();
+      await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+      return res.apiv3({ userData: serializedUserData });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-password-failed');
+    }
+  });
+
+  return router;
+};

+ 2 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -51,5 +51,7 @@ module.exports = (crowi) => {
   router.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   router.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
 
 
+  router.use('/forgot-password', require('./forgot-password')(crowi));
+
   return router;
   return router;
 };
 };

+ 3 - 0
packages/app/src/server/routes/apiv3/security-setting.js

@@ -380,6 +380,7 @@ module.exports = (crowi) => {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       },
       },
       generalAuth: {
       generalAuth: {
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
@@ -747,6 +748,7 @@ module.exports = (crowi) => {
     const requestParams = {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
       'security:registrationWhiteList': req.body.registrationWhiteList,
+      'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
     };
     };
     try {
     try {
       await updateAndReloadStrategySettings('local', requestParams);
       await updateAndReloadStrategySettings('local', requestParams);
@@ -754,6 +756,7 @@ module.exports = (crowi) => {
       const localSettingParams = {
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       };
       };
       return res.apiv3({ localSettingParams });
       return res.apiv3({ localSettingParams });
     }
     }

+ 30 - 27
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,5 +1,8 @@
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
@@ -61,7 +64,7 @@ module.exports = (crowi) => {
     ],
     ],
     slackIntegration: [
     slackIntegration: [
       body('currentBotType')
       body('currentBotType')
-        .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
+        .isIn(Object.values(SlackbotType)),
     ],
     ],
     proxyUri: [
     proxyUri: [
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
@@ -102,14 +105,14 @@ module.exports = (crowi) => {
 
 
     const params = {
     const params = {
       'slackbot:currentBotType': initializedType,
       'slackbot:currentBotType': initializedType,
-      'slackbot:signingSecret': null,
-      'slackbot:token': null,
-      'slackbot:proxyServerUri': null,
+      'slackbot:withoutProxy:signingSecret': null,
+      'slackbot:withoutProxy:botToken': null,
+      'slackbot:proxyUri': null,
     };
     };
 
 
     // set url if officialBot is specified
     // set url if officialBot is specified
-    if (initializedType === 'officialBot') {
-      params['slackbot:proxyServerUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
+    if (initializedType === SlackbotType.OFFICIAL) {
+      params['slackbot:proxyUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
     }
     }
 
 
     return updateSlackBotSettings(params);
     return updateSlackBotSettings(params);
@@ -117,7 +120,7 @@ module.exports = (crowi) => {
 
 
   async function getConnectionStatusesFromProxy(tokens) {
   async function getConnectionStatusesFromProxy(tokens) {
     const csv = tokens.join(',');
     const csv = tokens.join(',');
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
 
 
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
       headers: {
@@ -130,7 +133,7 @@ module.exports = (crowi) => {
   }
   }
 
 
   async function requestToProxyServer(token, method, endpoint, body) {
   async function requestToProxyServer(token, method, endpoint, body) {
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
     if (proxyUri == null) {
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
       throw new Error('Proxy URL is not registered');
     }
     }
@@ -173,15 +176,15 @@ module.exports = (crowi) => {
 
 
     // retrieve settings
     // retrieve settings
     const settings = {};
     const settings = {};
-    if (currentBotType === 'customBotWithoutProxy') {
-      settings.slackSigningSecretEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:signingSecret');
-      settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:token');
-      settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
-      settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:token');
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
+      settings.slackSigningSecretEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:signingSecret');
+      settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
+      settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
+      settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
     }
     }
     else {
     else {
-      settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
-      settings.proxyUriEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:proxyServerUri');
+      settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
+      settings.proxyUriEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:proxyUri');
     }
     }
 
 
     // retrieve connection statuses
     // retrieve connection statuses
@@ -191,7 +194,7 @@ module.exports = (crowi) => {
     if (currentBotType == null) {
     if (currentBotType == null) {
       // no need to do anything
       // no need to do anything
     }
     }
-    else if (currentBotType === 'customBotWithoutProxy') {
+    else if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const token = settings.slackBotToken;
       const token = settings.slackBotToken;
       // check the token is not null
       // check the token is not null
       if (token != null) {
       if (token != null) {
@@ -333,23 +336,23 @@ module.exports = (crowi) => {
    */
    */
   router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
   router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
       const msg = 'Not CustomBotWithoutProxy';
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
     }
     }
 
 
     const { slackSigningSecret, slackBotToken } = req.body;
     const { slackSigningSecret, slackBotToken } = req.body;
     const requestParams = {
     const requestParams = {
-      'slackbot:signingSecret': slackSigningSecret,
-      'slackbot:token': slackBotToken,
+      'slackbot:withoutProxy:signingSecret': slackSigningSecret,
+      'slackbot:withoutProxy:botToken': slackBotToken,
     };
     };
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
       crowi.slackIntegrationService.publishUpdatedMessage();
       crowi.slackIntegrationService.publishUpdatedMessage();
 
 
       const customBotWithoutProxySettingParams = {
       const customBotWithoutProxySettingParams = {
-        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
-        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
+        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret'),
+        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken'),
       };
       };
       return res.apiv3({ customBotWithoutProxySettingParams });
       return res.apiv3({ customBotWithoutProxySettingParams });
     }
     }
@@ -438,7 +441,7 @@ module.exports = (crowi) => {
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, csrf, validator.proxyUri, apiV3FormValidator, async(req, res) => {
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, csrf, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;
     const { proxyUri } = req.body;
 
 
-    const requestParams = { 'slackbot:proxyServerUri': proxyUri };
+    const requestParams = { 'slackbot:proxyUri': proxyUri };
 
 
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
@@ -554,7 +557,7 @@ module.exports = (crowi) => {
         { new: true },
         { new: true },
       );
       );
 
 
-      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
       if (proxyUri != null) {
       if (proxyUri != null) {
         await requestToProxyServer(
         await requestToProxyServer(
           slackAppIntegration.tokenGtoP,
           slackAppIntegration.tokenGtoP,
@@ -592,12 +595,12 @@ module.exports = (crowi) => {
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.relationTest, apiV3FormValidator, async(req, res) => {
   router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.relationTest, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not Proxy Type';
       const msg = 'Not Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
     }
     }
 
 
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
     if (proxyUri == null) {
     if (proxyUri == null) {
       return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
       return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
     }
     }
@@ -666,12 +669,12 @@ module.exports = (crowi) => {
    */
    */
   router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
   router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Select Without Proxy Type';
       const msg = 'Select Without Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
       return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
     }
     }
 
 
-    const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
+    const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
     const status = await getConnectionStatus(slackBotToken);
     const status = await getConnectionStatus(slackBotToken);
     if (status.error != null) {
     if (status.error != null) {
       return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
       return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));

+ 1 - 1
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -94,7 +94,7 @@ module.exports = (crowi) => {
   }
   }
 
 
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
+    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
     return next();
     return next();
   };
   };
 
 

+ 21 - 0
packages/app/src/server/routes/forgot-password.js

@@ -0,0 +1,21 @@
+module.exports = function(crowi, app) {
+  const actions = {};
+  const api = {};
+  actions.api = api;
+
+  actions.forgotPassword = async function(req, res) {
+    return res.render('forgot-password');
+  };
+
+  actions.resetPassword = async function(req, res) {
+    const { error, passwordResetOrder } = req;
+
+    if (error != null) {
+      return res.render('forgot-password/error', { key: error.key });
+    }
+
+    return res.render('reset-password', { email: passwordResetOrder.email });
+  };
+
+  return actions;
+};

+ 13 - 0
packages/app/src/server/routes/index.js

@@ -1,5 +1,13 @@
 const multer = require('multer');
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
 const autoReap = require('multer-autoreap');
+const rateLimit = require('express-rate-limit');
+
+const apiLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 5, // limit each IP to 5 requests per windowMs
+  message:
+    'Too many requests sent from this IP, please try again after 15 minutes',
+});
 
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
 
@@ -13,6 +21,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectResetOrderByTokenMiddleware = require('../middlewares/inject-reset-order-by-token-middleware')(crowi);
 
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
   const form = require('../form');
@@ -28,6 +37,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const forgotPassword = require('./forgot-password')(crowi, app);
 
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
 
@@ -175,6 +185,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
 
+  app.get('/forgot-password', forgotPassword.forgotPassword);
+  app.get('/forgot-password/:token'      ,apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword);
+
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);

+ 34 - 14
packages/app/src/server/service/config-loader.ts

@@ -306,6 +306,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     type:    ValueType.BOOLEAN,
     default: false,
     default: false,
   },
   },
+  LOCAL_STRATEGY_PASSWORD_RESET_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-local:isPasswordResetEnabled',
+    type:    ValueType.BOOLEAN,
+    default: true,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
@@ -444,39 +450,53 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_SIGNING_SECRET: {
+  GROWI_APP_ID_FOR_GROWI_CLOUD: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:signingSecret',
+    key:     'app:growiAppIdForCloud',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_BOT_TOKEN: {
+  DEFAULT_EMAIL_PUBLISHED: {
+    ns:      'crowi',
+    key:     'customize:isEmailPublishedForNewUser',
+    type:    ValueType.BOOLEAN,
+    default: true,
+  },
+  SLACKBOT_TYPE: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:token',
+    key:     'slackbot:currentBotType', // enum SlackbotType
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_INTEGRATION_PROXY_URI: {
+  SLACKBOT_INTEGRATION_PROXY_URI: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:proxyServerUri',
+    key:     'slackbot:proxyUri',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_BOT_TYPE: {
+  SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:currentBotType', // 'officialBot' || 'customBotWithoutProxy' || 'customBotWithProxy'
+    key:     'slackbot:withoutProxy:signingSecret',
+    type:    ValueType.STRING,
+    default: null,
   },
   },
-  GROWI_APP_ID_FOR_GROWI_CLOUD: {
+  SLACKBOT_WITHOUT_PROXY_BOT_TOKEN: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'app:growiAppIdForCloud',
+    key:     'slackbot:withoutProxy:botToken',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  DEFAULT_EMAIL_PUBLISHED: {
+  SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'customize:isEmailPublishedForNewUser',
-    type:    ValueType.BOOLEAN,
-    default: true,
+    key:     'slackbot:withProxy:saltForGtoP',
+    type:    ValueType.STRING,
+    default: 'gtop',
+  },
+  SLACKBOT_WITH_PROXY_SALT_FOR_PTOG: {
+    ns:      'crowi',
+    key:     'slackbot:withProxy:saltForPtoG',
+    type:    ValueType.STRING,
+    default: 'ptog',
   },
   },
 };
 };
 
 

+ 8 - 7
packages/app/src/server/service/slack-integration.ts

@@ -2,10 +2,11 @@ import mongoose from 'mongoose';
 
 
 import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { generateWebClient, markdownSectionBlock } from '@growi/slack';
 
 
+import { generateWebClient, markdownSectionBlock, SlackbotType } from '@growi/slack';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 
 
 import ConfigManager from './config-manager';
 import ConfigManager from './config-manager';
@@ -99,22 +100,22 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
   private isCheckTypeValid(): boolean {
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType == null) {
     if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+      throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
     }
     }
 
 
     return true;
     return true;
   }
   }
 
 
   /**
   /**
-   * generate WebClient instance for 'customBotWithoutProxy' type
+   * generate WebClient instance for CUSTOM_WITHOUT_PROXY type
    */
    */
   async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
   async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
     this.isCheckTypeValid();
     this.isCheckTypeValid();
 
 
-    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+    const token = this.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
 
 
     if (token == null) {
     if (token == null) {
-      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:token\') must be set.');
+      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
     }
     }
 
 
     return generateWebClient(token);
     return generateWebClient(token);
@@ -147,7 +148,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
 
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
 
 
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       return this.generateClientForCustomBotWithoutProxy();
       return this.generateClientForCustomBotWithoutProxy();
     }
     }
 
 
@@ -170,7 +171,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     this.isCheckTypeValid();
     this.isCheckTypeValid();
 
 
     // connect to proxy
     // connect to proxy
-    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyUri');
     const serverUri = new URL('/g2s', proxyServerUri);
     const serverUri = new URL('/g2s', proxyServerUri);
     const headers = {
     const headers = {
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,

+ 45 - 0
packages/app/src/server/views/forgot-password.html

@@ -0,0 +1,45 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.forgot_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.forgot_password') }}</h2>
+              <p>{{ t('forgot_password.password_reset_request_desc') }}</p>
+              <div id="password-reset-request-form"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+{% endblock %}

+ 54 - 0
packages/app/src/server/views/forgot-password/error.html

@@ -0,0 +1,54 @@
+{% extends '../layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.reset_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock-open large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+                {% if key === 'password-reset-order-is-not-appropriate' %}
+                <div>
+                  <div class="alert alert-warning mb-3">
+                    <h2>{{ t('forgot_password.incorrect_token_or_expired_url') }}</h2>
+                  </div>
+                  <a href="/forgot-password" class="link-switch">
+                    <i class="icon-key"></i> {{ t('forgot_password.forgot_password') }}
+                  </a>
+                </div>
+                {% endif %}
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+{% endblock %}

+ 2 - 0
packages/app/src/server/views/login.html

@@ -109,6 +109,7 @@
 
 
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
+      {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
 
 
       <div
       <div
         id="login-form"
         id="login-form"
@@ -119,6 +120,7 @@
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
+        data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"

+ 7 - 0
packages/app/src/server/views/login/error.html

@@ -37,6 +37,13 @@
         <div class="alert alert-success">
         <div class="alert alert-success">
           <h2>{{ t('login.Registration successful') }}</h2>
           <h2>{{ t('login.Registration successful') }}</h2>
         </div>
         </div>
+        {% elseif reason === 'password-reset-order' %}
+        <div class="alert alert-warning mb-3">
+          <h2>{{ t('forgot_password.incorrect_token_or_expired_url') }}</h2>
+        </div>
+          <a href="/forgot-password" class="link-switch">
+            <i class="icon-key"></i> {{ t('forgot_password.forgot_password') }}
+          </a>
         {% else %}
         {% else %}
         <div class="alert alert-warning">
         <div class="alert alert-warning">
             <h2>{{ t('login.Sign in error') }}</h2>
             <h2>{{ t('login.Sign in error') }}</h2>

+ 48 - 0
packages/app/src/server/views/reset-password.html

@@ -0,0 +1,48 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.reset_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock-open large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+              <h5>{{ email }}</h5>
+              <p class="mt-4">{{ t('forgot_password.password_reset_excecution_desc') }}</p>
+              <div id="password-reset-execution-form"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+    </div>
+  </div>
+
+{% endblock %}

+ 1 - 0
packages/slack/src/index.ts

@@ -25,6 +25,7 @@ export const defaultSupportedCommandsNameForSingleUse: string[] = [
 export * from './interfaces/growi-command';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/request-from-slack';
+export * from './interfaces/slackbot-types';
 export * from './models/errors';
 export * from './models/errors';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './middlewares/verify-slack-request';

+ 5 - 0
packages/slack/src/interfaces/slackbot-types.ts

@@ -0,0 +1,5 @@
+export enum SlackbotType {
+  OFFICIAL = 'officialBot',
+  CUSTOM_WITHOUT_PROXY = 'customBotWithoutProxy',
+  CUSTOM_WITH_PROXY = 'customBotWithProxy',
+}

+ 1 - 1
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "1.0.2",
+  "version": "1.0.3",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",

+ 13 - 9
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,5 +1,5 @@
 import {
 import {
-  BodyParams, Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
+  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
 } from '@tsed/common';
 } from '@tsed/common';
 
 
 import axios from 'axios';
 import axios from 'axios';
@@ -19,7 +19,10 @@ import { InstallationRepository } from '~/repositories/installation';
 import { RelationRepository } from '~/repositories/relation';
 import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
 import { OrderRepository } from '~/repositories/order';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
-import { AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware } from '~/middlewares/slack-to-growi/authorizer';
+import {
+  AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware, AuthorizeEventsMiddleware,
+} from '~/middlewares/slack-to-growi/authorizer';
+import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
@@ -331,14 +334,15 @@ export class SlackCtrl {
   }
   }
 
 
   @Post('/events')
   @Post('/events')
-  async handleEvent(@BodyParams() body:{[key:string]:string} /* , @Res() res: Res */): Promise<void|string> {
-    // eslint-disable-next-line max-len
-    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
-    if (body.type === 'url_verification') {
-      return body.challenge;
-    }
+  @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
+  async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
 
 
-    logger.info('receive event', body);
+    const { authorizeResult } = req;
+    const client = generateWebClient(authorizeResult.botToken);
+
+    if (req.body.event.type === 'app_home_opened') {
+      await postWelcomeMessage(client, req.body.event.channel);
+    }
 
 
     return;
     return;
   }
   }

+ 75 - 73
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -6,56 +6,29 @@ import {
 import Logger from 'bunyan';
 import Logger from 'bunyan';
 
 
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { InstallationRepository } from '~/repositories/installation';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-@Middleware()
-export class AuthorizeCommandMiddleware implements IMiddleware {
-
-  @Inject()
-  installerService: InstallerService;
-
-  @Inject()
-  installationRepository: InstallationRepository;
-
-  private logger: Logger;
 
 
-  constructor() {
-    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeCommandMiddleware');
-  }
+const getCommonMiddleware = (query:InstallationQuery<boolean>, installerService:InstallerService, logger:Logger) => {
+  return async(req: SlackOauthReq, res: Res): Promise<void|Res> => {
 
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
-    const { body } = req;
-
-    // extract id from body
-    const teamId = body.team_id;
-    const enterpriseId = body.enterprise_id;
-    const isEnterpriseInstall = body.is_enterprise_install === 'true';
-
-    if (teamId == null && enterpriseId == null) {
+    if (query.teamId == null && query.enterpriseId == null) {
       res.writeHead(400, 'No installation found');
       res.writeHead(400, 'No installation found');
       return res.end();
       return res.end();
     }
     }
 
 
-    // create query from body
-    const query: InstallationQuery<boolean> = {
-      teamId,
-      enterpriseId,
-      isEnterpriseInstall,
-    };
-
     let result: AuthorizeResult;
     let result: AuthorizeResult;
     try {
     try {
-      result = await this.installerService.installer.authorize(query);
+      result = await installerService.installer.authorize(query);
 
 
       if (result.botToken == null) {
       if (result.botToken == null) {
-        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
+        res.writeHead(403, `The installation for the team(${query.teamId || query.enterpriseId}) has no botToken`);
         return res.end();
         return res.end();
       }
       }
     }
     }
     catch (e) {
     catch (e) {
-      this.logger.error(e.message);
+      logger.error(e.message);
 
 
       res.writeHead(500, e.message);
       res.writeHead(500, e.message);
       return res.end();
       return res.end();
@@ -63,19 +36,39 @@ export class AuthorizeCommandMiddleware implements IMiddleware {
 
 
     // set authorized data
     // set authorized data
     req.authorizeResult = result;
     req.authorizeResult = result;
-  }
-
-}
+  };
+};
+@Middleware()
+export class AuthorizeCommandMiddleware implements IMiddleware {
 
 
+  private logger: Logger;
 
 
-@Middleware()
-export class AuthorizeInteractionMiddleware implements IMiddleware {
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeCommandMiddleware');
+  }
 
 
   @Inject()
   @Inject()
   installerService: InstallerService;
   installerService: InstallerService;
 
 
-  @Inject()
-  installationRepository: InstallationRepository;
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+    const { body } = req;
+    const teamId = body.team_id;
+    const enterpriseId = body.enterprise_id;
+    const isEnterpriseInstall = body.is_enterprise_install === 'true';
+    const query: InstallationQuery<boolean> = {
+      teamId,
+      enterpriseId,
+      isEnterpriseInstall,
+    };
+
+    const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+    await commonMiddleware(req, res);
+  }
+
+}
+
+@Middleware()
+export class AuthorizeInteractionMiddleware implements IMiddleware {
 
 
   private logger: Logger;
   private logger: Logger;
 
 
@@ -83,52 +76,61 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
     this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeInteractionMiddleware');
     this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeInteractionMiddleware');
   }
   }
 
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
-    const { body } = req;
+    @Inject()
+    installerService: InstallerService;
+
+    async use(@Req() req: SlackOauthReq, @Res() res:Res): Promise<void|Res> {
+      const { body } = req;
+
+      const payload = JSON.parse(body.payload);
+
+      // extract id from body.payload
+      const teamId = payload.team?.id;
+      const enterpriseId = payload.enterprise?.id;
+      const isEnterpriseInstall = payload.is_enterprise_install === 'true';
 
 
-    if (body.payload == null) {
+      if (body.payload == null) {
       // do nothing
       // do nothing
-      this.logger.info('body does not have payload');
-      return;
+        this.logger.info('body does not have payload');
+        return;
+      }
+
+      const query: InstallationQuery<boolean> = {
+        teamId,
+        enterpriseId,
+        isEnterpriseInstall,
+      };
+
+      const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+      await commonMiddleware(req, res);
     }
     }
 
 
-    const payload = JSON.parse(body.payload);
+}
+@Middleware()
+export class AuthorizeEventsMiddleware implements IMiddleware {
 
 
-    // extract id from body
-    const teamId = payload.team?.id;
-    const enterpriseId = payload.enterprise?.id;
-    const isEnterpriseInstall = payload.is_enterprise_install === 'true';
+  private logger: Logger;
 
 
-    if (teamId == null && enterpriseId == null) {
-      res.writeHead(400, 'No installation found');
-      return res.end();
-    }
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeEventsMiddleware');
+  }
 
 
-    // create query from body
+  @Inject()
+  installerService: InstallerService;
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+    const { body } = req;
+    const teamId = body.team_id;
+    const enterpriseId = body.enterprise_id;
+    const isEnterpriseInstall = body.is_enterprise_install === 'true';
     const query: InstallationQuery<boolean> = {
     const query: InstallationQuery<boolean> = {
       teamId,
       teamId,
       enterpriseId,
       enterpriseId,
       isEnterpriseInstall,
       isEnterpriseInstall,
     };
     };
 
 
-    let result: AuthorizeResult;
-    try {
-      result = await this.installerService.installer.authorize(query);
-
-      if (result.botToken == null) {
-        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
-        return res.end();
-      }
-    }
-    catch (e) {
-      this.logger.error(e.message);
-
-      res.writeHead(500, e.message);
-      return res.end();
-    }
-
-    // set authorized data
-    req.authorizeResult = result;
+    const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+    await commonMiddleware(req, res);
   }
   }
 
 
 }
 }

+ 20 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts

@@ -0,0 +1,20 @@
+import {
+  IMiddleware, Middleware, Req, Res,
+} from '@tsed/common';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+
+
+@Middleware()
+export class UrlVerificationMiddleware implements IMiddleware {
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
+
+    // eslint-disable-next-line max-len
+    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+    if (req.body.type === 'url_verification') {
+      res.send(req.body.challenge);
+      return;
+    }
+  }
+
+}

+ 5 - 0
yarn.lock

@@ -8218,6 +8218,11 @@ express-mongo-sanitize@^2.1.0:
   resolved "https://registry.yarnpkg.com/express-mongo-sanitize/-/express-mongo-sanitize-2.1.0.tgz#a8c647787c25ded6e97b5e864d113e7687c5d471"
   resolved "https://registry.yarnpkg.com/express-mongo-sanitize/-/express-mongo-sanitize-2.1.0.tgz#a8c647787c25ded6e97b5e864d113e7687c5d471"
   integrity sha512-ELGeH/Tx+kJGn3klCzSmOewfN1ezJQrkqzq83dl/K3xhd5PUbvLtiD5CiuYRmQfoZPL4rUEVjANf/YjE2BpTWQ==
   integrity sha512-ELGeH/Tx+kJGn3klCzSmOewfN1ezJQrkqzq83dl/K3xhd5PUbvLtiD5CiuYRmQfoZPL4rUEVjANf/YjE2BpTWQ==
 
 
+express-rate-limit@^5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.3.0.tgz#e7b9d3c2e09ece6e0406a869b2ce00d03fe48aea"
+  integrity sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew==
+
 express-session@^1.16.1:
 express-session@^1.16.1:
   version "1.16.1"
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"