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

SMS 본인인증 기능 구현 - 본인 인증 코드 문자 발송 API 구현 #98

Merged
merged 5 commits into from
Mar 30, 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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -151,6 +154,11 @@ jacocoTestCoverageVerification {
'*common.entity.FullAddress*',
'*repository.*Repository*'
]

excludes = [
'*service.NcpMessageService',
'*repository.UserAuthCodeRedisRepository'
]
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -30,6 +33,7 @@ public class AuthController {

private final AuthService authService;
private final KakaoService kakaoService;
private final UserAuthCodeService userAuthCodeService;

@Operation(
summary = "로그인",
Expand All @@ -52,18 +56,33 @@ public JwtTokenInfoResponse loginV1(@RequestBody @Valid LoginRequest loginReques
)
@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(
responseCode = "409", content = @Content,
@ApiResponse(responseCode = "409", content = @Content,
description = """
<p>[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우
<p>[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우
"""
),
"""),
@ApiResponse(responseCode = "Any", description = "[10000] 카카오 서버와의 통신 중 오류가 발생한 경우. Http status code는 kakao에서 응답받은 것과 동일하게 설정하여 응답한다.", content = @Content)
})
@PostMapping(value = "/kakao/login", headers = API_VERSION_HEADER_NAME + "=" + 1)
public JwtTokenInfoResponse kakaoLoginV1(@RequestBody @Valid KakaoLoginRequest kakaoLoginRequest) {
JwtTokenInfoDto jwtTokenInfoDto = kakaoService.login(kakaoLoginRequest);
return JwtTokenInfoResponse.from(jwtTokenInfoDto);
}

@Operation(
summary = "일회용 본인 인증 코드 발송 (SMS)",
description = """
<p>전달받은 전화번호로 일회용 본인 인증 코드를 발송합니다.
<p>인증 코드의 유효 기간은 5분입니다. 인증 코드가 만료된 경우, 다시 인증 코드를 발급받아야 합니다.
"""
)
@ApiResponses({
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "Any", description = "[1002] 인증 코드 문자 발송 과정 중 오류가 발생한 경우", content = @Content)
})
@PostMapping(value = "/codes/send", headers = API_VERSION_HEADER_NAME + "=" + 1)
public ResponseEntity<Void> sendUserAuthCodeV1(@RequestBody @Valid SendUserAuthCodeRequest sendCodeRequest) {
userAuthCodeService.sendUserAuthCodeViaSms(sendCodeRequest.getPhoneNumber());
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions src/main/java/com/ajou/hertz/common/auth/entity/UserAuthCode.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<String, UserAuthCode> redisTemplate;

@Override
public UserAuthCode save(UserAuthCode userAuthCode) {
redisTemplate.opsForValue().set(
userAuthCode.getCode(),
userAuthCode,
UserAuthCode.TTL_SECONDS,
TimeUnit.SECONDS
);
return userAuthCode;
}

@Override
public Optional<UserAuthCode> findByCode(String code) {
UserAuthCode userAuthCode = redisTemplate.opsForValue().get(code);
return Optional.ofNullable(userAuthCode);
}
}
Original file line number Diff line number Diff line change
@@ -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<UserAuthCode> findByCode(String code);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/ajou/hertz/common/config/ObjectMapperConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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, ":"));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, UserAuthCode> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
RedisTemplate<String, UserAuthCode> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, UserAuthCode.class));
return redisTemplate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum CustomExceptionType {

MULTIPART_FILE_NOT_READABLE(1000, "파일을 읽을 수 없습니다. 올바른 파일인지 다시 확인 후 요청해주세요."),
IO_PROCESSING(1001, "입출력 처리 중 오류가 발생했습니다."),
SEND_MESSAGE(1002, "문자 발송 과정에서 알 수 없는 에러가 발생했습니다."),

/**
* 로그인, 인증 관련 예외
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ajou.hertz.common.message.constant;

public enum MessageType {
SMS,
LMS,
MMS
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ajou.hertz.common.message.service;

public interface MessageService {

void sendShortMessage(String message, String targetPhoneNumber);
}
Loading
Loading