아래는 "스프링 시큐리티" 책의 8장에 포함될 "JWT(Json Web Token) 개요", "스프링 시큐리티와 JWT 연동", 그리고 "Stateless 인증 설계"에 대한 내용입니다. 개념을 명확히 하고, 스프링부트에서의 실습 가능한 구현 방법을 포함했습니다. --- ### 8장. JWT와 토큰 기반 인증 #### 8.1 JWT(Json Web Token) 개요 **JWT(Json Web Token)**는 인증 및 권한 부여를 위해 사용되는 표준 토큰 형식으로, 클라이언트와 서버 간에 안전하게 정보를 전달합니다. 세션 기반 인증과 달리 서버가 상태를 유지하지 않는(stateless) 특성을 가지며, REST API와 같은 분산 환경에서 널리 사용됩니다. ##### JWT 구조 JWT는 세 부분으로 구성되며, 점(`.`)으로 구분됩니다: 1. **Header**: 토큰의 유형(보통 "JWT")과 서명 알고리즘(예: HMAC SHA256)을 정의. - 예: `{"alg": "HS256", "typ": "JWT"}` 2. **Payload**: 사용자 정보(클레임, Claims)를 포함. 표준 클레임(예: `sub`, `exp`)과 커스텀 클레임으로 구성. - 예: `{"sub": "user123", "roles": ["USER"], "exp": 1698765432}` 3. **Signature**: Header와 Payload를 비밀 키로 서명한 값으로, 무결성을 보장. - `HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)` 결과적으로 JWT는 `header.payload.signature` 형태로 인코딩됩니다(예: `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature`). ##### 장점과 단점 - **장점**: 서버가 세션을 관리할 필요 없음, 확장성 좋음, 모바일 및 분산 시스템에 적합. - **단점**: 토큰 크기가 클 수 있음, 취소(Revocation)가 복잡, Payload는 암호화되지 않음(기밀 데이터 주의). #### 8.2 스프링 시큐리티와 JWT 연동 스프링부트에서 JWT를 사용하려면 토큰 생성, 검증, 인증 필터를 구현해야 합니다. 여기서는 기본적인 JWT 인증 흐름을 설정합니다. ##### 1. 의존성 추가 `pom.xml`에 JWT 라이브러리 추가: ```xml io.jsonwebtoken jjwt 0.9.1 org.springframework.boot spring-boot-starter-security ``` ##### 2. JWT 유틸리티 클래스 토큰 생성과 검증을 위한 유틸리티: ```java import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.stereotype.Component; import javax.crypto.spec.SecretKeySpec; import java.util.Date; @Component public class JwtUtil { private final String SECRET_KEY = "your-secret-key"; // 비밀 키 (환경 변수로 관리 권장) private final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간 public String generateToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes()) .compact(); } public String extractUsername(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY.getBytes()) .parseClaimsJws(token) .getBody() .getSubject(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(SECRET_KEY.getBytes()).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } } ``` ##### 3. JWT 인증 필터 HTTP 요청에서 JWT를 추출해 인증: ```java import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); if (jwtUtil.validateToken(token)) { String username = jwtUtil.extractUsername(token); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, null, null); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } } chain.doFilter(request, response); } } ``` ##### 4. SecurityConfig 설정 ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/login").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } } ``` ##### 5. 로그인 엔드포인트 토큰 발급을 위한 컨트롤러: ```java import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController { private final JwtUtil jwtUtil; public AuthController(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @PostMapping("/login") public String login(@RequestBody LoginRequest request) { // 실제 인증 로직 (예: DB에서 사용자 확인)은 생략 // 여기서는 단순히 username으로 토큰 생성 return jwtUtil.generateToken(request.getUsername()); } } class LoginRequest { private String username; private String password; // Getters, Setters public String getUsername() { return username; } public String getPassword() { return password; } public void setUsername(String username) { this.username = username; } public void setPassword(String password) { this.password = password; } } ``` ##### 동작 흐름 1. 클라이언트가 `/login`에 POST 요청으로 사용자 이름과 비밀번호 전송. 2. 서버가 JWT를 생성해 반환. 3. 클라이언트가 후속 요청에 `Authorization: Bearer ` 헤더 포함. 4. `JwtAuthenticationFilter`가 토큰을 검증하고 인증 설정. #### 8.4 Stateless 인증 설계 **Stateless 인증**은 서버가 클라이언트의 상태(세션)를 유지하지 않고, 각 요청마다 독립적으로 인증을 처리하는 방식입니다. JWT는 이를 구현하는 데 이상적입니다. ##### Stateless 설계 특징 - **세션 사용 안 함**: 서버는 세션 저장소를 유지하지 않음. - **토큰 기반**: 모든 인증 정보가 JWT에 포함되어 클라이언트가 관리. - **확장성**: 서버 부하가 줄어들고, 여러 서버 간 인증 공유가 쉬움. ##### Stateless 인증 구현 1. **세션 비활성화**: `SecurityConfig`에서 `SessionCreationPolicy.STATELESS` 설정. ```java .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) ``` 2. **CSRF 비활성화**: 세션을 사용하지 않으므로 CSRF 토큰도 필요 없음. ```java .csrf(csrf -> csrf.disable()) ``` 3. **토큰 검증**: 매 요청마다 `JwtAuthenticationFilter`가 토큰을 확인해 인증. ##### 고려 사항 - **토큰 만료**: `EXPIRATION_TIME`을 적절히 설정(예: 1시간). 리프레시 토큰을 추가로 구현 가능. - **토큰 취소**: Stateless 환경에서는 토큰 무효화가 어려움. 블랙리스트를 유지하거나 짧은 만료 시간을 설정. - **보안**: 비밀 키를 안전하게 관리하고, HTTPS를 사용해 토큰 유출 방지. ##### 리프레시 토큰 추가 (선택) 만료된 액세스 토큰을 갱신하려면: 1. 로그인 시 액세스 토큰과 리프레시 토큰을 함께 발급. 2. 리프레시 토큰은 긴 만료 시간(예: 7일) 설정. 3. `/refresh` 엔드포인트에서 리프레시 토큰으로 새 액세스 토큰 발급. ```java @PostMapping("/refresh") public String refreshToken(@RequestBody String refreshToken) { if (jwtUtil.validateToken(refreshToken)) { String username = jwtUtil.extractUsername(refreshToken); return jwtUtil.generateToken(username); // 새 액세스 토큰 반환 } throw new RuntimeException("Invalid refresh token"); } ``` ##### 실습 예제 1. `/login`으로 토큰 발급 후, 보호된 엔드포인트(`/home`)에 접근. 2. 토큰 없이 요청 시 403 확인. 3. 리프레시 토큰 로직을 추가해 토큰 갱신 테스트. --- 위 내용은 JWT의 개념, 스프링부트에서의 구현, 그리고 Stateless 인증 설계를 실습 가능하도록 설명했습니다. 추가적인 예제나 세부 사항이 필요하면 말씀해 주세요!