본문 바로가기

Backend/Spring | SpringBoot

[Spring Security] JWT토큰 사용 코드

반응형

이어지는 이전 글 : [Spring Security] JWT토큰 적용하기

 

[Spring Security] JWT토큰 적용하기

프로젝트 환경 spring boot : 3.2.2 spring security : 6.2.1 java : 17 database : h2 JWT토큰을 적용해 보려 한다. JWT토큰이란? JSON Web Token (JWT)는 마이크로 서비스의 인증, 인가에 사용할 수 있는 서명된 JSON이다.

nwblog06.tistory.com

 

JWT토큰관련 class

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@RequiredArgsConstructor
@Slf4j
@Component
public class JwtTokenizer {
  public static final String AUTHORIZATION_HEADER = "Authorization";
  public static final String REFRESH_HEADER = "Refresh";
  public static final String BEARER_PREFIX = "Bearer";

  // secretKey가져오기
  @Getter
  // @Value import주의
  @Value("${jwt.secret}")
  private String secretKey;

  // accessToken만료시간
  @Getter
  @Value("${jwt.access-token-expiration-millis}")
  private long accessTokenExpirationMillis;

  // refreshToken만료시간
  @Getter
  @Value("${jwt.refresh-token-expiration-millis}")
  private long refreshTokenExpirationMillis;

  private Key key;

  // bean등록 후 Key SecretKey HS알고리즘 decode
  // @PostConstruct는 의존성 주입이 이루어진 후 초기화를 수행하는 메서드이다.
  // @PostConstruct가 붙은 메서드는 @Component, @Service, @Repository, @Controller 클래스가 Spring Bean에 등록되기 전에 발생한다.
  // 이 메서드는 다른 리소스에서 호출되지 않는다해도 수행된다.
  // bean이 초기화 됨과 동시에 의존성 확인 가능 = @Autowired나 @Value를 붙여 객체 사용시 사용 가능
  // 실행 bean lifecycle 동안 오직 한번만 수행되는 것을 보장 = bean이 여러번 초기화 되는 것을 방지
  @PostConstruct
  public void init() {
    String base64EncodedSecretKey = encodeBase64URLSecretKey(this.secretKey);
    this.key = getKeyFromBase64URLEncodedKey(base64EncodedSecretKey);
  }

  // 인코딩
  public String encodeBase64URLSecretKey(String secretKey) {
    return Encoders.BASE64URL.encode(secretKey.getBytes(StandardCharsets.UTF_8));
  }

  // 디코딩
  private Key getKeyFromBase64URLEncodedKey(String base64EncodedSecretKey) {
    byte[] keyBytes = Decoders.BASE64URL.decode(base64EncodedSecretKey);
    return Keys.hmacShaKeyFor(keyBytes);
  }

  public String generateAccessToken(Map<String, Object> claims, String subject,
      Date expiration, String encodeBase64URLSecretKey) {
    Key key = getKeyFromBase64URLEncodedKey(encodeBase64URLSecretKey);

    return Jwts.builder()
        // Custom Claims 추가 ( 인증된 사용자와 관련 정보 )
        .setClaims(claims)
        // JWT 제목 (사용자 구분용 도로 사용예정)
        .setSubject(subject)
        // accessToken의 발행 시간
        .setIssuedAt(Calendar.getInstance().getTime())
        // accessToken의 만료 시간
        .setExpiration(expiration)
        // 서명
        .signWith(key)
        // JWT생성 및 직렬화
        .compact();
  }

  // AccessToken을 갱신하기에 Custom Claims가 필요없음
  public String generateRefreshToken(String subject, Date expiration,
      String encodeBase64URLSecretKey) {
    Key key = getKeyFromBase64URLEncodedKey(encodeBase64URLSecretKey);

    return Jwts.builder()
        // JWT 제목 (사용자 구분용 도로 사용예정)
        .setSubject(subject)
        // refreshToken의 발행 시간
        .setIssuedAt(Calendar.getInstance().getTime())
        // refreshToken의 만료 시간
        .setExpiration(expiration)
        // 서명
        .signWith(key)
        // JWT생성 및 직렬화
        .compact();
  }

  public void setAccessTokenHeader(String accessToken, HttpServletResponse response) {
    String headerValue = BEARER_PREFIX + accessToken;
    response.setHeader(AUTHORIZATION_HEADER, headerValue);
  }

  public void setRefreshTokenHeader(String refreshToken, HttpServletResponse response) {
    response.setHeader(REFRESH_HEADER, refreshToken);
  }

  // token 복호화
  public Claims parseClaim(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(key)
        .build()
        .parseClaimsJws(token)
        .getBody();
  }

  // Request Header에서 Access Token 정보 추출
  public String resolveAccessToken(HttpServletRequest request) {
    // Header값 추출
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    // Header값 비교
    if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
      // Header 텍스트 제외하고 토큰 값 추출
      return bearerToken.substring(7);
    }
    return null;
  }

  // Request Header에서 Refresh Token 정보 추출
  public String resolveRefreshToken(HttpServletRequest request) {
    // Header값 추출
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    // Header값 비교
    if(StringUtils.hasText(bearerToken)) {
      // 토큰 값 추출
      return bearerToken;
    }
    return null;
  }

  // 토큰 검증
  public boolean validateToken(String token) {
    try {
      // parsing하여 정보 추출
      parseClaim(token);
    } catch (MalformedJwtException e) {
      log.info("Invalid JWT token");
      log.trace("Invalid JWT token trace = {}", e);
    } catch (ExpiredJwtException e) {
      log.info("Expired JWT token");
      log.trace("Expired JWT token trace = {}", e);
    } catch (UnsupportedJwtException e) {
      log.info("Unsupported JWT token");
      log.trace("Unsupported JWT token trace = {}", e);
    } catch (IllegalArgumentException e) {
      log.info("JWT claims string is empty.");
      log.trace("JWT claims string is empty trace = {}", e);
    }
    return true;
  }
}

 

JWT인증 필터

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@Slf4j
@RequiredArgsConstructor
// JWT 토큰 인증
public class JwtAuthenticationFilter extends OncePerRequestFilter {
  private final CustomUserDetailService customUserDetailService;
  private final JwtTokenizer jwtTokenizer;

  @Override
  protected void doFilterInternal(@NonNull HttpServletRequest request,
      @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException{
    // access토큰 정보 추출
    String token = jwtTokenizer.resolveAccessToken(request);

    // 토큰 검증
    if(token != null && jwtTokenizer.validateToken(token)){
      // 토큰에서 유저정보 추출
      String email = jwtTokenizer.parseClaim(token).getSubject();

      // 사용자 인증
      UserDetails userDetails = customUserDetailService.loadUserByUsername(email);
      if (userDetails != null) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());
        log.info("authenticated user with email : {}", email);
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    }
    filterChain.doFilter(request, response);
  }
}

 

CustomLoginSuccessHandler수정

JWT는 인증 용도로 사용하기에 최초 로그인 후 발급해도 무관하다. 그래서 로그인 성공 핸들러에서 헤더에 넣도록 해두었다.

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

@Component(value = "customAuthenticationSuccessHandler")
@Slf4j
@RequiredArgsConstructor
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {

  private final Log logger = LogFactory.getLog(this.getClass());

  private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

  // 성공시 토큰 발행
  private JwtTokenizer jwtTokenizer;
  public CustomLoginSuccessHandler(JwtTokenizer jwtTokenizer){
    this.jwtTokenizer = jwtTokenizer;
  }

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {

    // JWT 토큰 생성
    String username = authentication.getName();
    String accessToken = jwtTokenizer.generateAccessToken(Map.of("username", username), username,
        new Date(System.currentTimeMillis() + jwtTokenizer.getAccessTokenExpirationMillis()), jwtTokenizer.getSecretKey());
    String refreshToken = jwtTokenizer.generateRefreshToken(username,
        new Date(System.currentTimeMillis() + jwtTokenizer.getAccessTokenExpirationMillis()), jwtTokenizer.getSecretKey());
    // JWT 토큰 발행
    jwtTokenizer.setAccessTokenHeader(accessToken, response);
    jwtTokenizer.setRefreshTokenHeader(refreshToken, response);

    if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
      response.setContentType("application/json;charset=UTF-8");
      response.setStatus(HttpServletResponse.SC_OK);

      Map<String, Object> responseData = new HashMap<>();
      String targetUrl = determineTargetUrl(authentication); // 사용자 역할에 따라 URL 결정
      responseData.put("redirectUrl", targetUrl); // 리디렉션할 URL을 JSON 응답에 포함

      new ObjectMapper().writeValue(response.getWriter(), responseData);
    } else {
      handle(request, response, authentication);
    }
    clearAuthenticationAttributes(request);
  }

  private void handle(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    String targetUrl = determineTargetUrl(authentication);
    logger.debug(authentication);

    if (response.isCommitted()) {
      logger.debug("Response has already been committed. Unable to redirect to "
          + targetUrl);
      return;
    }

    redirectStrategy.sendRedirect(request, response, targetUrl);
  }

  private String determineTargetUrl(final Authentication authentication) {

    // roleTargetUrlMap -> targetUrlParameterValue
    Map<String, String> roleTargetUrlMap = new HashMap<>();
    roleTargetUrlMap.put("ROLE_User", "/main");
    roleTargetUrlMap.put("ROLE_Admin", "/admin/main");

    final Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
    for (final GrantedAuthority grantedAuthority : authorities) {
      String authorityName = grantedAuthority.getAuthority();
      if (roleTargetUrlMap.containsKey(authorityName)) {
        return roleTargetUrlMap.get(authorityName);
      }
    }

    throw new IllegalStateException();
  }

  private void clearAuthenticationAttributes(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session == null) {
      return;
    }
    session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
  }
}

 

SpringSecurityConfig수정

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.DelegatingSecurityContextRepository;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SpringSecurityConfig extends AbstractHttpConfigurer {

  private final AuthenticationConfiguration authenticationConfiguration;
  private final JwtTokenizer jwtTokenizer;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
    http.authenticationManager(authenticationManager);

    http
        .authorizeHttpRequests(authorizeRequest ->
                authorizeRequest
                    .requestMatchers(new AntPathRequestMatcher("/main/**")).hasRole("User")
//                .requestMatchers(new AntPathRequestMatcher("/main/**")).permitAll()
                    .requestMatchers(new AntPathRequestMatcher("/admin/main/**")).hasRole("Admin")
                    .requestMatchers(new AntPathRequestMatcher("/login/**")).permitAll()
                    .requestMatchers(new AntPathRequestMatcher("/loginFail/**")).permitAll()
                    .requestMatchers(new AntPathRequestMatcher("/h2/**")).permitAll()
                    .anyRequest().permitAll()
        )
        // 필터 변경
        .addFilterAt(
            this.abstractAuthenticationProcessingFilter(authenticationManager, jwtTokenizer),
            UsernamePasswordAuthenticationFilter.class)
        .headers(
            headersConfigurer ->
                headersConfigurer
                    // SameOrigin Policy
                    .frameOptions(
                        FrameOptionsConfig::sameOrigin
                    )
                    // CSP
                    .contentSecurityPolicy(policyConfig ->
                        policyConfig.policyDirectives(
                            "script-src 'self'; img-src 'self'; font-src 'self' data:; default-src 'self'; frame-src 'self'"
                        ).reportOnly()
                    )
        )
    return http.build();
  }

  // 인증 필터
  public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter(
//      final AuthenticationManager authenticationManager) {
      // 토큰 정보 추가
      final AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
    LoginAuthenticationFilter loginAuthenticationFilter = new LoginAuthenticationFilter("/ajax/loginProcess", authenticationManager);
//    loginAuthenticationFilter.setAuthenticationSuccessHandler(customSuccessHandler());
    // Handler에 토큰 정보 추가
    loginAuthenticationFilter.setAuthenticationSuccessHandler(customSuccessHandler(jwtTokenizer));
    // Rest API 방식을 사용하기 위해 추가
    loginAuthenticationFilter.setSecurityContextRepository(
        new DelegatingSecurityContextRepository(
            new RequestAttributeSecurityContextRepository(),
            new HttpSessionSecurityContextRepository()
        ));
    return loginAuthenticationFilter;
  }

  // 로그인 성공시 handler
  @Bean
  public AuthenticationSuccessHandler customSuccessHandler(JwtTokenizer jwtTokenizer) {
    return new CustomLoginSuccessHandler(jwtTokenizer);
  }
}
반응형