PPAK

[Server] Application Architecture(인증/인가, Header 값 분리, CORS 허용) 본문

프로젝트

[Server] Application Architecture(인증/인가, Header 값 분리, CORS 허용)

PPakSang 2022. 8. 17. 14:44

본 포스팅에서는 최근에 진행중인 프로젝트의 서버 설계, 구현 과정을 살펴보려고 합니다.

 

우선 api 서버를 구축하기 위해서

1. 인증/인가

2. JWT 사용(Authorization Header)

3. CORS 허용

 

등의 요구사항이 있었고 이를 적용하고 있는 방식을 설명하고자 합니다.

 

위 사진은 일반적인 Spring 서버의 요청 처리 구조를 나타내는 그림입니다.

 

저는 현재 기본 Spring Boot Application 에

 

1. Filter(Spring Security) 를 통해 인증/인가

2. Interceptor 를 통해 인가된 요청 헤더값 추출

3. Controller 에서 JWT 생성 및 발급을 수행하고 있습니다.

 

Client 의 요청이 Filter -> Interceptor -> Controller 로 들어오는 방식 입니다.

 

인증/인가

클라이언트 요청에 대한 인증/인가는 Spring-Security Filter 에서 수행합니다.

Spring-Security 를 통해서 JWTFilter 를 만들고 이를 ServletFilterChain 에 포함시켰습니다.

 

Spring-Security 는 단순히 SecurityFilterChain 클래스를 Bean 으로 등록하기만 하면 알아서 DelegatingFilterProxy 에 ServletFilterChain 에 포함시켜줍니다.

 

자세한 Spring-Security 적용 코드는 지난 글 에서 확인할 수 있습니다.

본 포스팅에서는 어떤 방식으로 Filter 가 요청에서 인증/인가를 수행하는지 살펴보겠습니다.

 

우선 본 프로젝트에서 넷플릭스의 로그인 방식과 유사한 서비스를 구현해보고자 했습니다.

따라서 서버에서는 계정, 프로필 두 가지 레벨의 인증을 사용자에게 요구하여야 했기에 아래 두 가지의 토큰을 생성했습니다.

1. Account_Token: 계정 레벨의 요청에서 사용되는 인증 토큰

2. Profile_Token: 프로필 레벨의 요청에서 사용되는 인증 토큰

 

각 토큰은 요청 URL Pattern 에 따라서 요구되고 따라서 토큰 별 상이한 인증/인가 로직이 필요했습니다.

인증/인가 로직은 크게 차이 없이

1. Authorization Header 에서 토큰 추출

2. 토큰 유효성 검사

3. 인증/인가 수행

순서로 진행되고, 토큰 유효성을 판단할 때 요구 Claim 의 구성만 다르게 하였습니다.

 

 private JWTVerifier getAccountTokenValidator() {
        return JWT.require(getSigningKey(SECRET_KEY))
                .withClaimPresence("email")
                .withClaimPresence("account_id")
                .withIssuer(ISSUER)
                .build();
    }

    private JWTVerifier getProfileTokenValidator() {
        return JWT.require(getSigningKey(SECRET_KEY))
                .withClaimPresence("email")
                .withClaimPresence("account_id")
                .withClaimPresence("profile_id")
                .withIssuer(ISSUER)
                .build();
    }

private DecodedJWT validateToken(String token, JWTVerifier validator) throws JWTVerificationException {
        return validator.verify(token);
    }

현재 단계에서는 각 토큰이 어떻게 사용될지 명확하지 않기 때문에 다소 중복될 수 있는 값들을 같이 저장했고 제품을 완성하는 과정에서 하나씩 걷어내려고 합니다.

 

@Bean
    public SecurityFilterChain accountFilterChain(HttpSecurity http,
                                           JwtProvider jwtProvider,
                                           CookieUtil cookieUtil) throws Exception {
        return setJwtHttpSecurity(http)
                .requestMatchers()
                .antMatchers("/api/v1/auth/profile/**")
                .antMatchers("/api/v1/account/**")
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/auth/profile/**").hasRole("USER")
                .and()
                .addFilterBefore(jwtAuthenticationFilter(jwtProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }
    
    @Bean
    public SecurityFilterChain profileFilterChain(HttpSecurity http,
                                           JwtProvider jwtProvider,
                                           CookieUtil cookieUtil) throws Exception {
        return setJwtHttpSecurity(http)
                .requestMatchers()
                .antMatchers("/api/v1/profile/**")
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/profile/**").hasRole("USER")
                .and()
                .addFilterBefore(jwtProfileAuthenticationFilter(jwtProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

위 코드는 요청 URL Pattern 에 따라서 서로 다른 SecurityFilterChain 을 구성하는 예시 코드입니다.

 

setJwtHttpSecurity 는 공통적인 코드 로직을 메소드 추출한 것입니다.

 

코드를 작성하며 한가지 놓쳤던 부분은

처음 authorizedRequests() 만 설정하면 알아서 URL mapping 도 해준다고 생각했지만 그렇지 않았습니다.

반드시 requestMatchers() 를 통해 의도한 URL Pattern 에 따른 SecurityFilterChain 를 매핑시켜주어야 합니다.

 

결과

추가적으로 해당 레이어에서

인증 정보가 누락된 요청은 AuthenticationEntryPoint 를 통해 핸들링 하고

인가 되지 않은 요청은 AccessDeniedHandler 를 통해 핸들링 합니다.

또한 정상적인 요청은 Dispatcher Servlet 를 거쳐 Interceptor 로 전달됩니다.

 

JWT 사용(Authorization Header)

정상적으로 인가된 요청의 헤더에는 서버에서 사용하고자 하는 토큰값이 포함되어 있습니다.

일전에 토큰 내부에 여러가지 회원 인증 정보(account_id, profile_id) 등을 넣어 발급하였기 때문에 해당 데이터를 Controller 에서 이용할 수 있도록 처리가 필요합니다.

 

최초 코드 작성 당시에는 Controller 에서 헤더를 파싱하였지만 관심사 측면에서 Controller 는 요청을 처리하고, 응답을 구성하는 영역으로 제한하고 싶었기 때문에 별도로 Interceptor 를 두어 데이터를 처리 했습니다.

 

위 사진과 같이 Interceptor 는 Controller 로 매핑되는 요청을 가로채 처리하는 영역을 의미합니다.

DispatcherServlet 앞 단(Servlet Container) 에서는 Filter 가 비슷한 역할을 수행하고 있지만 Interceptor 는 실제 비즈니스 로직이 수행되는 영역(Spring Bean 이 존재하는 영역) 에서 동작한다는 점에서 차이가 있습니다.

 

@Component
@RequiredArgsConstructor
public class AccountTokenInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        String accountToken = JwtExtractor.extractJwt(request);

        try {
            String email = jwtProvider.getEmailFromToken(accountToken);
            Long accountId = jwtProvider.getAccountIdFromToken(accountToken);

            request.setAttribute("accountId", accountId);
            request.setAttribute("email", email);
        } catch (JWTVerificationException e) {
        }

        return true;
    }
}

 

서버에서는 JwtExtractor 클래스를 따로두어 클라이언트 요청 헤더(Authorization) 에서 토큰 값을 추출하도록 하였고, 토큰에서부터 또다시 Claim 값을 추출하여 Attribute 로 설정하는 Interceptor 를 추가했습니다.

 

Filter 에서 토큰 유효성 검사를 마쳤기 때문에 해당 단계에서 토큰 유효성 검사는 사실상 필요없습니다.

 

Claim 값의 유효성 검사 역시 Controller 에서 수행하기 때문에 더이상의 역할은 수행하지 않도록 코드를 짰습니다.

(현재 이용하는 accountId 값은 사실 인증 과정에서 사용되기 때문에 유효하다는 것을 보장할 순 있습니다. 이 외에 다른 Claim 값을 저장하고 사용한다고 했을 경우의 이야기 입니다)

 

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AccountTokenInterceptor accountTokenInterceptor;
    private final ProfileTokenInterceptor profileTokenInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accountTokenInterceptor).addPathPatterns(
                "/api/v1/account/**", "/api/v1/auth/profile/**"
        );

        registry.addInterceptor(profileTokenInterceptor).addPathPatterns(
                "/api/v1/profile/**"
        );
    }
}

최종적으로 작성한 Interceptor 를 WebMvcConfigurer 구현체를 통해 등록해주기만하면 스프링이 알아서 추가를 해줍니다.

문제

Controller 에서 발생하는 예외는 @RestControllerAdvice 를 포함하는 ResponseEntityExceptionHandler 를 통해서 적절히 핸들링을 해주었습니다.

 

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleExceptionInternal
            (Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        ApiErrorResult<String> error = ApiUtil.error(status.value(), ex.getMessage());
        return super.handleExceptionInternal(ex, error, headers, status, request);
    }

    @ExceptionHandler(NotFoundException.class)
    protected ResponseEntity<?> handleNotFoundException(Exception e) {
        ApiErrorResult<String> error = ApiUtil.error(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
        return ResponseEntity.status(HttpServletResponse.SC_NOT_FOUND).body(error);
    }

    @ExceptionHandler({
            Exception.class
    })
    protected ResponseEntity<?> handleNormalException(Exception e) {
        ApiErrorResult<String> error = ApiUtil.error(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
        return ResponseEntity.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).body(error);
    }
}

하지만 Interceptor 에서 예외가 발생했을 때 매핑되는 handler 가 존재한다면 HandlerExceptionResolver 까지 전달 되지만 그렇지 않을 경우 바로 DispatcherServlet 으로 전달되는 것을 확인했습니다.

 

DispatcherServlet.java 내 코드

 

 

따라서 여러가지 대안이 존재했는데

1. 에러 페이지를 핸들링하는 Controller 로 request forwarding (Controller 영역)

2. Interceptor 에서 예외를 잡고 적절한 response 로 돌려주기 (Interceptor 영역)

3. Interceptor 에서 예외를 잡고 attribute setting 없이 Controller 요청 진행 (Controller 영역)

 

우선 본 서버에서는 3번 방법을 채택했는데, 1번 같은 경우에는 프로젝트 규모가 작은 현재 단계에서 비용이 커질 것 같아서 미루었고, 2번은 관심사 측면에서 응답은 Controller 에서 생성하자는 생각과 현재 발생하는 예외가 Controller 단에서 충분히 잡을 수 있는 예외이기 때문에 3번으로 택했습니다.

 

CORS 허용

api 서버를 사용하면 무조건 만나게 된다는 CORS 문제를 저도 피할 수 없었습니다.

 

본 문제를 해결하기에 앞서 CORS 가 왜 필요한지, 어떻게 진행되는지 살펴보자면

 

CORS 가 필요한 이유(개인적인 생각이 섞였습니다)

 

서버

서버는 (안전한) 클라이언트의 요청만을 처리해야합니다. 안전하다는 것은 다시 말해 사전에 허용된 요청만을 서버 측에서 받을 수 있어야 하고 특히 서버와는 다른 주소에서 발송된 요청(대부분의 api 서버가 받는 요청) 은 더더욱 조심해야합니다.

 

따라서 HTTP 프로토콜 요청 중 Origin(요청자 주소), Method(GET, POST ... 등), Header(헤더), Cookie(브라우저 쿠키), Body 등

을 안전하게 받을 수 있어야 합니다.

 

따라서 서버는 위의 항목별 허용할 값을 사전에 설정해두고 클라이언트의 요청을 받도록 합니다.

 

CORS 는 서버보다는 클라이언트 보호 측면이 더 강한 것 같습니다. 서버는 요청자로부터 인증 정보만 확인된다면 정상적인 요청으로 간주하기 때문에(물론 CSRF 공격에 대한 대비는 되어있어야 합니다.) 이러한 CORS 보호 정책은 클라이언트로부터 비정상적인 요청이 서버로 날아가지 않도록 사전에 막아주는 정책에 가깝다고 볼 수 있습니다.

 

클라이언트

클라이언트도 서버의 CORS 정보가 중요하긴 마찬가지 입니다. 모든 요청 정보를 구성하였는데 서버에서 요청을 받지 않는다고 거부해 버리면 요청을 준비하는 시간이 상당히 낭비될 것입니다. 따라서 사전에 OPTION Method 를 통한 Preflight 요청을 먼저 전송하여 서버 측에서 해당 클라이언트의 요청을 수신할 수 있는지 미리 확인하는 과정을 거쳐 자원을 절약하는 것 같습니다.

 

CORS 가 허용되는 과정 (개인적인 생각이 섞였습니다)

현재 클라이언트는 react 와 협업중인데 요청을 살펴보니 fetch 로 요청을 구성하고 전송하니 자동으로 preflight 가 우선적으로 전송되는 것을 확인했습니다. 아마도 구성된 요청 정보(Origin, Header, Cookie 포함 여부)를 바탕으로 preflight 가 전송되는 것 같습니다.

 

위와 같은 preflight 요청은 서버로 전달되고 200 response 와 함께 허용한다는 의미를 가지는 헤더가 함께 전달되면 preflight 과정은 끝나고 본 요청을 전송하게 됩니다.

 

따라서 서버 측에서는 이러한 CORS 정책을 응답 헤더에 명시할 수 있어야합니다. 해당 설정을 Spring 혹은 Servlet 에서 제공하는 유틸 클래스를 사용하면 손쉽게 설정할 수 있습니다.

 

코드 예시

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AccountTokenInterceptor accountTokenInterceptor;
    private final ProfileTokenInterceptor profileTokenInterceptor;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("https://localhost:3000")
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                .allowedHeaders("*")
                .exposedHeaders("*")
                .allowCredentials(true);
    }
   }

 

1. Spring Security 에서 CorsConfigurer 를 이용하는 방법

2. WebMvcConfigurer 를 이용하는 방법

3. CorsFilter 를 등록하는 방법

 

3번을 제외한 1, 2 번은 각각 spring boot, spring 이 제공하는 메소드를 통해서 CORS 응답 헤더를 만들 수 있고, 3번의 경우에는 모든 OPTION Method 요청에 대한 응답 헤더를 서버 CORS 정책에 맞게 설정해주는 것입니다.

 

1, 2 번의 정의된 메소드를 사용하는 것이라 안전하다고 판단하였고 어느 것을 사용할까 고민하다 2번을 선택하였습니다.(둘 다 비슷)

 

문제(해결 중)

Post 요청 시 쿠키를 설정하기 위해서는 Https 를 통해 통신을 해야하고 기본 자바 스펙에서 제공하는 Cookie 클래스에는 sameSite 를 설정할 수 있는 방법이 없기 때문에 직접 Set-Cookie 헤더를 작성하거나 Spring-Security 의 ResponseCookie 클래스를 사용해야 합니다.

 

 

느낀 점

스프링으로 처음 진행하는 프로젝트가 한달 조금 넘게 지났습니다. 요구사항 정리, 도메인 분리, 엔티티 설계, CI/CD 적용도 쉽지않았고 완벽하지도 않지만 현재 가장 어려운 것은 관심사의 분리 인 것 같습니다. 레이어별로 역할을 나누는 것에 조금 더 익숙해져야할 것 같습니다.

 

또한

1. 예외 발생 시점, 핸들링

2. 응답로직 정형화

3. 요청 데이터를 어디서 어디까지 처리할지

등등의 리팩토링 요소도 많이 보입니다.

 

그래도 단순히 공부하는 것에서 직접 프로젝트를 진행해보니 놓쳤던 것들도 많이 보이고 서버 애플리케이션이 어떻게 돌아가는지 흐름을 이해할 수 있어 굉장히 만족하고 있습니다.

Comments