Spring Security 아는척하기

YoungHo-Cha·2022년 8월 4일
14

Catch Bug Project

목록 보기
6/12
post-thumbnail

자. 지금부터 스프링 시큐리티를 아는척해보자.


🚗 목차

  • 목표
  • 선행학습
  • Spring Security
  • Spring Security Filter Chain
  • 상황 구현하기
  • 시큐리티 설정

🌈 목표

우리의 목표는 스프링 시큐리티의 모든 것을 통달한 자가 아니다.

무언가를 해야할 때, 어떠한 것을 만지면 되는가?

이다.

🐳 상황

하지만 한가지의 상황에 맞게 공부를 해보자.

  1. 클라이언트와 서버간 API 보안은 JWT를 이용한다.

  2. 스프링 시큐리티 필터에서 JWT를 통한 인증을 수행한다.

  3. 인증에 성공하면, 컨트롤러 단에서 해당 인증 정보를 사용할 수 있도록 Security Context에 인증 정보를 저장한다.

  4. 서버는 Rest 서버이다.

  5. 인증 실패 시, 스프링 시큐리티 필터에서 응답을 수행한다.

🌈 선행 학습

위의 상황을 구현하기 위해서는 우선적으로 스프링 시큐리티에 관해서 알아야 한다.

다음 그림을 보자.

위의 그림은 클라이언트의 요청이 컨트롤러에 닿을 때까지의 순서이다.

🐳 Filter

필터는 톰캣 단에서 동작하는 것이다. Spring에 진입하기전(디스패처의 매핑전) 사전 처리 및 모든 자원 처리가 끝나고 사후 처리를 해주는 것이다.

🐳 Interceptor

스프링에 진입한 후(디스패처 매핑 후) 필요한 처리를 해주는 것이다. 인터셉터는 후에 동작할 Controller에 관한 사전 처리를 해주는 것이다.

🐳 AOP

AOP는 앞서 말한 2가지와 느낌이 다르다. Proxy 형태로 끼워넣는 형태이다. 어플리케이션에서 횡단으로 같은 로직을 넣기 위함이다.

보통 로깅, 에러 처리를 위해서 사용한다.

🐳 왜 나누는가?

당연히 이유가 있기 때문에 나누어진 것이다.

  • Filter : 스프링 외부에 존재해서 스프링과 완전 무관한 내용에 대해서만 처리 동작을 한다.

  • Interceptor : 컨트롤러 전과 후에 동작하기 때문에 컨트롤러와 매우 밀접한 행동을 한다.

인터셉터는 주로 어떤 로직에 쓰이는지 잘 모르겠다. 뭐 개발하기 나름이겠지만.. 아시는분 댓글 부탁드려요 ㅠㅠ

🐳 GenericFilterBean

갑자기 Bean이 왜 튀어나오나 생각할 수 있다.

하지만 매우매우 중요한 것이다.

Spring에 들어가기 전 수행되는 것이 필터이다.

그래서 스프링 컨테이너에 속해있는 Bean을 가지고 올 수 없다.

이것을 도와주는 것이 GenericFilterBean 이다.

✅ GenericFilterBean Docs

해당 클래스 내부를 보면 다음과 같다.

public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }

sevletContext를 읽어와서 주입해주는데, applicationContext를 살펴보면 다음과 같다.
(일부 발췌)

applicationContext는 servlet를 구현한다.

🌈 Spring Security

스프링 시큐리티 공부하다가 위의 내용이 왜 나오는지 궁금한 사람이 있을 것이다.

조금만 참고 더 읽어보자.

Spring Security는 스프링 기반의 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크다. 인증과 인가를 담당한다.
스프링 시큐리티는 주로 서블릿 필터와 이들로 구성된 필터체인으로의 구성된 위임 모델을 사용한다.

"서블릿 필터와 이들로 구성된 필터체인으로의 구성된 위임 모델을 사용한다." 에 집중하자.

그리고 다음 그림을 보자.

위의 그림을 보면 왜 설명한 지 느낌이 올 것이다.

맞다. 스프링 시큐리티는 일련의 필터 조합 프록시를 필터 단에 쏙~ 집어넣는 것이다.

이 필터들은 웹 개발에서 "인증"과 "인가"에 대한 로직을 편하게 수행할 수 있도록 도와준다.

🌈 Spring Security Filter Chain

또 다음 그림을 살펴보자.

위 그림은 스프링 시큐리티 필터 체인을 아주 살짝 확대한 모습이다.

엄청나자나..?

맞다.. 알아야할 내용이 뭔가 많고 복잡하다. 조금 더 자세히 볼 수 있는 그림을 보자.

위 그림은 스프링 시큐리티 필터체인과 상호작용을 표현하는 그림이다.

Default로 모든 내용이 설정되어 있다.

우리는 위의 필터들을 상속 및 구현하여 커스텀화 시켜주면 된다.

예를 들면 로그아웃을 내 마음대로 하고싶으면 LogOut과 관련된 모듈을 상속받거나 구현하여 메서드 오버라이드 해주면 된다.

저 필터들에 관한 자세한 내용은 다음 글에서 살펴보자!
✅ 필터 설명 페이지

🌈 상황 구현하기

먼저 생각을 해보자.

우리는 JWT를 통한 API 인증을 수행할 것이다. JWT 외 내용은 필요없고 Request에 존재하는 JWT가 유효한 것인지 판단하고

  • 유효하다면 Payload 내용이나 해당 유저에 관한 내용을 Security Context에 담고 filter 체인은 진행시켜 버리면 된다.

  • 유효하지 않다면 filter를 진행하지 않고, response를 생성하여 응답해버리면 된다.

위처럼하면 요청이 스프링까지 오지 않고 필터 단에서 요청에 대한 응답을 수행하기 때문에 쓰레드 낭비도 없고(쓰레드가 작업에 물린 상태에서 빨리 끝나니까), 요청도 빠르고, 그 외 자원도 안먹고, 또 그리고.. 뭐 이점이 많다.

🐳 기본 구현법

보통 시큐리티를 구현할 때 어떤식으로 이루어지는지 살펴보자.

  1. 구현하려는 행위를 정확히 알아야 한다.

구현하려는 행위를 알아야 하는 이유 : 행위에 따라 기존에 구현되어 있는 필터 중 어떤 것을 커스텀할지 정해지기 때문이다.

  1. 구현하려는 행위를 알았으면, 해당 필터를 커스텀하는 방법을 찾는다.

커스텀하는 방법은 수 없이 많다.
1. 해당 구현을 상속받아 오버라이드한다.
2. 구현하려는 행위에 관한 필터를 disable 시키고, 새로운 필터를 해당 필터자리에 끼워넣는다.
3. 그 외에도 방법은 많다.

주로 1번, 2번을 수행한다.

나는 JWT를 이용한 인증 및 인가 처리를 할 것이다.

🐳 스프링 시큐리티에서 제공하는 JWT 필터

스프링에서 JWT 관련하여 필터를 기본적으로 만들어놓고 우리가 커스텀하면 매우 편할 것이다. 그래서 찾아보았다.

없다.

나의 검색능력으로 못찾은 건지 알 수는 없지만 공식 문서, 시큐리티 필터 뒤져보기, 각종 글을 살펴보았지만 없다.

그럼 내가 직접 커스텀할 필터를 정해보자.

🐳 커스텀할 필터 정하기

스프링에서 제공해주는 필터가 없으니, 인증을 담당하는 필터를 직접 구현해보자.

인증 및 인가 커스텀을 도와주는 클래스는 많다.

  1. OncePerRequestFilter 이용하기

  2. AbstractAuthenticationProcessingFilter 이용하기

  3. UsernamePasswordAuthenticationFilter 이용하기

각각 어떠한 방식인지 대충만 알아보자.

일단 전부 다 인증을 위해서 사용할 수 있다.

  1. OncePerRequestFilter

해당 필터는 서블릿의 캐싱? 기능 때문에 생길 수 있는 장애?를 위한 필터이다. 서블릿은 기본적으로 어떠한 요청을 받으면 해당 요청에 대한 정보를 메모리에 저장해 두고, 같은 클라이언트의 요청이 오면 저장해둔 서블릿 객체를 재활용하여 사용한다.

문제는 서블릿이 다른 곳으로 dispatch가 되는 경우이다.

이 때, 다시 서블릿을 탈 것이고 만들어둔 객체의 필터 + 새로운 필터가 동작하여 같은 필터를 2번 타게 되는 경우가 있다.

이것을 방지해주기 위한 필터이다.

조금 더 추상적이고 넓은 범위에서 커스텀하여 구현할 수 있도록 만든 필터라고 할 수 있다.

  1. AbstractAuthenticationProcessingFilter(이 필터 채택🌼)
    해당 필터는 요청이 들어오면 가장 먼저 가로채 하위 필터에게 인증을 유보하는 역할을 한다.

Bean에 대한 정보도 가지고 있으므로 사용하기에 가장 적합하다고 판단된다.(나의 수준에서는..)

  1. UsernamePasswordAuthenticationFilter

해당 필터는 Rest가 아닌 View까지 관리를 하는 페이지에 주로 사용하는 필터이다. 내부 메서드를 보면, getParameter()를 이용하여 사용자의 아이디와 비밀번호를 추출한다.

하지만 내 서버는 Rest이고 Json으로 요청 및 응답이 이루어지기 때문에 부적절하다고 판단된다.
(굳이 이용하겠다면 이용할 수는 있음)

그리고 JWT를 이용하기 때문에 해당 필터를 상속받아서 쓰기에는 타개발자가 보기에 이해가 안갈 수 있다. 그래서 해당 필터는 배제.

🐳 구현해보기

코드는 최대한 생략할 예정이다. 핵심적인 부분만 살펴보자.

Filter 상속 해보기

그럼 위에서 정한 필터를 내려받아 커스텀해보자!

상속을 받으면 빨간 줄이 쭉 생긴다.

빨간 줄을 해결하다보면 생성자 1개, 메서드 1개가 생긴다.

  • 생성자 : Security Config를 통해 Request Matcher을 등록한다. Request Matcher가 해당 필터를 통과시킬지 판단하기 위해서 해당 클래스에 RequestMatcher를 받는 생성자가 필요하다.

  • attempAuthentication : 실제 인증을 수행하기 전에 사전 처리를 하는 메서드이다.

커스텀 해보기

"attemptAuthentication" 메서드는 "Authentication" 객체를 리턴하는 것을 볼 수 있다.

@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        // 사용자의 요청에 존재하는 Authorization Heaer Value
        String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);

        // Remove Prefix (Bearer)
        String token = authorizationHeader.substring(BEARER_PREFIX.length());

        // return 할 객체 만들기
        
        return null;
    }

사전 처리를 수행하는 과정이다. 사용자의 request에 존재하는 토큰을 받아오는 과정이다.

이제 Authentication 객체를 반환하면 된다.

Authentication이란?

해당 객체는 유저의 정보, 권한을 가지고 있는 객체이다.

주로 token방식에서는 AbstractAuthenticationToken 객체를 상속받아 사용한다.

상속받아서 인증 전 객체 Dto를 만들어보자.

public class DtoOfJwtAuthentication extends AbstractAuthenticationToken {
    private String token;

    public DtoOfJwtAuthentication(String token) {
        super(null); // 인증 전 객체이므로 권한 정보가 없음
        this.setAuthenticated(false);
        this.token = token;
    }

    @Override
    public Object getCredentials() {
        return "";
    }

    @Override
    public Object getPrincipal() {
        return token;
    }
}
  • getCredentials : 우리는 별도의 패스워드를 사용하지 않는다. (Oauth2를 사용하기 떄문)

  • getPrincipal : 인증 전이므로 token 그대로 뱉도록 한다.

왜 굳이 Authentication을 쓰는거야?

당연히 써야하는 것이다.

Spring Security의 동작들이 Authentication 객체를 이용하였으니까 그리고 우리는 중간에 필터를 가로채서 고쳤으니까 파라미터, 리턴 값은 지켜야한다.

Authentication 반환

이제 위의 로직에서 구현한 객체를 리턴하도록 해보자.

@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        
        // ... 중략 ...

        // return 할 객체 만들기
        DtoOfJwtAuthentication dtoOfJwtPreAuthentication = new DtoOfJwtAuthentication(token);
        return this.getAuthenticationManager().authenticate(dtoOfJwtPreAuthentication);
    }

완료.

인증하러 가보자!

인증 전 객체는 만들었다. 이제 인증은 어떻게 하는가?

인증하는 방법은 대표적으로 다음의 방법이 있다.

AuthenticationProvider 구현하면 위에서 호출했던 authenticate가 수행된다.

@RequiredArgsConstructor
@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtProvider jwtProvider;


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 사전 처리한 token 얻기
        String token = (String) authentication.getPrincipal();
        try {

            DtoOfUserDataFromJwt userPayloads = jwtProvider.getUserData(token);
            
            UserContext context = new UserContext(userPayloads);

            return new DtoOfJwtPostAuthenticationToken(context);

        } catch (SignatureException | MalformedJwtException | MissingClaimException ex) {
            // JWT 인증 에러
            throw new RuntimeException();

        } catch (ExpiredJwtException ex) {
            // JWT 만료 에러
            throw new RuntimeException();
        }

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return DtoOfJwtPostAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

여기서 새로운 객체를 볼 수 있다.

DtoOfJwtPostAuthenticationToken

이 또한 Authentication 객체이며, 비로소 Security Context에 저장하게 되는 인증완료된 객체이다.

DtoOfJwtPostAuthenticationToken.java

public class DtoOfJwtPostAuthenticationToken extends AbstractAuthenticationToken {
    private final UserContext userContext;
    public DtoOfJwtPostAuthenticationToken(UserContext userContext) {
        super(List.of(new SimpleGrantedAuthority(userContext.getUserPayloads().getNickname())));
        this.userContext = userContext;
        this.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return userContext;
    }
}

인증에 실패한 경우!

필터에서 인증에 실패한 경우를 대비하여, 처리해줄 Handler를 구현해야 한다.

인증 실패는 JWT에 관한 내용일 가능성이 상당히 높으니까 Hanlder이는 Jwt에 관한 예외 처리만 해놓겠다.

JwtAuthenticationFailureHandler.java

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        SecurityContextHolder.clearContext();

        log.info("JwtAuthenticationFailureHandler 실행");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setHeader(HttpHeaders.CONTENT_ENCODING, "UTF-8");
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

            PrintWriter writer = response.getWriter();
            writer.write(objectMapper.writeValueAsString("JWT 인증 실패했음"));


    }
}

🌈 시큐리티 설정

지금까지 만든 것을 시큐리티에 설정해야한다. 한번 해보자!

🐳 Security Config

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
    private JwtAuthenticationProvider jwtAuthenticationProvider;

    @Autowired
    private JwtAuthenticationFailureHandler jwtAuthenticationFailureHandler;
    
    // jwt 인증 필터
    public Filter jwtAuthenticationFilter() throws Exception {
        FilterSkipMatcher filterSkipMatcher = new FilterSkipMatcher(
                List.of("/api/refresh", "/api/logout"),
                List.of("/api/**")
        );
        //필터 생성
        JwtAuthenticateFilter jwtAuthenticateFilter = new JwtAuthenticateFilter(filterSkipMatcher);
        
        //필터 인증 매니저 설정
        jwtAuthenticateFilter.setAuthenticationManager(super.authenticationManager());
        
        
        //실패 Handler 설정
        jwtAuthenticateFilter.setAuthenticationFailureHandler(jwtAuthenticationFailureHandler);
        return jwtAuthenticateFilter;
    }
    
   // authentication manager setting
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
        auth.authenticationProvider(jwtAuthenticationProvider);
    }




    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // login disable
        http.formLogin().disable();

        // csrf disable
        http.csrf().disable();

        // http basic diable
        http.httpBasic().disable();

        // JWT
		// 필터 등록
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    }
}

Matcher와 관련된 부분은 따로 포스팅 하지 않았다.

이제 테스트를 해보자.

🧐 테스트

 @GetMapping("/test")
    public String test(){
        return "good!";
    }

아무 컨트롤러를 생성해서 요청해보자.

필터에 로그도 하나 추가해보자.

  1. AccessToken을 임의로 받고 PostMan을 통해서 요청해보자.

요청은 "JWT 인증 실패했음"가 왔다.
컨트롤러는 good이 와야 하는데 JWT 인증 실패해서 실패 Handler에서 작성해준 "JWT 인증 실패했음"이 온 것이다.

  1. 로그 확인해보자.

Filter -> Handler 순서로 실행된 것을 볼 수 있다.

기본 테스트는 했다.
이제 테스트 코드를 써보자.

테스트 코드에 관한 글은 다음 글에서..

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

0개의 댓글