API 서버와 JWT - 1

유승욱·2024년 2월 22일
0

화면 없이 Ajax와 JSON을 이용해서 데이터를 주고받는 구조에서는 HttpSession이나 쿠키를 이용하는 기존의 인증 방식에 제한받게 된다. 이를 해결하기 위해서 인증받은 사용자들은 특정한 문자열(토큰)을 이용하게 되는데, 이때 많이 사용하는 것이 JWT(JSON Web Token)이다.
API 서버를 구성하기 위해 별도의 프로젝트를 만들고 스프링 시큐리티로 이를 처리해보도록 하자.

API 서버

API 서버는 쉽게 말해서 '필요한 데이터만 제공하는 서버'르 의미한다. API 서버는 하면을 제공하는 것이 아니라 필요한 데이터를 호출하고 결과를 반환 받는 방식으로 동작한다. 이러한 구성을 '클라이언트 사이드 렌더링(CSR)'이라고 한다.

토큰 기반의 인증

API 서버가 단순히 데이터만을 주고받을 때 외부에서 누구나 호출하는 URI를 알게 되면 문제가 생기게 된다. 그래서 이를 막기 위해 초창기 API 서버는 주로 API를 호출하는 프로그램의 IP주소를 이용해 API 서버를 호출하는 쪽의 IP와 API 서버 내에 보관된 IP를 비교해서 허용된 IP에서만 API서버에서 결과를 만들어주는 방식을 사용했었다.

이번 예제에서 사용할 방식은 토큰(token)을 이용하는 방식이다. 토큰은 일종의 '표식'과 같은 역할을 하는 데이터이다. 토큰은 서버와 클라이언트가 주고받는 '문자열'에 불과한데, API 서버를 이용하고자 하는 사람들은 API 서버에서 토큰을 받아 보관하고 호출할 때 자신이 가지고 있는 토큰을 같이 전달해서 API 서버에서 이를 확인하는 방식이다.

Access Token/Refresh Token의 의미

입장권에 해당하는 토큰을 API 서버에서는 'Access Token'이라고 한다. 'Access Token'은 말 그대로 '특정한 자원에 접글할 권한이 있는지를 검사'하기 위한 용도이다. Access Token은 입장권과 같기 때문에 외부에서 API 서버를 호출할 때 Access Token을 함께 전달하면 이를 이용해서 검증하고 그 결과에 따라서 요청을 처리한다.
그러나 만일 Access Token을 악의적인 사용장게 탈취당한다면 문제가 발생한다. 따라서 Access Token을 이용할 때는 최대한 유효 시간을 짧게 지정하고 사용자에게는 Access Token을 새로 발급받을 수 있는 Refresh Token이라는 것을 같이 생성해 주어서 필요할 대 다시 Access Token을 발급받을 수 있도록 구성한다.

Access Token, Refresh Token을 이용하는 정상적인 시나리오는 다음과 같다.

  1. 사용자는 API 서버로부터 Access Token과 Refresh Token을 받는다. 예를 들어 Access Token의 유효 기간은 1일, Refresh Token의 유효 기간은 10일이라고 가정한다.
  2. 사용자가 특정한 작업을 하기 위해서 AccessToken을 전달한다.
  3. 서버는 우선 Access Token을 검사하고 해당 토큰이 유효한지 확인해서 작업을 처리한다.

Access Token이 만료되는 상황을 생각해보자.

  1. 사용자가 Access Token을 전달한다. API 서버에서는 Access Token을 검증하는데 이 과정에서 '만료된' 토큰임을 확인하고 사용자에게 만료된 토큰임을 알려준다.
  2. 사용자는 자신이 가지고 있는 Refresh Token을 전송해서 새로운 Access Token을 요구한다. API 서버에서는 Refresh Token에 문제가 없다면 새로운 Access Token을 생성해서 전달한다. 이 과정에서 Refresh Token이 거의 만료된 상황이라면 새로은 Refresh Token을 같이 전송할 수도 있다.

예제 프로젝트 생성

예제 프로젝트에서 구현해야 하는 내용은 다음과 같다.

• Access Token과 Refresh Token의 생성 처리
• Access Token이 만료되었을 때의 처리
• Refresh Token의 검사와 만료가 얼마 남지 않은 Refresh Token의 갱신, 새로운 Access Token의 생성

CustomSecurityConfig
CustomSecurityConfig 클래스에서는 CSRF 토큰의 비활성화와 세션을 사용하지 않을 것을 지정한다.

@Configuration
@Log4j2
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class CustomSecurityConfig {


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        log.info("--------------web configure------------------");

        return (web -> web.ignoring()
                .requestMatchers(
                        PathRequest.toStaticResources().atCommonLocations()));
    }

    @Bean
    public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {

        http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable());

        http.sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

API 사용자 처리

API 서버를 통해서 토큰들을 얻을 수 있는 사용자들에 대한 처리를 진행한다. 추가하려는 사용자는 스프링 시큐리티를 통해서 처리되는 것이 아니기 때문에 간단히 아이디와 패스워드를 이용해서 토큰 생성을 요청한다고 가정하고 예제를 작성하겠다.

APIUser는 일반 웹 서비스와 달리 Access Key를 발급받을때 자신의 mid와 mpw를 이용하므로 다른 정보들 없이 구성하였다.

APIUser 데이터 생성과 확인

APIUserRepository

public interface APIUserRepository extends JpaRepository<APIUser, String> {
}

APIUserRepositoryTests

@SpringBootTest
public class APIUserRepositoryTests {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private APIUserRepository apiUserRepository;

    @Test
    public void testInserts() {
        IntStream.rangeClosed(1, 100).forEach(i -> {
            APIUser apiUser = APIUser.builder()
                    .mid("apiuser" + i)
                    .mpw(passwordEncoder.encode("1111"))
                    .build();

            apiUserRepository.save(apiUser);
        });
    }
}

스프링 시큐리티의 UserDetailsService와 DTO

사용자들의 인증 자체는 스프링 시큐리티의 기능을 그대로 활용하도록 구성해보자.
먼저 loadUserByUsername()의 결과를 처리하기 위해서 APIUserDTO 클래스를 추가한다.

APIUserDTO

@Getter
@Setter
@ToString
public class APIUserDTO extends User {

    private String mid;
    private String mpw;

    public APIUserDTO(String username, String password,
                      Collection<GrantedAuthority> authorities) {

        super(username, password, authorities);
        this.mid = username;
        this.mpw = password;
    }
}

APIUserDetailsService의 loadUserByUsername() 내부에는 해당 사용자가 존재할 때 APIUserDTO를 반환하도록 코드를 완성한다.(이 과정에서 모든 사용자는 'ROLE_USER' 권한을 가지도록 구성한다.)

APIUserDetailsService

@Service
@Log4j2
@RequiredArgsConstructor
public class APIUserDetailsService implements UserDetailsService {

    private final APIUserRepository apiUserRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Optional<APIUser> result = apiUserRepository.findById(username);

        APIUser apiUser = result.orElseThrow(() -> new UsernameNotFoundException("Cannot find mid"));

        log.info("APIUserDetailsService apiUser-------------------------------------------");

        APIUserDTO dto = new APIUserDTO(
                apiUser.getMid(),
                apiUser.getMpw(),
                List.of(new SimpleGrantedAuthority("ROLE_USER"))
        );

        log.info(dto);

        return dto;
    }
}

토큰 인증을 위한 시큐리티 필터

스프링 시큐리티는 수많은 필터로 구성되어 있고, 이를 이용해서 컨트롤러에 도달하기 전에 필요한 인증 처리를 진행할 수 있다. 예제에서는 다음과 같은 기능 구현에 필터를 이용하도록 하겠다.

  1. 사용자가 자신의 아이디(mid)와 패스워드(mpw)를 이용해서 Access Token과 Refresh Token을 얻으려는 단계를 구현
  2. 사용자가 Access Token을 이용해서 컨트롤러를 호출하고자 할 때 인증과 권한을 체크하는 기능을 구현

인증과 JWT 발행 처리

사용자의 아이디(mid)와 패스워드(mpw)를 이용해서 JWT 문자열을 발행하는 기능은 컨트롤러를 이용할 수도 있지만 스프링 시큐리티의 AbstractAuthenticationProcessingFilter 클래스를 이용하면 좀 더 완전한 분리가 가능하다.
예제에서는 APILoginFilter라는 필터를 이용해서 인증 단계를 처리하고, 인증에 성공했을 때는 Access Token과 Refresh Token을 전송하도록 구성하겠다.

APILoginFilter

@Log4j2
public class APILoginFilter extends AbstractAuthenticationProcessingFilter {

    public APILoginFilter(String defaultFilterProcessUrl) {
        super(defaultFilterProcessUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        log.info("APILoginFilter");

        log.info("APILoginFilter-------------------------------------");

        return null;
    }

 
}

AbstractAuthenticationProcessingFilter의 영향으로 생성자와 추상 메소드를 오버라이드 해주어야만 한다.

참고

public APILoginFilter(String defaultFilterProcessUrl): 생성자에서는 defaultFilterProcessUrl을 매개변수로 받습니다. 이 URL은 이 필터가 인증을 처리할 요청의 URL을 의미합니다.
attemptAuthentication(HttpServletRequest request, HttpServletResponse response): 이 메소드는 실제 인증 처리를 담당합니다.
attemptAuthentication 메소드에서는 실제 인증 로직을 구현하고, 인증에 성공하면 Authentication 객체를 반환해야 합니다. 이 객체는 사용자의 인증 정보를 담고 있어, 이후 인증이 필요한 요청에서 사용자를 식별하는 데 사용됩니다.

AbstractAuthenticationProcessingFilter 설정

AbstractAuthenticationProcessingFilter는 로그인 처리를 담당하기 때문에 다른 필터들과 달리 로그인을 처리하는 경로에 대한 설정과 실제 인증 처리를 담당하는 Authentication Manager 객체의 설정이 필수로 필요하다. 이에 대한 설정은 CustomSecurityConfig를 이용해서 처리한다.

@Configuration
@Log4j2
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class CustomSecurityConfig {

    private final APIUserDetailsService apiUserDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        log.info("--------------web configure------------------");

        return (web -> web.ignoring()
                .requestMatchers(
                        PathRequest.toStaticResources().atCommonLocations()));
    }

    @Bean
    public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {

        // AuthenticationManager 설정
        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder
                .userDetailsService(apiUserDetailsService)
                .passwordEncoder(passwordEncoder());

        // Get AuthienticationManager
        AuthenticationManager authenticationManager = authenticationManagerBuilder.build();

        // 반드시 필요
        http.authenticationManager(authenticationManager);

        // APILoginFilter
        APILoginFilter apiLoginFilter = new APILoginFilter("/generateToken");
        apiLoginFilter.setAuthenticationManager(authenticationManager);

        // APILoginSuccessHandler
        APILoginSuccessHandler successHandler = new APILoginSuccessHandler();
        // SuccessHandler 세팅
        apiLoginFilter.setAuthenticationSuccessHandler(successHandler);

        // APILoginFilter의 위치 조정
        http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class);

        http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable());

        http.sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

앞의 코드에서 APILoginFilter는 'generateToken'이라는 경로로 지정되었고, 스프링 시큐리티에서 username과 password를 처리하는 UsernamePasswordAuthenticationFilter의 앞쪽으로 동작하도록 설정되었다.

참고
AuthenticationManagerBuilder authenticationManagerBuilder: 인증에 관련된 설정을 위한 객체를 생성합니다. 이 객체를 통해 사용자 상세 정보 서비스(userDetailsService)와 비밀번호 암호화 방식(passwordEncoder)를 설정합니다.
AuthenticationManager authenticationManager: 실제 인증을 처리하는 객체를 생성합니다.
APILoginFilter apiLoginFilter: 사용자 정의 필터를 생성하고, 생성한 인증 매니저를 이 필터에 설정합니다. 이 필터는 "/generateToken" URL에 대한 요청을 처리합니다.
APILoginSuccessHandler successHandler: 인증이 성공했을 때 실행되는 핸들러를 생성하고, 이를 apiLoginFilter에 설정합니다.
http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class): 생성한 apiLoginFilter를 기존의 UsernamePasswordAuthenticationFilter보다 먼저 동작하도록 설정합니다.

APILoginFilter의 JSON 처리

APILoginFilter는 사용자의 아이디와 패스워드를 이용해서 JWT 문자열을 생성하는 기능을 수행하기 위해서 사용자가 전달하는 mid, mpw 값을 알아낼 수 있어야한다. API 서버에서는 POST 방식으로 JSON 문자열을 이용하는 것이 일반적이므로 이를 APILoginFilter에 반영해보자.

인증 정보 JSON 문자열 처리

JWT 문자열들을 얻기 위해서 전송되는 mid, mpw는 JSON 문자열로 전송되므로 HttpServletRequest로 처리하려면 JSON 처리를 쉽게 할 수 있는 라이브러리를 활용하도록 한다.
build.gradle 파일에 Gson 라이브러리를 추가한다.

implementation 'com.google.code.gson:gson:2.8.9'

APILoginFilter는 POST 방식으로 요청이 들어올 때 JSON 문자열을 처리하는 parseRequestJSON() 메소드를 구성하고 mid와 mpw를 확인할 수 있도록 한다.
Map으로 처리된 mid, mpw를 이용해서 로그인을 처리하는 부분은 UsernamePasswordAuthenticationToken 인증 정보를 만들어서 다음 필터(UsernamePasswordAuthenticationFilter)에서 하도록 구성한다.

APILoginFilter

@Log4j2
public class APILoginFilter extends AbstractAuthenticationProcessingFilter {

    public APILoginFilter(String defaultFilterProcessUrl) {
        super(defaultFilterProcessUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        log.info("APILoginFilter");

        log.info("APILoginFilter-------------------------------------");

        if (request.getMethod().equalsIgnoreCase("GET")) {
            log.info("GET METHOD NOT SUPPORT");
            return null;
        }

        Map<String, String> jsonData = parseRequestJSON(request);

        log.info(jsonData);

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(
                        jsonData.get("mid"),
                        jsonData.get("mpw")
                );

        return getAuthenticationManager().authenticate(authenticationToken);
    }

    private Map<String, String> parseRequestJSON(HttpServletRequest request) {
        // JSON 데이터를 분석해서 mid, mpw 전달 값을 Map으로 처리
        try (Reader reader = new InputStreamReader(request.getInputStream())) {
            Gson gson = new Gson();

            return gson.fromJson(reader, Map.class);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        return null;
    }
}

인증 성공 처리

인증 처리되기는 했지만, 기존의 시큐리티처럼 로그인 후 '/'와 같이 화면을 이동하는 방식으로 동작하는 것을 확인할 수 있다. 원하는 작업은 JWT 문자열을 생성하는 것이므로 이에 대한 처리를 위해서 인증 성공 후 처리 작업을 담당하는 AuthenticationSuccessHandler를 이용해서 후처리를 진행한다.

APILoginSuccessHandler

@Log4j2
@RequiredArgsConstructor
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("Login Success Handler...................");
    }
}

APILoginSuccessHandler의 동작은 APILoginFilter와 연동되어야 하므로 CustomSecurityConfig 내부에서 이를 설정한다.

토큰 새성 과정에서 남은 작업은 APILoginSuccessHandler에서 Access Token과 Refresh Token을 생성해서 전송하는 작업이다. 이를 위해서는 JWT 문자열을 처리한는 방법을 학습할 필요가 있다.

JWT 문자열의 생성과 검증

JWT는 엄밀히 말해서 '인코딩된 문자열'이다. JWT는 크게 '헤더(header), 페이로드(payload), 서명(signature)' 부분으로 작성되어 있는데, 각 부분은 '.'을 이용해서 구분된다. 세 부분 중에 페이로드에는 클레임(claim)이라고 부르는 키/값으로 구성된 정보들을 저장한다.

앞의 화면과 같이 JWT 문자열은 각 부분에 정해진 속성을 가지고 있는데 마지막 서명 부분에 비밀키를 지정해서 인코딩한다.
JWT 문자열의 각 부분은 다음과 같이 구성된다.

Header
• typ : 토큰 타입
• alg : 해싱 알고리즘
payload
• iss : 토큰 발급자
• sub : 토큰 제목
• exp : 토큰 만료 시간
• iat : 토큰 발급 시간
• aud : 토큰 대상자
• nbf : 토큰 활성 시간
• jti : JWT 고유 식별자
signature
Headers의 인코딩 + Payload의 인코딩값을 해싱 + 비밀키

개발자의 고민은 '어떻게 JWT를 생성하고 넘겨받은 JWT를 확인할 수 있는가'이다. 이 부분은 JWT와 관련된 라이브러리를 이용해서 처리한다.

build.gradle

implementation 'io.jsonwebtoken:jjwt:0.9.1'

	implementation 'javax.xml.bind:jaxb-api:2.3.1'

JWT를 쉽게 이용하기 위해서 JWTUtil 클래스를 추가한다.
JWTUtil에서 서명을 처리하기 위해서 비밀키가 필요한데, 이 부분은 application.properties에 추가해서 사용한다.

org.zerock.jwt.secret=hello1234567890
@Component
@Log4j2
public class JWTUtil {

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

    public String generateToken(Map<String, Object> valueMap, int days) {
        log.info("generateKey..." + key);

        // 헤더 부분
        Map<String, Object> headers = new HashMap<>();
        headers.put("typ", "JWT");
        headers.put("alg", "HS256");

        // payload 부분 설정
        Map<String, Object> payloads = new HashMap<>();
        payloads.putAll(valueMap);

        // 테스트 시에는 짧은 유효 기간
        int time = (1) * days; // 테스트는 분단위로 나중에 60*24 (일) 단위변경

        String jwtStr = Jwts.builder()
                .setHeader(headers)
                .setClaims(payloads)
                .setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
                .setExpiration(Date.from(ZonedDateTime.now().plusMinutes(time).toInstant()))
                .signWith(SignatureAlgorithm.HS256, key.getBytes())
                .compact();

        return jwtStr;
    }

    public Map<String, Object> validateToken(String token) throws JwtException {
        Map<String, Object> claim = null;

        return claim;
    }
}

JWTUtil에서 필요한 기능은 크게 JWT 문자열을 생성하는 기능인 generateToken()과 토큰을 검증하는 validateToken() 기능이다.
Jwts.builder()를 이용해서 Header 부분과 payload 부분 등을 지정하고 발행 시간돠 서명을 이용해서 compact()를 수행하면 JWT 문자열이 생성된다. 테스트 코드를 실행해보자.

@SpringBootTest
public class JWTUtilTests {

    @Autowired
    private JWTUtil jwtUtil;

    @Test
    public void testGenerate() {
        Map<String, Object> claimMap = Map.of("mid", "ABCDE");

        String jwtStr = jwtUtil.generateToken(claimMap, 1);

        System.out.println(jwtStr);
    }
}


생성된 JWT 문자열이 정상적인지 확인해기 위해 https://jwt.io 사이트 기능을 이용해보자.

JWT 문자열 검증

JWTUtil을 이용해서 JWT 문자열을 검증할 때 가장 중요한 부분은 여러 종류의 예외가 발생하고 발생하는 예외를 JwtExcetion이라는 상위 타입의 예외로 던지도록 구성하는 점이다.
검증은 JWT 문자열 자체의 구성이 잘못되었거나, JWT 문자열의 유효 시간이 지났거나, 서명에 문제가 있는 등의 여러 문제가 발생할 수 있다. 이 검증은 추가된 라이브러리의 Jwts.parser()를 이용해서 처리한다. JWTUtil의 validateToken()을 다음과 같이 수정한다.

public Map<String, Object> validateToken(String token) throws JwtException {
        Map<String, Object> claim = null;

        claim = Jwts.parser()
                .setSigningKey(key.getBytes()) // Set key
                .parseClaimsJws(token) // 파싱 및 검증, 실패 시 에러
                .getBody();
        
        return claim;
    }

테스트 코드는 이미 유효 기간이 지난 JWT 문자열을 이용해서 validateToken()을 실행해보자.

@Test
    public void testValidate() {
        // 유효 시간이 지난 토큰
        String jwtStr = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg0MzEzMTcsIm1pZCI6IkFCQ0RFIiwiaWF0IjoxNzA4NDMxMjU3fQ.3eTAAj4gPbC8J-EO7LBcq2ZB7r_b5xAB4JrQ_qoGgYA";

        Map<String, Object> claim = jwtUtil.validateToken(jwtStr);

        System.out.println(claim);
    }


앞선 코드의 토큰은 이미 유효 시간이 지났으므로 다음과 같이 ExpiredJwtException 예외가 발생하는 것을 확인할 수 있다.

JWTUtil의 유효 기간

정상적인지를 확인하기 위해 JWTUtil에서 유효 기간을 일(day) 단위로 변경한다.

테스트 코드

@Test
    public void testAll() {
        String jwtStr = jwtUtil.generateToken(Map.of("mid", "AAAA", "email", "aaaa@bbb.com"), 1);

        System.out.println("jwtStr = " + jwtStr);

        Map<String, Object> claim = jwtUtil.validateToken(jwtStr);

        System.out.println("MID = " + claim.get("mid"));
        System.out.println("EMAIL = " + claim.get("email"));
    }

Access Token 발행

JWTUtil을 이용해서 JWT 관련 문자열(토큰)을 만들거나 검증할 수 있다는 사실을 알았다면 이제는 이를 언제 어떻게 활용해야 하는지를 다시 점검하고 구현해야한다.
사용자가 '/generateToken'을 POST 방식으로 필요한 정보(mid, mpw)를 전달하면 APILoginFilter가 동작하고 인증 처리가 된 후에는 APILoginSuccessHandler가 동작하게 된다. APILoginSuccessHandler의 내부에서는 인증된 사용자에게 Access Token/Refresh Token을 발행해 주기 위해서 JWTUtil을 이용해야 한다.

JWTUtil 주입

ApiLoginSuccessHandler

@Log4j2
@RequiredArgsConstructor
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JWTUtil jwtUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("Login Success Handler...................");

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        log.info(authentication);
        log.info(authentication.getName()); // username

        Map<String, Object> claim = Map.of("mid", authentication.getName());
        // Access Token 유효 기간 1일
        String accessToken = jwtUtil.generateToken(claim, 1);
        // Refresh Token 유효 기간 30일
        String refreshToken = jwtUtil.generateToken(claim, 30);

        Gson gson = new Gson();

        Map<String, String> keyMap = Map.of(
                "accessToken", accessToken,
                "refreshToken", refreshToken
        );

        String jsonStr = gson.toJson(keyMap);

        response.getWriter().println(jsonStr);
    }
}

CustomSecurityConfig에 우선 JWTUtil을 주입하고 APILoginSuccessHandler에 이를 주입한다.

생성된 토큰의 확인

설정이 완료되었다면 토큰들이 정상적으로 생성되는지 확인한다.

Access Token 검증 필터

Access Token과 Refresh Token 발행이 가능해졌다면 특정한 경로를 호출할 때 이 토큰들을 검사하고 문제가 없을 때만 접근 가능하도록 구성해볼 필요가 있다. 이 작업은 스프링 시큐리티에서 필터를 추가해 처리하도록 구성한다.

TokenCheckFilter의 생성

TokenCheckFilter는 현재 사용자가 로그인한 사용자인지 체크하는 로그인 체크용 필터와 유사하게 JWT 토큰을 검사하는 역할을 위해서 사용한다.
TokenCheckFilter는 OncePerRequestFilter를 상속해서 구성하는데, 하나의 요청에 대해서 한번씩 동작하는 필터로 서블릿 API의 필터와 유사하다. 구성하려는 TokenCheckFilter는 JWTUtil의 validateToken() 기능을 활용해야 한다.

TokenCheckFilter

@Log4j2
@RequiredArgsConstructor
public class TokenCheckFilter extends OncePerRequestFilter {
    
    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String path = request.getRequestURI();

        if (!path.startsWith("/api/")) {
            filterChain.doFilter(request, response);
            return;
        }
        
        log.info("Token Check Filter.................................");
        log.info("JWTUtil: " + jwtUtil);
        
        filterChain.doFilter(request, response);
    }
}

TokenCheckFilter의 설정은 CustomSecurityConfig를 이용해서 지정한다.

TokenCheckFilter 내 토큰 추출

TokenCheckFilter는 '/api/..'로 시작하는 모든 경로의 호출에 사용될 것이고, 사용자는 해당 경로에 다음과 같은 상황으로 접근하게 된다.

• Access Token이 없는 경우 - 토큰이 없다는 메시지 전달 필요
• Access Token이 잘못된 경우(서명 혹은 구성, 기타 에러) - 잘못된 토큰이라는 메시지 전달 필요
• Access Token이 존재하지만 오래된(expired) 값인 경우 - 토큰을 갱신하라는 메시지 전달 필요

이처럼 다양한 상황을 처리하기 위해서 TokenCheckFilter는 JWTUtil에서 발생하는 예외에 따른 처리를 세밀하게 처리해야한다.

Access Token의 추출과 검증

토큰 검증 단계에서 가장 먼저 할 일은 브라우저가 전송하는 Access Token을 추출하는 것이다. Access Token의 값은 HTTP Header 중에 'Authorization'을 이용해서 전달된다. Authorize 헤더는 'type + 인증값'으로 작성되는데 type값들은 'Basic, Bearer, Digest, HOBA, Mutual' 등을 이용한다. 이 중엣 OAuth나 JWT는 'Bearer'라는 타입을 이용한다.
TokenCheckFilter에서는 별도의 메소드를 이용해서 Authorizeation 헤더를 추출하고 Access Token을 검사하도록 하자.
Access Token에 문제가 있는 경우를 대비해서 AccessTokenException이라는 예외 클래서를 미리 정의하도록 한다. AccessTokenException은 발생하는 예외의 종류를 미리 enum으로 구분해 두고, 나중에 에러 메시지를 전송할 수 있는 구조로 작성한다.

public class AccessTokenException extends RuntimeException{

    TOKEN_ERROR token_error;

    public enum TOKEN_ERROR {
        UNACCEPT(401, "Token is null or too short"),
        BADTYPE(401, "Token type Bearer"),
        MALFORM(403, "Malformed Token"),
        BADSIGN(403, "BadSignatured Token"),
        EXPIRED(403, "Expired Token");

        private int status;
        private String msg;

        TOKEN_ERROR(int status, String msg) {
            this.status = status;
            this.msg = msg;
        }

        public int getStatus() {
            return this.status;
        }

        public String getMsg() {
            return this.msg;
        }
    }

    public AccessTokenException(TOKEN_ERROR error) {
        super(error.name());
        this.token_error = error;
    }

    public void sendResponseError(HttpServletResponse response) {
        response.setStatus(token_error.getStatus());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        Gson gson = new Gson();

        String responseStr = gson.toJson(Map.of("msg", token_error.getMsg(), "time", new Date()));

        try {
            response.getWriter().println(responseStr);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

TokenCheckFilter에는 Access Token을 검증하는 validateAccessToken() 메소드를 추가하고 예외 종륭 따라서 AccessTokenException으로 처리한다.

@Log4j2
@RequiredArgsConstructor
public class TokenCheckFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    private APIUserDetailsService apiUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String path = request.getRequestURI();

        if (!path.startsWith("/api/")) {
            filterChain.doFilter(request, response);
            return;
        }

        log.info("Token Check Filter.................................");
        log.info("JWTUtil: " + jwtUtil);

        try{
            Map<String, Object> payload = validateAccessToken(request);

            //mid
            String mid = (String)payload.get("mid");

            log.info("mid: " + mid);

            UserDetails userDetails = apiUserDetailsService.loadUserByUsername(mid);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());


            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request,response);
        }catch(AccessTokenException accessTokenException){
            accessTokenException.sendResponseError(response);
        }
    }

    private Map<String, Object> validateAccessToken(HttpServletRequest request) throws AccessTokenException {

        String headerStr = request.getHeader("Authorization");

        if(headerStr == null  || headerStr.length() < 8){
            throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.UNACCEPT);
        }

        //Bearer 생략
        String tokenType = headerStr.substring(0,6);
        String tokenStr =  headerStr.substring(7);

        if(tokenType.equalsIgnoreCase("Bearer") == false){
            throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.BADTYPE);
        }

        log.info("TOKENSTR-----------------------------");
        log.info(tokenStr);
        log.info("----------------------------------------");

        try{
            Map<String, Object> values = jwtUtil.validateToken(tokenStr);

            return values;
        }catch(MalformedJwtException malformedJwtException){
            log.error("MalformedJwtException----------------------");
            throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.MALFORM);
        }catch(SignatureException signatureException){
            log.error("SignatureException----------------------");
            throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.BADSIGN);
        }catch(ExpiredJwtException expiredJwtException){
            log.error("ExpiredJwtException----------------------");
            throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.EXPIRED);
        }
    }
}

TokenCheckFilter에는 아직 모든 기능이 구현되지 않았지만 적어도 Access Token에 대해서는 파악이 가능하므로 테스트 환경을 구성하고 이를 확인하도록 하자.

Swagger UI에서 헤더 처리

Swagger UI는 'Authorization'과 같이 보안과 관련된 헤더를 추가하기 위해서 SwaggerConfig를 수정해주어야 한다.

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        Info info = new Info()
                .title("Boot API 01 Project Swagger")
                .description("JWT 문서 API")
                .version("1.0.0");

        // Security 스키마 설정
        SecurityScheme bearerAuth = new SecurityScheme()
                .type(SecurityScheme.Type.HTTP)
                .scheme("bearer")
                .bearerFormat("Authorization")
                .in(SecurityScheme.In.HEADER)
                .name(HttpHeaders.AUTHORIZATION);

        // Security 요청 설정
        SecurityRequirement addSecurityItem = new SecurityRequirement();
        addSecurityItem.addList("Authorization");

        return new OpenAPI()
                .components(
                        new Components()
                                .addSecuritySchemes("Authorization", bearerAuth)
                )
                // API 마다 Security 인증 컴포넌트 설정
                .addSecurityItem(addSecurityItem)
                .addServersItem(new Server().url("/"))
                .info(info);
    }
}

변경된 SwaggerConfig에서는 '/api'로 시작하는 경로들에 대해서 Authorizeation 헤더를 지정하도록 설정하였다.
앞의 설정이 반영되면 상단에 [Authorize] 버튼이 생성되고 Authorization 헤더의 값을 입력할 수 있는 모달창이 보이게된다.

Access Token이 없는 경우

Authorization이 지정되지 않은 상태에서 호출하면 서버에서는 다음과 같은 에러 메시지가 전송된다.

잘못된(Malformd, BadSignatured) Access Token

화면에서 모달창을 열고 'Baerer 1111'과 같이 'Bearer'로 시작하는 문자열을 입력하고 [close] 버튼을 눌러 저장한다.

다시 호출하면 다음과 같이 'Malformed Token' 메시지를 확인할 수 있다.

정상적인 Access Token

Refresh Token 처리

만료된 토큰이 전송되는 경웨 사용자는 다시 서버에 Access Token을 갱신해 달라고 요구해야한다. 예제에서는 'refreshToken'이라는 경로를 이용해서 사용자가 다시 현재의 Access Token과 Refresh Token을 전송해 주면 이를 처리하도록 구성한다.
'/refreshToken'에는 주어진 토큰이 다음과 같은 검증 과정으로 동작하도록 작성한다.

• Access Token이 존재하는지 확인
• Refresh Token의 만료 여부 확인
• Refresh Token의 만료 기간이 지났다면 다시 인증을 통해서 토큰들을 발급받아야 함을 전달

Refresh Token을 이용하는 과정에서는 다음과 같은 상황들이 발생할 수 있다.

• Refresh Token의 만료 기간이 충분히 남아 있으므로 Access Token만 새로 만들어지는 경우
• Refresh Token 자체도 만료 기간이 얼마 안 남아서(예제에서는 3일 이내) Access Token과 Refresh Token 모두 새로 만들어야 하는 경우

RefreshTokenFilter의 생성

RefreshTokenFilter는 토큰 갱신에 사용할 경로('/refreshToken')와 JWTUtil을 주이바도록 설계하고, 해당 경로가 아닌 경우에는 다음 순서의 필터가 실행되도록 구성한다.

@Log4j2
@RequiredArgsConstructor
public class RefreshTokenFilter extends OncePerRequestFilter {

    private final String refreshPath;
    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String path = request.getRequestURI();

        if (!path.equals(refreshPath)) {
            log.info("skip refresh token filter.....");
            filterChain.doFilter(request, response);
            return;
        }

        log.info("Refresh Token Filter...run..........1");
    }
}

RefreshTokenFilter 설정

RefreshTokenFilter 설정은 CustomSecurityFilter를 통해서 설정한다. RefreshTokenFilter는 다른 JWT 관련 필터들의 동작 이전에 할 수 있도록 TokenCheckFilter 앞으로 배치한다.

Refresh Token 구현과 예외 처리

RefreshTokenFilter의 내부 구현은 다음과 같은 순서로 처리된다.

  1. 전송된 JSON 데이터에서 accessToken과 refresh Token을 추출
  2. accessToken을 검사해서 토큰이 없거나 잘못된 토큰인 경우 에러 메시지 전송
  3. refreshToken을 검사해서 토큰이 없거나 잘못된 토큰 혹은 만료된 토큰인 경우 에러 메시지 전송
  4. 새로운 accessToken 생성
  5. 만료 기한이 얼마 남지 않은 경우 새로운 refreshToken 생성
  6. accessToken과 refreshToken 전송

RefreshTokenException

public class RefreshTokenException extends RuntimeException{
    
    private ErrorCase errorCase;

    public enum ErrorCase {
        NO_ACCESS, NO_REFRESH, OLD_REFRESH
    }

    public RefreshTokenException(ErrorCase errorCase){
        super(errorCase.name());
        this.errorCase = errorCase;
    }

    public void sendResponseError(HttpServletResponse response){

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        Gson gson = new Gson();

        String responseStr = gson.toJson(Map.of("msg", errorCase.name(), "time", new Date()));

        try {
            response.getWriter().println(responseStr);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

토큰 검사

새로운 Access Token 발행
토큰들의 검증 단계가 끝났다면 이제 새로운 토큰들을 발행해 주어야한다.

Access Token은 무조건 새로 발행한다.
Refresh Token은 만료일이 얼마 남지 않은 경우에 새로 발행한다.

@Log4j2
@RequiredArgsConstructor
public class RefreshTokenFilter extends OncePerRequestFilter {
private final String refreshPath;
private final JWTUtil jwtUtil;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    String path = request.getRequestURI();

    if (!path.equals(refreshPath)) {
        log.info("skip refresh token filter.....");
        filterChain.doFilter(request, response);
        return;
    }

    log.info("Refresh Token Filter...run..............1");

    //전송된 JSON에서 accessToken과 refreshToken을 얻어온다.
    Map<String, String> tokens = parseRequestJSON(request);

    String accessToken = tokens.get("accessToken");
    String refreshToken = tokens.get("refreshToken");

    log.info("accessToken: " + accessToken);
    log.info("refreshToken: " + refreshToken);

    try{
        checkAccessToken(accessToken);
    }catch(RefreshTokenException refreshTokenException){
        refreshTokenException.sendResponseError(response);
        return;
    }

    Map<String, Object> refreshClaims = null;

    try {

        refreshClaims = checkRefreshToken(refreshToken);
        log.info(refreshClaims);

    }catch(RefreshTokenException refreshTokenException){
        refreshTokenException.sendResponseError(response);
        return;
    }

    //Refresh Token의 유효시간이 얼마 남지 않은 경우
    Integer exp = (Integer)refreshClaims.get("exp");

    Date expTime = new Date(Instant.ofEpochMilli(exp).toEpochMilli() * 1000);

    Date current = new Date(System.currentTimeMillis());

    //만료 시간과 현재 시간의 간격 계산
    //만일 3일 미만인 경우에는 Refresh Token도 다시 생성
    long gapTime = (expTime.getTime() - current.getTime());

    log.info("-----------------------------------------");
    log.info("current: " + current);
    log.info("expTime: " + expTime);
    log.info("gap: " + gapTime );

    String mid = (String)refreshClaims.get("mid");

    //이상태까지 오면 무조건 AccessToken은 새로 생성
    String accessTokenValue = jwtUtil.generateToken(Map.of("mid", mid), 1);

    String refreshTokenValue = tokens.get("refreshToken");

    //RefrshToken이 3일도 안남았다면..
    if(gapTime < (1000 * 60  * 60  ) ){
        //if(gapTime < (1000 * 60 * 60 * 24 * 3  ) ){
        log.info("new Refresh Token required...  ");
        refreshTokenValue = jwtUtil.generateToken(Map.of("mid", mid), 30);
    }

    log.info("Refresh Token result....................");
    log.info("accessToken: " + accessTokenValue);
    log.info("refreshToken: " + refreshTokenValue);

    sendTokens(accessTokenValue, refreshTokenValue, response);
}

private Map<String,String> parseRequestJSON(HttpServletRequest request) {

    //JSON 데이터를 분석해서 mid, mpw 전달 값을 Map으로 처리
    try(Reader reader = new InputStreamReader(request.getInputStream())){

        Gson gson = new Gson();

        return gson.fromJson(reader, Map.class);

    }catch(Exception e){
        log.error(e.getMessage());
    }
    return null;
}

private void checkAccessToken(String accessToken)throws RefreshTokenException {

    try{
        jwtUtil.validateToken(accessToken);
    }catch (ExpiredJwtException expiredJwtException){
        log.info("Access Token has expired");
    }catch(Exception exception){
        throw new RefreshTokenException(RefreshTokenException.ErrorCase.NO_ACCESS);
    }
}

private Map<String, Object> checkRefreshToken(String refreshToken)throws RefreshTokenException{

    try {
        Map<String, Object> values = jwtUtil.validateToken(refreshToken);

        return values;

    }catch(ExpiredJwtException expiredJwtException){
        throw new RefreshTokenException(RefreshTokenException.ErrorCase.OLD_REFRESH);
    }catch(Exception exception){
        exception.printStackTrace();
        new RefreshTokenException(RefreshTokenException.ErrorCase.NO_REFRESH);
    }
    return null;
}

private void sendTokens(String accessTokenValue, String refreshTokenValue, HttpServletResponse response) {


    response.setContentType(MediaType.APPLICATION_JSON_VALUE);

    Gson gson = new Gson();

    String jsonStr = gson.toJson(Map.of("accessToken", accessTokenValue,
            "refreshToken", refreshTokenValue));

    try {
        response.getWriter().println(jsonStr);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

}



0개의 댓글