개발일기

[Vue, SpringBoot] Naver SENS & Redis를 이용한 문자인증 서비스

찐팡민 2022. 3. 14. 22:05

프로젝트를 수행하면서 문자인증을 통해 회원가입 서비스를 제공하기 위해서

Naver SENS(문자 전송) + Redis(토큰 관리) 를 이용하였습니다.

 

대략적인 흐름도는 다음과 같습니다.

1. Vue에서 유저 전화번호를 axios REST API로  SpringBoot에 전달합니다.

2. SpringBoot에서 Naver SENS API로 유저의 전화번호로 인증번호를 전송(SMS 서비스)합니다.

3. Redis 서버에 해당 인증번호(Map 토큰)를 저장하고,

4. SpringBoot에서 5.번호 비교를 할때마다 해당 인증번호와 비교해서 동일하면 해당 토큰을 삭제하고,

1. 에서 새로운 요청이 들어오면 기존 토큰을 삭제하고 다시 1~5. 과정을 반복합니다.

 

Naver SENS API 초기 설정.

 

네이버 클라우드에 가입하고, 콘솔(Products & Services)에서 빨간 박스로 이동합니다.

 

그리고 다음 Open API 가이드 순서대로 프로젝트를 생성합니다.

 

그 후 SMS를 눌러줍니다.

발신번호를 지정해주고, 다시 Open API 가이드 순서대로 진행하시면됩니다.

 

"accessKey, secretKey 발급받기

인증키 관리 탭으로 이동하여 신규 인증키를 생성하면 끝입니다!

 

SpringBoot 코드(인증번호 생성 & Redis 서버에 저장)

초기설정

build.gradle

    implementation ('org.springframework.boot:spring-boot-starter-data-redis')

 

application.yml

spring:
  redis:
    host: "서버 url"
    port: "포트번호"
    
# 변수
naver:
  serviceid: ncp:sms:kr:0000000000000:integproject
  accesskey: Hasdasdasdqasdasdas
  secretkey: fouu4u2asdasdasdasd

 

Redis & Spring 연동.

RedisConnectionFactory & RedisTemplate 이용

RedisConnectionFactory

  • Redis 서버와의 통신을 위한 low-level 추상화를 제공
  • 설정에 따라서 새로운 RedisConnection 또는 이미 존재하는 RedisConnection을 리턴.
  • RedisConnection 은 Redis 서버와의 통신 추상화를 제공하며, exception 발생시 Spring DataAccessException으로 전환.
  • RedisConnection 은 binary value를 인자로 받고 결과를 리턴하는 low-level method를 리턴.
  • Jedis, Jredis(1.7 Deprecated), Lettuce, SRP, RJC 등의 클라이언트 라이브러리가 있음.가장 많이 사용되는 Java의 Redis Client는 Jedis와 Lettuce입니다. 두 가지의 성능을 비교한 글을 참고하여 적합한 기술을 이용하시길 바랍니다.

RedisTemplate

  • Redis 서버에 Redis Command를 수행하기 위한 high-level 추상화를 제공
  • 오브젝트 serialization 과 connection management를 수행
  • Redis 서버에 데이터 CRUD를 위한 Key Type Operations 와 Key Bound Operations 인터페이스를 제공
  • 오브젝트 serialization 과 connection management를 수행
  • thread-safe 하며, 재 사용이 가능
  • 대부분의 기능에 RedisSerializer 인터페이스를 사용. StringRedisSerializer, JdkSerializationRedisSerializer, JacksonJsonRedisSerializer, Jackson2JsonRedisSerializer, GenericJackson2JsonRedisSerializer, OxmSerializer를 사용할 수 있음.
  • Redis에 저장된 키와 값이 java.lang.String이 되도록하는 것이 일반적이므로 StringRedisTemplate 확장 기능을 제공.
  • StringRedisSerializer를 사용하며, 저장된 키와 값은 사람이 읽을 수 있음.

RedisConfig.java

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

MessageServiceImpl

@RequiredArgsConstructor
@Service
public class MessageServiceImpl implements MessageService {

    @Value("${naver.serviceid}")
    private String serviceId;

    @Value("${naver.accesskey}")
    private String ak;

    @Value("${naver.secretkey}")
    private String sk;

    private final SmsCertificationRepository smsCertificationRepository;

    @Override
    public SendSmsResponseDto sendSms(String recipientPhoneNumber) throws ParseException, JsonProcessingException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException, URISyntaxException {
        Long time = System.currentTimeMillis();
        List<MessagesRequestDto> messages = new ArrayList<>(); // 보내는 사람에게 내용을 보냄.

        String randomNumber = makeRandomNumber();

        String content = "인증번호는 " + randomNumber + " 입니다";

        messages.add(new MessagesRequestDto(recipientPhoneNumber,content)); // content부분이 내용임 // 전체 json에 대해 메시지를 만든다.
        SmsRequestDto smsRequestDto = new SmsRequestDto("SMS", "COMM", "82", "01073085445", content, messages); // 쌓아온 바디를 json 형태로 변환시켜준다.
        ObjectMapper objectMapper = new ObjectMapper();
        String jsonBody = objectMapper.writeValueAsString(smsRequestDto); // 헤더에서 여러 설정값들을 잡아준다.
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("x-ncp-apigw-timestamp", time.toString());
        headers.set("x-ncp-iam-access-key", ak); // 제일 중요한 signature 서명하기.
        String sig = makeSignature(time);
        System.out.println("sig -> " + sig);
        headers.set("x-ncp-apigw-signature-v2", sig); // 위에서 조립한 jsonBody와 헤더를 조립한다.
        HttpEntity<String> body = new HttpEntity<>(jsonBody, headers);
        System.out.println(body.getBody()); // restTemplate로 post 요청을 보낸다. 별 일 없으면 202 코드 반환된다.
        RestTemplate restTemplate = new RestTemplate();
        SendSmsResponseDto sendSmsResponseDto = restTemplate.postForObject(new URI("https://sens.apigw.ntruss.com/sms/v2/services/"+serviceId+"/messages"), body, SendSmsResponseDto.class);
        System.out.println(sendSmsResponseDto.getStatusCode());

        smsCertificationRepository.createSmsCertification(recipientPhoneNumber, randomNumber);
        return sendSmsResponseDto;
    }

    @Override
    public boolean verifySms(MessagesRequestDto messageRequest) {
        if (isVerify(messageRequest.getTo(), messageRequest.getNumber())) {
            return false;
        }
        smsCertificationRepository.removeSmsCertification(messageRequest.getTo());
        return true;
    }

    @Override
    public boolean isVerify(String recipientPhoneNumber, String certificationNumber) {
        return !(smsCertificationRepository.hasKey(recipientPhoneNumber) &&
                smsCertificationRepository.getSmsCertification(recipientPhoneNumber)
                        .equals(certificationNumber));
    }

    private String makeRandomNumber() {
        int authNo = (int)(Math.random() * (9999 - 1000 + 1)) + 1000;

        return Integer.toString(authNo);
    }


    @Override
    public String makeSignature(Long time) throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
        String space = " "; // one space
        String newLine = "\n"; // new line
        String method = "POST"; // method
        String url = "/sms/v2/services/"+serviceId+"/messages"; // url (include query string)
        String timestamp = time.toString(); // current timestamp (epoch)
        String accessKey = ak; // access key id (from portal or Sub Account)
        String secretKey = sk;
        String message = new StringBuilder()
                .append(method)
                .append(space)
                .append(url)
                .append(newLine)
                .append(timestamp)
                .append(newLine)
                .append(accessKey)
                .toString();
        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; }

}

 

MessageService

public interface MessageService {
    public SendSmsResponseDto sendSms(String recipientPhoneNumber) throws ParseException, JsonProcessingException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException, URISyntaxException;

    public String makeSignature(Long time) throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException;
    public boolean isVerify(String recipientPhoneNumber, String certificationNumber);
    public boolean verifySms(MessagesRequestDto messageRequest);
}

 

SmsCertificationRepository

@RequiredArgsConstructor
@Component
public class SmsCertificationRepository {
    private final String PREFIX = "sms:";  // (1)
    private final int LIMIT_TIME = 3 * 60;  // (2)

    private final StringRedisTemplate stringRedisTemplate;

    public void createSmsCertification(String phone, String certificationNumber) { //(3)
        stringRedisTemplate.opsForValue()
                .set(PREFIX + phone, certificationNumber, Duration.ofSeconds(LIMIT_TIME));
    }

    public String getSmsCertification(String phone) { // (4)
        return stringRedisTemplate.opsForValue().get(PREFIX + phone);
    }

    public void removeSmsCertification(String phone) { // (5)
        stringRedisTemplate.delete(PREFIX + phone);
    }

    public boolean hasKey(String phone) {  //(6)
        return stringRedisTemplate.hasKey(PREFIX + phone);
    }
}

 

핵심 코드 설명

1. Naver Sens Console에서 지정한 Headers 메타 데이터를 포함해야함.

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("x-ncp-apigw-timestamp", time.toString());
        headers.set("x-ncp-iam-access-key", ak); // 제일 중요한 signature 서명하기.
        String sig = makeSignature(time);
        System.out.println("sig -> " + sig);
        headers.set("x-ncp-apigw-signature-v2", sig); // 위에서 조립한 jsonBody와 헤더를 조립한다.

 

2. NaverSens Console로 요청

        RestTemplate restTemplate = new RestTemplate();
        SendSmsResponseDto sendSmsResponseDto = restTemplate.postForObject(new URI("https://sens.apigw.ntruss.com/sms/v2/services/"+serviceId+"/messages"), body, SendSmsResponseDto.class);

 

3. 전달한 번호를 Redis에서 관리하여 사용자 인증 로직에서 사용.

SmsCertificationRepository에서 StringRedisTemplate을 주입받아 Redis에 토큰 정보 저장.

StringRedisTemplate

대부분 레디스 key-value 는 문자열 위주이기 때문에 문자열에 특화된 템플릿을 제공
RedisTemplate 을 상속받은 클래스임.
StringRedisSerializer로 직렬화함.

 

@RequiredArgsConstructor
@Component
public class SmsCertificationRepository {
    private final String PREFIX = "sms:";  // (1)
    private final int LIMIT_TIME = 3 * 60;  // (2)

    private final StringRedisTemplate stringRedisTemplate;

    public void createSmsCertification(String phone, String certificationNumber) { //(3)
        stringRedisTemplate.opsForValue()
                .set(PREFIX + phone, certificationNumber, Duration.ofSeconds(LIMIT_TIME));
    }

    public String getSmsCertification(String phone) { // (4)
        return stringRedisTemplate.opsForValue().get(PREFIX + phone);
    }

    public void removeSmsCertification(String phone) { // (5)
        stringRedisTemplate.delete(PREFIX + phone);
    }

    public boolean hasKey(String phone) {  //(6)
        return stringRedisTemplate.hasKey(PREFIX + phone);
    }
}