Spring Boot JWT 적용

koonlx·2024년 8월 17일

stay_connect

목록 보기
4/10

1. JWT를 사용한 이유

    1. Self-contained(자체적으로 포함된 정보)
      JWT는 토큰 안에 필요한 사용자 정보나 권한 정보(Claims)를 자체적으로 포함시킬 수 있다. 그래서 서버는 이 토큰을 별도로 조회하지 않고도 사용자에 대한 정보나 권한을 확인할 수 있다. 이로 인해 서버의 부하가 줄어들고, 토큰 검증 속도가 빨라진다.
    1. Stateless(무상태)
      JWT는 서버에 상태를 저장하지 않는 무상태(stateless) 방식의 인증을 지원한다. 서버는 JWT를 발급하고 클라이언트가 이 토큰을 보관한다. 이후 클라이언트가 요청 시마다 이 토큰을 서버에 전달하면, 서버는 토큰만으로 사용자를 인증할 수 있다. 이를 통해 확장성과 성능이 향상된다.
    1. 토큰 기반 인증의 표준
      JWT는 JSON 형식으로 데이터를 표현하고, 널리 사용되는 표준에 따라 인코딩된다. 이로 인해 다양한 언어와 플랫폼에서 쉽게 구현할 수 있으며, 여러 시스템 간에 호환성을 보장할 수 있다.
    1. 안전성(Security)
      JWT는 서명되어 발행된다. HMAC 또는 RSA와 같은 알고리즘을 사용해 서명하기 때문에, 서버는 이 서명을 검증하여 토큰이 변조되지 않았음을 확인할 수 있다. 또한, JWT는 기본적으로 비공개 키로 서명할 수 있어, 서버만이 토큰을 생성하고 검증할 수 있다.
      옵션으로 암호화(Encryption)를 통해 토큰 내부의 정보도 보호할 수 있다.
    1. 유연성
      JWT는 다양한 유형의 정보를 토큰에 담을 수 있다. 예를 들어, 사용자 ID, 만료 시간, 권한 정보 등 필요한 정보를 자유롭게 추가할 수 있다. 이를 통해 인증과 권한 부여 외에도 다양한 목적을 위해 활용할 수 있다.
    1. 분산 시스템 및 마이크로서비스에 적합
      JWT는 토큰 자체에 정보를 포함하고 있기 때문에, 마이크로서비스 아키텍처와 같이 여러 서비스 간의 인증 정보 공유가 필요한 환경에서 매우 유용하다. 각 서비스는 JWT를 독립적으로 검증할 수 있어, 중앙 집중식 세션 관리의 필요성을 제거한다.
    1. 토큰 만료 및 갱신
      JWT는 만료 시간(expiration time)을 설정할 수 있어, 일정 시간이 지나면 토큰이 자동으로 무효화된다. 또한, 리프레시 토큰(refresh token)을 활용하여 토큰 갱신도 가능하다.

2. JWT 단점

    1. 토큰 무효화 어려움
      JWT는 무상태 인증 방식이기 때문에 서버에서 특정 JWT를 무효화하는 것이 어렵다. 토큰이 발급된 후에는 만료 시간 이전에 이를 회수하거나 무효화하기 어렵다.
    1. 보안 위험
      JWT가 노출되면 악의적인 사용자가 이를 사용해 인증된 것처럼 행동할 수 있다. 이를 방지하기 위해 토큰 저장과 전송에 대한 보안이 매우 중요하다.
    1. 토큰 크기
      JWT는 세션 ID보다 크기가 클 수 있으며, 이는 특히 모바일 환경에서 네트워크 대역폭에 영향을 미칠 수 있다.
    1. 서명 검증 비용
      서버는 매 요청마다 JWT의 서명을 검증해야 하므로, CPU 사용량이 증가할 수 있다.

3. Session-based Authentication VS Json Web Token

인증을 구현할 때 가장 많이 비교하는 방식이 Session과 JWT이다. 아무래도 가장 대중적인 방식들이기 때문에 비교를 많이 하는 것 같다.
나는 AWS Free-tier를 사용할 것이기 때문에 기준치 이상을 사용하면 과금이 될 뿐만 아니라 할당받은 인스턴스가 과부하가 올 경우 실행이 안될 수도 있다.
Session기반 인증은 서버가 상태를 관리해야 하고, Redis 또는 RDBMS에서 상태를 관리하기 때문에 서버에 부하가 생길 가능성이 크다.
반면 JWT는 무상태로 서버 간 상태 동기화도 필요 없고 오토 스케일링, 로드 밸런싱에 유리하기 때문에 네트워크 비용, 서명 검증 비용이 비교적 크지만 Free-tier로 핸들링하기엔 문제가 없다고 생각해서 JWT를 사용하기로 했다.

4. 종속성

implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

JJWT Documentation(얘는 그렇게 많이 안 봄), JJWT Github 참고하여 최신버전 문법에 맞게 구현해보았다.
github repository를 받아서 코드를 살펴보는게 간편해서 둘러보았다. 버전이 안맞아서 코드 오류는 있지만 주석이랑 코드만 읽어봐도 꽤 도움되기 때문에 github를 보는게 훨씬 도움되었다.
구버전에서 사용하던 사람들의 코드를 보면서 참고해야할 Class의 범위를 줄일 수 있었기 때문에 그나마 시간 단축을 할 수 있었다. 바뀐 메소드만 확인하면 되는 수준이었다.

4. JJWT 코드 구현

1. JWT Provider

@Slf4j
@Component
@Getter
public class JWTokenProvider {

  private final SecretKey secretKey;

  @Value("${JSON_WEB_TOKEN_EXPIRATION_TIME}")
  private int expiration; // 30min 1800000

  public JWTokenProvider(@Value("${JSON_WEB_TOKEN_SECRET_KEY}") String JWTokenStringKey) {
    this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(JWTokenStringKey));
  }

  public String generateToken(Authentication authentication) {
    return createToken(authentication);
  }

  public boolean validateToken(String token) {
    try {
      Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
      return true;
    } catch (MalformedJwtException e) {
      log.error("Invalid JWT token: {}", e.getMessage());
    } catch (ExpiredJwtException e) {
      log.error("JWT token is expired: {}", e.getMessage());
    } catch (UnsupportedJwtException e) {
      log.error("JWT token is unsupported: {}", e.getMessage());
    } catch (IllegalArgumentException e) {
      log.error("JWT claims string is empty: {}", e.getMessage());
    }
    return false;
  }

  private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
    final Claims claims = extractAllClaims(token);
    return claimsResolver.apply(claims);
  }

  public String extractUsername(String token) {
    return extractClaim(token, Claims::getSubject);
  }

  public Date extractExpiration(String token) {
    return extractClaim(token, Claims::getExpiration);
  }

  public String extractRole(String token) {
    return extractClaim(token, claims -> claims.get("role", String.class));
  }

  public Claims extractAllClaims(String token) {
    return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
  }

  private String createToken(Authentication authentication) {

    User user = (User) authentication.getPrincipal();

    return Jwts.builder()
        .subject(user.getUsername())
        .claim("role", user.getRole().getName())
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + expiration))
        .signWith(secretKey)
        .compact();
  }
}

해당 파일에서 토큰 생성, Claim 추출, 등 토큰 관련 작업을 처리하는 로직을 정의한다.
국비 강사님은 authentication의 detail, credential을 포함하라고 하셨는데 filter에서 유의미한 검증과정이 필요한게 아닌 이상 차라리 제외시키는게 낫다. 사용자의 중요한 정보들이기 때문에 Token형태로 유출시키는 것은 위험하다. 만약 필요하다면 JWE(Json Web Encrytion)으로 암호화해서 Token을 생성하는걸 추천한다.

2. JWT Handler

@Component
public class JWTLoginSuccessHandler implements AuthenticationSuccessHandler {

  @Autowired
  private JWTokenProvider jwTokenProvider;

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException, ServletException {
    String token = jwTokenProvider.generateToken(authentication);
    Cookie cookie = new Cookie("jwt", token);
    cookie.setHttpOnly(true);
    cookie.setSecure(false);
    cookie.setPath("/");
    cookie.setMaxAge(jwTokenProvider.getExpiration() / 1000);

    response.addCookie(cookie);
    response.sendRedirect("/");
  }
}

로그인에 성공하면 클라이언트에 쿠키를 할당해주고 "/"경로로 리다이렉팅.

@Component
public class JWTLogoutSuccessHandler implements LogoutSuccessHandler {

  @Override
  public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
      throws IOException, ServletException {
    Cookie cookie = new Cookie("jwt", null);
    cookie.setHttpOnly(true);
    cookie.setSecure(false); // 지금은 개발중이기 때문에 HTTP 환경에서도 작동하도록 설정
    cookie.setPath("/"); // 모든 경로에서 쿠키 접근 가능
    cookie.setMaxAge(0); // 쿠키 제거

    response.addCookie(cookie);
    response.setStatus(HttpServletResponse.SC_OK);
    response.sendRedirect("/auth/signin?logout"); // 로그아웃 후 리다이렉트
  }
}

로그아웃 시 쿠키를 삭제하여 "/auth/signin?logout"경로로 리다이렉팅

3. JWT Filter

public class JWTAuthenticationFilter extends OncePerRequestFilter {

  @Autowired
  private JWTokenProvider jwTokenProvider;

  @Override
  protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
      @NonNull FilterChain filterChain)
      throws ServletException, IOException {
    String token = getTokenFromRequest(request);

    if (token != null && jwTokenProvider.validateToken(token) && !isTokenExpired(token)) {
      String username = jwTokenProvider.extractUsername(token);
      String role = jwTokenProvider.extractRole(token);
      List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(role));

      UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null,
          authorities);
      SecurityContextHolder.getContext().setAuthentication(authentication);
    } else if (token != null) {
      SecurityContextHolder.clearContext();
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      response.getWriter().write("Invalid or expired token. Please log in again.");
    }

    filterChain.doFilter(request, response);
  }

  private String getTokenFromRequest(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
      return Arrays.stream(cookies)
          .filter(cookie -> "jwt".equals(cookie.getName()))
          .map(Cookie::getValue)
          .findAny()
          .orElse(null);
    }
    return null;
  }

  private boolean isTokenExpired(String token) {
    Long expiration = jwTokenProvider.extractExpiration(token);
    return expiration != null && expiration < System.currentTimeMillis();
  }
}

토큰이 유효한지 확인하고 그렇지 않다면 로그아웃시킨다.

5. security filter에 JWT 적용

JWT 관련 설정은 다 했으니 이제 통신할 때마다 또는 필요한 경우에 코드가 동작하도록 SecurityFilterChain에 포함시켜줄거다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JWTAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private JWTLoginSuccessHandler jwtLoginSuccessHandler;

    @Autowired
    private JWTLogoutSuccessHandler jwtLogoutSuccessHandler;

    @Autowired
    private OAuth2UserLoginFailureHandler oAuth2UserLoginFailureHandler;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
			.csrf((csrf) -> csrf.disable())
			.authorizeHttpRequests((authorizeRequests) -> authorizeRequests
				.requestMatchers("/", "/accommodation/**", "/search").permitAll()
				.requestMatchers("/auth/**").not().authenticated()
				.requestMatchers("/css/**", "/js/**", "/img/**", "/lib/**", "/fonts/**").permitAll()
				.requestMatchers("/user/**","/chat/**").hasAnyRole("USER", "ADMIN")
				.requestMatchers("/admin/**").hasRole("ADMIN")
				.anyRequest().authenticated())

			.formLogin((formLogin) -> formLogin
				.loginPage("/auth/signin")
				.usernameParameter("username")
				.passwordParameter("password")
				.defaultSuccessUrl("/", true)
				.successHandler(jwtLoginSuccessHandler)) // 일반 로그인 핸들러 설정

			.logout(logout -> logout
				.logoutUrl("/user/logout")
				.logoutSuccessUrl("/")
				.logoutSuccessHandler(jwtLogoutSuccessHandler)) // 로그아웃 핸들러 설정

			.sessionManagement(session -> session // JWT 사용할거라서 기존 session 비활성화
				.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터를 기존 인증필터 앞에서 실행되도록 위치

		return http.build();
    }
}

이렇게 하면 JJWT를 Spring Security와 함께 적용하여 사용가능하다.

profile
Server Developer

0개의 댓글