diff --git a/.env.example b/.env.example index 9861f36..ad64c43 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,9 @@ KAKAO_CLIENT_SECRET=your_kakao_client_secret JWT_SECRET=your_very_long_and_secret_random_string_for_signing_tokens_at_least_64_characters # 6. Frontend Redirect URL -OAUTH2_REDIRECT_URL=http://localhost:3000/oauth2/redirect \ No newline at end of file +OAUTH2_REDIRECT_URL=http://localhost:3000/oauth2/redirect + +# 7. SMS +SMS_API_KEY=your_sms_api_key +SMS_SECRET_KEY=your_sms_secret_key +SMS_SENDER_NUMBER=01012345678 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad380ff..9738488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,4 +51,7 @@ jobs: REDIS_PORT: 6379 MAIL_USERNAME: test@example.com MAIL_PASSWORD: test_password - JWT_SECRET: dGhpcy1pcy1hLXRlc3Qtc2VjcmV0LWtleS1mb3ItY2ktdGVzdC1wdXJwb3Nlcw== \ No newline at end of file + JWT_SECRET: dGhpcy1pcy1hLXRlc3Qtc2VjcmV0LWtleS1mb3ItY2ktdGVzdC1wdXJwb3Nlcw== + SMS_API_KEY: test_api_key + SMS_SECRET_KEY: test_secret_key + SMS_SENDER_NUMBER: 01000000000 \ No newline at end of file diff --git a/build.gradle b/build.gradle index eed250c..2fb534a 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,12 @@ dependencies { //Email implementation 'org.springframework.boot:spring-boot-starter-mail' + + // SMS + implementation 'net.nurigo:sdk:4.3.0' + + // Dotenv + implementation 'me.paulschwarz:spring-dotenv:4.0.0' } tasks.named('test') { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/SmsRequest.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/SmsRequest.java new file mode 100644 index 0000000..6faa7f2 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/SmsRequest.java @@ -0,0 +1,16 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class SmsRequest { + public record SmsSendRequest( + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^\\d{10,11}$", message = "전화번호는 하이픈 없이 10~11자리 숫자만 입력해주세요.") String phoneNumber) { + } + + public record SmsVerifyRequest( + @NotBlank(message = "전화번호는 필수입니다.") String phoneNumber, + @NotBlank(message = "인증코드는 필수입니다.") String verificationCode) { + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/SmsResponse.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/SmsResponse.java new file mode 100644 index 0000000..847c155 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/SmsResponse.java @@ -0,0 +1,16 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.response; + +public class SmsResponse { + + public record SmsSentResponse( + String message, + String phoneNumber, // 전송한 휴대폰 번호 + long expireIn) { + } + + public record SmsVerifiedResponse( + boolean isVerified, + String verificationMessage, + String email) { + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/SmsService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/SmsService.java new file mode 100644 index 0000000..bd97629 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/SmsService.java @@ -0,0 +1,95 @@ +package com.whereyouad.WhereYouAd.domains.user.domain.service; + +import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; +import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; +import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import net.nurigo.sdk.NurigoApp; +import net.nurigo.sdk.message.model.Message; +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SmsResponse; +import com.whereyouad.WhereYouAd.global.utils.RedisUtil; +import net.nurigo.sdk.message.request.SingleMessageSendingRequest; +import net.nurigo.sdk.message.service.DefaultMessageService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Random; + +@Service +@Transactional +@Slf4j +public class SmsService { + private final RedisUtil redisUtil; + private final UserRepository userRepository; + private final DefaultMessageService defaultMessageService; + private final String senderNumber; + + public SmsService(RedisUtil redisUtil, UserRepository userRepository, + @Value("${coolSms.apiKey}") String smsApiKey, + @Value("${coolSms.secretKey}") String smsSecretKey, + @Value("${coolSms.senderNumber}") String senderNumber) { + this.redisUtil = redisUtil; + this.userRepository = userRepository; + this.senderNumber = senderNumber; + this.defaultMessageService = NurigoApp.INSTANCE.initialize(smsApiKey, smsSecretKey, + "https://api.coolsms.co.kr"); + } + + // SMS 전송 + public SmsResponse.SmsSentResponse sendSms(String phoneNumber) { + // 이메일 찾기 기능: 가입된 유저인지 확인 + if (!userRepository.existsByPhoneNumber(phoneNumber)) { + throw new UserHandler(UserErrorCode.USER_NOT_FOUND_BY_PHONE); + } + + Message message = new Message(); + String verificationCode = generateCode(); + + message.setFrom(senderNumber); // 발신 번호 + message.setTo(phoneNumber); + message.setText("[Where You Ad] 이메일 찾기\n 휴대폰 인증 번호는 [" + verificationCode + "] 입니다."); + + // redis 저장 + redisUtil.setDataExpire(phoneNumber, verificationCode, 180); // 유효 기간 3분 + + try { + this.defaultMessageService.sendOne(new SingleMessageSendingRequest(message)); + } catch (Exception e) { + log.error(e.getMessage()); + throw new UserHandler(UserErrorCode.SMS_SEND_FAILED); + } + + return new SmsResponse.SmsSentResponse("인증번호가 전송되었습니다.", phoneNumber, 180L); + } + + // 랜덤 인증 번호 생성 + public String generateCode() { + Random random = new Random(); + int code = 100000 + random.nextInt(900000); // 6자리 인증 번호 + return Integer.toString(code); + } + + // 인증번호 확인 + public boolean verifyCode(String phoneNumber, String insertedNum) { + String savedCode = redisUtil.getData(phoneNumber); + + if (savedCode != null && savedCode.equals(insertedNum)) { + redisUtil.deleteData(phoneNumber); + // 인증 완료 + return true; + } + return false; + } + + // 인증번호 확인 후 유저 이메일 반환 + public String isPhoneVerified(String phoneNumber, String insertedNum) { + if (verifyCode(phoneNumber, insertedNum)) { + User user = userRepository.findUserByPhoneNumber(phoneNumber) + .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND_BY_PHONE)); + return user.getEmail(); + } else + throw new UserHandler(UserErrorCode.USER_SMS_NOT_VERIFIED); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java index 2ac2870..84b653e 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java @@ -8,7 +8,7 @@ @Getter @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { - //400 + // 400 USER_EMAIL_DUPLICATE(HttpStatus.BAD_REQUEST, "USER_400_1", "이미 사용중인 이메일 입니다."), USER_EMAIL_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_400_2", "해당 이메일로 메일 전송에 실패했습니다."), USER_EMAIL_AUTH_INVALID(HttpStatus.BAD_REQUEST, "USER_400_3", "인증 코드가 올바르지 않습니다."), @@ -17,10 +17,14 @@ public enum UserErrorCode implements BaseErrorCode { // 401 USER_EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "USER_401_1", "이메일 인증이 진행되지 않았습니다."), + USER_SMS_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "USER_401_2", "휴대폰 인증이 진행되지 않았습니다."), // 404 - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "이메일에 해당하는 사용자를 찾을 수 없습니다.") - ; + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "이메일에 해당하는 사용자를 찾을 수 없습니다."), + USER_NOT_FOUND_BY_PHONE(HttpStatus.NOT_FOUND, "USER_404_2", "해당 전화번호를 가진 사용자를 찾을 수 없습니다."), + + // 500 + SMS_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_1", "문자 전송에 실패했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/UserRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/UserRepository.java index d6f0011..7d31eea 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/UserRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/repository/UserRepository.java @@ -11,6 +11,11 @@ public interface UserRepository extends JpaRepository { @Query("select u from User u where u.email = :email") Optional findUserByEmail(@Param("email") String email); - //이메일 중복 예외 처리를 위한 이메일로 조회 메서드 + @Query("select u from User u where u.phoneNumber = :phoneNumber") + Optional findUserByPhoneNumber(@Param("phoneNumber") String phoneNumber); + + boolean existsByPhoneNumber(String phoneNumber); + + // 이메일 중복 예외 처리를 위한 이메일로 조회 메서드 boolean existsByEmail(String email); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java index 78f5163..729653e 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java @@ -1,9 +1,12 @@ package com.whereyouad.WhereYouAd.domains.user.presentation; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SmsRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SmsResponse; import com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService; +import com.whereyouad.WhereYouAd.domains.user.domain.service.SmsService; import com.whereyouad.WhereYouAd.domains.user.domain.service.UserService; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; @@ -24,6 +27,7 @@ public class UserController implements UserControllerDocs { private final UserService userService; private final EmailService emailService; + private final SmsService smsService; @PostMapping("/signup") public ResponseEntity> signUp(@RequestBody @Valid SignUpRequest request) { @@ -46,8 +50,21 @@ public ResponseEntity> verifyEmail(@RequestBody @Valid Emai emailService.verifyEmailCode(request.email(), request.authCode()); return ResponseEntity.ok( - DataResponse.from("이메일 인증이 성공적으로 완료되었습니다.") - ); + DataResponse.from("이메일 인증이 성공적으로 완료되었습니다.")); + } + + @PostMapping("/sms-send") + public ResponseEntity> sendSms( + @RequestBody @Valid SmsRequest.SmsSendRequest request) { + SmsResponse.SmsSentResponse smsSentResponse = smsService.sendSms(request.phoneNumber()); + return ResponseEntity.ok(DataResponse.from(smsSentResponse)); + } + + @PostMapping("/sms-verify") + public ResponseEntity> verifySms( + @RequestBody @Valid SmsRequest.SmsVerifyRequest request) { + String email = smsService.isPhoneVerified(request.phoneNumber(), request.verificationCode()); + return ResponseEntity.ok(DataResponse.from(new SmsResponse.SmsVerifiedResponse(true, "이메일 찾기 성공", email))); } @PostMapping("/password-reset/request") diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java index 2eca79f..ce462ed 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java @@ -1,9 +1,11 @@ package com.whereyouad.WhereYouAd.domains.user.presentation.docs; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SmsRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SmsResponse; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; import com.whereyouad.WhereYouAd.global.response.DataResponse; import io.swagger.v3.oas.annotations.Operation; @@ -67,4 +69,20 @@ public interface UserControllerDocs { @ApiResponse(responseCode = "404_1", description = "해당 이메일로 가입한 회원 존재하지 않음") }) public ResponseEntity> resetPassword(@RequestBody @Valid PwdResetRequest request); + + @Operation(summary = "이메일 찾기 - SMS 인증 번호 전송 API", description = "입력받은 전화번호로 인증 번호를 전송합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "실패") + }) + public ResponseEntity> sendSms( + @RequestBody @Valid SmsRequest.SmsSendRequest request); + + @Operation(summary = "이메일 찾기 - SMS 인증 번호 확인 API", description = "전화번호와 인증 코드를 받아 맞는지 검증합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "실패") + }) + public ResponseEntity> verifySms( + @RequestBody @Valid SmsRequest.SmsVerifyRequest request); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 17b01d1..2528346 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -98,4 +98,10 @@ jwt: # 7. OAuth2 로그인 성공 후 프론트엔드 리다이렉트 URL oauth2: - redirect-url: ${OAUTH2_REDIRECT_URL:http://localhost:3000/oauth2/redirect} \ No newline at end of file + redirect-url: ${OAUTH2_REDIRECT_URL:http://localhost:3000/oauth2/redirect} + +# 8. SMS 인증 +coolSms: + apiKey: ${SMS_API_KEY} + secretKey: ${SMS_SECRET_KEY} + senderNumber: ${SMS_SENDER_NUMBER} \ No newline at end of file