Spring) QR-Code Login을 구현해보자

CokeBear·2022년 11월 5일
4

spring

목록 보기
12/15
post-thumbnail

개요

Telegram 과 같은 채팅어플을 개발중에 QR-LOGIN을 구현하는 기획이 있었다.

방법 찾기

나는 NAVER에서 구현한 QR-LOGIN이 어떤방식으로 로그인처리를 하는지 알고싶어서 파헤치기 시작했다.

1. 네이버는 QR에 3분의 시간 제한을 두었다.

2. QR을 스캔했을때 접속하는 주소이다. QR-code마다 sessionID를 새로 생성한다.

https://nid.naver.com/nidlogin.qrcode?mode=qrcode&qrcodesession=sdHkwNo6NLt958rSgTAwwvVsR8QKeH0Y

3. QR코드 로그인 시에 Event-Stream을 연결한다는 걸 알 수있다.

  • 연결된 Event Stream 으로 ping정보를 계속 보내 연결되었는지 확인하는 것을 알수 있다.
  • 사진에 나오진 않지만 QR-Code Scan 을 하게되면 인가된 로그인 정보가 서버로 발송되는 구조인것 같다. 발송된 Token이 지금의 통로를 통해 Token이 오게 된다.

구상

앞서 확인한 NAVER 를 참고하여 그림으로 구상해보자

  1. QR-CODE LOGIN 페이지 접속
  2. 임의의 SessionID 생성 (3분의 시간제한을 가지고 있다.)
  3. 생성한 SessionID를 가지고 서버로 SSE 통신 Request
  4. 서버에서 SessionID를 키값으로 가진 SSE 통로생성 후 메세지 Response
  5. 인가된 회원의 앱으로 QR스캔
  6. QR-code에 포함된 주소로 Token 정보 Request
  7. 서버에서 인가된 토큰 확인 후 연결되어있는 SSE 통로로 Token Response

코드 작성

구상은 끝났으니 코드로 작성해보자. 우선, 우리 프로젝트는 Spirng MVC 이라는 점을 감안했다.
또 , 데이터의 구독과 발행은 Redis의 pub/sub을 사용하여 여러 인스턴스에서 사용할 수 있게 하였다.

SSE 통신은 비동기 통신이다. MVC에서는 SseEmitter 클래스를 통해 구현가능하다.

gradle.build

  • 레디스의 PUB/SUB과 db를 활용 할 예정이기 때문에 레디스를 impl해 준다.
   implementation 'org.springframework.boot:spring-boot-starter-data-redis'

CONTROLER

// SSE 통신 요청 
  @GetMapping(value = "/qrcode-req")
  @ResponseStatus(HttpStatus.OK)
  public SseEmitter qrcodeReq(@RequestParam String sessionId) throws IOException {
    return sseQrcodeService.newSseEmitterForRedisChannel(sessionId);
  }
 // Ping 체크
  @GetMapping("/qrcode-req/ping")
  @ResponseStatus(HttpStatus.OK)
  public CommonResult pingCheck(@RequestParam String sessionId) throws IOException {
    sseQrcodeService.pingCheck(sessionId);
    return responseService.getSuccessResult();
  }
 // QR-Code 주소 -> login token을 응답받을 주소이다.
  @GetMapping("/qrcode-res")
  @ResponseStatus(HttpStatus.OK)
  public CommonResult qrcodeRes(@RequestParam String sessionId,
                          @RequestHeader(value = "Authorization", required = false, defaultValue = "") String token){
    sseQrcodeService.sendTokenToSseEmitter(sessionId,token.substring(7));
    return responseService.getSuccessResult();
  }

SERVICE

package com.send.apiauth.domain.auth.service;

import com.send.apiauth.domain.auth.res.LoginRes;
import com.send.modulecore.exception.CustomRuntimeException;
import com.send.modulecore.response.code.UsersResponseCode;
import com.send.modulesystem.redis.RedisPubService;
import com.send.modulesystem.redis.RedisService;
import com.send.modulesystem.security.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;

import static java.util.Objects.isNull;

@Service
@RequiredArgsConstructor
@Slf4j
public class SseQrcodeService {

  private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 10; 
  // SSE 만료시간
  private static final Long EXPIRED_TOKEN_TIMEOUT = 185L;
  // 토큰 만료 시간
  private final JwtProvider jwtProvider;
  // jwt 토큰 관리자
  private final RedisService redisService;
  // 레디스 key value 
  private final RedisMessageListenerContainer redisContainer;
  // 레디스 listener container
  private final RedisPubService redisPubService;
  // 레디스 발행 서비스
  
  /**
  * 최초 SSE 채널을 생성하고 Redis listner Container에 이벤트를 등록한다.
  */
  public SseEmitter newSseEmitterForRedisChannel(final String sessionId) {
    final SseEmitter emitter = new   SseEmitter(TimeUnit.MINUTES.toMillis(DEFAULT_TIMEOUT)); 
    // SSE 통신을 위한 SSe emitter 객체를 생성해 준다.
    MessageListenerAdapter listenerAdapter = new MessageListenerAdapter(setMessageListener(sessionId, emitter));
    // 이벤트가 들어오면 작동할 함수를 정의한다.
    
    redisContainer.addMessageListener(listenerAdapter, new PatternTopic(getChannelNameWithPrefix(sessionId)));
    // 레디스 컨테이너에 topic과 함수를 등록한다.
    
    redisService.setValues(getChannelNameWithPrefix(sessionId),now(),Duration.ofSeconds(EXPIRED_TOKEN_TIMEOUT));
    // 레디스에 sessionID를 key로 갖고, 3분의 만료시간을 갖는 데이터를 생성한다.
    
    log.info("Added emitter {} from listenerAdapter {}", emitter, listenerAdapter);
     
    sendToClient(emitter, "CONNECT", "CONNECT_" + sessionId, "SUCCESS_" + sessionId + now());
    // 연결된 통로를 통해 연결 성공 데이터를 보낸다.
    
    emitter.onCompletion(() -> { // 연결이 종로 되었을 때 이벤트를 설정한다.
      log.info("Removed emitter {} from listenerAdapter {}", emitter, listenerAdapter);
      redisContainer.removeMessageListener(listenerAdapter);
      // 등록했던 이벤트 리스너를 삭제한다.
      redisService.deleteValues(getChannelNameWithPrefix(sessionId));
      // sessionId를 키로 갖는 데이터를 레디스에서 삭제한다.
    });
    
    return emitter;
  }

 /**
 * client가 sseEmiter를 종료했는지 확인하기 위해 핑을 체크한다.
 */
  public void pingCheck(String sessionId) {
    sendMessage(sessionId,"ping");
  }
  /**
  * Client가 QR코드로 인증을 시도한 경우 Token을 보낸다.
  */
  public void sendTokenToSseEmitter(String sessionId, String substring) {
    sendMessage(sessionId,substring);
  }

/**
* 토큰 안에서 정보를 가져와서 다시 토큰을 발행해주는 함수
*/
  private LoginRes createToken(String replaceMessage) {
    String accountId = jwtProvider.getAccountId(replaceMessage);
    List<String> roles = jwtProvider.getRoles(replaceMessage);
    String accessToken = jwtProvider.createAccessToken(accountId,roles);
    String refreshToken = jwtProvider.createRefreshToken(accountId);
    return LoginRes.builder().accessToken(accessToken).refreshToken(refreshToken).build();
  }

/**
* 이벤트를 정의하는 함수
*/
  private MessageListener setMessageListener(String sessionId, SseEmitter emitter) {
    return (message, pattern) -> {
      String replaceMessage = message.toString().replaceAll("\"", "");
      // 메세지가 /ping 이런식으로 오기 때문에 replaceAll로 데이터를 변경해 준다.
      if (replaceMessage.equals("ping")) {
      //  ping 인 경우 
        sendPingMessage(sessionId, emitter);
        // ping 메세지를 전달하고
        return;
        // 리턴한다.
      }
      // 그 외 (JWT TOKEN인 경우)
      sendToClient(emitter, "COMPLETE", "ping_" + sessionId, createToken(replaceMessage)); 
      // 성공했다는 메세지와 함께 새로 만든 토큰을 보낸다.
      log.debug("Received {} on {}", message, Thread.currentThread().getName());
    };
  }

/**
* 10초 간격으로 반복해서 PING을 보내는 함수
*/
  private void sendPingMessage(String sessionId, SseEmitter emitter) {
    Timer timer = new Timer();
    
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        if(!hasKey(sessionId)) {
        // Redis에  sessionId 키를 조회하여 없으면 
          timer.cancel();
          // 반복을 종료시키고
          sendToClient(emitter, "FAIL", "FAIL_" + sessionId, "FAIL_" + sessionId + "_" + now());
          // 종료 메세지를 클라이언트로 보낸다.
          emitter.complete();
          // Sse Emitter도 종료 시킨다.
        }else{ // 키가 있다면
          String eventKey = sessionId + "_" + System.currentTimeMillis();
          // 메세지를 만들어
          if(!sendToClient(emitter, "ping", "ping_" + eventKey, now())) 
          // 핑 메세지를 보낸후 성공 여부를 리턴받는다.
          timer.cancel();
          //실패한경우 타이머를 종료 시킨다.
        }
      }
    };
    timer.schedule(task,0L,10000);

  }

/**
* 메세지를 레디스에 발행하는 함수다.
*/
  private void sendMessage(String sessionId, String message) {
    if (hasKey(sessionId)) {
     // 레디스에 키가 있는지 먼저 확인 후 redisPubService.publicMessageToRedisChannel(getChannelNameWithPrefix(sessionId), message);
     // sessionId 토픽으로 데이터를 발행한다.
    } else {
      keyNotFoundException();
      // 없으면 오루를 발생시킨다.
    }
  }
 /**
 * 레디스에 키가 있는지 조회하는 함수다.
 */ 
  private boolean hasKey(String sessionId) {
    return !isNull(redisService.getValues(getChannelNameWithPrefix(sessionId)));
  }
/**
* 오류를 담당하는 함수
*/
  private void keyNotFoundException(){
    throw new CustomRuntimeException("세션이 만료되었습니다.", UsersResponseCode.TOKEN_NOT_FOUND);
  }
/**
* SSE 통신에 데이터를 보내는 함수다.
*/
  private boolean sendToClient(SseEmitter emitter, String name, String id, Object data) {
    try {
      emitter.send(SseEmitter.event()
        .id(id)
        .name(name)
        // 클라이언트에서 메세지를 구분할 때 사용한다.
        .data(data));
     // 파라미터 값으로 받은 데이터들로 이벤트를 만들어 데이터를 보낸다.  
    } catch (IOException | IllegalStateException exception) {
    // 에러발생시
      emitter.completeWithError(exception);
      // 통로를 닫고 에러를 발생시킨다.
      log.info("연결이 종료되었습니다. 연결 및 데이터를 삭제합니다.");
      return false;
    }
    return true;
  }

  private static String now() {
    return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
  }
  private String getChannelNameWithPrefix(String channelName) {
    return "qrcode:" + channelName;
  }
}

Repsitory

redis pubsubConfig

package com.send.modulesystem.redis;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.util.StringUtils;

import java.util.concurrent.Executors;
/**
* 레디스 구독 발행 설정 클래스이다.
*/
@Configuration
public class RedisPubSubConfig {

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

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

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

  @Bean
  public RedisConnectionFactory redisConnectionFactory() {
    RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
    redisStandaloneConfiguration.setHostName(redisHost);
    redisStandaloneConfiguration.setPassword(password);
    redisStandaloneConfiguration.setPort(redisPort);
    return new LettuceConnectionFactory(redisStandaloneConfiguration);
  }

  @Bean
  public RedisTemplate<String, Object> redisTemplate() {
    final RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
    template.setConnectionFactory(redisConnectionFactory());
    template.setValueSerializer(new Jackson2JsonRedisSerializer<Object>(Object.class));
    return template;
  }

  @Bean
  RedisMessageListenerContainer redisContainer() {
    final RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(redisConnectionFactory());
    container.setTaskExecutor(Executors.newFixedThreadPool(5));
    return container;
  }


}

redis PubService


package com.send.modulesystem.redis;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service

/**
* 토픽으로 데이터를 발행하는 클래스이다.
*/
public class RedisPubService {

  private final RedisTemplate<String, Object> redisTemplate;

  public boolean publicMessageToRedisChannel(String channelName, String message) {
    redisTemplate.convertAndSend(channelName,message);
    return true;
  }


}

redis Service

package com.send.modulesystem.redis;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

import java.time.Duration;
/**
* 레디스 디비를 담당하는 클래스이다. key / value 값을 저장할때 필요하다.
*/
@Service
@RequiredArgsConstructor
public class RedisService {
    private final RedisTemplate<String, String> redisTemplate;

    public void setValues(String key, String data) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public String getValues(String key) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }

}

Client

- 임시로 만든 SSE EMITTER TEST용 HTML 스크립트 이다.

<html lang="ko">
<head>
    <meta charset="utf-8">
</head>
<body>

<button id="reset" onclick="resetting()">초기화</button>

<script>
  
  <-- SESSION ID 를 생성해주는 함수다. -->
    const generateRandomString = (num) => {
        const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
        let result = '';
        const charactersLength = characters.length;
        for (let i = 0; i < num; i++) {
            result += characters.charAt(Math.floor(Math.random() * charactersLength));
        }
        return result;
    }
	
    let randomChar = generateRandomString(10)// sessionId 생성
    console.log(randomChar)
    let eventSource = new EventSource(`http://localhost:80/api/v1/qrcode-req?sessionId=${randomChar}`)
	//만든 sessionId 로 최초 요청을 보낸다.	
    
    <!-- 서버에서 보낸 SSE message의 name으로 각각 함수를 설정해 주었다.  -->
    
    <!--CONNECT 인경우-->
    eventSource.addEventListener('CONNECT',(e) => {
      console.log(e.data)
     //데이터 출력
      fetch(`http://localhost:80/api/v1/qrcode-req/ping?sessionId=${randomChar}`)
    .then(
              (response) => console.log(response)
      )
    //핑을 체크하는 엔드포인트를 호출한다.
    })
    
    <!--ping 인 경우-->
    eventSource.addEventListener('ping',(e) => {
      console.log(e.data)
     //데이터를 출력한다
    })
    
    <!--FAIL인 경우-->
    eventSource.addEventListener('FAIL',(e) => {
        console.log(e.data)
        eventSource.close();
    //SSE 통신을 종료시킨다.
    })
    
    <!--COMPLETE 인 경우-->
    eventSource.addEventListener('COMPLETE',(e) => {
      console.log(e.data)
     //TODO: 토큰으로 로그인하는 로직이 필요하다.
      eventSource.close();
    //SSE 통신을 종료시킨다.
    })

    
    <!--초기화 시켰을경우-->
    function resetting() {
        console.log("close");
        eventSource.close();
        randomChar = generateRandomString(10)
        eventSource = new EventSource(`http://localhost:80/api/v1/qrcode-req?sessionId=${randomChar}`)

      eventSource.addEventListener('CONNECT',(e) => {
        console.log(e.data)
        fetch(`http://localhost:80/api/v1/qrcode-req/ping?sessionId=${randomChar}`).then(
                (response) => console.log(response)
        )
      })
      eventSource.addEventListener('ping',(e) => {
        console.log(e.data)
      })
        eventSource.addEventListener('FAIL',(e) => {
            console.log(e.data)
            eventSource.close();
        })
      eventSource.addEventListener('COMPLETE',(e) => {
        console.log(e.data)
        eventSource.close();
        // 토큰을 받고 홈페이지로 리다이렉트
      })
    }


</script>
</body>
</html>
profile
back end developer

1개의 댓글

comment-user-thumbnail
2024년 5월 18일

예제 코드랑
SSE의 흐름을 잘 설명해주셔서 많은 도움이 되었습니다!!

답글 달기