254 lines
10 KiB
Markdown
254 lines
10 KiB
Markdown
아래는 "스프링 시큐리티" 책의 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 인증 설계를 실습 가능하도록 설명했습니다. 추가적인 예제나 세부 사항이 필요하면 말씀해 주세요! |