From d34f45759c0bef8fcee5345ff3c18880ea77337f Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:18:57 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=ED=97=AC=ED=8D=BC?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/domain/RefreshTokenSession.java | 34 +++ .../repository/RefreshTokenRepository.java | 85 +++++++ .../token/service/RefreshTokenService.java | 85 +++++++ .../RedisOAuth2AuthorizationService.java | 181 ++++++++++++++ .../redis/AuthorizationEntityMapper.java | 220 ++++++++++++++++++ .../global/redis/AuthorizationKeyManager.java | 50 ++++ .../redis/AuthorizationRedisRepository.java | 71 ++++++ .../global/redis/AuthorizationTtlPolicy.java | 68 ++++++ 8 files changed, 794 insertions(+) create mode 100644 src/main/java/org/creditto/authserver/auth/token/domain/RefreshTokenSession.java create mode 100644 src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java create mode 100644 src/main/java/org/creditto/authserver/auth/token/service/RefreshTokenService.java create mode 100644 src/main/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationService.java create mode 100644 src/main/java/org/creditto/authserver/global/redis/AuthorizationEntityMapper.java create mode 100644 src/main/java/org/creditto/authserver/global/redis/AuthorizationKeyManager.java create mode 100644 src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java create mode 100644 src/main/java/org/creditto/authserver/global/redis/AuthorizationTtlPolicy.java diff --git a/src/main/java/org/creditto/authserver/auth/token/domain/RefreshTokenSession.java b/src/main/java/org/creditto/authserver/auth/token/domain/RefreshTokenSession.java new file mode 100644 index 0000000..9690960 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/token/domain/RefreshTokenSession.java @@ -0,0 +1,34 @@ +package org.creditto.authserver.auth.token.domain; + +import lombok.Builder; + +import java.time.Instant; + +@Builder(toBuilder = true) +public record RefreshTokenSession( + String userId, + String username, + String roles, + String certificateId, + String certificateSerial, + String countryCode, + String phoneNo, + String clientId, + String tokenValue, + Instant issuedAt, + Instant expiresAt, + String sessionId, + String ipAddress, + String userAgent +) { + public RefreshTokenSession rotate(String newTokenValue, Instant newIssuedAt, Instant newExpiresAt, String newIp, String newUserAgent) { + return this.toBuilder() + .tokenValue(newTokenValue) + .issuedAt(newIssuedAt) + .expiresAt(newExpiresAt) + .ipAddress(newIp) + .userAgent(newUserAgent) + .sessionId(newTokenValue) + .build(); + } +} diff --git a/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java b/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..c2ea953 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java @@ -0,0 +1,85 @@ +package org.creditto.authserver.auth.token.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.creditto.authserver.auth.token.domain.RefreshTokenSession; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String SESSION_KEY = "RT:%s:%s"; + private static final String TOKEN_INDEX_KEY = "RTI:%s"; + private static final Duration MIN_TTL = Duration.ofSeconds(1); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + public void save(RefreshTokenSession session) { + Duration ttl = calculateTtl(session.expiresAt()); + String sessionKey = buildSessionKey(session.userId(), session.sessionId()); + try { + String json = objectMapper.writeValueAsString(session); + redisTemplate.opsForValue().set(sessionKey, json, ttl); + redisTemplate.opsForValue().set(buildTokenIndexKey(session.tokenValue()), sessionKey, ttl); + } catch (Exception e) { + log.error("RefreshTokenSession 직렬화 실패 - userId: {}", session.userId(), e); + throw new IllegalStateException("RefreshTokenSession 직렬화에 실패했습니다.", e); + } + } + + public Optional findByToken(String refreshToken) { + String sessionKey = redisTemplate.opsForValue().get(buildTokenIndexKey(refreshToken)); + if (!StringUtils.hasText(sessionKey)) { + return Optional.empty(); + } + String json = redisTemplate.opsForValue().get(sessionKey); + if (!StringUtils.hasText(json)) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, RefreshTokenSession.class)); + } catch (Exception e) { + log.error("RefreshTokenSession 역직렬화 실패 - key: {}", sessionKey, e); + throw new IllegalStateException("RefreshTokenSession 역직렬화에 실패했습니다.", e); + } + } + + public void deleteByToken(String refreshToken) { + String sessionKey = redisTemplate.opsForValue().get(buildTokenIndexKey(refreshToken)); + if (!StringUtils.hasText(sessionKey)) { + return; + } + redisTemplate.delete(sessionKey); + redisTemplate.delete(buildTokenIndexKey(refreshToken)); + } + + private Duration calculateTtl(Instant expiresAt) { + if (expiresAt == null) { + return MIN_TTL; + } + Duration ttl = Duration.between(Instant.now(), expiresAt); + if (ttl.isNegative() || ttl.isZero()) { + return MIN_TTL; + } + return ttl; + } + + private String buildSessionKey(String userId, String sessionId) { + return SESSION_KEY.formatted(userId, sessionId); + } + + private String buildTokenIndexKey(String tokenValue) { + return TOKEN_INDEX_KEY.formatted(tokenValue); + } +} diff --git a/src/main/java/org/creditto/authserver/auth/token/service/RefreshTokenService.java b/src/main/java/org/creditto/authserver/auth/token/service/RefreshTokenService.java new file mode 100644 index 0000000..8f6070e --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/token/service/RefreshTokenService.java @@ -0,0 +1,85 @@ +package org.creditto.authserver.auth.token.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.creditto.authserver.auth.authentication.RequestClientInfo; +import org.creditto.authserver.auth.token.domain.RefreshTokenSession; +import org.creditto.authserver.auth.token.exception.InvalidRefreshTokenException; +import org.creditto.authserver.auth.token.repository.RefreshTokenRepository; +import org.creditto.authserver.certificate.entity.Certificate; +import org.creditto.authserver.user.entity.User; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + public void store(User user, Certificate certificate, RegisteredClient registeredClient, OAuth2RefreshToken refreshToken, RequestClientInfo clientInfo) { + if (refreshToken == null) { + return; + } + + RefreshTokenSession session = RefreshTokenSession.builder() + .userId(user.getId().toString()) + .username(user.getName()) + .roles(user.mapRoleListToString()) + .certificateId(certificate.getId().toString()) + .certificateSerial(certificate.getSerialNumber()) + .countryCode(user.getCountryCode()) + .phoneNo(user.getPhoneNo()) + .clientId(registeredClient.getClientId()) + .tokenValue(refreshToken.getTokenValue()) + .issuedAt(refreshToken.getIssuedAt()) + .expiresAt(refreshToken.getExpiresAt()) + .sessionId(refreshToken.getTokenValue()) + .ipAddress(clientInfo != null ? clientInfo.ipAddress() : null) + .userAgent(clientInfo != null ? clientInfo.userAgent() : null) + .build(); + + refreshTokenRepository.save(session); + log.debug("RefreshTokenSession 저장 완료 - userId: {}, clientId: {}", session.userId(), registeredClient.getClientId()); + } + + public RefreshTokenSession validate(String refreshTokenValue, String clientId) { + RefreshTokenSession session = refreshTokenRepository.findByToken(refreshTokenValue) + .orElseThrow(InvalidRefreshTokenException::new); + + if (!session.clientId().equals(clientId)) { + throw new InvalidRefreshTokenException("클라이언트 정보가 일치하지 않습니다."); + } + + return session; + } + + public RefreshTokenSession rotate(RefreshTokenSession session, OAuth2RefreshToken newRefreshToken, RequestClientInfo clientInfo) { + if (newRefreshToken == null) { + return session; + } + + refreshTokenRepository.deleteByToken(session.tokenValue()); + + RefreshTokenSession rotated = session.rotate( + newRefreshToken.getTokenValue(), + newRefreshToken.getIssuedAt(), + newRefreshToken.getExpiresAt(), + clientInfo != null ? clientInfo.ipAddress() : null, + clientInfo != null ? clientInfo.userAgent() : null + ); + + refreshTokenRepository.save(rotated); + return rotated; + } + + public void revoke(String refreshTokenValue) { + if (!StringUtils.hasText(refreshTokenValue)) { + return; + } + refreshTokenRepository.deleteByToken(refreshTokenValue); + } +} diff --git a/src/main/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationService.java b/src/main/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationService.java new file mode 100644 index 0000000..fdca3a4 --- /dev/null +++ b/src/main/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationService.java @@ -0,0 +1,181 @@ +package org.creditto.authserver.client.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; +import org.creditto.authserver.global.redis.AuthorizationEntityMapper; +import org.creditto.authserver.global.redis.AuthorizationKeyManager; +import org.creditto.authserver.global.redis.AuthorizationRedisRepository; +import org.creditto.authserver.global.redis.AuthorizationTtlPolicy; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import static org.creditto.authserver.global.response.error.AssertErrorMessage.ID_EMPTY; +import static org.creditto.authserver.global.response.error.AssertErrorMessage.TOKEN_EMPTY; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService { + + private final AuthorizationRedisRepository redisRepository; + private final AuthorizationEntityMapper authorizationEntityMapper; + private final AuthorizationKeyManager keyManager; + private final AuthorizationTtlPolicy ttlPolicy; + + @Override + public void save(OAuth2Authorization authorization) { + Assert.notNull(authorization, "authorization cannot be null"); + + OAuth2AuthorizationEntity existingEntity = redisRepository.findAuthorization(authorization.getId()); + if (existingEntity != null) { + OAuth2Authorization existingAuthorization = authorizationEntityMapper.toObject(existingEntity); + removeIndexes(existingAuthorization); + } + + OAuth2AuthorizationEntity entity = authorizationEntityMapper.toEntity(authorization, existingEntity); + Duration ttl = ttlPolicy.authorizationTtl(authorization); + redisRepository.saveAuthorization(entity, ttlPolicy.ensurePositive(ttl)); + registerIndexes(authorization, ttlPolicy.ensurePositive(ttl)); + + log.debug("OAuth2Authorization Redis 저장 완료 - ID: {}, Principal: {}", authorization.getId(), authorization.getPrincipalName()); + } + + @Override + public void remove(OAuth2Authorization authorization) { + Assert.notNull(authorization, "authorization cannot be null"); + OAuth2Authorization stored = findById(authorization.getId()); + if (stored != null) { + removeIndexes(stored); + } + redisRepository.deleteAuthorization(authorization.getId()); + log.debug("OAuth2Authorization Redis 삭제 완료 - ID: {}", authorization.getId()); + } + + @Override + public OAuth2Authorization findById(String id) { + Assert.hasText(id, ID_EMPTY); + OAuth2AuthorizationEntity entity = redisRepository.findAuthorization(id); + return entity != null ? authorizationEntityMapper.toObject(entity) : null; + } + + @Override + public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) { + Assert.hasText(token, TOKEN_EMPTY); + + String authorizationId = resolveAuthorizationId(token, tokenType); + if (!StringUtils.hasText(authorizationId)) { + return null; + } + return findById(authorizationId); + } + + private String resolveAuthorizationId(String token, OAuth2TokenType tokenType) { + if (tokenType == null) { + for (String prefix : keyManager.indexPrefixes()) { + String id = redisRepository.readIndex(prefix + token); + if (StringUtils.hasText(id)) { + return id; + } + } + return null; + } + + if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) { + return redisRepository.readIndex(keyManager.accessTokenIndexKey(token)); + } + if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { + return redisRepository.readIndex(keyManager.refreshTokenIndexKey(token)); + } + if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) { + return redisRepository.readIndex(keyManager.authorizationCodeIndexKey(token)); + } + if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { + return redisRepository.readIndex(keyManager.stateIndexKey(token)); + } + if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) { + return redisRepository.readIndex(keyManager.oidcTokenIndexKey(token)); + } + return null; + } + + private void registerIndexes(OAuth2Authorization authorization, Duration fallbackTtl) { + storeIndex(keyManager.stateIndexKey(authorization.getAttribute(OAuth2ParameterNames.STATE)), + authorization.getId(), fallbackTtl); + + storeTokenIndex(authorization.getToken(OAuth2AuthorizationCode.class), + keyManager::authorizationCodeIndexKey, + authorization.getId()); + + storeTokenIndex(authorization.getToken(OAuth2AccessToken.class), + keyManager::accessTokenIndexKey, + authorization.getId()); + + storeTokenIndex(authorization.getToken(OAuth2RefreshToken.class), + keyManager::refreshTokenIndexKey, + authorization.getId()); + + storeTokenIndex(authorization.getToken(OidcIdToken.class), + keyManager::oidcTokenIndexKey, + authorization.getId()); + } + + private void removeIndexes(OAuth2Authorization authorization) { + deleteIndex(keyManager.stateIndexKey(authorization.getAttribute(OAuth2ParameterNames.STATE))); + deleteTokenIndex(authorization.getToken(OAuth2AuthorizationCode.class), keyManager::authorizationCodeIndexKey); + deleteTokenIndex(authorization.getToken(OAuth2AccessToken.class), keyManager::accessTokenIndexKey); + deleteTokenIndex(authorization.getToken(OAuth2RefreshToken.class), keyManager::refreshTokenIndexKey); + deleteTokenIndex(authorization.getToken(OidcIdToken.class), keyManager::oidcTokenIndexKey); + } + + private void storeIndex(String key, String authorizationId, Duration ttl) { + if (!StringUtils.hasText(key)) { + return; + } + redisRepository.storeIndex(key, authorizationId, ttlPolicy.ensurePositive(ttl)); + } + + private void storeTokenIndex( + OAuth2Authorization.Token token, + UnaryOperator keyFunction, + String authorizationId + ) { + if (token == null || token.getToken() == null) { + return; + } + String key = keyFunction.apply(token.getToken().getTokenValue()); + Duration ttl = ttlPolicy.tokenTtl(token.getToken().getExpiresAt()); + redisRepository.storeIndex(key, authorizationId, ttl); + } + + private void deleteTokenIndex( + OAuth2Authorization.Token token, + UnaryOperator keyFunction + ) { + if (token == null || token.getToken() == null) { + return; + } + String key = keyFunction.apply(token.getToken().getTokenValue()); + deleteIndex(key); + } + + private void deleteIndex(String key) { + if (StringUtils.hasText(key)) { + redisRepository.deleteIndex(key); + } + } +} diff --git a/src/main/java/org/creditto/authserver/global/redis/AuthorizationEntityMapper.java b/src/main/java/org/creditto/authserver/global/redis/AuthorizationEntityMapper.java new file mode 100644 index 0000000..0ecef14 --- /dev/null +++ b/src/main/java/org/creditto/authserver/global/redis/AuthorizationEntityMapper.java @@ -0,0 +1,220 @@ +package org.creditto.authserver.global.redis; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.function.Consumer; + +import static org.creditto.authserver.global.response.error.AssertErrorMessage.JSON_PARSE_FAILED; +import static org.creditto.authserver.global.response.error.AssertErrorMessage.JSON_WRITE_FAILED; +import static org.creditto.authserver.global.response.error.AssertErrorMessage.REGISTERED_CLIENT_NOT_FOUND_IN_REPO; + +@Component +@RequiredArgsConstructor +public class AuthorizationEntityMapper { + + private final RegisteredClientRepository registeredClientRepository; + private final ObjectMapper metadataObjectMapper = buildMetadataObjectMapper(); + + public OAuth2Authorization toObject(OAuth2AuthorizationEntity entity) { + RegisteredClient registeredClient = registeredClientRepository.findById(entity.getRegisteredClientId()); + + if (registeredClient == null) { + throw new DataRetrievalFailureException(REGISTERED_CLIENT_NOT_FOUND_IN_REPO); + } + + OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient) + .id(entity.getId()) + .principalName(entity.getPrincipalName()) + .authorizationGrantType(new AuthorizationGrantType(entity.getAuthorizationGrantType())) + .authorizedScopes(StringUtils.commaDelimitedListToSet(entity.getAuthorizedScopes())) + .attributes(attributes -> attributes.putAll(parseMap(entity.getAttributes()))); + + mapAuthorizationCode(entity, builder); + mapAccessToken(entity, builder); + mapRefreshToken(entity, builder); + mapOidcToken(entity, builder); + + return builder.build(); + } + + public OAuth2AuthorizationEntity toEntity(OAuth2Authorization authorization, OAuth2AuthorizationEntity existingEntity) { + OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder = OAuth2AuthorizationEntity.builder() + .id(authorization.getId()) + .registeredClientId(authorization.getRegisteredClientId()) + .principalName(authorization.getPrincipalName()) + .authorizationGrantType(authorization.getAuthorizationGrantType().getValue()) + .authorizedScopes(StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes())) + .attributes(writeMap(authorization.getAttributes())) + .state(authorization.getAttribute(OAuth2ParameterNames.STATE)); + + if (existingEntity != null && existingEntity.getCreatedAt() != null) { + builder.createdAt(existingEntity.getCreatedAt()); + } else { + builder.createdAt(LocalDateTime.now()); + } + setTokenValueOfAuthorization(authorization, builder); + setTokenValueOfAccessToken(authorization, builder); + setTokenValueOfRefreshToken(authorization, builder); + setTokenValueOfOidcToken(authorization, builder); + + return builder.build(); + } + + private void setTokenValueOfOidcToken(OAuth2Authorization authorization, OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder) { + OAuth2Authorization.Token oidcIdToken = authorization.getToken(OidcIdToken.class); + setTokenValues( + oidcIdToken, + builder::oidcIdTokenValue, + builder::oidcIdTokenIssuedAt, + builder::oidcIdTokenExpiresAt, + builder::oidcIdTokenMetadata + ); + if (oidcIdToken != null) { + builder.oidcIdTokenClaims(writeMap(oidcIdToken.getClaims())); + } + } + + private void setTokenValueOfRefreshToken(OAuth2Authorization authorization, OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder) { + setTokenValues(authorization.getToken(OAuth2RefreshToken.class), + builder::refreshTokenValue, + builder::refreshTokenIssuedAt, + builder::refreshTokenExpiresAt, + builder::refreshTokenMetadata); + } + + private void setTokenValueOfAccessToken(OAuth2Authorization authorization, OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder) { + setTokenValues(authorization.getToken(OAuth2AccessToken.class), + builder::accessTokenValue, + builder::accessTokenIssuedAt, + builder::accessTokenExpiresAt, + builder::accessTokenMetadata); + + OAuth2Authorization.Token accessToken = authorization.getToken(OAuth2AccessToken.class); + if (accessToken != null && accessToken.getToken().getScopes() != null) { + builder.accessTokenType(accessToken.getToken().getTokenType().getValue()); + builder.accessTokenScopes(StringUtils.collectionToCommaDelimitedString(accessToken.getToken().getScopes())); + } + } + + private void setTokenValueOfAuthorization(OAuth2Authorization authorization, OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder) { + setTokenValues(authorization.getToken(OAuth2AuthorizationCode.class), + builder::authorizationCodeValue, + builder::authorizationCodeIssuedAt, + builder::authorizationCodeExpiresAt, + builder::authorizationCodeMetadata); + } + + private void mapAuthorizationCode(OAuth2AuthorizationEntity entity, OAuth2Authorization.Builder builder) { + if (entity.getAuthorizationCodeValue() == null) { + return; + } + OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( + entity.getAuthorizationCodeValue(), + entity.getAuthorizationCodeIssuedAt(), + entity.getAuthorizationCodeExpiresAt() + ); + builder.token(authorizationCode, metadata -> metadata.putAll(parseMap(entity.getAuthorizationCodeMetadata()))); + } + + private void mapAccessToken(OAuth2AuthorizationEntity entity, OAuth2Authorization.Builder builder) { + if (entity.getAccessTokenValue() == null) { + return; + } + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + entity.getAccessTokenValue(), + entity.getAccessTokenIssuedAt(), + entity.getAccessTokenExpiresAt(), + StringUtils.commaDelimitedListToSet(entity.getAccessTokenScopes()) + ); + builder.token(accessToken, metadata -> metadata.putAll(parseMap(entity.getAccessTokenMetadata()))); + } + + private void mapRefreshToken(OAuth2AuthorizationEntity entity, OAuth2Authorization.Builder builder) { + if (entity.getRefreshTokenValue() == null) { + return; + } + OAuth2RefreshToken refreshToken = new OAuth2RefreshToken( + entity.getRefreshTokenValue(), + entity.getRefreshTokenIssuedAt(), + entity.getRefreshTokenExpiresAt() + ); + builder.token(refreshToken, metadata -> metadata.putAll(parseMap(entity.getRefreshTokenMetadata()))); + } + + private void mapOidcToken(OAuth2AuthorizationEntity entity, OAuth2Authorization.Builder builder) { + if (entity.getOidcIdTokenValue() == null) { + return; + } + OidcIdToken idToken = new OidcIdToken( + entity.getOidcIdTokenValue(), + entity.getOidcIdTokenIssuedAt(), + entity.getOidcIdTokenExpiresAt(), + parseMap(entity.getOidcIdTokenClaims()) + ); + builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getOidcIdTokenMetadata()))); + } + + private void setTokenValues( + OAuth2Authorization.Token token, + Consumer tokenValueConsumer, + Consumer issuedAtConsumer, + Consumer expiresAtConsumer, + Consumer metadataConsumer + ) { + if (token == null || token.getToken() == null) { + return; + } + T source = token.getToken(); + tokenValueConsumer.accept(source.getTokenValue()); + issuedAtConsumer.accept(source.getIssuedAt()); + expiresAtConsumer.accept(source.getExpiresAt()); + metadataConsumer.accept(writeMap(token.getMetadata())); + } + + private Map parseMap(String data) { + try { + return StringUtils.hasText(data) + ? metadataObjectMapper.readValue(data, new TypeReference>() {}) + : Map.of(); + } catch (Exception ex) { + throw new IllegalArgumentException(JSON_PARSE_FAILED + ": " + ex.getMessage(), ex); + } + } + + private String writeMap(Map metadata) { + try { + return metadataObjectMapper.writeValueAsString(metadata); + } catch (Exception ex) { + throw new IllegalArgumentException(JSON_WRITE_FAILED + ": " + ex.getMessage(), ex); + } + } + + private ObjectMapper buildMetadataObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + ClassLoader classLoader = AuthorizationEntityMapper.class.getClassLoader(); + objectMapper.registerModules(SecurityJackson2Modules.getModules(classLoader)); + objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module()); + return objectMapper; + } +} diff --git a/src/main/java/org/creditto/authserver/global/redis/AuthorizationKeyManager.java b/src/main/java/org/creditto/authserver/global/redis/AuthorizationKeyManager.java new file mode 100644 index 0000000..087bfe5 --- /dev/null +++ b/src/main/java/org/creditto/authserver/global/redis/AuthorizationKeyManager.java @@ -0,0 +1,50 @@ +package org.creditto.authserver.global.redis; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static org.creditto.authserver.global.redis.RedisConstants.*; + +@Component +public class AuthorizationKeyManager { + + public String authorizationKey(String authorizationId) { + return AUTHORIZATION_KEY_PREFIX + authorizationId; + } + + public List indexPrefixes() { + return List.of( + STATE_INDEX_PREFIX, + AUTH_CODE_INDEX_PREFIX, + ACCESS_TOKEN_INDEX_PREFIX, + REFRESH_TOKEN_INDEX_PREFIX, + OIDC_TOKEN_INDEX_PREFIX + ); + } + + public String stateIndexKey(String state) { + return buildIndexKey(STATE_INDEX_PREFIX, state); + } + + public String authorizationCodeIndexKey(String code) { + return buildIndexKey(AUTH_CODE_INDEX_PREFIX, code); + } + + public String accessTokenIndexKey(String tokenValue) { + return buildIndexKey(ACCESS_TOKEN_INDEX_PREFIX, tokenValue); + } + + public String refreshTokenIndexKey(String tokenValue) { + return buildIndexKey(REFRESH_TOKEN_INDEX_PREFIX, tokenValue); + } + + public String oidcTokenIndexKey(String tokenValue) { + return buildIndexKey(OIDC_TOKEN_INDEX_PREFIX, tokenValue); + } + + private String buildIndexKey(String prefix, String value) { + return StringUtils.hasText(value) ? prefix + value : null; + } +} diff --git a/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java b/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java new file mode 100644 index 0000000..a4d48f6 --- /dev/null +++ b/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java @@ -0,0 +1,71 @@ +package org.creditto.authserver.global.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.RequiredArgsConstructor; +import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AuthorizationRedisRepository { + + private final StringRedisTemplate redisTemplate; + private final AuthorizationKeyManager keyManager; + private final ObjectMapper entityObjectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + public void saveAuthorization(OAuth2AuthorizationEntity entity, Duration ttl) { + redisTemplate.opsForValue().set( + keyManager.authorizationKey(entity.getId()), + writeEntity(entity), + ttl + ); + } + + public OAuth2AuthorizationEntity findAuthorization(String authorizationId) { + String json = redisTemplate.opsForValue().get(keyManager.authorizationKey(authorizationId)); + if (!StringUtils.hasText(json)) { + return null; + } + try { + return entityObjectMapper.readValue(json, OAuth2AuthorizationEntity.class); + } catch (Exception e) { + throw new IllegalArgumentException("Authorization entity 역직렬화에 실패했습니다.", e); + } + } + + public void deleteAuthorization(String authorizationId) { + redisTemplate.delete(keyManager.authorizationKey(authorizationId)); + } + + public void storeIndex(String indexKey, String authorizationId, Duration ttl) { + if (!StringUtils.hasText(indexKey)) { + return; + } + redisTemplate.opsForValue().set(indexKey, authorizationId, ttl); + } + + public String readIndex(String indexKey) { + return StringUtils.hasText(indexKey) + ? redisTemplate.opsForValue().get(indexKey) + : null; + } + + public void deleteIndex(String indexKey) { + if (StringUtils.hasText(indexKey)) { + redisTemplate.delete(indexKey); + } + } + + private String writeEntity(OAuth2AuthorizationEntity entity) { + try { + return entityObjectMapper.writeValueAsString(entity); + } catch (Exception e) { + throw new IllegalArgumentException("Authorization entity 직렬화에 실패했습니다.", e); + } + } +} diff --git a/src/main/java/org/creditto/authserver/global/redis/AuthorizationTtlPolicy.java b/src/main/java/org/creditto/authserver/global/redis/AuthorizationTtlPolicy.java new file mode 100644 index 0000000..050c1b6 --- /dev/null +++ b/src/main/java/org/creditto/authserver/global/redis/AuthorizationTtlPolicy.java @@ -0,0 +1,68 @@ +package org.creditto.authserver.global.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.creditto.authserver.global.redis.RedisConstants.MIN_TTL; + +@Component +public class AuthorizationTtlPolicy { + + private final Duration defaultAuthorizationTtl; + + public AuthorizationTtlPolicy(@Value("${auth.token.refresh-ttl:PT336H}") Duration defaultAuthorizationTtl) { + this.defaultAuthorizationTtl = defaultAuthorizationTtl != null ? defaultAuthorizationTtl : Duration.ofDays(14); + } + + public Duration authorizationTtl(OAuth2Authorization authorization) { + Instant now = Instant.now(); + List candidates = new ArrayList<>(); + candidates.add(extractExpiresAt(authorization.getToken(OAuth2AuthorizationCode.class))); + candidates.add(extractExpiresAt(authorization.getToken(OAuth2AccessToken.class))); + candidates.add(extractExpiresAt(authorization.getToken(OAuth2RefreshToken.class))); + candidates.add(extractExpiresAt(authorization.getToken(OidcIdToken.class))); + + Instant max = candidates.stream() + .filter(Objects::nonNull) + .max(Instant::compareTo) + .orElse(null); + + if (max == null) { + return defaultAuthorizationTtl; + } + + Duration ttl = Duration.between(now, max); + return ensurePositive(ttl); + } + + public Duration tokenTtl(Instant expiresAt) { + if (expiresAt == null) { + return defaultAuthorizationTtl; + } + Duration ttl = Duration.between(Instant.now(), expiresAt); + return ensurePositive(ttl); + } + + public Duration ensurePositive(Duration ttl) { + if (ttl == null || ttl.isNegative() || ttl.isZero()) { + return MIN_TTL; + } + return ttl; + } + + private Instant extractExpiresAt(OAuth2Authorization.Token token) { + return token != null && token.getToken() != null ? token.getToken().getExpiresAt() : null; + } +} From ccda7681ae10225b534efaf75bdab699ea904d1a Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:21:35 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20RefreshToken=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?API=20=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ManualAuthorizationServerContext.java | 31 ++++ .../auth/controller/AuthController.java | 40 +++++ .../authserver/auth/dto/LogoutRequest.java | 9 + .../auth/dto/RefreshTokenRequest.java | 11 ++ .../authserver/auth/dto/TokenResponse.java | 12 ++ .../authserver/auth/service/AuthService.java | 163 ++++++++++++++++++ .../InvalidRefreshTokenException.java | 13 ++ 7 files changed, 279 insertions(+) create mode 100644 src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java create mode 100644 src/main/java/org/creditto/authserver/auth/controller/AuthController.java create mode 100644 src/main/java/org/creditto/authserver/auth/dto/LogoutRequest.java create mode 100644 src/main/java/org/creditto/authserver/auth/dto/RefreshTokenRequest.java create mode 100644 src/main/java/org/creditto/authserver/auth/dto/TokenResponse.java create mode 100644 src/main/java/org/creditto/authserver/auth/service/AuthService.java create mode 100644 src/main/java/org/creditto/authserver/auth/token/exception/InvalidRefreshTokenException.java diff --git a/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java b/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java new file mode 100644 index 0000000..7d95a98 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java @@ -0,0 +1,31 @@ +package org.creditto.authserver.auth.context; + +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; + +/** + * Simple AuthorizationServerContext implementation for manual token issuance. + */ +public record ManualAuthorizationServerContext( + String issuer, + AuthorizationServerSettings authorizationServerSettings +) implements AuthorizationServerContext { + + public ManualAuthorizationServerContext(AuthorizationServerSettings authorizationServerSettings) { + this(resolveIssuer(authorizationServerSettings), authorizationServerSettings); + } + + private static String resolveIssuer(AuthorizationServerSettings settings) { + return settings.getIssuer(); + } + + @Override + public String getIssuer() { + return resolveIssuer(authorizationServerSettings); + } + + @Override + public AuthorizationServerSettings getAuthorizationServerSettings() { + return authorizationServerSettings; + } +} diff --git a/src/main/java/org/creditto/authserver/auth/controller/AuthController.java b/src/main/java/org/creditto/authserver/auth/controller/AuthController.java new file mode 100644 index 0000000..df1241c --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/controller/AuthController.java @@ -0,0 +1,40 @@ +package org.creditto.authserver.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.creditto.authserver.auth.dto.LogoutRequest; +import org.creditto.authserver.auth.dto.RefreshTokenRequest; +import org.creditto.authserver.auth.dto.TokenResponse; +import org.creditto.authserver.auth.service.AuthService; +import org.creditto.authserver.global.response.ApiResponseUtil; +import org.creditto.authserver.global.response.BaseResponse; +import org.creditto.authserver.global.response.SuccessCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/token/refresh") + public ResponseEntity> refreshToken( + @Valid @RequestBody RefreshTokenRequest request, + HttpServletRequest httpServletRequest + ) { + TokenResponse response = authService.refreshToken(request, httpServletRequest); + return ApiResponseUtil.success(SuccessCode.OK, response); + } + + @PostMapping("/logout") + public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { + authService.logout(request); + return ApiResponseUtil.success(SuccessCode.OK); + } +} diff --git a/src/main/java/org/creditto/authserver/auth/dto/LogoutRequest.java b/src/main/java/org/creditto/authserver/auth/dto/LogoutRequest.java new file mode 100644 index 0000000..a440bbb --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/dto/LogoutRequest.java @@ -0,0 +1,9 @@ +package org.creditto.authserver.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LogoutRequest( + @NotBlank(message = "refreshToken은 필수입니다.") + String refreshToken +) { +} diff --git a/src/main/java/org/creditto/authserver/auth/dto/RefreshTokenRequest.java b/src/main/java/org/creditto/authserver/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..381dbe6 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,11 @@ +package org.creditto.authserver.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshTokenRequest( + @NotBlank(message = "refreshToken은 필수입니다.") + String refreshToken, + @NotBlank(message = "clientId는 필수입니다.") + String clientId +) { +} diff --git a/src/main/java/org/creditto/authserver/auth/dto/TokenResponse.java b/src/main/java/org/creditto/authserver/auth/dto/TokenResponse.java new file mode 100644 index 0000000..af044a4 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/dto/TokenResponse.java @@ -0,0 +1,12 @@ +package org.creditto.authserver.auth.dto; + +import java.time.Instant; + +public record TokenResponse( + String tokenType, + String accessToken, + Instant accessTokenExpiresAt, + String refreshToken, + Instant refreshTokenExpiresAt +) { +} diff --git a/src/main/java/org/creditto/authserver/auth/service/AuthService.java b/src/main/java/org/creditto/authserver/auth/service/AuthService.java new file mode 100644 index 0000000..1325e6a --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/service/AuthService.java @@ -0,0 +1,163 @@ +package org.creditto.authserver.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.creditto.authserver.auth.authentication.RequestClientInfo; +import org.creditto.authserver.auth.constants.ClaimConstants; +import org.creditto.authserver.auth.context.ManualAuthorizationServerContext; +import org.creditto.authserver.auth.dto.LogoutRequest; +import org.creditto.authserver.auth.dto.RefreshTokenRequest; +import org.creditto.authserver.auth.dto.TokenResponse; +import org.creditto.authserver.auth.jwt.CertificateOAuth2TokenGenerator; +import org.creditto.authserver.auth.token.domain.RefreshTokenSession; +import org.creditto.authserver.auth.token.service.RefreshTokenService; +import org.creditto.authserver.global.response.error.ErrorMessage; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.Set; + +import static org.creditto.authserver.auth.constants.Constants.USER_AGENT; +import static org.creditto.authserver.global.response.error.ErrorMessage.INVALID_CLIENT; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final RegisteredClientRepository registeredClientRepository; + private final RefreshTokenService refreshTokenService; + private final CertificateOAuth2TokenGenerator tokenGenerator; + private final OAuth2AuthorizationService authorizationService; + private final AuthorizationServerSettings authorizationServerSettings; + + public TokenResponse refreshToken(RefreshTokenRequest request, HttpServletRequest httpServletRequest) { + RegisteredClient registeredClient = registeredClientRepository.findByClientId(request.clientId()); + clientNullCheck(registeredClient, request.clientId()); + + RequestClientInfo clientInfo = RequestClientInfo.from( + httpServletRequest.getRemoteAddr(), + httpServletRequest.getHeader(USER_AGENT) + ); + + RefreshTokenSession session = refreshTokenService.validate(request.refreshToken(), request.clientId()); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient, + registeredClient.getClientAuthenticationMethods().stream().findFirst().orElseThrow(), + registeredClient.getClientSecret() + ); + + OAuth2Authorization.Builder authorizationBuilder = buildAuthorizationFromSession(registeredClient, session); + OAuth2RefreshTokenAuthenticationToken authenticationToken = + new OAuth2RefreshTokenAuthenticationToken(request.refreshToken(), clientPrincipal, Set.of(), Map.of()); + + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(clientPrincipal) + .authorizedScopes(registeredClient.getScopes()) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrant(authenticationToken); + + tokenContextBuilder.authorizationServerContext(new ManualAuthorizationServerContext(authorizationServerSettings)); + + OAuth2Authorization authorizationBeforeTokens = authorizationBuilder.build(); + tokenContextBuilder.authorization(authorizationBeforeTokens); + + OAuth2AccessToken accessToken = generateAccessToken(tokenContextBuilder); + authorizationBuilder.accessToken(accessToken); + + OAuth2RefreshToken newRefreshToken = generateRefreshToken(registeredClient, tokenContextBuilder); + if (newRefreshToken != null) { + authorizationBuilder.refreshToken(newRefreshToken); + refreshTokenService.rotate(session, newRefreshToken, clientInfo); + } + + OAuth2Authorization authorization = authorizationBuilder.build(); + authorizationService.save(authorization); + + log.info("Refresh Token 재발급 완료 - userId: {}, clientId: {}", session.userId(), session.clientId()); + + return new TokenResponse( + OAuth2AccessToken.TokenType.BEARER.getValue(), + accessToken.getTokenValue(), + accessToken.getExpiresAt(), + newRefreshToken != null ? newRefreshToken.getTokenValue() : request.refreshToken(), + newRefreshToken != null ? newRefreshToken.getExpiresAt() : session.expiresAt() + ); + } + + public void logout(LogoutRequest request) { + refreshTokenService.revoke(request.refreshToken()); + } + + private OAuth2Authorization.Builder buildAuthorizationFromSession(RegisteredClient registeredClient, RefreshTokenSession session) { + Set scopes = registeredClient.getScopes(); + return OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(session.userId()) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizedScopes(scopes) + .attribute(ClaimConstants.CERT_SERIAL_CAMEL, session.certificateSerial()) + .attribute(ClaimConstants.CERT_ID, session.certificateId()) + .attribute(ClaimConstants.USER_ID, session.userId()) + .attribute(ClaimConstants.USERNAME, session.username()) + .attribute(ClaimConstants.COUNTRY_CODE, session.countryCode()) + .attribute(ClaimConstants.USER_PHONE_NO, session.phoneNo()) + .attribute(ClaimConstants.ROLES, session.roles()); + } + + private OAuth2AccessToken generateAccessToken(DefaultOAuth2TokenContext.Builder tokenContextBuilder) { + OAuth2TokenContext tokenContext = tokenContextBuilder + .tokenType(OAuth2TokenType.ACCESS_TOKEN) + .build(); + OAuth2Token generatedAccessToken = tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + throw new IllegalStateException(ErrorMessage.TOKEN_GENERATION_FAILED); + } + return new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), + generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), + tokenContext.getAuthorizedScopes() + ); + } + + private OAuth2RefreshToken generateRefreshToken(RegisteredClient registeredClient, DefaultOAuth2TokenContext.Builder tokenContextBuilder) { + if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) { + return null; + } + OAuth2TokenContext tokenContext = tokenContextBuilder + .tokenType(OAuth2TokenType.REFRESH_TOKEN) + .build(); + OAuth2Token generatedRefreshToken = tokenGenerator.generate(tokenContext); + if (generatedRefreshToken == null) { + return null; + } + return (OAuth2RefreshToken) generatedRefreshToken; + } + + private void clientNullCheck(RegisteredClient registeredClient, String clientId) { + if (registeredClient == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_client", INVALID_CLIENT + ": " + clientId, null) + ); + } + } +} diff --git a/src/main/java/org/creditto/authserver/auth/token/exception/InvalidRefreshTokenException.java b/src/main/java/org/creditto/authserver/auth/token/exception/InvalidRefreshTokenException.java new file mode 100644 index 0000000..12e3ae1 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/token/exception/InvalidRefreshTokenException.java @@ -0,0 +1,13 @@ +package org.creditto.authserver.auth.token.exception; + +import org.creditto.authserver.global.response.error.ErrorMessage; + +public class InvalidRefreshTokenException extends RuntimeException { + public InvalidRefreshTokenException() { + super(ErrorMessage.INVALID_REFRESH_TOKEN); + } + + public InvalidRefreshTokenException(String message) { + super(message); + } +} From c53849329d890b299d15a49d673ab19e075fce0f Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:23:21 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?Stateless=20SAS=20=EC=84=A4=EC=A0=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ertificateGrantAuthenticationProvider.java | 23 ++++++++------ .../config/AuthorizationServerConfig.java | 30 +++++++++++++++++-- .../entity/OAuth2AuthorizationEntity.java | 2 ++ .../global/redis/RedisConstants.java | 17 +++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/creditto/authserver/global/redis/RedisConstants.java diff --git a/src/main/java/org/creditto/authserver/auth/authentication/CertificateGrantAuthenticationProvider.java b/src/main/java/org/creditto/authserver/auth/authentication/CertificateGrantAuthenticationProvider.java index 76b6bb9..4b78102 100644 --- a/src/main/java/org/creditto/authserver/auth/authentication/CertificateGrantAuthenticationProvider.java +++ b/src/main/java/org/creditto/authserver/auth/authentication/CertificateGrantAuthenticationProvider.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.creditto.authserver.auth.constants.ClaimConstants; import org.creditto.authserver.auth.jwt.CertificateOAuth2TokenGenerator; +import org.creditto.authserver.auth.token.service.RefreshTokenService; import org.creditto.authserver.certificate.entity.Certificate; import org.creditto.authserver.certificate.service.CertificateService; import org.creditto.authserver.user.entity.User; @@ -40,6 +41,7 @@ public class CertificateGrantAuthenticationProvider implements AuthenticationPro private final RegisteredClientRepository registeredClientRepository; private final OAuth2AuthorizationService authorizationService; private final CertificateOAuth2TokenGenerator tokenGenerator; + private final RefreshTokenService refreshTokenService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -53,7 +55,8 @@ public Authentication authenticate(Authentication authentication) throws Authent // 2. 인증서 기반 인증 수행 String certificateSerial = certificateToken.getCertificateSerial(); String simplePassword = certificateToken.getCredentials(); - Certificate certificate = authenticateWithCertificate(certificateToken, certificateSerial, simplePassword); + RequestClientInfo clientInfo = extractClientInfo(certificateToken); + Certificate certificate = authenticateWithCertificate(certificateSerial, simplePassword, clientInfo); User user = certificate.getUser(); // 3. Principal 생성 (OAuth2ClientAuthenticationToken 생성) / 인증된 주체 정보 @@ -80,6 +83,7 @@ public Authentication authenticate(Authentication authentication) throws Authent if (refreshToken != null) { authorizationBuilder.refreshToken(refreshToken); + refreshTokenService.store(user, certificate, registeredClient, refreshToken, clientInfo); } // 8. OAuth2Authorization 저장 @@ -115,21 +119,22 @@ private static void clientNullCheck(RegisteredClient registeredClient, String cl /** * 인증서 기반 인증 - * @param certificateToken 인증 객체 (Authorization) * @param certificateSerial 인증서 SerialNumber * @param simplePassword 인증서 간편 비밀번호 * @return Certificate */ - private Certificate authenticateWithCertificate(CertificateAuthenticationToken certificateToken, String certificateSerial, String simplePassword) { - String ipAddress = null; - String userAgent = null; + private Certificate authenticateWithCertificate(String certificateSerial, String simplePassword, RequestClientInfo clientInfo) { + String ipAddress = clientInfo != null ? clientInfo.ipAddress() : null; + String userAgent = clientInfo != null ? clientInfo.userAgent() : null; + return certificateService.authenticateWithCertificate(certificateSerial, simplePassword, ipAddress, userAgent); + } + + private RequestClientInfo extractClientInfo(CertificateAuthenticationToken certificateToken) { Object details = certificateToken.getDetails(); if (details instanceof RequestClientInfo info) { - ipAddress = info.ipAddress(); - userAgent = info.userAgent(); + return info; } - - return certificateService.authenticateWithCertificate(certificateSerial, simplePassword, ipAddress, userAgent); + return null; } /** diff --git a/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java b/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java index 5096789..9e6d4c2 100644 --- a/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java +++ b/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java @@ -8,12 +8,16 @@ import lombok.RequiredArgsConstructor; import org.creditto.authserver.auth.authentication.CertificateGrantAuthenticationConverter; import org.creditto.authserver.auth.authentication.CertificateGrantAuthenticationProvider; +import org.creditto.authserver.auth.jwk.CachedJwkSetEndpointFilter; +import org.creditto.authserver.auth.jwk.JwkCacheService; import org.creditto.authserver.auth.jwt.CertificateOAuth2TokenGenerator; import org.creditto.authserver.auth.jwt.RsaKeyProperties; import org.creditto.authserver.auth.jwt.RsaKeyUtil; +import org.creditto.authserver.auth.token.service.RefreshTokenService; import org.creditto.authserver.certificate.service.CertificateService; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -112,10 +116,11 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) .requestMatchers("/api/user/**").permitAll() .requestMatchers("/api/certificate/**").permitAll() .requestMatchers("/api/client/**").permitAll() + .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf - .ignoringRequestMatchers("/api/user/**", "/api/certificate/**", "/api/client/**") + .ignoringRequestMatchers("/api/user/**", "/api/certificate/**", "/api/client/**", "/api/auth/token/refresh") ) .cors(Customizer.withDefaults()) .exceptionHandling(exceptions -> exceptions @@ -177,13 +182,15 @@ public CertificateGrantAuthenticationProvider certificateGrantAuthenticationProv CertificateService certificateService, RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService, - CertificateOAuth2TokenGenerator certificateTokenGenerator + CertificateOAuth2TokenGenerator certificateTokenGenerator, + RefreshTokenService refreshTokenService ) { return new CertificateGrantAuthenticationProvider( certificateService, registeredClientRepository, authorizationService, - certificateTokenGenerator + certificateTokenGenerator, + refreshTokenService ); } @@ -191,4 +198,21 @@ public CertificateGrantAuthenticationProvider certificateGrantAuthenticationProv private void handleTokenError(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { handlerExceptionResolver.resolveException(request, response, null, exception); } + + @Bean + public FilterRegistrationBean cachedJwkSetEndpointFilter( + JWKSource jwkSource, + JwkCacheService jwkCacheService, + AuthorizationServerSettings authorizationServerSettings + ) { + CachedJwkSetEndpointFilter filter = new CachedJwkSetEndpointFilter( + jwkSource, + jwkCacheService, + authorizationServerSettings.getJwkSetEndpoint() + ); + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setOrder(0); + registration.addUrlPatterns(authorizationServerSettings.getJwkSetEndpoint()); + return registration; + } } diff --git a/src/main/java/org/creditto/authserver/client/entity/OAuth2AuthorizationEntity.java b/src/main/java/org/creditto/authserver/client/entity/OAuth2AuthorizationEntity.java index 1ed4022..9332da7 100644 --- a/src/main/java/org/creditto/authserver/client/entity/OAuth2AuthorizationEntity.java +++ b/src/main/java/org/creditto/authserver/client/entity/OAuth2AuthorizationEntity.java @@ -1,5 +1,6 @@ package org.creditto.authserver.client.entity; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.*; import lombok.*; import org.springframework.data.annotation.CreatedDate; @@ -19,6 +20,7 @@ @Builder(access = AccessLevel.PUBLIC) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonIgnoreProperties(ignoreUnknown = true) public class OAuth2AuthorizationEntity { @Id diff --git a/src/main/java/org/creditto/authserver/global/redis/RedisConstants.java b/src/main/java/org/creditto/authserver/global/redis/RedisConstants.java new file mode 100644 index 0000000..289e3e4 --- /dev/null +++ b/src/main/java/org/creditto/authserver/global/redis/RedisConstants.java @@ -0,0 +1,17 @@ +package org.creditto.authserver.global.redis; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.Duration; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RedisConstants { + public static final String AUTHORIZATION_KEY_PREFIX = "oauth2:authorization:"; + public static final String STATE_INDEX_PREFIX = "oauth2:index:state:"; + public static final String AUTH_CODE_INDEX_PREFIX = "oauth2:index:code:"; + public static final String ACCESS_TOKEN_INDEX_PREFIX = "oauth2:index:access:"; + public static final String REFRESH_TOKEN_INDEX_PREFIX = "oauth2:index:refresh:"; + public static final String OIDC_TOKEN_INDEX_PREFIX = "oauth2:index:oidc:"; + public static final Duration MIN_TTL = Duration.ofSeconds(1); +} From 89b4aec45ba327c4be4d4eea90314778937c7f36 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:23:51 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OAuth2AuthorizationRepository.java | 46 --- .../JpaOAuth2AuthorizationService.java | 286 ------------------ .../JpaOAuth2AuthorizationServiceTest.java | 124 -------- 3 files changed, 456 deletions(-) delete mode 100644 src/main/java/org/creditto/authserver/client/repository/OAuth2AuthorizationRepository.java delete mode 100644 src/main/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationService.java delete mode 100644 src/test/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationServiceTest.java diff --git a/src/main/java/org/creditto/authserver/client/repository/OAuth2AuthorizationRepository.java b/src/main/java/org/creditto/authserver/client/repository/OAuth2AuthorizationRepository.java deleted file mode 100644 index fbbd5e2..0000000 --- a/src/main/java/org/creditto/authserver/client/repository/OAuth2AuthorizationRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.creditto.authserver.client.repository; - -import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface OAuth2AuthorizationRepository extends JpaRepository { - - // State로 Authorization 조회 - Optional findByState(String state); - - // Authorization Code로 조회 - Optional findByAuthorizationCodeValue(String authorizationCode); - - // Access Token으로 조회 - Optional findByAccessTokenValue(String accessToken); - - // Refresh Token으로 조회 - Optional findByRefreshTokenValue(String refreshToken); - - // OIDC ID Token으로 조회 - Optional findByOidcIdTokenValue(String idToken); - - // User Code로 조회 - Optional findByUserCodeValue(String userCode); - - // Device Code로 조회 - Optional findByDeviceCodeValue(String deviceCode); - - // 복합 조회 - State와 Authorization Code - @Query("SELECT a FROM OAuth2AuthorizationEntity a WHERE a.state = :token " + - "OR a.authorizationCodeValue = :token " + - "OR a.accessTokenValue = :token " + - "OR a.refreshTokenValue = :token " + - "OR a.oidcIdTokenValue = :token " + - "OR a.userCodeValue = :token " + - "OR a.deviceCodeValue = :token") - Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue( - @Param("token") String token - ); -} diff --git a/src/main/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationService.java b/src/main/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationService.java deleted file mode 100644 index 2ddc399..0000000 --- a/src/main/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationService.java +++ /dev/null @@ -1,286 +0,0 @@ -package org.creditto.authserver.client.service; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; -import org.creditto.authserver.client.repository.OAuth2AuthorizationRepository; -import org.springframework.dao.DataRetrievalFailureException; -import org.springframework.security.jackson2.SecurityJackson2Modules; -import org.springframework.security.oauth2.core.*; -import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; -import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; -import org.springframework.stereotype.Service; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -import static org.creditto.authserver.global.response.error.AssertErrorMessage.*; - -/** - * JPA 기반 OAuth2AuthorizationService 구현 - * OAuth2Authorization을 데이터베이스에 저장/조회 - */ -@Slf4j -@Service -public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService { - - private final OAuth2AuthorizationRepository authorizationRepository; - private final RegisteredClientRepository registeredClientRepository; - private final ObjectMapper objectMapper = new ObjectMapper(); - - public JpaOAuth2AuthorizationService( - OAuth2AuthorizationRepository authorizationRepository, - RegisteredClientRepository registeredClientRepository - ) { - Assert.notNull(authorizationRepository, AUTHORIZATION_REPOSITORY_NULL); - Assert.notNull(registeredClientRepository, REGISTERED_CLIENT_REPOSITORY_NULL); - this.authorizationRepository = authorizationRepository; - this.registeredClientRepository = registeredClientRepository; - - // Jackson 모듈 등록 - ClassLoader classLoader = JpaOAuth2AuthorizationService.class.getClassLoader(); - List securityModules = SecurityJackson2Modules.getModules(classLoader); - this.objectMapper.registerModules(securityModules); - this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module()); - } - - @Override - public void save(OAuth2Authorization authorization) { - Assert.notNull(authorization, AUTHORIZATION_NULL); - - OAuth2AuthorizationEntity entity = authorizationRepository.findById(authorization.getId()) - .orElse(OAuth2AuthorizationEntity.create( - authorization.getId(), - authorization.getRegisteredClientId(), - authorization.getPrincipalName(), - authorization.getAuthorizationGrantType().getValue() - )); - - OAuth2AuthorizationEntity updatedEntity = toEntity(authorization, entity); - authorizationRepository.save(updatedEntity); - - log.debug("OAuth2Authorization 저장 완료 - ID: {}, Principal: {}", authorization.getId(), authorization.getPrincipalName()); - } - - @Override - public void remove(OAuth2Authorization authorization) { - Assert.notNull(authorization, AUTHORIZATION_NULL); - authorizationRepository.deleteById(authorization.getId()); - log.debug("OAuth2Authorization 삭제 완료 - ID: {}", authorization.getId()); - } - - @Override - public OAuth2Authorization findById(String id) { - Assert.hasText(id, ID_EMPTY); - return authorizationRepository.findById(id) - .map(this::toObject) - .orElse(null); - } - - @Override - public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) { - Assert.hasText(token, TOKEN_EMPTY); - - OAuth2AuthorizationEntity entity = null; - - if (tokenType == null) { - entity = authorizationRepository - .findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(token) - .orElse(null); - } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { - entity = authorizationRepository.findByState(token).orElse(null); - } else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) { - entity = authorizationRepository.findByAuthorizationCodeValue(token).orElse(null); - } else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) { - entity = authorizationRepository.findByAccessTokenValue(token).orElse(null); - } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { - entity = authorizationRepository.findByRefreshTokenValue(token).orElse(null); - } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { - entity = authorizationRepository.findByUserCodeValue(token).orElse(null); - } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { - entity = authorizationRepository.findByDeviceCodeValue(token).orElse(null); - } - - return entity != null ? toObject(entity) : null; - } - - /** - * Entity를 OAuth2Authorization 객체로 변환 - */ - private OAuth2Authorization toObject(OAuth2AuthorizationEntity entity) { - RegisteredClient registeredClient = registeredClientRepository.findById(entity.getRegisteredClientId()); - if (registeredClient == null) { - throw new DataRetrievalFailureException(REGISTERED_CLIENT_NOT_FOUND_IN_REPO); - } - - OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient) - .id(entity.getId()) - .principalName(entity.getPrincipalName()) - .authorizationGrantType(new AuthorizationGrantType(entity.getAuthorizationGrantType())) - .authorizedScopes(StringUtils.commaDelimitedListToSet(entity.getAuthorizedScopes())) - .attributes(attributes -> attributes.putAll(parseMap(entity.getAttributes()))); - - // Authorization Code - if (entity.getAuthorizationCodeValue() != null) { - OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( - entity.getAuthorizationCodeValue(), - entity.getAuthorizationCodeIssuedAt(), - entity.getAuthorizationCodeExpiresAt() - ); - builder.token(authorizationCode, metadata -> metadata.putAll(parseMap(entity.getAuthorizationCodeMetadata()))); - } - - // Access Token - if (entity.getAccessTokenValue() != null) { - OAuth2AccessToken accessToken = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - entity.getAccessTokenValue(), - entity.getAccessTokenIssuedAt(), - entity.getAccessTokenExpiresAt(), - StringUtils.commaDelimitedListToSet(entity.getAccessTokenScopes()) - ); - builder.token(accessToken, metadata -> metadata.putAll(parseMap(entity.getAccessTokenMetadata()))); - } - - // Refresh Token - if (entity.getRefreshTokenValue() != null) { - OAuth2RefreshToken refreshToken = new OAuth2RefreshToken( - entity.getRefreshTokenValue(), - entity.getRefreshTokenIssuedAt(), - entity.getRefreshTokenExpiresAt() - ); - builder.token(refreshToken, metadata -> metadata.putAll(parseMap(entity.getRefreshTokenMetadata()))); - } - - // OIDC ID Token - if (entity.getOidcIdTokenValue() != null) { - OidcIdToken idToken = new OidcIdToken( - entity.getOidcIdTokenValue(), - entity.getOidcIdTokenIssuedAt(), - entity.getOidcIdTokenExpiresAt(), - parseMap(entity.getOidcIdTokenClaims()) - ); - builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getOidcIdTokenMetadata()))); - } - - return builder.build(); - } - - /** - * OAuth2Authorization을 Entity로 변환 - */ - private OAuth2AuthorizationEntity toEntity(OAuth2Authorization authorization, OAuth2AuthorizationEntity existingEntity) { - // 기본 정보로 Builder 생성 - OAuth2AuthorizationEntity.OAuth2AuthorizationEntityBuilder builder = OAuth2AuthorizationEntity.builder() - .id(authorization.getId()) - .registeredClientId(authorization.getRegisteredClientId()) - .principalName(authorization.getPrincipalName()) - .authorizationGrantType(authorization.getAuthorizationGrantType().getValue()) - .authorizedScopes(StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes())) - .attributes(writeMap(authorization.getAttributes())) - .state(authorization.getAttribute(OAuth2ParameterNames.STATE)); - - // 기존 엔티티가 있으면 createdAt 유지 - if (existingEntity != null && existingEntity.getCreatedAt() != null) { - builder.createdAt(existingEntity.getCreatedAt()); - } - - // Authorization Code - OAuth2Authorization.Token authorizationCode = - authorization.getToken(OAuth2AuthorizationCode.class); - setTokenValues( - authorizationCode, - builder::authorizationCodeValue, - builder::authorizationCodeIssuedAt, - builder::authorizationCodeExpiresAt, - builder::authorizationCodeMetadata - ); - - // Access Token - OAuth2Authorization.Token accessToken = - authorization.getToken(OAuth2AccessToken.class); - setTokenValues( - accessToken, - builder::accessTokenValue, - builder::accessTokenIssuedAt, - builder::accessTokenExpiresAt, - builder::accessTokenMetadata - ); - if (accessToken != null && accessToken.getToken().getScopes() != null) { - builder.accessTokenType(accessToken.getToken().getTokenType().getValue()); - builder.accessTokenScopes(StringUtils.collectionToCommaDelimitedString(accessToken.getToken().getScopes())); - } - - // Refresh Token - OAuth2Authorization.Token refreshToken = - authorization.getToken(OAuth2RefreshToken.class); - setTokenValues( - refreshToken, - builder::refreshTokenValue, - builder::refreshTokenIssuedAt, - builder::refreshTokenExpiresAt, - builder::refreshTokenMetadata - ); - - // OIDC ID Token - OAuth2Authorization.Token oidcIdToken = - authorization.getToken(OidcIdToken.class); - setTokenValues( - oidcIdToken, - builder::oidcIdTokenValue, - builder::oidcIdTokenIssuedAt, - builder::oidcIdTokenExpiresAt, - builder::oidcIdTokenMetadata - ); - if (oidcIdToken != null) { - builder.oidcIdTokenClaims(writeMap(oidcIdToken.getClaims())); - } - - return builder.build(); - } - - private void setTokenValues( - OAuth2Authorization.Token token, - Consumer tokenValueConsumer, - Consumer issuedAtConsumer, - Consumer expiresAtConsumer, - Consumer metadataConsumer) { - if (token != null) { - T oAuth2Token = token.getToken(); - tokenValueConsumer.accept(oAuth2Token.getTokenValue()); - issuedAtConsumer.accept(oAuth2Token.getIssuedAt()); - expiresAtConsumer.accept(oAuth2Token.getExpiresAt()); - metadataConsumer.accept(writeMap(token.getMetadata())); - } - } - - private Map parseMap(String data) { - try { - return StringUtils.hasText(data) ? - objectMapper.readValue(data, new TypeReference>() {}) : - Map.of(); - } catch (Exception ex) { - throw new IllegalArgumentException(JSON_PARSE_FAILED + ": " + ex.getMessage(), ex); - } - } - - private String writeMap(Map metadata) { - try { - return objectMapper.writeValueAsString(metadata); - } catch (Exception ex) { - throw new IllegalArgumentException(JSON_WRITE_FAILED + ": " + ex.getMessage(), ex); - } - } -} diff --git a/src/test/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationServiceTest.java b/src/test/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationServiceTest.java deleted file mode 100644 index a3ddabb..0000000 --- a/src/test/java/org/creditto/authserver/client/service/JpaOAuth2AuthorizationServiceTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.creditto.authserver.client.service; - -import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; -import org.creditto.authserver.client.repository.OAuth2AuthorizationRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; -import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; -import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; -import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; - -import java.time.Instant; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class JpaOAuth2AuthorizationServiceTest { - - @Mock - private OAuth2AuthorizationRepository authorizationRepository; - @Mock - private RegisteredClientRepository registeredClientRepository; - - private JpaOAuth2AuthorizationService service; - private RegisteredClient registeredClient; - private OAuth2Authorization authorization; - - @BeforeEach - void setUp() { - service = new JpaOAuth2AuthorizationService(authorizationRepository, registeredClientRepository); - registeredClient = RegisteredClient.withId("registered-client-id") - .clientId("client-id") - .clientSecret("secret") - .clientName("테스트 클라이언트") - .clientAuthenticationMethod(org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_BASIC) - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .scope("read") - .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) - .tokenSettings(TokenSettings.builder().reuseRefreshTokens(true).build()) - .build(); - - Instant issuedAt = Instant.parse("2024-01-01T10:00:00Z"); - OAuth2AccessToken token = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - "access-token", - issuedAt, - issuedAt.plusSeconds(120), - Set.of("read") - ); - - authorization = OAuth2Authorization.withRegisteredClient(registeredClient) - .id("authorization-id") - .principalName("principal-user") - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .authorizedScopes(Set.of("read")) - .attribute(OAuth2ParameterNames.STATE, "state-value") - .token(token, metadata -> metadata.put("meta", "value")) - .build(); - } - - @Test - @DisplayName("Authorization 저장 시 JPA 엔티티로 변환되어 저장된다") - void save_persistsConvertedEntity() { - // given - when(authorizationRepository.findById(authorization.getId())).thenReturn(Optional.empty()); - - // when - service.save(authorization); - - // then - ArgumentCaptor captor = ArgumentCaptor.forClass(OAuth2AuthorizationEntity.class); - verify(authorizationRepository).save(captor.capture()); - OAuth2AuthorizationEntity entity = captor.getValue(); - assertThat(entity.getPrincipalName()).isEqualTo("principal-user"); - assertThat(entity.getAccessTokenValue()).isEqualTo("access-token"); - assertThat(entity.getAuthorizedScopes()).contains("read"); - } - - @Test - @DisplayName("액세스 토큰으로 Authorization을 조회하면 도메인 객체로 복원된다") - void findByToken_returnsAuthorization() { - // given - AtomicReference storedEntity = new AtomicReference<>(); - when(authorizationRepository.findById("authorization-id")).thenReturn(Optional.empty()); - when(authorizationRepository.save(any(OAuth2AuthorizationEntity.class))).thenAnswer(invocation -> { - OAuth2AuthorizationEntity entity = invocation.getArgument(0); - storedEntity.set(entity); - return entity; - }); - - service.save(authorization); - - when(authorizationRepository.findByAccessTokenValue("access-token")) - .thenReturn(Optional.ofNullable(storedEntity.get())); - when(registeredClientRepository.findById("registered-client-id")).thenReturn(registeredClient); - - // when - OAuth2Authorization result = service.findByToken("access-token", OAuth2TokenType.ACCESS_TOKEN); - - // then - assertThat(result).isNotNull(); - assertThat(result.getPrincipalName()).isEqualTo("principal-user"); - OAuth2Authorization.Token token = result.getToken(OAuth2AccessToken.class); - assertThat(token).isNotNull(); - assertThat(token.getToken().getTokenValue()).isEqualTo("access-token"); - } - -} From 261af6ecec0f83f4b4b5ec95caf3af9d95fd9809 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:24:19 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20Redis=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20chore=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/GlobalExceptionHandler.java | 10 ++++++++++ .../global/response/error/AssertErrorMessage.java | 3 --- .../global/response/error/ErrorBaseCode.java | 1 + .../authserver/global/response/error/ErrorMessage.java | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/creditto/authserver/global/exception/GlobalExceptionHandler.java b/src/main/java/org/creditto/authserver/global/exception/GlobalExceptionHandler.java index b882614..17256c6 100644 --- a/src/main/java/org/creditto/authserver/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/creditto/authserver/global/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import org.creditto.authserver.auth.token.exception.InvalidRefreshTokenException; import org.creditto.authserver.global.response.ApiResponseUtil; import org.creditto.authserver.global.response.BaseResponse; import org.creditto.authserver.global.response.error.ErrorBaseCode; @@ -153,6 +154,15 @@ public ResponseEntity> handleInvalidBearerTokenException(fina return ApiResponseUtil.failure(ErrorBaseCode.EXPIRED_TOKEN, e.getMessage()); } + /** + * 401 - Refresh Token 오류 + */ + @ExceptionHandler(InvalidRefreshTokenException.class) + public ResponseEntity> handleRefreshTokenException(final RuntimeException e) { + logWarn(e); + return ApiResponseUtil.failure(ErrorBaseCode.INVALID_REFRESH_TOKEN, e.getMessage()); + } + /** * 403 - AccessDeniedException * 예외 내용: 사용자가 허가되지 않은 자원에 접근할 때 발생 diff --git a/src/main/java/org/creditto/authserver/global/response/error/AssertErrorMessage.java b/src/main/java/org/creditto/authserver/global/response/error/AssertErrorMessage.java index e0a401a..4bad8ce 100644 --- a/src/main/java/org/creditto/authserver/global/response/error/AssertErrorMessage.java +++ b/src/main/java/org/creditto/authserver/global/response/error/AssertErrorMessage.java @@ -6,9 +6,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class AssertErrorMessage { - public static final String AUTHORIZATION_REPOSITORY_NULL = "AuthorizationRepository는 null일 수 없습니다."; - public static final String REGISTERED_CLIENT_REPOSITORY_NULL = "registeredClientRepository는 null일 수 없습니다."; - public static final String AUTHORIZATION_NULL = "authorization는 null일 수 없습니다."; public static final String ID_EMPTY = "id가 비어있습니다."; public static final String TOKEN_EMPTY = "token이 비어있습니다."; diff --git a/src/main/java/org/creditto/authserver/global/response/error/ErrorBaseCode.java b/src/main/java/org/creditto/authserver/global/response/error/ErrorBaseCode.java index e877673..108f197 100644 --- a/src/main/java/org/creditto/authserver/global/response/error/ErrorBaseCode.java +++ b/src/main/java/org/creditto/authserver/global/response/error/ErrorBaseCode.java @@ -30,6 +30,7 @@ public enum ErrorBaseCode implements ErrorCode { OAUTH_INVALID_CLIENT_CREDENTIALS(HttpStatus.UNAUTHORIZED, 40102, "유효하지 않은 증명입니다."), OAUTH_INVALID_GRANT_TYPE(HttpStatus.UNAUTHORIZED, 40103, "유효하지 않은 Grant Type 입니다."), INVALID_SIMPLE_PASSWORD(HttpStatus.UNAUTHORIZED, 40104, "잘못된 간편비밀번호입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, 40105, "유효하지 않은 Refresh Token 입니다."), /** diff --git a/src/main/java/org/creditto/authserver/global/response/error/ErrorMessage.java b/src/main/java/org/creditto/authserver/global/response/error/ErrorMessage.java index f5b265e..016de3e 100644 --- a/src/main/java/org/creditto/authserver/global/response/error/ErrorMessage.java +++ b/src/main/java/org/creditto/authserver/global/response/error/ErrorMessage.java @@ -50,6 +50,7 @@ public final class ErrorMessage { public static final String INVALID_CLIENT = "유효하지 않은 클라이언트입니다."; public static final String TOKEN_GENERATION_FAILED = "토큰 생성에 실패했습니다."; public static final String CLIENT_NOT_FOUND = "클라이언트를 찾을 수 없습니다."; + public static final String INVALID_REFRESH_TOKEN = "유효하지 않은 Refresh Token 입니다."; /** * CONFLICT From 5d4d3d1fe7430047e8c73e54be9c245b4b370d0d Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:24:39 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20JWK=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=98=20=EC=9D=91=EB=8B=B5=20=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/jwk/CachedJwkSetEndpointFilter.java | 77 +++++++++++++++++++ .../authserver/auth/jwk/JwkCacheService.java | 40 ++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/main/java/org/creditto/authserver/auth/jwk/CachedJwkSetEndpointFilter.java create mode 100644 src/main/java/org/creditto/authserver/auth/jwk/JwkCacheService.java diff --git a/src/main/java/org/creditto/authserver/auth/jwk/CachedJwkSetEndpointFilter.java b/src/main/java/org/creditto/authserver/auth/jwk/CachedJwkSetEndpointFilter.java new file mode 100644 index 0000000..ad85582 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/jwk/CachedJwkSetEndpointFilter.java @@ -0,0 +1,77 @@ +package org.creditto.authserver.auth.jwk; + +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; + +@Slf4j +public class CachedJwkSetEndpointFilter extends OncePerRequestFilter { + + private final JWKSource jwkSource; + private final String endpointUri; + private final JWKSelector jwkSelector = new JWKSelector(new com.nimbusds.jose.jwk.JWKMatcher.Builder().build()); + private final JwkCacheService jwkCacheService; + + public CachedJwkSetEndpointFilter( + JWKSource jwkSource, + JwkCacheService jwkCacheService, + String endpointUri + ) { + this.jwkSource = jwkSource; + this.jwkCacheService = jwkCacheService; + this.endpointUri = endpointUri; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + String jwkJson = jwkCacheService.getCachedJwk() + .orElseGet(this::loadAndCacheJwk); + writeResponse(response, jwkJson); + } catch (Exception ex) { + log.error("JWK 응답 생성 실패", ex); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to load JWK set"); + } + } + + private String loadAndCacheJwk() { + try { + List jwkList = jwkSource.get(jwkSelector, null); + String jwkJson = new JWKSet(jwkList).toString(); + jwkCacheService.cacheJwk(jwkJson); + return jwkJson; + } catch (Exception ex) { + throw new IllegalStateException("JWK 조회에 실패했습니다: " + ex.getMessage(), ex); + } + } + + private void writeResponse(HttpServletResponse response, String jwkJson) throws IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + PrintWriter writer = response.getWriter(); + writer.write(jwkJson); + writer.flush(); + } + + private boolean matches(HttpServletRequest request) { + return "GET".equalsIgnoreCase(request.getMethod()) + && request.getRequestURI().equals(endpointUri); + } +} diff --git a/src/main/java/org/creditto/authserver/auth/jwk/JwkCacheService.java b/src/main/java/org/creditto/authserver/auth/jwk/JwkCacheService.java new file mode 100644 index 0000000..39e5270 --- /dev/null +++ b/src/main/java/org/creditto/authserver/auth/jwk/JwkCacheService.java @@ -0,0 +1,40 @@ +package org.creditto.authserver.auth.jwk; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.util.Optional; + +@Service +public class JwkCacheService { + + private static final String JWK_CACHE_KEY = "jwk:set:cache"; + + private final StringRedisTemplate redisTemplate; + private final Duration cacheTtl; + + public JwkCacheService( + StringRedisTemplate redisTemplate, + @Value("${auth.jwk.cache-ttl:PT30M}") Duration cacheTtl + ) { + this.redisTemplate = redisTemplate; + this.cacheTtl = cacheTtl != null ? cacheTtl : Duration.ofMinutes(30); + } + + public Optional getCachedJwk() { + String value = redisTemplate.opsForValue().get(JWK_CACHE_KEY); + return StringUtils.hasText(value) + ? Optional.of(value) + : Optional.empty(); + } + + public void cacheJwk(String jwkJson) { + if (!StringUtils.hasText(jwkJson)) { + return; + } + redisTemplate.opsForValue().set(JWK_CACHE_KEY, jwkJson, cacheTtl); + } +} From 18815125da13b6286d5355f501b061d61e723520 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:24:59 +0900 Subject: [PATCH 07/11] =?UTF-8?q?build:=20Redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index d74d5ee..2307cb1 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server' implementation 'me.paulschwarz:spring-dotenv:4.0.0' implementation 'org.springframework.security:spring-security-crypto' From 2fd591c0170c887aa7046e0c6a8e0495d00d36ee Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:29:04 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test:=20Redis=20OAuth2=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=B2=B4=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RedisOAuth2AuthorizationServiceTest.java | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/test/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationServiceTest.java diff --git a/src/test/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationServiceTest.java b/src/test/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationServiceTest.java new file mode 100644 index 0000000..8872277 --- /dev/null +++ b/src/test/java/org/creditto/authserver/client/service/RedisOAuth2AuthorizationServiceTest.java @@ -0,0 +1,138 @@ +package org.creditto.authserver.client.service; + +import org.creditto.authserver.global.redis.AuthorizationEntityMapper; +import org.creditto.authserver.global.redis.AuthorizationKeyManager; +import org.creditto.authserver.global.redis.AuthorizationRedisRepository; +import org.creditto.authserver.global.redis.AuthorizationTtlPolicy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RedisOAuth2AuthorizationServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + @Mock + private RegisteredClientRepository registeredClientRepository; + @Mock + private ValueOperations valueOperations; + + private RedisOAuth2AuthorizationService service; + private RegisteredClient registeredClient; + private OAuth2Authorization authorization; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + AuthorizationKeyManager keyManager = new AuthorizationKeyManager(); + AuthorizationRedisRepository repository = new AuthorizationRedisRepository(redisTemplate, keyManager); + AuthorizationEntityMapper mapper = new AuthorizationEntityMapper(registeredClientRepository); + AuthorizationTtlPolicy ttlPolicy = new AuthorizationTtlPolicy(Duration.ofHours(1)); + service = new RedisOAuth2AuthorizationService(repository, mapper, keyManager, ttlPolicy); + + registeredClient = RegisteredClient.withId("registered-client-id") + .clientId("client-id") + .clientSecret("secret") + .clientName("테스트 클라이언트") + .clientAuthenticationMethod(org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("read") + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) + .tokenSettings(TokenSettings.builder().reuseRefreshTokens(true).build()) + .build(); + + Instant issuedAt = Instant.parse("2024-01-01T10:00:00Z"); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "access-token", + issuedAt, + issuedAt.plusSeconds(120), + Set.of("read") + ); + + authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .id("authorization-id") + .principalName("principal-user") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .authorizedScopes(Set.of("read")) + .attribute(OAuth2ParameterNames.STATE, "state-value") + .token(accessToken, metadata -> metadata.put("meta", "value")) + .build(); + } + + @Test + @DisplayName("Authorization 저장 시 Redis에 JSON으로 기록된다") + void save_persistsAuthorizationInRedis() { + AtomicReference storedValue = new AtomicReference<>(); + AtomicReference storedKey = new AtomicReference<>(); + + doAnswer(invocation -> { + String key = invocation.getArgument(0); + String value = invocation.getArgument(1); + Duration ttl = invocation.getArgument(2); + if (key.startsWith("oauth2:authorization:")) { + storedKey.set(key); + storedValue.set(value); + assertThat(ttl.toSeconds()).isPositive(); + } + return null; + }).when(valueOperations).set(any(String.class), any(String.class), any(Duration.class)); + + service.save(authorization); + + assertThat(storedKey.get()).isEqualTo("oauth2:authorization:" + authorization.getId()); + assertThat(storedValue.get()).contains("principal-user"); + } + + @Test + @DisplayName("액세스 토큰으로 Redis에서 Authorization을 복원한다") + void findByToken_returnsAuthorization() { + Map inMemoryRedis = new ConcurrentHashMap<>(); + + doAnswer(invocation -> { + String key = invocation.getArgument(0); + String value = invocation.getArgument(1); + inMemoryRedis.put(key, value); + return null; + }).when(valueOperations).set(any(String.class), any(String.class), any(Duration.class)); + + when(valueOperations.get(any(String.class))).thenAnswer(invocation -> inMemoryRedis.get(invocation.getArgument(0))); + when(registeredClientRepository.findById("registered-client-id")).thenReturn(registeredClient); + + service.save(authorization); + + OAuth2Authorization result = service.findByToken("access-token", OAuth2TokenType.ACCESS_TOKEN); + + assertThat(result).isNotNull(); + assertThat(result.getPrincipalName()).isEqualTo("principal-user"); + assertThat(result.getToken(OAuth2AccessToken.class)).isNotNull(); + } + +} From d6c3db2162357e975e53104bbfd62568534464cc Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:33:06 +0900 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20application.yml=20Redis=20?= =?UTF-8?q?=EB=B0=8F=20Token=20=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 13 ++++++++++++- src/main/resources/application.yml | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 0a4feeb..8a8fc0c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,4 +1,11 @@ spring: + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD:} + timeout: 3s + datasource: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} @@ -31,6 +38,10 @@ auth: jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH} public-key-path: ${JWT_PUBLIC_KEY_PATH} + token: + refresh-ttl: ${AUTH_REFRESH_TOKEN_TTL} + jwk: + cache-ttl: ${AUTH_JWK_CACHE_TTL} management: tracing: @@ -38,4 +49,4 @@ management: probability: 1.0 zipkin: tracing: - endpoint: ${ZIPKIN_URL}/api/v2/spans \ No newline at end of file + endpoint: ${ZIPKIN_URL}/api/v2/spans diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f3df489..912514c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,13 @@ spring: application: name: auth-server + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD:} + timeout: 3s + datasource: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} @@ -14,7 +21,7 @@ spring: show-sql: true properties: hibernate: - format_sql: true + format_sql: false dialect: org.hibernate.dialect.MySQL8Dialect management: @@ -33,4 +40,8 @@ logging: auth: jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH} - public-key-path: ${JWT_PUBLIC_KEY_PATH} \ No newline at end of file + public-key-path: ${JWT_PUBLIC_KEY_PATH} + token: + refresh-ttl: ${AUTH_REFRESH_TOKEN_TTL} + jwk: + cache-ttl: ${AUTH_JWK_CACHE_TTL} From bcb5df30e165e3eb590217babb2c0a7cc4e00d72 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:35:46 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20health=20check=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EB=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../authserver/auth/config/AuthorizationServerConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java b/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java index 9e6d4c2..b8c4862 100644 --- a/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java +++ b/src/main/java/org/creditto/authserver/auth/config/AuthorizationServerConfig.java @@ -117,10 +117,12 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) .requestMatchers("/api/certificate/**").permitAll() .requestMatchers("/api/client/**").permitAll() .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/actuator/info").permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf - .ignoringRequestMatchers("/api/user/**", "/api/certificate/**", "/api/client/**", "/api/auth/token/refresh") + .ignoringRequestMatchers("/api/user/**", "/api/certificate/**", "/api/client/**", "/api/auth/token/refresh", "/actuator/**") ) .cors(Customizer.withDefaults()) .exceptionHandling(exceptions -> exceptions From afe78c96cd0ff043ccbba10e4e25697f4389c5fe Mon Sep 17 00:00:00 2001 From: Jeyong Date: Thu, 4 Dec 2025 15:49:35 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20PR=20Review=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/context/ManualAuthorizationServerContext.java | 8 ++------ .../auth/token/repository/RefreshTokenRepository.java | 2 +- .../global/redis/AuthorizationRedisRepository.java | 3 +-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java b/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java index 7d95a98..218a03f 100644 --- a/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java +++ b/src/main/java/org/creditto/authserver/auth/context/ManualAuthorizationServerContext.java @@ -12,16 +12,12 @@ public record ManualAuthorizationServerContext( ) implements AuthorizationServerContext { public ManualAuthorizationServerContext(AuthorizationServerSettings authorizationServerSettings) { - this(resolveIssuer(authorizationServerSettings), authorizationServerSettings); - } - - private static String resolveIssuer(AuthorizationServerSettings settings) { - return settings.getIssuer(); + this(authorizationServerSettings.getIssuer(), authorizationServerSettings); } @Override public String getIssuer() { - return resolveIssuer(authorizationServerSettings); + return authorizationServerSettings.getIssuer(); } @Override diff --git a/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java b/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java index c2ea953..ec184d9 100644 --- a/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java +++ b/src/main/java/org/creditto/authserver/auth/token/repository/RefreshTokenRepository.java @@ -23,7 +23,7 @@ public class RefreshTokenRepository { private static final Duration MIN_TTL = Duration.ofSeconds(1); private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final ObjectMapper objectMapper; public void save(RefreshTokenSession session) { Duration ttl = calculateTtl(session.expiresAt()); diff --git a/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java b/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java index a4d48f6..759be84 100644 --- a/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java +++ b/src/main/java/org/creditto/authserver/global/redis/AuthorizationRedisRepository.java @@ -1,7 +1,6 @@ package org.creditto.authserver.global.redis; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import org.creditto.authserver.client.entity.OAuth2AuthorizationEntity; import org.springframework.data.redis.core.StringRedisTemplate; @@ -16,7 +15,7 @@ public class AuthorizationRedisRepository { private final StringRedisTemplate redisTemplate; private final AuthorizationKeyManager keyManager; - private final ObjectMapper entityObjectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final ObjectMapper entityObjectMapper; public void saveAuthorization(OAuth2AuthorizationEntity entity, Duration ttl) { redisTemplate.opsForValue().set(