From 436fb5ccc74a90353d661bc9e385d7eee4087cf6 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 30 Mar 2024 23:29:04 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20#34=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/resources/application.properties | 3 +++ 2 files changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 4a79f03..ce7a94c 100644 --- a/build.gradle +++ b/build.gradle @@ -77,6 +77,9 @@ dependencies { runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 142e73d..668671b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,6 +15,9 @@ spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} + spring.servlet.multipart.max-request-size=70MB spring.servlet.multipart.max-file-size=10MB From 2f2596a2b9dc9b21f905d2b41bf7f4c41b593113 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 31 Mar 2024 01:21:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20#34=20=EC=9D=BC=ED=9A=8C=EC=9A=A9?= =?UTF-8?q?=20=EB=B3=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/entity/UserAuthCode.java | 22 +++++++ .../UserAuthCodeRedisRepository.java | 35 ++++++++++++ .../repository/UserAuthCodeRepository.java | 12 ++++ .../common/config/ObjectMapperConfig.java | 20 +++++++ .../common/config/RedisTemplateConfig.java | 27 +++++++++ .../UserAuthCodeRedisRepositoryTest.java | 57 +++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 src/main/java/com/ajou/hertz/common/auth/entity/UserAuthCode.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRedisRepository.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRepository.java create mode 100644 src/main/java/com/ajou/hertz/common/config/ObjectMapperConfig.java create mode 100644 src/main/java/com/ajou/hertz/common/config/RedisTemplateConfig.java create mode 100644 src/test/java/com/ajou/hertz/integration/common/auth/repository/UserAuthCodeRedisRepositoryTest.java diff --git a/src/main/java/com/ajou/hertz/common/auth/entity/UserAuthCode.java b/src/main/java/com/ajou/hertz/common/auth/entity/UserAuthCode.java new file mode 100644 index 0000000..c95746c --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/entity/UserAuthCode.java @@ -0,0 +1,22 @@ +package com.ajou.hertz.common.auth.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class UserAuthCode implements Serializable { + + // Redis 뿐만 아니라, user auth code 자체의 유효 기간을 의미. + public static final int TTL_SECONDS = 300; + + private String code; + private String phoneNumber; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRedisRepository.java b/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRedisRepository.java new file mode 100644 index 0000000..4177e95 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRedisRepository.java @@ -0,0 +1,35 @@ +package com.ajou.hertz.common.auth.repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Repository +public class UserAuthCodeRedisRepository implements UserAuthCodeRepository { + + private final RedisTemplate redisTemplate; + + @Override + public UserAuthCode save(UserAuthCode userAuthCode) { + redisTemplate.opsForValue().set( + userAuthCode.getCode(), + userAuthCode, + UserAuthCode.TTL_SECONDS, + TimeUnit.SECONDS + ); + return userAuthCode; + } + + @Override + public Optional findByCode(String code) { + UserAuthCode userAuthCode = redisTemplate.opsForValue().get(code); + return Optional.ofNullable(userAuthCode); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRepository.java b/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRepository.java new file mode 100644 index 0000000..da39d5e --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/repository/UserAuthCodeRepository.java @@ -0,0 +1,12 @@ +package com.ajou.hertz.common.auth.repository; + +import java.util.Optional; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; + +public interface UserAuthCodeRepository { + + UserAuthCode save(UserAuthCode userAuthCode); + + Optional findByCode(String code); +} diff --git a/src/main/java/com/ajou/hertz/common/config/ObjectMapperConfig.java b/src/main/java/com/ajou/hertz/common/config/ObjectMapperConfig.java new file mode 100644 index 0000000..e340ce6 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/ObjectMapperConfig.java @@ -0,0 +1,20 @@ +package com.ajou.hertz.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + JavaTimeModule javaTimeModule = new JavaTimeModule(); + objectMapper.registerModules(javaTimeModule, new Jdk8Module()); + return objectMapper; + } +} diff --git a/src/main/java/com/ajou/hertz/common/config/RedisTemplateConfig.java b/src/main/java/com/ajou/hertz/common/config/RedisTemplateConfig.java new file mode 100644 index 0000000..dc6e35e --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/RedisTemplateConfig.java @@ -0,0 +1,27 @@ +package com.ajou.hertz.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Configuration +public class RedisTemplateConfig { + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper objectMapper + ) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, UserAuthCode.class)); + return redisTemplate; + } +} diff --git a/src/test/java/com/ajou/hertz/integration/common/auth/repository/UserAuthCodeRedisRepositoryTest.java b/src/test/java/com/ajou/hertz/integration/common/auth/repository/UserAuthCodeRedisRepositoryTest.java new file mode 100644 index 0000000..7f98902 --- /dev/null +++ b/src/test/java/com/ajou/hertz/integration/common/auth/repository/UserAuthCodeRedisRepositoryTest.java @@ -0,0 +1,57 @@ +package com.ajou.hertz.integration.common.auth.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; +import com.ajou.hertz.common.auth.repository.UserAuthCodeRedisRepository; + +@Disabled("TODO: 추후 testcontainers 또는 embedded redis 환경 구축 후 테스트 대상에 포함시킬 예정") +@DisplayName("[Integration] Repository(Redis) - user auth code") +@ActiveProfiles("test") +@SpringBootTest +class UserAuthCodeRedisRepositoryTest { + + private final UserAuthCodeRedisRepository sut; + + @Autowired + public UserAuthCodeRedisRepositoryTest(UserAuthCodeRedisRepository sut) { + this.sut = sut; + } + + @Test + void 유저_인증_코드를_저장한다() { + // given + UserAuthCode userAuthCode = new UserAuthCode("code", "01012345678", LocalDateTime.now()); + + // when + sut.save(userAuthCode); + + // then + Optional result = sut.findByCode(userAuthCode.getCode()); + assertThat(result).isNotNull(); + } + + @Test + void 주어진_코드로_유저_인증_코드를_단건_조회한다() { + // given + String code = "code"; + UserAuthCode userAuthCode = new UserAuthCode(code, "01012345678", LocalDateTime.now()); + sut.save(userAuthCode); + + // when + Optional result = sut.findByCode(code); + + // then + assertThat(result).isNotNull(); + } +} \ No newline at end of file From 25e77100c54a11d8c0f4c3debfe52c24e298614f Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 31 Mar 2024 01:22:28 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20#34=20NCP=20SENS=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B0=9C=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...hVariableEncodingExclusionInterceptor.java | 19 ++++ .../constant/CustomExceptionType.java | 1 + .../common/message/constant/MessageType.java | 7 ++ .../exception/SendMessageException.java | 14 +++ .../message/service/MessageService.java | 6 ++ .../message/service/NcpMessageService.java | 89 +++++++++++++++++ .../ncp/client/NcpShortMessageClient.java | 32 ++++++ .../ncp/constant/NcpMessageContentType.java | 7 ++ .../request/NcpSendShortMessageRequest.java | 41 ++++++++ .../response/NcpSendShortMessageResponse.java | 11 +++ .../common/properties/NcpProperties.java | 22 +++++ src/main/resources/application.properties | 5 + .../service/NcpMessageServiceTest.java | 99 +++++++++++++++++++ 13 files changed, 353 insertions(+) create mode 100644 src/main/java/com/ajou/hertz/common/config/PathVariableEncodingExclusionInterceptor.java create mode 100644 src/main/java/com/ajou/hertz/common/message/constant/MessageType.java create mode 100644 src/main/java/com/ajou/hertz/common/message/exception/SendMessageException.java create mode 100644 src/main/java/com/ajou/hertz/common/message/service/MessageService.java create mode 100644 src/main/java/com/ajou/hertz/common/message/service/NcpMessageService.java create mode 100644 src/main/java/com/ajou/hertz/common/ncp/client/NcpShortMessageClient.java create mode 100644 src/main/java/com/ajou/hertz/common/ncp/constant/NcpMessageContentType.java create mode 100644 src/main/java/com/ajou/hertz/common/ncp/dto/request/NcpSendShortMessageRequest.java create mode 100644 src/main/java/com/ajou/hertz/common/ncp/dto/response/NcpSendShortMessageResponse.java create mode 100644 src/main/java/com/ajou/hertz/common/properties/NcpProperties.java create mode 100644 src/test/java/com/ajou/hertz/unit/common/message/service/NcpMessageServiceTest.java diff --git a/src/main/java/com/ajou/hertz/common/config/PathVariableEncodingExclusionInterceptor.java b/src/main/java/com/ajou/hertz/common/config/PathVariableEncodingExclusionInterceptor.java new file mode 100644 index 0000000..5e7db51 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/PathVariableEncodingExclusionInterceptor.java @@ -0,0 +1,19 @@ +package com.ajou.hertz.common.config; + +import org.springframework.stereotype.Component; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +@Component +public class PathVariableEncodingExclusionInterceptor implements RequestInterceptor { + private static final String COLON = "%3A"; + + @Override + public void apply(final RequestTemplate template) { + final String path = template.path(); + if (path.contains(COLON)) { + template.uri(path.replaceAll(COLON, ":")); + } + } +} diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java index 319d35e..6193219 100644 --- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java +++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java @@ -21,6 +21,7 @@ public enum CustomExceptionType { MULTIPART_FILE_NOT_READABLE(1000, "파일을 읽을 수 없습니다. 올바른 파일인지 다시 확인 후 요청해주세요."), IO_PROCESSING(1001, "입출력 처리 중 오류가 발생했습니다."), + SEND_MESSAGE(1002, "문자 발송 과정에서 알 수 없는 에러가 발생했습니다."), /** * 로그인, 인증 관련 예외 diff --git a/src/main/java/com/ajou/hertz/common/message/constant/MessageType.java b/src/main/java/com/ajou/hertz/common/message/constant/MessageType.java new file mode 100644 index 0000000..fa08eed --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/message/constant/MessageType.java @@ -0,0 +1,7 @@ +package com.ajou.hertz.common.message.constant; + +public enum MessageType { + SMS, + LMS, + MMS +} diff --git a/src/main/java/com/ajou/hertz/common/message/exception/SendMessageException.java b/src/main/java/com/ajou/hertz/common/message/exception/SendMessageException.java new file mode 100644 index 0000000..25a19cb --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/message/exception/SendMessageException.java @@ -0,0 +1,14 @@ +package com.ajou.hertz.common.message.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; + +import com.ajou.hertz.common.exception.CustomException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class SendMessageException extends CustomException { + + public SendMessageException(@NonNull HttpStatus httpStatus, String optionalMessage) { + super(httpStatus, CustomExceptionType.SEND_MESSAGE, optionalMessage); + } +} diff --git a/src/main/java/com/ajou/hertz/common/message/service/MessageService.java b/src/main/java/com/ajou/hertz/common/message/service/MessageService.java new file mode 100644 index 0000000..4365a0e --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/message/service/MessageService.java @@ -0,0 +1,6 @@ +package com.ajou.hertz.common.message.service; + +public interface MessageService { + + void sendShortMessage(String message, String targetPhoneNumber); +} diff --git a/src/main/java/com/ajou/hertz/common/message/service/NcpMessageService.java b/src/main/java/com/ajou/hertz/common/message/service/NcpMessageService.java new file mode 100644 index 0000000..886d5ee --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/message/service/NcpMessageService.java @@ -0,0 +1,89 @@ +package com.ajou.hertz.common.message.service; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import com.ajou.hertz.common.message.exception.SendMessageException; +import com.ajou.hertz.common.ncp.client.NcpShortMessageClient; +import com.ajou.hertz.common.ncp.dto.request.NcpSendShortMessageRequest; +import com.ajou.hertz.common.ncp.dto.response.NcpSendShortMessageResponse; +import com.ajou.hertz.common.properties.NcpProperties; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class NcpMessageService implements MessageService { + + private final NcpShortMessageClient ncpShortMessageClient; + private final NcpProperties ncpProperties; + + @Override + public void sendShortMessage(String messageContent, String targetPhoneNumber) { + String currentTimeMills = Long.toString(System.currentTimeMillis()); + String signature = makeSignature( + HttpMethod.POST, + String.format("/sms/v2/services/%s/messages", ncpProperties.sens().sms().serviceId()), + currentTimeMills + ); + NcpSendShortMessageRequest sendMessageRequest = NcpSendShortMessageRequest.of( + ncpProperties.sens().sms().callingNumber(), + List.of(new NcpSendShortMessageRequest.NcpShortMessageInfo(targetPhoneNumber, messageContent)) + ); + + ResponseEntity messageSendResult = ncpShortMessageClient.sendMessage( + currentTimeMills, + ncpProperties.accessKey(), + signature, + ncpProperties.sens().sms().serviceId(), + sendMessageRequest + ); + + String requestId = messageSendResult.getBody() != null ? messageSendResult.getBody().requestId() : null; + if (!messageSendResult.getStatusCode().is2xxSuccessful()) { + throw new SendMessageException( + HttpStatus.valueOf(messageSendResult.getStatusCode().value()), + "requestId=" + requestId + ); + } + } + + private String makeSignature(HttpMethod httpMethod, String url, String timestamp) { + try { + String space = " "; + String newLine = "\n"; + + String message = httpMethod.toString() + + space + + url + + newLine + + timestamp + + newLine + + ncpProperties.accessKey(); + + SecretKeySpec signingKey = new SecretKeySpec( + ncpProperties.secretKey().getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + + byte[] rawHmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + + return Base64.encodeBase64String(rawHmac); + } catch (NoSuchAlgorithmException | InvalidKeyException ex) { + return ""; + } + } +} diff --git a/src/main/java/com/ajou/hertz/common/ncp/client/NcpShortMessageClient.java b/src/main/java/com/ajou/hertz/common/ncp/client/NcpShortMessageClient.java new file mode 100644 index 0000000..dd6f0fe --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/ncp/client/NcpShortMessageClient.java @@ -0,0 +1,32 @@ +package com.ajou.hertz.common.ncp.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.ajou.hertz.common.ncp.dto.request.NcpSendShortMessageRequest; +import com.ajou.hertz.common.ncp.dto.response.NcpSendShortMessageResponse; + +import feign.Param; + +@FeignClient( + name = "ncpSendMessageClient", + url = "https://sens.apigw.ntruss.com/sms" +) +public interface NcpShortMessageClient { + + @PostMapping( + value = "/v2/services/{serviceId}/messages", + headers = "Content-Type=application/json;charset=utf-8" + ) + ResponseEntity sendMessage( + @RequestHeader("x-ncp-apigw-timestamp") String timestamp, + @RequestHeader("x-ncp-iam-access-key") String accessKey, + @RequestHeader("x-ncp-apigw-signature-v2") String signature, + @PathVariable String serviceId, + @RequestBody NcpSendShortMessageRequest sendSmsMessageRequest + ); +} diff --git a/src/main/java/com/ajou/hertz/common/ncp/constant/NcpMessageContentType.java b/src/main/java/com/ajou/hertz/common/ncp/constant/NcpMessageContentType.java new file mode 100644 index 0000000..e0a81ef --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/ncp/constant/NcpMessageContentType.java @@ -0,0 +1,7 @@ +package com.ajou.hertz.common.ncp.constant; + +public enum NcpMessageContentType { + + COMM, + AD +} diff --git a/src/main/java/com/ajou/hertz/common/ncp/dto/request/NcpSendShortMessageRequest.java b/src/main/java/com/ajou/hertz/common/ncp/dto/request/NcpSendShortMessageRequest.java new file mode 100644 index 0000000..ee4e090 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/ncp/dto/request/NcpSendShortMessageRequest.java @@ -0,0 +1,41 @@ +package com.ajou.hertz.common.ncp.dto.request; + +import java.util.List; + +import com.ajou.hertz.common.message.constant.MessageType; +import com.ajou.hertz.common.ncp.constant.NcpMessageContentType; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class NcpSendShortMessageRequest { + + private MessageType type; + private NcpMessageContentType contentType; + private String from; + private String content; + private List messages; + + public static NcpSendShortMessageRequest of(String senderPhoneNumber, List messages) { + return new NcpSendShortMessageRequest( + MessageType.SMS, + NcpMessageContentType.COMM, + senderPhoneNumber, + "안녕하세요. 헤르츠입니다.", + messages + ); + } + + @AllArgsConstructor + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @Getter + public static class NcpShortMessageInfo { + private String to; + private String content; + } +} diff --git a/src/main/java/com/ajou/hertz/common/ncp/dto/response/NcpSendShortMessageResponse.java b/src/main/java/com/ajou/hertz/common/ncp/dto/response/NcpSendShortMessageResponse.java new file mode 100644 index 0000000..a6c3acd --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/ncp/dto/response/NcpSendShortMessageResponse.java @@ -0,0 +1,11 @@ +package com.ajou.hertz.common.ncp.dto.response; + +import java.time.LocalDateTime; + +public record NcpSendShortMessageResponse( + String requestId, + LocalDateTime requestTime, + String statusCode, + String statusName +) { +} diff --git a/src/main/java/com/ajou/hertz/common/properties/NcpProperties.java b/src/main/java/com/ajou/hertz/common/properties/NcpProperties.java new file mode 100644 index 0000000..8a9f6a6 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/properties/NcpProperties.java @@ -0,0 +1,22 @@ +package com.ajou.hertz.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("ncp") +public record NcpProperties( + String accessKey, + String secretKey, + Sens sens +) { + + public record Sens( + Sms sms + ) { + + public record Sms( + String serviceId, + String callingNumber + ) { + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 668671b..db2c01e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -39,3 +39,8 @@ aws.cloud-front.base-url=${AWS_CLOUD_FRONT_BASE_URL} kakao.rest-api-key=${KAKAO_REST_API_KEY} kakao.client-secret=${KAKAO_CLIENT_SECRET} + +ncp.access-key=${NCP_ACCESS_KEY} +ncp.secret-key=${NCP_SECRET_KEY} +ncp.sens.sms.service-id=${NCP_SERVICE_ID} +ncp.sens.sms.calling-number=${NCP_SENS_SMS_CALLING_NUMBER} diff --git a/src/test/java/com/ajou/hertz/unit/common/message/service/NcpMessageServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/message/service/NcpMessageServiceTest.java new file mode 100644 index 0000000..68e012e --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/common/message/service/NcpMessageServiceTest.java @@ -0,0 +1,99 @@ +package com.ajou.hertz.unit.common.message.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import com.ajou.hertz.common.message.exception.SendMessageException; +import com.ajou.hertz.common.message.service.NcpMessageService; +import com.ajou.hertz.common.ncp.client.NcpShortMessageClient; +import com.ajou.hertz.common.ncp.dto.response.NcpSendShortMessageResponse; +import com.ajou.hertz.common.properties.NcpProperties; + +@DisplayName("[Unit] Service - Ncp message") +@ExtendWith(MockitoExtension.class) +class NcpMessageServiceTest { + + @InjectMocks + private NcpMessageService sut; + + @Mock + private NcpShortMessageClient ncpShortMessageClient; + + @Mock + private NcpProperties ncpProperties; + @Mock + private NcpProperties.Sens sensProperties; + @Mock + private NcpProperties.Sens.Sms smsProperties; + + @BeforeEach + void setUpMockProperties() { + lenient().when(ncpProperties.accessKey()).thenReturn("ncp-access-key"); + lenient().when(ncpProperties.secretKey()).thenReturn("ncp-secret-key"); + lenient().when(ncpProperties.sens()).thenReturn(sensProperties); + lenient().when(ncpProperties.sens().sms()).thenReturn(smsProperties); + lenient().when(sensProperties.sms()).thenReturn(smsProperties); + lenient().when(smsProperties.serviceId()).thenReturn("ncp-service-id"); + lenient().when(smsProperties.callingNumber()).thenReturn("01013572468"); + } + + @Test + void 메시지_내용과_전화번호가_주어지고_주어진_전화번호에_메시지를_발송한다() { + // given + String messageContent = "content"; + String targetPhoneNumber = "01012345678"; + NcpSendShortMessageResponse sendMessageResponse = new NcpSendShortMessageResponse( + "request-id", + LocalDateTime.now(), + "status-code", + "status-name" + ); + given(ncpShortMessageClient.sendMessage(anyString(), anyString(), anyString(), anyString(), any())) + .willReturn(ResponseEntity.ok(sendMessageResponse)); + + // when + sut.sendShortMessage(messageContent, targetPhoneNumber); + + // then + then(ncpShortMessageClient).should().sendMessage(anyString(), anyString(), anyString(), anyString(), any()); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + @Test + void 메시지_내용과_전화번호가_주어지고_주어진_전화번호에_메시지를_발송한다_이때_메시지_발송_결과에_오류가_있을_경우_예외가_발생한다() { + // given + String messageContent = "content"; + String targetPhoneNumber = "01012345678"; + NcpSendShortMessageResponse sendMessageResponse = new NcpSendShortMessageResponse( + "request-id", + LocalDateTime.now(), + "status-code", + "status-name" + ); + given(ncpShortMessageClient.sendMessage(anyString(), anyString(), anyString(), anyString(), any())) + .willReturn(ResponseEntity.badRequest().body(sendMessageResponse)); + + // when + Throwable ex = catchThrowable(() -> sut.sendShortMessage(messageContent, targetPhoneNumber)); + + // then + then(ncpShortMessageClient).should().sendMessage(anyString(), anyString(), anyString(), anyString(), any()); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(ex).isInstanceOf(SendMessageException.class); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { + then(ncpShortMessageClient).shouldHaveNoMoreInteractions(); + } +} \ No newline at end of file From 815f7d0b0b7066393bd9390557699831d03ab3b5 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 31 Mar 2024 01:23:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20#34=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20business=20=20logic?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++ .../auth/service/UserAuthCodeService.java | 37 +++++++++++++ .../auth/service/UserAuthCodeServiceTest.java | 53 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/main/java/com/ajou/hertz/common/auth/service/UserAuthCodeService.java create mode 100644 src/test/java/com/ajou/hertz/unit/common/auth/service/UserAuthCodeServiceTest.java diff --git a/build.gradle b/build.gradle index ce7a94c..ff64749 100644 --- a/build.gradle +++ b/build.gradle @@ -154,6 +154,11 @@ jacocoTestCoverageVerification { '*common.entity.FullAddress*', '*repository.*Repository*' ] + + excludes = [ + '*service.NcpMessageService', + '*repository.UserAuthCodeRedisRepository' + ] } } } diff --git a/src/main/java/com/ajou/hertz/common/auth/service/UserAuthCodeService.java b/src/main/java/com/ajou/hertz/common/auth/service/UserAuthCodeService.java new file mode 100644 index 0000000..7bbb824 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/service/UserAuthCodeService.java @@ -0,0 +1,37 @@ +package com.ajou.hertz.common.auth.service; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; +import com.ajou.hertz.common.auth.repository.UserAuthCodeRepository; +import com.ajou.hertz.common.message.service.MessageService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class UserAuthCodeService { + + private static final int USER_AUTH_CODE_LEN = 8; + + private final MessageService messageService; + private final UserAuthCodeRepository userAuthCodeRepository; + + public void sendUserAuthCodeViaSms(@NonNull String targetPhoneNumber) { + String authCode = generateRandomAuthCode(); + UserAuthCode userAuthCode = new UserAuthCode(authCode, targetPhoneNumber, LocalDateTime.now()); + userAuthCodeRepository.save(userAuthCode); + messageService.sendShortMessage( + String.format("[헤르츠] 본인 확인을 위해 인증코드 %s 를 입력해주세요.", authCode), + targetPhoneNumber + ); + } + + private String generateRandomAuthCode() { + return UUID.randomUUID().toString().substring(0, USER_AUTH_CODE_LEN); + } +} diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/service/UserAuthCodeServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/auth/service/UserAuthCodeServiceTest.java new file mode 100644 index 0000000..8c96498 --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/common/auth/service/UserAuthCodeServiceTest.java @@ -0,0 +1,53 @@ +package com.ajou.hertz.unit.common.auth.service; + +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ajou.hertz.common.auth.entity.UserAuthCode; +import com.ajou.hertz.common.auth.repository.UserAuthCodeRepository; +import com.ajou.hertz.common.auth.service.UserAuthCodeService; +import com.ajou.hertz.common.message.service.MessageService; + +@DisplayName("[Unit] Service - User auth code") +@ExtendWith(MockitoExtension.class) +class UserAuthCodeServiceTest { + + @InjectMocks + private UserAuthCodeService sut; + + @Mock + private MessageService messageService; + + @Mock + private UserAuthCodeRepository userAuthCodeRepository; + + @Test + void 랜덤한_인증_코드를_생성_및_저장하고_전달된_전화번호로_인증코드를_발송한다() { + // given + String targetPhoneNumber = "01012345678"; + given(userAuthCodeRepository.save(any(UserAuthCode.class))) + .willReturn(new UserAuthCode("code", targetPhoneNumber, LocalDateTime.now())); + willDoNothing().given(messageService).sendShortMessage(anyString(), eq(targetPhoneNumber)); + + // when + sut.sendUserAuthCodeViaSms(targetPhoneNumber); + + // then + then(userAuthCodeRepository).should().save(any(UserAuthCode.class)); + then(messageService).should().sendShortMessage(anyString(), eq(targetPhoneNumber)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { + then(messageService).shouldHaveNoMoreInteractions(); + then(userAuthCodeRepository).shouldHaveNoMoreInteractions(); + } +} \ No newline at end of file From 6649cd47dd4816e553fed3aac54376bced438280 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 31 Mar 2024 01:23:33 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20#34=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 27 ++++++++++++++--- .../dto/request/SendUserAuthCodeRequest.java | 21 ++++++++++++++ .../hertz/common/config/SecurityConfig.java | 1 + .../auth/controller/AuthControllerTest.java | 29 +++++++++++++++++++ .../com/ajou/hertz/util/ReflectionUtils.java | 8 +++++ 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/common/auth/dto/request/SendUserAuthCodeRequest.java diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthController.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthController.java index 2c6e231..094e62e 100644 --- a/src/main/java/com/ajou/hertz/common/auth/controller/AuthController.java +++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import static com.ajou.hertz.common.constant.GlobalConstants.*; +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; @@ -10,8 +11,10 @@ import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.dto.request.SendUserAuthCodeRequest; import com.ajou.hertz.common.auth.dto.response.JwtTokenInfoResponse; import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.common.auth.service.UserAuthCodeService; import com.ajou.hertz.common.kakao.service.KakaoService; import io.swagger.v3.oas.annotations.Operation; @@ -30,6 +33,7 @@ public class AuthController { private final AuthService authService; private final KakaoService kakaoService; + private final UserAuthCodeService userAuthCodeService; @Operation( summary = "로그인", @@ -52,13 +56,11 @@ public JwtTokenInfoResponse loginV1(@RequestBody @Valid LoginRequest loginReques ) @ApiResponses({ @ApiResponse(responseCode = "200"), - @ApiResponse( - responseCode = "409", content = @Content, + @ApiResponse(responseCode = "409", content = @Content, description = """

[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우

[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우 - """ - ), + """), @ApiResponse(responseCode = "Any", description = "[10000] 카카오 서버와의 통신 중 오류가 발생한 경우. Http status code는 kakao에서 응답받은 것과 동일하게 설정하여 응답한다.", content = @Content) }) @PostMapping(value = "/kakao/login", headers = API_VERSION_HEADER_NAME + "=" + 1) @@ -66,4 +68,21 @@ public JwtTokenInfoResponse kakaoLoginV1(@RequestBody @Valid KakaoLoginRequest k JwtTokenInfoDto jwtTokenInfoDto = kakaoService.login(kakaoLoginRequest); return JwtTokenInfoResponse.from(jwtTokenInfoDto); } + + @Operation( + summary = "일회용 본인 인증 코드 발송 (SMS)", + description = """ +

전달받은 전화번호로 일회용 본인 인증 코드를 발송합니다. +

인증 코드의 유효 기간은 5분입니다. 인증 코드가 만료된 경우, 다시 인증 코드를 발급받아야 합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "Any", description = "[1002] 인증 코드 문자 발송 과정 중 오류가 발생한 경우", content = @Content) + }) + @PostMapping(value = "/codes/send", headers = API_VERSION_HEADER_NAME + "=" + 1) + public ResponseEntity sendUserAuthCodeV1(@RequestBody @Valid SendUserAuthCodeRequest sendCodeRequest) { + userAuthCodeService.sendUserAuthCodeViaSms(sendCodeRequest.getPhoneNumber()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/request/SendUserAuthCodeRequest.java b/src/main/java/com/ajou/hertz/common/auth/dto/request/SendUserAuthCodeRequest.java new file mode 100644 index 0000000..2b9389a --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/dto/request/SendUserAuthCodeRequest.java @@ -0,0 +1,21 @@ +package com.ajou.hertz.common.auth.dto.request; + +import com.ajou.hertz.common.validator.PhoneNumber; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class SendUserAuthCodeRequest { + + @Schema(description = "본인 인증 코드를 발송할 전화번호", example = "01012345678") + @NotBlank + @PhoneNumber + private String phoneNumber; +} diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java index e477df1..17aa806 100644 --- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java @@ -46,6 +46,7 @@ public class SecurityConfig { AUTH_WHITE_LIST.put("/api/auth/login", POST); AUTH_WHITE_LIST.put("/api/auth/kakao/login", POST); AUTH_WHITE_LIST.put("/api/users", POST); + AUTH_WHITE_LIST.put("/api/auth/codes/send", POST); AUTH_WHITE_LIST.put("/api/users/existence", GET); AUTH_WHITE_LIST.put("/api/users/email", GET); AUTH_WHITE_LIST.put("/api/administrative-areas/sido", GET); diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerTest.java b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerTest.java index 36bfdf0..4b538fb 100644 --- a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerTest.java @@ -20,7 +20,9 @@ import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.dto.request.SendUserAuthCodeRequest; import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.common.auth.service.UserAuthCodeService; import com.ajou.hertz.common.kakao.service.KakaoService; import com.ajou.hertz.config.ControllerTestConfig; import com.ajou.hertz.util.ReflectionUtils; @@ -37,6 +39,9 @@ class AuthControllerTest { @MockBean private KakaoService kakaoService; + @MockBean + private UserAuthCodeService userAuthCodeService; + private final MockMvc mvc; private final ObjectMapper objectMapper; @@ -87,9 +92,29 @@ public AuthControllerTest(MockMvc mvc, ObjectMapper objectMapper) { verifyEveryMocksShouldHaveNoMoreInteractions(); } + @Test + void 전달된_전화번호로_일회용_본인_인증_코드를_발송한다() throws Exception { + // given + String phoneNumber = "01012345678"; + SendUserAuthCodeRequest sendCodeRequest = createSendUserAuthCodeRequest(phoneNumber); + willDoNothing().given(userAuthCodeService).sendUserAuthCodeViaSms(phoneNumber); + + // when & then + mvc.perform( + post("/api/auth/codes/send") + .header(API_VERSION_HEADER_NAME, 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(sendCodeRequest)) + ) + .andExpect(status().isNoContent()); + then(userAuthCodeService).should().sendUserAuthCodeViaSms(phoneNumber); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(authService).shouldHaveNoMoreInteractions(); then(kakaoService).shouldHaveNoMoreInteractions(); + then(userAuthCodeService).shouldHaveNoMoreInteractions(); } private LoginRequest createLoginRequest() throws Exception { @@ -100,6 +125,10 @@ private static KakaoLoginRequest createKakaoLoginRequest() throws Exception { return ReflectionUtils.createKakaoLoginRequest("authorization-code", "https://redirect-uri"); } + private SendUserAuthCodeRequest createSendUserAuthCodeRequest(String targetPhoneNumber) throws Exception { + return ReflectionUtils.createSendUserAuthCodeRequest(targetPhoneNumber); + } + private JwtTokenInfoDto createJwtTokenInfoDto() { return new JwtTokenInfoDto( "access-token", diff --git a/src/test/java/com/ajou/hertz/util/ReflectionUtils.java b/src/test/java/com/ajou/hertz/util/ReflectionUtils.java index 23fad3b..50e383b 100644 --- a/src/test/java/com/ajou/hertz/util/ReflectionUtils.java +++ b/src/test/java/com/ajou/hertz/util/ReflectionUtils.java @@ -11,6 +11,7 @@ import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.dto.request.SendUserAuthCodeRequest; import com.ajou.hertz.common.dto.AddressDto; import com.ajou.hertz.common.dto.request.AddressRequest; import com.ajou.hertz.common.entity.Address; @@ -880,6 +881,13 @@ public static ElectricGuitarUpdateRequest createElectricGuitarUpdateRequest( ); } + public static SendUserAuthCodeRequest createSendUserAuthCodeRequest(String phoneNumber) throws Exception { + Constructor constructor = + SendUserAuthCodeRequest.class.getDeclaredConstructor(String.class); + constructor.setAccessible(true); + return constructor.newInstance(phoneNumber); + } + /** * DTO(Response) */