본문 바로가기
개발 일기/spring

Spring Security 설정 (JWT 2)

by URMOO 2022. 8. 31.
반응형

로그인까지 성공했으니, 로그인한 사용자의 토큰으로 권한별 api를 호출해야한다.

나는 USER, ADMIN 권한 2개를 만들었고, 각 권한은 /user 와 /admin 주소로 들어갈 수 있도록 설정하였다.

그 외의 주소는 전부 접속 가능하도록 만들었다.

보통 설계에서는 ADMIN 권한으로 /user까지 접속 가능한게 대부분이다.

이부분에 관련해서는 다음 포스팅에 작성하도록 하겠다!

본 포스팅의 코드는 여기서 확인 가능합니다😉

0. 토큰을 포함하여 api 호출하기

프론트엔드에서의 호출이나 swagger, postman을 사용하여 api를 호출할 때, 헤더에 Authorization을 넣어서 api 요청을 하면 된다. 이 때, TokenProvider에서 설정한 TokenType을 같이 넣어주어야한다.

swagger

postman

코드

SecurityConfig.java

//...
public class SecurityConfig {

  private final AuthenticationProvider authenticationProvider;
  private final JWTFilter jwtFilter;
  private final JWTExceptionHandlerFilter jwtExceptionHandlerFilter;
  private final CustomAccessDeniedHandler customAccessDeniedHandler;
  private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity security) throws Exception {

   //...

    security.authorizeHttpRequests()
        .antMatchers("...").permitAll()
        .antMatchers("/admin").hasRole("ADMIN")
        .antMatchers("/user").hasRole("USER")
        .antMatchers("/**").permitAll()
        .anyRequest().authenticated();

    security
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
        .addFilterBefore(jwtExceptionHandlerFilter, JWTFilter.class);

    security
        .exceptionHandling()
        .accessDeniedHandler(customAccessDeniedHandler)
        .authenticationEntryPoint(customAuthenticationEntryPoint);

    security.formLogin().disable();

    security.httpBasic().disable();

    return security.build();
  }

 //other codes...

}

JWT 관련 필터

우선, antMatcher에서 url별 권한을 설정해준 후, 필터를 만들어 등록해주었다.

 security
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

이 부분은 헤더를 통해 들어온 JWT를 판별하는 필터 부분이다.

JWTFilter에서 JWT의 상태를 확인하고, 올바른 상태가 아니라면401 상태를 응답시킨다.

토큰 상태 확인은 TokenProvider함수에서 만들어 주었다.

JWTFilter.java

package com.example.securitytest.security.jwt;

import io.jsonwebtoken.JwtException;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Slf4j
@Component
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

  public static final String AUTHORIZATION_HEADER = "Authorization";
  private final TokenProvider tokenProvider;

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

    try {
      String jwt = resolveToken(servletRequest);
      //만약 토큰에 이상이 있다면 오류가 발생한다. 
      if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
        //tokenProvider에서 jwt를 가져가 Authentication 객체생성
        Authentication authentication = this.tokenProvider.getAuthentication(jwt);
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
      //이상이 없다면 계속 진행
      filterChain.doFilter(servletRequest, servletResponse);
    } catch (JwtException e) {
      //토큰에 오류가 있다면 401에러를 응답한다. 
      log.error("[JWTExceptionHandlerFilter] "+e.getMessage());
      servletResponse.setStatus(401);
      servletResponse.setContentType("application/json;charset=UTF-8");
    }
  }

  private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    if (StringUtils.hasText(bearerToken) && StringUtils.startsWithIgnoreCase(bearerToken,
        "Bearer ")) {
      return bearerToken.substring(7);
    }
    return null;
  }
}

TokenProvider.java

//...
public class TokenProvider implements InitializingBean {

    //...

  public Authentication getAuthentication(String token) {
    Claims claims = Jwts.parserBuilder()
        .setSigningKey(key)
        .build()
        .parseClaimsJws(token)
        .getBody();

    List<SimpleGrantedAuthority> authorities = Arrays.stream(
            claims.get("Bearer").toString().split(","))
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toList());

    User principal = new User(claims.get("user_name").toString(), "", authorities);

    return new UsernamePasswordAuthenticationToken(principal, token, authorities);
  }

  //해당 함수를 실행할 때, 토큰에 이상이 있다면 JWTException이 발생한다.
  public boolean validateToken(String token) {
    Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    return true;
  }

}

예제 코드라 간단하게 401만 발생시키는 코드를 작성하였다. 추가적인 조치가 필요하다면 이 부분을 수정해서 사용하면된다.

권한, 인증 관련 처리

 security
        .exceptionHandling()
        .accessDeniedHandler(customAccessDeniedHandler)
        .authenticationEntryPoint(customAuthenticationEntryPoint);

SecurityConfig의 이 부분에서 accessDeniedHandler는 권한관련 처리를, authenticationEntryPoint는 인증관련 처리를 담당한다.

만약 ROLE_USER/admin을 호출했을 경우엔, accessDeniedHandler에 영향을 받아 403에러를 응답하게된다.

만약 헤더에 Authorization이 없거나, 인증과정에 실패한 경우에는 authenticationEntryPoint의 영향을 받아 401에러를 응답하게 된다.

없어도 각각 401, 403을 응답하지만 커스텀한 응답을 발생시키고 싶다면, 각자 Custom 파일을 만들어서 위의 코드처럼 등록시켜주면 된다. (자세한 코드는 git에 있습니다)

관련 테스트코드를 실행시킨 결과, 잘 나오는 것을 확인할 수 있다. (이것도 git에.. )

반응형

댓글