Explorar el Código

Merge pull request #4135 from weseek/feat/password-rsettings-by-users

Feat/password rsettings by users
Yuki Takei hace 4 años
padre
commit
983a578728
Se han modificado 34 ficheros con 789 adiciones y 7 borrados
  1. 2 1
      packages/app/package.json
  2. 8 0
      packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt
  3. 13 0
      packages/app/resource/locales/en_US/notifications/notActiveUser.txt
  4. 10 0
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  5. 19 1
      packages/app/resource/locales/en_US/translation.json
  6. 13 0
      packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt
  7. 10 0
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  8. 6 0
      packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt
  9. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  10. 6 0
      packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt
  11. 13 0
      packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt
  12. 10 0
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  13. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  14. 34 0
      packages/app/src/client/nologin.jsx
  15. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  16. 22 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  17. 7 0
      packages/app/src/components/LoginForm.jsx
  18. 96 0
      packages/app/src/components/PasswordResetExecutionForm.jsx
  19. 66 0
      packages/app/src/components/PasswordResetRequestForm.jsx
  20. 24 0
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.js
  21. 1 0
      packages/app/src/server/models/index.js
  22. 57 0
      packages/app/src/server/models/password-reset-order.js
  23. 115 0
      packages/app/src/server/routes/apiv3/forgot-password.js
  24. 2 0
      packages/app/src/server/routes/apiv3/index.js
  25. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  26. 21 0
      packages/app/src/server/routes/forgot-password.js
  27. 13 0
      packages/app/src/server/routes/index.js
  28. 6 0
      packages/app/src/server/service/config-loader.ts
  29. 45 0
      packages/app/src/server/views/forgot-password.html
  30. 54 0
      packages/app/src/server/views/forgot-password/error.html
  31. 2 0
      packages/app/src/server/views/login.html
  32. 7 0
      packages/app/src/server/views/login/error.html
  33. 48 0
      packages/app/src/server/views/reset-password.html
  34. 5 0
      yarn.lock

+ 2 - 1
packages/app/package.json

@@ -56,8 +56,8 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
     "@growi/plugin-attachment-refs": "^4.3.3-RC",
-    "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
     "@growi/plugin-lsx": "^4.3.3-RC",
+    "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
@@ -91,6 +91,7 @@
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
+    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "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": {
       "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> .",
-      "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": {
       "enable_ldap": "Enable LDAP",
@@ -845,5 +848,20 @@
     "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.",
     "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": {
       "name": "ID/Password",
       "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": {
       "enable_ldap": "LDAP を有効にする",
@@ -839,5 +842,20 @@
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "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 Page": "更新本页",
 	"Warning": "警告",
-	"Sign in": "登录",
+  "Sign in": "登录",
 	"Sign up is here": "注册",
 	"Sign in is here": "登录",
 	"Sign up": "注册",
@@ -592,7 +592,10 @@
 		"Local": {
 			"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> .",
-			"enable_local": "Enable ID/Password"
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "用户重置密码",
+      "enable_password_reset_by_users": "启用用户重置密码",
+      "password_reset_desc": "忘记密码时,用户可以自行重置"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
@@ -850,5 +853,20 @@
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "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 LoginForm from '../components/LoginForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 
 const i18n = i18nFactory();
 
@@ -38,6 +40,7 @@ if (loginFormElem) {
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
+  const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
@@ -68,6 +71,7 @@ if (loginFormElem) {
           isRegistrationEnabled={isRegistrationEnabled}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
+          isPasswordResetEnabled={isPasswordResetEnabled}
           isLocalStrategySetup={isLocalStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
@@ -77,3 +81,33 @@ if (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,
       registrationWhiteList: [],
       useOnlyEnvVars: false,
+      isPasswordResetEnabled: false,
     };
 
   }
@@ -34,6 +35,7 @@ export default class AdminLocalSecurityContainer extends Container {
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
+        isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
       });
     }
     catch (err) {
@@ -66,14 +68,22 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ registrationWhiteList: value.split('\n') });
   }
 
+  /**
+   * Switch password reset enabled
+   */
+  switchIsPasswordResetEnabled() {
+    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+  }
+
   /**
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
+      isPasswordResetEnabled,
     });
 
     const { localSettingParams } = response.data;
@@ -81,6 +91,7 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
+      isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
     });
 
     return localSettingParams;

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

@@ -32,7 +32,7 @@ class LocalSecuritySettingContents extends React.Component {
 
   render() {
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
 
     return (
@@ -157,6 +157,27 @@ class LocalSecuritySettingContents extends React.Component {
               </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="offset-3 col-6">
                 <button

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

@@ -251,6 +251,7 @@ class LoginForm extends React.Component {
       isLocalStrategySetup,
       isLdapStrategySetup,
       isRegistrationEnabled,
+      isPasswordResetEnabled,
       objOfIsExternalAuthEnableds,
     } = this.props;
 
@@ -268,6 +269,11 @@ class LoginForm extends React.Component {
                 {isRegistrationEnabled && (
                   <div className="row">
                     <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}>
                         <i className="ti-check-box"></i> {t('Sign up is here')}
                       </a>
@@ -307,6 +313,7 @@ LoginForm.propTypes = {
   isRegistrationEnabled: PropTypes.bool,
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
+  isPasswordResetEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   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);

+ 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'),
   ShareLink: require('./share-link'),
   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;
+};

+ 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('/staffs', require('./staffs')(crowi));
 
+  router.use('/forgot-password', require('./forgot-password')(crowi));
+
   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'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       },
       generalAuth: {
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
@@ -747,6 +748,7 @@ module.exports = (crowi) => {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
+      'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
     };
     try {
       await updateAndReloadStrategySettings('local', requestParams);
@@ -754,6 +756,7 @@ module.exports = (crowi) => {
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       };
       return res.apiv3({ localSettingParams });
     }

+ 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 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
 
@@ -13,6 +21,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(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 form = require('../form');
@@ -28,6 +37,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const forgotPassword = require('./forgot-password')(crowi, app);
 
   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.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('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -306,6 +306,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     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: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 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 isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
+      {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
 
       <div
         id="login-form"
@@ -119,6 +120,7 @@
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
+        data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         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">
           <h2>{{ t('login.Registration successful') }}</h2>
         </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 %}
         <div class="alert alert-warning">
             <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 %}

+ 5 - 0
yarn.lock

@@ -7966,6 +7966,11 @@ express-mongo-sanitize@^2.1.0:
   resolved "https://registry.yarnpkg.com/express-mongo-sanitize/-/express-mongo-sanitize-2.1.0.tgz#a8c647787c25ded6e97b5e864d113e7687c5d471"
   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:
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"