Telegram 과 같은 채팅어플을 개발중에 QR-LOGIN을 구현하는 기획이 있었다.
나는 NAVER에서 구현한 QR-LOGIN이 어떤방식으로 로그인처리를 하는지 알고싶어서 파헤치기 시작했다.
https://nid.naver.com/nidlogin.qrcode?mode=qrcode&qrcodesession=sdHkwNo6NLt958rSgTAwwvVsR8QKeH0Y
앞서 확인한 NAVER 를 참고하여 그림으로 구상해보자
Request
Response
Request
Response
구상은 끝났으니 코드로 작성해보자. 우선, 우리 프로젝트는 Spirng MVC 이라는 점을 감안했다.
또 , 데이터의 구독과 발행은 Redis의 pub/sub을 사용하여 여러 인스턴스에서 사용할 수 있게 하였다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// 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();
}
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;
}
}
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;
}
}
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;
}
}
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);
}
}
<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>
예제 코드랑
SSE의 흐름을 잘 설명해주셔서 많은 도움이 되었습니다!!