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

Merge branch 'feat/password-rsettings-by-users' into imprv/gw6805-be-able-to-switch-pasword-reset-func

# Conflicts:
#	src/client/js/components/LoginForm.jsx
kaori 4 лет назад
Родитель
Сommit
f3ac23f232

+ 13 - 1
resource/locales/en_US/translation.json

@@ -41,7 +41,6 @@
   "Update Page": "Update Page",
   "Warning": "Warning",
   "Sign in": "Sign in",
-  "forgot_password": "Forgot password?",
   "Sign up is here": "Sign up",
   "Sign in is here": "Sign in",
   "Sign up": "Sign up",
@@ -844,5 +843,18 @@
     "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"
   }
 }

+ 13 - 1
resource/locales/ja_JP/translation.json

@@ -42,7 +42,6 @@
   "Update Page": "ページを更新",
   "Warning": "注意",
   "Sign in": "ログイン",
-  "forgot_password": "パスワードをお忘れですか?",
   "Sign up is here": "新規登録はこちら",
   "Sign in is here": "ログインはこちら",
   "Sign up": "新規登録",
@@ -838,5 +837,18 @@
     "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": "メールを送信しました"
   }
 }

+ 13 - 1
resource/locales/zh_CN/translation.json

@@ -43,7 +43,6 @@
 	"Update Page": "更新本页",
 	"Warning": "警告",
   "Sign in": "登录",
-  "forgot_password": "忘记密码?",
 	"Sign up is here": "注册",
 	"Sign in is here": "登录",
 	"Sign up": "注册",
@@ -849,5 +848,18 @@
     "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": "我发了一封电子邮件"
   }
 }

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

@@ -275,7 +275,7 @@ class LoginForm extends React.Component {
                   <div className="col-12 text-right py-2">
                     {this.state.isPasswordResetEnable && (
                       <a href="/forgot-password" className="d-block link-switch mb-1">
-                        <i className="icon-key"></i> {t('forgot_password')}
+                        <i className="icon-key"></i> {t('forgot_password.forgot_password')}
                       </a>
                     )}
                     <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>

+ 19 - 6
src/client/js/components/PasswordResetExecutionForm.jsx

@@ -4,14 +4,27 @@ import { withTranslation } from 'react-i18next';
 
 
 const PasswordResetExecutionForm = (props) => {
-  // TODO: apply i18n by GW-6861
-  // const { t } = props;
+  const { t } = props;
 
   return (
-    // TODO: improve the form by GW-6852
-    <div>
-      PasswordResetExecutionForm
-    </div>
+    <form role="form" className="form" method="post">
+      <div className="form-group">
+        <div className="input-group">
+          <input name="password" placeholder={t('forgot_password.new_password')} className="form-control" type="password" />
+        </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" />
+        </div>
+      </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>
   );
 };
 

+ 50 - 30
src/client/js/components/PasswordResetRequestForm.jsx

@@ -1,46 +1,66 @@
-import React from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+import AppContainer from '../services/AppContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const PasswordResetRequestForm = (props) => {
-  // TODO: apply i18n by GW-6861
-  // const { t } = props;
+  const { t, appContainer } = props;
+  const [email, setEmail] = useState();
 
-  return (
-    <>
-      <div className="container">
-        <div className="row justify-content-md-center">
-          <div className="col-md-6 mt-5">
-            <div className="text-center">
-              <h1><i className="icon-lock large"></i></h1>
-              <h2 className="text-center">Forgot Password?</h2>
-              <p>You can reset your password here.</p>
-              <form role="form" className="form" method="post">
-                <div className="form-group">
-                  <div className="input-group">
-                    <input name="email" placeholder="email address" className="form-control" type="email" />
-                  </div>
-                </div>
-                <div className="form-group">
-                  <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value="Reset Password" type="submit" />
-                </div>
-                <a href="/login">
-                  <i className="icon-login mr-1"></i>Return to login
-                </a>
-              </form>
-            </div>
-          </div>
+  const changeEmail = (inputValue) => {
+    setEmail(inputValue);
+  };
+
+  const sendPasswordResetRequestMail = async(e) => {
+    e.preventDefault();
+    if (email == null) {
+      toastError('err', t('forgot_password.email_is_required'));
+      return;
+    }
+
+    try {
+      await appContainer.apiPost('/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()(PasswordResetRequestForm);
+export default withTranslation()(PasswordResetRequestFormWrapper);

+ 5 - 1
src/client/js/nologin.jsx

@@ -82,11 +82,15 @@ if (loginFormElem) {
 
 // render PasswordResetRequestForm
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
+const appContainer = new AppContainer();
+appContainer.initApp();
 if (passwordResetRequestFormElem) {
 
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <PasswordResetRequestForm />
+      <Provider inject={[appContainer]}>
+        <PasswordResetRequestForm />
+      </Provider>
     </I18nextProvider>,
     passwordResetRequestFormElem,
   );

+ 19 - 0
src/server/middlewares/password-reset.js

@@ -0,0 +1,19 @@
+module.exports = (crowi, app) => {
+  const PasswordResetOrder = crowi.model('PasswordResetOrder');
+
+  return async(req, res, next) => {
+    const { token } = req.params;
+
+    if (token == null) {
+      return res.redirect('/login');
+    }
+
+    const passwordResetOrder = await PasswordResetOrder.findOne({ token });
+    // check the oneTimeToken is valid
+    if (passwordResetOrder == null || passwordResetOrder.isExpired()) {
+      return res.redirect('/login');
+    }
+
+    return next();
+  };
+};

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

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

+ 23 - 4
src/server/models/password-reset-order.js

@@ -1,5 +1,6 @@
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
+const crypto = require('crypto');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -8,18 +9,36 @@ const schema = new mongoose.Schema({
   email: { type: String, required: true },
   relatedUser: { type: ObjectId, ref: 'User' },
   createdAt: { type: Date, default: Date.now, required: true },
-  expiredAt: { type: Date, default: Date.now + 600000, required: true },
+  expiredAt: { type: Date, default: Date.now() + 600000, required: true },
 });
 schema.plugin(uniqueValidator);
 
 class PasswordResetOrder {
 
   static generateOneTimeToken() {
-    // TODO: generate unique token by GW-6802
+    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;
   }
 
-  static isExpired() {
-    return this.expiredAt.getTime() < new Date().getTime();
+  isExpired() {
+    return this.expiredAt.getTime() < Date.now();
   }
 
 }

+ 31 - 16
src/server/routes/forgot-password.js

@@ -1,5 +1,9 @@
+const logger = require('@alias/logger')('growi:routes:forgot-password');
+const ApiResponse = require('../util/apiResponse');
+
 module.exports = function(crowi, app) {
-  const { /* appService, */ mailService } = crowi;
+  const PasswordResetOrder = crowi.model('PasswordResetOrder');
+  const { appService, mailService, configManager } = crowi;
   const path = require('path');
   const actions = {};
   const api = {};
@@ -13,26 +17,37 @@ module.exports = function(crowi, app) {
     return res.render('reset-password');
   };
 
-
-  async function sendPasswordResetEmail() {
-
+  async function sendPasswordResetEmail(email, url, i18n) {
     return mailService.send({
-      to: 'hoge@gmail.com',
-      subject: 'forgotPasswordMailTest',
-      // TODO: apply i18n by GW-6833
-      template: path.join(crowi.localeDir, 'en_US/notifications/passwordReset.txt'),
-      // TODO: need to set appropriate values by GW-6828
-      // vars: {
-      //   appTitle: appService.getAppTitle(),
-      //   email: 'hoge@gmail.com',
-      //   url: 'https://www.google.com/',
-      // },
+      to: email,
+      subject: 'Password Reset',
+      template: path.join(crowi.localeDir, `${i18n}/notifications/passwordReset.txt`),
+      vars: {
+        appTitle: appService.getAppTitle(),
+        email,
+        url,
+      },
     });
   }
 
   api.post = async function(req, res) {
-    await sendPasswordResetEmail();
-    return;
+    const { email } = req.body;
+    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+    const i18n = req.language || grobalLang;
+    const appUrl = appService.getSiteUrl();
+
+    try {
+      const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
+      const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
+      const oneTimeUrl = url.href;
+      await sendPasswordResetEmail(email, oneTimeUrl, i18n);
+      return res.json(ApiResponse.success());
+    }
+    catch (err) {
+      const msg = 'Error occurred during password reset request procedure';
+      logger.error(err);
+      return res.json(ApiResponse.error(msg));
+    }
   };
 
 

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

@@ -13,6 +13,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 passwordReset = require('../middlewares/password-reset')(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
@@ -177,9 +178,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.get('/forgot-password', forgotPassword.forgotPassword);
-  app.post('/forgot-password', forgotPassword.api.post);
-  // TODO: apply oneTimeToken to the link by GW−6856
-  app.get('/forgot-password/hogeToken', forgotPassword.resetPassword);
+  app.post('/_api/forgot-password', forgotPassword.api.post);
+  app.get('/forgot-password/:token'      , passwordReset, forgotPassword.resetPassword);
 
   app.get('/share/:linkId', page.showSharedPage);
 

+ 12 - 1
src/server/views/forgot-password.html

@@ -27,7 +27,18 @@
 
   <div id="main" class="main">
     <div id="content-main" class="content-main container-lg">
-      <div id="password-reset-request-form"></div>
+      <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>
 

+ 14 - 1
src/server/views/reset-password.html

@@ -27,7 +27,20 @@
 
   <div id="main" class="main">
     <div id="content-main" class="content-main container-lg">
-      <div id="password-reset-execution-form"></div>
+      <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>
+              <p>{{ t('forgot_password.password_reset_excecution_desc') }}</p>
+              <div id="password-reset-execution-form"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
     </div>
   </div>