@Service
public class JwtTokenService {
@Value("${jwt.secret}")
private String secretKey;
private final long ACCESS_TOKEN_VALIDITY = 3600000; // 1์๊ฐ
private final long REFRESH_TOKEN_VALIDITY = 604800000; // 7์ผ
// 1. ํ ํฐ ์์ฑ
public TokenPair generateTokenPair(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", userDetails.getUsername());
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
String accessToken = createToken(claims, ACCESS_TOKEN_VALIDITY);
String refreshToken = createRefreshToken(userDetails.getUsername());
return new TokenPair(accessToken, refreshToken);
}
private String createToken(Map<String, Object> claims, long validity) {
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + validity))
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
// 2. ํ ํฐ ๊ฒ์ฆ
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
// ๋ง๋ฃ ์๊ฐ ๊ฒ์ฆ
return !claims.getBody()
.getExpiration()
.before(new Date());
} catch (JwtException | IllegalArgumentException e) {
throw new InvalidTokenException("Invalid JWT token");
}
}
// 3. ํ ํฐ์์ ์ ๋ณด ์ถ์ถ
public Authentication getAuthentication(String token) {
Claims claims = extractClaims(token);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("roles").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(
claims.get("username").toString(),
"",
authorities);
return new UsernamePasswordAuthenticationToken(
principal,
"",
authorities);
}
}
@Component
public class TokenSecurityEnhancer {
private final RedisTemplate<String, String> redisTemplate;
// 1. ํ ํฐ ๋ธ๋๋ฆฌ์คํธ ๊ด๋ฆฌ
public class TokenBlacklistManager {
public void blacklistToken(String token, Claims claims) {
long expirationTime =
claims.getExpiration().getTime() -
System.currentTimeMillis();
redisTemplate.opsForValue().set(
"blacklist:" + token,
"blocked",
expirationTime,
TimeUnit.MILLISECONDS
);
}
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(
"blacklist:" + token));
}
}
// 2. ํ ํฐ Rotation
public class TokenRotationManager {
public TokenPair rotateTokens(String refreshToken) {
Claims claims = validateRefreshToken(refreshToken);
// ์ด์ ๋ฆฌํ๋ ์ ํ ํฐ ๋ฌดํจํ
invalidateRefreshToken(refreshToken);
// ์๋ก์ด ํ ํฐ ์ ์์ฑ
return generateNewTokenPair(claims.getSubject());
}
private void invalidateRefreshToken(String refreshToken) {
redisTemplate.delete("refresh_token:" + refreshToken);
}
}
// 3. ํ ํฐ ์ง๋ฌธ(Fingerprint)
public class TokenFingerprintManager {
public TokenWithFingerprint generateTokenWithFingerprint(
UserDetails user) {
String fingerprint = generateSecureFingerprint();
String fingerprintHash = hashFingerprint(fingerprint);
// ํ ํฐ์ ์ง๋ฌธ ํด์ ํฌํจ
Map<String, Object> claims = new HashMap<>();
claims.put("fph", fingerprintHash);
String token = generateToken(claims);
return new TokenWithFingerprint(token, fingerprint);
}
public boolean verifyFingerprint(
String token,
String providedFingerprint) {
Claims claims = extractClaims(token);
String storedHash = claims.get("fph", String.class);
String providedHash = hashFingerprint(providedFingerprint);
return storedHash.equals(providedHash);
}
}
}
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService tokenService;
private final TokenSecurityEnhancer securityEnhancer;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = extractToken(request);
if (token != null && tokenService.validateToken(token)) {
// ํ ํฐ ๋ธ๋๋ฆฌ์คํธ ํ์ธ
if (securityEnhancer.isBlacklisted(token)) {
throw new InvalidTokenException("Token is blacklisted");
}
// ํ ํฐ ์ง๋ฌธ ํ์ธ
String fingerprint = extractFingerprint(request);
if (!securityEnhancer.verifyFingerprint(token, fingerprint)) {
throw new InvalidTokenException(
"Invalid token fingerprint");
}
// ์ธ์ฆ ์ ๋ณด ์ค์
Authentication auth = tokenService.getAuthentication(token);
SecurityContextHolder.getContext()
.setAuthentication(auth);
}
} catch (JwtException e) {
SecurityContextHolder.clearContext();
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
e.getMessage());
return;
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) &&
bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@RestController
@RequestMapping("/api/auth")
public class TokenController {
private final JwtTokenService tokenService;
private final TokenSecurityEnhancer securityEnhancer;
// 1. ํ ํฐ ๊ฐฑ์
@PostMapping("/refresh")
public TokenResponse refreshToken(
@RequestBody RefreshTokenRequest request) {
try {
// ๋ฆฌํ๋ ์ ํ ํฐ ๊ฒ์ฆ
if (!tokenService.validateRefreshToken(
request.getRefreshToken())) {
throw new InvalidTokenException(
"Invalid refresh token");
}
// ์๋ก์ด ํ ํฐ ์ ์์ฑ
TokenPair newTokens = securityEnhancer
.rotateTokens(request.getRefreshToken());
return new TokenResponse(newTokens);
} catch (Exception e) {
throw new TokenRefreshException(
"Failed to refresh token", e);
}
}
// 2. ํ ํฐ ๋ฌดํจํ
@PostMapping("/logout")
public ResponseEntity<?> logout(
@RequestHeader("Authorization") String token) {
try {
String actualToken = token.substring(7); // "Bearer " ์ ๊ฑฐ
// ํ ํฐ ๋ธ๋๋ฆฌ์คํธ์ ์ถ๊ฐ
Claims claims = tokenService.extractClaims(actualToken);
securityEnhancer.blacklistToken(actualToken, claims);
// ๋ฆฌํ๋ ์ ํ ํฐ ๋ฌดํจํ
securityEnhancer.invalidateRefreshToken(
claims.getSubject());
return ResponseEntity.ok()
.body("Successfully logged out");
} catch (Exception e) {
return ResponseEntity.status(
HttpStatus.INTERNAL_SERVER_ERROR)
.body("Logout failed");
}
}
}
์ด๋ฌํ JWT ๊ธฐ๋ฐ ์ธ์ฆ ์์คํ ์ ํตํด:
-
ํ ํฐ ๊ด๋ฆฌ
- ์์ ํ ํ ํฐ ์์ฑ
- ์ ํจ์ฑ ๊ฒ์ฆ
- ์ ๋ณด ์ถ์ถ
-
๋ณด์ ๊ฐํ
- ํ ํฐ ๋ธ๋๋ฆฌ์คํธ
- ํ ํฐ ํ์
- ํ ํฐ ์ง๋ฌธ
-
์ธ์ฆ ํํฐ
- ์์ฒญ ์ธ์ฆ
- ํ ํฐ ๊ฒ์ฆ
- ๋ณด์ ์ปจํ ์คํธ ๊ด๋ฆฌ
-
ํ ํฐ ๊ฐฑ์
- ๋ฆฌํ๋ ์ ํ ํฐ ์ฒ๋ฆฌ
- ์์ ํ ๋ก๊ทธ์์
- ์์ธ ์ฒ๋ฆฌ
๋ฅผ ๊ตฌํํ ์ ์์ต๋๋ค.
๋ฉด์ ๊ด: Refresh Token์ ์ฌ์ฉํ ๋์ ๋ณด์ ๊ณ ๋ ค์ฌํญ์ ๋ฌด์์ธ๊ฐ์?
@Service
public class RefreshTokenSecurityService {
// 1. Refresh Token ์ ์ฅ ๋ฐ ๊ด๋ฆฌ
@Service
public class RefreshTokenStore {
private final RedisTemplate<String, RefreshTokenData> redisTemplate;
private final UserDeviceRepository deviceRepository;
public void storeRefreshToken(String userId,
RefreshTokenData tokenData) {
String key = generateRefreshTokenKey(userId, tokenData.getDeviceId());
// ๋๋ฐ์ด์ค๋ณ ํ ํฐ ๊ด๋ฆฌ
redisTemplate.opsForHash()
.put("refresh_tokens:" + userId,
tokenData.getDeviceId(),
tokenData);
// ํ ํฐ ๋ง๋ฃ ์ค์
redisTemplate.expire(
"refresh_tokens:" + userId,
30,
TimeUnit.DAYS
);
// ๋๋ฐ์ด์ค ์ ๋ณด ์ ์ฅ
saveDeviceInfo(userId, tokenData.getDeviceInfo());
}
public void validateDeviceAndToken(String userId,
String refreshToken,
DeviceInfo currentDevice) {
RefreshTokenData storedToken = getStoredToken(userId,
currentDevice.getDeviceId());
if (storedToken == null ||
!storedToken.getToken().equals(refreshToken)) {
throw new InvalidRefreshTokenException(
"Invalid refresh token for device");
}
// ๋๋ฐ์ด์ค ์ง๋ฌธ ๊ฒ์ฆ
if (!verifyDeviceFingerprint(
storedToken.getDeviceInfo(),
currentDevice)) {
throw new DeviceMismatchException(
"Device fingerprint mismatch");
}
}
}
// 2. ํ์ (Rotation) ์ ์ฑ
๊ตฌํ
@Service
public class TokenRotationPolicy {
private final RefreshTokenStore tokenStore;
private final SecurityEventPublisher eventPublisher;
public TokenPair rotateTokens(String userId,
String currentRefreshToken,
DeviceInfo deviceInfo) {
// ํ์ฌ ํ ํฐ ๊ฒ์ฆ
tokenStore.validateDeviceAndToken(
userId,
currentRefreshToken,
deviceInfo);
// ์ฌ์ฌ์ฉ ๊ฐ์ง
if (isTokenReused(userId, currentRefreshToken)) {
handleTokenReuse(userId);
throw new TokenReuseException(
"Refresh token reuse detected");
}
// ์ ํ ํฐ ์ ์์ฑ
TokenPair newTokens = generateNewTokenPair(userId);
// Refresh Token ์ ์ฅ
tokenStore.storeRefreshToken(userId,
RefreshTokenData.builder()
.token(newTokens.getRefreshToken())
.deviceId(deviceInfo.getDeviceId())
.deviceInfo(deviceInfo)
.issuedAt(Instant.now())
.build()
);
// ์ด์ ํ ํฐ ๋ฌดํจํ ์ด๋ ฅ ์ ์ฅ
markTokenAsUsed(currentRefreshToken);
return newTokens;
}
private boolean isTokenReused(String userId, String refreshToken) {
return redisTemplate.opsForSet()
.isMember("used_tokens:" + userId, refreshToken);
}
private void handleTokenReuse(String userId) {
// ๋ชจ๋ ๋ฆฌํ๋ ์ ํ ํฐ ๋ฌดํจํ
tokenStore.revokeAllTokens(userId);
// ๋ณด์ ์ด๋ฒคํธ ๋ฐํ
eventPublisher.publishSecurityEvent(
new TokenReuseEvent(userId));
}
}
// 3. ํ ํฐ ์ถ์ ๋ฐ ๊ฐ์ฌ
@Service
public class TokenAuditService {
private final AuditEventRepository auditRepository;
@Async
public void auditTokenUsage(TokenUsageEvent event) {
AuditEvent auditEvent = AuditEvent.builder()
.userId(event.getUserId())
.deviceId(event.getDeviceInfo().getDeviceId())
.action(event.getAction())
.tokenId(event.getTokenId())
.timestamp(Instant.now())
.ipAddress(event.getIpAddress())
.userAgent(event.getUserAgent())
.build();
auditRepository.save(auditEvent);
}
public void detectSuspiciousActivity(String userId) {
List<AuditEvent> recentEvents =
auditRepository.findRecentByUserId(
userId,
Duration.ofHours(24)
);
// ์์ฌ์ค๋ฌ์ด ํจํด ๋ถ์
if (hasMultipleDeviceAccess(recentEvents) ||
hasUnusualGeographicalAccess(recentEvents)) {
notifySecurityTeam(userId, recentEvents);
}
}
}
// 4. ์๋ ํ ํฐ ์ ๋ฆฌ
@Service
public class TokenCleanupService {
@Scheduled(cron = "0 0 * * * *") // ๋งค์๊ฐ ์คํ
public void cleanupExpiredTokens() {
// ๋ง๋ฃ๋ ๋ฆฌํ๋ ์ ํ ํฐ ์ญ์
Set<String> expiredUserIds = findUsersWithExpiredTokens();
for (String userId : expiredUserIds) {
cleanupUserTokens(userId);
}
}
@Scheduled(cron = "0 0 0 * * *") // ๋งค์ผ ์คํ
public void cleanupUnusedTokens() {
// 30์ผ ์ด์ ๋ฏธ์ฌ์ฉ ํ ํฐ ์ ๋ฆฌ
LocalDateTime cutoffDate =
LocalDateTime.now().minusDays(30);
List<RefreshTokenData> unusedTokens =
findUnusedTokens(cutoffDate);
for (RefreshTokenData token : unusedTokens) {
revokeToken(token);
notifyUser(token.getUserId(),
"Unused refresh token revoked");
}
}
}
}
์ด๋ฌํ Refresh Token ๋ณด์ ์ ๋ต์ ํตํด:
-
์์ ํ ํ ํฐ ์ ์ฅ
- Redis ๊ธฐ๋ฐ ํ ํฐ ์ ์ฅ
- ๋๋ฐ์ด์ค๋ณ ํ ํฐ ๊ด๋ฆฌ
- ๋ง๋ฃ ์๊ฐ ์ค์
-
ํ ํฐ ํ์ ์ ์ฑ
- ์ฌ์ฌ์ฉ ๊ฐ์ง
- ์๋ ๋ฌดํจํ
- ๋๋ฐ์ด์ค ๊ฒ์ฆ
-
๊ฐ์ฌ ๋ฐ ๋ชจ๋ํฐ๋ง
- ์ฌ์ฉ ๊ธฐ๋ก ์ถ์
- ์์ฌ ํ๋ ๊ฐ์ง
- ๋ณด์ ์ด๋ฒคํธ ์ฒ๋ฆฌ
-
์๋ํ๋ ๊ด๋ฆฌ
- ๋ง๋ฃ ํ ํฐ ์ ๋ฆฌ
- ๋ฏธ์ฌ์ฉ ํ ํฐ ์ ๊ฑฐ
- ์ฌ์ฉ์ ์๋ฆผ
ํนํ ์ค์ํ ๋ณด์ ๊ณ ๋ ค์ฌํญ:
- ๋๋ฐ์ด์ค ๋ฐ์ธ๋ฉ
- ํ ํฐ ์ฌ์ฌ์ฉ ๋ฐฉ์ง
- ์ด์ ์งํ ๊ฐ์ง
- ์๋ ํด๋ฆฐ์
์ด๋ฅผ ํตํด Refresh Token์ ์์ ํ ๊ด๋ฆฌ์ ๋ณด์ ์ํ ๋์์ด ๊ฐ๋ฅํฉ๋๋ค.
// TokenService.ts
export class TokenService {
private static readonly ACCESS_TOKEN_KEY = 'access_token';
private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
// 1. ๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ฐ ์ก์ธ์ค ํ ํฐ ๊ด๋ฆฌ
private static accessToken: string | null = null;
// 2. HttpOnly ์ฟ ํค๋ฅผ ์ํ ์ธํฐํ์ด์ค
public static storeTokens(tokens: TokenResponse) {
// Access Token์ ๋ฉ๋ชจ๋ฆฌ์ ์ ์ฅ
this.accessToken = tokens.accessToken;
// Refresh Token์ HttpOnly ์ฟ ํค๋ก ์ ์ฅ (์๋ฒ์ ์์ฒญ)
this.storeRefreshToken(tokens.refreshToken);
}
private static storeRefreshToken(refreshToken: string) {
// ์๋ฒ์ ์ฟ ํค ์ค์ ์์ฒญ
axios.post('/api/auth/cookie', { refreshToken }, {
withCredentials: true // CORS ์ค์ ํ์
});
}
// 3. ํ ํฐ ์ํธํ ์ ์ฅ (ํ์ํ ๊ฒฝ์ฐ)
private static encryptToken(token: string): string {
const encoder = new TextEncoder();
const data = encoder.encode(token);
return window.btoa(String.fromCharCode(...new Uint8Array(data)));
}
}
// ApiInterceptor.ts
export class ApiInterceptor {
private static instance: AxiosInstance;
public static setup() {
this.instance = axios.create({
baseURL: process.env.API_BASE_URL,
timeout: 10000,
withCredentials: true
});
this.setupInterceptors();
}
private static setupInterceptors() {
// 1. ์์ฒญ ์ธํฐ์
ํฐ
this.instance.interceptors.request.use(
async config => {
const token = TokenService.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// CSRF ํ ํฐ ์ถ๊ฐ
const csrfToken = CsrfTokenService.getToken();
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
return config;
},
error => Promise.reject(error)
);
// 2. ์๋ต ์ธํฐ์
ํฐ
this.instance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
// ํ ํฐ ๊ฐฑ์ ์๋
const newTokens =
await TokenService.refreshTokens();
TokenService.storeTokens(newTokens);
// ์๋ ์์ฒญ ์ฌ์๋
return this.instance(originalRequest);
} catch (refreshError) {
// ๋ฆฌํ๋ ์ ์คํจ ์ ๋ก๊ทธ์์
await AuthService.logout();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
}
// SecurityUtils.ts
export class SecurityUtils {
// 1. XSS ๋ฐฉ์ง
public static sanitizeInput(input: string): string {
const div = document.createElement('div');
div.textContent = input;
return div.innerHTML;
}
// 2. ๋๋ฐ์ด์ค ์ง๋ฌธ
public static async generateDeviceFingerprint(): Promise<string> {
const components = [
navigator.userAgent,
navigator.language,
new Date().getTimezoneOffset(),
screen.width,
screen.height,
navigator.hardwareConcurrency,
// Canvas ์ง๋ฌธ
await this.getCanvasFingerprint(),
// WebGL ์ง๋ฌธ
await this.getWebGLFingerprint()
];
// ํด์ ์์ฑ
const fingerprint = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(components.join(''))
);
return Array.from(new Uint8Array(fingerprint))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// 3. CSRF ๋ณดํธ
public static setupCsrfProtection() {
const token = document.querySelector(
'meta[name="csrf-token"]'
)?.getAttribute('content');
if (token) {
CsrfTokenService.setToken(token);
}
}
}
// AuthContext.tsx
interface AuthContextType {
isAuthenticated: boolean;
user: User | null;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
refreshAuth: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider: React.FC = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
// 1. ์ธ์ฆ ์ํ ์ด๊ธฐํ
useEffect(() => {
const initializeAuth = async () => {
try {
const token = TokenService.getAccessToken();
if (token) {
await refreshAuth();
}
} catch (error) {
await logout();
}
};
initializeAuth();
}, []);
// 2. ๋ก๊ทธ์ธ ์ฒ๋ฆฌ
const login = async (credentials: LoginCredentials) => {
try {
// ๋๋ฐ์ด์ค ์ง๋ฌธ ์์ฑ
const deviceFingerprint =
await SecurityUtils.generateDeviceFingerprint();
const response = await axios.post('/api/auth/login', {
...credentials,
deviceFingerprint
});
TokenService.storeTokens(response.data.tokens);
setUser(response.data.user);
setIsAuthenticated(true);
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
// 3. ๋ก๊ทธ์์ ์ฒ๋ฆฌ
const logout = async () => {
try {
await axios.post('/api/auth/logout');
} finally {
TokenService.clearTokens();
setUser(null);
setIsAuthenticated(false);
}
};
// 4. ์๋ ํ ํฐ ๊ฐฑ์
const refreshAuth = async () => {
try {
const deviceFingerprint =
await SecurityUtils.generateDeviceFingerprint();
const response = await axios.post('/api/auth/refresh', {
deviceFingerprint
}, {
withCredentials: true
});
TokenService.storeTokens(response.data.tokens);
setUser(response.data.user);
setIsAuthenticated(true);
} catch (error) {
throw error;
}
};
return (
<AuthContext.Provider value={{
isAuthenticated,
user,
login,
logout,
refreshAuth
}}>
{children}
</AuthContext.Provider>
);
};
์ด๋ฌํ ํด๋ผ์ด์ธํธ ์ธก ๋ณด์ ์ ๋ต์ ํตํด:
-
ํ ํฐ ๋ณด์ ์ ์ฅ
- Access Token์ ๋ฉ๋ชจ๋ฆฌ์ ์ ์ฅ
- Refresh Token์ HttpOnly ์ฟ ํค๋ก ๊ด๋ฆฌ
- ํ์์ ์ถ๊ฐ ์ํธํ
-
์์ฒญ/์๋ต ๋ณด์
- ์๋ ํ ํฐ ๊ฐฑ์
- CSRF ๋ณดํธ
- ์๋ฌ ์ฒ๋ฆฌ
-
๋๋ฐ์ด์ค ๋ณด์
- ๋๋ฐ์ด์ค ์ง๋ฌธ ์์ฑ
- XSS ๋ฐฉ์ง
- ์ ๋ ฅ ๊ฒ์ฆ
-
์ํ ๊ด๋ฆฌ
- ์์ ํ ์ธ์ฆ ์ํ ๊ด๋ฆฌ
- ์๋ ๋ก๊ทธ์ธ/๋ก๊ทธ์์
- ํ ํฐ ๋ง๋ฃ ์ฒ๋ฆฌ
๋ฅผ ๊ตฌํํ ์ ์์ต๋๋ค.