ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Boot JWT Tutorial (2)JWT
    Spring/SpringSecurity 2022. 4. 12. 12:41

    ===

    https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard

     

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

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

    www.inflearn.com

     

    ====================================

    JWT 설정 추가, JWT 관련 코드 개발, Security 설정 추가

    HS512 알고리즘 사용 하기 때문에 Secret Key는 64Byte 이상이 돼야한다

    spring:
    
      h2:
        console:
          enabled: true
    
      datasource:
        url: jdbc:h2:mem:testdb
        driver-class-name: org.h2.Driver
        username: sa
        password:
    
      jpa:
        database-platform: org.hibernate.dialect.H2Dialect
        hibernate:
          ddl-auto: create-drop
        properties:
          hibernate:
            format_sql: true
            show_sql: true
        defer-datasource-initialization: true
    
    jwt:
      header: Authorization
      #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
      #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
      secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
      token-validity-in-seconds: 86400
    
    logging:
      level:
        me.silvernine: DEBUG

    참고로 이 값은 Secret Key를 Base64로 인코딩한 값을 사용했습니다

    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

     

    ===

    JWT 코드 개발

    jwt 패키지!

    토큰의 생성, 토큰의 유효성 검증등을 담당할 TokenProvider를 만들어보겠습니다.

    package me.silvernine.tutorial.jwt;
    
    import io.jsonwebtoken.*;
    import io.jsonwebtoken.io.Decoders;
    import io.jsonwebtoken.security.Keys;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.InitializingBean;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.stereotype.Component;
    
    import java.security.Key;
    import java.util.Arrays;
    import java.util.Collection;
    import java.util.Date;
    import java.util.stream.Collectors;
    
    @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);
       }
    
       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();
       }
    
       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);
       }
    
       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;
       }
    }

     

    ===

    나눠서 보자

    @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을 오버라이드 한 이유는

    빈이 생성이 되고(@Component) 의존성 주입을 받은 후에(생성자로)

    주입받은 secret값을 Base64 Decode한 다음 key변수에 할당하기 위함(메소드 내용)

     

    ===

    Authentication 객체의 권한 정보를 이용해서 토큰을 생성하는 createToken메소드 추가

    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 파라미터를 받아서 

    authorities... 권한들

     

    long now = 는 application.yml에서 설정했던 만료시간을 설정하고 토큰 생성

    jwt토큰을 생성해서 리턴

     

    ===

    이번에는 역으로 Token을 파라미터로 받아서 토큰에 담겨있는 권한정보들을 이용해서 Authentication 객체를 리턴하는 getAuthentication메소드

    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을 받아서 토큰으로 claims를 만들고

     

    claims에서 권한정보들을 빼내서 권한정보를 이용해서 User principal 객체를 만들고

     

    유저객체, 토큰, 권한정보를 이용해서 최종적으로 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;
       }

    토큰을 파라미터로 받아서, try 파싱을 해보고 

    나오는 익셉션들을 캐치하고 문제가 있으면 return false, 문제가 없으면 return true

     

    ===

    JWT를 위한 커스텀 필터를 만들기 위해 JwtFilter라는 클래스를 만들어보기

    package me.silvernine.tutorial.jwt;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.util.StringUtils;
    import org.springframework.web.filter.GenericFilterBean;
    
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    import javax.servlet.http.HttpServletRequest;
    import java.io.IOException;
    
    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)
          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);
       }
    
       private String resolveToken(HttpServletRequest request) {
          String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
          if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
             return bearerToken.substring(7);
          }
          return null;
       }
    }

    JwtFilter는 방금 만들었던 TokenProvider를 주입받음.

     

    필터링을 하기 위해 토큰 정보가 있어야 하니까 resolveToken이라는 메소드 추가

    request Header에서(request.getHeader) 토큰 정보를 꺼내오는 메소드

     

    GenericFilterBean을 extends해서 doFilter메소드를 오버라이드. 실제 필터링 로직은 doFilter 내부에 들어감

    doFilter메소드의 역할은 jwt토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하기 위한 역할 수행

     

    doFilter메소드의 내용은

     

    String jwt = resolveToken(httpServletRequest);

     :  request에서 토큰을 받아서

     

    이 jwt토큰을 우리가 방금전에 만들었던 tokenProvider.validateToken 유효성 검증 메소드를 통과하고

    토큰이 정상이면 토큰에서 authentication 객체를 받아와서 SecurityContext에 set해준다

     

     

    ===

    자 이제 우리가 만든 TokenProvider와 JwtFilter를 SecurityConfig에 적용할 때 사용할 JwtSecurityConfig를 만들것

    package me.silvernine.tutorial.jwt;
    
    import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.web.DefaultSecurityFilterChain;
    import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    
    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);
        }
    }

    extends SecurityConfigurerAdapter하고

    TokenProvider를 주입받음.

    configure 메소드를 오버라이드해서 우리가 방금 만들었던 JwtFilter를 Security로직에 필터로 등록하는 역할을 합니다=http.addFilterBefore(....)

     

    ===

    이번엔 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴하기 위해서 JwtAuthenticationEntryPoint implements AuthenticationEntryPoint 만들기

    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.web.AuthenticationEntryPoint;
    import org.springframework.stereotype.Component;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
       @Override
       public void commence(HttpServletRequest request,
                            HttpServletResponse response,
                            AuthenticationException authException) throws IOException {
          // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
          response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
       }
    }

    401UNAUTHORIZED 를 send하는 클래스입니다.

     

    ===

    필요한 권한이 존재하지 않은 경우에 403 Forbidden에러를 리턴하기 위해서 

    AccessDeniedHandler를 구현한 JwtAccessDeniedHandler

    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    
       @Override
       public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
          //필요한 권한이 없이 접근하려 할때 403
          response.sendError(HttpServletResponse.SC_FORBIDDEN);
       }
    }

     

    ===

    jwt에 만든 5개의 클래스를 SecurityConfig에 추가하는 작업

     

    강의에는 private final CorsFilter corsFilter;가 없다. 깃헙에만 있어

    import me.silvernine.tutorial.jwt.JwtSecurityConfig;
    import me.silvernine.tutorial.jwt.JwtAccessDeniedHandler;
    import me.silvernine.tutorial.jwt.JwtAuthenticationEntryPoint;
    import me.silvernine.tutorial.jwt.TokenProvider;
    import org.springframework.context.annotation.Bean;
    import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.builders.WebSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.config.http.SessionCreationPolicy;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    import org.springframework.web.filter.CorsFilter;
    
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        private final TokenProvider tokenProvider;
        private final CorsFilter corsFilter;
        private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
        private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
        public SecurityConfig(
                TokenProvider tokenProvider,
                CorsFilter corsFilter,
                JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                JwtAccessDeniedHandler jwtAccessDeniedHandler
        ) {
            this.tokenProvider = tokenProvider;
            this.corsFilter = corsFilter;
            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라고 하는 애너테이션을 메소드단위로 추가하기 위해 사용

     

    ▽원래 (1)에서 처음에 만들 땐 생성자 아무것도 안받았었는데

    tokenProvider, jwtAuthenticationEntryPoint, jwtAccessDeniedHandler 주입받음

    PasswordEncoder는 BCryptPasswordEncoder 사용

     

    configure 메소드를 오버라이드한 부분에서 많은 내용 추가.

    우리는 토큰방식을 사용하기 때문에 csrf설정을 disable

    exception을 Handling할 때 authenticationEntryPoint와 accessDeniedHandler를 우리가 만들었던 클래스들 추가

    얘네는 h2-console을 위한 설정 추가한 것

     

    우리는 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정

    토큰을 받기 위한 로그인 API(authenticate)와, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에 모두 permitAll 설정

     

    마지막으로 방금 만들었던 jwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용

     

    서버 잘 켜짐

Designed by Tistory.