SpringBoot

Spring Security + JWT 토큰을 사용한 로그인

똑똑한망치 2024. 4. 2. 20:46
728x90
반응형

JWT

JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.

 

📝Security + JWT 기본 동작 원리

Security + JWT 기본 동작 원리

  • 클라이언트에서 ID/PW를 통해 로그인 요청
  • 서버에서 DB에 해당 ID/PW를 가진 User이 있다면, Access TokenRefresh Token을 발급
  • 클라이언트는 발급받은 Access Token을 헤더에 담아서 서버가 허용한 API를 사용할 수 있다.

여기서 Refresh Token은 새로운 Access Token을 발급하기 위한 토큰이다. 기본적으로 Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정한다. 정상적인 클라이언트는 유효기간이 끝난 Access Token에 대해 Refresh Token을 사용하여 새로운 Access Token을 발급받을 수 있다.

 

📝새로운 Access Token + Refresh Token 재발급 원리

Access Token + Refresh Token 재발급 과정

 

build.gradle

dependencies {
 
    ...
    
    //security
	implementation 'org.springframework.boot:spring-boot-starter-security'
 
	// 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'
}

 

 

Token Entity

클라이언트에 토큰을 보내기 위한 엔티티 생성

@Builder
@Data
@AllArgsConstructor
public class Token {
 
    private String grantType;
    private String accessToken;
    private String refreshToken;
}

grantType은 JWT에 대한 인증 타입으로, 이 글에서는 Bearer를 사용한다. 이후 HTTP 헤더에 prefix로 붙여주는 타입이기도 하다.

 

 

 

JwtTokenProvider

JWT 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능 구현이 될 클래스이다.

우선 application.properties에 다음 설정을 추가한다.

# Jwt
jwt.secretKey=bd29b643f4f2298590fe7c57242238618c44afbcfe61cdb57186f81673b3f6af

 

 

JwtTokenProvider.java

@Component
@Slf4j
public class JwtTokenProvider {

  private static final String AUTHORITIES_KEY = "auth";
  private static final String BEARER_TYPE = "Bearer";

  //유효시간 = 1시간
  private static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 60 * 1000L;

  // 유효시간 = 7일
  private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;
  private final Key key;

  public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
    byte[] keyBytes = Decoders.BASE64.decode(secretKey);
    this.key = Keys.hmacShaKeyFor(keyBytes);
  }

  /**
   * 유저 정보로 생성된 Authenticaiton으로 AccessToken, RefreshToken 생성
   *
   * @param authentication
   * @return
   */
  public JwtToken createToken(Authentication authentication) {

    // 권한 가져오기
    String authorities = authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));

    long now = (new Date()).getTime();

    // AccessToken 생성
    Date accessTokenExpiratedTime = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

    String accessToken = Jwts.builder()
        .setSubject(authentication.getName())
        .claim(AUTHORITIES_KEY, authorities)
        .setExpiration(accessTokenExpiratedTime)
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();


    // refreshToken 생성
    String refreshToken = Jwts.builder()
        .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();

    return JwtToken.builder()
        .grantType(BEARER_TYPE)
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
  }


  // Jwt 토큰을 복호화하여 토큰에 들어있는 인증 정보를 꺼내는 메서드
  public Authentication getAuthentication(String accessToken) {
    // Jwt 토큰 복호화
    Claims claims = parseClaims(accessToken);

    if (claims.get(AUTHORITIES_KEY) == null) {
      throw new RuntimeException("권한 정보가 없는 토큰입니다.");
    }

    // 해당 사용자의 권한 정보 가져오기
    Collection<? extends GrantedAuthority> authorities =
        Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

    // UserDetails 객체 생성해서 Authentication 리턴
    UserDetails principal = new User(claims.getSubject(), "", authorities);
    return new UsernamePasswordAuthenticationToken(principal, "", authorities);
  }


  /**
   * 토큰 정보 검증
   * @param token
   * @return
   */
  public boolean validateToken(String token) {
    try {
      Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
      return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
      log.info("Invalid JWT Token", e);
    } catch (ExpiredJwtException e) {
      log.info("Expired JWT Token", e);
    } catch (UnsupportedJwtException e) {
      log.info("Unsupported JWT Token", e);
    } catch (IllegalArgumentException e) {
      log.info("JWT claims string is empty.", e);
    }
    return false;
  }

  private Claims parseClaims(String accessToken) {
    try {
      return Jwts.parserBuilder()
          .setSigningKey(key)
          .build()
          .parseClaimsJws(accessToken)
          .getBody();
    } catch (ExpiredJwtException e) {
      return e.getClaims();
    }
  }
}

 

 

 

JwtAuthenticationFilter

클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터이다.

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends GenericFilterBean  {

  private final JwtTokenProvider jwtTokenProvider;

  public static final String TOKEN_HEADER = "Authorization";
  public static final String TOKEN_PREFIX = "Bearer";

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

    // 1. Request Header 에서 JWT 토큰 추출
    String token = resolveToken((HttpServletRequest) request);

    // 2. validateToken 으로 토큰 유효성 검사
    if (token != null && jwtTokenProvider.validateToken(token)) {
      Authentication authentication = jwtTokenProvider.getAuthentication(token);
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    chain.doFilter(request, response);
  }


  // RequestHeader 에서 토큰 정보 추출
  private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(TOKEN_HEADER);

    if(!ObjectUtils.isEmpty(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
      return bearerToken.substring(TOKEN_PREFIX.length());
    }
    return null;
  }
}

 

 

 

SecurityConfig

@Configuration
@EnableMethodSecurity
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  private final JwtTokenProvider jwtTokenProvider;

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

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // csrf 방지
        .csrf(
            AbstractHttpConfigurer::disable
        )

        // Jwt를 사용하기 때문에 session은 사용X
        .sessionManagement( (session) -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )

        .httpBasic(AbstractHttpConfigurer::disable)

        //홈,로그인,회원가입 페이지는 로그인 없이 접근 가능
        .authorizeHttpRequests(
            (request) -> request
                .requestMatchers("/","/user/**").permitAll()
                .anyRequest().authenticated()
        )


        .formLogin(
//            (form) -> form
//                .loginPage("/user/login")
//                .defaultSuccessUrl("/", true)
//                .permitAll()
            AbstractHttpConfigurer::disable
        )

        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }

}

 

 

 

CustomUserDetails

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

  private final User user;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    Collection<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority(user.getRole().toString()));
    return authorities;
  }

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getLoginId();
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

 

 

 

CustomUserDetailsService

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
    log.info(" --- 회원 정보 찾기, {} --- ", loginId);
    return userRepository.findByLoginId(loginId)
        .map(this::createUserDetails)
        .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
  }

  private UserDetails createUserDetails(User user) {
    return new CustomUserDetails(user);
  }
}
반응형