diff --git a/.gitignore b/.gitignore index b033fea10..a1d24a406 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ replay_pid* application-db.yml application-oauth.yml application-secret.yml + +# FCM +firebase-admin-sdk.json diff --git a/build.gradle b/build.gradle index 7dfe3ecca..8baf28319 100644 --- a/build.gradle +++ b/build.gradle @@ -87,6 +87,9 @@ dependencies { // REST Docs asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // FCM + implementation 'com.google.firebase:firebase-admin:9.2.0' } /** QueryDSL start **/ @@ -114,7 +117,7 @@ tasks.register('copySecret', Copy) { description = 'Copy submodules to project' from('./src/main/resources/secret') { - include('*.yml') + include('*') } into('src/main/resources') } diff --git a/src/main/java/kr/codesquad/secondhand/application/firebase/FcmTokenService.java b/src/main/java/kr/codesquad/secondhand/application/firebase/FcmTokenService.java new file mode 100644 index 000000000..fbf67d4d9 --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/application/firebase/FcmTokenService.java @@ -0,0 +1,40 @@ +package kr.codesquad.secondhand.application.firebase; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import kr.codesquad.secondhand.application.redis.RedisService; +import kr.codesquad.secondhand.exception.ErrorCode; +import kr.codesquad.secondhand.exception.InternalServerException; +import kr.codesquad.secondhand.infrastructure.properties.FcmProperties; +import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenIssueResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.io.FileInputStream; +import java.io.IOException; + +@RequiredArgsConstructor +@Service +public class FcmTokenService { + + private static final String FCM_TOKEN_PREFIX = "fcm_token:"; + + private final FcmProperties fcmProperties; + private final RedisService redisService; + + public void updateToken(String token, Long memberId) { + redisService.set(FCM_TOKEN_PREFIX + memberId, token, fcmProperties.getExpirationMillis()); + } + + public FcmTokenIssueResponse issueToken() { + try (FileInputStream serviceAccount = new FileInputStream(fcmProperties.getPrivateKeyPath())) { + GoogleCredentials credentials = GoogleCredentials.fromStream(serviceAccount) + .createScoped(fcmProperties.getScopes()); + + AccessToken accessToken = credentials.refreshAccessToken(); + + return new FcmTokenIssueResponse(accessToken.getTokenValue()); + } catch (IOException e) { + throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "FCM 토큰 발급에 실패했습니다."); + } + } +} diff --git a/src/main/java/kr/codesquad/secondhand/config/FcmConfig.java b/src/main/java/kr/codesquad/secondhand/config/FcmConfig.java new file mode 100644 index 000000000..4c2352823 --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/config/FcmConfig.java @@ -0,0 +1,51 @@ +package kr.codesquad.secondhand.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import kr.codesquad.secondhand.exception.ErrorCode; +import kr.codesquad.secondhand.exception.InternalServerException; +import kr.codesquad.secondhand.infrastructure.properties.FcmProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +@Profile("!test") +@Configuration +public class FcmConfig { + + private final FcmProperties fcmProperties; + + @Bean + public FirebaseMessaging firebaseMessaging() { + List apps = FirebaseApp.getApps(); + + try (FileInputStream refreshToken = new FileInputStream(fcmProperties.getPrivateKeyPath())) { + return apps.stream() + .filter(app -> app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) + .map(FirebaseMessaging::getInstance) + .findFirst() + .orElseGet(() -> createFirebaseMessaging(refreshToken)); + } catch (IOException e) { + throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "Firebase 설정 파일을 읽어올 수 없습니다."); + } + } + + private FirebaseMessaging createFirebaseMessaging(FileInputStream refreshToken) { + try { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + + return FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options)); + } catch (IOException e) { + throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "Firebase 설정 파일을 읽어올 수 없습니다."); + } + } +} diff --git a/src/main/java/kr/codesquad/secondhand/exception/ErrorCode.java b/src/main/java/kr/codesquad/secondhand/exception/ErrorCode.java index a64a7129e..3b77ae2c1 100644 --- a/src/main/java/kr/codesquad/secondhand/exception/ErrorCode.java +++ b/src/main/java/kr/codesquad/secondhand/exception/ErrorCode.java @@ -29,7 +29,11 @@ public enum ErrorCode { // COMMON INVALID_PARAMETER("유효한 파라미터값이 아닙니다."), INVALID_REQUEST("유효한 요청이 아닙니다."), - NOT_FOUND("페이지를 찾을 수 없습니다."); + NOT_FOUND("페이지를 찾을 수 없습니다."), + + // FIREBASE + FIREBASE_CONFIG_ERROR("Firebase 설정 파일을 읽어올 수 없습니다."), + FCM_TOKEN_NOT_FOUND("FCM 토큰을 찾을 수 없습니다."); private final String message; } diff --git a/src/main/java/kr/codesquad/secondhand/infrastructure/properties/FcmProperties.java b/src/main/java/kr/codesquad/secondhand/infrastructure/properties/FcmProperties.java new file mode 100644 index 000000000..ee1ab0c29 --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/infrastructure/properties/FcmProperties.java @@ -0,0 +1,23 @@ +package kr.codesquad.secondhand.infrastructure.properties; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +@Getter +@ConfigurationProperties("fcm") +public class FcmProperties { + + private static final String MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"; + + private final String privateKeyPath; + private final long expirationMillis; + private final String[] scopes; + + @ConstructorBinding + public FcmProperties(String privateKeyPath, long expirationMillis) { + this.privateKeyPath = privateKeyPath; + this.expirationMillis = expirationMillis; + this.scopes = new String[]{MESSAGING_SCOPE}; + } +} diff --git a/src/main/java/kr/codesquad/secondhand/presentation/FcmController.java b/src/main/java/kr/codesquad/secondhand/presentation/FcmController.java new file mode 100644 index 000000000..530f350a7 --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/presentation/FcmController.java @@ -0,0 +1,30 @@ +package kr.codesquad.secondhand.presentation; + +import kr.codesquad.secondhand.application.firebase.FcmTokenService; +import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenIssueResponse; +import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenUpdateRequest; +import kr.codesquad.secondhand.presentation.support.Auth; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import javax.validation.Valid; + +@RequiredArgsConstructor +@RequestMapping("/api/fcm-token") +@RestController +public class FcmController { + + private final FcmTokenService fcmTokenService; + + @PatchMapping + public void updateToken(@Valid @RequestBody FcmTokenUpdateRequest request, + @Auth Long memberId) { + fcmTokenService.updateToken(request.getToken(), memberId); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public FcmTokenIssueResponse issueToken() { + return fcmTokenService.issueToken(); + } +} diff --git a/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenIssueResponse.java b/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenIssueResponse.java new file mode 100644 index 000000000..d2af1897d --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenIssueResponse.java @@ -0,0 +1,11 @@ +package kr.codesquad.secondhand.presentation.dto.fcm; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class FcmTokenIssueResponse { + + private final String token; +} diff --git a/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenUpdateRequest.java b/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenUpdateRequest.java new file mode 100644 index 000000000..b148d3ebd --- /dev/null +++ b/src/main/java/kr/codesquad/secondhand/presentation/dto/fcm/FcmTokenUpdateRequest.java @@ -0,0 +1,13 @@ +package kr.codesquad.secondhand.presentation.dto.fcm; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import javax.validation.constraints.NotEmpty; + +@Getter +@NoArgsConstructor +public class FcmTokenUpdateRequest { + + @NotEmpty(message = "토큰은 필수 입력 값입니다.") + private String token; +} diff --git a/src/main/resources/secret b/src/main/resources/secret index ef25c60ca..a02f7c5cf 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit ef25c60caa43b02f5119eb1a71522e9c253569ea +Subproject commit a02f7c5cf4ae02755f8d3678b248ea028e2c7ac3 diff --git a/src/test/java/kr/codesquad/secondhand/acceptance/FcmAcceptanceTest.java b/src/test/java/kr/codesquad/secondhand/acceptance/FcmAcceptanceTest.java new file mode 100644 index 000000000..42618ccfe --- /dev/null +++ b/src/test/java/kr/codesquad/secondhand/acceptance/FcmAcceptanceTest.java @@ -0,0 +1,37 @@ +package kr.codesquad.secondhand.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.RestAssured; +import kr.codesquad.secondhand.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import java.util.Map; + +public class FcmAcceptanceTest extends AcceptanceTestSupport { + + @DisplayName("FCM 토큰을 저장하는데 성공한다.") + @Test + void saveFcmToken() { + // given + Member member = signup(); + String tokenValue = "testTokenValue"; + + var request = RestAssured + .given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtProvider.createAccessToken(member.getId())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("token", tokenValue)); + + // when + var response = request + .patch("/api/fcm-token") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(200); + } +} diff --git a/src/test/java/kr/codesquad/secondhand/application/firebase/FcmTokenServiceTest.java b/src/test/java/kr/codesquad/secondhand/application/firebase/FcmTokenServiceTest.java new file mode 100644 index 000000000..c90bb19c2 --- /dev/null +++ b/src/test/java/kr/codesquad/secondhand/application/firebase/FcmTokenServiceTest.java @@ -0,0 +1,35 @@ +package kr.codesquad.secondhand.application.firebase; + +import static org.assertj.core.api.Assertions.assertThat; + +import kr.codesquad.secondhand.application.ApplicationTestSupport; +import kr.codesquad.secondhand.domain.member.Member; +import kr.codesquad.secondhand.fixture.FixtureFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class FcmTokenServiceTest extends ApplicationTestSupport { + + @Autowired + private FcmTokenService fcmTokenService; + + @Autowired + private RedisTemplate redisTemplate; + + @DisplayName("토큰을 저장하는데 성공한다.") + @Test + void givenTokenValue_whenUpdateToken_thenSuccess() { + // given + Member member = supportRepository.save(FixtureFactory.createMember()); + String tokenValue = "testTokenValue"; + + // when + fcmTokenService.updateToken(tokenValue, member.getId()); + + // then + String token = (String) redisTemplate.opsForValue().get("fcm_token:" + member.getId()); + assertThat(token).isNotBlank(); + } +} diff --git a/src/test/java/kr/codesquad/secondhand/config/FcmTestConfig.java b/src/test/java/kr/codesquad/secondhand/config/FcmTestConfig.java new file mode 100644 index 000000000..35bcc6eaf --- /dev/null +++ b/src/test/java/kr/codesquad/secondhand/config/FcmTestConfig.java @@ -0,0 +1,35 @@ +package kr.codesquad.secondhand.config; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import kr.codesquad.secondhand.infrastructure.fcm.MockGoogleCredentials; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ActiveProfiles; +import java.util.List; + +@ActiveProfiles("test") +@Configuration +public class FcmTestConfig { + + @Bean + public FirebaseMessaging firebaseMessaging() { + List apps = FirebaseApp.getApps(); + + return apps.stream() + .filter(app -> app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) + .map(FirebaseMessaging::getInstance) + .findFirst() + .orElseGet(this::createFirebaseMessaging); + } + + private FirebaseMessaging createFirebaseMessaging() { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .setProjectId("test-project-id") + .build(); + + return FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options)); + } +} diff --git a/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTest.java b/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTest.java index 20998fb99..07765136f 100644 --- a/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTest.java +++ b/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTest.java @@ -1,19 +1,12 @@ package kr.codesquad.secondhand.documentation; +import kr.codesquad.secondhand.presentation.*; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import kr.codesquad.secondhand.presentation.AuthController; -import kr.codesquad.secondhand.presentation.CategoryController; -import kr.codesquad.secondhand.presentation.ChatController; -import kr.codesquad.secondhand.presentation.ItemController; -import kr.codesquad.secondhand.presentation.MemberController; -import kr.codesquad.secondhand.presentation.ResidenceController; -import kr.codesquad.secondhand.presentation.SalesHistoryController; -import kr.codesquad.secondhand.presentation.WishItemController; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -25,7 +18,8 @@ MemberController.class, ResidenceController.class, SalesHistoryController.class, - WishItemController.class + WishItemController.class, + FcmController.class }) @AutoConfigureRestDocs public @interface DocumentationTest { diff --git a/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTestSupport.java b/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTestSupport.java index 0a5a0a175..43113f349 100644 --- a/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTestSupport.java +++ b/src/test/java/kr/codesquad/secondhand/documentation/DocumentationTestSupport.java @@ -4,12 +4,12 @@ import static org.mockito.BDDMockito.given; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; import kr.codesquad.secondhand.application.auth.AuthService; import kr.codesquad.secondhand.application.auth.TokenService; import kr.codesquad.secondhand.application.category.CategoryService; import kr.codesquad.secondhand.application.chat.ChatLogService; import kr.codesquad.secondhand.application.chat.ChatRoomService; +import kr.codesquad.secondhand.application.firebase.FcmTokenService; import kr.codesquad.secondhand.application.image.ImageService; import kr.codesquad.secondhand.application.item.ItemReadFacade; import kr.codesquad.secondhand.application.item.ItemService; @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; +import java.util.Optional; @MockBean({ ChatLogService.class, @@ -38,7 +39,8 @@ ResidenceService.class, WishItemService.class, SalesHistoryService.class, - ItemReadFacade.class + ItemReadFacade.class, + FcmTokenService.class }) @DocumentationTest public abstract class DocumentationTestSupport { diff --git a/src/test/java/kr/codesquad/secondhand/documentation/fcm/FcmDocumentationTest.java b/src/test/java/kr/codesquad/secondhand/documentation/fcm/FcmDocumentationTest.java new file mode 100644 index 000000000..d0576b742 --- /dev/null +++ b/src/test/java/kr/codesquad/secondhand/documentation/fcm/FcmDocumentationTest.java @@ -0,0 +1,55 @@ +package kr.codesquad.secondhand.documentation.fcm; + +import static kr.codesquad.secondhand.documentation.support.ConstraintsHelper.withPath; +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.codesquad.secondhand.application.firebase.FcmTokenService; +import kr.codesquad.secondhand.documentation.DocumentationTestSupport; +import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenUpdateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; + +public class FcmDocumentationTest extends DocumentationTestSupport { + + @Autowired + private FcmTokenService fcmTokenService; + + @DisplayName("FCM 토큰 저장") + @Test + void saveFcmToken() throws Exception { + // given + willDoNothing().given(fcmTokenService).updateToken(anyString(), anyLong()); + + // when + var response = mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/fcm-token") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtProvider.createAccessToken(1L)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content("{\"token\": \"testTokenValue\"}")); + // then + var resultActions = response.andExpect(status().isOk()); + + // docs + resultActions + .andDo(document("fcm/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("액세스 토큰을 담는 인증 헤더") + ), + requestFields( + withPath("token", FcmTokenUpdateRequest.class).description("FCM 토큰 값") + ) + )); + + } +} diff --git a/src/test/java/kr/codesquad/secondhand/infrastructure/fcm/MockGoogleCredentials.java b/src/test/java/kr/codesquad/secondhand/infrastructure/fcm/MockGoogleCredentials.java new file mode 100644 index 000000000..b483a820c --- /dev/null +++ b/src/test/java/kr/codesquad/secondhand/infrastructure/fcm/MockGoogleCredentials.java @@ -0,0 +1,26 @@ +package kr.codesquad.secondhand.infrastructure.fcm; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +public class MockGoogleCredentials extends GoogleCredentials { + + private final String token; + private final long expiredTime; + + public MockGoogleCredentials(String token) { + this(token, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); + } + + public MockGoogleCredentials(String token, long expiredTime) { + this.token = token; + this.expiredTime = expiredTime; + } + + @Override + public AccessToken refreshAccessToken() { + return new AccessToken(token, new Date(expiredTime)); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index bba105541..df004e94c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -5,3 +5,7 @@ spring: custom: default-profile: https://e7.pngegg.com/pngimages/1000/665/png-clipart-computer-icons-profile-s-free-angle-sphere.png + +fcm: + private-key-path: private-key-path + expiration-millis: 3600000