[SpringBoot] Security 적용으로 발생한 Swagger, H2 개발환경 이슈 해결하기

zl존석동

·

2022. 5. 22. 03:48


SpringBoot 프로젝트에 SpringSecurity 를 적용해서 발생했던 사소한 문제들을 해결했던 내용을 간단하게 기록하였다!


 

 

개요

 

 

팀 프로젝트를 하면서 리액트 프론트엔드 개발 역할을 맡았으나

 

SpringBoot  백엔드에서의 Spring Security 를 활용한  JWT 인증,인가 구현도 맡게 되었다.

 

이미 프로젝트에는 Swagger API 설정을 통한 문서화나 개발/테스트를 위한 H2 데이터베이스 콘솔이 적용되어있었고

 

Spring Security 적용 후 이것들에 대해 어떤 문제가 발생하게 되었다.  

 

팀원이 올려둔 깃헙 이슈를 기반으로 즉각적으로 해결하고 개선했던 기록을 남겨보게 되었다!.

 

 

 

문제상황1

 

Swagger-ui 에 인증객체 정보가 입력 파라미터로 나와요!

 

스웨거 3.0 버전 기준입니다.

 

@GetMapping("/me")
@Authenticated
public ResponseEntity<Response<MypageResponse>> mypage(@CurrentMember Long id) {
    return ResponseEntity.ok(
            Response.of(HttpStatus.OK, memberService.mypage(id), "회원조회성공"));
}

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : id")
public @interface CurrentMember {
}

 

인가가 필요한 요청에 대해 Authorization 헤더로 토큰을 가진 상태에서 요청을 시도한 인증된 사용자로부터

 

@CurrentMember 를 통해 Authentication Principal을 얻어오고

 

그 인증 객체에 있는 사용자 아이디를 가져오게 하여 비즈니스 로직에서 활용할 수 있게끔 구현해두었다.

 

스프링 시큐리티를 접해보지 않은 백엔드 팀원들을 위해 

 

시큐리티 설정을 건드리지 않아도 되고 비즈니스 로직 상에서 시큐리티 관련 흐름이나 코드를 최대한 몰라도 되게끔 하면서도

 

인가가 필요한 요청임을 잘 나타내주게 하기 위해 인증객체 정보 얻기와 API 인가 처리를 커스텀 어노테이션으로  해결했다.

 

중복되는걸 정말 싫어하는데 인가와 인증정보가 필요한 매 클래스 또는 메소드마다 저걸 중복해서 붙여야 하나 하며 마음아팠지만

 

시큐리티 설정에 난잡하게 그때그때 필요한 경로를 하드코딩 해놓는 것 보다는 api 만들 때 붙여서

 

어노테이션만 보고 "아 ~ 이건 인가가 필요한 API구나~~" 라고 더 쉽게 알아챌 수 있지 않을까라는 자기 합리화를 해본다. 깔깔!

 

 

아무튼 해당 이슈는 별도의 설정 없이 어노테이션과 함께 컨트롤러의 파라미터에 넣다보니  Swagger API 문서에 파라미터로 입력하라는 듯이 나왔다는 것이다.

 

 

 

해결?  @ApiIgnore 어노테이션 붙이기.

 

@GetMapping("/me")
@Authenticated
public ResponseEntity<Response<MypageResponse>> mypage(@ApiIgnore @CurrentMember Long id) {
    return ResponseEntity.ok(
            Response.of(HttpStatus.OK, memberService.mypage(id), "회원조회성공"));
}

 

물론 Swagger 의 해당 어노테이션을 이용하면 문서상에 표기되지 않게 할 수는 있지만 저걸 매 API 마다 붙일 수는 없다.

 

 

 

해결! 

 

스웨거 설정을 추가하자

 

Swagger Configuration 에 아래와 같은 코드를 추가해주면 된다.

 

 .ignoredParameterTypes(CurrentMember.class)

 

있을 것 같아서 스웨거 설정 Docket 클래스 까서 메소드 찾아보다가 메소드 이름이나 파라미터 타입만 딱 봐도 해당 클래스로의 파라미터는 무시하겠다는 소리 같길래 써봤더니 되었다.

 

이렇게 친절하게 자바독으로 설명까지 다 써있다.

 

역시 네이밍이 이래서 중요하고 역시 API 문서화가 이래서 중요하지 싶다.

 

 

 

 

문제상황2

 

Swagger-ui 접속 시 N번 Access Token 검증 작업이 이루어져요!

 

 

 

jwt 기반 인증, 인가 구현을 위해 JwtProvider라는 클래스를 만들었었고 이 클래스의 역할은 이름대로 토큰 생성, 검증 등이었다.

 

사용자 api 요청 시 토큰 검증을 위해 인가 서블릿 필터에서 해당 빈을 주입받아 토큰을 검증하게끔 로직을 구성했었는데

 

스웨거 키면서 디버깅해보니 역시나 그 부분이 문제였다.

 

개발자 도구 네트워크 요청이랑 코드 디버깅으로 확인해보니 스웨거 딱 한 번 키는데 대략 다음과 같은 요청들을 항상 하고 있었다.

 

/swagger-ui/index.html
/swagger-resources/configuration/ui
/swagger-resources/configuration/security
/swagger-resources
/v3/api-docs

 

 

해결하기

 

따라서 BasicAuthenticationFilter 를 상속받는 인가 필터에서 shouldNotFilter 메소드를 재정의해서 "api" 라는 경로 프리픽스가 붙는 실제 api 들만 인가 필터가 적용되도록 구현했다.

 

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtProvider jwtProvider;
    // ...
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String url = request.getServletPath();
        return Stream.of("/api").noneMatch(url::startsWith);
    }
}

 

사실 이는 적용하면서도 그렇게 좋은방법 같지는 않아보였다는 생각을 했다.

 

인가 필터를 거치지 말아야 할 요청들이 확실시 된다면 그것들을 화이트리스트로 만들고 shouldNotFilter 메소드에 적용시키는 것이 조금 더 바람직해 보였다.

 

우선은 세부적으로 정확하게 어떤 것들이 그러한지 불확실했고

 

저렇게 해도 인가가 필요한 적절한 api의 필터 동작에는 전혀 문제 없을거라 감히 판단을 해버렸다. 

 

 

 

문제상황3

 

h2-console 출력 안됨... security 설정이 /** 인데 왜 h2-console만 안되네요.. 명시적으로 /h2-console/**로 선언해도 적용이 안됩니다.

 

 

h2 접속이 안된다는 건 줄 알고 들어가봤더니 h2 접속 자체는 200으로 잘 되었다.

 

하지만 직접 확인 해보니 접속은 되는데 다음과 같이 UI 가 구성되지 않았다.

 

맨 왼쪽에 테이블, 위에 SQL 입력란 밑에 결과로그가 나와야 한다!

 

매우 익숙한 h2 콘솔 구조로  레이아웃이 잘 나눠져 있는데 안에 내용물만 나오지 않고 있다.

 

그래서 궁금해서 크롬 개발자 도구 키고 태그를 확인해봤는데 저 내부의 것들은 <frame> 태그로 구성되어있었다.

 

예전에 어디선가 iframe 이 웹 보안상 위험하다 해서 관련 헤더 설정이 deny여야 한다 했던걸 봤던 기억이 나 개발자 도구에서 요청 헤더를 봤는데

 

 

역시 X-Frame-OptionDeny 였었다. (역시 킹발자 도구가 최고다).

 

 

저 때 시큐리티 적용하면서 헤더 허용을 특정한 것들만 했었는지 전부 했었는지 기억은 안나는데

 

추후에 기본으로 X-Frame-Option 헤더가 Deny 로 설정되게끔 설계되어있는 건지 확인해봐야 할 것 같다.

 

아마 밑의 시큐리티 설정으로 유추해보자면  csrf 필터도 안 끄면 기본으로 적용되게 설정되듯 

 

X-Frame-Option 헤더 정책도 Deny 정책이 기본으로 적용되게끔 되어있는 것 같다.

 

 

 

해결하기

 

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .antMatcher("/h2-console/**").headers().frameOptions().disable()
            .and()
            .antMatcher("/**").cors().configurationSource(corsConfigurationSource())
            // ...

 

해결법이 매우 간단하다 h2-console 요청에 대해서는 frameOptions 에 대한 헤더설정을 무효화 해주면 된다. 

 

메서드 체이닝으로 아래쪽에 모든 요청에 대한 cors 설정이 매핑되어있는데 반드시 이런 전역적인 설정 보다 위에있어야 한다!

 

 

문제상황4 

 

서블릿 필터로 구현하신 로그인 api는 Swagger 문서에 나오지 않아요.

컨트롤러 상에서 동작하게 변경하면 안되나요?

 

 

해결 시점 로그인 로직의 경우 인증 처리를 위해 AuthenticationManager 가 필요했고 

 

성공 시 토큰 생성 발급을 위한 JwtProvider 의존성이 필요했고

 

추후에는 RefreshToken을 위한 Secure Cookie 설정이 필요하며   

 

TTL이 있는 RefreshToken 저장이나 로그인 대입 공격 방지를 위한 횟수 제한 처리 등의 부가 기능 구현을 위한  Redis crud 제공 클래스 등 다양한 의존성들이 필요하게 될 것이다.

 

 

스프링 시큐리티가 어차피 필터 기반이긴 하나

 

본인의 목표가 핵심 비즈니스 로직 흐름에 이런 부가기능을 위한 의존성 잡동사니들을 최대한 넣지 않는 것이었기 때문에

 

로그인을 비즈니스 api가 아닌 필터에서 구현했던 것 같다. 

 

서블릿 필터 기술로 구현하다 보니 비교적 예외 핸들링이나 요청 데이터 얻기가 빡셌고

 

비즈니스 로직 테스트에 비해 유닛 테스트가 어려웠지만

 

적어도 로그인 기능에 대해서는 확실하게 핵심 비즈니스 로직과 부가적인 의존성들을 분리하는 것에 성공했었다고 볼 수 있었다.

 

 

해당 로그인 기능의 리팩터링 없이도 팀원의 요구사항은 충족할 수 있었다.

 

어차피 요구하는 건 문서에 api가 잘 나오게끔 하는 것이기 때문이었다.

 

 

 

해결하기

 

컨트롤러에 가짜 로그인 api 를 만든다.

 

@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest) {
    return ResponseEntity.ok();
}

 

아무런 동작도 없이 성공 응답만 하는 코드지만 적절한 url 과 적절한 body 요청데이터와 적절한 method 와 함께  Swagger 가 인식하여 문서를 만들 것이다.

 

하지만 동작은 필터에서 구현한 로그인 로직과 똑같이 잘 될 것이다.

 

 

이게 가능한 이유는 스프링에서 서블릿 필터와 컨트롤러가 요청을 받는 위치만 알면 바로 이해가 될텐데 

 

실제 시큐리티의 로그인 로직은 서블릿 필터에서 시작하여 성공하던 실패하던 반드시 거기서 끝나게 되기 때문에

 

Dispatcher Servlet 뒤에 존재하는 컨트롤러는 요청을 구경도 못하는 것이다.

 

따라서 스웨거에는 문서화 되어있고 동작도 잘 하지만 실제로 동작은 서블릿 필터에서 시작하여 거기서 끝나기 때문에

 

저 가짜 컨트롤러 api에 500을 뱉게 하든 200을 뱉게 하든  "나는 바보다" 를 로깅하게 하던 절대 동작할 수 없다.

 

 

약간 유령코드? 느낌이라 

 

시큐리티나 필터 다 배제시키고  WebMvcTest 유닛 테스트로 저것만 동작시키지 않는 이상 커버리지도 못 채우고

 

실제로는 어떻게든 동작을 못시키기 때문에 미봉책같다는 생각이 없지않아 들지만

 

어쨋건 팀원이 요구하는 api 명시가 잘 되었고 스웨거에서 돌려도 실제 로그인 로직이 돌아가기 때문에 전혀 문제 없을것이라 판단했다.

 

이 이슈는 사실 너무 궁금해서 해결 후 구글링을 좀 해봤었는데 이 방법에 대한 답변 1개 외에는 해결책을 찾지 못했다. ㅠㅠ 

 

 

 

 

'Spring' 카테고리의 다른 글

Api 캐싱  (0) 2022.07.14
[SpringBoot] Spring Data Redis 를 사용해보자  (0) 2022.07.13