diff --git a/build.gradle b/build.gradle
index 4a79f03..ff64749 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'
@@ -151,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/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/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/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/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/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/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/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/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 142e73d..db2c01e 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
@@ -36,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/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
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/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
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
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)
*/