diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e6aa963..85fa530 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -63,5 +63,10 @@ jobs: export COOLSMS_API_SECRET=${{ secrets.COOLSMS_API_SECRET }} export COOLSMS_SENDER=${{ secrets.COOLSMS_SENDER }} + export FCM_KEY_PATH=/app/firebase/${{ secrets.FCM_FILE_NAME }} + + # Spring Boot 앱만 pull docker compose -f docker-compose.yml pull spring-boot-app - docker compose -f docker-compose.yml up -d --no-deps spring-boot-app + + # 전체 재시작 (Redis는 변경 없으면 그대로 유지됨) + docker compose -f docker-compose.yml up -d diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e302bc6..c8bea31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,11 @@ jobs: test: runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 steps: - uses: actions/checkout@v3 @@ -21,4 +26,6 @@ jobs: run: chmod +x gradlew - name: Run tests + env: + SPRING_PROFILES_ACTIVE: test run: ./gradlew test --stacktrace --no-daemon \ No newline at end of file diff --git a/build.gradle b/build.gradle index 80d672f..9e1f8fe 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.fasterxml.jackson.module:jackson-module-parameter-names' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/docker-compose.yml b/docker-compose.yml index ea6a907..7df5846 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - "80:8080" environment: SPRING_PROFILES_ACTIVE: prod + FCM_KEY_PATH: ${FCM_KEY_PATH} S3_BUCKET: ${S3_BUCKET} DB_URL: ${DB_URL} DB_USERNAME: ${DB_USERNAME} @@ -14,17 +15,17 @@ services: JWT_SECRET_KEY: ${JWT_SECRET_KEY} ADMIN_SECRET: ${ADMIN_SECRET} KAKAO_APP_KEY: ${KAKAO_APP_KEY} - REDIS_HOST: redis - REDIS_PORT: 6379 COOLSMS_API_KEY: ${COOLSMS_API_KEY} COOLSMS_API_SECRET: ${COOLSMS_API_SECRET} COOLSMS_SENDER: ${COOLSMS_SENDER} + volumes: + - /home/ec2-user/app/firebase:/app/firebase:ro depends_on: - redis networks: - spring-boot-app-network - redis: # Redis 서비스 추가! + redis: image: redis:7-alpine container_name: redis ports: diff --git a/src/main/java/com/meetkey/server/domain/auth/controller/AuthController.java b/src/main/java/com/meetkey/server/domain/auth/controller/AuthController.java index 5a2914f..8835b46 100644 --- a/src/main/java/com/meetkey/server/domain/auth/controller/AuthController.java +++ b/src/main/java/com/meetkey/server/domain/auth/controller/AuthController.java @@ -2,19 +2,16 @@ import com.meetkey.server.domain.auth.service.AuthService; import com.meetkey.server.domain.auth.service.SmsService; -import com.meetkey.server.domain.member.entity.Member; import com.meetkey.server.domain.member.enums.Provider; -import com.meetkey.server.domain.member.enums.Role; -import com.meetkey.server.domain.member.repository.MemberRepository; -import com.meetkey.server.domain.member.service.MemberService; import com.meetkey.server.global.apiPayload.response.BasicResponse; import com.meetkey.server.global.apiPayload.status.CommonErrorStatus; import com.meetkey.server.global.apiPayload.status.CommonSuccessStatus; import com.meetkey.server.global.security.CustomUserDetails; -import com.meetkey.server.global.security.jwt.JwtUtil; import com.meetkey.server.global.security.jwt.dto.JwtResDTO; import com.meetkey.server.global.security.oauth.dto.OauthReqDTO; +import com.meetkey.server.global.security.oauth.dto.OidcDTO; +import com.meetkey.server.global.security.oauth.kakao.KakaoOauthClient; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -37,6 +34,7 @@ public class AuthController { private final AuthService authService; private final SmsService smsService; + private final KakaoOauthClient kakaoOauthClient; @Value("${admin.secret}") @@ -140,4 +138,11 @@ public ResponseEntity> verifyAuthCode( .body(BasicResponse.success(CommonSuccessStatus._OK, true)); } + // OIDC Cache 설정 확인 테스트 + @GetMapping("/test/kakao-keys") + public ResponseEntity getKeys() { + OidcDTO.OIDCPublicKeys keys = kakaoOauthClient.getKakaoOIDCOpenKeys(); + + return ResponseEntity.ok(keys); + } } diff --git a/src/main/java/com/meetkey/server/domain/auth/service/AuthService.java b/src/main/java/com/meetkey/server/domain/auth/service/AuthService.java index f295476..99519ea 100644 --- a/src/main/java/com/meetkey/server/domain/auth/service/AuthService.java +++ b/src/main/java/com/meetkey/server/domain/auth/service/AuthService.java @@ -35,7 +35,7 @@ public class AuthService { @Value(("{apple.app-key}")) private String appleAppKey; - private final KakaoOauthClient kakaoClient; + private final KakaoOauthClient kakaoOauthClient; private final AppleOauthClient appleClient; private final OauthOidcHelper oAuthOIDCHelper; @@ -173,7 +173,7 @@ private String getAppleProviderIdFromIdToken(String idToken){ } private String getKakaoProviderIdFromIdToken(String idToken){ - OidcDTO.OIDCPublicKeys response = kakaoClient.getKakaoOIDCOpenKeys(); + OidcDTO.OIDCPublicKeys response = kakaoOauthClient.getKakaoOIDCOpenKeys(); OidcDTO.OIDCDecodePayload payload = oAuthOIDCHelper.getPayloadFromIdToken( idToken, "https://kauth.kakao.com", diff --git a/src/main/java/com/meetkey/server/domain/chat/message/redis/RedisSubscriberConfig.java b/src/main/java/com/meetkey/server/domain/chat/message/redis/RedisSubscriberConfig.java index a50dd14..d2433ed 100644 --- a/src/main/java/com/meetkey/server/domain/chat/message/redis/RedisSubscriberConfig.java +++ b/src/main/java/com/meetkey/server/domain/chat/message/redis/RedisSubscriberConfig.java @@ -1,6 +1,6 @@ package com.meetkey.server.domain.chat.message.redis; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -8,12 +8,18 @@ import org.springframework.data.redis.listener.RedisMessageListenerContainer; @Configuration -@RequiredArgsConstructor public class RedisSubscriberConfig { private final RedisConnectionFactory connectionFactory; private final ChatRedisSubscriber chatRedisSubscriber; + public RedisSubscriberConfig( + RedisConnectionFactory connectionFactory, + ChatRedisSubscriber chatRedisSubscriber) { + this.connectionFactory = connectionFactory; + this.chatRedisSubscriber = chatRedisSubscriber; + } + @Bean public RedisMessageListenerContainer redisMessageListenerContainer() { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); diff --git a/src/main/java/com/meetkey/server/global/config/FcmConfig.java b/src/main/java/com/meetkey/server/global/config/FcmConfig.java index 2dd99d1..4fe563c 100644 --- a/src/main/java/com/meetkey/server/global/config/FcmConfig.java +++ b/src/main/java/com/meetkey/server/global/config/FcmConfig.java @@ -6,12 +6,16 @@ import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.ResourceLoader; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; @Configuration +@Profile("!test") public class FcmConfig { @Value("${fcm.key.path}") @@ -20,7 +24,7 @@ public class FcmConfig { @PostConstruct public void init() { try { - InputStream serviceAccount = new ClassPathResource(fcmKeyPath).getInputStream(); + InputStream serviceAccount = getResourceStream(); FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) .build(); @@ -31,4 +35,12 @@ public void init() { throw new RuntimeException("FCM 초기화 실패", e); } } + + private InputStream getResourceStream() throws IOException { + // 경로가 /로 시작하면 외부 파일 시스템(prod), 아니면 클래스패스(local)에서 읽도록 구성 + if (fcmKeyPath.startsWith("/")) { + return new FileInputStream(fcmKeyPath); + } + return new ClassPathResource(fcmKeyPath).getInputStream(); + } } diff --git a/src/main/java/com/meetkey/server/global/config/FeignConfig.java b/src/main/java/com/meetkey/server/global/config/FeignConfig.java new file mode 100644 index 0000000..29ff5ec --- /dev/null +++ b/src/main/java/com/meetkey/server/global/config/FeignConfig.java @@ -0,0 +1,20 @@ +package com.meetkey.server.global.config; + +import feign.Logger; +import feign.slf4j.Slf4jLogger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FeignConfig { + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } + + @Bean + public Logger feignLogger() { + return new Slf4jLogger(); + } +} \ No newline at end of file diff --git a/src/main/java/com/meetkey/server/global/config/RecordSupportingTypeResolver.java b/src/main/java/com/meetkey/server/global/config/RecordSupportingTypeResolver.java new file mode 100644 index 0000000..6d359a9 --- /dev/null +++ b/src/main/java/com/meetkey/server/global/config/RecordSupportingTypeResolver.java @@ -0,0 +1,22 @@ +package com.meetkey.server.global.config; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; + +public class RecordSupportingTypeResolver extends ObjectMapper.DefaultTypeResolverBuilder { + public RecordSupportingTypeResolver(ObjectMapper.DefaultTyping t, PolymorphicTypeValidator ptv) { + super(t, ptv); + } + + @Override + public boolean useForType(JavaType t){ + boolean isRecord = t.getRawClass().isRecord(); + boolean superResult = super.useForType(t); + + if (isRecord) { + return true; + } + return superResult; + } +} diff --git a/src/main/java/com/meetkey/server/global/config/RedisConfig.java b/src/main/java/com/meetkey/server/global/config/RedisConfig.java index 4795044..edf5431 100644 --- a/src/main/java/com/meetkey/server/global/config/RedisConfig.java +++ b/src/main/java/com/meetkey/server/global/config/RedisConfig.java @@ -1,17 +1,35 @@ package com.meetkey.server.global.config; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.impl.StdTypeResolverBuilder; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; + @Configuration +@EnableCaching public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; @Bean public ObjectMapper redisObjectMapper() { @@ -22,7 +40,20 @@ public ObjectMapper redisObjectMapper() { } @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper redisObjectMapper) { + public RedisConnectionFactory RedisConnectionFactory(){ + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + factory.afterPropertiesSet(); + return factory; + } + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper redisObjectMapper) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); @@ -35,6 +66,44 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec return template; } + + // SMS, RefreshToken용 + @Bean + public StringRedisTemplate stringRedisTemplate( + RedisConnectionFactory connectionFactory) { + StringRedisTemplate template = new StringRedisTemplate(); + template.setConnectionFactory(connectionFactory); + return template; + } + + // OIDC 공개용 키 저장 + @Bean + public RedisCacheManager oidcCacheManager( + RedisConnectionFactory connectionFactory + ){ + ObjectMapper cacheMapper = new ObjectMapper(); + RecordSupportingTypeResolver typeResolver = new RecordSupportingTypeResolver(ObjectMapper.DefaultTyping.NON_FINAL, cacheMapper.getPolymorphicTypeValidator()); + StdTypeResolverBuilder initializedResolver = typeResolver.init(JsonTypeInfo.Id.CLASS, null); + initializedResolver = initializedResolver.inclusion(JsonTypeInfo.As.PROPERTY); + cacheMapper.setDefaultTyping(initializedResolver); + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(cacheMapper)) + ) + // 키 앞에 "jwk:" 접두사를 붙임 + .computePrefixWith(cacheName -> "jwk:" + cacheName + "::") + .entryTtl(Duration.ofDays(3)); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(connectionFactory) + .cacheDefaults(config) + .build(); + } + } diff --git a/src/main/java/com/meetkey/server/global/security/oauth/dto/OidcDTO.java b/src/main/java/com/meetkey/server/global/security/oauth/dto/OidcDTO.java index 194ab2f..02e3d12 100644 --- a/src/main/java/com/meetkey/server/global/security/oauth/dto/OidcDTO.java +++ b/src/main/java/com/meetkey/server/global/security/oauth/dto/OidcDTO.java @@ -17,7 +17,9 @@ public record OIDCDecodePayload ( public record OIDCPublicKey ( // JWK String kid, + String kty, String alg, + String use, String n, String e ){} diff --git a/src/main/java/com/meetkey/server/global/security/oauth/kakao/KakaoOauthClient.java b/src/main/java/com/meetkey/server/global/security/oauth/kakao/KakaoOauthClient.java index a792f2f..b1dad2f 100644 --- a/src/main/java/com/meetkey/server/global/security/oauth/kakao/KakaoOauthClient.java +++ b/src/main/java/com/meetkey/server/global/security/oauth/kakao/KakaoOauthClient.java @@ -1,17 +1,19 @@ package com.meetkey.server.global.security.oauth.kakao; +import com.meetkey.server.global.config.FeignConfig; import com.meetkey.server.global.config.OauthConfig; import com.meetkey.server.global.security.oauth.dto.OidcDTO; +import org.springframework.cache.annotation.Cacheable; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; @FeignClient( name = "KakaoAuthClient", url = "https://kauth.kakao.com", - configuration = OauthConfig.class + configuration = {OauthConfig.class, FeignConfig.class} ) public interface KakaoOauthClient { - // @Cacheable(cacheNames = "KakaoOICD", cacheManager = "oidcCacheManager") + @Cacheable(cacheNames = "KakaoOICD", cacheManager = "oidcCacheManager") @GetMapping("/.well-known/jwks.json") OidcDTO.OIDCPublicKeys getKakaoOIDCOpenKeys(); } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 9c19eb9..b2e2106 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,10 +1,8 @@ - spring: data: redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - + host: localhost + port: 6379 jwt: secret: ${JWT_SECRET_KEY:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9b282d2..e825d0c 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,9 +1,8 @@ spring: data: redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - + host: redis + port: 6379 jwt: secret: ${JWT_SECRET_KEY:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa} diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml new file mode 100644 index 0000000..0872f09 --- /dev/null +++ b/src/main/resources/application-test.yaml @@ -0,0 +1,52 @@ +spring: + spring: + config: + activate: + on-profile: test + data: + redis: + host: localhost + port: 6379 + jwt: + secret: ${JWT_SECRET_KEY:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa} + + application: + name: server + + datasource: + url: ${DB_URL:} + username: ${DB_USERNAME:} + password: ${DB_PW:} + driver-class-name: ${DB_DRIVER:} + + jpa: + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate: + format_sql: true + +coolsms: + api-key: ${COOLSMS_API_KEY:} + api-secret: ${COOLSMS_API_SECRET:} + sender: ${COOLSMS_SENDER:} + +admin: + secret: ${ADMIN_SECRET:} + +kakao: + app-key: ${KAKAO_APP_KEY:} + +apple: + app-key: ${APPLE_APP_KEY:} + +cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY:} + secret-key: ${AWS_SECRET_KEY:} + +fcm: + key: + path: "dummy-path" diff --git a/src/test/java/com/meetkey/server/ServerApplicationTests.java b/src/test/java/com/meetkey/server/ServerApplicationTests.java index 1ba2d46..d7ebcc4 100644 --- a/src/test/java/com/meetkey/server/ServerApplicationTests.java +++ b/src/test/java/com/meetkey/server/ServerApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class ServerApplicationTests { @Test