JWT 방식으로 로그인을 해보자!

로컬에서 React를 이용한 간단한 로그인 페이지를 제작했습니다.

문제는 이 프론트엔드와 백엔드를 연동해야 하는 문제인데…

로컬스토리지 방식으로 무작정 정보를 저장하고 활용하는 방식은 쉽지만, 보안에 취약하다는 약점이 있다.

어차피 언젠가는 넘어야 할 산…

JWT 활용을 이용해 로그인 및 정보를 할당하는 작업을 진행해보자.

현재 내 로그인 코드

저는 오른쪽 과 같은 방식을 통해서 백엔드에 있는 로그인 라우터에 접근하고

로그인에 성공하면 각종  정보들을 저장하는 형태를 띄고 있습니다.

여기서 로그인시 로컬스토리에도 값을 할당하고 있는데,

isLoggedIn 이라는 변수에 “true” 를 넣어주는 것을 목적으로 합니다.

이럼 사용자가 새로고침 하든 새탭열든 이 정보는 가져가니까 

로그아웃을 굳이 하지 않는 한 계속 로그인 상태가 지속되게 하고 싶었습니다.

문제는 서버에서 다른 정보들을 가져와서 지속적으로 담고 싶은데..

이때 로컬스토리지에 그 상태 그대로 넣으면 뭐 당연히 보안에 좋지 않겠죠..

아래는 로컬스토리지 약점과, JWT 에 대한 GPT의 답변입니다.

JWT (JSON Web Token)에 대한 설명

JWT는 JSON 객체를 사용하여 정보를 안전하게 전송하기 위한 토큰 기반의 인증 방식입니다. JWT는 헤더, 페이로드, 시그니처의 세 부분으로 구성됩니다.

  • 헤더(Header): 토큰의 타입과 해싱 알고리즘 정보가 들어 있습니다.
  • 페이로드(Payload): 실제 전달할 데이터가 들어 있습니다. 이 데이터는 클레임(claim)이라고도 하며, 사용자 ID, 유효 기간 등이 될 수 있습니다.
  • 시그니처(Signature): 헤더와 페이로드를 해싱 알고리즘과 비밀 키를 사용하여 암호화한 값입니다.

이 세 부분을 합쳐서 하나의 문자열로 만들면 JWT가 완성됩니다. 이 토큰을 사용하여 사용자 인증, 정보 전달 등을 할 수 있습니다.

로컬 스토리지의 보안 취약점

로컬 스토리지는 웹 스토리지의 일종으로, 키-값 쌍을 브라우저에 저장할 수 있게 해줍니다. 하지만 다음과 같은 이유로 보안에 취약합니다.

  1. XSS (Cross-Site Scripting) 공격: 악성 스크립트가 웹 페이지에 삽입되어 실행될 경우, 이 스크립트가 로컬 스토리지에 접근할 수 있습니다. 이렇게 해서 민감한 정보가 탈취될 수 있습니다.

  2. CSRF (Cross-Site Request Forgery) 공격: 로컬 스토리지에 저장된 토큰을 사용하여 인증을 처리할 경우, CSRF 공격에 취약해질 수 있습니다.

  3. 접근성: 로컬 스토리지는 JavaScript에서 쉽게 접근할 수 있으므로, 악성 코드가 실행되면 민감한 정보가 쉽게 노출될 수 있습니다.

  4. 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 의 구성과, 버튼을 눌렀을 때 작동되는 함수까지 확인이 가능합니다.

그럼 토큰이 발급이 되는 순서를 정리해보죠.

  1. 사용자가 로그인 폼에 ID와 비밀번호를 입력합니다.
  2. “Confirm” 버튼을 누르면 handleLogin 함수가 호출됩니다.
  3. handleLogin 함수 내에서는 입력받은 ID와 비밀번호를 payload라는 객체에 담습니다.
  4. payload를 JSON 형태로 변환하여 서버의 /login 라우터에 POST 요청을 보냅니다.
  5. 서버에서는 이 요청을 처리하여 결과를 JSON 형태로 다시 보냅니다.
  6. 클라이언트에서는 이 JSON 응답을 data 변수에 저장합니다.
  7. data.successtrue인 경우, 로그인이 성공적으로 이루어진 것입니다.
  8. 로그인이 성공하면, 서버로부터 받은 토큰(data.token)을 로컬 스토리지에 저장합니다.
  9. 마지막으로, 사용자를 “/mypage”로 리다이렉트합니다.
위 순서로 로컬 스토리지에 token 이라는 key 에  정보를 담게 되었습니다.
그럼 페이지가 로드될 때 몇몇 보안이 필요한 페이지의 경우는, 이 token 이 존재하는 user 만 이용할 수 있도록 할 겁니다.
 

아래로 이어지는 코드는 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 (
    <div className={`login-container ${isLoggedIn ? "blurred" : ""}`}>
      <form className={`login-form ${isLoggedIn ? "disabled" : ""}`}>
        <fieldset>
          <legend>Login Page</legend>
          <div className="input-box">
            <label htmlFor="log">ID</label>
            <input
              type="text"
              id="log"
              value={id}
              onChange={(e) => setId(e.target.value)}
              required
            />
          </div>
          <div className="input-box">
            <label htmlFor="pwd">Password</label>
            <input
              type="password"
              id="pwd"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>
          <div className="button-box">
            <input type="checkbox" id="rememberme" />
            <label htmlFor="rememberme">Remember me</label>
            <button type="button" onClick={() => handleLogin(id, password)}>
              Confirm
            </button>
          </div>
          <div className="link-box">
            <span>
              New User? <a href="/register">Register</a>
            </span>
          </div>

          <button className="google-login-button" onClick={handleGoogleLogin}>
            {/* <img decoding="async" src="/path-to-google-logo.png" alt="Google Logo" /> */}
            Continue with Google
          </button>
        </fieldset>
      </form>

      {isLoggedIn && (
        <div className="logout-button-container">
          <button onClick={handleLogout}>
            Are you sure you want to log out? <br></br>
            Click
          </button>
        </div>
      )}
    </div>
  );
				
			

마치며...

이제 로그인 상태를 이 토큰을 이용해 확인 가능합니다.

다른 정보들도 토큰에 담아서 활용할 수 있는 방안을 생각해봐야겠네요.