BackEnd/Spring

[Spring] Spring Security + JWT 구현하기

dev seon 2024. 12. 21. 23:02

 


RESTful API와 같이 상태를 유지하지 않는 아키텍처에서 클라이언트와 서버 간의 인증 상태를 효율적으로 관리하는 것이 매우 중요합니다.

이때 널리 사용되는 인증 방식 중 하나가 JWT입니다.

JWT는 가볍고 자체적으로 필요한 정보를 포함하고 있어, 세션 관리가 필요 없는 환경에서 유리합니다.

이를 통해 인증 상태를 클라이언트가 보관하고, 서버는 각 요청에서 JWT를 검증하여 인증을 처리할 수 있습니다.

이번 글에서는 Spring Security와 JWT를 활용하여 인증 시스템을 구축하는 방법을 정리해보겠습니다.

1. JWT란?

JWT (JSON Web Token)는 JSON 기반의 토큰으로, 클라이언트와 서버 간에 인증정보 교환에 사용됩니다.

JWT는 다음과 같은 특징을 가지고 있습니다:

  • 세션을 사용하지 않고 인증 상태를 클라이언트 측에서 유지합니다.
  • 토큰 안에 필요한 정보(사용자 ID, 권한 등)가 포함되어 있습니다.
  • 서명된 토큰을 사용하여 위변조를 방지합니다.

JWT는 세 가지 주요 부분으로 구성됩니다.

<Header>.<Payload>.<Signature>
  • Header: 토큰의 타입(JWT)과 해싱 알고리즘(HS256 등)을 포함.
  • Payload: 토큰에 담길 데이터(사용자 정보, 만료 시간 등).
  • Signature: Header와 Payload를 비밀키로 서명하여 생성.

JWT의 장점

  • 서버의 상태 정보를 유지할 필요 없이 클라이언트 측에서 인증 정보를 보관할 수 있습니다.
  • 확장성이 좋아 마이크로서비스와 같이 분산된 시스템에서 효율적입니다.

JWT의 단점

  • 클라이언트에 저장되기 때문에 토큰이 탈취되면 악용될 수 있으므로 보안 관리가 필요합니다.
  • 크기가 커질수록 네트워크 대역폭을 더 소모할 수 있습니다.

2. JWT 동작 원리

  1. 사용자 로그인 요청
    • 클라이언트가 서버에 사용자 인증 정보를 전달합니다.
  2. 서버에서 JWT 생성
    • 인증 성공 시, 서버는 사용자 정보를 바탕으로 JWT를 생성하고 클라이언트에게 반환합니다.
  3. 클라이언트에서 JWT 저장
    • 클라이언트는 받은 JWT를 로컬 저장소 또는 쿠키에 저장합니다.
  4. 클라이언트의 인증 요청
    • 클라이언트는 요청 헤더에 JWT를 포함시켜 서버에 인증 요청을 보냅니다.
  5. 서버에서 JWT 검증
    • 서버는 받은 JWT의 서명을 검증하여 요청의 유효성을 확인합니다.
  6. 응답 반환
    • 인증이 성공하면 서버는 요청된 자원 또는 데이터를 반환합니다.

JWT의 동작 과정에서 보안을 고려하여 아래와 같이 설계해야 합니다.

  • 토큰 탈취: HTTPS를 사용하여 전송 중 토큰이 노출되지 않도록 보호해야 합니다.
  • 토큰 만료: 만료 시간을 설정하여 오래된 토큰의 악용을 방지합니다.

3. Security Config 설정

Spring Security와 JWT를 통합하기 위해 SecurityConfig를 작성해야 합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.project.security.JwtAuthenticationFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity //시큐리티 활성화, 웹 보안 설정 구성에 사용
@RequiredArgsConstructor //생성자 주입
public class SecurityConfig{
	
	//JWT 인증 필터 가져오기
	private final JwtAuthenticationFilter jwtAuthenticationFilter;
	
	//인증 매니저 
    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
		return authenticationConfiguration.getAuthenticationManager();
	}
	
  //비밀번호 암호화 
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	//Security 필터 체인 설정 
	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
			//교차 사이트 요청 위조 비활성화 
		http.csrf((csrf) -> csrf.disable())
			//교차 출처 리소스 공유 비활성화 
			.cors(Customizer.withDefaults())
			//세션 관리 정책 없음 -> 스프링 시큐리티가 생성하거나 기존 것을 사용하지 않도록 함
			//JWT와 같은 토큰 방식을 사용할 때 필요한 설정
			.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
			//요청 권한 설정 (현재는 모든 링크를 열어두었음)
			.authorizeHttpRequests(auth -> auth.requestMatchers("*").permitAll().anyRequest().authenticated())
			//JWT 필터 추가
			.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
		
		return http.build();
	}
}

4. JwtAuthenticationFilter 설정

JwtAuthenticationFilter는 요청에서 JWT를 검증하고,

유효한 경우에 인증 정보를 SecurityContextHolder에 설정하여

Spring Security의 인증 상태를 유지하도록 돕는 필터입니다.

import java.io.IOException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

  // JwtTokenProvider는 토큰 생성, 검증, 사용자 정보를 추출하는 역할을 수행
	private final JwtTokenProvider jwtTokenProvider;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		// 요청 헤더에서 JWT 토큰 추출
		String accessToken = getJwtFromRequest(request);
		if (accessToken != null) { // 토큰이 존재할 경우
			if (jwtTokenProvider.validateToken(accessToken)) {
				// 액세스 토큰이 유효하면 인증 설정
				setAuthentication(accessToken, request);
			} else {
				// 액세스 토큰이 만료되었거나 유효하지 않다면 리프레시 토큰 처리
				handleRefreshToken(request, response);
			}
		}
		
		// 다음 필터로 요청을 전달
		filterChain.doFilter(request, response);
	}

	// 리프레시 토큰 처리
	private void handleRefreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
		// 쿠키에서 리프레시 토큰을 추출
		String refreshToken = getRefreshTokenFromCookies(request);

		if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
			// 리프레시 토큰이 유효하면 사용자 정보를 기반으로 새 액세스 토큰 생성
			String userSeq = jwtTokenProvider.getUserSeqFromToken(refreshToken);
			String newAccessToken = jwtTokenProvider.generateToken(userSeq, JwtTokenProvider.TokenType.ACCESS);
			// 새 액세스 토큰을 응답 헤더에 추가
			response.setHeader("Authorization", "Bearer " + newAccessToken);
			// 새 액세스 토큰으로 인증 설정
			setAuthentication(newAccessToken, request);
		} else {
			// 리프레시 토큰이 유효하지 않으면 예외 발생
			throw new IllegalArgumentException("Invalid or missing refresh token.");
		}
	}

	// 쿠키에서 리프레시 토큰 추출
	private String getRefreshTokenFromCookies(HttpServletRequest request) {
		// 요청에 쿠키가 포함되어 있는지 확인
		if (request.getCookies() != null) {
			for (Cookie cookie : request.getCookies()) {
				// "refreshToken"이라는 이름의 쿠키를 찾음
				if ("refreshToken".equals(cookie.getName())) {
					return cookie.getValue(); // 해당 쿠키의 값을 반환
				}
			}
		}
		return null; // 리프레시 토큰이 없으면 null 반환
	}

	// 인증 정보를 설정하는 메서드
	private void setAuthentication(String token, HttpServletRequest request) {
		// 토큰에서 사용자 식별 정보(userSeq)를 추출
		String userSeq = jwtTokenProvider.getUserSeqFromToken(token);
		// UsernamePasswordAuthenticationToken을 생성하여 인증 객체 생성
		UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userSeq, null);
		// 요청의 추가 정보를 설정
		authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
		// SecurityContextHolder에 인증 객체를 설정하여 현재 요청을 인증 상태로 만듦
		SecurityContextHolder.getContext().setAuthentication(authentication);
	}

	// 요청 헤더에서 JWT 토큰 추출
	private String getJwtFromRequest(HttpServletRequest request) {
		// "Authorization" 헤더 값을 가져옴
		String bearerToken = request.getHeader("Authorization");
		// "Bearer "로 시작하면 토큰 값만 반환
		if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
			return bearerToken.substring(7);
		}
		return null; // 토큰이 없거나 형식이 잘못되었으면 null 반환
	}

}

5. JwtTokenProvider 설정

JwtTokenProvider에는 총 4개의 메서드가 있습니다.

  1. generateSecretKey : 비밀키 생성, HS512 알고리즘에 맞는 키를 생성합니다.
  2. generateToken : userSeq, TokenType에 따라 JWT 토큰을 생성합니다.
  3. validateToken : JWT 토큰의 유효성을 검증합니다.
  4. getUserSeqFromToken : JWT 토큰에서 UserSeq를 추출합니다.
import java.security.Key;
import java.util.Date;

import javax.crypto.spec.SecretKeySpec;

import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    // 비밀키 생성 및 저장 (애플리케이션에서 동일한 키를 사용)
    private final Key secretKey = generateSecretKey();

    // 리프레시 토큰 유효 기간 (24시간)
    private final long refreshTokenValidity = 86_400_000; // 24 hours

    // 액세스 토큰 유효 기간 (30분)
    private final long accessTokenValidity = 1_800_000; // 30 minutes

    // 토큰 타입 (ACCESS와 REFRESH 구분)
    public enum TokenType {
        ACCESS, REFRESH
    }

    /**
     * 비밀키 생성
     * @return SecretKey - HS512 알고리즘에 사용할 비밀키
     */
    public Key generateSecretKey() {
        String keyString = "thisisssafyrecruitapplicationfromssafy12thseoulclass5team!wehave4members"; // 비밀키 문자열
        return new SecretKeySpec(keyString.getBytes(), SignatureAlgorithm.HS512.getJcaName()); // 키 생성
    }

    /**
     * JWT 토큰 생성
     * @param userSeq - 사용자 고유 식별자
     * @param tokenType - 토큰 타입 (ACCESS 또는 REFRESH)
     * @return 생성된 JWT 토큰 문자열
     */
    public String generateToken(String userSeq, TokenType tokenType) {
        Date now = new Date(); // 현재 시간
        long validity = (tokenType == TokenType.ACCESS) ? accessTokenValidity : refreshTokenValidity; // 유효 기간 설정
        Date expiryDate = new Date(now.getTime() + validity); // 만료 시간 계산

        return Jwts.builder()
                .setSubject(userSeq) // 사용자 정보 저장
                .setIssuedAt(now) // 발행 시간
                .setExpiration(expiryDate) // 만료 시간
                .signWith(secretKey, SignatureAlgorithm.HS512) // 서명 알고리즘과 비밀키 사용
                .compact(); // 최종 토큰 생성
    }

    /**
     * JWT 토큰 유효성 검증
     * @param token - 클라이언트로부터 받은 JWT 토큰
     * @return 토큰이 유효한 경우 true, 그렇지 않은 경우 false
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(secretKey) // 비밀키 설정
                .build()
                .parseClaimsJws(token); // 토큰 검증
            return true; // 유효한 경우 true 반환
        } catch (Exception e) {
            return false; // 예외 발생 시 false 반환
        }
    }

    /**
     * JWT 토큰에서 사용자 고유 식별자 추출
     * @param token - 클라이언트로부터 받은 JWT 토큰
     * @return 사용자 식별자 (userSeq)
     */
    public String getUserSeqFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey) // 비밀키 설정
                .build()
                .parseClaimsJws(token) // 토큰에서 클레임 추출
                .getBody();
        return claims.getSubject(); // 클레임에서 사용자 식별자(subject) 반환
    }
}

결론 및 정리

이번 프로젝트에서는 JWT를 활용해 Spring Security 기반의 인증 시스템을 구현했습니다.

JWT는 RESTful 환경에서 세션리스 인증을 제공하며, 이를 통해 확장성과 효율성을 높일 수 있었습니다.

JWT를 활용하여 서버의 부담을 감소시키고 확장성을 확보하고 보안성을 강화할 수 있을 것을 기대합니다.

'BackEnd > Spring' 카테고리의 다른 글

[Spring] 스프링 의존성 주입 Dependency Injection 총정리  (2) 2024.10.11