Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 문자 인증 기능 추가 #45

Merged
merged 17 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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