Spring 10: JWT 인증
토큰 기반 인증 구현
1. JWT 개념과 토큰 기반 인증
🔐JWT(JSON Web Token)란?
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 컴팩트하고 자체 포함된 방법을 정의하는 개방형 표준(RFC 7519)입니다.
JWT는 디지털 서명이 되어 있어 신뢰할 수 있으며, HMAC 알고리즘을 사용하거나 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍으로 서명할 수 있습니다.
핵심 특징:
- • Self-contained: 토큰 자체에 사용자 정보가 포함
- • Stateless: 서버에서 세션 상태를 저장하지 않음
- • Portable: 다양한 플랫폼과 언어에서 사용 가능
⚖️토큰 기반 인증 vs 세션 기반 인증
세션 기반 인증
동작 방식:
- 사용자 로그인 시 서버에서 세션 생성
- 세션 ID를 쿠키로 클라이언트에 전송
- 클라이언트는 요청 시마다 쿠키 전송
- 서버는 세션 저장소에서 세션 정보 조회
한계점:
- • 서버 메모리/DB에 세션 저장 필요
- • 확장성 문제 (서버 증설 시 세션 공유)
- • CORS 문제
토큰 기반 인증
동작 방식:
- 사용자 로그인 시 JWT 토큰 생성
- 클라이언트가 토큰을 저장
- API 요청 시 Authorization 헤더에 토큰 포함
- 서버는 토큰을 검증하여 사용자 인증
장점:
- • Stateless (서버 상태 저장 불필요)
- • 확장성 우수
- • 마이크로서비스 아키텍처에 적합
🏗️JWT 구조 (Header.Payload.Signature)
1. Header (헤더)
토큰의 타입과 해싱 알고리즘을 지정합니다. Base64Url로 인코딩됩니다.
{
"alg": "HS256", // 서명 알고리즘
"typ": "JWT" // 토큰 타입
}주요 알고리즘:
- •
HS256: HMAC SHA-256 (대칭키) - •
RS256: RSA SHA-256 (비대칭키) - •
ES256: ECDSA SHA-256 (타원곡선)
2. Payload (페이로드)
클레임(Claims)이라고 불리는 사용자 정보와 추가 데이터를 포함합니다.
{
// Registered Claims (표준 클레임)
"iss": "https://example.com", // 발급자
"sub": "user123", // 주제 (사용자 ID)
"aud": "my-app", // 대상
"exp": 1735689600, // 만료 시간
"nbf": 1735603200, // 유효 시작 시간
"iat": 1735603200, // 발급 시간
"jti": "unique-token-id", // JWT ID
// Public Claims (공개 클레임)
"name": "John Doe",
"email": "john@example.com",
// Private Claims (비공개 클레임)
"role": "ADMIN",
"permissions": ["READ", "WRITE"]
}⚠️ 주의사항:
Payload는 Base64로 인코딩되어 있어 누구나 디코딩할 수 있습니다. 민감한 정보(비밀번호, 개인정보)는 포함하지 마세요!
3. Signature (서명)
토큰의 무결성을 보장하고 변조를 방지합니다. Header와 Payload를 비밀키로 서명합니다.
// HMAC SHA256 서명 생성 과정 HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret ) // 예시 signature = HMACSHA256( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNzM1NjAzMjAwfQ", "your-256-bit-secret" )
완성된 JWT 토큰 예시
■ Header (빨간색)
■ Payload (파란색)
■ Signature (초록색)
⚖️JWT 장단점 상세 분석
✅장점 (Advantages)
1. Stateless (무상태)
서버에서 세션 정보를 저장할 필요가 없어 메모리 사용량이 줄어들고 서버 확장이 용이합니다.
2. 확장성 (Scalability)
로드 밸런서를 통해 여러 서버로 요청을 분산해도 세션 공유 문제가 없습니다.
3. 크로스 도메인 지원
CORS 문제 없이 다른 도메인의 API에서도 사용할 수 있습니다.
4. 모바일 친화적
쿠키를 사용하지 않아 모바일 앱에서 사용하기 적합합니다.
5. 마이크로서비스 적합
서비스 간 인증 정보 공유가 쉽고 독립적인 검증이 가능합니다.
❌단점 (Disadvantages)
1. 토큰 크기
세션 ID보다 크기가 크므로 네트워크 오버헤드가 발생할 수 있습니다.
2. 토큰 무효화 어려움
만료 전까지 토큰을 무효화하기 어려워 보안상 위험할 수 있습니다.
3. 정보 노출 위험
Payload가 Base64 인코딩되어 있어 민감한 정보가 노출될 수 있습니다.
4. 토큰 탈취 위험
XSS 공격으로 토큰이 탈취되면 만료까지 악용될 수 있습니다.
5. 실시간 권한 변경 어려움
사용자 권한이 변경되어도 토큰 만료까지 반영되지 않습니다.
🎯JWT 적합한 사용 시나리오
✅ 적합한 경우
- • RESTful API 인증
- • 마이크로서비스 아키텍처
- • 모바일 앱 인증
- • SPA (Single Page Application)
- • 서버 확장이 필요한 환경
- • 크로스 도메인 인증
❌ 부적합한 경우
- • 실시간 권한 변경이 필요한 경우
- • 높은 보안이 요구되는 금융 시스템
- • 토큰 무효화가 빈번한 시스템
- • 네트워크 대역폭이 제한적인 환경
- • 세션 기반 기능이 많은 웹 애플리케이션
🔄 하이브리드 접근
- • JWT + Redis (토큰 블랙리스트)
- • 짧은 만료시간 + Refresh Token
- • 세션 + JWT (용도별 분리)
- • JWT + 실시간 권한 검증
2. JWT 생성과 검증
📚JJWT 라이브러리 소개
JJWT(Java JWT)는 Java와 Android에서 JWT를 생성하고 검증하기 위한 가장 인기 있는 라이브러리입니다. 타입 안전성과 유연성을 제공합니다.
Maven 의존성 추가
<!-- pom.xml -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>Gradle 의존성 추가
// build.gradle
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}JJWT 주요 특징:
- • 타입 안전성: 컴파일 타임에 오류 검출
- • Fluent API: 직관적이고 읽기 쉬운 코드
- • 다양한 알고리즘 지원: HMAC, RSA, ECDSA
- • Jackson 통합: JSON 직렬화/역직렬화
🔨JWT 토큰 생성 (Token Creation)
1. 기본 토큰 생성
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtTokenProvider {
// 256비트 비밀키 생성 (실제로는 환경변수나 설정파일에서 관리)
private final SecretKey secretKey = Keys.hmacShaKeyFor(
"mySecretKeyForJWTTokenGenerationAndValidation123456789".getBytes()
);
private final long ACCESS_TOKEN_VALIDITY = 1000 * 60 * 15; // 15분
/**
* Access Token 생성
*/
public String createAccessToken(String userId, String role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
return Jwts.builder()
.subject(userId) // 사용자 ID
.claim("role", role) // 사용자 역할
.claim("type", "access") // 토큰 타입
.issuedAt(now) // 발급 시간
.expiration(expiryDate) // 만료 시간
.signWith(secretKey) // 서명
.compact(); // 토큰 생성
}
}2. 상세 정보가 포함된 토큰 생성
/**
* 상세 정보가 포함된 토큰 생성
*/
public String createDetailedToken(UserDetails userDetails) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
// 사용자 권한 목록 추출
List<String> authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.header() // 헤더 설정
.type("JWT")
.and()
.subject(userDetails.getUsername()) // 사용자명
.claim("userId", userDetails.getUserId())
.claim("email", userDetails.getEmail())
.claim("role", userDetails.getRole())
.claim("authorities", authorities) // 권한 목록
.claim("type", "access")
.issuer("my-app") // 발급자
.audience().add("web-client") // 대상
.and()
.issuedAt(now)
.notBefore(now) // 유효 시작 시간
.expiration(expiryDate)
.id(UUID.randomUUID().toString()) // JWT ID
.signWith(secretKey, Jwts.SIG.HS256) // 명시적 알고리즘 지정
.compact();
}
/**
* Refresh Token 생성 (더 긴 만료시간)
*/
public String createRefreshToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);
return Jwts.builder()
.subject(userId)
.claim("type", "refresh")
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}3. 커스텀 클레임이 포함된 토큰
/**
* 커스텀 클레임이 포함된 토큰 생성
*/
public String createTokenWithCustomClaims(String userId, Map<String, Object> customClaims) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
JwtBuilder builder = Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiryDate);
// 커스텀 클레임 추가
customClaims.forEach(builder::claim);
return builder.signWith(secretKey).compact();
}
// 사용 예시
Map<String, Object> customClaims = new HashMap<>();
customClaims.put("department", "IT");
customClaims.put("level", "SENIOR");
customClaims.put("permissions", Arrays.asList("READ", "WRITE", "DELETE"));
String token = createTokenWithCustomClaims("user123", customClaims);🔍JWT 토큰 파싱 (Token Parsing)
1. 기본 토큰 파싱
/**
* JWT 토큰에서 Claims 추출
*/
public Claims parseToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
throw new InvalidTokenException("Invalid JWT token", e);
}
}
public String getUserIdFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
public List<String> getAuthoritiesFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("authorities", List.class);
}2. 안전한 토큰 파싱 (예외 처리 포함)
/**
* 안전한 토큰 파싱 (상세한 예외 처리)
*/
public Optional<Claims> parseTokenSafely(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return Optional.of(claims);
} catch (ExpiredJwtException e) {
log.warn("JWT token is expired: {}", e.getMessage());
return Optional.empty();
} catch (UnsupportedJwtException e) {
log.warn("JWT token is unsupported: {}", e.getMessage());
return Optional.empty();
} catch (MalformedJwtException e) {
log.warn("JWT token is malformed: {}", e.getMessage());
return Optional.empty();
} catch (SignatureException e) {
log.warn("JWT signature validation failed: {}", e.getMessage());
return Optional.empty();
} catch (IllegalArgumentException e) {
log.warn("JWT token compact of handler are invalid: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 토큰 유효성 검사
*/
public boolean isTokenValid(String token) {
return parseTokenSafely(token).isPresent();
}
/**
* 토큰 만료 여부 확인
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = parseToken(token);
return claims.getExpiration().before(new Date());
} catch (JwtException e) {
return true; // 파싱 실패 시 만료된 것으로 간주
}
}✅JWT 토큰 검증 (Token Validation)
1. 종합적인 토큰 검증
/**
* 종합적인 JWT 토큰 검증
*/
public TokenValidationResult validateToken(String token) {
try {
// 1. 토큰 파싱 및 서명 검증
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
// 2. 만료 시간 검증 (자동으로 수행되지만 명시적 확인)
if (claims.getExpiration().before(new Date())) {
return TokenValidationResult.expired();
}
// 3. 토큰 타입 검증
String tokenType = claims.get("type", String.class);
if (!"access".equals(tokenType)) {
return TokenValidationResult.invalidType();
}
// 4. 발급자 검증
if (!"my-app".equals(claims.getIssuer())) {
return TokenValidationResult.invalidIssuer();
}
// 5. 대상 검증
if (!claims.getAudience().contains("web-client")) {
return TokenValidationResult.invalidAudience();
}
// 6. 사용자 존재 여부 확인 (선택적)
String userId = claims.getSubject();
if (!userService.existsById(userId)) {
return TokenValidationResult.userNotFound();
}
return TokenValidationResult.valid(claims);
} catch (ExpiredJwtException e) {
return TokenValidationResult.expired();
} catch (JwtException e) {
return TokenValidationResult.invalid(e.getMessage());
}
}
/**
* 토큰 검증 결과 클래스
*/
public static class TokenValidationResult {
private final boolean valid;
private final String errorMessage;
private final Claims claims;
// 생성자 및 팩토리 메서드들...
public static TokenValidationResult valid(Claims claims) {
return new TokenValidationResult(true, null, claims);
}
public static TokenValidationResult expired() {
return new TokenValidationResult(false, "Token expired", null);
}
public static TokenValidationResult invalid(String message) {
return new TokenValidationResult(false, message, null);
}
// getter 메서드들...
}2. 실시간 토큰 검증 (블랙리스트 확인)
/**
* 블랙리스트를 고려한 토큰 검증
*/
@Service
public class JwtTokenValidator {
private final RedisTemplate<String, String> redisTemplate;
private final JwtTokenProvider tokenProvider;
/**
* 토큰이 블랙리스트에 있는지 확인
*/
public boolean isTokenBlacklisted(String token) {
try {
Claims claims = tokenProvider.parseToken(token);
String jti = claims.getId(); // JWT ID
if (jti != null) {
return redisTemplate.hasKey("blacklist:" + jti);
}
// JTI가 없는 경우 토큰 해시로 확인
String tokenHash = DigestUtils.sha256Hex(token);
return redisTemplate.hasKey("blacklist:" + tokenHash);
} catch (JwtException e) {
return true; // 파싱 실패 시 블랙리스트로 간주
}
}
/**
* 토큰을 블랙리스트에 추가
*/
public void blacklistToken(String token) {
try {
Claims claims = tokenProvider.parseToken(token);
String jti = claims.getId();
if (jti != null) {
// 토큰 만료시간까지만 블랙리스트에 보관
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(
"blacklist:" + jti,
"true",
Duration.ofMillis(ttl)
);
}
}
} catch (JwtException e) {
log.warn("Failed to blacklist token: {}", e.getMessage());
}
}
/**
* 완전한 토큰 검증 (블랙리스트 포함)
*/
public boolean isValidToken(String token) {
// 1. 기본 토큰 검증
TokenValidationResult result = tokenProvider.validateToken(token);
if (!result.isValid()) {
return false;
}
// 2. 블랙리스트 확인
if (isTokenBlacklisted(token)) {
return false;
}
return true;
}
}💡실제 사용 예시
JWT 서비스 통합 예시
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtTokenProvider tokenProvider;
private final JwtTokenValidator tokenValidator;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
/**
* 로그인 처리
*/
public LoginResponse login(LoginRequest request) {
// 1. 사용자 인증
User user = userService.findByUsername(request.getUsername())
.orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new BadCredentialsException("Invalid credentials");
}
// 2. 토큰 생성
String accessToken = tokenProvider.createAccessToken(
user.getId().toString(),
user.getRole().name()
);
String refreshToken = tokenProvider.createRefreshToken(
user.getId().toString()
);
// 3. 응답 생성
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(900) // 15분
.build();
}
/**
* 토큰 갱신
*/
public TokenRefreshResponse refreshToken(String refreshToken) {
// 1. Refresh Token 검증
if (!tokenValidator.isValidToken(refreshToken)) {
throw new InvalidTokenException("Invalid refresh token");
}
// 2. 사용자 정보 추출
String userId = tokenProvider.getUserIdFromToken(refreshToken);
User user = userService.findById(Long.valueOf(userId))
.orElseThrow(() -> new UserNotFoundException("User not found"));
// 3. 새로운 Access Token 생성
String newAccessToken = tokenProvider.createAccessToken(
userId,
user.getRole().name()
);
return TokenRefreshResponse.builder()
.accessToken(newAccessToken)
.tokenType("Bearer")
.expiresIn(900)
.build();
}
/**
* 로그아웃 처리
*/
public void logout(String accessToken) {
// 토큰을 블랙리스트에 추가
tokenValidator.blacklistToken(accessToken);
}
}3. JWT Filter 구현
🔄OncePerRequestFilter 이해하기
OncePerRequestFilter는 Spring Security에서 제공하는 추상 클래스로, HTTP 요청당 한 번만 실행되는 것을 보장하는 필터입니다.
OncePerRequestFilter vs GenericFilterBean
OncePerRequestFilter
- • 요청당 한 번만 실행 보장
- • 비동기 요청 처리 지원
- • 에러 디스패치 처리 지원
- • shouldNotFilter() 메서드 제공
GenericFilterBean
- • 기본적인 필터 기능만 제공
- • 중복 실행 가능성
- • 수동으로 중복 방지 구현 필요
- • 더 많은 제어권 제공
JWT 필터에서 OncePerRequestFilter를 사용하는 이유:
- • 토큰 검증을 요청당 한 번만 수행
- • 성능 최적화 (중복 검증 방지)
- • 일관된 인증 상태 유지
🛡️JWT 인증 필터 기본 구현
1. 기본 JWT 필터 클래스
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final JwtTokenValidator tokenValidator;
private final UserDetailsService userDetailsService;
// JWT 토큰이 필요하지 않은 경로들
private static final List<String> EXCLUDED_PATHS = Arrays.asList(
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/public",
"/health",
"/actuator"
);
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// 1. Authorization 헤더에서 JWT 토큰 추출
String token = extractTokenFromRequest(request);
// 2. 토큰이 존재하고 유효한 경우 인증 처리
if (token != null && tokenValidator.isValidToken(token)) {
authenticateUser(token);
}
} catch (Exception e) {
log.error("JWT authentication failed: {}", e.getMessage());
// 인증 실패 시에도 필터 체인 계속 진행 (다른 인증 방식 시도 가능)
}
// 3. 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
/**
* 특정 요청에 대해 필터를 적용하지 않을지 결정
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return EXCLUDED_PATHS.stream().anyMatch(path::startsWith);
}
}2. 토큰 추출 메서드
/**
* HTTP 요청에서 JWT 토큰 추출
*/
private String extractTokenFromRequest(HttpServletRequest request) {
// 1. Authorization 헤더에서 추출 (권장 방식)
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거
}
// 2. 쿠키에서 추출 (선택적)
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
// 3. 쿼리 파라미터에서 추출 (WebSocket 등 특수한 경우)
String tokenParam = request.getParameter("token");
if (StringUtils.hasText(tokenParam)) {
return tokenParam;
}
return null;
}
/**
* 다양한 토큰 추출 방식을 지원하는 고급 버전
*/
private String extractTokenFromRequestAdvanced(HttpServletRequest request) {
// 토큰 추출 전략들
List<TokenExtractionStrategy> strategies = Arrays.asList(
new BearerTokenStrategy(),
new CookieTokenStrategy(),
new QueryParameterTokenStrategy(),
new CustomHeaderTokenStrategy()
);
for (TokenExtractionStrategy strategy : strategies) {
String token = strategy.extractToken(request);
if (token != null) {
return token;
}
}
return null;
}
// 토큰 추출 전략 인터페이스
interface TokenExtractionStrategy {
String extractToken(HttpServletRequest request);
}
// Bearer 토큰 추출 전략
class BearerTokenStrategy implements TokenExtractionStrategy {
@Override
public String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}👤사용자 인증 및 SecurityContext 설정
1. 기본 인증 처리
/**
* JWT 토큰을 기반으로 사용자 인증 처리
*/
private void authenticateUser(String token) {
try {
// 1. 토큰에서 사용자 정보 추출
Claims claims = tokenProvider.parseToken(token);
String userId = claims.getSubject();
// 2. 사용자 상세 정보 로드
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
// 3. 인증 토큰 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null, // credentials (JWT에서는 null)
userDetails.getAuthorities()
);
// 4. 인증 세부 정보 설정
authentication.setDetails(new WebAuthenticationDetailsSource()
.buildDetails((HttpServletRequest) request));
// 5. SecurityContext에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT authentication successful for user: {}", userId);
} catch (Exception e) {
log.error("Failed to authenticate user from JWT: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}
/**
* 토큰 기반 커스텀 인증 객체 생성
*/
private void authenticateUserWithCustomToken(String token) {
try {
Claims claims = tokenProvider.parseToken(token);
String userId = claims.getSubject();
// 커스텀 인증 토큰 생성
JwtAuthenticationToken authentication = new JwtAuthenticationToken(
userId,
token,
extractAuthoritiesFromClaims(claims)
);
// 추가 정보 설정
authentication.setUserDetails(userDetailsService.loadUserByUsername(userId));
authentication.setClaims(claims);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
log.error("JWT authentication failed: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}2. 커스텀 JWT 인증 토큰
/**
* JWT 전용 커스텀 인증 토큰
*/
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final String principal;
private final String token;
private UserDetails userDetails;
private Claims claims;
public JwtAuthenticationToken(String principal, String token,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.token = token;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return token;
}
@Override
public Object getPrincipal() {
return userDetails != null ? userDetails : principal;
}
// JWT 특화 메서드들
public String getToken() {
return token;
}
public Claims getClaims() {
return claims;
}
public void setClaims(Claims claims) {
this.claims = claims;
}
public void setUserDetails(UserDetails userDetails) {
this.userDetails = userDetails;
}
/**
* 토큰에서 특정 클레임 값 추출
*/
public <T> T getClaim(String claimName, Class<T> clazz) {
return claims != null ? claims.get(claimName, clazz) : null;
}
/**
* 사용자 역할 확인
*/
public boolean hasRole(String role) {
return getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals("ROLE_" + role));
}
}
/**
* Claims에서 권한 정보 추출
*/
@SuppressWarnings("unchecked")
private Collection<? extends GrantedAuthority> extractAuthoritiesFromClaims(Claims claims) {
List<String> authorities = claims.get("authorities", List.class);
if (authorities == null || authorities.isEmpty()) {
// authorities가 없으면 role에서 추출
String role = claims.get("role", String.class);
if (role != null) {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role));
}
return Collections.emptyList();
}
return authorities.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}⚡고급 JWT 필터 구현
1. 성능 최적화된 필터
@Component
@RequiredArgsConstructor
@Slf4j
public class OptimizedJwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final JwtTokenValidator tokenValidator;
private final UserDetailsService userDetailsService;
private final RedisTemplate<String, Object> redisTemplate;
// 캐시 TTL (5분)
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = extractTokenFromRequest(request);
if (token != null) {
// 1. 캐시에서 인증 정보 확인
Authentication cachedAuth = getCachedAuthentication(token);
if (cachedAuth != null) {
SecurityContextHolder.getContext().setAuthentication(cachedAuth);
} else if (tokenValidator.isValidToken(token)) {
// 2. 토큰 검증 후 인증 처리
Authentication auth = authenticateAndCache(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (Exception e) {
log.error("JWT authentication error: {}", e.getMessage());
handleAuthenticationError(response, e);
return;
}
filterChain.doFilter(request, response);
}
/**
* 캐시에서 인증 정보 조회
*/
private Authentication getCachedAuthentication(String token) {
try {
String cacheKey = "auth:" + DigestUtils.sha256Hex(token);
return (Authentication) redisTemplate.opsForValue().get(cacheKey);
} catch (Exception e) {
log.warn("Failed to get cached authentication: {}", e.getMessage());
return null;
}
}
/**
* 인증 처리 후 캐시에 저장
*/
private Authentication authenticateAndCache(String token) {
Claims claims = tokenProvider.parseToken(token);
String userId = claims.getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
JwtAuthenticationToken authentication = new JwtAuthenticationToken(
userId, token, userDetails.getAuthorities()
);
authentication.setUserDetails(userDetails);
authentication.setClaims(claims);
// 캐시에 저장
cacheAuthentication(token, authentication);
return authentication;
}
/**
* 인증 정보 캐시 저장
*/
private void cacheAuthentication(String token, Authentication auth) {
try {
String cacheKey = "auth:" + DigestUtils.sha256Hex(token);
redisTemplate.opsForValue().set(cacheKey, auth, CACHE_TTL);
} catch (Exception e) {
log.warn("Failed to cache authentication: {}", e.getMessage());
}
}
}2. 에러 처리 및 로깅
/**
* 인증 에러 처리
*/
private void handleAuthenticationError(HttpServletResponse response, Exception e)
throws IOException {
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
ErrorResponse errorResponse = ErrorResponse.builder()
.error("AUTHENTICATION_FAILED")
.message("JWT authentication failed")
.timestamp(Instant.now())
.build();
ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
/**
* 상세한 로깅을 위한 필터
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("requestId", requestId);
try {
log.debug("[{}] JWT filter processing: {} {}",
requestId, request.getMethod(), request.getRequestURI());
String token = extractTokenFromRequest(request);
if (token != null) {
log.debug("[{}] JWT token found, length: {}", requestId, token.length());
if (tokenValidator.isValidToken(token)) {
authenticateUser(token);
log.debug("[{}] JWT authentication successful", requestId);
} else {
log.warn("[{}] Invalid JWT token", requestId);
}
} else {
log.debug("[{}] No JWT token found", requestId);
}
} catch (Exception e) {
log.error("[{}] JWT authentication failed: {}", requestId, e.getMessage(), e);
} finally {
MDC.remove("requestId");
}
filterChain.doFilter(request, response);
}
/**
* 필터 등록 및 순서 설정
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.anyRequest().authenticated())
// JWT 필터를 UsernamePasswordAuthenticationFilter 이전에 추가
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}🧪JWT 필터 테스트
단위 테스트 예시
@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {
@Mock
private JwtTokenProvider tokenProvider;
@Mock
private JwtTokenValidator tokenValidator;
@Mock
private UserDetailsService userDetailsService;
@Mock
private HttpServletRequest request;
@Mock
private HttpServletResponse response;
@Mock
private FilterChain filterChain;
@InjectMocks
private JwtAuthenticationFilter jwtFilter;
@Test
void shouldAuthenticateValidToken() throws Exception {
// Given
String token = "valid.jwt.token";
String userId = "user123";
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
when(tokenValidator.isValidToken(token)).thenReturn(true);
when(tokenProvider.parseToken(token)).thenReturn(createMockClaims(userId));
when(userDetailsService.loadUserByUsername(userId))
.thenReturn(createMockUserDetails(userId));
// When
jwtFilter.doFilterInternal(request, response, filterChain);
// Then
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth).isNotNull();
assertThat(auth.getName()).isEqualTo(userId);
assertThat(auth.isAuthenticated()).isTrue();
verify(filterChain).doFilter(request, response);
}
@Test
void shouldSkipAuthenticationForInvalidToken() throws Exception {
// Given
String token = "invalid.jwt.token";
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
when(tokenValidator.isValidToken(token)).thenReturn(false);
// When
jwtFilter.doFilterInternal(request, response, filterChain);
// Then
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth).isNull();
verify(filterChain).doFilter(request, response);
verify(userDetailsService, never()).loadUserByUsername(any());
}
private Claims createMockClaims(String userId) {
Claims claims = Jwts.claims().subject(userId).build();
claims.put("role", "USER");
return claims;
}
private UserDetails createMockUserDetails(String userId) {
return User.builder()
.username(userId)
.password("password")
.authorities("ROLE_USER")
.build();
}
}4. 로그인 API 구현
🔑로그인 API 컨트롤러
기본 로그인 컨트롤러
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Validated
@Slf4j
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
try {
log.info("Login attempt for user: {}", request.getUsername());
LoginResponse response = authService.authenticate(request);
log.info("Login successful for user: {}", request.getUsername());
return ResponseEntity.ok(response);
} catch (BadCredentialsException e) {
log.warn("Login failed for user: {} - Invalid credentials", request.getUsername());
throw new AuthenticationException("Invalid username or password");
} catch (AccountLockedException e) {
log.warn("Login failed for user: {} - Account locked", request.getUsername());
throw new AuthenticationException("Account is locked");
} catch (DisabledException e) {
log.warn("Login failed for user: {} - Account disabled", request.getUsername());
throw new AuthenticationException("Account is disabled");
}
}
}📝요청/응답 DTO 정의
LoginRequest DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
private String username;
@NotBlank(message = "Password is required")
@Size(min = 6, max = 100, message = "Password must be between 6 and 100 characters")
private String password;
@Builder.Default
private boolean rememberMe = false;
// 추가 보안을 위한 필드들
private String captcha;
private String deviceId;
private String userAgent;
}LoginResponse DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LoginResponse {
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
private Long expiresIn; // seconds
// 사용자 정보
private UserInfo user;
// 추가 메타데이터
private Instant loginTime;
private String sessionId;
@Data
@Builder
public static class UserInfo {
private Long id;
private String username;
private String email;
private String role;
private List<String> permissions;
private boolean firstLogin;
private Instant lastLoginTime;
}
}⚙️인증 서비스 구현
AuthService 구현
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider tokenProvider;
private final LoginAttemptService loginAttemptService;
private final RedisTemplate<String, Object> redisTemplate;
public LoginResponse authenticate(LoginRequest request) {
// 1. 로그인 시도 횟수 확인
validateLoginAttempts(request.getUsername());
// 2. 사용자 조회 및 검증
User user = findAndValidateUser(request.getUsername(), request.getPassword());
// 3. 토큰 생성
String accessToken = tokenProvider.createAccessToken(
user.getId().toString(),
user.getRole().name()
);
String refreshToken = tokenProvider.createRefreshToken(
user.getId().toString()
);
// 4. Refresh Token 저장
saveRefreshToken(user.getId(), refreshToken);
// 5. 로그인 성공 처리
handleSuccessfulLogin(user, request);
// 6. 응답 생성
return createLoginResponse(user, accessToken, refreshToken);
}
private void validateLoginAttempts(String username) {
if (loginAttemptService.isBlocked(username)) {
throw new AccountLockedException("Account temporarily locked due to too many failed attempts");
}
}
private User findAndValidateUser(String username, String password) {
User user = userRepository.findByUsernameOrEmail(username, username)
.orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
if (!user.isEnabled()) {
throw new DisabledException("Account is disabled");
}
if (!user.isAccountNonLocked()) {
throw new AccountLockedException("Account is locked");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
loginAttemptService.recordFailedAttempt(username);
throw new BadCredentialsException("Invalid credentials");
}
return user;
}
}로그인 성공 처리
private void handleSuccessfulLogin(User user, LoginRequest request) {
// 1. 로그인 시도 횟수 초기화
loginAttemptService.clearFailedAttempts(request.getUsername());
// 2. 마지막 로그인 시간 업데이트
user.setLastLoginTime(Instant.now());
user.setLoginCount(user.getLoginCount() + 1);
// 3. 로그인 히스토리 저장
saveLoginHistory(user, request);
// 4. 활성 세션 관리 (동시 로그인 제한)
manageActiveSessions(user);
userRepository.save(user);
}
private void saveLoginHistory(User user, LoginRequest request) {
LoginHistory history = LoginHistory.builder()
.userId(user.getId())
.loginTime(Instant.now())
.ipAddress(getCurrentIpAddress())
.userAgent(request.getUserAgent())
.deviceId(request.getDeviceId())
.success(true)
.build();
loginHistoryRepository.save(history);
}
private void manageActiveSessions(User user) {
String sessionKey = "active_sessions:" + user.getId();
// 현재 활성 세션 수 확인
Set<String> activeSessions = redisTemplate.opsForSet().members(sessionKey);
// 최대 동시 로그인 수 제한 (예: 3개)
if (activeSessions != null && activeSessions.size() >= 3) {
// 가장 오래된 세션 제거
String oldestSession = activeSessions.iterator().next();
redisTemplate.opsForSet().remove(sessionKey, oldestSession);
// 해당 토큰을 블랙리스트에 추가
tokenProvider.blacklistToken(oldestSession);
}
// 새 세션 추가
String newSessionId = UUID.randomUUID().toString();
redisTemplate.opsForSet().add(sessionKey, newSessionId);
redisTemplate.expire(sessionKey, Duration.ofDays(30));
}🛡️보안 강화 기능
로그인 시도 제한 서비스
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
private final RedisTemplate<String, Object> redisTemplate;
private static final int MAX_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
public void recordFailedAttempt(String username) {
String key = "login_attempts:" + username;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
attempts = (attempts == null) ? 1 : attempts + 1;
redisTemplate.opsForValue().set(key, attempts, LOCK_DURATION);
if (attempts >= MAX_ATTEMPTS) {
String lockKey = "login_locked:" + username;
redisTemplate.opsForValue().set(lockKey, true, LOCK_DURATION);
log.warn("Account locked due to {} failed login attempts: {}", attempts, username);
}
}
public boolean isBlocked(String username) {
String lockKey = "login_locked:" + username;
return Boolean.TRUE.equals(redisTemplate.hasKey(lockKey));
}
public void clearFailedAttempts(String username) {
String key = "login_attempts:" + username;
String lockKey = "login_locked:" + username;
redisTemplate.delete(key);
redisTemplate.delete(lockKey);
}
public int getFailedAttempts(String username) {
String key = "login_attempts:" + username;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
return attempts != null ? attempts : 0;
}
}추가 보안 검증
@Component
@RequiredArgsConstructor
public class SecurityValidator {
private final GeoLocationService geoLocationService;
private final DeviceService deviceService;
/**
* 의심스러운 로그인 감지
*/
public boolean isSuspiciousLogin(User user, LoginRequest request) {
// 1. 새로운 위치에서의 로그인 확인
if (isNewLocation(user, getCurrentIpAddress())) {
return true;
}
// 2. 새로운 디바이스에서의 로그인 확인
if (isNewDevice(user, request.getDeviceId())) {
return true;
}
// 3. 비정상적인 시간대 로그인 확인
if (isUnusualTime(user)) {
return true;
}
return false;
}
private boolean isNewLocation(User user, String ipAddress) {
String country = geoLocationService.getCountryByIp(ipAddress);
// 사용자의 최근 로그인 국가들 조회
List<String> recentCountries = loginHistoryRepository
.findRecentCountriesByUserId(user.getId(), Duration.ofDays(30));
return !recentCountries.contains(country);
}
/**
* 2FA 필요 여부 판단
*/
public boolean requiresTwoFactorAuth(User user, LoginRequest request) {
return user.isTwoFactorEnabled() || isSuspiciousLogin(user, request);
}
}⚠️에러 처리 및 응답
글로벌 예외 처리
@RestControllerAdvice
@Slf4j
public class AuthExceptionHandler {
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException e) {
log.warn("Authentication failed: {}", e.getMessage());
ErrorResponse error = ErrorResponse.builder()
.error("INVALID_CREDENTIALS")
.message("Invalid username or password")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(AccountLockedException.class)
public ResponseEntity<ErrorResponse> handleAccountLocked(AccountLockedException e) {
ErrorResponse error = ErrorResponse.builder()
.error("ACCOUNT_LOCKED")
.message("Account is temporarily locked")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.LOCKED).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ValidationErrorResponse response = ValidationErrorResponse.builder()
.error("VALIDATION_FAILED")
.message("Request validation failed")
.fieldErrors(errors)
.timestamp(Instant.now())
.build();
return ResponseEntity.badRequest().body(response);
}
}5. Refresh Token 전략
🔄Access Token vs Refresh Token
Access Token
목적: API 요청 인증
수명: 짧음 (15분~1시간)
저장: 메모리 또는 sessionStorage
보안: 탈취 시 피해 최소화
특징:
- • 모든 API 요청에 포함
- • 짧은 만료시간으로 보안 강화
- • 자주 갱신됨
Refresh Token
목적: Access Token 갱신
수명: 김 (7일~30일)
저장: httpOnly 쿠키 또는 보안 저장소
보안: 서버에서 무효화 가능
특징:
- • 토큰 갱신 전용
- • 서버에서 관리 및 검증
- • 일회성 사용 (RTR 패턴)
토큰 갱신 플로우
로그인
토큰 발급
API 사용
토큰 갱신
⚙️Refresh Token 구현
Refresh Token 생성 및 저장
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RedisTemplate<String, Object> redisTemplate;
private final JwtTokenProvider tokenProvider;
private static final Duration REFRESH_TOKEN_VALIDITY = Duration.ofDays(7);
/**
* Refresh Token 생성 및 저장
*/
public String createAndStoreRefreshToken(Long userId) {
// 1. Refresh Token 생성
String refreshToken = tokenProvider.createRefreshToken(userId.toString());
// 2. Redis에 저장 (사용자별로 하나의 Refresh Token만 유지)
String key = "refresh_token:" + userId;
RefreshTokenInfo tokenInfo = RefreshTokenInfo.builder()
.token(refreshToken)
.userId(userId)
.createdAt(Instant.now())
.expiresAt(Instant.now().plus(REFRESH_TOKEN_VALIDITY))
.used(false)
.build();
redisTemplate.opsForValue().set(key, tokenInfo, REFRESH_TOKEN_VALIDITY);
return refreshToken;
}
/**
* Refresh Token 검증
*/
public boolean isValidRefreshToken(String token, Long userId) {
try {
// 1. JWT 토큰 자체 검증
Claims claims = tokenProvider.parseToken(token);
// 2. 토큰 타입 확인
if (!"refresh".equals(claims.get("type"))) {
return false;
}
// 3. Redis에서 저장된 토큰 정보 확인
String key = "refresh_token:" + userId;
RefreshTokenInfo tokenInfo = (RefreshTokenInfo) redisTemplate.opsForValue().get(key);
if (tokenInfo == null || tokenInfo.isUsed()) {
return false;
}
// 4. 토큰 일치 여부 확인
return token.equals(tokenInfo.getToken());
} catch (Exception e) {
log.error("Refresh token validation failed: {}", e.getMessage());
return false;
}
}
}토큰 갱신 API
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class TokenController {
private final RefreshTokenService refreshTokenService;
private final JwtTokenProvider tokenProvider;
private final UserService userService;
@PostMapping("/refresh")
public ResponseEntity<TokenRefreshResponse> refreshToken(
@Valid @RequestBody TokenRefreshRequest request) {
try {
// 1. Refresh Token에서 사용자 ID 추출
Claims claims = tokenProvider.parseToken(request.getRefreshToken());
Long userId = Long.valueOf(claims.getSubject());
// 2. Refresh Token 유효성 검증
if (!refreshTokenService.isValidRefreshToken(request.getRefreshToken(), userId)) {
throw new InvalidTokenException("Invalid refresh token");
}
// 3. 사용자 정보 조회
User user = userService.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
// 4. 새로운 Access Token 생성
String newAccessToken = tokenProvider.createAccessToken(
userId.toString(),
user.getRole().name()
);
// 5. RTR(Refresh Token Rotation) 적용 - 새로운 Refresh Token 생성
String newRefreshToken = refreshTokenService.rotateRefreshToken(
request.getRefreshToken(), userId
);
// 6. 응답 생성
TokenRefreshResponse response = TokenRefreshResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn(900) // 15분
.build();
return ResponseEntity.ok(response);
} catch (JwtException e) {
throw new InvalidTokenException("Invalid refresh token format");
}
}
}🔄RTR (Refresh Token Rotation)
RTR 구현
/**
* Refresh Token Rotation 구현
*/
public String rotateRefreshToken(String oldToken, Long userId) {
try {
// 1. 기존 토큰을 사용됨으로 표시
markTokenAsUsed(oldToken, userId);
// 2. 새로운 Refresh Token 생성
String newRefreshToken = createAndStoreRefreshToken(userId);
// 3. 토큰 패밀리 관리 (보안 강화)
manageTokenFamily(oldToken, newRefreshToken, userId);
return newRefreshToken;
} catch (Exception e) {
log.error("Token rotation failed for user {}: {}", userId, e.getMessage());
// 보안상 모든 Refresh Token 무효화
revokeAllRefreshTokens(userId);
throw new TokenRotationException("Token rotation failed");
}
}
/**
* 토큰을 사용됨으로 표시
*/
private void markTokenAsUsed(String token, Long userId) {
String key = "refresh_token:" + userId;
RefreshTokenInfo tokenInfo = (RefreshTokenInfo) redisTemplate.opsForValue().get(key);
if (tokenInfo != null && token.equals(tokenInfo.getToken())) {
tokenInfo.setUsed(true);
tokenInfo.setUsedAt(Instant.now());
// 사용된 토큰은 짧은 시간 동안만 보관 (재사용 감지용)
redisTemplate.opsForValue().set(key + ":used", tokenInfo, Duration.ofMinutes(5));
}
}
/**
* 토큰 패밀리 관리 (재사용 감지)
*/
private void manageTokenFamily(String oldToken, String newToken, Long userId) {
String familyKey = "token_family:" + userId;
// 토큰 패밀리에 새 토큰 추가
TokenFamily family = TokenFamily.builder()
.userId(userId)
.previousToken(oldToken)
.currentToken(newToken)
.createdAt(Instant.now())
.build();
redisTemplate.opsForValue().set(familyKey, family, REFRESH_TOKEN_VALIDITY);
}
/**
* 재사용 감지 및 처리
*/
public void detectAndHandleReuse(String token, Long userId) {
String usedKey = "refresh_token:" + userId + ":used";
RefreshTokenInfo usedToken = (RefreshTokenInfo) redisTemplate.opsForValue().get(usedKey);
if (usedToken != null && token.equals(usedToken.getToken())) {
log.warn("Refresh token reuse detected for user: {}", userId);
// 보안 위반: 모든 토큰 무효화
revokeAllRefreshTokens(userId);
// 보안 알림 발송
securityNotificationService.notifyTokenReuse(userId);
throw new SecurityViolationException("Token reuse detected");
}
}6. 예외 처리 및 에러 응답
⚠️JWT 예외 유형 및 처리
토큰 관련 예외
ExpiredJwtException
토큰이 만료된 경우
MalformedJwtException
토큰 형식이 잘못된 경우
SignatureException
서명 검증이 실패한 경우
인증 관련 예외
BadCredentialsException
잘못된 인증 정보
AccessDeniedException
접근 권한이 없는 경우
🏗️커스텀 예외 클래스 정의
JWT 관련 커스텀 예외
public class JwtAuthenticationException extends AuthenticationException {
private final String errorCode;
public JwtAuthenticationException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
}
public class TokenExpiredException extends JwtAuthenticationException {
public TokenExpiredException(String message) {
super(message, "TOKEN_EXPIRED");
}
}
public class InvalidTokenException extends JwtAuthenticationException {
public InvalidTokenException(String message) {
super(message, "INVALID_TOKEN");
}
}🛡️글로벌 예외 처리기
JWT 예외 처리기
@RestControllerAdvice
@Slf4j
public class JwtExceptionHandler {
@ExceptionHandler(TokenExpiredException.class)
public ResponseEntity<ErrorResponse> handleTokenExpired(
TokenExpiredException e, HttpServletRequest request) {
ErrorResponse error = ErrorResponse.builder()
.error(e.getErrorCode())
.message("Access token has expired")
.path(request.getRequestURI())
.status(HttpStatus.UNAUTHORIZED.value())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ErrorResponse> handleInvalidToken(
InvalidTokenException e, HttpServletRequest request) {
ErrorResponse error = ErrorResponse.builder()
.error(e.getErrorCode())
.message("Invalid or malformed token")
.path(request.getRequestURI())
.status(HttpStatus.UNAUTHORIZED.value())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
}📊에러 응답 DTO
ErrorResponse 클래스
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
private String error;
private String message;
private String path;
private Integer status;
private Instant timestamp;
private Object details;
public static ErrorResponse of(String error, String message) {
return ErrorResponse.builder()
.error(error)
.message(message)
.timestamp(Instant.now())
.build();
}
}7. 정리 및 실전 가이드
🔒JWT 보안 고려사항
⚠️ 보안 위험 요소
1. 토큰 탈취 (XSS)
JavaScript로 접근 가능한 저장소에 토큰을 저장하면 XSS 공격으로 탈취 가능
2. 토큰 재사용
만료된 Refresh Token의 재사용으로 인한 보안 위협
3. 민감 정보 노출
Payload에 민감한 정보를 포함하여 정보 유출 위험
4. 토큰 무효화 어려움
Stateless 특성으로 인한 즉시 토큰 무효화의 어려움
✅ 보안 강화 방안
1. 안전한 토큰 저장
httpOnly 쿠키 또는 메모리 저장, localStorage 사용 금지
2. 짧은 만료시간
Access Token은 15분 이내, 자동 갱신 메커니즘 구현
3. RTR 패턴 적용
Refresh Token Rotation으로 재사용 공격 방지
4. 블랙리스트 관리
Redis를 활용한 토큰 블랙리스트 및 즉시 무효화
🛡️ 보안 체크리스트
토큰 관리
- ✓강력한 비밀키 사용 (256비트 이상)
- ✓환경변수로 비밀키 관리
- ✓토큰 만료시간 적절히 설정
- ✓Refresh Token 순환 구현
클라이언트 보안
- ✓HTTPS 통신 강제
- ✓CSP 헤더 설정
- ✓XSS 방지 조치
- ✓CSRF 토큰 사용
🚀실전 구현 가이드
1. 프로젝트 설정
# application.yml
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: 900000 # 15분 (밀리초)
refresh-token-validity: 604800000 # 7일 (밀리초)
spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
security:
login-attempt:
max-attempts: 5
lock-duration: 900000 # 15분
logging:
level:
com.example.security: DEBUG
org.springframework.security: DEBUG2. 구현 순서
JWT 토큰 프로바이더 구현
토큰 생성, 파싱, 검증 로직 구현
JWT 필터 구현
OncePerRequestFilter를 상속한 인증 필터
인증 API 구현
로그인, 토큰 갱신, 로그아웃 엔드포인트
Refresh Token 관리
Redis를 활용한 토큰 저장 및 순환
예외 처리 구현
글로벌 예외 처리기 및 에러 응답
보안 강화 및 테스트
보안 검증, 단위 테스트, 통합 테스트
⚡성능 최적화 방안
캐싱 전략
토큰 검증 결과 캐싱
Redis를 활용하여 토큰 검증 결과를 5분간 캐싱
사용자 정보 캐싱
UserDetails 객체를 캐싱하여 DB 조회 최소화
권한 정보 캐싱
사용자 권한 정보를 메모리에 캐싱하여 빠른 접근
최적화 기법
비동기 처리
로그인 히스토리 저장 등을 비동기로 처리
Connection Pool
Redis 연결 풀 최적화로 성능 향상
토큰 압축
불필요한 클레임 제거로 토큰 크기 최소화
📊모니터링 및 운영 가이드
핵심 메트릭
인증 메트릭
- • 로그인 성공/실패 비율
- • 토큰 갱신 빈도
- • 계정 잠금 발생률
- • 평균 세션 지속 시간
성능 메트릭
- • 토큰 검증 응답 시간
- • Redis 연결 상태
- • 캐시 히트율
- • 메모리 사용량
보안 메트릭
- • 의심스러운 로그인 시도
- • 토큰 재사용 감지
- • 비정상적인 API 호출
- • 지역별 접근 패턴
운영 체크리스트
일일 점검
- □인증 실패율 확인
- □Redis 상태 점검
- □에러 로그 검토
- □성능 지표 모니터링
주간 점검
- □보안 이벤트 분석
- □토큰 만료 정책 검토
- □캐시 효율성 분석
- □사용자 피드백 검토
🎯핵심 요약
JWT 인증의 핵심
- • Stateless: 서버 확장성 향상
- • Self-contained: 토큰에 필요 정보 포함
- • Portable: 다양한 플랫폼에서 사용 가능
- • Secure: 적절한 보안 조치 시 안전
구현 시 주의사항
- • 보안: 토큰 저장 및 전송 보안
- • 성능: 캐싱 및 최적화 적용
- • 모니터링: 지속적인 보안 감시
- • 운영: 체계적인 관리 프로세스