OAuth 2.1은 OAuth 2.0의 보안 취약점을 보완하고 현대적인 웹 애플리케이션 환경에 최적화된 인증 프로토콜입니다.
기존 OAuth 2.0에서 발견된 보안 이슈들을 해결하고, 더욱 안전하고 효율적인 인증 시스템 구축을 위한 새로운 표준을 제시합니다.
이 글에서는 OAuth 2.1의 주요 변화점과 스프링 시큐리티를 활용한 실제 구현 방법을 상세히 알아보겠습니다.
OAuth 2.1이란? 기존 OAuth 2.0과의 차이점 분석
OAuth 2.1은 2023년에 발표된 OAuth 2.0의 개선된 버전으로, 보안성과 사용성을 크게 향상시켰습니다.
가장 중요한 변화는 보안 취약점으로 지적받던 Implicit Grant 방식의 완전한 제거입니다.
주요 변화점 요약
제거된 기능들:
- Implicit Grant 방식 완전 제거
- Resource Owner Password Credentials Grant 제거
- Bearer Token의 URI 파라미터 전송 방식 제거
강화된 보안 기능들:
- PKCE(Proof Key for Code Exchange) 의무화
- 리프레시 토큰 회전(Refresh Token Rotation) 권장
- 클라이언트 인증 강화
새로운 권장사항들:
- Authorization Code Grant with PKCE 중심 사용
- 보안 헤더 강화
- 토큰 수명 단축 권장
OAuth 2.1 보안 강화 포인트와 PKCE 적용 방법
OAuth 2.1에서 가장 핵심적인 보안 강화 요소는 PKCE(Proof Key for Code Exchange)의 의무적 적용입니다.
PKCE는 Authorization Code Interception Attack을 방지하기 위해 설계된 확장 기능으로,
모든 OAuth 2.1 클라이언트에서 필수적으로 구현해야 합니다.
PKCE 동작 원리
// PKCE Code Verifier 생성
public class PKCEUtil {
public static String generateCodeVerifier() {
SecureRandom secureRandom = new SecureRandom();
byte[] codeVerifier = new byte[32];
secureRandom.nextBytes(codeVerifier);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(codeVerifier);
}
public static String generateCodeChallenge(String codeVerifier) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not available", e);
}
}
}
PKCE 구현 시 클라이언트는 code_verifier를 생성하고, 이를 SHA-256으로 해시한 code_challenge를 인증 서버에 전송합니다.
인증 서버는 인증 코드와 함께 원본 code_verifier를 요구하여 공격자의 코드 가로채기를 방지합니다.
스프링 시큐리티 6.x에서 OAuth 2.1 구현하기
스프링 시큐리티 6.x 버전은 OAuth 2.1 표준을 완벽하게 지원하며,
간단한 설정만으로도 안전한 OAuth 2.1 인증 시스템을 구축할 수 있습니다.
기본 설정 및 의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
OAuth 2.1 클라이언트 설정
@Configuration
@EnableWebSecurity
public class OAuth21SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.clientRegistrationRepository(clientRegistrationRepository())
.authorizedClientService(authorizedClientService())
)
.oauth2Client(oauth2 -> oauth2
.clientRegistrationRepository(clientRegistrationRepository())
.authorizedClientRepository(authorizedClientRepository())
);
return http.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
ClientRegistration googleRegistration = ClientRegistration.withRegistrationId("google")
.clientId("your-google-client-id")
.clientSecret("your-google-client-secret")
.scope("openid", "profile", "email")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://oauth2.googleapis.com/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName("sub")
.clientName("Google OAuth 2.1")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.build();
return new InMemoryClientRegistrationRepository(googleRegistration);
}
}
Authorization Code Grant with PKCE 실제 구현 예제
OAuth 2.1에서 권장하는 Authorization Code Grant with PKCE 방식의 완전한 구현 예제를 살펴보겠습니다.
커스텀 OAuth 2.1 인증 서버 구성
@Configuration
@EnableAuthorizationServer
public class OAuth21AuthorizationServerConfig {
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oauth21-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:8080/login/oauth2/code/oauth21-client")
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(true) // PKCE 필수 설정
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofHours(8))
.reuseRefreshTokens(false) // 리프레시 토큰 회전 활성화
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static KeyPair generateRsaKey() {
KeyPairGenerator keyPairGenerator;
try {
keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
클라이언트 측 PKCE 구현
@RestController
@RequestMapping("/oauth2")
public class OAuth21ClientController {
private final OAuth2AuthorizedClientService authorizedClientService;
private final ClientRegistrationRepository clientRegistrationRepository;
public OAuth21ClientController(
OAuth2AuthorizedClientService authorizedClientService,
ClientRegistrationRepository clientRegistrationRepository) {
this.authorizedClientService = authorizedClientService;
this.clientRegistrationRepository = clientRegistrationRepository;
}
@GetMapping("/login")
public ResponseEntity<Map<String, String>> initiateLogin() {
String codeVerifier = PKCEUtil.generateCodeVerifier();
String codeChallenge = PKCEUtil.generateCodeChallenge(codeVerifier);
// 세션에 code_verifier 저장 (실제 환경에서는 보안 저장소 사용)
HttpSession session = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest().getSession();
session.setAttribute("code_verifier", codeVerifier);
ClientRegistration clientRegistration =
clientRegistrationRepository.findByRegistrationId("oauth21-client");
String authorizationUri = UriComponentsBuilder
.fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri())
.queryParam("response_type", "code")
.queryParam("client_id", clientRegistration.getClientId())
.queryParam("scope", String.join(" ", clientRegistration.getScopes()))
.queryParam("state", generateState())
.queryParam("redirect_uri", clientRegistration.getRedirectUri())
.queryParam("code_challenge", codeChallenge)
.queryParam("code_challenge_method", "S256")
.build()
.toUriString();
Map<String, String> response = new HashMap<>();
response.put("authorization_uri", authorizationUri);
return ResponseEntity.ok(response);
}
@GetMapping("/callback")
public ResponseEntity<Map<String, Object>> handleCallback(
@RequestParam String code,
@RequestParam String state,
HttpServletRequest request) {
String codeVerifier = (String) request.getSession().getAttribute("code_verifier");
if (codeVerifier == null) {
throw new IllegalStateException("Code verifier not found in session");
}
// 토큰 교환 로직
Map<String, Object> tokenResponse = exchangeCodeForToken(code, codeVerifier);
return ResponseEntity.ok(tokenResponse);
}
private Map<String, Object> exchangeCodeForToken(String code, String codeVerifier) {
ClientRegistration clientRegistration =
clientRegistrationRepository.findByRegistrationId("oauth21-client");
WebClient webClient = WebClient.create();
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "authorization_code");
formData.add("code", code);
formData.add("redirect_uri", clientRegistration.getRedirectUri());
formData.add("code_verifier", codeVerifier);
formData.add("client_id", clientRegistration.getClientId());
formData.add("client_secret", clientRegistration.getClientSecret());
return webClient.post()
.uri(clientRegistration.getProviderDetails().getTokenUri())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(Map.class)
.block();
}
private String generateState() {
return UUID.randomUUID().toString();
}
}
리프레시 토큰 회전(Refresh Token Rotation) 구현
OAuth 2.1에서는 보안 강화를 위해 리프레시 토큰 회전을 권장합니다.
이는 리프레시 토큰을 사용할 때마다 새로운 리프레시 토큰을 발급하여 토큰 탈취 위험을 최소화합니다.
리프레시 토큰 회전 서비스 구현
@Service
public class OAuth21TokenService {
private final JdbcTemplate jdbcTemplate;
private final JWKSource<SecurityContext> jwkSource;
public OAuth21TokenService(JdbcTemplate jdbcTemplate,
JWKSource<SecurityContext> jwkSource) {
this.jdbcTemplate = jdbcTemplate;
this.jwkSource = jwkSource;
}
public TokenResponse refreshToken(String refreshToken, String clientId) {
// 기존 리프레시 토큰 검증
if (!isValidRefreshToken(refreshToken, clientId)) {
throw new OAuth2AuthenticationException("Invalid refresh token");
}
// 기존 토큰 무효화
revokeRefreshToken(refreshToken);
// 새로운 액세스 토큰 및 리프레시 토큰 생성
String newAccessToken = generateAccessToken(clientId);
String newRefreshToken = generateRefreshToken(clientId);
// 새로운 리프레시 토큰 저장
storeRefreshToken(newRefreshToken, clientId);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn(1800) // 30분
.build();
}
private boolean isValidRefreshToken(String refreshToken, String clientId) {
String sql = "SELECT COUNT(*) FROM oauth2_refresh_tokens " +
"WHERE token = ? AND client_id = ? AND revoked = false " +
"AND expires_at > NOW()";
int count = jdbcTemplate.queryForObject(sql, Integer.class, refreshToken, clientId);
return count > 0;
}
private void revokeRefreshToken(String refreshToken) {
String sql = "UPDATE oauth2_refresh_tokens SET revoked = true " +
"WHERE token = ?";
jdbcTemplate.update(sql, refreshToken);
}
private void storeRefreshToken(String refreshToken, String clientId) {
String sql = "INSERT INTO oauth2_refresh_tokens " +
"(token, client_id, expires_at, revoked) " +
"VALUES (?, ?, ?, ?)";
Timestamp expiresAt = Timestamp.from(
Instant.now().plus(Duration.ofHours(8))
);
jdbcTemplate.update(sql, refreshToken, clientId, expiresAt, false);
}
private String generateAccessToken(String clientId) {
// JWT 액세스 토큰 생성 로직
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://your-auth-server.com")
.subject(clientId)
.audience(Arrays.asList("resource-server"))
.issuedAt(now)
.expiresAt(now.plus(Duration.ofMinutes(30)))
.claim("scope", "read write")
.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build();
JwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource);
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
private String generateRefreshToken(String clientId) {
return UUID.randomUUID().toString() + "-" + clientId;
}
}
OAuth 2.1 보안 모범 사례와 취약점 대응 방안
OAuth 2.1 구현 시 반드시 고려해야 할 보안 모범 사례들을 정리하면 다음과 같습니다.
1. 토큰 보안 강화
@Configuration
public class OAuth21SecurityConfiguration {
@Bean
public TokenSettings tokenSettings() {
return TokenSettings.builder()
// 액세스 토큰 수명 단축 (15-30분 권장)
.accessTokenTimeToLive(Duration.ofMinutes(15))
// 리프레시 토큰 수명 설정 (최대 24시간 권장)
.refreshTokenTimeToLive(Duration.ofHours(8))
// 리프레시 토큰 재사용 방지
.reuseRefreshTokens(false)
// ID 토큰 수명 설정
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("https://*.yourdomain.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/oauth2/**", configuration);
return source;
}
}
2. 클라이언트 인증 강화
@Component
public class EnhancedClientAuthenticationProvider {
private final RegisteredClientRepository clientRepository;
private final PasswordEncoder passwordEncoder;
public EnhancedClientAuthenticationProvider(
RegisteredClientRepository clientRepository,
PasswordEncoder passwordEncoder) {
this.clientRepository = clientRepository;
this.passwordEncoder = passwordEncoder;
}
public boolean authenticateClient(String clientId, String clientSecret,
String clientAssertionType, String clientAssertion) {
RegisteredClient registeredClient = clientRepository.findByClientId(clientId);
if (registeredClient == null) {
return false;
}
// JWT 클라이언트 인증을 위한 private_key_jwt 방식 지원
if ("urn:ietf:params:oauth:client-assertion-type:jwt-bearer".equals(clientAssertionType)) {
return validateJwtClientAssertion(clientAssertion, registeredClient);
}
// 기본 클라이언트 시크릿 인증
if (clientSecret != null) {
return passwordEncoder.matches(clientSecret, registeredClient.getClientSecret());
}
return false;
}
private boolean validateJwtClientAssertion(String clientAssertion,
RegisteredClient registeredClient) {
try {
// JWT 클라이언트 어설션 검증 로직
JwtDecoder jwtDecoder = createJwtDecoderForClient(registeredClient);
Jwt jwt = jwtDecoder.decode(clientAssertion);
// 클레임 검증
String subject = jwt.getSubject();
String issuer = jwt.getIssuer().toString();
List<String> audience = jwt.getAudience();
return registeredClient.getClientId().equals(subject) &&
registeredClient.getClientId().equals(issuer) &&
audience.contains("https://your-auth-server.com");
} catch (Exception e) {
return false;
}
}
private JwtDecoder createJwtDecoderForClient(RegisteredClient registeredClient) {
// 클라이언트별 공개키로 JWT 디코더 생성
// 실제 구현에서는 클라이언트의 공개키를 사용
return NimbusJwtDecoder.withJwkSetUri(
registeredClient.getClientSettings().getJwkSetUrl()
).build();
}
}
3. 스코프 기반 접근 제어
@RestController
@RequestMapping("/api")
public class ProtectedResourceController {
@GetMapping("/user/profile")
@PreAuthorize("hasAuthority('SCOPE_profile')")
public ResponseEntity<UserProfile> getUserProfile(Authentication authentication) {
JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication;
String userId = jwtToken.getToken().getSubject();
UserProfile profile = userService.getUserProfile(userId);
return ResponseEntity.ok(profile);
}
@PostMapping("/user/update")
@PreAuthorize("hasAuthority('SCOPE_write') and hasAuthority('SCOPE_profile')")
public ResponseEntity<String> updateUserProfile(
@RequestBody UserProfileUpdateRequest request,
Authentication authentication) {
JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication;
String userId = jwtToken.getToken().getSubject();
userService.updateUserProfile(userId, request);
return ResponseEntity.ok("Profile updated successfully");
}
@GetMapping("/admin/users")
@PreAuthorize("hasAuthority('SCOPE_admin')")
public ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
}
마이그레이션 가이드: OAuth 2.0에서 OAuth 2.1로 전환하기
기존 OAuth 2.0 시스템을 OAuth 2.1로 마이그레이션하는 체계적인 접근 방법을 제시합니다.
1단계: 현재 시스템 분석 및 계획 수립
@Component
public class OAuth20to21MigrationAnalyzer {
public MigrationReport analyzeMigrationRequirements(OAuth20Configuration currentConfig) {
MigrationReport report = new MigrationReport();
// Implicit Grant 사용 여부 확인
if (currentConfig.hasImplicitGrant()) {
report.addCriticalIssue(
"Implicit Grant detected",
"Must migrate to Authorization Code Grant with PKCE"
);
}
// ROPC Grant 사용 여부 확인
if (currentConfig.hasResourceOwnerPasswordCredentialsGrant()) {
report.addCriticalIssue(
"ROPC Grant detected",
"Must migrate to Authorization Code Grant or Client Credentials Grant"
);
}
// PKCE 미적용 확인
if (!currentConfig.hasPKCE()) {
report.addRequiredChange(
"PKCE not implemented",
"Must implement PKCE for all public clients"
);
}
// 토큰 수명 확인
if (currentConfig.getAccessTokenLifetime().toMinutes() > 60) {
report.addRecommendation(
"Access token lifetime too long",
"Consider reducing to 15-30 minutes"
);
}
return report;
}
}
2단계: 점진적 마이그레이션 구현
@Configuration
@ConditionalOnProperty(name = "oauth.migration.enabled", havingValue = "true")
public class OAuth21MigrationConfig {
@Bean
@Primary
public AuthorizationServerSettings migrationAwareAuthorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://your-auth-server.com")
// 기존 엔드포인트와 새로운 엔드포인트 모두 지원
.authorizationEndpoint("/oauth2/authorize")
.tokenEndpoint("/oauth2/token")
.tokenIntrospectionEndpoint("/oauth2/introspect")
.tokenRevocationEndpoint("/oauth2/revoke")
.jwkSetEndpoint("/oauth2/jwks")
.oidcLogoutEndpoint("/oauth2/logout")
.oidcUserInfoEndpoint("/oauth2/userinfo")
.oidcClientRegistrationEndpoint("/oauth2/register")
.build();
}
@Bean
public RegisteredClientRepository dualSupportClientRepository() {
// OAuth 2.0과 2.1 클라이언트 모두 지원
List<RegisteredClient> clients = Arrays.asList(
// 기존 OAuth 2.0 클라이언트 (마이그레이션 중)
RegisteredClient.withId("legacy-client")
.clientId("legacy-app")
.clientSecret("{bcrypt}$2a$10$...")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://legacy-app.com/callback")
.scope("read", "write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(false)
.requireProofKey(false) // 임시로 PKCE 선택사항
.build())
.build(),
// 새로운 OAuth 2.1 클라이언트
RegisteredClient.withId("oauth21-client")
.clientId("modern-app")
.clientSecret("{bcrypt}$2a$10$...")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://modern-app.com/callback")
.scope("read", "write", "profile")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(true) // PKCE 필수
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofHours(2))
.reuseRefreshTokens(false)
.build())
.build()
);
return new InMemoryRegisteredClientRepository(clients);
}
}
3단계: 클라이언트 애플리케이션 업데이트
// 기존 Implicit Grant 방식 (제거 예정)
function legacyImplicitLogin() {
window.location.href =
'https://auth-server.com/oauth2/authorize?' +
'response_type=token&' +
'client_id=legacy-app&' +
'redirect_uri=https://app.com/callback&' +
'scope=read write';
}
// 새로운 OAuth 2.1 Authorization Code with PKCE 방식
async function oauth21Login() {
// PKCE 파라미터 생성
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// 세션 스토리지에 code_verifier 저장
sessionStorage.setItem('code_verifier', codeVerifier);
// 인증 요청
const authUrl = new URL('https://auth-server.com/oauth2/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'modern-app');
authUrl.searchParams.set('redirect_uri', 'https://app.com/callback');
authUrl.searchParams.set('scope', 'read write profile');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateState());
window.location.href = authUrl.toString();
}
// 콜백 처리
async function handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
if (!code) {
throw new Error('Authorization code not received');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
// 토큰 교환
const tokenResponse = await fetch('https://auth-server.com/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://app.com/callback',
client_id: 'modern-app',
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
// 토큰 안전하게 저장
secureTokenStorage.setTokens(tokens);
// code_verifier 제거
sessionStorage.removeItem('code_verifier');
}
// PKCE 유틸리티 함수들
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(digest));
}
function base64URLEncode(array) {
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function generateState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
OAuth 2.1 성능 최적화 및 모니터링 전략
OAuth 2.1 시스템의 안정적인 운영을 위한 성능 최적화와 모니터링 방안을 살펴보겠습니다.
JWT 토큰 최적화 및 캐싱 전략
@Service
public class OptimizedJwtTokenService {
private final RedisTemplate<String, Object> redisTemplate;
private final JWKSource<SecurityContext> jwkSource;
private final LoadingCache<String, JwtDecoder> jwtDecoderCache;
public OptimizedJwtTokenService(RedisTemplate<String, Object> redisTemplate,
JWKSource<SecurityContext> jwkSource) {
this.redisTemplate = redisTemplate;
this.jwkSource = jwkSource;
// JWT 디코더 캐시 설정
this.jwtDecoderCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofHours(1))
.build(this::createJwtDecoder);
}
public String generateOptimizedAccessToken(String clientId, Set<String> scopes,
String userId) {
Instant now = Instant.now();
// 최소한의 클레임으로 JWT 크기 최적화
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer("https://your-auth-server.com")
.subject(userId)
.audience(Arrays.asList("api-server"))
.issuedAt(now)
.expiresAt(now.plus(Duration.ofMinutes(15)))
.id(UUID.randomUUID().toString());
// 스코프가 많은 경우 해시값으로 대체
if (scopes.size() > 5) {
String scopeHash = DigestUtils.sha256Hex(String.join(",", scopes));
claimsBuilder.claim("scp_hash", scopeHash);
// Redis에 스코프 정보 캐싱
redisTemplate.opsForValue().set(
"scope:" + scopeHash,
scopes,
Duration.ofMinutes(20)
);
} else {
claimsBuilder.claim("scope", String.join(" ", scopes));
}
JwtClaimsSet claims = claimsBuilder.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256)
.keyId(getActiveKeyId())
.build();
JwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource);
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims))
.getTokenValue();
}
@Cacheable(value = "jwt-validation", key = "#token.hashCode()")
public boolean validateToken(String token) {
try {
JwtDecoder decoder = jwtDecoderCache.get("default");
Jwt jwt = decoder.decode(token);
// 토큰 블랙리스트 확인
String jti = jwt.getId();
Boolean isBlacklisted = (Boolean) redisTemplate.opsForValue()
.get("blacklist:" + jti);
return !Boolean.TRUE.equals(isBlacklisted);
} catch (Exception e) {
return false;
}
}
public Set<String> resolveScopes(Jwt jwt) {
// 일반 스코프 클레임 확인
String scopeClaim = jwt.getClaimAsString("scope");
if (scopeClaim != null) {
return Set.of(scopeClaim.split(" "));
}
// 해시된 스코프 클레임 확인
String scopeHash = jwt.getClaimAsString("scp_hash");
if (scopeHash != null) {
@SuppressWarnings("unchecked")
Set<String> cachedScopes = (Set<String>) redisTemplate.opsForValue()
.get("scope:" + scopeHash);
return cachedScopes != null ? cachedScopes : Collections.emptySet();
}
return Collections.emptySet();
}
private JwtDecoder createJwtDecoder(String key) {
return NimbusJwtDecoder.withJwkSource(jwkSource).build();
}
private String getActiveKeyId() {
// 키 로테이션을 고려한 활성 키 ID 반환
return "active-key-2024";
}
}
실시간 모니터링 및 알림 시스템
@Component
public class OAuth21MonitoringService {
private final MeterRegistry meterRegistry;
private final NotificationService notificationService;
private final Counter tokenGenerationCounter;
private final Counter tokenValidationCounter;
private final Counter authenticationFailureCounter;
private final Timer tokenGenerationTimer;
private final Gauge activeSessionsGauge;
public OAuth21MonitoringService(MeterRegistry meterRegistry,
NotificationService notificationService) {
this.meterRegistry = meterRegistry;
this.notificationService = notificationService;
// 메트릭 초기화
this.tokenGenerationCounter = Counter.builder("oauth2.token.generation")
.description("Number of tokens generated")
.tag("version", "2.1")
.register(meterRegistry);
this.tokenValidationCounter = Counter.builder("oauth2.token.validation")
.description("Number of token validations")
.register(meterRegistry);
this.authenticationFailureCounter = Counter.builder("oauth2.authentication.failure")
.description("Number of authentication failures")
.register(meterRegistry);
this.tokenGenerationTimer = Timer.builder("oauth2.token.generation.time")
.description("Time taken to generate tokens")
.register(meterRegistry);
this.activeSessionsGauge = Gauge.builder("oauth2.sessions.active")
.description("Number of active OAuth sessions")
.register(meterRegistry, this, OAuth21MonitoringService::getActiveSessionCount);
}
public void recordTokenGeneration(String clientId, String grantType,
Duration processingTime) {
tokenGenerationCounter.increment(
Tags.of(
"client_id", clientId,
"grant_type", grantType,
"status", "success"
)
);
tokenGenerationTimer.record(processingTime);
// 성능 임계값 확인
if (processingTime.toMillis() > 1000) {
notificationService.sendAlert(
"Slow token generation detected",
String.format("Token generation took %d ms for client %s",
processingTime.toMillis(), clientId)
);
}
}
public void recordAuthenticationFailure(String clientId, String reason) {
authenticationFailureCounter.increment(
Tags.of(
"client_id", clientId,
"reason", reason
)
);
// 비정상적인 실패율 감지
double failureRate = calculateFailureRate(clientId);
if (failureRate > 0.1) { // 10% 초과
notificationService.sendAlert(
"High authentication failure rate",
String.format("Client %s has %.2f%% failure rate", clientId, failureRate * 100)
);
}
}
public void recordTokenValidation(String clientId, boolean isValid) {
tokenValidationCounter.increment(
Tags.of(
"client_id", clientId,
"result", isValid ? "valid" : "invalid"
)
);
}
@EventListener
public void handleSecurityEvent(OAuth2SecurityEvent event) {
switch (event.getType()) {
case SUSPICIOUS_ACTIVITY:
handleSuspiciousActivity(event);
break;
case TOKEN_INTRUSION_ATTEMPT:
handleTokenIntrusionAttempt(event);
break;
case UNUSUAL_ACCESS_PATTERN:
handleUnusualAccessPattern(event);
break;
}
}
private void handleSuspiciousActivity(OAuth2SecurityEvent event) {
// 의심스러운 활동 로깅
log.warn("Suspicious OAuth2 activity detected: {}", event.getDetails());
// 즉시 알림 발송
notificationService.sendUrgentAlert(
"OAuth2 Security Alert",
"Suspicious activity detected: " + event.getDetails()
);
// 관련 세션 검토를 위한 플래그 설정
flagSessionForReview(event.getSessionId());
}
private void handleTokenIntrusionAttempt(OAuth2SecurityEvent event) {
String clientId = event.getClientId();
// 클라이언트 임시 차단
temporarilyBlockClient(clientId, Duration.ofMinutes(15));
// 관련 토큰 모두 무효화
revokeAllTokensForClient(clientId);
// 보안팀에 긴급 알림
notificationService.sendSecurityAlert(
"Token Intrusion Attempt",
String.format("Possible token intrusion attempt from client: %s", clientId)
);
}
private double getActiveSessionCount() {
// Redis에서 활성 세션 수 조회
return redisTemplate.keys("session:*").size();
}
private double calculateFailureRate(String clientId) {
// 최근 1시간 내 실패율 계산
return meterRegistry.get("oauth2.authentication.failure")
.tag("client_id", clientId)
.counter()
.count() / getAuthenticationAttempts(clientId);
}
}
OAuth 2.1 보안 테스트 및 검증 방법
OAuth 2.1 구현의 보안성을 검증하기 위한 종합적인 테스트 전략을 제시합니다.
자동화된 보안 테스트 구현
@SpringBootTest
@TestPropertySource(locations = "classpath:test-security.properties")
public class OAuth21SecurityTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OAuth21TokenService tokenService;
@Test
@DisplayName("PKCE 없는 인증 요청은 거부되어야 함")
public void shouldRejectAuthorizationRequestWithoutPKCE() {
// Given
String authUrl = "/oauth2/authorize" +
"?response_type=code" +
"&client_id=test-client" +
"&redirect_uri=https://test.com/callback" +
"&scope=read";
// When
ResponseEntity<String> response = restTemplate.getForEntity(authUrl, String.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("code_challenge required");
}
@Test
@DisplayName("잘못된 code_verifier로 토큰 교환 시도는 실패해야 함")
public void shouldFailTokenExchangeWithInvalidCodeVerifier() {
// Given
String validCode = generateValidAuthorizationCode();
String invalidCodeVerifier = "invalid-verifier";
MultiValueMap<String, String> tokenRequest = new LinkedMultiValueMap<>();
tokenRequest.add("grant_type", "authorization_code");
tokenRequest.add("code", validCode);
tokenRequest.add("code_verifier", invalidCodeVerifier);
tokenRequest.add("client_id", "test-client");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// When
ResponseEntity<Map> response = restTemplate.postForEntity(
"/oauth2/token",
new HttpEntity<>(tokenRequest, headers),
Map.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error")).isEqualTo("invalid_grant");
}
@Test
@DisplayName("만료된 리프레시 토큰으로 갱신 시도는 실패해야 함")
public void shouldFailRefreshWithExpiredToken() {
// Given
String expiredRefreshToken = generateExpiredRefreshToken();
MultiValueMap<String, String> refreshRequest = new LinkedMultiValueMap<>();
refreshRequest.add("grant_type", "refresh_token");
refreshRequest.add("refresh_token", expiredRefreshToken);
refreshRequest.add("client_id", "test-client");
// When
ResponseEntity<Map> response = restTemplate.postForEntity(
"/oauth2/token",
new HttpEntity<>(refreshRequest, createBasicAuthHeaders()),
Map.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error")).isEqualTo("invalid_grant");
}
@Test
@DisplayName("토큰 재사용 시도 탐지 및 차단")
public void shouldDetectAndBlockTokenReuse() {
// Given
String refreshToken = generateValidRefreshToken();
// 첫 번째 사용
tokenService.refreshToken(refreshToken, "test-client");
// When - 같은 토큰으로 재시도
assertThatThrownBy(() ->
tokenService.refreshToken(refreshToken, "test-client")
).isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("Token reuse detected");
// Then - 관련 토큰들이 모두 무효화되었는지 확인
assertThat(isTokenRevoked(refreshToken)).isTrue();
}
@Test
@DisplayName("비정상적인 토큰 요청 패턴 탐지")
public void shouldDetectAbnormalTokenRequestPattern() {
String clientId = "test-client";
// When - 짧은 시간 내 대량 요청
IntStream.range(0, 100).parallel().forEach(i -> {
try {
requestToken(clientId);
} catch (Exception e) {
// 예상되는 rate limiting 예외
}
});
// Then - 클라이언트가 임시 차단되었는지 확인
assertThat(isClientTemporarilyBlocked(clientId)).isTrue();
}
@Test
@DisplayName("Cross-Site Request Forgery 공격 방어")
public void shouldPreventCSRFAttack() {
// Given - state 파라미터 없는 인증 요청
String maliciousAuthUrl = "/oauth2/authorize" +
"?response_type=code" +
"&client_id=test-client" +
"&redirect_uri=https://malicious.com/callback" +
"&code_challenge=test-challenge" +
"&code_challenge_method=S256";
// When
ResponseEntity<String> response = restTemplate.getForEntity(maliciousAuthUrl, String.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("state parameter required");
}
// 테스트 헬퍼 메서드들
private String generateValidAuthorizationCode() {
// 유효한 authorization code 생성 로직
return "valid-auth-code-" + System.currentTimeMillis();
}
private String generateExpiredRefreshToken() {
// 만료된 refresh token 생성 로직
return "expired-refresh-token";
}
private String generateValidRefreshToken() {
// 유효한 refresh token 생성 로직
return "valid-refresh-token-" + UUID.randomUUID();
}
private HttpHeaders createBasicAuthHeaders() {
String credentials = "test-client:test-secret";
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Basic " + encodedCredentials);
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return headers;
}
private void requestToken(String clientId) {
// 토큰 요청 로직
MultiValueMap<String, String> request = new LinkedMultiValueMap<>();
request.add("grant_type", "client_credentials");
request.add("client_id", clientId);
request.add("scope", "read");
restTemplate.postForEntity("/oauth2/token", request, Map.class);
}
private boolean isTokenRevoked(String token) {
// 토큰 무효화 상태 확인 로직
return tokenService.isTokenRevoked(token);
}
private boolean isClientTemporarilyBlocked(String clientId) {
// 클라이언트 차단 상태 확인 로직
return rateLimitingService.isClientBlocked(clientId);
}
}
결론: OAuth 2.1 도입의 핵심 가치와 향후 전망
OAuth 2.1은 단순한 프로토콜 업데이트를 넘어서 현대적인 보안 요구사항에 부응하는 종합적인 인증 솔루션입니다.
PKCE 의무화, Implicit Grant 제거, 리프레시 토큰 회전 등의 핵심 개선사항을 통해
기존 OAuth 2.0의 보안 취약점을 효과적으로 해결했습니다.
스프링 시큐리티 6.x와의 완벽한 통합을 통해 개발자들은 복잡한 보안 로직을 직접 구현할 필요 없이,
검증된 라이브러리의 기능을 활용하여 안전하고 효율적인 OAuth 2.1 시스템을 구축할 수 있습니다.
특히 마이크로서비스 아키텍처와 모바일 애플리케이션 환경에서 OAuth 2.1의 강화된 보안 기능은 필수적인 요소로 자리잡고 있습니다.
향후 웹 애플리케이션 보안의 표준으로 자리매김할 OAuth 2.1을 지금부터 도입하여 미래지향적인 보안 인프라를 구축하시기 바랍니다.
개발팀의 보안 역량 강화와 사용자 데이터 보호 수준 향상을 위해 OAuth 2.1 도입을 적극 검토해보시기 바랍니다.
체계적인 마이그레이션 계획과 충분한 테스트를 통해 안전하고 성공적인 OAuth 2.1 전환을 이루어내시길 바랍니다.
'스프링 시큐리티와 보안 가이드' 카테고리의 다른 글
한국형 Edge AI 보안 아키텍처 설계: 스프링 시큐리티 기반 완전 가이드 (0) | 2025.06.27 |
---|---|
WebAuthn을 활용한 패스워드 없는 인증 구현하기: Spring Security로 안전한 생체인증 시스템 구축 (0) | 2025.05.26 |
스프링 시큐리티와 보안 가이드: JWT vs OAuth2 vs Session – 인증 방식 비교와 적용 전략 (1) | 2025.05.07 |
[Spring Security] Spring Boot Actuator 완벽 가이드 - 보안 설정부터 운영 환경 적용까지 (0) | 2025.01.26 |
[Spring Security] Spring Security의 FilterChain 구조 완벽 이해 (0) | 2025.01.25 |