JWT(Json Web Token) : 서버가 로그인 상태를 저장하지 않고, JWT 자체에 모든 인증 정보가 담겨있는 인증 방식

동작 흐름

  1. 사용자 로그인
  2. 토큰 생성 및 전송 : 서버가 사용자의 정보와 권한 등을 담은 암호화 토큰을 생성하여 클라이언트에게 보냄 (서버는 저장 X)
  3. 토큰 저장 : 클라이언트는 스토리지나 쿠키에 토큰 저장
  4. 요청과 검증 : 클라이언트는 HTTP 헤더에 토큰을 실어 요청을 보냄 → 서버는 토큰의 서명을 검증하여 유효성 확인

JWT를 사용한 로그인

  1. build.gradle에 의존성 추가

    implementation "io.jsonwebtoken:jjwt-api:0.12.5"
    runtimeOnly  "io.jsonwebtoken:jjwt-impl:0.12.5"
    runtimeOnly  "io.jsonwebtoken:jjwt-jackson:0.12.5"
    
  2. JwtUtil 클래스 생성 : init(), generateToken(), get(), validateToken() 메서드 생성

    jwt:
      secret:
        key: HiMyNameIsBellaNowImStudyingJwtItsNotReallyHardButItsKindOfComplicated
    
    // Bean으로 등록
    @Component
    public class JwtUtil {
    
        // 암호화 키(yml 파일에서 불러오기) : 그냥 사용해도 상관없지만, 유지보수를 위해 yml 파일에서 생성하는 것이 좋음
        @Value("${jwt.secret.key}")
        private String secretKeyString;
    
        // JWT 라이브러리에서 사용하는 객체
        private SecretKey key; // secretKey 문자열을 비밀키로 만들어 넣어주는 객체 -> 암호화, 복호화할 때 사용
        private JwtParser parser; // 복호화할 때 사용하는 객체
        
        private final long TOKEN_TIME = 60 * 60 * 1000L; // JWT 토큰의 만료 시간(밀리초 단위, 여기서는 60분)
        
        // 내가 지정한 문자열을 JWT에서 사용하는 객체로 변환
        @PostConstruct // 서버를 실행할 때 가장 먼저 실행되어야 하기 때문에 Bean 중에 가장 먼저 실행할 수 있도록 어노테이션 추가
        public void init() {
            // 문자열인 시크릿키를 비트 단위로 분해
            byte[] bytes = Decoders.BASE64.decode(secretKeyString);
            // 암호화를 시킨 뒤에 JWT에서 사용할 객체에 저장 -> JWT에서 제공한 암호화 로직을 거쳐 한 번 더 보안을 강화하는 것
            this.key = Keys.hmacShaKeyFor(bytes);
            // 암호화된 키를 사용해서 parser 객체를 만들어주는 것
            this.parser = Jwts.parser().verifyWith(this.key).build();
        }
    
        // 토큰 생성
        public String generateToken(long userId) {
            Date date = new Date();
            
            return Jwts.builder()
                    .claim("userId", userId) // 토큰에 들어갈 payload -> 유저ID넣어주기
                    .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 설정
                    .setIssuedAt(date) // 발급 시간 설정
                    .signWith(this.key, Jwts.SIG.HS256) // 토큰에 들어갈 header(signature) 암호화 -> key를 사용하고, HS256 암호화 방식을 사용
                    .compact();
        }
    
        // 토큰 안에 있는 정보 가져오기
        public Claims getAllData(String jwt) {
            // 암호화된 토큰을 풀어주기 -> jwt 토큰 중에 Payload 값을 가져오기
            return parser.parseSignedClaims(jwt).getPayload();
        }
    
        // 토큰 안의 유저ID만 가져오기
        public String getUserId(String jwt) {
            return parser.parseSignedClaims(jwt).getPayload().get("userId", String.class);
        }
    }
    
  3. 로그인 시 토큰 생성하기

    // Controller
    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody LoginRequest request, HttpSession session) {
    		// Service에서 생성된 토큰을 반환
        return ResponseEntity.ok(userService.login(request));
    }
    
    // Service
    @Transactional(readOnly = true)
    public String login(LoginRequest request) {
    
        User user = userRepository.findByEmail(request.getEmail()).orElseThrow(() -> new CustomException(NOT_VALID_LOGIN));
    
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new CustomException(NOT_VALID_LOGIN);
        }
    
    		// generateToken을 통해 토큰 생성 후 반환
        return jwtUtil.generateToken(user.getId());
    }
    

JWT 필터로 로그인 확인하기

  1. JwtUtil 클래스 : 토큰의 유효성을 확인하는 validateToken() 메서드 생성

    // Bean으로 등록
    @Component
    public class JwtUtil {
    
        ...
    
        // 토큰의 유효성 확인
        public boolean validateToken(String jwt) {
            // 토큰이 비어있거나 공백인지 확인
            if (jwt == null || jwt.isBlank()) {
                return false;
            }
    				
    				// 토큰이 유효한지 확인
            try {
                parser.parseSignedClaims(jwt);
                return true;
            } catch (SecurityException | MalformedJwtException | SignatureException e) {
                // 토큰 서명이 잘못되었거나, 잘못된 형식의 JWT가 전달된 경우
                log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            } catch (ExpiredJwtException e) {
                // 토큰이 만료된 경우
                log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            } catch (UnsupportedJwtException e) {
                // 지원되지 않는 JWT 형식이 전달된 경우
                log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            } catch (IllegalArgumentException e) {
                // JWT 클레임이 비어 있거나 잘못된 형식일 경우
                log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
            }
            return false; // 예외가 발생한 경우 토큰이 유효하지 않음
        }
    }
    
  2. JwtFilter 생성

    @Slf4j(topic = "JwtFilter")
    @Component
    @RequiredArgsConstructor
    public class JwtFilter extends OncePerRequestFilter {
    
        private final JwtUtil jwtUtil;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
            String requestUrl = request.getRequestURI();
    
            // 로그인 API에 대해서는 로그인 인증 인가를 검증하지 않음
            if (requestUrl.equals("/users/login")) {
                filterChain.doFilter(request, response);
            } else {
    		        // JWT 검증
    		        // 1단계 : header에 Authorization 값이 있는지 앖는지 확인
    		        String authorizationHeader = request.getHeader("Authorization"); // HTTP header 중 Authorization 키 값에 넣어서 토큰을 보냄
    		        
    		        // 2단계 : Authorization 안의 값이 Bearer 토큰인지 확인(다른 값을 넣어서 보낼 수도 있기 때문) -> 토큰을 보낼 때 'Bearer ...'로 prefix를 붙여서 보내주고 있음
    		        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
    		            // 토큰이 없기 때문에 예외 발생
    		            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 필요 합니다.");
    		            return;
    		        }
    		        
    		        // 3단계 : 유효한 토큰인지(내가 만든 토큰인지) 확인
    		        String jwt = authorizationHeader.substring(7); // 'Bearer '를 제외하고 순수한 jwt 값만 가져오기
    		        if (!jwtUtil.validateToken(jwt)) {
    		            // 유효하지 않은 토큰인 경우, 예외 발생
    		            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 필요 합니다.");
    		        }
    		
    		        // 유효성 검사 모두 통과한 경우
    		        // HttpServletRequest 안에 있는 attribute 저장 공간에 userId를 넣어서 컨트롤러에게 전달
    		        Long userId = Long.valueOf(jwtUtil.getUserId(jwt));
    		        request.setAttribute("userId", userId);
    		        filterChain.doFilter(request, response);
    	       }
        }
    }
    
  3. 로그인한 유저의 정보 받아오기

    // Controller
    public ResponseEntity<Void> getSchedule(HttpServletRequest request) {
        Long userId = (Long) request.getAttribute("userId");
        return ResponseEntity.ok().build();
    }
    

    <aside> 💡

    @RequestAttribute를 통해 @SessionAttribute처럼 사용 가능!

    // JwtFilter
    // HttpServletRequest 안에 있는 attribute 저장 공간에 SessionUser 객체를 만들어서 넣어주기
    request.setAttribute("loginUser", new SessionUser(userId, ":"));
    filterChain.doFilter(request, response);
    
    // Controller
    public ResponseEntity<Void> getSchedule(@RequestAttribute(name = "loginUser") SessionUser sessionUser) {
        sessionUser.getUserId();
        sessionUser.getUserEmail();
        return ResponseEntity.ok().build();
    }
    

    </aside>

JWT를 사용할 때의 로그아웃

JWT의 로그인 상태 확인 → 토큰이 있는지 없는지 / 있다면 내가 발급한 토큰이 맞는지 확인

⇒ 로그아웃 상태는 곧 토큰이 없는 상태!

토큰이 발급되면 프론트엔드에서 해당 토큰을 헤더에 넣어 이후의 내용들을 진행해줌

⇒ 로그아웃을 하면 프론트엔드에서 토큰을 헤더에서 지우면 됨!

즉, 백엔드에서는 로그아웃을 구현할 필요도 없다!