2025-04-08T19:56:24
This commit is contained in:
254
docs/security/08_JWT.md
Normal file
254
docs/security/08_JWT.md
Normal 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 인증 설계를 실습 가능하도록 설명했습니다. 추가적인 예제나 세부 사항이 필요하면 말씀해 주세요!
|
||||
Reference in New Issue
Block a user