Spring Security/Json Web Token

[Spring] JWT(Json Web Token) 예제

sbs1621 2022. 6. 6. 18:00

이전 게시글에 이어 간단하게 JWT를 이용하여 실습 예제를 만들어 보겠습니다.

 

JWT패키지 생성 및 토큰 설정

TokenProvider클래스 생성

토큰 생성 및 토근 유효성 검증을 담당합니다.
@Component
public class TokenProvider implements InitializingBean {

   private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
   private static final String AUTHORITIES_KEY = "auth";
   private final String secret;
   private final long tokenValidityInMilliseconds;
   private Key key;


   public TokenProvider(
      @Value("${jwt.secret}") String secret,
      @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
      this.secret = secret;
      this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
   }

   @Override
   public void afterPropertiesSet() {
      byte[] keyBytes = Decoders.BASE64.decode(secret);
      this.key = Keys.hmacShaKeyFor(keyBytes);
   }

}
  • InitializingBean을 implements 해서 afterPropertiesSet을 Override
    • 빈이 생성되고 의존성 주입을 받은 후 시크릿값을 Base64로 Decode 해서 key변수에 할당

 

createToken 메소드 추가

Authentication 객체에 포함되어 있는 권한 정보들을 담은 토큰을 생성하는 기능을 합니다.
public String createToken(Authentication authentication) {
    String authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    long now = (new Date()).getTime();
    Date validity = new Date(now + this.tokenValidityInMilliseconds);

    return Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();
}
  • Authentication 권한설정과 application.yml에서 설정했던 만료시간을 설정하고 토큰 생성

 

getAuthentication 메소드 생성

Token에 담겨있는 정보를 이용해 Authentication 객체를 리턴
public Authentication getAuthentication(String token) {
    Claims claims = Jwts
            .parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();

    Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

    User principal = new User(claims.getSubject(), "", authorities);

    return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
  • Token을 이용하여 클레임을 만들고 클레임에서 권한 정보를 빼냄
  • 권한 정보를 이용하여 User객체를 만듦
  • 최종적으로 User객체, 토큰, 권한 정보를 이용하여 authentication 객체를 리턴

 

validateToken 메소드 추가

토큰 유효성 검사를 할 수 있습니다.
public boolean validateToken(String token) {
   try {
      Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
      return true;
   } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
      logger.info("잘못된 JWT 서명입니다.");
   } catch (ExpiredJwtException e) {
      logger.info("만료된 JWT 토큰입니다.");
   } catch (UnsupportedJwtException e) {
      logger.info("지원되지 않는 JWT 토큰입니다.");
   } catch (IllegalArgumentException e) {
      logger.info("JWT 토큰이 잘못되었습니다.");
   }
   return false;
}
  • 토큰을 파싱 해보고 발생하는 exception 을 캐치
  • 문제가 있으면 false, 정상이면 true를 리턴

JwtFilter 클래스 생성

JWT 커스텀 필터를 만들기 위해 작성합니다.
public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private TokenProvider tokenProvider;

    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            //...
    }


}
  • GenericFilterBean을 extends해서 doFilter 메소드를 오버라이딩
    • 실제 필터링 로직은 doFilter 내부에 작성됨
  • doFilter의 역할은 JWT의 인증정보를 현재 실행 중인 SecurityContext에 저장하는 역할
  • JwtFilter는 방금 만들었던 TokenProvider를 주입 받음

 

resolveToken 메소드 추가

필터링을 하기 위해서 토큰 정보가 있어야 한다.
private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}
  • Request Header에서 꺼내오는 메소드

 

doFilter내부 작성

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
    String jwt = resolveToken(httpServletRequest);
    String requestURI = httpServletRequest.getRequestURI();

    if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
        Authentication authentication = tokenProvider.getAuthentication(jwt);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
    } else {
        logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
    }

    filterChain.doFilter(servletRequest, servletResponse);
}
  • resolveToken을 통해 request에서 토큰을 받아서 방금 전 만들었던 유효성 검사를 통과하고 토큰이 정상적이면 Authentication 객체를 받아와 SecurityContext에 setAuthenciation해줌

JwtSecurityConfig클래스 추가

TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용됩니다.
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private TokenProvider tokenProvider;

    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter를 extends 하고 TokenProvider를 주입 받음
  • configure메소드를 override 해서 방금 만들었던 JwtFilter를 Security로직에 필터를 등록함

JwtAuthenticationEntryPoint클래스 생성

유효한 자격증명을 제공하지 않고 접근하려고 할 때 401 에러를 리턴
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • AuthenticationEntryPoint를 impliments 하고 401 에러를 send

JwtAccessDeniedHandler클래스 생성

필요한 권한이 존재하지 않는 경우 403 에러를 리턴
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • AccessDeniedHandler를 impliments 해서 403에러를 send

SecurityConfig

jwt패키지 안에 만들었던 클래스를 SecurityConfig에 추가
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(
            TokenProvider tokenProvider,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            JwtAccessDeniedHandler jwtAccessDeniedHandler
    ) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(
                        "/h2-console/**"
                        ,"/favicon.ico"
                        ,"/error"
                );
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf().disable()

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // enable h2-console
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 세션을 사용하지 않기 때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .antMatchers("/api/authenticate").permitAll()
                .antMatchers("/api/signup").permitAll()
                .anyRequest().authenticated()

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}

@EnableGlobalMethodSecurity추가

  • 나중에 @PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해서 사용
  •  SecurityConfig
    • TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler주입
  • PasswordEncoder는 BCryptPasswordEncoder사용

Configure 메소드 추가

  • Token방식을 사용하기 때문에 csrt설정은 disable
.csrf().disable()
  • Exception을 핸들링할 때 AuthenticationEntryPoint와 accessDeniedHandler를 아까 만들었던 class로 추가해줌
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
  • h2-console을 위한 설정 추가
.headers()
.frameOptions()
.sameOrigin()
  • session을 사용하지 않기 때문에 session설정을 STALELESS
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  • 로그인, 회원가입은 토큰이 없는 상태로 요청이 들어오기 때문에 permitAll
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll()
  • JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스 적용
.apply(new JwtSecurityConfig(tokenProvider));

기본적인 JWT코드 개발과 Security설정이 끝이 났습니다.

다음으로는 로그인과 권한 검증을 확인해보는 시간을 가져보도록 합시다.


Reference

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com