diff --git a/BackEnd/package-lock.json b/BackEnd/package-lock.json index ea6a2b0..ed4e7e9 100644 --- a/BackEnd/package-lock.json +++ b/BackEnd/package-lock.json @@ -21,11 +21,13 @@ "express-validator": "^7.0.1", "helmet": "^7.0.0", "jsonwebtoken": "^9.0.1", + "memory-cache": "^0.2.0", "method-override": "^3.0.0", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "multer-s3": "^2.10.0", "mysql2": "^2.3.3", + "nodemailer": "^6.9.4", "sequelize": "^6.32.1", "sequelize-cli": "^6.4.1", "winston": "^3.8.2", @@ -5774,6 +5776,11 @@ "timers-ext": "^0.1.7" } }, + "node_modules/memory-cache": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", + "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -6368,6 +6375,14 @@ "integrity": "sha512-+M0PwXeU80kRohZ3aT4J/OnR+l9/KD2nVLNNoRgFtnf+umQVFdGBAO2N8+nCnEi0xlh/Wk3zOGC+vNNx+uM79Q==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz", + "integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -12989,6 +13004,11 @@ "timers-ext": "^0.1.7" } }, + "memory-cache": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", + "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -13459,6 +13479,11 @@ "integrity": "sha512-+M0PwXeU80kRohZ3aT4J/OnR+l9/KD2nVLNNoRgFtnf+umQVFdGBAO2N8+nCnEi0xlh/Wk3zOGC+vNNx+uM79Q==", "dev": true }, + "nodemailer": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz", + "integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==" + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/BackEnd/package.json b/BackEnd/package.json index 56cc8c5..4bfe775 100644 --- a/BackEnd/package.json +++ b/BackEnd/package.json @@ -24,11 +24,13 @@ "express-validator": "^7.0.1", "helmet": "^7.0.0", "jsonwebtoken": "^9.0.1", + "memory-cache": "^0.2.0", "method-override": "^3.0.0", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "multer-s3": "^2.10.0", "mysql2": "^2.3.3", + "nodemailer": "^6.9.4", "sequelize": "^6.32.1", "sequelize-cli": "^6.4.1", "winston": "^3.8.2", diff --git a/BackEnd/src/controllers/user.js b/BackEnd/src/controllers/user.js index d7db2c6..921c39f 100644 --- a/BackEnd/src/controllers/user.js +++ b/BackEnd/src/controllers/user.js @@ -5,6 +5,7 @@ const user = require('../services/user'); const { success, fail } = require('../functions/responseStatus'); const { startDate, endDate, todayDate, firstDay } = require('../functions/common'); +const { verifycodeMail } = require('../functions/nodemail'); /** * 제공된 이메일과 비밀번호로 로그인을 시도하고, 성공하면 토큰을 발급한다. @@ -143,7 +144,7 @@ const updateProfile = async (req, res) => { /** * 사용자의 id로 오늘 출석 했는지를 조회합니다. - * @param {number} id + * @param {number} id * @returns {object} { code: number, message: string } * 출석했다면 409을 반환 * 출석하지 않았다면 출석 체크를 하고 200반환 @@ -190,15 +191,53 @@ const getAttendance = async (req, res) => { }; /** - * 현재 비밀번호, 새 비밀번호, 비밀번호 확인 입력받아 비밀번호 변경 - * @param {string} confirm_password 사용자가 입력한 기존 비밀번호 + * 비밀번호 재확인 + * @param {string} confirm_password 기존 비밀번호 + * + */ +const checkPassword = async (req, res) => { + let { confirm_password } = req.body; + let user_id = req.decoded.id; + try { + //비밀번호 확인 + let result = await user.comparePassword(confirm_password, user_id); + if (result) { + //일회성 토큰 발급 + await user.issueOneTimeToken(result).then((data) => { + let token = data; + return success(res, 200, 'Authorize success.', token); + }); + } + } catch (err) { + let code; + switch (err.message) { + case 'Incorrect password.': + code = 401; + break; + case 'Can not find profile.': + code = 404; + break; + default: + code = 500; + break; + } + return fail(res, code, err.message); + } +}; + +/** + * 새 비밀번호 입력받아 비밀번호 변경 * @param {string} new_password 새 비밀번호 */ const editPassword = async (req, res) => { - let { confirm_password, new_password } = req.body; + let { new_password } = req.body; let user_id = req.decoded.id; + //console.log(req.headers.authorization); try { - let result = await user.updatePassword(user_id, confirm_password, new_password); + if (req.decoded.type !== 'OneTimeJWT') { + throw new Error('token is invalid.'); + } + let result = await user.updatePassword(user_id, new_password); if (result.message === 'Password changed.') { let data = result.user; return success(res, 200, result.message, data); @@ -208,11 +247,76 @@ const editPassword = async (req, res) => { } catch (err) { let code; switch (err.message) { + case 'token is invalid.': + code = 403; // 403일 경우 위치 변경 + break; case 'Can not find profile.': code = 404; break; - case 'Incorrect password.': - code = 401; + default: + code = 500; + break; + } + return fail(res, code, err.message); + } +}; + +/** + * email을 받아 확인후 인증번호 메일전송 + * @param {string} email 사용자가 입력한 기존 비밀번호 + */ +const sendVerifyEmail = async (req, res) => { + let { email } = req.body; + try { + let result = await user.findUser('email', email, 0); + if (result) { + // 인증번호, 캐시저장 + let verifycode = await user.verifycode(email); + console.log(verifycode); // 임시 + //인증번호 전송 메일 (비동기) + verifycodeMail(email, verifycode); + return success(res, 200); + } else { + throw new Error('Services error.'); + } + } catch (err) { + let code; + switch (err.message) { + case 'Can not find profile.': + code = 404; + break; + default: + code = 500; + break; + } + return fail(res, code, err.message); + } +}; + +/** + * 이메일, 인증번호 입력받아 확인 + * @param {string} email 사용자가 받는데 사용한 이메일 + * @param {number} verifycode 입력한 인증번호 + */ +const checkVerifyCode = async (req, res) => { + let { email, verifycode } = req.body; + try { + //인증번호 확인 + let result = user.checkCode(email, verifycode); + //새비밀번호 페이지로 넘기기 + if (result) { + //일회성 토큰 발급 + await user.issueOneTimeToken(email).then((data) => { + let token = data; + return success(res, 200, 'Authorize success.', token); + }); + } + } catch (err) { + let code; + switch (err.message) { + case "Code dosesn't match.": //401이나 403? + case 'Verifycode expired.': + code = 409; break; default: code = 500; @@ -256,11 +360,25 @@ const viewAttend = (req, res) => { }); }; +/** + * 비밀번호 확인페이지를 렌더링한다. + */ +const viewVerifyPassword = (req, res) => { + res.render('user/verifyPassword'); +}; + /** * 비밀번호 변경페이지를 렌더링한다. */ const viewChangePassword = (req, res) => { - res.render('user/password'); + res.render('user/newPassword'); +}; + +/** + * 메일 인증페이지를 렌더링한다. + */ +const viewverifyEmail = (req, res) => { + res.render('user/verifyEmail'); }; module.exports = { @@ -270,10 +388,15 @@ module.exports = { updateProfile, postAttendance, getAttendance, + checkPassword, editPassword, + sendVerifyEmail, + checkVerifyCode, viewLogin, viewRegister, viewProfile, viewAttend, + viewVerifyPassword, viewChangePassword, + viewverifyEmail, }; diff --git a/BackEnd/src/functions/nodemail.js b/BackEnd/src/functions/nodemail.js new file mode 100644 index 0000000..6cd0bb9 --- /dev/null +++ b/BackEnd/src/functions/nodemail.js @@ -0,0 +1,31 @@ +'use strict'; + +const nodemailer = require('nodemailer'); +const config = require('../../config/default.json'); +const logger = require('./winston'); + +const transporter = nodemailer.createTransport({ + service: 'gmail', + port: 587, // 보안없는경우 587, 있는경우 465로 설정. 기본은 587 + auth: { + user: config.mail_info.user, + pass: config.mail_info.pass, + }, +}); + +exports.verifycodeMail = async (email, code) => { + let mailOptions = { + from: config.mail_info.user, + to: email, + subject: 'Verifying Code by CSW_BOARD', + text: `Your Verifycode is ${code}.`, + }; + let send = await transporter.sendMail(mailOptions); + if (send) { + logger.info(`'send verifycode to ${email}'`); + //console.log('send OK'); + return 'Mail send success.'; + } else { + throw new Error('Mail send fail.'); + } +}; diff --git a/BackEnd/src/functions/signJWT.js b/BackEnd/src/functions/signJWT.js index ce184fd..d76ef5e 100644 --- a/BackEnd/src/functions/signJWT.js +++ b/BackEnd/src/functions/signJWT.js @@ -21,6 +21,13 @@ const refreshToken = (payload) => { }); }; +const oneTimeToken = (payload) => { + return jwt.sign(payload, access_secret_key, { + expiresIn: '5m', + issuer: config.get('JWT.issuer'), + }); +}; + const issuanceToken = (req, res) => { return jwt.verify(req.headers.authorization, refresh_secret_key, (err, decoded) => { if (err) { @@ -41,5 +48,6 @@ const issuanceToken = (req, res) => { module.exports = { accessToken, refreshToken, + oneTimeToken, issuanceToken, }; diff --git a/BackEnd/src/routes/user.js b/BackEnd/src/routes/user.js index 09cd558..794b8bd 100644 --- a/BackEnd/src/routes/user.js +++ b/BackEnd/src/routes/user.js @@ -62,11 +62,38 @@ router.patch( router.post('/attendance', auth, ctrl.postAttendance); router.get('/attendance', auth, ctrl.getAttendance); +router.post( + '/verifyPassword', + auth, + [ + check('confirm_password', 'Password must be shorter than 101 characters.').isLength({ max: 100 }), + validator, + ], + ctrl.checkPassword); + +router.post('/sendEmail', [ + check('email') + .isEmail() + .withMessage('Email must be in the correct format.') + .isLength({ max: 30 }) + .withMessage('Email must be shorter than 31 characters.'), + validator, +], ctrl.sendVerifyEmail); + +router.post('/verifyEmail', [ + check('email') + .isEmail() + .withMessage('Email must be in the correct format.') + .isLength({ max: 30 }) + .withMessage('Email must be shorter than 31 characters.'), + check('verifycode', 'Please input code.').notEmpty(), + validator, +], ctrl.checkVerifyCode); + router.patch( - '/profile/password', + '/newPassword', auth, [ - check('confirm_password', 'Please input password.').notEmpty(), check('new_password', 'Password must be longer than 2 characters & shorter than 101 characters.').isLength({ min: 3, max: 100 }), validator, ], @@ -80,6 +107,9 @@ router.get('/login', ctrl.viewLogin); router.get('/register', ctrl.viewRegister); router.get('/profile/output/', ctrl.viewProfile); router.get('/attendance/output', ctrl.viewAttend); -router.get('/profile/password', ctrl.viewChangePassword); +router.get('/verifyPassword', ctrl.viewVerifyPassword); // 비밀번호 확인 페이지 +router.get('/verifyEmail', ctrl.viewverifyEmail); // 인증번호 보내고 체크하는 페이지 +router.get('/newPassword', ctrl.viewChangePassword); // 비밀번호 변경 페이지 + module.exports = router; diff --git a/BackEnd/src/services/user.js b/BackEnd/src/services/user.js index b639d56..b0001c0 100644 --- a/BackEnd/src/services/user.js +++ b/BackEnd/src/services/user.js @@ -3,8 +3,12 @@ const { User, Attendance } = require('../utils/connect'); const { Op } = require('sequelize'); -const { accessToken, refreshToken } = require('../functions/signJWT'); +const { accessToken, refreshToken, oneTimeToken } = require('../functions/signJWT'); const bcrypt = require('bcrypt'); +const random = require('crypto'); + +const cache = require('memory-cache'); +const logger = require('../functions/winston'); /** * 사용자 검색 후 return @@ -206,15 +210,13 @@ const findAttendanceDate = async (user_id, start_date, end_date) => { }; /** - * 비밀번호 입력받아 확인 후, 비밀번호 변경 + * 비밀번호 확인 + * @param {string} confirm_password 비밀번호 * @param {number} user_id - * @param {string} confirm_password 사용자가 입력한 기존 비밀번호 - * @param {string} new_password 새 비밀번호 * - * @returns {Object} { message: string, data : DBdata } + * @returns {string} email */ -const updatePassword = async (user_id, confirm_password, new_password) => { - let message = ''; +const comparePassword = async (confirm_password, user_id) => { const user = await User.findByPk(user_id); if (user === null) { throw new Error('Can not find profile.'); @@ -222,6 +224,23 @@ const updatePassword = async (user_id, confirm_password, new_password) => { const match = await bcrypt.compare(confirm_password, user.password); if (!match) { throw new Error('Incorrect password.'); + } else { + return user.email; + } +}; + +/** + * 비밀번호 변경 + * @param {number} user_id + * @param {string} new_password 새 비밀번호 + * + * @returns {Object} { message: string, data : DBdata } + */ +const updatePassword = async (user_id, new_password) => { + let message = ''; + const user = await User.findByPk(user_id); + if (user === null) { + throw new Error('Can not find profile.'); } const encrypted_pw = await bcrypt.hash(new_password, 10); const data = await User.update( @@ -238,6 +257,54 @@ const updatePassword = async (user_id, confirm_password, new_password) => { } }; +/** + * email을 입력 받아 인증번호 생성 후 캐시메모리에 저장 + * @param {string} email + * + * @returns {string} code + */ +const verifycode = (email) => { + let code = parseInt(random.randomBytes(2).toString('hex'), 16).toString(10); + //캐시메모리에 저장 (캐시 메모리 너무 많이 쌓이는 경우?) + cache.put(email, code, 300000, (key, value) => { + logger.info(`'verifycode is timeout. key: ${key} - value: ${value}'`); + //console.log('key: ' + key + ' value: ' + value + ' timeout'); // 로그로 변경필요 + }); // key: email, value: code, 300000ms (5min) 후 삭제 + return code; +}; + +/** + * 인증번호 체크 + * @param {string} email + * @param {number} verifycode + * + * @returns {boolean} + */ +const checkCode = (email, verifycode) => { + let cachecode = cache.get(email); + if (cachecode) { + if (parseInt(verifycode) !== parseInt(cachecode)) { + throw new Error("Code dosesn't match."); + } else { + return true; + } + } else { + throw new Error('Verifycode expired.'); + } +}; + +/** + * 일회용 토큰 발급 + * @param {string} email + * + * @returns {object} data + */ +const issueOneTimeToken = async (email) => { + let user = await findUser('email', email, 1); + let one_time_access_token = await oneTimeToken({ type: 'OneTimeJWT', id: user.id }); + return one_time_access_token; +}; + module.exports = { findUser, createUser, @@ -247,5 +314,9 @@ module.exports = { findAttendance, createAttendance, findAttendanceDate, + comparePassword, updatePassword, + verifycode, + checkCode, + issueOneTimeToken, }; diff --git a/BackEnd/src/test/user.test.js b/BackEnd/src/test/user.test.js index 9015c19..9a68280 100644 --- a/BackEnd/src/test/user.test.js +++ b/BackEnd/src/test/user.test.js @@ -8,7 +8,7 @@ const user = require('../controllers/user'); const { path, config, chalk } = require('../../loaders/module'); -const bcrypt = require('bcrypt'); +const cache = require('memory-cache'); /** * * 로그인 테스트 @@ -469,72 +469,269 @@ describe('getAttendance', () => { }); }); +// 비밀번호 변경 테스트 /** - * *비밀번호 변경 테스트 - * 1. 비밀번호 변경 성공 - * 2. 비밀번호 변경 실패 (비밀번호 에러) + * 비밀번호 인증 테스트 + * 1. 비밀번호 인증 완료 + * 2. 비밀번호 에러 * 3. 프로필 조회 실패 */ -describe('passwordChange', () => { - let req, res; +describe('checkPassword', () => { + let req, res, server; + + beforeAll(() => { + server = app.listen(config.get('server.port')); + }); + + afterAll((done) => { + server.close(done); + }); beforeEach(() => { req = { + decoded: { id: 1 }, body: { confirm_password: 'password', - new_password: 'newpassword', }, - decoded: { id: '1' }, }; res = { status: jest.fn().mockReturnThis(), json: jest.fn(), }; }); - afterEach(async () => { - let encrypted_pw = await bcrypt.hash('password', 10); - await User.update({ password: encrypted_pw }, { where: { id: '1' } }); - jest.clearAllMocks(); + + // 비밀번호 인증 완료 + test(`should return ${chalk.green(200)} if ${chalk.blue(`password authorize success`)}`, async () => { + await user.checkPassword(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 200, + message: 'Authorize success.', + data: expect.stringMatching(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]+$/), + }), + ); }); - //비밀번호 변경 성공 - test(`should return ${chalk.green(200)} if ${chalk.blue(`password changed`)}`, async () => { - await user.editPassword(req, res); + // 비밀번호 에러 + test(`should return ${chalk.yellow(401)} if ${chalk.blue(`password is incorrect`)}`, async () => { + req.body.confirm_password = 'notpassword'; + await user.checkPassword(req, res); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + message: 'Incorrect password.', + detail: 'No detail.', + }); + }); + + // 프로필 조회 실패 + test(`should return ${chalk.yellow(404)} if ${chalk.blue(`Can not find profile`)}`, async () => { + req.decoded.id = 6974; + await user.checkPassword(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + message: 'Can not find profile.', + detail: 'No detail.', + }); + }); +}); + +/** + * 이메일 인증 테스트 + * 인증번호 발송 테스트 + * 1. 인증번호 발송 완료 + * 2. 프로필 조회 실패 + */ +jest.mock('../functions/nodemail'); +describe('sendVerifyEmail', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + email: 'test_user@example.com', + }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + // 인증번호 발송 완료 + test(`should return ${chalk.green(200)} if ${chalk.blue(`email send success`)}`, async () => { + await user.sendVerifyEmail(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ code: 200, - message: 'Password changed.', - data: expect.objectContaining({ - id: 1, - user_name: 'test_user', - email: 'test_user@example.com', - }), + message: 'No message.', + data: 'No data.', }), ); }); - //비밀번호 변경 실패 (비밀번호 오류) - test(`should return ${chalk.yellow(401)} if ${chalk.blue(`incorrect password`)}`, async () => { - req.body.confirm_password = 'differentpassword'; - await user.editPassword(req, res); + // 프로필 조회 실패 + test(`should return ${chalk.yellow(404)} if ${chalk.blue(`Can not find profile`)}`, async () => { + req.body.email = 'notmail@example.com'; + await user.sendVerifyEmail(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + message: 'Can not find profile.', + detail: 'No detail.', + }); + }); +}); - expect(res.status).toHaveBeenCalledWith(401); +/** + * 이메일 인증 테스트 + * 인증번호 확인 테스트 + * 1. 인증번호 확인 완료 + * 2. 인증번호 에러 + * 3. 인증번호 만료 + */ +describe('checkVerifyCode', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + email: 'test_user@example.com', + verifycode: '12345', + }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + cache.put('test_user@example.com', 12345); + }); + + afterEach(() => { + cache.clear(); + }); + + // 인증번호 확인 완료 + test(`should return ${chalk.green(200)} if ${chalk.blue(`email authorize success`)}`, async () => { + await user.checkVerifyCode(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 200, + message: 'Authorize success.', + data: expect.stringMatching(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]+$/), + }), + ); + }); + + // 인증번호 에러 + test(`should return ${chalk.yellow(409)} if ${chalk.blue(`code is incorrect`)}`, async () => { + req.body.verifycode = 6974; + await user.checkVerifyCode(req, res); + expect(res.status).toHaveBeenCalledWith(409); expect(res.json).toHaveBeenCalledWith({ + message: "Code dosesn't match.", detail: 'No detail.', - message: 'Incorrect password.', }); }); - //비밀번호 변경 실패 (프로필 조회 실패) - test(`should return ${chalk.yellow(404)} if ${chalk.blue(`can not find profile`)}`, async () => { - req.decoded.id = 0; + // 인증번호 만료 + test(`should return ${chalk.yellow(409)} if ${chalk.blue(`verifycode is expired`)}`, async () => { + cache.del('test_user@example.com'); + await user.checkVerifyCode(req, res); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + message: 'Verifycode expired.', + detail: 'No detail.', + }); + }); +}); + +/** + * 비밀번호 변경 테스트 + * 1. 비밀번호 변경 성공 + * 2. 프로필 조회 실패 + * 3. 비밀번호 변경 실패 (시간 만료) + */ +describe('newPassword', () => { + let req, res, token, server; + + beforeAll(async () => { + server = app.listen(config.get('server.port')); + + //캐시생성 + cache.put('test_user@example.com', 12345); + + // 인증하여 토큰발급 + const res = await request(app) + .post('/user/verifyEamil') + .send({ email: 'test_user@example.com', verifycode: 12345 }); + token = res.body.data; + }); + + afterAll((done) => { + cache.clear(); + server.close(done); + }); + + beforeEach(() => { + req = { + decoded: { + id: 1, + type: 'OneTimeJWT', + }, + body: { + new_password: '0000', + }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + afterEach(async () => { + // await request(app) + // .post('/user/newPassword') + // .set('authorization', `${token}`) + // .send({ new_password: 'password' }); + req.body.new_password = 'password'; await user.editPassword(req, res); + }); + //비밀번호 변경성공 + test(`should return ${chalk.green(200)} if ${chalk.blue(`password change success`)}`, async () => { + await user.editPassword(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 200, + message: 'Password changed.', + }), + ); + }); + + // 프로필 조회 실패 + test(`should return ${chalk.yellow(404)} if ${chalk.blue(`can not find profile`)}`, async () => { + req.decoded.id = 6974; + await user.editPassword(req, res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ - detail: 'No detail.', message: 'Can not find profile.', + detail: 'No detail.', }); }); + + // 토큰 만료 (시간이 걸리므로 TDD 어려움) + // test(`should return ${chalk.yellow(403)} if ${chalk.blue(`token is invalid`)}`, async () => { + // token = ''; + // await user.editPassword(req, res); + // expect(res.status).toHaveBeenCalledWith(403); + // expect(res.json).toHaveBeenCalledWith( + // { + // message: "Verifycode expired.", + // detail: 'No detail.', + // } + // ); + // }); }); diff --git a/FrontEnd/public/js/user/password.js b/FrontEnd/public/js/user/newPassword.js similarity index 70% rename from FrontEnd/public/js/user/password.js rename to FrontEnd/public/js/user/newPassword.js index c4b84dd..6a3d4d6 100644 --- a/FrontEnd/public/js/user/password.js +++ b/FrontEnd/public/js/user/newPassword.js @@ -2,14 +2,13 @@ const pw_change_btn = document.querySelector("#pw_change_btn"); -pw_change_btn.addEventListener("click", passwordPatch); +pw_change_btn.addEventListener("click", newPassword); -function passwordPatch() { - const confirm_password = document.getElementsByName("old_password")[0].value; +function newPassword() { const new_password = document.getElementsByName("new_password")[0].value; const new_password_confirm = document.getElementsByName("new_password_confirm")[0].value; - if (!(confirm_password && new_password && new_password_confirm)) { + if (!(new_password && new_password_confirm)) { return alert("Please input password."); } else if (new_password !== new_password_confirm) { return alert("confirm password does not match."); @@ -18,24 +17,27 @@ function passwordPatch() { } const req = { - confirm_password: confirm_password, new_password: new_password, }; - fetch("/user/profile/password", { + fetch("/user/newPassword", { method: 'PATCH', headers: { 'content-type': 'application/json', - authorization: localStorage.getItem('access_token') + authorization: localStorage.getItem('one_time_access_token') }, body: JSON.stringify(req) }).then((res) => res.json()) .then((res) => { if (res.code === 200) { location.href = "/" - } else { + } else if (res.code === 500) { alert(res.message); location.reload(); + } else { //403 or 404 + alert(res.message); + alert("please try again."); + location.href = "/" } }) diff --git a/FrontEnd/public/js/user/verifyEmail.js b/FrontEnd/public/js/user/verifyEmail.js new file mode 100644 index 0000000..e0ec953 --- /dev/null +++ b/FrontEnd/public/js/user/verifyEmail.js @@ -0,0 +1,93 @@ +const email = document.querySelector("#email"); +const email_button = document.querySelector("#email_button"); +const verifycode = document.querySelector("#verifycode"); +const submit_button = document.querySelector("#submit_button"); + +email_button.addEventListener("click", sendEmail); +submit_button.addEventListener("click", checkCode); + +const Timer = document.getElementById("timer"); //타이머 +let time = 300000; +let min = 5; +let sec = 60; + +Timer.value = 'left time: ' + min + ':' + '00'; + +function timer() { + playtime = setInterval(() => { + time = time - 1000; + min = time / (60 * 1000); + if (sec > 0) { + sec = sec - 1; + Timer.value = 'left time: ' + Math.floor(min) + ':' + sec; + } + if (sec === 0) { + sec = 60; + Timer.value = 'left time: ' + Math.floor(min) + ':' + '00'; + } + }, 1000); +}; + + +function sendEmail() { + if (!email.value) return alert("Please input email."); + + const req = { + email: email.value, + } + + if (Timer.value !== '') { + Timer.value = ''; + time = 300000; + min = 5; + sec = 60; + } + + fetch("/user/sendEmail", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(req) + }) + .then((res) => res.json()) + .then((res) => { + if (res.code === 200) { + console.log('res good'); + email_button.innerText = "resend"; + document.getElementById("verify").style.display = "block"; + timer(); + setTimeout(() => { + clearInterval(playtime); + Timer.value = "code expired. please resend" + }, 300000); + } else return alert(res.message); + }) +}; + +function checkCode() { + if (!verifycode.value) return alert("Please enter code."); + + const req = { + email: email.value, + verifycode: verifycode.value, + } + console.log(req); + + fetch("/user/verifyEmail", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(req) + }) + .then((res) => res.json()) + .then((res) => { + if (res.code === 200) { + //일회성 토큰저장 + localStorage.setItem("one_time_access_token", res.data); + //리다이렉트 + location.href = "/user/newPassword"; + } else return alert(res.message); + }) +}; diff --git a/FrontEnd/public/js/user/verifyPassword.js b/FrontEnd/public/js/user/verifyPassword.js new file mode 100644 index 0000000..bea2cec --- /dev/null +++ b/FrontEnd/public/js/user/verifyPassword.js @@ -0,0 +1,39 @@ +'use strict'; + +const pw_change_btn = document.querySelector("#pw_btn"); + +pw_change_btn.addEventListener("click", checkPassword); + +function checkPassword() { + const confirm_password = document.getElementsByName("old_password")[0].value; + + if (!confirm_password) { + return alert("Please input password."); + } else if (confirm_password.length > 100) { + return alert("Password must be shorter than 101 characters."); + } + + const req = { + confirm_password: confirm_password, + }; + + fetch("/user/verifyPassword", { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: localStorage.getItem('access_token') + }, + body: JSON.stringify(req) + }).then((res) => res.json()) + .then((res) => { + if (res.code === 200) { + //일회성 토큰저장 + localStorage.setItem("one_time_access_token", res.data); + location.href = "/user/newPassword" + } else { + alert(res.message); + location.reload(); + } + }) + +} \ No newline at end of file diff --git a/FrontEnd/views/user/login.ejs b/FrontEnd/views/user/login.ejs index 9375ffc..7fbba00 100644 --- a/FrontEnd/views/user/login.ejs +++ b/FrontEnd/views/user/login.ejs @@ -21,6 +21,7 @@ + forgot your password? diff --git a/FrontEnd/views/user/password.ejs b/FrontEnd/views/user/newPassword.ejs similarity index 71% rename from FrontEnd/views/user/password.ejs rename to FrontEnd/views/user/newPassword.ejs index 37c83c8..5168671 100644 --- a/FrontEnd/views/user/password.ejs +++ b/FrontEnd/views/user/newPassword.ejs @@ -9,16 +9,10 @@ <%- include('../partials/nav') %>