diff --git a/build.gradle b/build.gradle index 1f483a7c..cf3de54a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,9 @@ repositories { } dependencies { + // coolsms + implementation group: 'io.awspring.cloud', name: 'spring-cloud-aws-messaging', version: '2.3.1' + //JWT implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' diff --git a/src/main/java/com/moddy/server/common/dto/SuccessNonDataResponse.java b/src/main/java/com/moddy/server/common/dto/SuccessNonDataResponse.java index 56d6dd6e..01fc22a0 100644 --- a/src/main/java/com/moddy/server/common/dto/SuccessNonDataResponse.java +++ b/src/main/java/com/moddy/server/common/dto/SuccessNonDataResponse.java @@ -6,11 +6,11 @@ @Getter @ToString @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class SuccessNonDataResponse { +public class SuccessNonDataResponse { private final int code; private final String message; public static SuccessNonDataResponse success(SuccessCode successCode) { - return new SuccessNonDataResponse<>(successCode.getHttpStatus().value(), successCode.getMessage()); + return new SuccessNonDataResponse(successCode.getHttpStatus().value(), successCode.getMessage()); } } \ No newline at end of file diff --git a/src/main/java/com/moddy/server/common/exception/enums/ErrorCode.java b/src/main/java/com/moddy/server/common/exception/enums/ErrorCode.java index 4b2addf9..80c7ca45 100644 --- a/src/main/java/com/moddy/server/common/exception/enums/ErrorCode.java +++ b/src/main/java/com/moddy/server/common/exception/enums/ErrorCode.java @@ -13,6 +13,8 @@ public enum ErrorCode { INVALID_TOKEN_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 토큰을 입력했습니다."), EMPTY_KAKAO_CODE_EXCEPTION(HttpStatus.BAD_REQUEST, "카카오 코드 값을 입력해 주세요."), INVALID_KAKAO_CODE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 카카오 코드를 입력했습니다."), + NOT_MATCH_VERIFICATION_CODE_EXCEPTION(HttpStatus.BAD_REQUEST, "인증번호가 일치하지 않습니다."), + EXPIRE_VERIFICATION_CODE_EXCEPTION(HttpStatus.BAD_REQUEST, "만료된 인증 코드입니다."), // 401 TOKEN_NOT_CONTAINED_EXCEPTION(HttpStatus.UNAUTHORIZED, "Access Token이 필요합니다."), @@ -30,6 +32,7 @@ public enum ErrorCode { NOT_FOUND_APPLICATION_EXCEPTION(HttpStatus.NOT_FOUND, "해당 지원서를 찾을 수 없습니다."), NOT_FOUND_RESOURCE_EXCEPTION(HttpStatus.NOT_FOUND, "해당 자원을 찾을 수 없습니다."), NOT_FOUND_REGION_EXCEPTION(HttpStatus.NOT_FOUND, "해당 지역을 찾을 수 없습니다."), + NOT_FOUND_VERIFICATION_CODE_EXCEPTION(HttpStatus.NOT_FOUND, "인증 코드가 존재하지 않습니다."), // 405 METHOD_NOT_ALLOWED METHOD_NOT_ALLOWED_EXCEPTION(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 메소드 입니다."), diff --git a/src/main/java/com/moddy/server/common/exception/enums/SuccessCode.java b/src/main/java/com/moddy/server/common/exception/enums/SuccessCode.java index a072bd9d..c6005f29 100644 --- a/src/main/java/com/moddy/server/common/exception/enums/SuccessCode.java +++ b/src/main/java/com/moddy/server/common/exception/enums/SuccessCode.java @@ -18,8 +18,10 @@ public enum SuccessCode { OPEN_CHAT_GET_SUCCESS(HttpStatus.OK, "오픈채팅방 연결 성공"), USER_MY_PAGE_SUCCESS(HttpStatus.OK, "마이페이지 유저 정보 조회 성공입니다."), OFFER_ACCEPT_SUCCESS(HttpStatus.OK, "제안서 승락 성공입니다."), + POST_OFFER_SUCCESS(HttpStatus.OK, "제안서 작성 성공입니다."), + SEND_VERIFICATION_CODE_SUCCESS(HttpStatus.OK, "전화번호 인증 요청 성공입니다."), FIND_REGION_LIST_SUCCESS(HttpStatus.OK, "희망 지역 리스트 조회 성공입니다."), - POST_OFFER_SUCCESS(HttpStatus.OK, "제안서 작성 성공입니다."); + VERIFICATION_CODE_MATCH_SUCCESS(HttpStatus.OK, "전화번호 인증 성공입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/moddy/server/common/util/SmsUtil.java b/src/main/java/com/moddy/server/common/util/SmsUtil.java new file mode 100644 index 00000000..dd81c39c --- /dev/null +++ b/src/main/java/com/moddy/server/common/util/SmsUtil.java @@ -0,0 +1,15 @@ +package com.moddy.server.common.util; + +import com.moddy.server.external.sms.SmsService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SmsUtil { + private final SmsService smsService; + + public boolean sendVerificationCode(String to, String verificationCode) { + return smsService.sendSms(to, verificationCode); + } +} diff --git a/src/main/java/com/moddy/server/config/sms/AwsSnsConfig.java b/src/main/java/com/moddy/server/config/sms/AwsSnsConfig.java new file mode 100644 index 00000000..66d14c62 --- /dev/null +++ b/src/main/java/com/moddy/server/config/sms/AwsSnsConfig.java @@ -0,0 +1,27 @@ +package com.moddy.server.config.sms; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.sns.AmazonSNSClient; +import com.amazonaws.services.sns.AmazonSNSClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AwsSnsConfig { + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Bean + public AmazonSNSClient amazonSNSClient() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonSNSClient) AmazonSNSClientBuilder + .standard() + .withRegion("ap-northeast-1") + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/moddy/server/controller/auth/AuthController.java b/src/main/java/com/moddy/server/controller/auth/AuthController.java index 292c0be3..600fb541 100644 --- a/src/main/java/com/moddy/server/controller/auth/AuthController.java +++ b/src/main/java/com/moddy/server/controller/auth/AuthController.java @@ -1,14 +1,18 @@ package com.moddy.server.controller.auth; import com.moddy.server.common.dto.ErrorResponse; +import com.moddy.server.common.dto.SuccessNonDataResponse; import com.moddy.server.common.dto.SuccessResponse; import com.moddy.server.common.exception.enums.SuccessCode; +import com.moddy.server.common.util.SmsUtil; import com.moddy.server.config.resolver.kakao.KakaoCode; -import com.moddy.server.controller.model.dto.request.ModelCreateRequest; +import com.moddy.server.controller.auth.dto.request.PhoneNumberRequestDto; +import com.moddy.server.controller.auth.dto.request.VerifyCodeRequestDto; import com.moddy.server.controller.auth.dto.response.LoginResponseDto; -import com.moddy.server.controller.designer.dto.response.UserCreateResponse; import com.moddy.server.controller.auth.dto.response.RegionResponse; import com.moddy.server.controller.designer.dto.request.DesignerCreateRequest; +import com.moddy.server.controller.designer.dto.response.UserCreateResponse; +import com.moddy.server.controller.model.dto.request.ModelCreateRequest; import com.moddy.server.service.auth.AuthService; import com.moddy.server.service.designer.DesignerService; import com.moddy.server.service.model.ModelService; @@ -32,7 +36,9 @@ import java.util.List; +import static com.moddy.server.common.exception.enums.SuccessCode.SEND_VERIFICATION_CODE_SUCCESS; import static com.moddy.server.common.exception.enums.SuccessCode.SOCIAL_LOGIN_SUCCESS; +import static com.moddy.server.common.exception.enums.SuccessCode.VERIFICATION_CODE_MATCH_SUCCESS; @Tag(name = "Auth Controller", description = "로그인 및 회원 가입 관련 API 입니다.") @RestController @@ -43,6 +49,7 @@ public class AuthController { private final AuthService authService; private final DesignerService designerService; private final ModelService modelService; + private final SmsUtil smsUtil; @Operation(summary = "[KAKAO CODE] 로그인 API") @ApiResponses(value = { @@ -102,4 +109,35 @@ public SuccessResponse createModel( return SuccessResponse.success(SuccessCode.MODEL_CREATE_SUCCESS, modelService.createModel(request.getHeader(ORIGIN), kakaoCode, modelCreateRequest)); } + @Operation(summary = "[SMS 기능 미완성] 인증번호 요청 API", description = "인증번호 요청 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "전화번호 인증 요청 성공입니다."), + @ApiResponse(responseCode = "400", description = "유효하지 않은 카카오 코드를 입력했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "유효하지 않은 값을 입력했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/phoneNumber") + public SuccessNonDataResponse sendVerificationCodeMessageToUser(@RequestBody PhoneNumberRequestDto phoneNumberRequestDto) { + authService.sendVerificationCodeMessageToUser(phoneNumberRequestDto.phoneNumber()); + return SuccessNonDataResponse.success(SEND_VERIFICATION_CODE_SUCCESS); + } + + @Operation(summary = "전화번호 인증 API", description = "전화번호 인증 API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "전화번호 인증 성공입니다."), + @ApiResponse( + responseCode = "400", + description = "1. 인증번호가 일치하지 않습니다." + + "2. 만료된 인증 코드입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse(responseCode = "404", description = "인증 코드가 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/phoneNumber/verify") + public SuccessNonDataResponse verifyCode(@RequestBody VerifyCodeRequestDto verifyCodeRequestDto) { + authService.verifyCode(verifyCodeRequestDto.phoneNumber(), verifyCodeRequestDto.verifyCode()); + return SuccessNonDataResponse.success(VERIFICATION_CODE_MATCH_SUCCESS); + } + } diff --git a/src/main/java/com/moddy/server/controller/auth/dto/request/PhoneNumberRequestDto.java b/src/main/java/com/moddy/server/controller/auth/dto/request/PhoneNumberRequestDto.java new file mode 100644 index 00000000..bbf0e818 --- /dev/null +++ b/src/main/java/com/moddy/server/controller/auth/dto/request/PhoneNumberRequestDto.java @@ -0,0 +1,4 @@ +package com.moddy.server.controller.auth.dto.request; + +public record PhoneNumberRequestDto(String phoneNumber) { +} diff --git a/src/main/java/com/moddy/server/controller/auth/dto/request/VerifyCodeRequestDto.java b/src/main/java/com/moddy/server/controller/auth/dto/request/VerifyCodeRequestDto.java new file mode 100644 index 00000000..462f0290 --- /dev/null +++ b/src/main/java/com/moddy/server/controller/auth/dto/request/VerifyCodeRequestDto.java @@ -0,0 +1,4 @@ +package com.moddy.server.controller.auth.dto.request; + +public record VerifyCodeRequestDto(String phoneNumber, String verifyCode) { +} diff --git a/src/main/java/com/moddy/server/domain/BaseTimeEntity.java b/src/main/java/com/moddy/server/domain/BaseTimeEntity.java index dc6065ba..c4721540 100644 --- a/src/main/java/com/moddy/server/domain/BaseTimeEntity.java +++ b/src/main/java/com/moddy/server/domain/BaseTimeEntity.java @@ -3,6 +3,7 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; import org.springframework.data.annotation.CreatedDate; @@ -11,6 +12,7 @@ import java.time.LocalDateTime; +@Getter @SuperBuilder @MappedSuperclass @NoArgsConstructor diff --git a/src/main/java/com/moddy/server/domain/verify/UserVerification.java b/src/main/java/com/moddy/server/domain/verify/UserVerification.java new file mode 100644 index 00000000..8060ef05 --- /dev/null +++ b/src/main/java/com/moddy/server/domain/verify/UserVerification.java @@ -0,0 +1,36 @@ +package com.moddy.server.domain.verify; + +import com.moddy.server.domain.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class UserVerification extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotNull + private String phoneNumber; + @NotNull + private String verificationCode; + + public boolean isExpireCode(LocalDateTime now) { + long minutesDifference = ChronoUnit.MINUTES.between(this.getCreatedAt(), now); + return minutesDifference >= 3; + } +} diff --git a/src/main/java/com/moddy/server/domain/verify/repository/UserVerificationRepository.java b/src/main/java/com/moddy/server/domain/verify/repository/UserVerificationRepository.java new file mode 100644 index 00000000..88cefd25 --- /dev/null +++ b/src/main/java/com/moddy/server/domain/verify/repository/UserVerificationRepository.java @@ -0,0 +1,12 @@ +package com.moddy.server.domain.verify.repository; + +import com.moddy.server.domain.verify.UserVerification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserVerificationRepository extends JpaRepository { + Optional findByPhoneNumber(String phoneNumber); + + void deleteByPhoneNumber(String phoneNumber); +} diff --git a/src/main/java/com/moddy/server/external/sms/SmsService.java b/src/main/java/com/moddy/server/external/sms/SmsService.java new file mode 100644 index 00000000..db3a4a8f --- /dev/null +++ b/src/main/java/com/moddy/server/external/sms/SmsService.java @@ -0,0 +1,7 @@ +package com.moddy.server.external.sms; + +public interface SmsService { + String MESSAGE_FORM = "[%s] moddy에서 보낸 인증번호입니다. 해당 인증번호는 3분간 유효합니다."; + + boolean sendSms(String to, String verificationCode); +} diff --git a/src/main/java/com/moddy/server/external/sms/impl/AwsSmsServiceImpl.java b/src/main/java/com/moddy/server/external/sms/impl/AwsSmsServiceImpl.java new file mode 100644 index 00000000..fd83dc1e --- /dev/null +++ b/src/main/java/com/moddy/server/external/sms/impl/AwsSmsServiceImpl.java @@ -0,0 +1,29 @@ +package com.moddy.server.external.sms.impl; + +import com.amazonaws.services.sns.AmazonSNSClient; +import com.amazonaws.services.sns.model.PublishRequest; +import com.moddy.server.external.sms.SmsService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AwsSmsServiceImpl implements SmsService { + private final AmazonSNSClient amazonSNSClient; + private static final String KR = "+82"; + + @Override + public boolean sendSms(String to, String verificationCode) { + try { + PublishRequest request = new PublishRequest(); + request.setMessage(String.format(MESSAGE_FORM, verificationCode)); + request.setPhoneNumber(KR + to); + + amazonSNSClient.publish(request); + return true; + } catch (Exception e) { + System.out.println(e); + return false; + } + } +} diff --git a/src/main/java/com/moddy/server/service/auth/AuthService.java b/src/main/java/com/moddy/server/service/auth/AuthService.java index f27cc8a0..593c1ae1 100644 --- a/src/main/java/com/moddy/server/service/auth/AuthService.java +++ b/src/main/java/com/moddy/server/service/auth/AuthService.java @@ -1,7 +1,10 @@ package com.moddy.server.service.auth; import com.moddy.server.common.dto.TokenPair; +import com.moddy.server.common.exception.model.BadRequestException; import com.moddy.server.common.exception.model.NotFoundException; +import com.moddy.server.common.util.SmsUtil; +import com.moddy.server.common.util.VerificationCodeGenerator; import com.moddy.server.config.jwt.JwtService; import com.moddy.server.controller.auth.dto.response.LoginResponseDto; import com.moddy.server.controller.auth.dto.response.RegionResponse; @@ -9,22 +12,32 @@ import com.moddy.server.domain.region.repository.RegionJpaRepository; import com.moddy.server.domain.user.User; import com.moddy.server.domain.user.repository.UserRepository; +import com.moddy.server.domain.verify.UserVerification; +import com.moddy.server.domain.verify.repository.UserVerificationRepository; import com.moddy.server.external.kakao.service.KakaoSocialService; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; +import static com.moddy.server.common.exception.enums.ErrorCode.EXPIRE_VERIFICATION_CODE_EXCEPTION; +import static com.moddy.server.common.exception.enums.ErrorCode.NOT_FOUND_VERIFICATION_CODE_EXCEPTION; +import static com.moddy.server.common.exception.enums.ErrorCode.NOT_MATCH_VERIFICATION_CODE_EXCEPTION; import static com.moddy.server.common.exception.enums.ErrorCode.USER_NOT_FOUND_EXCEPTION; @Service @RequiredArgsConstructor public class AuthService { + private final SmsUtil smsUtil; private final JwtService jwtService; private final KakaoSocialService kakaoSocialService; private final UserRepository userRepository; private final RegionJpaRepository regionJpaRepository; + private final UserVerificationRepository userVerificationRepository; public LoginResponseDto login(final String baseUrl, final String kakaoCode) { String kakaoId = kakaoSocialService.getIdFromKakao(baseUrl, kakaoCode); @@ -34,7 +47,7 @@ public LoginResponseDto login(final String baseUrl, final String kakaoCode) { return new LoginResponseDto(tokenPair.accessToken(), tokenPair.refreshToken(), user.getRole().name()); } - public List getRegionList(){ + public List getRegionList() { List regionResponseList = regionJpaRepository.findAll().stream().map(region -> { RegionResponse regionResponse = new RegionResponse( @@ -47,7 +60,7 @@ public List getRegionList(){ return regionResponseList; } - public UserCreateResponse createUserToken(String useId){ + public UserCreateResponse createUserToken(String useId) { TokenPair tokenPair = jwtService.generateTokenPair(useId); UserCreateResponse userCreateResponse = new UserCreateResponse(tokenPair.accessToken(), tokenPair.refreshToken()); @@ -55,4 +68,36 @@ public UserCreateResponse createUserToken(String useId){ return userCreateResponse; } + @Transactional + public void sendVerificationCodeMessageToUser(String phoneNumber) { + Optional userVerification = userVerificationRepository.findByPhoneNumber(phoneNumber); + if (userVerification.isPresent()) { + userVerificationRepository.deleteByPhoneNumber(phoneNumber); + } + + String verificationCode = VerificationCodeGenerator.generate(); + // smsUtil.sendVerificationCode(phoneNumber, verificationCode); + + UserVerification newUserVerification = UserVerification.builder() + .phoneNumber(phoneNumber) + .verificationCode(verificationCode) + .build(); + userVerificationRepository.save(newUserVerification); + } + + @Transactional + public void verifyCode(String phoneNumber, String verificationCode) { + UserVerification userVerification = userVerificationRepository.findByPhoneNumber(phoneNumber) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_VERIFICATION_CODE_EXCEPTION)); + + if (userVerification.isExpireCode(LocalDateTime.now())) { + userVerificationRepository.deleteByPhoneNumber(phoneNumber); + throw new BadRequestException(EXPIRE_VERIFICATION_CODE_EXCEPTION); + } + + if (!verificationCode.equals(userVerification.getVerificationCode())) + throw new BadRequestException(NOT_MATCH_VERIFICATION_CODE_EXCEPTION); + + userVerificationRepository.deleteByPhoneNumber(phoneNumber); + } } diff --git a/src/test/java/com/moddy/server/domain/verify/UserVerificationTest.java b/src/test/java/com/moddy/server/domain/verify/UserVerificationTest.java new file mode 100644 index 00000000..9345f77d --- /dev/null +++ b/src/test/java/com/moddy/server/domain/verify/UserVerificationTest.java @@ -0,0 +1,45 @@ +package com.moddy.server.domain.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class UserVerificationTest { + + @Test + @DisplayName("테이블 생성 시간이 2000년 2월 3일 6시 30분이고, 이후에 3분이 지났으면 true를 반환한다.") + void isExpireCodeTest() { + // given + UserVerification userVerification = UserVerification + .builder() + .createdAt(LocalDateTime.of(2000, 2, 3, 6, 30)) + .build(); + LocalDateTime now = LocalDateTime.of(2000, 2, 3, 6, 33); + + // when + boolean isExpire = userVerification.isExpireCode(now); + + // then + assertThat(isExpire).isTrue(); + } + + @Test + @DisplayName("테이블 생성 시간이 2000년 2월 3일 6시 30분이고, 이후에 2분이 지났으면 false를 반환한다.") + void isExpireCodeTest2() { + // given + UserVerification userVerification = UserVerification + .builder() + .createdAt(LocalDateTime.of(2000, 2, 3, 6, 30)) + .build(); + LocalDateTime now = LocalDateTime.of(2000, 2, 3, 6, 32); + + // when + boolean isExpire = userVerification.isExpireCode(now); + + // then + assertThat(isExpire).isFalse(); + } +} \ No newline at end of file