API 테스트

ReviewController

package com.jh.jwt.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/reviews")
@RequiredArgsConstructor
public class ReviewController {

    @PostMapping
    public ResponseEntity<String> writeReview(Authentication authentication) {
        System.out.println("authentication = " + authentication);
//        System.out.println("authentication.getAuthorities() = " + authentication.getAuthorities());
        return ResponseEntity.ok().body(authentication.getName() + "님의 리뷰 등록이 완료 되었습니다.");
    }
}

ReviewController를 작성하여 해당 API를 호출하는 테스트를 진행하였습니다. 해당 API는 토큰이 없으면 호출할 수 없는 API이며, 포스트맨을 이용하여 테스트를 진행하였습니다.

테스트 진행

  1. 회원가입: localhost/api/v1/users/sign을 호출했을 때 정상적으로 가입됨스크린샷 2023-10-24 오후 10 45 18

  2. 로그인: localhost/api/v1/users/login을 호출하여 성공하면 accessToken과 refreshToken을 전달받는다.스크린샷 2023-10-24 오후 10 45 33

  3. 리뷰등록: localhost/api/v1/reviews을 호출했을 때 accessToken이 정상적으로 존재하면 등록되고 아니면 에러가 발생한다.

  • 성공적으로 리뷰가 등록되었을 때

    스크린샷 2023-10-24 오후 10 46 01
  • 토큰을 전달하지 않고 리뷰를 등록하려할 때

    스크린샷 2023-10-24 오후 11 12 19
  1. 리프레시: localhost/api/v1/users/refresh AccessToken이 만료되었을 때 실행되는 API로 다시 accessToken과 refreshToken을 전달해준다.스크린샷 2023-10-24 오후 10 53 34

  2. 로그아웃: localhost/api/v1/users/logout 로그아웃을 하게되면 redis에 accessToken을 블랙리스트로 등록한다.스크린샷 2023-10-24 오후 10 56 38

redisdb에 정상적으로 accessToken이 등록됨(일정시간이 지나면 삭제된다.)

스크린샷 2023-10-24 오후 11 01 11

JWT토큰 예외처리

AuthenticationConfig

.addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)

AuthenticationConfig에서 필터를 거치기 전에 ExceptionFilter를 거치며 에러를 처리할 수 있도록 코드를 추가해준다.

JWTExceptionFilter

package com.jh.jwt.exception;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.SignatureException;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// 발생할 수 있는 에러를 만들어 setErrorResponse로 전달
@Component
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (MalformedJwtException e){
            log.error("JWT Filter MalformedJwtException Exception");
            setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
        } catch (ExpiredJwtException e) {
            log.error("JWT Filter ExpiredJwtException Exception");
            setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
        } catch (UnsupportedJwtException e) {
            log.error("JWT Filter UnsupportedJwtException Exception");
            setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
        } catch (SignatureException e) {
            log.error("JWT Filter SignatureException Exception");
            setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
        } catch (IllegalArgumentException e) {
            log.error("JWT Filter IllegalArgumentException Exception");
            setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
        }
    }

    // 에러가 발생되었을 때 전달받은 에러에 맞도록 세팅 후 반환
    public void setErrorResponse(HttpServletResponse response, HttpStatus status, Throwable e) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(status.value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        ErrorResponse errorResponse = new ErrorResponse("error",status, e.getMessage());
        try {
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    // 반환 타입 Dto
    @Data
    public static class ErrorResponse {
        private final String status;
        private final HttpStatus code;
        private final String message;
    }
}

JWT토큰을 사용하며 발생할 수있는 에러를 처리한다. 에러가 발생하게 되면 doFilterInternal 함수를 통해 에러가 잡히게 되고 해당 에러는 setErrorResponse를 통하여 사용자에게 전달되도록 되어있다. 이를 통해 JWT에서 발생하는 에러를 관리할 수 있다.


로직 예외처리

package com.jh.jwt.exception;

import lombok.Data;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(UsernameNotFoundException.class)
    public ResponseEntity<Object> usernameNotFoundExceptionHandler(UsernameNotFoundException e) {
        ErrorResponse errorResponse = new ErrorResponse("error", 404, e.getMessage());
        return ResponseEntity.badRequest().body(errorResponse);
    }

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<Object> badCredentialsExceptionHandler(BadCredentialsException e) {
        ErrorResponse errorResponse = new ErrorResponse("error", 400, e.getMessage());
        return ResponseEntity.badRequest().body(errorResponse);
    }

    @ExceptionHandler(NotLoggedInException.class)
    public ResponseEntity<Object> NotLoggedInExceptionHandler(NotLoggedInException e) {
        ErrorResponse errorResponse = new ErrorResponse("error", 400, e.getMessage());
        return ResponseEntity.badRequest().body(errorResponse);
    }

    @Data
    public static class ErrorResponse {
        private final String status;
        private final Integer code;
        private final String message;
    }
}

ControllerAdvice

@ControllerAdvice는 이용하여 모든 컨트롤러에서 발생할 수 있는 예외를 잡아 처리해주는 어노테이션이다. 해당 어노테이션을 사용하여 발생할 수 있는 예외들을 관리할 수 있다.

ExceptionHandler

@ExceptionHandler를 사용하면 value로 원하는 예외를 지정하고 핸들링이 가능하다. 이는 지정한 예외 뿐 아니라 예외의 자식클래스도 모두 캐치하여 지정된 응답을 반환하게 된다.


NotLoggedInException

package com.jh.jwt.exception;

public class NotLoggedInException extends RuntimeException {
    public NotLoggedInException() {
    }

    public NotLoggedInException(String message) {
        super(message);
    }

    public NotLoggedInException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotLoggedInException(Throwable cause) {
        super(cause);
    }
}

로그인 되지 않았을 때의 에러를 Custom으로 만듬

로그아웃 흐름

Redis를 사용해서 로그아웃을 요청한 AccessToken을 블랙리스트에 등록해둬야합니다. AccessToken의 유효기간이 남아있다면 탈취되었을 경우 악용할 수 있기 때문입니다. 그렇기 때문에 Redis에 토큰을 저장해둠으로써 해당 토큰으로 접근하는 것을 막을 수 있습니다.

  1. 클라이언트는 로그아웃을 요청한다.
  2. 토큰값이 유효하다면 로그아웃 처리를 하면서 해당 AccessToken을 Redis에 블랙리스트로 등록한다(Redis DB에 저장).
  3. 블랙리스트로 등록된 AccessToken은 서버에 접근할 수 없다.
  4. 블랙리스트로 등록된 토큰은 만료시간이 지나면 자동으로 삭제된다.

RedisProperties

Redis에 연결하기 위한 기본 정보 Spring.redis에서 데이터를 가져온다.

package com.jh.jwt.configuration;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedisProperties {

    private int port;
    private String host;
}

RedisConfig

package com.jh.jwt.configuration;


import com.jh.jwt.utils.KeyExpiredMessageListener;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // RedisProperties를 통해 host와 port를 가져와 Redis에 연결한다.
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }

    // 만료된 토큰을 제거하기 위하여 MessageListenerAdapter, RedisMessageListenerContainer를 구성해서 메시지 리스너를 등록
    // "__keyevent@*__:expired"이러한 패턴 채널로부터 만료된 키에 대한 메시지를 수신하고 메시지가 도착하면 KeyExpiredMessageListener     // 를 호출하게 된다.
    @Bean
    public MessageListenerAdapter messageListenerAdapter() {
        return new MessageListenerAdapter(new KeyExpiredMessageListener());
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory,
                                                                       MessageListenerAdapter         
                                                                       messageListenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(messageListenerAdapter, new PatternTopic("__keyevent@*__:expired"));
        return container;
    }
}

RedisUtil

Redis를 좀 더 쉽게 만들어주기 위해 Util을 생성

package com.jh.jwt.utils;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
@RequiredArgsConstructor
public class RedisUtil {

    private final RedisTemplate<String, Object> redisTemplate;
    private final  RedisTemplate<String, Object> redisBlackListTemplate;

    public void set(String key, Object o, int minutes) {
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
        redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    // Redis의 저장된 값을 불러온다.
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    // Redis의 저장된 값을 삭제
    public boolean delete(String key) {
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }

    // 키값이 존재하는지 확인
    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    // 블랙리스트로 등록(Redis에 저장)
    public void setBlackList(String key, Object o, int minutes) {
        redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
        // Key: value 형태로 저장되며 minutes를 통해 key의 유효기간을 설정하여 자동으로 삭제될 시점을 정한다.
        redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object getBlackList(String key) {
        return redisBlackListTemplate.opsForValue().get(key);
    }

    public boolean deleteBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
    }

    public boolean hasKeyBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
    }
}

KeyExpiredMessageListener

메시지가 도달하였을 때 실행되는 리스너이며 해당 클래스를 통해 삭제 기능이 수행된다.

package com.jh.jwt.utils;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.RedisTemplate;

@RequiredArgsConstructor
public class KeyExpiredMessageListener implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        // 삭제될 key를 불러온다.
        String expiredKey = message.toString();

        // 만료된 키를 삭제한다.
        redisTemplate.delete(expiredKey);

    }
}

인증 API 구현

UserController

package com.jh.jwt.controller;

import com.jh.jwt.service.UserService;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.http.parser.Authorization;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/login")
    public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto dto) {
        return ResponseEntity.ok().body(userService.login(dto.getUsername(), dto.getPassword()));
    }

    @PostMapping("/sign")
    public Long sign(@RequestBody RequestSignupDto dto) {
        return ResponseEntity.ok().body(userService.join(dto.getUsername(), dto.getPassword(), dto.getAuth())).getBody();
    }

    @PostMapping("/refresh")
    public ResponseEntity<LoginResponseDto> refresh(HttpServletRequest request) {
        return ResponseEntity.ok().body(userService.refresh(request));
    }

    @PostMapping("/logout")
    public ResponseEntity<String> logout(HttpServletRequest request) {
        return ResponseEntity.ok().body(userService.logout(request));
    }

    @Data
    public static class RefreshTokenRequestDto {
        private String token;
    }

    @Data
    public static class LoginRequestDto {
        private String username;
        private String password;
    }

    @Data
    public static class RequestSignupDto {
        String username;
        String password;
        String auth;
    }
}
  • 기본적으로 로그인, 회원가입, 리프레시 기능이 있음
  • 리프레시는 accessToken이 만료되었을 때 호출하는 API
  • Dto는 요청 받을 때 필요한 타입들을 명시해둠

LoginResponseDto

package com.jh.jwt.controller;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class LoginResponseDto {
    private String accessToken;
    private String refreshToken;

    public LoginResponseDto(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}
  • 로그인이나 리프레시가 성공적으로 수행되었을 때 반환되는 형태

UserService

package com.jh.jwt.service;

import com.jh.jwt.controller.LoginResponseDto;
import com.jh.jwt.controller.UserController;
import com.jh.jwt.domain.Authority;
import com.jh.jwt.domain.CustomUserDetails;
import com.jh.jwt.domain.User;
import com.jh.jwt.repository.UserRepository;
import com.jh.jwt.utils.RedisUtil;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.servlet.http.HttpServletRequest;
import java.util.*;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    private final RedisUtil redisUtil;
    protected final Logger logger = LoggerFactory.getLogger(UserService.class);

    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;

    @Transactional
    public Long join(String username, String password, String auth){
        User user = User.builder()
                .username(username)
                .password(passwordEncoder.encode(password))
                .authority(Authority.valueOf(auth))
                .build();
        return userRepository.save(user);
    }

    @Transactional
    public LoginResponseDto login(String username, String password){
        // 회원 validation
        List<User> users = userRepository.findByUsername(username);
        if (users.isEmpty()) {
            throw new UsernameNotFoundException("존재하지 않는 회원입니다.");
        }
        // 비밀번호 체크
        if (!passwordEncoder.matches(password, users.get(0).getPassword())) {
            throw new BadCredentialsException("비밀번호가 틀렸습니다.");
        }

        // username과 password를 통하여 UsernamePasswordAuthenticationToken을 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        logger.debug("JwtAuthenticationFilter : 토큰생성완료");

        // authenticationToken을 통하여 Authentication을 생성
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // authentication을 통해 미리 정의했던 CustomUserDetails형태의 principal을 생성
        CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal();

        // principal을 이용하여 Jwt토큰 생성
        String accessToken = tokenProvider.createAccessJwt(principal);
        String refreshToken = tokenProvider.createRefreshJwt(principal);

        users.get(0).setRefreshToken(refreshToken);


        return LoginResponseDto.builder().accessToken(accessToken).refreshToken(refreshToken).build();
    }

    // AccessToken이 만료되었지만 refreshToken이 유효하다면 Token들을 재발급 해준다.
    public LoginResponseDto refresh(HttpServletRequest request) {
        // 헤더에서 refreshToken정보를 꺼내온다.
        String refreshToken = tokenProvider.resolveToken(request);

        // refreshToken 유효성 검사
        boolean validate = tokenProvider.validateRefreshToken(refreshToken);

        // user 정보로 정의했던 CustomUserDetails를 만들어 Jwt토큰 생성
        String username = tokenProvider.getRefreshUsername(refreshToken);
        List<User> user = userRepository.findByUsername(username);

        if (!user.get(0).getRefreshToken().equals(refreshToken)){
            throw new BadCredentialsException("잘못된 접근입니다.");
        }

        CustomUserDetails cud = new CustomUserDetails(user.get(0));

        String newAccessToken = tokenProvider.createAccessJwt(cud);
        String newRefreshToken = tokenProvider.createRefreshJwt(cud);
        return LoginResponseDto.builder().accessToken(newAccessToken).refreshToken(newRefreshToken).build();
    }

    // 레디스 블랙리스트에 accessToken값을 저장
    // accessToken이 만료되지 않았을 때 로그아웃하면 재사용이 불가능 하도록 Redis에 저장해둠
    public String logout(HttpServletRequest request) {
        String accessToken = tokenProvider.resolveToken(request);
        String username = tokenProvider.getUsername(accessToken);
        List<User> user = userRepository.findByUsername(username);

        user.get(0).setRefreshToken("");
        redisUtil.setBlackList(accessToken, "accessToken", 5);
        return "로그아웃";
    }
}

UserRepository

package com.jh.jwt.repository;

import com.jh.jwt.domain.User;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;

@Repository
public class UserRepository{
    @PersistenceContext
    private EntityManager em;

    // 데이터베이스에 사용자 저장
    public Long save(User user) { em.persist(user); return user.getId();}

    // 사용자이름을 통해 사용자 조회
    public List<User> findByUsername(String username) {
        return em.createQuery("select u from User u where u.username = :username", User.class)
                .setParameter("username", username)
                .getResultList();
    }

    // 사용자 id를 통해 사용자 조회
    public User findOne(Long id) { return em.find(User.class, id);}
}

로그아웃과 관련된 내용은 다음 게시글에 Redis 설정부터 올리겠습니다.

Configuration 구현

AuthenticationConfig

스프링 시큐리티의 기본적인 웹 보안 활성화하고, 메서드를 재정의하여 인증과 권한을 설정할 수 있는 설정파일

package com.jh.jwt.configuration;

import com.jh.jwt.exception.JwtExceptionFilter;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig {

    private final TokenProvider tokenProvider;

    // 비밀번호 인코딩에 BcryptPasswordEncoder 사용
    @Bean
    public BCryptPasswordEncoder encodePassword() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                // JWT를 사용하기 때문에 httpBasic, csrf를 disable
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                // API 경로 설정
                .antMatchers("/api/v1/users/login").permitAll() // 권한없이 사용할 수 있는 api
                .antMatchers("/api/v1/users/sign").permitAll() // 권한없이 사용할 수 있는 api
                .antMatchers(HttpMethod.POST, "/api/v1/reviews").authenticated() // JWT인증이 필요한 api

                // 세션도 사용하지 않기때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                // 필터를 거치기 이전에 JwtFilter를 먼저 거치도록 설정
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                // Jwt 필터를 거치기 전에 Exception 관리를 위해 JwtException Filter를 등록
                .addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)
                .build();
    }
}
  • @EnableWebSecurity는 스프링 스큐리티의 기본적인 웹 보안을 활성화 합니다.
  • 추가로 antMathcer.hasRole("ADMIN")을 이용하면 ADMIN권한을 가진사람만 api를 사용할 수 있도록 설정할 수 있다.

TokenProvider

토큰을 생성하고 검증하는 역할과 토큰의 정보를 꺼내는 역할을 수행

package com.jh.jwt.utils;

import com.jh.jwt.domain.CustomUserDetails;
import com.jh.jwt.exception.NotLoggedInException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;

public class TokenProvider {

    protected final String secret;
    protected final String refreshSecret;

    protected final long accessTokenExpired;
    protected final long refreshTokenExpired;

    private final RedisUtil redisUtil;

    protected Key accessKey;
    protected Key refreshKey;
    public TokenProvider(String secret, Long expiredMs, String refreshSecret, Long refreshTokenExpired, RedisUtil redisUtil){
        this.secret = secret;
        this.accessTokenExpired = expiredMs;
        this.refreshSecret = refreshSecret;
        this.refreshTokenExpired = refreshTokenExpired;

        byte[] accessKeyBytes = Base64.getDecoder().decode(secret);
        this.accessKey = Keys.hmacShaKeyFor(accessKeyBytes);

        byte[] refreshKeyBytes = Base64.getDecoder().decode(refreshSecret);
        this.refreshKey = Keys.hmacShaKeyFor(refreshKeyBytes);

        this.redisUtil = redisUtil;
    }

    // Access 토큰에서 사용자명 반환
    public String getUsername(String token) {
        return Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody().get("username", String.class);
    }

    // Refresh토큰에서 사용자명 반환
    public String getRefreshUsername(String token) {
        return Jwts.parserBuilder().setSigningKey(refreshKey).build().parseClaimsJws(token).getBody().get("username", String.class);
    }

    // 토큰에서 권한정보 반환
    public Collection<? extends GrantedAuthority> getAuthority(String token) {
        Claims claims = Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody();
        return Arrays.stream(claims.get("auth").toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    // Access 토큰 생성
    public String createAccessJwt(CustomUserDetails principal) {
        Claims claims = Jwts.claims();
        claims.put("username", principal.getUsername());
        claims.put("auth", principal.getAuthorities());

        return  Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpired))
                .signWith(accessKey, SignatureAlgorithm.HS512)
                .compact();
    }

    // Refresh 토큰 생성
    public String createRefreshJwt(CustomUserDetails principal) {
        Claims claims = Jwts.claims();
        claims.put("username", principal.getUsername());

        return  Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpired))
                .signWith(refreshKey, SignatureAlgorithm.HS512)
                .compact();
    }

    // 헤더 Authorization에서 토큰정보 추출
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
    // Access토큰 토큰 유효성 검사
    public boolean validateToken(String token) {
        try {
            Claims claims = Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody();
            if (redisUtil.hasKeyBlackList(token)) {
                throw new SignatureException("로그인을 해주세요.");
            }
            return true;
        } catch(MalformedJwtException | SignatureException e) {
            throw new MalformedJwtException("잘못된 JWT 서명입니다.");
        } catch(ExpiredJwtException e) {
            throw new ExpiredJwtException(e.getHeader(), e.getClaims(), "만료된 JWT 토큰입니다.");
        } catch(UnsupportedJwtException e) {
            throw new UnsupportedJwtException("지원하지 않는 JWT 토큰입니다.");
        } catch(IllegalArgumentException e) {
            throw new IllegalArgumentException("JWT 토큰이 잘못되었습니다.");
        }
    }

    // Refresh 토큰 유효성 검사
    public boolean validateRefreshToken(String token) {
        try {
            Claims claims = Jwts.parserBuilder().setSigningKey(refreshKey).build().parseClaimsJws(token).getBody();
            return true;
        } catch(MalformedJwtException | SignatureException  |  ExpiredJwtException |  UnsupportedJwtException | IllegalArgumentException e) {
            System.out.println("e = " + e);
            throw new NotLoggedInException("로그인을 해주세요.");
        }
    }

}

JwtFilter

실직적인 인증절차를 진행하는 설정파일

package com.jh.jwt.configuration;

import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;

// 토큰 인증 계층
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    protected final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // authorization이 null이면 SecurityFilterChain을 거치도록한다.
            final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
            if(authorization == null){
                filterChain.doFilter(request, response);
                return;
            }
            String token = tokenProvider.resolveToken(request);
            if (tokenProvider.validateToken(token)){

                // Username, Authorities Token에서 꺼내기
                String username = tokenProvider.getUsername(token);
                Collection<? extends GrantedAuthority>  authority = tokenProvider.getAuthority(token);
                logger.info("username: {}", username);
                logger.info("authority: {}", authority);

                // 권한 부여
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(username, null, authority);

                // 해당 authenticationToken을 스프링 시큐리티 컨텍스트에 저장한다.
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                filterChain.doFilter(request, response);
        }
    }
}
  • OncePerRequestFilter는 Request한번에 한번만 실행되는 Filter입니다.
  • doFilterInternal에서 토큰의 유효성을 검증하고, 토큰에 들어있는 username과 authority를 가져와 authentication을 생성하고 스프링 시큐리티 컨텍스트에 저장하는 역할을 합니다.

JWTConfig

TokenProvider에 의존성을 주입하고 빈을 생성하는 역할을 하는 설정파일

package com.jh.jwt.configuration;

import com.jh.jwt.utils.RedisUtil;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// TokenProvider에 의존성을 주입하고 빈을 생성하는 설정 파일
@Configuration
@RequiredArgsConstructor
public class JwtConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access_token_expired}")
    private Long accessTokenExpired;

    @Value("${jwt.refresh_secret}")
    private String refreshSecret;

    @Value("${jwt.refresh_token_expired}")
    private Long refreshTokenExpired;

    private final RedisUtil redisUtil;

    @Bean(name = "tokenProvider")
    public TokenProvider tokenProvider() {
        return new TokenProvider(secret, accessTokenExpired, refreshSecret, refreshTokenExpired, redisUtil);
    }
}

참고

멋사 SpringSecurity 인증인가 - 04 Token유효한지 만료되었는지 검사하기
https://velog.io/@suhongkim98/Spring-Security-JWT로 인증인가구현하기

엔티티 구현

User

인증에 사용되는 사용자 클래스 구현

package com.jh.jwt.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 100, nullable = false, unique = true)
    private String username;

    @Column(length = 300, nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    private Authority authority;

    private String refreshToken;

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }
}

 

Authority

인가에 사용되는 권한 Enum 클래스 구현

package com.jh.jwt.domain;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Authority {
    ADMIN("ROLE_ADMIN"), USER("ROLE_USER");

    private final String key;
}

 

CustomUserDetails구현

CustomUserDetails

Spring Security의 UserDetail을 상속받아 User엔티티에 맞도록 CustomUserDetails 클래스 구현

package com.jh.jwt.domain;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Data
public class CustomUserDetails implements UserDetails {

    private final User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> user.getAuthority().getKey());
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

JWT를 선택하는 이유

JWT를 선택하는 것에는 다양한 이유가 존재합니다.

  1. Stateless
    • 서버 측에서 세션을 유지하지 않기 떄문에 상태 정보를 저장하고 확인하지 않아 서버 부하의 원인이 되지 않고, 여러 대의 서버나 분산 시스템으로 구성된 환경에서도 동일한 토큰을 사용하여 인증 처리가 가능합니다.
  2. Scalable
    • 디바이스별 요청마다 발급되는 토큰의 값만 확인하고 체크하는 행위로 안전하게 접근 제어 및 권한 부여등 작업을 처리할 수 있어 확장에 용이합니다.
  3. Extensibility
    • 로그인 정보가 사용되는 분야의 확장이 가능합니다. 이는 토큰을 사용하여 다른 서비스에서 권한을 공유할 수있는 것을 말합니다.(소셜 로그인)
  4. Security
    • HTTPS와 함께 사용하면 더 안전하게 사용할 수 있습니다.

하지만 이 토이 프로젝트를 진행한 이유는 한번도 사용해본적 없고 많은 기업에서 사용하기에 구현해보기 위해 JWT를 이용해보기로 하였습니다.

 

JWT 인증방식은 이전글에서 설명하여 생략

 

1. 스프링부트 프로젝트 생성

JAVA 버전 11로 환경을 구성하여 세팅하였습니다.

 

2. 의존성 설정

스프링 스타터 패키지, JPA, security, redis, jwt, lombok, mariadb, configuration-processor에 관한 의존성을 추가해줍니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

 

3. application.yml 세팅

DB는 Mariadb를 사용하기 때문에 MariaDb에 관한 세팅을 해주었습니다.
redis는 로그아웃할 떄 이용하기 때문에 설정을 추가해줍니다.
jwt의 secret관련 부분은 HS512알고리즘을 사용하기 때문에 64바이트 이상의 길이를 가진 secret으로 설정해줍니다.

spring:
    datasource:
        url: jdbc:mariadb://localhost:3306/{DbName}?characterEncoding=UTF-8&serverTimezone=UTC
        username: root
        password: 1234
        driver-class-name: org.mariadb.jdbc.Driver
    jpa:
        hibernate:
            ddl-auto: create
        properties:
            hibernate:
                format_sql: true
                default_batch_fetch_size: 100
    redis:
        pool:
            min-idle: 0
            max-idle: 8
            max-active: 8
        port: 6379
        host: localhost
logging:
    level:
        org.hibernate.SQL: debug
        org.hibernate.type: trace

jwt:
    header: Authorization
    secret: eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJQc3NyIERldiIsIlVzZXJuYW1lIjoiUHJlc2lkZW50aWFsIFByb3RlY3Rpb24gQW5kIFNlY3VyaXR5IiwiaWF0IjoxNjg3MDA0Mjg1fQ
    refresh_secret: RUUyNkIwREQ0QUY3RTc0OUFBMUE4RUUzQzEwQUU5OTIzRjYxODk4MDc3MkU0NzNGODgxOUE1RDQ5NDBFMERCMjdBQzE4NUY4QTBFMUQ1Rjg0Rjg4QkM4ODdGRDY3QjE0MzczMkMzMDRDQzVGQTlBRDhFNkY1N0Y1MDAyOEE4RkY=
    access_token_expired: 360000
    refresh_token_expired: 1209600000

JWT(Json Web Token) 이란 무엇인가?

사전적 정의
JWT는 Json Web Token의 약자로, 웹 표준으로 지정된 RFC 7519에 따라 생성되는 인증 토큰이다. 인증에 필요한 최소한의 정보를 암호화시켜 만든 토큰을 통해 서버와 클라이언트가 안전하게 정보를 교환할 수 있다.

 

세션 인증 방식의 경우 Stateful 방식으로 매 요청마다 서버와 통신을 해왔고, 이는 사용자가 많아질수록 많은 트래픽이 발생하여 서버 부하의 원인이 되었습니다. JWT는 이러한 문제점들을 해결하기 위해 등장하게 되었고, 토큰안에 인증에 필요한 정보를 보관함으로 요청이 될 때 마다 서버와 통신을 할 필요가 없게 되어 서버 부하와 같은 문제들을 해결할 수 있었다.

 

하지만 JWT는 한번 발급되면 만료기간까지 유효하여 탈취에 대한 보안적인 이슈가 생길 수 있습니다. 이를 해결하기 위해 토큰 만료기간을 짧게 설정하여 토큰이 자주 변경되게 할 수 있고, JWT는 토큰을 저장할 때 httpOnly 쿠키에 저장하는 것을 권장한다.

 

JWT 인증 방식

AccessToken과 RefreshToken을 사용했을 때 인증 방식

 

1. ID, PW로 로그인을 하게되면 AccessToken과 RefreshToken을 발급받는다.

2. AccessToken를 통해 인증을 받아 API를 실행할 수 있다.

3. AccessToken이 만료되면 만료 에러가 발생된다.

4. 만료에러가 발생하였을 때 AT을 재발급 받기 위해 RT를 통해 /refresh를 호출한다.

5. RT가 정상적이라면 NEW AT를 사용자에게 전달해준다.

 

JWT 구조

JWT는 세 부분으로 구성됩니다.

1. Header

헤더는 일반적으로 토큰 유형(JWT)과 사용 중인 서명 알고리즘(예: HMAC SHA256 또는 RSA)의 두 부분으로 구성되어 있다.

HEADER:ALGORITHM & TOKEN TYPE
{
  "alg": "HS256",
  "typ": "JWT"
}

 

2. Payload

페이로드는 세가지 유형으로 구성되어있다.

1). Registered claims

필수는 아니지만 상호 운용 가능한 클레임을 제공하기 위해 권장되는 미리 정의된 클레임의 집합, 그중 일부는 iss(issuer), exp(expiration time), sub(subject), aud(audience), 기타 

 

2). Public claims

JWT를 사용하는 사람들이 마음대로 정의할 수 있지만 클레임 이름의 충돌을 방지하기 위해 IANA에 정의하거나 충돌이 방지된 URI로 정의해야 한다.

 

3). Private claims

사용자와 서버의 협의하에 사용되는 클레임의 이름으로 Registered, Public 둘다 아니다.

PAYLOAD:DATA
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

 

3. Signature

서명 부분은 인코딩된 헤더, 인코딩된 페이로드, 비밀키와 헤더에 지정된 알고리즘을 통해 서명된다.

VERIFY SIGNATURE
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret

)

 

이렇게 만들어진 세가지 요소를 JWT키로 만들게 되면 헤더와 페이로드가 인코딩되고 암호로 서명된 JWT를 보여준다.

인코딩 디코딩 참고: https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

+ Recent posts