NodeJS (JWT를 이용한 로그인 구현하기)

Jeonghun·2023년 7월 6일
5

NodeJS

목록 보기
1/2


NodeJS JWT 토큰으로 로그인 구현

첫번째 프로젝트를 진행했을 당시, 백엔드를 담당하였고 그 중 User와 Admin 관련 기능을 구현하게 되었다. User 기능 중 가장 기본이 되는 로그인과 로그아웃 방식을 JWT 토큰을 활용한 방식으로 구현하였는데, 그 방법에 대해 알아보도록 하자.

🤔 JWT 토큰이란?
JWT (JSON Web Token) 는 클라이언트와 서버간에 정보를 안전하게 전달하기 위한 간편한 방법 중 하나이다. 이에 포함된 정보는 디지털 서명이 되어 있어, 전송 중 정보가 조작되지 않았음을 검증할 수 있다. JWT 토큰은 주로 웹사이트의 사용자 인증에 사용되며, 로그인 과정을 통해 발행된 토큰을 이용하여 사용자의 신원을 확인하는 방식이다. 우선 JWT를 사용하는 방법에 대해 알아보자.

- JWT를 이용하는 방법

JWT 토큰을 이용하여 간단하게 로그인 방식을 구현하는 방법은 다음과 같다.

📌 NPM 모듈 설치하기

먼저 JWT를 사용하기 위해서는 이를 위한 라이브러리를 설치해야 한다. NodeJS에서는 아래와와 같이 'jsonwebtoken' 이라는 패키지를 설치하여 사용할 수 있다.

npm i jsonwebtoken

📌 토큰 생성

사용자가 로그인을 시도하면, 서버는 사용자의 신원을 확인하고 JWT 토큰을 생성하여 응답한다. 이 때 JWT 토큰은 사용자의 식별 정보와 서버 내의 'Secret Key'를 이용하여 생성된다.

const jwt = require('jsonwebtoken'); // 설치한 모듈을 불러온다.
const secretKey = 'your-secret-key'; // secretKey는 보안을 위해 일반적으로 .env 파일에 작성한다.

// 사용자 신원 확인 후
const payload = { username: 'user' };
const token = jwt.sign(payload, secretKey); // 토큰 생성

위 예제 코드에서 생성된 JWT 토큰은 일반적으로 응답 본문이나 Authorization 헤더에 Bearer {token} 형식으로 포함되어 클라이언트에게 전송된다.

📌 토큰 검증

클라이언트는 이후 요청에 JWT 토큰을 포함하여 서버로 전송하게 되며, 서버는 이 토큰을 검증하여 사용자의 신원을 확인한다.

const receivedToken = 'received-token-from-client';
try {
  const decoded = jwt.verify(receivedToken, secretKey);
  // 사용자의 신원 확인
} catch (err) {
  // 토큰이 유효하지 않은 경우 에러 처리
}

- JWT의 Refresh Token

JWT 토큰은 유효 기간이 정해져 있다. 이 유효 기간이 만료되면 클라이언트는 새로운 JWT를 요청해야 하는데, 이 때 주로 사용되는 것이 '리프레시 토큰'이다.

🤔 Refresh 토큰이란?
위에서 말했듯 JWT (JSON Web Token) 는 유효 기간이 정해져 있으며, 기간이 만료될 경우 새로운 토큰을 발급하는데, 보안상의 이유로 사용자에게 로그인을 자주 요청하는 것은 바람직하지 않다. 이런 문제를 해결하기 위해 사용되는 것이 '리프레시 토큰 (Refresh Token)'이다. JWT가 만료되면 클라이언트는 리프레시 토큰을 이용하여 새로운 JWT를 요청할 수 있고, 이렇게 함으로써 사용자는 로그인을 다시 하지 않아도 지속적으로 서비스를 이용할 수 있다.

📌 리프레시 토큰 사용 예제

const refreshToken = jwt.sign(payload, secretKey, { expiresIn: '7d' }); // 7일 동안 유효한 리프레시 토큰

// JWT 만료 시 새로운 JWT 발행
app.post('/refresh', (req, res) => {
  const receivedRefreshToken = req.body.token;
  try {
    const decoded = jwt.verify(receivedRefreshToken, secretKey);
    const payload = { username: decoded.username };
    const newToken = jwt.sign(payload, secretKey);
    res.json({ token: newToken });
  } catch (err) {
    // 토큰이 유효하지 않은 경우 에러 처리
  }
});

JWT의 장단점

👍
1. 서버가 사용자의 상태를 유지하지 않아도 되므로 여러 서버간에 요청을 자유롭게 라우팅 할 수 있다. 이는 로드 밸런싱에 이점을 제공하며, 서버의 확장성을 증가시킨다.

  1. 몇몇 모바일 플랫폼에서는 쿠키를 지원하지 않거나 제한적으로만 지원하는데, JWT는 HTTP 헤더에 포함되므로 쿠키를 사용하지 않는 환경에서도 사용할 수 있다.
  1. 클라이언트와 서버는 각각의 토큰을 통해 서로를 인증하므로 서로 독립적으로 운영될 수 있다.

👎
1. JWT는 세션 ID에 비해 상대적으로 크기가 크므로, 많은 데이터를 토큰에 포함하는 것은 효율적이지 않을 수 있다.

  1. 일단 JWT가 발행되면 만료되기 전까지 유효하다. 따라서 토큰을 즉시 폐기해야 하는 경우에는 별도의 블랙리스트 메커니즘이 필요하다.
  1. JWT가 탈취당하면 사용자의 정보가 노출될 수 있으므로 HTTPS 등을 통한 안전한 전송이 필요하다.

프로젝트에서의 실제 서비스 코드 구현

위에서 설명한 JWT와 Refresh 토큰을 활용하여 실제 프로젝트에서 내가 구현한 코드는 다음과 같다.

📌 실제 코드

// jwt.js

const jwt = require('jsonwebtoken'); // jwt 모듈 불러오기
const secretKey = process.env.JWT_SECRET_KEY;

const generateToken = (payload) => {
  const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });

    return token;
}; // jwt.sign() 메서드를 통해 jwt 토큰 발행. expiresIn : '1h' 설정으로 1시간 후 토큰이 만료되게 설정.

// 기존 토큰을 사용하여 새로운 토큰을 생성하는 함수
const refreshToken = (token) => {
  try {
    // 기존 토큰의 유효성 검사 및 디코딩
    const decoded = jwt.verify(token, secretKey);
    
    // 새로운 페이로드 생성
    const payload = {
      userId: decoded.userId,
      isAdmin: decoded.isAdmin,
    };
    
    // 새로운 토큰 생성
    const newToken = generateToken(payload);
    return newToken;
  } catch (error) {
    // 토큰 새로 고침 중 오류 발생 시 출력
    console.error('Error refreshing token:', error);
    return null;
  }
};

module.exports = { generateToken, refreshToken };
// login-required.js

const jwt = require('jsonwebtoken');

function loginRequired(req, res, next) {
    // request 헤더로부터 authorization bearer 토큰을 받음.
    const userToken = req.headers["authorization"]?.split(" ")[1];

    // 이 토큰은 jwt 토큰 문자열이거나, 혹은 "null" 문자열이거나, undefined임.
    // 토큰이 "null" 일 경우, login_required 가 필요한 서비스 사용을 제한함.
    if (!userToken || userToken === "null") {
        console.log("서비스 사용 요청이 있습니다. 하지만, Authorization 토큰: 없음");

        res.status(401).json({
            result: "forbidden-approach",
            message: "로그인한 유저만 사용할 수 있는 서비스입니다.",
        });

        return;
    }

    // 해당 token 이 정상적인 token인지 확인
    try {
        const secretKey = process.env.JWT_SECRET_KEY || "secret-key";
        const jwtDecoded = jwt.verify(userToken, secretKey);

        const userId = jwtDecoded.userId;
        req.currentUserId = userId;

        next();
    } catch (error) {
        res.status(401).json({
            result: "forbidden-approach",
            message: "정상적인 토큰이 아닙니다.",
        });

        return;
    }
}

module.exports = loginRequired;
// refreshToken.js

const jwt = require('jsonwebtoken'); // jwt 모듈 불러오기
const { refreshToken } = require('../utils/jwt');

// JWT 토큰을 새로 고치는 미들웨어 함수
const refreshJwtMiddleware = (req, res, next) => {
  // 요청으로부터 쿠키의 토큰 정보 가져오기
  const token = req.cookies.token;

  // 토큰이 존재하는 경우
  if (token) {
    // 토큰을 새로고침하고 새 토큰을 가져옴
    const newToken = refreshToken(token);

    // 새 토큰이 있다면 쿠키에 저장
    if (newToken) {
      res.cookie('token', newToken, { httpOnly: true, maxAge: 3600000 });
    }
  }

  // 다음 미들웨어로 이동
  next();
};

// 미들웨어 함수 내보내기
module.exports = refreshJwtMiddleware;
// userService.js

const userModel = require('../db/models/userModel'); // user 모델 불러오기
const jwt = require('jsonwebtoken'); // jwt 토큰 사용을 위해 모듈 불러오기
const { generateToken } = require('../utils/jwt'); // jwt 토큰 생성 파일 불러오기

// 코드 생략 . . .
// 로그인 로직 구현
    async login(req, res) {

        // 유저 아이디, 비밀번호 받아옴
        const { userId, password } = req;

        // 아이디로 해당 유저 검색
        const user = await userModel.findByUserId(userId);

        // 아이디가 db에 없을 경우 에러 메세지 전송
        if (!user) {
            throw new Error('가입되지 않은 아이디 입니다.');
        }

        // 비밀번호 일치 여부 확인
        const isMatched = await bcrypt.compare(password, user.password);

        // 일치하지 않을 경우 에러 메세지 전송
        if (!isMatched) {
            throw new Error('비밀번호가 일치하지 않습니다.');
        }

        // 유저 id, 관리자 여부 객체로 토큰 페이로드 정보 생성
        const payload = {
            userId: user.userId,
            isAdmin: user.isAdmin,
        };

        // jwt.js에서 작성된 토큰 생성 코드 실행
        const token = generateToken(payload);

        // 'token' 이라는 쿠키 이름으로 토큰 저장, 'httpOnly' 옵션으로 접근 보호
        // 'maxAge' 옵션을 3600000(1시간, 밀리초) 설정
        res.cookie('token', token, { httpOnly: true, maxAge: 3600000 });
        res.json({ message: '성공적으로 로그인 되었습니다.', user, token });
    };

    // 로그아웃 로직 구현
    logout(req, res) {
        const token = req.cookies.token;

        if (!token) {
            res.status(400).json({ message: '토큰이 없습니다. 로그인 상태를 확안하세요.' });
            return;
        }

        const decoded = jwt.decode(token);

        if (!decoded) {
            res.status(401).json({ message: '잘못된 토큰입니다. 로그인 상태를 확인하세요.' });
            return;
        }

        res.clearCookie('token'); // 로그아웃시 쿠키 삭제
        res.json({ message: '로그아웃 되었습니다.' });
    };

// 코드 생략 . . .
//userRouter.js

const loginRequired = require('../middlewares/login-required'); // 로그인 확인 미들웨어 불러오기 (로그인이 필요한 기능이 있을시 해당 라우터에 사용됨)

// 로그인 라우터
router.post('/login', async (req, res) => {
    try {
        await userService.login(req.body, res);
    } catch (err) {
        console.log(err);
        res.status(400).json({ message: err.message }); // JSON 형식으로 에러 메시지 반환
    }
});

// 로그아웃 라우터
// 쿠키에서 토큰을 제거하는 작업은 동기적인 작업이므로, async 처리 불필요
router.post('/logout', (req, res) => {
    try {
        userService.logout(req, res);
    } catch (err) {
        console.log(err);
        res.status(400).json({ message: err.message }); // JSON 형식으로 에러 메시지 반환
    }
});

📌 구현한 방식

JWT를 생성하는 코드 파일과 refreshToken을 발급하는 파일, 발급된 JWT를 통해 유저 정보를 확인하는 login-required 미들웨어, 그리고 이를 이용한 서비스 로직과 라우터를 포함한다. 이 때 토큰의 유효 시간은 1시간으로 설정하였는데, 이를 이용하여 공공기관 페이지에서 자주 볼 수 있는 자동 로그아웃 기능을 구현하였다. 하지만 시간이 너무 짧아서 불편하다는 팀원들의 의견을 반영하여 시간을 3시간으로 연장하고 사용자가 새로운 api 요청을 보낼 경우에 계속해서 우리의 웹페이지를 사용하고 있다고 판단, refreshToken을 발행하여 자동 로그아웃을 방지하였다.


포스팅을 마치며

사용자 인증을 통한 로그인을 구현하는 방식은 JWT 토큰 뿐만 아니라 세션과 같은 다른 방법도 존재한다. 이는 각각의 장단점을 지니고 있기 때문에 어떨 때 어떤 방식을 사용하는 것이 바람직할지 고민하여 상황에 따른 구현 방식을 채택하는 것이 좋을 것 같다. 또한, UserService를 구현하는데에 있어서 무엇보다 중요한 것은 보안이다. 기능을 구현하고 끝나는 것이 아니라, 어떻게 하면 더 보안을 강화할 수 있을까 끊임없이 고민하고 이를 적용하는 것이 좋은 개발자가 되기 위한 자세가 아닐까!

profile
안녕하세요, 프론트엔드 개발자 임정훈입니다.

7개의 댓글

comment-user-thumbnail
2024년 4월 1일

안녕하세요! 덕분에 로그인에 성공하면 브라우저에 토큰을 발급받는 것 까지 구현했습니다.
그런데 프론트측에서는 어떻게 서버에 요청해야 할지 감이 안잡혀 질문드립니다. ㅠ
메인페이지로 이동할때 토큰을 검증하는 방식을 구현중인데 axios를 사용하고 헤더에 쿠키를 담아도 인증이 안되고
여러방법을 써봐도 안되네요,,

혹시 예제 코드나 조언을 받을 수 있을까요?

2개의 답글