지금 하는 중인 프로젝트가 원래는 아이디 + 비밀번호 기반 로그인이여서
메일 기반 인증과 OAuth 를 준비중이었는데
다시 문자 인증 기반 로그인으로 기획이 변경되었다
문자 인증 같은 경우는 예전 프로젝트에서
네이버 SMS API를 통해 구현해 본 적이 있어서 그다지 어렵지는 않지만
마침 만드는 김에 복습하는 차원으로 구현하고 나서 글을 적어보기로 했다
⚙️ Dependency
//webflux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
서버 상에서 http 요청을 보내고 응답받기 위한 WebClient를 사용하기 위해 webflux를 추가,
휴대폰 번호와 인증번호를 만료기간을 두고 관리하기 위한 인메모리 DB인 Redis 를 추가해준다
🚀 네이버 클라우드 플랫폼 SMS API 가입
가입 절차를 진행하고 본인 인증 후 콘솔로 진입하면 된다
먼저 우리가 사용할 서비스는 SMS API 서비스이고
이름은 SENS(Simple & Easy Notification Service) 이다
문자 메세지 전송 및 푸시 알림을 보낼 수 있다
https://www.ncloud.com/product/applicationService/sens
문자 인증에는 당연히 80자 이하의 SMS 를 사용할 것이고
50건 이하까지는 무료이다
테스트로 몇 번씩 보내보면서 진행하기에 딱이다
그리고 신규 가입 후 결제 수단 등록하면 크레딧도 주고
몇몇 제휴된 대학에서는 크레딧을 뿌리기도 하니 확인해볼 것
우리 학교는 없다 하...
콘솔 진입후
대시보드 -> 서비스 -> 검색어로 Simple & Easy Notification Service 진입
프로젝트 생성하기
옆에 OPEN API 가이드는 이따가 봐야하는데
문자 메세지를 어떻게 전송하고 응답 받는지 전부 다 적혀있다
개발자는 공식 Docs 도 잘 봐야한다
문자 인증이니 당연히 SMS 체크 후 이름과 설명 대충 만들어주기
이후 왼쪽 대시보드에서 Projects 선택 후에
방금 만든 프로젝트 행에서 오른쪽 서비스 ID의 열쇠모양 클릭
ncp: 로 시작하는 API Service ID Key 가 나오는데
꼭 써먹어야 하니 이를 기억해두자
이번엔 Simple & Easy Notification Service -> Solutions -> SMS -> Calling Number 들어와서 발신번호 등록해주기
개인 회원은 개인 명의의 휴대폰밖에 등록 못하니 주의
다른 번호 등록하거나 사업자 같은 경우 따로 서류를 준비해서 여차저차 하면 된다고 설명되어 있다
이번엔 다시 콘솔에서 나와서
네이버 클라우드 플랫폼 -> 마이페이지 -> 계정 관리 -> 인증키 관리로 들어옴
이번엔 API 인증키 관리에서
Access Key 와 Secret Key 를 잘 기억해두자
이제 대충 준비는 끝났따
📝 .env
NAVER_SMS_ID={네이버sms 서비스ID}
NAVER_SMS_PHONE_NUMBER={발신전화번호}
NAVER_ACCESS_KEY={네이버API 엑세스키}
NAVER_SECRET_KEY={네이버API 비밀키}
dotenv 나 application.properties 파일등에 비밀스럽게 잘 두면 된다
특히 git 에 이 정보가 올라가면 큰일나니 주의
요즘엔 워낙 서비스가 잘 되어있어서
git 같은데 올라가는 거 감지되면 서비스를 강제 종료시켜버리던가 그러지만
애초에 올리지 않으면 해결될 일이니
🚀 문자 인증 절차
https://api.ncloud-docs.com/docs/ko/ai-application-service-sens-smsv2
물론 공식 레퍼런스에 너무나도 잘 설명되어 있다
하나하나 차근차근 따라가보자
📌 API Header 정의
네이버 클라우드 API 를 사용하기 위해서는 위 3개의 헤더를 끼워서 요청을 보내야한다
1. 밀리초 단위의 타임스탬프
2. 아까 받은 계정 Access Key
3. 1번과 2번을 암호화 알고리즘으로 서명한 것
1번과 2번은 여차저차 하면 되지만
3번은 조금 헷갈릴 수 있다
📌 서명
https://api.ncloud-docs.com/docs/common-ncpapi
서명 생성 예제가 모두 나와있으니 보면 된다
📝 SmsCertificationServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsCertificationServiceImpl implements SmsCertificationService {
private final WebClient webClient;
private final RedisService redisService;
private final String VERIFICATION_PREFIX = "sms:";
private final int VERIFICATION_TIME_LIMIT = 3 * 60;
@Value("${SPRING_PROFILE}")
private String springProfile;
@Value("${NAVER_ACCESS_KEY}")
private String accessKey;
@Value("${NAVER_SECRET_KEY}")
private String secretKey;
@Value("${NAVER_SMS_ID}")
private String serviceId;
@Value("${NAVER_SMS_PHONE_NUMBER}")
private String senderNumber;
/**
* sms 전송을 위한 서명을 추가한다
* @param currentTime 현재 시간
* @return 서명
*/
@Override
public String makeSignature(Long currentTime) {
String space = " ";
String newLine = "\n";
String method = "POST";
String url = "/sms/v2/services/" + this.serviceId + "/messages";
String timestamp = currentTime.toString();
String accessKey = this.accessKey;
String secretKey = this.secretKey;
try {
String message = method +
space +
url +
newLine +
timestamp +
newLine +
accessKey;
SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
String encodeBase64String = Base64.encodeBase64String(rawHmac);
return encodeBase64String;
} catch (Exception e) {
throw new BadRequestException(ErrorCode._BAD_REQUEST, e.getMessage());
}
}
...
...
}
예제와 비슷하지만 매개변수로 Long 형의 현재 시간을 받아온다
📌 DTO 정의
📝 NaverSmsMessageDTO.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class NaverSmsMessageDTO {
String to;
String content;
}
📝 NaverSmsRequestDTO.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class NaverSmsRequestDTO {
String type;
String contentType;
String countryCode;
String from;
String content;
List<NaverSmsMessageDTO> messages;
}
📝 NaverSmsResponseDTO.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class NaverSmsResponseDTO {
String requestId;
LocalDateTime requestTime;
String statusCode;
String statusName;
}
꼭 정의해야하는 DTO는 위의 세 가지로
네이버 SMS API의 메세지 응답, 처리에 필요하다
이름 마음대로 바꿨다가 DTO <-> JSON 매칭이 안돼서 삽질좀 했다
왠만하면 그대로 사용하는 게 좋다
request body 의 각 필드는 optional 이라서 필요한 것만 추가해주면 된다
📌 인증번호 전송
📝 SmsCertificationServiceImpl.java
...
...
/**
* 인증번호가 담긴 메세지를 전송한다
* @param to 수신자
* @return 네이버 api 서버 메세지 응답
*/
@Override
public String sendVerificationMessage(String to) {
final String smsURL = "https://sens.apigw.ntruss.com/sms/v2/services/"+ serviceId +"/messages";
final Long time = System.currentTimeMillis();
//랜덤 6자리 인증번호
final String verificationCode = generateVerificationCode();
//3분 제한시간
final Duration verificationTimeLimit = Duration.ofSeconds(VERIFICATION_TIME_LIMIT);
//[local, dev] 배포 환경이 아닐때는 fake service 를 제공합니다
if (!springProfile.equals("prod")) {
log.info("스프링 프로파일(" + springProfile + ") 따라 fake 서비스를 제공합니다");
String message = generateMessageWithCode(verificationCode);
log.info(message);
redisService.setValues(VERIFICATION_PREFIX + to, verificationCode, verificationTimeLimit);
return message;
}
//[prod] 실 배포 환경에서는 문자를 전송합니다
try {
//네이버 sms 메세지 dto
final NaverSmsMessageDTO naverSmsMessageDTO = new NaverSmsMessageDTO(to, generateMessageWithCode(verificationCode));
List<NaverSmsMessageDTO> messages = new ArrayList<>();
messages.add(naverSmsMessageDTO);
final NaverSmsRequestDTO naverSmsRequestDTO = NaverSmsRequestDTO.builder()
.type("SMS")
.contentType("COMM")
.countryCode("82")
.from(senderNumber)
.content(naverSmsMessageDTO.getContent())
.messages(messages)
.build();
final String body = new ObjectMapper().writeValueAsString(naverSmsRequestDTO);
// final ResponseMessageDTO responseMessageDTO =
webClient.post().uri(smsURL)
.contentType(MediaType.APPLICATION_JSON)
.header("x-ncp-apigw-timestamp", time.toString())
.header("x-ncp-iam-access-key", accessKey)
.header("x-ncp-apigw-signature-v2", makeSignature(time))
.accept(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(body))
.retrieve()
.bodyToMono(NaverSmsResponseDTO.class).block();
//redis 에 3분 제한의 인증번호 토큰 저장
redisService.setValues(VERIFICATION_PREFIX + to, verificationCode, verificationTimeLimit);
return "메세지 전송 성공";
// return responseMessageDTO;
} catch (Exception e) {
e.printStackTrace();
throw new BadRequestException(ErrorCode._BAD_REQUEST, "메세지 발송에 실패하였습니다");
}
}
/**
* 랜덤 인증번호를 생성한다
* @return 인증번호 6자리
*/
private String generateVerificationCode() {
Random random = new Random();
int verificationCode = random.nextInt(888888) + 111111;
return Integer.toString(verificationCode);
}
/**
* 인증번호가 포함된 메세지를 생성한다
* @param code 인증번호 6자리
* @return 인증번호 6자리가 포함된 메세지
*/
private String generateMessageWithCode(String code) {
final String provider = "내친소";
return "[" + provider + "] 인증번호 [" + code + "] 를 입력해주세요 :)";
}
...
인증번호 전송은 fake 와 real 서비스로 나누었다
스프링 프로파일을 local / dev / prod 총 3가지로 나누었는데
각 로컬, 개발서버, 배포서버에서 활용할 예정이다
프로파일이 prod가 아닐 경우에는 문자를 전송하지 않고
response 로 바로 인증번호를 반환시켜버렸다
프로파일이 prod 이면은 실제 문자를 전송시키는데
Webflux 의 WebClient를 사용해서 네이버 SMS API 서버와 통신했다
인증번호가 생성되고 문자를 보내면
3분 기한을 두고 Redis 에 {Key: Value} 형태로 저장해둔다
Key는 사용자의 번호로 두었고 인증 과정에서 뽑아올 때 번호를 통해 처리한다
📌 Redis 설정
📝 RedisConfig.java
@Configuration
public class RedisConfig {
@Value("${REDIS_HOST}")
private String host;
@Value("${REDIS_PORT}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
// redisTemplate를 받아와서 set, get, delete를 사용
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
/**
* setKeySerializer, setValueSerializer 설정
* redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
*/
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
레디스 설정 파일이다
레디스 서버는 도커 컴포즈를 통해 로컬에서 관리중이다
📝 RedisServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisServiceImpl implements RedisService {
private final RedisTemplate<String, String> redisTemplate;
@Override
public void setValues(String key, String data) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key, data);
}
@Override
public void setValues(String key, String data, Duration duration) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key, data, duration);
}
@Override
public String getValues(String key) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
return values.get(key);
}
@Override
public void deleteValues(String key) {
redisTemplate.delete(key);
}
@Override
public boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
}
레디스 서비스
키 저장, 삭제, 뽑아오기 등을 담당하고 있다
📌 인증번호 검증
📝 SmsCertificationServiceImpl.java
...
...
@Override
public String verifyCode(SmsCertificationRequestDTO smsCertificationRequestDto) {
String phoneNumber = smsCertificationRequestDto.getPhoneNumber();
String code = smsCertificationRequestDto.getCode();
String key = VERIFICATION_PREFIX + phoneNumber;
//redis 에 해당 번호의 키가 없는 경우는 인증번호(3분) 만료로 처리
if (!redisService.hasKey(key)) {
throw new UnauthorizedException(ErrorCode.EXPIRED_VERIFICATION_CODE);
}
//redis 에 해당 번호의 키와 인증번호가 일치하지 않는 경우
if (!redisService.getValues(key).equals(code)) {
throw new UnauthorizedException(ErrorCode.MISMATCH_VERIFICATION_CODE);
}
//필터를 모두 통과, 인증이 완료되었으니 redis 에서 전화번호 삭제
redisService.deleteValues(key);
return "인증에 성공하였습니다";
}
...
...
만약 사용자의 번호로 된 Key값이 없으면 만료된 것으로 처리
있지만 인증번호가 다를 경우 인증 실패로 처리했다
모든 필터를 통과하면 Redis 상에서 번호로 된 키 값을 제거(만료)시키고
인증 성공 처리를 한다
리턴 값을 String 메세지 형태로 주었는데
인증에 성공할 경우를 제외하면 모두 4xx 에러로 리턴되기 때문이다
📌 컨트롤러 구성
📝 SmsCertificationController.java
@Slf4j
@RestController
@RequestMapping("/sms")
@RequiredArgsConstructor
public class SmsCertificationController {
private final SmsCertificationService smsCertificationService;
@PostMapping("/send")
public CommonApiResponse<String> sendMessageWithVerificationCode(@RequestBody @Valid SmsVerificationCodeRequestDTO dto) {
String result = smsCertificationService.sendVerificationMessage(dto.getPhoneNumber());
return CommonApiResponse.of(result);
}
@PostMapping("/verify")
public CommonApiResponse<String> verifyCode(@RequestBody @Valid SmsCertificationRequestDTO dto) {
String result = smsCertificationService.verifyCode(dto);
return CommonApiResponse.of(result);
}
}
인증번호 전송과 검증 처리 관련 url 을 추가했다
📝 SmsVerificationCodeRequestDTO.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class SmsVerificationCodeRequestDTO {
@NotBlank(message = "전화번호를 입력해주세요")
@Pattern(regexp = "[0-9]{10,11}", message = "하이픈 없는 10~11자리 숫자를 입력해주세요")
String phoneNumber;
}
인증번호 전송 요청 DTO
Spring Validation 을 통해 인자를 검증한다
01000000000 형태의 붙어있는 번호를 받을 것이기 때문에
정규식으로 숫자만 있는 문자열이 오도록 했다
📝 SmsCertificationRequestDTO.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class SmsCertificationRequestDTO {
@NotBlank(message = "전화번호를 입력해주세요")
@Pattern(regexp = "[0-9]{10,11}", message = "하이픈 없는 10~11자리 숫자를 입력해주세요")
String phoneNumber;
@NotBlank(message = "인증번호를 입력해주세요")
@Pattern(regexp = "[0-9]{6}", message = "인증번호 6자리를 입력해주세요")
String code;
}
인증번호 검증 요청 DTO
마찬가지로 휴대폰번호와 인증번호를 검증했다
Validation 을 통과하지 못하면 MethodArgumentNotValidException 을 내뿜어서
글로벌 익셉션 핸들러 정의한 곳에서 걸리게 된다
📌 문자인증 테스트
프로파일이 local 일 경우 이렇게 데이터로 바로 넘어온다
인자가 유효하지 않으면 유효하지 않은 해당 필드가 반환되도록 했다
똑바로 입력하면 성공
이후 똑같은 인증번호로 인증 요청을 하게 되면
Redis 상에서 삭제했기 때문에 만료되었다고 뜬다
이번엔 스프링 프로파일 변경 후 시도
이번엔 인증번호가 아니라 메세지 전송 성공이라는 메세지가 응답된다
문자가 잘 오는 모습
첫 프로젝트에서 담당했던게 Node.js + 문자인증이었는데
다시 해보니까 그다지 어렵지는 않은 거 같다
그 때는 이렇게 어려운게 없었는데
이제 메세지 큐 이용해서 비동기로 메세지 보내는 거까지 구현을 해보아야 겠다
아무튼 문자인증 구현 끗