diff --git a/changmin-todolist/package.json b/changmin-todolist/package.json index eddb60c..e96e673 100644 --- a/changmin-todolist/package.json +++ b/changmin-todolist/package.json @@ -10,6 +10,7 @@ "body-parser": "^1.19.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^10.0.0", "express": "^4.17.2", "jsonwebtoken": "^8.5.1", diff --git a/changmin-todolist/public/index.html b/changmin-todolist/public/index.html index aa069f2..b3b8152 100644 --- a/changmin-todolist/public/index.html +++ b/changmin-todolist/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Changmin TodoList diff --git a/changmin-todolist/server/controllers/todoController.js b/changmin-todolist/server/controllers/todoController.js index 15a4092..847e9f7 100644 --- a/changmin-todolist/server/controllers/todoController.js +++ b/changmin-todolist/server/controllers/todoController.js @@ -17,10 +17,14 @@ function connectDB() { } exports.getTodoList = async (req, res) => { + const username = req.body.username; + const pool = connectDB(); const conn = await pool.getConnection(); try { - const [rows] = await conn.query("SELECT * FROM todo"); + const [rows] = await conn.query("SELECT * FROM todo WHERE username = ?", [ + req.body.username, + ]); console.log(rows); res.send(rows); } catch (err) { @@ -31,32 +35,38 @@ exports.getTodoList = async (req, res) => { } }; -exports.postTodoList = async (req, res) => { +exports.editTodoList = async (req, res) => { + const username = req.body.username; const action = req.body.action; + console.log(action); const pool = connectDB(); const conn = await pool.getConnection(); try { - let sql; + let sql, values; switch (action.type) { case "CREATE": - sql = `INSERT INTO todo (id, text, done) VALUES (${action.todo.id}, '${action.todo.text}', ${action.todo.done})`; + sql = "INSERT INTO todo (username, id, text, done) VALUES (?, ?, ?, ?)"; + values = [username, action.todo.id, action.todo.text, action.todo.done]; break; case "TOGGLE": - sql = `UPDATE todo SET done = 1 - done WHERE id = ${action.id}`; + sql = "UPDATE todo SET done = 1 - done WHERE username = ? AND id = ?"; + values = [username, action.id]; break; case "REMOVE": - sql = `DELETE FROM todo WHERE id = ${action.id}`; + sql = "DELETE FROM todo WHERE username = ? AND id = ?"; + values = [username, action.id]; break; case "EDIT": - sql = `UPDATE todo SET text = '${action.editText}' WHERE id = ${action.id}`; + sql = "UPDATE todo SET text = ? WHERE username = ? AND id = ?"; + values = [action.editText, username, action.id]; break; default: throw new Error(`Undefined Action: ${action.type}`); } - console.log(sql); - await conn.query(sql); + console.log(`${sql} / ${values}`); + await conn.query(sql, values); res.sendStatus(200); } catch (err) { console.log(err); diff --git a/changmin-todolist/server/controllers/userController.js b/changmin-todolist/server/controllers/userController.js index 9acef6a..ed06557 100644 --- a/changmin-todolist/server/controllers/userController.js +++ b/changmin-todolist/server/controllers/userController.js @@ -1,6 +1,7 @@ const jwt = require("jsonwebtoken"); const mysql = require("mysql2/promise"); const dotenv = require("dotenv").config(); +const { randomBytes, scrypt } = require("crypto"); const options = { host: process.env.MYSQL_HOST, user: process.env.MYSQL_USER, @@ -25,25 +26,32 @@ exports.createToken = async (req, res) => { const conn = await pool.getConnection(); try { - const [rows] = await conn.query( - `SELECT * FROM user WHERE username = '${req.body.username}' AND password = '${req.body.password}'` - ); + const [rows] = await conn.query("SELECT * FROM user WHERE username = ?", [ + req.body.username, + ]); if (rows.length) { - const token = jwt.sign( - { - username: rows[0].username, - }, - SECRET_KEY, - { - expiresIn: "1h", - } - ); - res.cookie("user", token, { - path: "/", - maxAge: 60 * 60 * 1000, + const [password, salt] = rows[0].password.split("$"); + + scrypt(req.body.password, salt, 64, (err, derivedKey) => { + if (err) throw err; + if (derivedKey.toString("base64") == password) { + const token = jwt.sign( + { + username: rows[0].username, + }, + SECRET_KEY, + { + expiresIn: "1h", + } + ); + res.cookie("user", token, { + path: "/", + maxAge: 60 * 60 * 1000, + }); + res.send("OK"); + } else res.send("USER_NOT_FOUND"); }); - res.send("OK"); } else { res.send("USER_NOT_FOUND"); } @@ -62,14 +70,20 @@ exports.createNewUser = async (req, res) => { const conn = await pool.getConnection(); try { - const [rows] = await conn.query( - `SELECT * FROM user WHERE username = '${req.body.username}'` - ); + const [rows] = await conn.query("SELECT * FROM user WHERE username = ?", [ + req.body.username, + ]); if (!rows.length) { - await conn.query( - `INSERT INTO user (username, password) VALUES ('${req.body.username}', '${req.body.password}')` - ); + const salt = randomBytes(32).toString("base64"); + + scrypt(req.body.password, salt, 64, async (err, derivedKey) => { + if (err) throw err; + await conn.query( + "INSERT INTO user (username, password) VALUES (?, ?)", + [req.body.username, `${derivedKey.toString("base64")}$${salt}`] + ); + }); res.send("OK"); } else { res.send("USER_EXISTS"); @@ -89,9 +103,12 @@ exports.removeUser = async (req, res) => { const conn = await pool.getConnection(); try { - await conn.query( - `DELETE FROM user WHERE username = '${req.body.username}'` - ); + await conn.query("DELETE FROM user WHERE username = ?", [ + req.body.username, + ]); + await conn.query("DELETE FROM todo WHERE username = ?", [ + req.body.username, + ]); res.send("OK"); } catch (err) { console.log(err); diff --git a/changmin-todolist/server/routes/todo.js b/changmin-todolist/server/routes/todo.js index 8b71cab..1093e79 100644 --- a/changmin-todolist/server/routes/todo.js +++ b/changmin-todolist/server/routes/todo.js @@ -2,9 +2,7 @@ const express = require("express"); const router = express.Router(); const todoController = require("../controllers/todoController"); -router - .route("/") - .get(todoController.getTodoList) - .post(todoController.postTodoList); +router.post("/get", todoController.getTodoList); +router.post("/edit", todoController.editTodoList); module.exports = router; diff --git a/changmin-todolist/server/server.js b/changmin-todolist/server/server.js index 4a45651..044689d 100644 --- a/changmin-todolist/server/server.js +++ b/changmin-todolist/server/server.js @@ -13,8 +13,8 @@ app.use(cors({ origin: true, credentials: true })); app.use(bodyParser.json()); app.use(cookieParser()); -app.use("/todo", todoRouter); -app.use("/user", userRouter); +app.use("/api/todo", todoRouter); +app.use("/api/user", userRouter); app.listen(port, () => { console.log(`Example app listening at http://localhost:${port}`); diff --git a/changmin-todolist/src/App.js b/changmin-todolist/src/App.js index 2007f87..95268bc 100644 --- a/changmin-todolist/src/App.js +++ b/changmin-todolist/src/App.js @@ -1,13 +1,15 @@ import Main from "./Main"; import styled, { createGlobalStyle } from "styled-components"; -import { Routes, Route, Link } from "react-router-dom"; +import { Routes, Route, Link, Navigate } from "react-router-dom"; import TodoTemplate from "./components/TodoTemplate"; import LoginForm from "./components/LoginForm"; import MenuTemplate from "./components/MenuTemplate"; import { TodoProvider } from "./components/TodoContext"; -import { useEffect, useState } from "react"; -import axios from "axios"; +import { useContext, useEffect } from "react"; import Cookies from "universal-cookie"; +import UserContext from "./contexts/UserContext"; +import { TodoAPI } from "./utils/axios"; +import Page from "./Page"; const cookies = new Cookies(); @@ -34,18 +36,18 @@ const MenuStyle = { }; const App = () => { - const [currentUsername, setCurrentUsername] = useState(); + const { state, actions } = useContext(UserContext); async function verifyToken() { - await axios - .post("http://localhost:3001/user/auth", null, { - withCredentials: true, - }) + await TodoAPI.post("/user/auth", null, { + withCredentials: true, + }) .then((res) => { - setCurrentUsername(res.data); + actions.setUsername(res.data); }) .catch((err) => { console.log("Invalid token, removing the cookie"); + actions.setUsername(null); cookies.remove("user"); }); } @@ -53,7 +55,8 @@ const App = () => { useEffect(() => { if (cookies.get("user") && cookies.get("user") !== "undefined") verifyToken(); - }, []); + else actions.setUsername(null); + }, [state]); return ( @@ -66,20 +69,36 @@ const App = () => { - {!currentUsername ? "Login" : currentUsername} + {!state.username ? "Login" : state.username} - } /> + +
+ + ) : ( + + ) + } + /> + state.username !== null ? ( + + + + ) : ( + + + + ) } /> diff --git a/changmin-todolist/src/Page.js b/changmin-todolist/src/Page.js new file mode 100644 index 0000000..49ca562 --- /dev/null +++ b/changmin-todolist/src/Page.js @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +const Page = (props) => { + useEffect(() => { + document.title = `${ + props.title ? `${props.title} - ` : "" + }Changmin TodoList`; + }, [props.title]); + return props.children; +}; + +export default Page; diff --git a/changmin-todolist/src/components/LoginForm.js b/changmin-todolist/src/components/LoginForm.js index 8bdda38..d127edb 100644 --- a/changmin-todolist/src/components/LoginForm.js +++ b/changmin-todolist/src/components/LoginForm.js @@ -1,8 +1,10 @@ -import axios from "axios"; -import React, { useRef, useState } from "react"; +import React, { useContext, useRef, useState } from "react"; import { useNavigate } from "react-router"; import styled from "styled-components"; import Cookies from "universal-cookie"; +import UserContext from "../contexts/UserContext"; +import { TodoAPI } from "../utils/axios"; +import { useTodoDispatch } from "./TodoContext"; const cookies = new Cookies(); @@ -64,15 +66,25 @@ const Input = styled.input` box-sizing: border-box; `; -function LoginForm({ currentUsername, setCurrentUsername }) { +function LoginForm() { + const { state, actions } = useContext(UserContext); const [inputs, setInputs] = useState({ username: "", password: "", + passwordCheck: "", }); - const { username, password } = inputs; // 입력받은 username / password + const { username, password, passwordCheck } = inputs; // 입력받은 username / password / passwordCheck const usernameInput = useRef(); // Username 입력 focus 위해 사용 const passwordInput = useRef(); // Password 입력 focus 위해 사용 + const passwordCheckInput = useRef(); // PasswordCheck 입력 focus 위해 사용 + const [passwordCheckDisplay, setPasswordCheckDisplay] = useState(false); // PasswordCheck 입력 표시 여부 const navigate = useNavigate(); // 로그인 성공 후 navigate 위해 사용 + const dispatch = useTodoDispatch(); + + const setPasswordCheckInput = (node) => { + if (node) passwordCheckInput.current = node; + passwordCheckInput.current.focus(); + }; const onChange = (e) => { const { value, name } = e.target; @@ -96,14 +108,13 @@ function LoginForm({ currentUsername, setCurrentUsername }) { } async function checkUser() { - await axios - .post("http://localhost:3001/user/login", inputs, { - withCredentials: true, - }) + await TodoAPI.post("/user/login", inputs, { + withCredentials: true, + }) .then((res) => { switch (res.data) { case "OK": - setCurrentUsername(username); + actions.setUsername(username); alert(`성공적으로 로그인되었습니다. 안녕하세요, ${username}님!`); navigate("/"); break; @@ -142,9 +153,34 @@ function LoginForm({ currentUsername, setCurrentUsername }) { return; } + if (username.length < 4 || username.length > 20) { + alert("Username은 4~20글자만 가능합니다."); + usernameInput.current.focus(); + return; + } + if (password.length < 8 || password.length > 20) { + alert("Password는 8~20글자만 가능합니다."); + passwordInput.current.focus(); + return; + } + + if (!passwordCheckDisplay) { + setPasswordCheckDisplay(true); + return; + } + if (!passwordCheck) { + alert("Password Check를 입력해주세요."); + passwordCheckInput.current.focus(); + return; + } + if (password !== passwordCheck) { + alert("Password와 Password Check이 다릅니다."); + passwordCheckInput.current.focus(); + return; + } + async function checkUser() { - await axios - .post("http://localhost:3001/user/register", inputs) + await TodoAPI.post("/user/register", inputs) .then((res) => { switch (res.data) { case "OK": @@ -152,20 +188,17 @@ function LoginForm({ currentUsername, setCurrentUsername }) { `${username} 계정을 성공적으로 생성하였습니다. 다시 로그인해주세요.` ); - // username과 password input field 초기화 - setInputs({ username: "", password: "" }); + setInputs({ username: "", password: "", passwordCheck: "" }); - // username field에 focus + setPasswordCheckDisplay(false); usernameInput.current.focus(); break; case "USER_EXISTS": alert(`${username} 계정이 이미 존재합니다.`); - // password input field 초기화 - setInputs({ ...inputs, password: "" }); + setInputs({ ...inputs, password: "", passwordCheck: "" }); - // password field에 focus - passwordInput.current.focus(); + usernameInput.current.focus(); break; default: alert("알 수 없는 오류가 발생했습니다."); @@ -182,8 +215,9 @@ function LoginForm({ currentUsername, setCurrentUsername }) { const onLogout = (e) => { e.preventDefault(); - setCurrentUsername(undefined); + actions.setUsername(null); cookies.remove("user"); + dispatch({ type: "INIT", todo: [] }); alert("로그아웃되었습니다."); navigate("/"); @@ -192,14 +226,14 @@ function LoginForm({ currentUsername, setCurrentUsername }) { const onUnregister = (e) => { e.preventDefault(); async function checkUser() { - await axios - .post("http://localhost:3001/user/unregister", { - username: currentUsername, - }) + await TodoAPI.post("/user/unregister", { + username: state.username, + }) .then((res) => { switch (res.data) { case "OK": - alert(`${username} 계정을 성공적으로 삭제하였습니다.`); + alert(`${state.username} 계정을 성공적으로 삭제하였습니다.`); + dispatch({ type: "INIT", todo: [] }); navigate("/"); break; default: @@ -211,17 +245,22 @@ function LoginForm({ currentUsername, setCurrentUsername }) { console.log(err); alert("알 수 없는 오류가 발생했습니다."); }); - setCurrentUsername(undefined); + actions.setUsername(null); cookies.remove("user"); } - checkUser(); + if ( + window.confirm( + `정말 ${state.username} 계정을 삭제하시겠습니까? 이 계정의 정보 및 투두리스트가 영구적으로 삭제되며, 되돌릴 수 없습니다.` + ) + ) + checkUser(); }; - if (!!currentUsername) { + if (state.username) { // 로그인 된 화면 return ( -

안녕하세요, {currentUsername}님!

+

안녕하세요, {state.username}님!

@@ -253,6 +292,19 @@ function LoginForm({ currentUsername, setCurrentUsername }) { ref={passwordInput} /> + {passwordCheckDisplay && ( +
+ Password:{" "} + +
+ )} diff --git a/changmin-todolist/src/components/TodoContext.js b/changmin-todolist/src/components/TodoContext.js index f99dd20..00a3f6f 100644 --- a/changmin-todolist/src/components/TodoContext.js +++ b/changmin-todolist/src/components/TodoContext.js @@ -1,4 +1,3 @@ -import axios from "axios"; import React, { createContext, useContext, @@ -6,9 +5,13 @@ import React, { useReducer, useRef, } from "react"; +import UserContext from "../contexts/UserContext"; +import { TodoAPI } from "../utils/axios"; const todoList = []; +let username; + function todoReducer(state, action) { let newState = []; switch (action.type) { @@ -37,7 +40,10 @@ function todoReducer(state, action) { throw new Error(`Undefined Action: ${action.type}`); } async function sendTodo() { - await axios.post("http://localhost:3001/todo", { action }); + await TodoAPI.post("/todo/edit", { + username: username, + action, + }); } if (action.type !== "INIT") sendTodo(); return newState; @@ -51,15 +57,21 @@ export function TodoProvider({ children }) { const [state, dispatch] = useReducer(todoReducer, todoList); const nextId = useRef(0); + const value = useContext(UserContext); + + async function getInitialTodo() { + await TodoAPI.post("/todo/get", { username }).then((res) => { + dispatch({ type: "INIT", todo: res.data }); + nextId.current = res.data.length + ? res.data[res.data.length - 1].id + 1 + : 1; + }); + } + useEffect(() => { - async function getInitialTodo() { - await axios.get("http://localhost:3001/todo").then((res) => { - dispatch({ type: "INIT", todo: res.data }); - nextId.current = res.data[res.data.length - 1].id + 1; - }); - } - getInitialTodo(); - }, []); + username = value.state.username; + if (username) getInitialTodo(); + }, [value]); return ( diff --git a/changmin-todolist/src/contexts/UserContext.js b/changmin-todolist/src/contexts/UserContext.js new file mode 100644 index 0000000..4edde4d --- /dev/null +++ b/changmin-todolist/src/contexts/UserContext.js @@ -0,0 +1,23 @@ +import { createContext, useState } from "react"; + +const UserContext = createContext({ + state: { username: undefined }, + actions: { setUsername: () => {} }, +}); + +const UserProvider = ({ children }) => { + const [username, setUsername] = useState(undefined); + + const value = { + state: { username }, + actions: { setUsername }, + }; + + return {children}; +}; + +const { Consumer: UserConsumer } = UserContext; + +export { UserProvider, UserConsumer }; + +export default UserContext; diff --git a/changmin-todolist/src/index.js b/changmin-todolist/src/index.js index 4b591da..3d7c122 100644 --- a/changmin-todolist/src/index.js +++ b/changmin-todolist/src/index.js @@ -4,10 +4,13 @@ import "./index.css"; import App from "./App"; import reportWebVitals from "./reportWebVitals"; import { BrowserRouter } from "react-router-dom"; +import { UserProvider } from "./contexts/UserContext"; ReactDOM.render( - + + + , document.getElementById("root") ); diff --git a/changmin-todolist/src/utils/axios.js b/changmin-todolist/src/utils/axios.js new file mode 100644 index 0000000..4700d25 --- /dev/null +++ b/changmin-todolist/src/utils/axios.js @@ -0,0 +1,8 @@ +import axios from "axios"; + +export const TodoAPI = axios.create({ + baseURL: + process.env.NODE_ENV === "development" + ? "http://localhost:3001/api" + : "/api", +}); diff --git a/changmin-todolist/yarn.lock b/changmin-todolist/yarn.lock index 3bdd31e..d8d6911 100644 --- a/changmin-todolist/yarn.lock +++ b/changmin-todolist/yarn.lock @@ -3892,6 +3892,11 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +crypto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" + integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== + css-blank-pseudo@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5"