관리 메뉴

거니의 velog

(30) 스프링 시큐리티와 API 서버 4 본문

SpringBoot_React 풀스택 프로젝트

(30) 스프링 시큐리티와 API 서버 4

Unlimited00 2024. 3. 6. 13:26

4. Access Token 체크 필터

* Access Token 은 말 그대로 API 서버의 특정한 경로를 '접근'하기 위해서 사용한다. Access Token은 HTTP 헤더 항목 중에서 Authorization 항목의 값으로 전달해서 서버에서 이를 체크해서 사용한다. Authorization 헤더는 <타입>, <토큰>의 형식으로 중간에 공백 문자로 구분된 값으로 구성되는데 일반적으로 JWT를 활용하면 Bearer 라는 타입으로 지정된다.

* 서버에서는 보호하고자 하는 자원에 대해서 Access Token을 체크해서 유효한 경우에 접근을 허용하는 구현이 필요한데 필터나 스프링 MVC의 인터셉터, 스프링 시큐리티의 필터를 활용해서 구현할 수 있다. 예제에서는 시큐리티를 이용하고 있으므로 필터를 추가해서 /api/todo/... 경로나 /api/products/... 경로에 접근할 경우 Access Token 을 확인하도록 구현한다.

* 프로젝트의 security 패키지에 filter 패키지를 추가하고 JWTCheckFilter 클래스를 추가한다.

package com.unlimited.mallapi.security.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

    /**
     * 요청 필터링 여부를 결정하는 메소드
     * 
     * @param request 현재 요청 객체
     * @return 필터링을 적용하지 않을 경우 false를 반환
     * @throws ServletException 서블릿 예외 발생 시
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {

        // 특정 URL에 대한 필터링을 제외하고자 할 때 사용
        String path = request.getRequestURI();
        log.info("check url...." + path);
        return false;

    }

    /**
     * 실제 필터링 작업이 수행되는 메소드
     * 
     * @param request     현재 요청 객체
     * @param response    현재 응답 객체
     * @param filterChain 다음 필터로 전달하기 위한 필터 체인 객체
     * @throws ServletException 서블릿 예외 발생 시
     * @throws IOException      입출력 예외 발생 시
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        // 실제 필터링 작업 수행
        log.info("-----------------------------");
        log.info("-----------------------------");
        log.info("-----------------------------");

        // 다음 필터로 전달
        filterChain.doFilter(request, response);

    }

}

* JWTChainFIlter는 시큐리티에서 제공하는 OncePerRequestFilter를 상속하는데, 주로 모든 요청에 대해서 체크하려고 할 때 사용한다. shouldNotFilter() 는 OncePerRequestFilter의 상위 클래스에 정의된 메서드로 필터로 체크하지 않을 경로나 메서드(GET/POST) 등을 지정하기 위해서 사용한다.

* JWTTokenFilter는 CustomSecurityConfig에서 추가한다.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------보안 설정-----------------");

        // CORS 구성을 설정합니다.
        http.cors(corsConfig -> {
            corsConfig
                    .configurationSource(corsConfigurationSource());
        });

        // 세션 생성 정책을 무상태로 설정합니다.
        http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // CSRF 보안을 비활성화합니다.
        http.csrf(csrfConfig -> csrfConfig.disable());

        // 로그인 페이지를 지정하고 로그인 성공 및 실패 시의 핸들러를 설정합니다.
        http.formLogin(loginConfig -> {
            loginConfig.loginPage("/api/member/login");
            loginConfig.successHandler(new APILoginSuccessHandler());
            loginConfig.failureHandler(new APILoginFailHandler());
        });

        // JWT 체크
        http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

* 현재까지는 필터의 내용을 모두 구현한 것이 아니므로 프로젝트 실행 후에 /api/products/list를 호출하면 JWTCheckFilter의 동작 여부만 확인이 가능하다.

* Postman에서 /api/products/list와 같은 경로를 호출한 후에 서버의 로그를 확인한다.


(1) 필터를 통한 검증/예외 처리

* JWTCheckFilter는 /api/member/login 의 경우와 Ajax 통신 시 Preflight 로 전송되는 OPTIONS 방식이거나 로그인을 처리하는 경로, 첨부파일 이미지를 사용하는 경로 등에 대해서는 체크하지 않도록 지정할 수 있다.

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {

        // Preflight 요청은 체크하지 않음
        if (request.getMethod().equals("OPTIONS")) {
            return true;
        }

        String path = request.getRequestURI();
        log.info("check url...." + path);

        // api/member/ 경로의 호출은 체크하지 않음
        if (path.startsWith("/api/member/")) {
            return true;
        }

        // 이미지 조회 경로는 체크하지 않음
        if (path.startsWith("/api/products/view/")) {
            return true;
        }

        return false;

    }

* Access Token에 대한 확인은 doFilterInternal에서 JWTUtil이 가진 validateToken()을 활용해서 예외의 발생 여부를 확인한다.

package com.unlimited.mallapi.security.filter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.web.filter.OncePerRequestFilter;

import com.google.gson.Gson;
import com.unlimited.mallapi.util.JWTUtil;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {

        (...)

    }

    /**
     * 실제 필터링 작업이 수행되는 메소드
     * 
     * @param request     현재 요청 객체
     * @param response    현재 응답 객체
     * @param filterChain 다음 필터로 전달하기 위한 필터 체인 객체
     * @throws ServletException 서블릿 예외 발생 시
     * @throws IOException      입출력 예외 발생 시
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

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

        // Authorization 헤더에서 토큰 추출
        String authHeaderStr = request.getHeader("Authorization");

        try {
            // Bearer accesstoken...
            String accessToken = authHeaderStr.substring(7);

            // 토큰 검증 및 클레임 추출
            Map<String, Object> claims = JWTUtil.validateToken(accessToken);

            log.info("JWT claims : " + claims);

            // 다음 필터로 전달
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            // JWT 검증 실패 시 처리
            log.error("JWT Check Error......");
            log.error(e.getMessage());

            // JSON 형태의 에러 메시지 생성
            Gson gson = new Gson();
            String msg = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));

            // HTTP 응답 설정 및 전송
            response.setContentType("application/json");
            PrintWriter printWriter = response.getWriter();
            printWriter.println(msg);
            printWriter.close();
        }
    }

}

* 변경된 코드는 정상적인 경우라면 컨트롤러까지 호출이 가능하게 되고, Access Token에 문제가 있다면 JSON으로 에러 메시지가 전송된다. JWTFilter가 적용된 후에 Postman에서 /api/products/list 를 호출하면 아래와 같이 에러 메시지가 발생하는 것을 확인할 수 있다.

* 정상적인 결과를 확인하려면 우선 /api/member/login 에서 발생한 Access Token을 이용해야 한다(유효시간은 10분).

* 로그인 결과에서 발생한 accessToken의 내용을 복사해서 /api/products/list 를 호출할 때 'Authorization' Header 를 추가하고 'Bearer 토큰값' 을 설정한다(Access Token의 유효 시간이 10분이므로 주의, 만일 인증에 실패하면 재로그인 후 다시 시도한다).

Bearer 선언 후 한번 띄어쓰기 한 뒤에 Access Token을 붙여 넣어야 정상적으로 동작한다.

* 유효시간이 만료되지 않은 정상적인 Access Token은 호출 결과를 볼 수 있다. 서버에서는 로그를 통해서 Access Token의 내용물을 확인할 수 있다.

* 로그인 과정에서 만든 Access Token의 유효시간은 10분이기 때문에 10분이 지난 후에는 에러가 발생하게 된다. 이 때 서버에는 Expired와 같은 예외 메시지를 확인할 수 있다.


(2) @PreAuthorize 를 통한 접근 권한 처리

* 스프링 시큐리티를 활용하면 각 경로에 따라서 특정한 권한을 가진 사용자만 접근이 가능하도록 구성할 수 있다. 이에 대한 설정은 CustomSecurityConfig에서 지정할 수도 있지만 가장 편리한 방식은 @PreAuthorize를 이용해서 메서드 선언부에 이를 적용하는 방식이다.

* 메서드별 권한을 체크하기 위해서는 시큐리티 설정에 org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 를 추가해 주어야 한다.

@Configuration
@Log4j2
@RequiredArgsConstructor
@EnableMethodSecurity
public class CustomSecurityConfig {

	(...)

}

* 각 메서드에는 org.springframework.security.access.prepost.PreAuthorize 를 이용해서 특정 권한을 가진 사용자만이 접근할 수 있도록 제한한다. 예를 들어 /api/products/list를 USER, ADMIN 권한을 가진 사용자만 접근하도록 제한하려면 아래와 같이 설정을 추가한다.

    // 페이지 및 정렬 조건을 전달받아 상품 목록을 조회하는 메서드입니다.
    @PreAuthorize("hasAnyRole('ROLE_USER','ROLE_ADMIN')") // 임시로 권한 설정
    @GetMapping("/list")
    public PageResponseDTO<ProductDTO> list(PageRequestDTO pageRequestDTO) {
        // 전달받은 페이지 요청 정보를 로그로 출력합니다.
        log.info("list...." + pageRequestDTO);

        // ProductService의 getList 메서드를 호출하여 상품 목록을 조회하고 반환합니다.
        return productService.getList(pageRequestDTO);
    }

* 마지막으로 JWTCheckFilter에서는 JWT 인증 정보를 활용해서 사용자를 구성하고 이를 시큐리티에 지정해 주어야 한다. JWT 토큰 내에는 인증에 필요한 모든 정보를 가지고 있기 때문에 이를 활용해서 시큐리티에 필요한 객체(MemberDTO)를 구성한다.

package com.unlimited.mallapi.security.filter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
import java.util.List;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import com.google.gson.Gson;
import com.unlimited.mallapi.dto.MemberDTO;
import com.unlimited.mallapi.util.JWTUtil;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {

        (...)

    }

    /**
     * 실제 필터링 작업이 수행되는 메소드
     * 
     * @param request     현재 요청 객체
     * @param response    현재 응답 객체
     * @param filterChain 다음 필터로 전달하기 위한 필터 체인 객체
     * @throws ServletException 서블릿 예외 발생 시
     * @throws IOException      입출력 예외 발생 시
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

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

        // Authorization 헤더에서 토큰 추출
        String authHeaderStr = request.getHeader("Authorization");

        try {
            // Bearer accesstoken...
            String accessToken = authHeaderStr.substring(7);

            // 토큰 검증 및 클레임 추출
            Map<String, Object> claims = JWTUtil.validateToken(accessToken);

            log.info("JWT claims : " + claims);

            // filterChain.doFilter(request, response); // 이하에 추가

            // 토큰에서 추출한 정보로 MemberDTO 객체 생성
            String email = (String) claims.get("email");
            String pw = (String) claims.get("pw");
            String nickname = (String) claims.get("nickname");
            Boolean social = (Boolean) claims.get("social");
            List<String> roleNames = (List<String>) claims.get("roleNames");

            MemberDTO memberDTO = new MemberDTO(email, pw, nickname, social.booleanValue(), roleNames);

            log.info("-------------------------------------------");
            log.info(memberDTO);
            log.info(memberDTO.getAuthorities());

            // 인증 객체 생성 및 SecurityContextHolder에 설정
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    memberDTO,
                    pw,
                    memberDTO.getAuthorities());

            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

            // 다음 필터로 전달
            filterChain.doFilter(request, response);

        } catch (Exception e) {
            // JWT 검증 실패 시 처리
            log.error("JWT Check Error......");
            log.error(e.getMessage());

            // JSON 형태의 에러 메시지 생성
            Gson gson = new Gson();
            String msg = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));

            // HTTP 응답 설정 및 전송
            response.setContentType("application/json");
            PrintWriter printWriter = response.getWriter();
            printWriter.println(msg);
            printWriter.close();
        }
    }

}

* user9@aaa.com/1111 로 로그인한 경우에는 ADMIN 권한이 있기 때문에 정상적인 호출이 가능하다.

* user1@aaa.com/1111 의 경우 역시 USER 권한이 있는 사용자이므로 접근이 가능하다

* 이번에는 ProductController의 list()의 접근 권한을 ADMIN 사용자만 가능하도록 임시로 수정해 본다.

    // 페이지 및 정렬 조건을 전달받아 상품 목록을 조회하는 메서드입니다.
    // @PreAuthorize("hasAnyRole('ROLE_USER','ROLE_ADMIN')") // 임시로 권한 설정
    @PreAuthorize("hasAnyRole('ROLE_ADMIN')") // ROLE_ADMIN 만 list를 볼 수 있는 권한을 가진다.
    @GetMapping("/list")
    public PageResponseDTO<ProductDTO> list(PageRequestDTO pageRequestDTO) {
        // 전달받은 페이지 요청 정보를 로그로 출력합니다.
        log.info("list...." + pageRequestDTO);

        // ProductService의 getList 메서드를 호출하여 상품 목록을 조회하고 반환합니다.
        return productService.getList(pageRequestDTO);
    }

* 다시 user1@aaa.com 계정으로 만들어진 Access Token을 입력해서 확인하면 ADMIN 권한이 없기 때문에 에러가 발생하게 된다.


[메서드 접근제한 예외 처리]

* API 서버는 항상 호출한 애플리케이션에게 정확한 메시지를 전달해야 하므로 접근제한 상황에 대해서 예외 메시지를 전달해 주어야 한다. 이를 위해서 security/handler 패키지에 CustomAccessDeniedHandler 클래스를 추가한다.

package com.unlimited.mallapi.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import com.google.gson.Gson;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * 커스텀 AccessDeniedHandler 구현 클래스
 * 사용자의 권한이 없을 때 발생하는 AccessDeniedException을 처리하는 역할을 합니다.
 */
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    /**
     * AccessDeniedException을 처리하는 메소드
     *
     * @param request               현재 요청 객체
     * @param response              현재 응답 객체
     * @param accessDeniedException 발생한 AccessDeniedException 객체
     * @throws IOException          입출력 예외 발생 시
     * @throws ServletException     서블릿 예외 발생 시
     */
    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {

        // Gson 객체 생성
        Gson gson = new Gson();

        // 에러 메시지를 JSON 형식으로 변환
        String jsonStr = gson.toJson(Map.of("error", "ERROR_ACCESSDENIED"));

        // HTTP 응답 설정
        response.setContentType("application/json");
        response.setStatus(HttpStatus.FORBIDDEN.value());

        // 응답 데이터 전송
        PrintWriter printWriter = response.getWriter();
        printWriter.println(jsonStr);
        printWriter.close();

    }

}

* CustomSecurityConfig에서는 접근제한 시 CustomAccessDeniedHandler를 활용하도록 설정을 추가한다.

* 또한 람다식의 매개변수 명은 혼동을 피하기 위해 config 인수를 각기 메서드에 맞는 인수명으로 변환한다.

    /**
     * Security 필터 체인을 설정합니다.
     *
     * @param http HttpSecurity 객체
     * @return SecurityFilterChain 객체
     * @throws Exception 예외 처리
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------보안 설정-----------------");

        // CORS 구성을 설정합니다.
        http.cors(corsConfig -> {
            corsConfig
                    .configurationSource(corsConfigurationSource());
        });

        // 세션 생성 정책을 무상태로 설정합니다.
        http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // CSRF 보안을 비활성화합니다.
        http.csrf(csrfConfig -> csrfConfig.disable());

        // 로그인 페이지를 지정하고 로그인 성공 및 실패 시의 핸들러를 설정합니다.
        http.formLogin(loginConfig -> {
            loginConfig.loginPage("/api/member/login");
            loginConfig.successHandler(new APILoginSuccessHandler());
            loginConfig.failureHandler(new APILoginFailHandler());
        });

        // JWT 체크 필터를 추가합니다.
        http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class);

        // 접근 거부 핸들러를 설정합니다.
        http.exceptionHandling(exceptionHandlingConfig -> {
            exceptionHandlingConfig.accessDeniedHandler(new CustomAccessDeniedHandler());
        });

        return http.build();
    }

* 유효시간이 지나지 않았지만, 권한이 없는 사용자가 가진 Access Token을 사용하는 경우 아래와 같은 메시지를 전송하게 된다.