Spring_JWT Token Setting

2023. 5. 15. 18:16Spring

728x90

JWT (Json Web Token)

일반적으로 클라이언트와 서버 통신 시 권한 인가를 위해 사용하는 토큰

클라이언트의 세션 상태를 저장하는 것이 아니라 필요한 정보를 토큰 body에 저장해 클라이언트가 가지고 있고 그것을 증명서처럼 사용

웹에서는 주로 Cookie에 저장하여 사용

기본 구성

xxxxx.yyyyy.zzzzz

  • Header (xxxxx)— JWT인 토큰의 유형이나 HMAC SHA256 또는 RSA와 같이 사용되는 해시 알고리즘이 무엇으로 사용했는지 등 정보가 담긴다. Base64Url로 인코딩되어있다.
  • Payload (yyyyy)— 클라이언트에 대한 정보나, meta Data같은 내용이 들어있고, Base64Url로 인코딩되어있다.
  • Signature (zzzzz)— header에서 지정한 알고리즘과 secret 키, 서명으로 payload와 header를 담는다.

기본 인증 과정

  1. 클라이언트가 로그인을 하면, 서버로부터 access 토큰을 부여받는다.
  2. 이후 클라이언트가 모든 api 요청을 할 때 access 토큰을 포함시킨다.
  3. 서버는 access 토큰을 해독해 확인하고 검증되면 해당 api 기능을 수행한다.
  4. 기한이 만료되었으면 access 토큰을 지워주고 재로그인을 하게 한다.

문제점 :

  • 클라이언트가 계속 시스템을 이용하다가 access 토큰 기한이 만료된다면 사용중에 갑자기 로그인을 하라고 할 것이다.
  • 수명이 짧다면 만료될때마다 로그인 해주어야 한다.
  • 수명이 길면 해커에게 해독되어 사용될 가능성이 높아진다.

Refresh Token

access 토큰이 만료되었을 때, Refresh 토큰으로 서벙새로운 access 토큰을 발급받을 수 있다.

필요성 :

  • 서버 데이터베이스에 Refresh Token이 저장되어 있을때 클라이언트가 블랙리스트에 포함되어 있다면, access 토큰을 발급해주는 것을 막을 수 있다.
  • Refresh Token으로 access 토큰이 만료되면 알아서 갱신한다.

동작흐름

 

JWT 설정 코드

1. build.gradle _ JWT 의존성 추가

dependencies {

		...

    **// jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'**
}

2. DTO 생성

import lombok.Builder;
import lombok.Data;

@Builder
@Data // @Getter, @Setter, @ToString, @EqialsAndHashCode, @RequiredArgsConstructor 모두 설정 어노테이션
public class TokenDTO {
    private String grantType; // JWT 인증 타입
    private String accessToken;
    private String refreshToken;
}

 

3. application.yml

 

# jwt 설정
jwt:
  header: Authorization # JWT를 검증하는데 필요한 정보
  secret: ${env.jwt.secret} #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다
  token-validity-in-seconds: 10000 # 토큰의 만료시간을 지정함 (단위는 초)
# env.properties

[#](<https://www.notion.so/Spring-b1a1461da9024196960bc800c54cceb4>) JWT
# 해당 값은 "Secret Key" 를 Base64 로 인코딩한 값
env.jwt.secret = c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK

 

4. TokenProvider

jwt 패키지를 생성하고, 토큰의 생성과 토큰의 유효성 검증등을 담당

 

- 전체 코드

더보기
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.InitializingBean;
import io.jsonwebtoken.io.Decoders;
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
// jwt 패키지를 생성하고, 토큰의 생성과 토큰의 유효성 검증등을 담당
public class TokenProvider implements InitializingBean {

    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;
    private static final String AUTHORITIES_KEY = "auth";

    // 생성자
    public TokenProvider(
            @Value("${jwt.secret}") String secret, //jwt secret
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds //토큰 유효기간
            ) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
    }

    // 빈이 생성이 되고 의존성 주입 받은 secret값을 Base64 Decode해서 key변수에 할당
    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes); // HMAC-SHA 로 암호화
    }

    // access 토큰 생성
    public String createToken(String userEmail, String userRole, long tokenValidTime){

        Date now = new Date(); //현재 시간
        long nowTime = now.getTime();
        Date validity = new Date(nowTime + tokenValidTime); // 토큰의 유효기간 설정

        return Jwts.builder() // 빌더 객체 생성
                .setHeaderParam("type", "jwt")
                .setSubject(userEmail) // 클레임중 subject 클레임 이름 생성
                .claim(AUTHORITIES_KEY, userRole)  // payload에 들어갈 정보 <key, value>
                .signWith(key, SignatureAlgorithm.HS512) // signature
                .setExpiration(validity) // 만료 시간 설정
                .compact();
    }

    // refreshToken 생성
    public String createRefreshToken(long refreshTokenValidTime){
        long now = (new Date()).getTime(); //현재 시간 가져오고
        Date validity = new Date(now + refreshTokenValidTime); // 토큰의 유효기간

        return Jwts.builder() // 빌더 객체 생성
                .setHeaderParam("type", "jwt")
                .signWith(key, SignatureAlgorithm.HS512) // signature
                .setExpiration(validity) // 만료 시간 설정
                .compact();
    }

    // authentication 객체 리턴
    public Authentication getAuthentication(String token){
        // token -> claim 생성 (Claims : JWT 의 속성정보, java 에서 Claims 는 Json map 형식의 인터페이스)
        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;
    }
}

 

  • 생성자
public TokenProvider(
            @Value("${jwt.secret}") String secret, //jwt secret
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds //토큰 유효기간
            ) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
    }

 

  • afterPropertiesSet()
// 빈이 생성이 되고 의존성 주입 받은 secret값을 Base64 Decode해서 key변수에 할당
    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes); // HMAC-SHA 로 암호화
    }

 

  • createTocken()
// 토큰 생성
    public String createToken(String userEmail, String userRole, long tokenValidtime){

        Date now = new Date(); //현재 시간
        long nowTime = now.getTime();
        Date validity = new Date(nowTime + tokenValidtime); // 토큰의 유효기간 설정

        return Jwts.builder() // 빌더 객체 생성
                .setHeaderParam("type", "jwt")
                .setSubject(userEmail) // 클레임중 subject 클레임 이름 생성
                .claim(AUTHORITIES_KEY, userRole)  // payload에 들어갈 정보 <key, value>
                .signWith(key, SignatureAlgorithm.HS512) // signature
                .setExpiration(validity) // 만료 시간 설정
                .compact();
    }

 

  • createRefreshToken
// refreshToken 생성
    public String createRefreshToken(long refreshTokenValidTime){
        long now = (new Date()).getTime(); //현재 시간 가져오고
        Date validity = new Date(now + refreshTokenValidTime); // 토큰의 유효기간

        return Jwts.builder() // 빌더 객체 생성
                .setHeaderParam("type", "jwt")
                .signWith(key, SignatureAlgorithm.HS512) // signature
                .setExpiration(validity) // 만료 시간 설정
                .compact();
    }

 

  • getAuthentication()

Authentication 객체 : Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체, 사용자가 인증 과정을 성공적으로 마치면, Spring Security는 사용자의 정보 및 인증 성공여부를 가지고 Authentication 객체를 생성한 후 보관

// authentication 객체 리턴
    public Authentication getAuthentication(String token){
        // token -> claim 생성 (Claims : JWT 의 속성정보, java 에서 Claims 는 Json map 형식의 인터페이스)
        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);
    }

 

  • 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;
    }

 

5. JwtFilter class

JWT를 위한 커스텀 필터를 만들기 위한 클래스

- 전체 코드

더보기
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.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;

@Component
// JWT를 위한 커스텀 필터를 만들기 위한 클래스
public class JwtFilter extends GenericFilterBean {

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

    // 생성자
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    // doFilter : JWT 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역활 (실제 필터링 로직은 doFilter 내부에 작성)
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();
        String jwt = resolveToken(httpServletRequest); // 토큰 정보 획득

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 토큰 유효성 검사
            Authentication authentication = tokenProvider.getAuthentication(jwt); // 토큰의 인증 정보 가져오기
            SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰 인증 정보를 security context에 저장
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {} " + requestURI);
        }
        chain.doFilter(request, response);
    }

    // 토큰 정보를 가져오는 메소드
    private String resolveToken(HttpServletRequest request){
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        // 인증타입 : Bearer
        if (StringUtils.hasText(bearerToken)
                && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

}

 

  • 생성자
// 생성자
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

 

  • resolveToken()
// 토큰 정보를 가져오는 메소드
private String resolveToken(HttpServletRequest request){
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    // 인증타입 : Bearer
    if (StringUtils.hasText(bearerToken)
            && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}

 

  • doFilter()
// doFilter : JWT 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역활 (실제 필터링 로직은 doFilter 내부에 작성)
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();
        String jwt = resolveToken(httpServletRequest); // 토큰 정보 획득

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 토큰 유효성 검사
            Authentication authentication = tokenProvider.getAuthentication(jwt); // 토큰의 인증 정보 가져오기
            SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰 인증 정보를 security context에 저장
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {} " + requestURI);
        }
        chain.doFilter(request, response);
    }

 

6. JwtSecurityConfig

TokenProvider, JwtFilter 를 SecurityConfig에 적용할때 사용할 클래스

- 전체 코드

더보기
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;

// TokenProvider, JwtFilter 를 SecurityConfig에 적용할때 사용할 클래스
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private TokenProvider tokenProvider;

    // 생성자
    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    // JwtFilter를 Security 로직에 필터로 등록
    @Override
    public void configure(HttpSecurity http) throws Exception {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

 

  • configure()
// JwtFilter를 Security 로직에 필터로 등록
    @Override
    public void configure(HttpSecurity http) throws Exception {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }

 

7. JwtAuthenticationEntryPoint

유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴할 클래스

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴할 클래스
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

 

8. JwtAccessDeniedHandler

필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴하기 위한 클래스

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
// 필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴 클래스
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

 

9. SecurityConfig

작성한 정보 필터 추가

- 전체 코드

더보기
import com.example.yutnoribackend.jwt.JwtAccessDeniedHandler;
import com.example.yutnoribackend.jwt.JwtAuthenticationEntryPoint;
import com.example.yutnoribackend.jwt.JwtSecurityConfig;
import com.example.yutnoribackend.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터 체인에 등록됨
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    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;
    }

    // 인증, 인가 서비스가 필요하지 않은 endpoin 적용
    @Bean
    public WebSecurityCustomizer configure(){
        return (web) -> web.ignoring()
                .antMatchers(
                        "/v3/api-docs/**",
                        "/swagger-ui/**"
                );
    }

    // CORS 설정
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(Arrays.asList("*")); // 모든 패턴에 대해 허용
        configuration.setAllowedMethods(Arrays.asList("HEAD","POST","GET","DELETE","PUT"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf().disable() // csrf 비활성화

                // JWT 관련 에러 발생시 처리 (401, 403)
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
                .accessDeniedHandler(jwtAccessDeniedHandler) // 403

                .and()
                .headers().frameOptions().sameOrigin() // 동일 도메인에서는 X-Frame-Option 활성화

                .and()
                .cors(Customizer.withDefaults()) // cors 설정

                .authorizeRequests() // 요청에 의한 보안 검사 시작
                .antMatchers("/*").permitAll() //antMatchers 에 설정한 리소스의 접근을 인증 절차 없이 허용

                .anyRequest().authenticated() // 위에서 설정하지 않은 나머지 부분들은 인증 절차 수행

                // JwtSecurityConfig 실행
                .and()
                .apply(new JwtSecurityConfig(tokenProvider))

                .and()
                .build();
    }
}

 

  • filterChain()
@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf().disable() // csrf 비활성화

                // JWT 관련 에러 발생시 처리 (401, 403)
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
                .accessDeniedHandler(jwtAccessDeniedHandler) // 403

                .and()
                .headers().frameOptions().sameOrigin() // 동일 도메인에서는 X-Frame-Option 활성화

                .and()
                .cors(Customizer.withDefaults()) // cors 설정

                .authorizeRequests() // 요청에 의한 보안 검사 시작
                .antMatchers("/*").permitAll() //antMatchers 에 설정한 리소스의 접근을 인증 절차 없이 허용

                .anyRequest().authenticated() // 위에서 설정하지 않은 나머지 부분들은 인증 절차 수행

                // JwtSecurityConfig 실행
                .and()
                .apply(new JwtSecurityConfig(tokenProvider))

                .and()
                .build();
    }

 


참고자료

https://yonghyunlee.gitlab.io/node/jwt/

https://gksdudrb922.tistory.com/217#��%--�%--%-C

https://hoon9901.github.io/springboot-jwt-tutorial3/

'Spring' 카테고리의 다른 글

SpringSecurity_CORS 설정  (0) 2023.04.10
Spring Security Swagger 예외처리  (0) 2023.04.03
Spring Swagger  (0) 2023.04.03
Spring Security  (0) 2023.04.03
JPA Mapping  (0) 2023.03.31