로컬에서 React를 이용한 간단한 로그인 페이지를 제작했습니다.
문제는 이 프론트엔드와 백엔드를 연동해야 하는 문제인데…
로컬스토리지 방식으로 무작정 정보를 저장하고 활용하는 방식은 쉽지만, 보안에 취약하다는 약점이 있다.
어차피 언젠가는 넘어야 할 산…
JWT 활용을 이용해 로그인 및 정보를 할당하는 작업을 진행해보자.
저는 오른쪽 과 같은 방식을 통해서 백엔드에 있는 로그인 라우터에 접근하고
로그인에 성공하면 각종 정보들을 저장하는 형태를 띄고 있습니다.
여기서 로그인시 로컬스토리에도 값을 할당하고 있는데,
isLoggedIn 이라는 변수에 “true” 를 넣어주는 것을 목적으로 합니다.
이럼 사용자가 새로고침 하든 새탭열든 이 정보는 가져가니까
로그아웃을 굳이 하지 않는 한 계속 로그인 상태가 지속되게 하고 싶었습니다.
문제는 서버에서 다른 정보들을 가져와서 지속적으로 담고 싶은데..
이때 로컬스토리지에 그 상태 그대로 넣으면 뭐 당연히 보안에 좋지 않겠죠..
아래는 로컬스토리지 약점과, JWT 에 대한 GPT의 답변입니다.
JWT는 JSON 객체를 사용하여 정보를 안전하게 전송하기 위한 토큰 기반의 인증 방식입니다. JWT는 헤더, 페이로드, 시그니처의 세 부분으로 구성됩니다.
이 세 부분을 합쳐서 하나의 문자열로 만들면 JWT가 완성됩니다. 이 토큰을 사용하여 사용자 인증, 정보 전달 등을 할 수 있습니다.
로컬 스토리지는 웹 스토리지의 일종으로, 키-값 쌍을 브라우저에 저장할 수 있게 해줍니다. 하지만 다음과 같은 이유로 보안에 취약합니다.
XSS (Cross-Site Scripting) 공격: 악성 스크립트가 웹 페이지에 삽입되어 실행될 경우, 이 스크립트가 로컬 스토리지에 접근할 수 있습니다. 이렇게 해서 민감한 정보가 탈취될 수 있습니다.
CSRF (Cross-Site Request Forgery) 공격: 로컬 스토리지에 저장된 토큰을 사용하여 인증을 처리할 경우, CSRF 공격에 취약해질 수 있습니다.
접근성: 로컬 스토리지는 JavaScript에서 쉽게 접근할 수 있으므로, 악성 코드가 실행되면 민감한 정보가 쉽게 노출될 수 있습니다.
Same Origin Policy: 로컬 스토리지는 동일 출처 정책을 따르므로, 서브도메인 간에는 공유되지 않습니다. 이로 인해 여러 도메인을 가진 서비스에서는 로컬 스토리지를 사용하기 어려울 수 있습니다.
위와 같은 정보를 확인했으니, 저는 로컬스토리지에 암호화된 token 을 넣어 사용해보겠습니다.
const handleLogin = async (id, password) => {
const payload = {
id,
password,
};
try {
const response = await fetch("http://localhost:5000/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (data.success) {
console.log("Login successful");
setIsLoggedIn(true);
setUserId(id);
localStorage.setItem("isLoggedIn", "true"); // 로그인 상태를 로컬 스토리지에 저장합니다.
navigate("/mypage"); // 로그인 성공 후 페이지 이동
} else {
alert("Failed to login.");
console.log("Login failed");
}
} catch (error) {
alert("Failed to login.");
console.log("An error occurred:", error);
}
};
// 로그인 라우터
app.post("/login", async (req, res) => {
const { id, password } = req.body;
const request = new sql.Request();
console.log("Received login request");
try {
// 사용자 ID로 정보 검색
const query = `SELECT * FROM dbo.user_info WHERE user_id = @id`;
request.input("id", sql.NVarChar, id);
const result = await request.query(query);
if (result.recordset.length > 0) { // result 로 반환된 행의 개수가 1개 이상인지 확인한다.
const hashedPassword = result.recordset[0].password; // DB에서 가져온 암호화된 비밀번호
// bcrypt를 사용하여 비밀번호 비교
const match = await bcrypt.compare(password, hashedPassword);
if (match) {
console.log("login is completed");
res.json({ success: true, id }); // 로그인 성공과 함께 true 값과, id 를 반환해준다.
} else {
res.json({ success: false });
}
} else {
res.json({ success: false });
}
} catch (error) { // 백엔드 작동 시 에러가 발생하면 왜 발생하는지 여기서 알려준다.
console.log("Error in query execution:", error);
res.json({ success: false });
}
console.log("Sending login response");
});
const jwt = require("jsonwebtoken");
// npm install jsonwebtoken
먼저 서버 측에 위와 같은 모듈을 다운로드 후에, jwt 로 불러와주어야 합니다.
그리고 오른쪽 코드에서 24번 줄처럼 token 이라는 것을 생성합니다.
이럼 로그인 성공하면 사용자에게 token 이 발급되는 건데요.
26번 줄에서 id와 함께 token을 전달할 겁니다.
로그인을 위해 id, pw 가 담길 수 있는 form 을 만들 것이고 이곳에는 이 입력한 정보를 전송 시킬 수 있는 버튼이 있겠죠?
프론트엔드에서 제가 만든 Form은 오른쪽 하단에 첨부하겠습니다.
이럼 이제 From 의 구성과, 버튼을 눌렀을 때 작동되는 함수까지 확인이 가능합니다.
그럼 토큰이 발급이 되는 순서를 정리해보죠.
handleLogin 함수가 호출됩니다.handleLogin 함수 내에서는 입력받은 ID와 비밀번호를 payload라는 객체에 담습니다.payload를 JSON 형태로 변환하여 서버의 /login 라우터에 POST 요청을 보냅니다.data 변수에 저장합니다.data.success가 true인 경우, 로그인이 성공적으로 이루어진 것입니다.data.token)을 로컬 스토리지에 저장합니다.아래로 이어지는 코드는 verify 라는 라우터로 로컬스토리지에 저장된 토큰의 값을 전달하고 있습니다.
그리고 그 결과 에 따라서 로그인 상태를 true 로 둘지 false 로 둘지 결정하게 됩니다.
useEffect(() => {
const storedLoginStatus = localStorage.getItem("isLoggedIn");
const token = localStorage.getItem("token");
if (storedLoginStatus === "true") {
setIsLoggedIn(true);
}
if (token) {
// 서버에 토큰 유효성 검사 요청
fetch("http://localhost:5000/auth/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data.success) {
// 토큰이 유효하면 로그인 상태를 유지
setIsLoggedIn(true);
} else {
// 토큰이 유효하지 않으면 로그아웃 처리
setIsLoggedIn(false);
localStorage.removeItem("token");
}
})
.catch((error) => {
console.log("An error occurred:", error);
});
}
}, []);
// 토큰 유효성 검사 라우터
app.post("/auth/verify", (req, res) => {
const token = req.headers["authorization"].split(" ")[1]; // "Bearer [token]" 형태로 오기 때문에 split을 사용
console.log("Received token for verification:", token); // 토큰 출력
if (!token) {
console.log("No token provided"); // 토큰이 없을 경우
return res
.status(401)
.json({ success: false, message: "No token provided" });
}
jwt.verify(token, "your_secret_key", (err, decoded) => {
if (err) {
console.log("Failed to authenticate token:", err); // 토큰 검증 실패
return res
.status(401)
.json({ success: false, message: "Failed to authenticate token" });
}
console.log("Token is valid, decoded:", decoded); // 토큰 검증 성공, 디코딩된 정보 출력
// 토큰이 유효하면 decoded에 담긴 정보를 사용할 수 있습니다.
// 예: const userId = decoded.id;
return res.status(200).json({ success: true, message: "Token is valid" });
});
});
// 로그인 라우터
app.post("/login", async (req, res) => {
const { id, password } = req.body;
const request = new sql.Request();
console.log("Received login request");
try {
// 사용자 ID로 정보 검색
const query = `SELECT * FROM dbo.user_info WHERE user_id = @id`;
request.input("id", sql.NVarChar, id);
const result = await request.query(query);
if (result.recordset.length > 0) {
// result 로 반환된 행의 개수가 1개 이상인지 확인한다.
const hashedPassword = result.recordset[0].password; // DB에서 가져온 암호화된 비밀번호
// bcrypt를 사용하여 비밀번호 비교
const match = await bcrypt.compare(password, hashedPassword);
if (match) {
console.log("login is completed");
// JWT 토큰 생성
const token = jwt.sign({ id }, "your_secret_key", { expiresIn: "1h" });
res.json({ success: true, id, token }); // 로그인 성공과 함께 true 값과, id 를 반환해준다.
} else {
res.json({ success: false });
}
} else {
res.json({ success: false });
}
} catch (error) {
// 백엔드 작동 시 에러가 발생하면 왜 발생하는지 여기서 알려준다.
console.log("Error in query execution:", error);
res.json({ success: false });
}
console.log("Sending login response");
});
return (
{isLoggedIn && (
)}
);
이제 로그인 상태를 이 토큰을 이용해 확인 가능합니다.
다른 정보들도 토큰에 담아서 활용할 수 있는 방안을 생각해봐야겠네요.