2025-04-08T19:56:24

This commit is contained in:
2025-04-08 19:56:24 +09:00
parent a75a1dbd0f
commit eef061c1c9
100 changed files with 18639 additions and 0 deletions

254
docs/security/08_JWT.md Normal file
View File

@@ -0,0 +1,254 @@
아래는 "스프링 시큐리티" 책의 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
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
```
##### 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 <token>` 헤더 포함.
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 인증 설계를 실습 가능하도록 설명했습니다. 추가적인 예제나 세부 사항이 필요하면 말씀해 주세요!