Skip to content

Commit

Permalink
Merge pull request #45 from tukcomCD2024/ARCH-114-feat/message-verifi…
Browse files Browse the repository at this point in the history
…cation

feat: 문자 인증 기능 추가
  • Loading branch information
GaBaljaintheroom authored Jan 19, 2024
2 parents efff35c + 9f61d21 commit 88d77c4
Show file tree
Hide file tree
Showing 28 changed files with 564 additions and 38 deletions.
16 changes: 0 additions & 16 deletions backend/core/.github/workflows/google-java-format.yml

This file was deleted.

3 changes: 3 additions & 0 deletions backend/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'

//rest template
implementation 'org.apache.httpcomponents.client5:httpclient5:5.3'

//flyway
implementation 'org.flywaydb:flyway-core:9.5.1'
implementation 'org.flywaydb:flyway-mysql:9.5.1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import org.springframework.web.bind.annotation.PostMapping;
import site.timecapsulearchive.core.domain.auth.dto.request.SignUpRequest;
import site.timecapsulearchive.core.domain.auth.dto.request.TokenReIssueRequest;
import site.timecapsulearchive.core.domain.auth.dto.request.VerificationMessageSendRequest;
import site.timecapsulearchive.core.domain.auth.dto.response.OAuthUrlResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.TemporaryTokenResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.TokenResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.VerificationMessageSendResponse;
import site.timecapsulearchive.core.global.common.response.ApiSpec;

public interface AuthApi {
Expand Down Expand Up @@ -138,7 +140,10 @@ public interface AuthApi {
value = "/verification/send-message",
consumes = {"application/json"}
)
ResponseEntity<Void> sendVerificationMessage();
ResponseEntity<ApiSpec<VerificationMessageSendResponse>> sendVerificationMessage(
Long memberId,
VerificationMessageSendRequest request
);


@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import site.timecapsulearchive.core.domain.auth.dto.request.SignUpRequest;
import site.timecapsulearchive.core.domain.auth.dto.request.TokenReIssueRequest;
import site.timecapsulearchive.core.domain.auth.dto.request.VerificationMessageSendRequest;
import site.timecapsulearchive.core.domain.auth.dto.response.OAuthUrlResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.TemporaryTokenResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.TokenResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.VerificationMessageSendResponse;
import site.timecapsulearchive.core.domain.auth.service.MessageVerificationService;
import site.timecapsulearchive.core.domain.auth.service.TokenService;
import site.timecapsulearchive.core.domain.member.dto.mapper.MemberMapper;
import site.timecapsulearchive.core.domain.member.service.MemberService;
Expand All @@ -23,6 +27,7 @@
public class AuthApiController implements AuthApi {

private final TokenService tokenService;
private final MessageVerificationService messageVerificationService;
private final MemberService memberService;
private final MemberMapper memberMapper;

Expand Down Expand Up @@ -73,8 +78,23 @@ public ResponseEntity<ApiSpec<TemporaryTokenResponse>> signUpWithSocialProvider(
}

@Override
public ResponseEntity<Void> sendVerificationMessage() {
return null;
public ResponseEntity<ApiSpec<VerificationMessageSendResponse>> sendVerificationMessage(
@AuthenticationPrincipal final Long memberId,
@Valid @RequestBody final VerificationMessageSendRequest request
) {
final VerificationMessageSendResponse response = messageVerificationService.sendVerificationMessage(
memberId,
request.receiver(),
request.appHashKey()
);

return ResponseEntity.accepted()
.body(
ApiSpec.success(
SuccessCode.ACCEPTED,
response
)
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package site.timecapsulearchive.core.domain.auth.service;
package site.timecapsulearchive.core.domain.auth.dto;

public record MemberInfo(Long memberId) {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package site.timecapsulearchive.core.domain.auth.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.NotBlank;
import site.timecapsulearchive.core.global.common.valid.annotation.Phone;

@Schema(description = "인증 문자 요청")
public record VerificationMessageSendRequest(

@Schema(description = "핸드폰 번호")
@Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message = "10 ~ 11 자리의 숫자만 입력 가능합니다.")
String phone
@Schema(description = "수신자 핸드폰 번호 ex)01012341234")
@Phone
String receiver,

@Schema(description = "앱의 해시 키")
@NotBlank(message = "앱의 해시 키는 필수입니다.")
String appHashKey
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package site.timecapsulearchive.core.domain.auth.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(name = "인증 문자 발송 응답")
public record VerificationMessageSendResponse(

@Schema(name = "전송 상태")
Integer status,

@Schema(name = "상태 메시지")
String message
) {

public static VerificationMessageSendResponse success(final Integer status,
final String message) {
return new VerificationMessageSendResponse(status, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package site.timecapsulearchive.core.domain.auth.exception;

import site.timecapsulearchive.core.global.error.ErrorCode;
import site.timecapsulearchive.core.global.error.exception.BusinessException;

public class TooManyRequestException extends BusinessException {

public TooManyRequestException() {
super(ErrorCode.TOO_MANY_REQUEST_ERROR);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import site.timecapsulearchive.core.domain.auth.service.MemberInfo;
import site.timecapsulearchive.core.domain.auth.dto.MemberInfo;

@Repository
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package site.timecapsulearchive.core.domain.auth.repository;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class MessageAuthenticationCacheRepository {

private static final int MINUTE = 5;
private static final String PREFIX = "messageAuthentication:";

private final StringRedisTemplate redisTemplate;

public void save(final Long memberId, final String code) {
redisTemplate.opsForValue().set(PREFIX + memberId, code, MINUTE, TimeUnit.MINUTES);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package site.timecapsulearchive.core.domain.auth.service;

import java.util.concurrent.ThreadLocalRandom;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import site.timecapsulearchive.core.domain.auth.dto.response.VerificationMessageSendResponse;
import site.timecapsulearchive.core.domain.auth.repository.MessageAuthenticationCacheRepository;
import site.timecapsulearchive.core.infra.sms.SmsApiService;
import site.timecapsulearchive.core.infra.sms.dto.SmsApiResponse;

@Service
@RequiredArgsConstructor
public class MessageVerificationService {

private static final int MIN = 1000;
private static final int MAX = 10000;

private final MessageAuthenticationCacheRepository messageAuthenticationCacheRepository;
private final SmsApiService smsApiService;

/**
* 사용자 아이디와 수신자 핸드폰을 받아서 인증번호를 발송한다.
*
* @param memberId 사용자 아이디
* @param receiver 수신자 핸드폰 번호
* @param appHashKey 앱의 해시 키(메시지 자동 파싱)
*/
public VerificationMessageSendResponse sendVerificationMessage(
final Long memberId,
final String receiver,
final String appHashKey
) {
final String code = generateRandomCode();

final String message = generateMessage(code, appHashKey);

final SmsApiResponse apiResponse = smsApiService.sendMessage(receiver, message);

messageAuthenticationCacheRepository.save(memberId, code);

return VerificationMessageSendResponse.success(apiResponse.resultCode(),
apiResponse.message());
}

private String generateMessage(final String code, final String appHashKey) {
return "<#>[ARchive]"
+ "본인확인 인증번호는 ["
+ code
+ "]입니다."
+ appHashKey;
}

private String generateRandomCode() {
return String.valueOf(
ThreadLocalRandom.current()
.nextInt(MIN, MAX)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import site.timecapsulearchive.core.domain.auth.dto.MemberInfo;
import site.timecapsulearchive.core.domain.auth.dto.response.TemporaryTokenResponse;
import site.timecapsulearchive.core.domain.auth.dto.response.TokenResponse;
import site.timecapsulearchive.core.domain.auth.exception.AlreadyReIssuedTokenException;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package site.timecapsulearchive.core.global.api;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import site.timecapsulearchive.core.domain.auth.exception.TooManyRequestException;

@Component
@RequiredArgsConstructor
public class ApiLimitCheckInterceptor implements HandlerInterceptor {

private static final int MAX_API_CALL_LIMIT = 5;
private static final int NO_USAGE = 0;

private final ApiUsageCacheRepository apiUsageCacheRepository;

/**
* 엔드포인트에 Api 요청 횟수를 검사하는 인터셉터이다.
* WebMvcConfig에 path로 등록된 경로는 여기를 거치게 된다.
* 아직 문자 인증에 대한 요청만 걸려있다.
* @param request 요청
* @param response 응답
* @param handler 해당 요청을 처리할 메서드
* @return 클라이언트가 보낸 요청이 Api 횟수 제한에 걸리는지 여부 {@code True, False}
* @throws TooManyRequestException 횟수 제한이 발생하면 예외 발생
*/
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws TooManyRequestException {
Long memberId = (Long) SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();

Integer apiUsageCount = apiUsageCacheRepository.getSmsApiUsage(memberId)
.orElse(NO_USAGE);

if (apiUsageCount > MAX_API_CALL_LIMIT) {
throw new TooManyRequestException();
}

if (isFirstRequest(apiUsageCount)) {
apiUsageCacheRepository.saveAsFirstRequest(memberId);
return true;
}

apiUsageCacheRepository.increaseSmsApiUsage(memberId);

return true;
}

private boolean isFirstRequest(Integer apiUsageCount) {
return apiUsageCount.equals(NO_USAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package site.timecapsulearchive.core.global.api;

import java.util.Optional;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class ApiUsageCacheRepository {

private static final String PREFIX = "apiUsage:";
private static final String SMS_API_USAGE = "smsApi";
private static final String FIRST_REQUEST = "1";
private static final int EXPIRATION_DAYS = 1;

private final StringRedisTemplate redisTemplate;

public Optional<Integer> getSmsApiUsage(Long memberId) {
String result = (String) redisTemplate.opsForHash().get(PREFIX + memberId, SMS_API_USAGE);

if (result == null) {
return Optional.empty();
}

return Optional.of(Integer.parseInt(result));
}

public void increaseSmsApiUsage(Long memberId) {
redisTemplate.opsForHash().increment(PREFIX + memberId, SMS_API_USAGE, 1);
}

public void saveAsFirstRequest(Long memberId) {
String key = PREFIX + memberId;

redisTemplate.opsForHash().put(key, SMS_API_USAGE, FIRST_REQUEST);

redisTemplate.expire(key, EXPIRATION_DAYS, TimeUnit.DAYS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
@RequiredArgsConstructor
public enum SuccessCode {
//success handle
SUCCESS("00", "요청 처리에 성공했습니다.");
SUCCESS("00", "요청 처리에 성공했습니다."),
ACCEPTED("01", "요청이 수락되었습니다.");

private final String code;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package site.timecapsulearchive.core.global.common.valid;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
import site.timecapsulearchive.core.global.common.valid.annotation.Phone;

public class PhoneValidator implements ConstraintValidator<Phone, String> {

private static final String PHONE_REGEX = "^\\d{11}$";

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return Pattern.matches(PHONE_REGEX, value);
}
}
Loading

0 comments on commit 88d77c4

Please sign in to comment.