오늘은 네이버나 카카오등 여러 서비스에서 지원하는 QR 코드 로그인을 따라해(?) 볼것이다.
일단 해당 내용은 토이프로젝트성으로 진행된 것이다.
QR 로그인 기능을 구현하기 위해서 일단 SPRING TOOL 을 사용할 것이며,
DB 는 내가 자주 쓰는 SQL SERVER 를 채택하였다.
위의 그림처럼 현재 모바일 환경이 아닌 데스크탑 pc 에서 QR 코드인증을 통하여 로그인을 구현해볼 것이다.
QR 코드 인증을 위해서는 위와 같이 해당 사이트에서 인가가 된 모바일 기기가 필요하다.
pom.xml 에서는 아래 두개는 필수적으로 추가해줘야 QR 로그인을 따라할 수 있다.
일단 기본적으로 토이성 프로젝트인만큼 로그인 기능등 여러가지 기능이 구현되어 있다는 걸 가정한다.
일단 해당 QR 코드 로그인을 사용하기 위해서는 모바일 기기로 해당 사이트에서의 로그인 기록이 존재해야하고
마지막 로그인으로부터 15일 이내에만 사용가능한 것이라고 제한해 두었다.
그럼 해당 기능 개발을 위해 우리는 먼저, 단말기 쪽 로직과 데스크탑쪽 로직을 나누어 생각해야 한다.
**PC USER 관련 로직
컴퓨터를 켜서 쇼핑을 하려는데 해당 사이트에 아이디와 비밀번호가 기억나지 않는다.
다행히 몇일전에 모바일로 해당 사이트에서 쇼핑을 했던 기억이 있다.
이럴 경우에는 QR CODE 를 사용하여 비밀번호 찾기 없이 빠르게 로그인을 할 수 있다.
즉, 사용자는 QR 코드 로그인을 위한 접근(Access) 을 수반할 것이다.
해당 접근을 처리해줄 controller 의 첫번째 기능에 대한 코드는 아래와 같다.
//QR code 로그인 관련 -> 첫번째로 qr 로그인을 하려는 피씨가 자신의 피씨정보를 db단으로 넘겨준다.
@RequestMapping(value = "/loginQr.action", method = { RequestMethod.GET })
public String loginQr(HttpServletRequest request, HttpServletResponse response, IpCheck ic, ErrorAlarm ea) {
int qrResult = logService.loginGetQr(request,response,ic,ea);
if (qrResult == 1) return "/login/MainQrLogin";
else return "/testwaiting/kakaoerror";
}
qrResult 의 값에 따라 해당 값이 1이 나오게 되면 QR 코드인증 하는 사이트로 보내주고
1이 아닌 값이 나오게 되면 에러처리를 진행해주면 된다.
그럼 loginService 객체로 넘어가서 loginGetQr 메소드가 어떤 역할을 수행하는지 알아보자.
//QR 관련 로직 -> QR관련 url을 생성해준다.(QR 첫단계)
@Override
public int loginGetQr(HttpServletRequest request, HttpServletResponse response, IpCheck ic, ErrorAlarm ea) {
try {
//qr 시도하는 컴퓨터측 ip 정보
String requestIpAddress = ic.getClientIP(request);//qr 로그인 시도하는 컴퓨터측 ip 정보
//현재 열려있는 서버포트
int serverPort = request.getServerPort();
request.setAttribute("serverPort", serverPort);
request.setAttribute("requestIpAddress", requestIpAddress);
return 1;
} catch(Exception e) {
ea.basicErrorException(request, e);
return -1;//오류 발생
}
}
첫번째 서비스 로직에는 그저 qr 을 시도하는 컴퓨터측 ip 정보와 현재 열려있는 서버포트를 구해주고 넘겨주는 역할을 수행한다.
만약 문제 없이 진행하였다면 아래와 같은 화면이 출력될 것이다.
기본적으로 5분이내에 소켓통신을 진행하여 qr 로그인을 진행하여야 하며 5분이 지난 이후에는
소켓이 끊어지고 다시 연결되는 방식을 취하고 있다.(window.reload() 방식 사용)
해당 페이지의 자세한 코드는 아래에 나와 있다.
해당 로직에서 가장 중요한 부분은
qr code 에 특정한 uuid 와 같이 유일한 값을 인코딩 하여서 데이터를 삽입시키고
모바일 기기로 해당 qr code 로 만들어진 인코딩 된 데이터를 디코드 해서 데이터를 읽는 것이다.
<form action="/SYJ_Mall/loginQrDeviceCheck.action" method = "POST" id = "last_checking_form">
<input type="hidden" name="throwUuid" id="throwUuid" value="" />
</form>
타이머 관련 로직은 아래와 같다.
타이머가 0초가 되었을때 페이지를 다시 로드하는 방식을 취하고 있다.
해당 페이지를 다시 리로드하게 해주면 기존 소켓은 버리고 새로운 소켓을 생성해낸다.
let qruuid; //Universally Unique IDentifier
let time; //총 시간
let min = ""; //분
let sec = ""; //초
//위의 qr 코드 박스에 어떤 qr code 값을 넣을 것인지 지정해주는 코드
let qrcode = new QRCode(document.getElementById("qrcode"), {
//width, height modify
width : 150, height : 150
});
//QR code
const x = setInterval(function() {
min = parseInt(time / 60);
sec = time % 60;
const zeroMin = String(min).padStart(2,'0');
const zerosec = String(sec).padStart(2,'0');
$("#timeCheck").html(zeroMin + " : " + zerosec);
time--;
// if Timecount over 5min then refresh Qrcode and timer
if (time < 1) {
refreshQr();//location reload 역할 수행
}
}, 1000);
QR 관련 소켓에 관련된 로직은 아래와 같다.
request_ip 와 server_port 는 loginService > loginGetQr 메소드에서 넘어온 정보를 사용한다.
onmessage 부분에는 소켓이 계속 대기하면서 백엔드 사이드에서 어떤 데이터가 넘어오는지
모니터링 하는 중이라고 생각하면 편하다.
/************************* QR Code Socket process *************************/
let request_ip = '${requestIpAddress}'; // 위에서 넘겨준 pc ip 주소
let server_port = '${serverPort}'; // 현재 사용하는 서버포트
window.onload = function(){
openSocket(server_port,request_ip);
time = 300;//QR 연결 지속시간 -> 300초 지정 (5분)
}
//if click the refresh button then refresh Qrcode and timer
$(document).on("click","#resetBtn",function(e){
refreshQr();
});
//go to genenral login page
$(document).on("click",".info-another",function(e){
closeSocket();
location.href = "/SYJ_Mall/login.action";
});
/************************* QR Code Socket process *************************/
let wss;//socket Object
function openSocket(server_port,request_ip) {
if(wss !== undefined && wss.readyState !== WebSocket.CLOSED ){
alert("WebSocket is already opened.");
return;
}
//Generate Websocekt
wss = new WebSocket("ws://byeanma.kro.kr:"+server_port+"/SYJ_Mall/qrecho.action");
wss.onopen = function(event){
if(event.data === undefined){
return;
}
};
/* 이부분이 소켓이 계속 감시하고 있는 부분이다.
서버에서 메시지를 보내면 이벤트발생으로 간주하여
실행시키는 메서드라고 생각하면 편하다.
*/
wss.onmessage = function(event){
let gubun = event.data.split(",");
let first = gubun[0];
let second = gubun[1];
//Generate Qrcode and making Qr image
if (first == 'gen') {
let qruuid = second;
let qrhttps = 'http://byeanma.kro.kr:'+server_port+'/SYJ_Mall/loginQrPrevCheck.action?qrhttps='+ qruuid + '&tryip=' + request_ip;
qrcode.makeCode(qrhttps);//qr code image making
}
else if (first == 'qruuid'){
//login pass
QRLogin(second);
} else if (first == 'stop') {
closeSocket();
location.href = '/SYJ_Mall/qrLoginBannedMonitor.action';
} else {
closeSocket();
location.href = "/SYJ_Mall/totalError.action";
}
};
wss.onclose = function(event){
//closeSocket();
}
}
//close the Qrsocket
function closeSocket(){
wss.close();
}
//refresh the Qrsocket
function refreshQr() {
location.reload();
}
//Post the Qrcode Backend logic
function QRLogin(qruuid) {
$('#throwUuid').val(qruuid);
$('#last_checking_form').submit();
}
백엔드 사이드의 websocket 자세한 로직은 아래와 같다.
백엔드 사이드에서 중요한 부분의 로직을 참고하자.
백엔드에서는 처음 소켓이 생성되었을때 uuid 를 생성하여 화면단으로 정보를 넘겨주는 모습을 볼 수 있다.
@Controller
@ServerEndpoint(value="/qrecho.action")
public class CommonWebsocket {
public static final List<Session> sessionLists = new ArrayList<Session>();
public static final Map<String,String> guidLists = new HashMap<String,String>();
public static final Map<String,String> guidUserSeqMap = new HashMap<String,String>();
//소켓통신 객체만 생성
public CommonWebsocket() {
// TODO Auto-generated constructor stub
}
//소켓통신 직접 연결
@OnOpen
public void onOpen(Session session) {
logger.info("Open session id:" + session.getId());
try {
final Basic basic = session.getBasicRemote();
String uuid = UUID.randomUUID().toString();//uuid 생성
guidLists.put(uuid, session.getId());//uuid 가 key의 역할을 수행
basic.sendText("gen,"+uuid);//정보를 담아 text로 넘겨준다. -> 통신
sessionLists.add(session);//세션 리스트에 새로운 세션 등록
} catch (Exception e) {
// TODO: handle exception
System.out.println(e.getMessage());
}
}
....
}
결국 위의 로직을 도식화 하면 아래와 같이 나타낼 수 있다.
또한 소켓 클래스에 존재하는 sessionLists, guidLists, guidUserSeqMap 은 아래와 같은 정보를 저장하고 있다.
사용자가 pc 에서 QR 로그인탭을 클릭하는 순간 서버에서는 session 을 생성하고 해당 세션을 sessionLists 에 넣어준다.
또한, 동시에 uuid (Universally Unique IDentifier) 를 생성하여 uuid 는 유일하므로 map 객체의 key 로 지정할 수 있다.
해당 uuid 를 key 로 지정하고 value 값으로는 생성된 session의 id를 넣는다.
**MOBILE DEVICE 관련 로직
위에서는 로그인할 피씨환경에서 로직이 준비되어있으므로 이번에는 사용자가 모바일기기로 QR 코드사진을 찍었을
경우 진행되는 로직을 살펴봐야 한다.
일단 초기 진입 컨트롤러의 모습을 살펴보자.
서비스 객체로 기능을 위임하여 해당 loginQrPrevCheck() 메서드가 1 을 return 할 경우는
고객이 단말기로 15일 이내에 로그인을 하여 해당 아이디에 대한 정보가 암호화 되어 쿠키로써 저장되어 있는 경우로
qr 로그인이 가능하다. 하지만, 2 를 리턴하게 되면 단말기에 로그인 정보가 없기 때문에, qr 로그인을 진행 할 수 없다.
//QR 검증 -> 핸드폰으로 qr 코드를 찍어서 넘어온 경우
@RequestMapping(value = "/loginQrPrevCheck.action", method = { RequestMethod.GET })
public String loginQrCheck(HttpServletRequest request, HttpServletResponse response,KakaoCookie kc,AES256Util au,StringFormatClass sf, ErrorAlarm ea, CommonWebsocket cw, CommonDAO cdao) {
int qrPrevCheck = logService.loginQrPrevCheck(request,response,kc,au,sf,ea,cw,cdao);
if (qrPrevCheck == 1) return "/login/UserQrChecking";
else if (qrPrevCheck == 2) return "/semitiles/QrLoginNotUserSeq.layout";
else return "/testwaiting/kakaoerror";
}
서비스 객체의 모습을 살펴보자.
단말기 카메라를 통해 넘겨진 qr code 의 정보속에는 uuid 정보와 접속 시도 ip정보가 존재하고
단말기 내부에는 로그인 정보가 존재한다.
uuid 가 실제로 서버에서 만들어져 대기하고 있는 세션인지 확인해야하고
문제가 없는 uuid 와 로그인 정보라면 해당 아이디가 블랙리스트나 비정상적으로 접속한 로그가 있는지
db 에서 조회하여 문제가 없는 경우라면 1을 리턴해준다.
//QR 코드 모바일 기기로 접근하는 처음경우 uuid 등 기본정보 조회
@Override
public int loginQrPrevCheck(HttpServletRequest request, HttpServletResponse response,KakaoCookie kc,AES256Util au,StringFormatClass sf, ErrorAlarm ea, CommonWebsocket cw, CommonDAO cdao) {
try {
String qruuid = request.getParameter("qrhttps");//넘어온 uuid 정보
String tryIp = request.getParameter("tryip");//넘어온 시도 ip 정보
String QrSeqCode = (String) kc.getCookieInfo(request, "QrSeqCode");//고객이 단말기로 로그인한 정보 -> 암호화 되어있다.
String decodeQrSeqCode;
// 최근 15일동안 모바일에서 로그인한 기록이 없음
if (QrSeqCode == null) {
return 2;
}
// 최근 15일 이내에 모바일에서 로그인한 기록이 있음
else {
decodeQrSeqCode = sf.findDigitString(au.decrypt(QrSeqCode));//고객번호가 암호화 되어있는데 이것을 복호화 시킨다.
//확인창 화면에서 정보를 볼수 있도록 request 객체에 넘겨준다.
request.setAttribute("QrSeqCode",QrSeqCode);//고객번호가 그냥 넘어가면 안되므로 암호화 상태로 넘겨준다.
request.setAttribute("qruuid",qruuid);
//고객번호가 이상한 경우 -> 숫자가 아닌 경우(공격시도로 볼 수 있음)
if (!sf.isStringDigit(decodeQrSeqCode)) return -1;
// 넘어온 uuid 에 대한 정보가 현재 소켓 세션에도 존재하는지 찾아준다.
Map<String,String> guidLists = cw.guidLists;
if (guidLists.get(qruuid) == null) return 2;
// 소켓에 저장된 uuid에 대해서 문제가 없다면 해당 아이피와 유저의 번호가 문제없는지 확인해준다.
int userCheck = dao.checkUserPassYn(decodeQrSeqCode,tryIp);
if (userCheck != 1) return -1;//문제 있는경우 오류 발생
String userId = cdao.getUserId(Integer.parseInt(decodeQrSeqCode));//db 단에서 정보를 조회해준다.
cdao.close();
request.setAttribute("qrUserId",userId);
request.setAttribute("qrUserIp",tryIp);
}
return 1;
} catch(Exception e) {
ea.basicErrorException(request, e);
return -1;//오류 발생
}
}
해당 로직검증과정에서 문제가 없다면 단말기에는 아래와 같은 화면이 출력될 것이다.
아래에서 허용을 눌러준다면, 접속을 시도한 pc에서 정상적으로 로그인이 될것이다.
로그인을 허용할때는 이제 사용자 pc 쪽에서 이벤트가 발생해야 한다.
pc 관련 로직으로 넘어가보자.
**PC USER 관련 로직
//QR 검증 -> 핸드폰으로 qr 코드를 찍어서 넘어온 경우 -> 로그인 허용
@RequestMapping(value = "/loginQrLastCheck.action", method = { RequestMethod.POST })
public String loginQrLastCheck(HttpServletRequest request, HttpServletResponse response, ErrorAlarm ea, KakaoCookie kc, AES256Util au, StringFormatClass sf, CommonWebsocket cw) {
int qrLastCheck = logService.loginQrChecking(request, response,ea,kc,au,sf,cw);
if (qrLastCheck == 1) return "/login/QrLoginResult";
else return "/testwaiting/kakaoerror";
}
사용자의 pc 로 하여금 사용자 단말기에 존재하는 아이디 정보를 pc 에 전송시켜
로그인을 허용하게 해줄 서비스 객체의 로직은 아래와 같다.
//모바일기기에서 아이디 체킹하는 작업 -> QR 로그인 허용
@Override
public int loginQrChecking(HttpServletRequest request, HttpServletResponse response,ErrorAlarm ea, KakaoCookie kc, AES256Util au, StringFormatClass sf, CommonWebsocket cw) {
try {
String qruuid = request.getParameter("qruuid");//넘어온 uuid 정보
String QrSeqCode = request.getParameter("QrSeqCode");//유저 고유번호
String decodeQrSeqCode = sf.findDigitString(au.decrypt(QrSeqCode));//유저 고유번호 복호화
//이쪽에서 로그인 허용해줘야한다.
Map<String,String> guidLists = cw.guidLists;
List<Session> sessionLists = cw.sessionLists;
Map<String,String> guidUserSeqMap = cw.guidUserSeqMap;
Session selectSession = null;
String sessionId = guidLists.get(qruuid);
for (Session s : sessionLists) {
if (s.getId().equals(sessionId)) {
selectSession = s;
break;
}
}
//허용을 해준다면 해당 세션아이디와 복호화된 고객의 고유번호를 넘겨준다
guidUserSeqMap.put(sessionId, decodeQrSeqCode);
final Basic basic = selectSession.getBasicRemote();
basic.sendText("qruuid," + qruuid);
request.setAttribute("qrResult", "허용");
return 1;
} catch(Exception e) {
ea.basicErrorException(request, e);
return -1;//오류 발생
}
}
서버사이드 코드의 로직상에는 유저가 QR 코드 로그인 탭을 누를때 uuid 와 session 이 만들어지고
uuid 를 키로 하고 session id 를 value 로 가지는 map 객체인 guidLists 가 존재한다 (map 으로 이름을 지을껄...)
그럼 이제 서버단에서 넘어온 uuid 를 guidLists 에서 조회하고 해당 아이디를 가지는 session 객체 자체를 가져와준다.
그리하여 해당 객체에게만 메시지를 보내주어 로그인을 진행시키면 된다.
이때 유저의 개인정보 보호를 위해서 해당 uuid 만 메시지로 넘겨주고
유저의 pc에서는 히든 값을 통해서 session id 를 키로 가지고 복호화된 유저의 고유번호를 value 로
가지는 값을 guidUserSeqMap 개체에 넣어준다.
특정 session 에게 메시지를 보냈으므로, 위에서 QR 관련 js 코드를 보면 이해가 가겠지만, 해당 부분에서 이벤트가 발생하게 된다.
그럼 이제 유저가 접속을 시도하는 pc 에서 직접 uuid 를 서버로 보내게 된다.
그럼 해당 서버에서는 다시 해당 uuid 를 토대로 guidLists , guidUserSeqMap 을 조회하여 문제가 없을 경우에
해당 컴퓨터에 사용자 단말기에 있는 로그인 정보를 토대로 로그인을 시켜주면 된다.
//QR 로그인 마지막단계 - qr 로그인 시도하는 디바이스에서 로그인을 허용할지 말지 정해준다.
@Override
public String getQrDevicePassYn(HttpServletRequest request, HttpServletResponse response,ErrorAlarm ea, CommonWebsocket cw, IpCheck ic, KakaoCookie kc) {
try {
String uuid = request.getParameter("throwUuid");//넘겨져온 uuid정보
String ip = ic.getClientIP(request);
Map<String,String> guidLists = cw.guidLists;
Map<String,String> guidUserSeqMap = cw.guidUserSeqMap;
String sessionId = guidLists.get(uuid);
String userSeq = guidUserSeqMap.get(sessionId);
//1. userSeq 가 존재하는지 판단하고 존재하지 않으면 오류발생으로 보낸다.
//2. userSeq 가 존재한다면 해당 회원의 기본정보를 디비에서 조회해서 가져와준다.
//3. 로그인 로직을 따른다.
if (sessionId != null && userSeq != null) {
int qrLoginResult = dao.getQrLoginResult(userSeq,ip);
// 1. 정확하게 로그인 성공한 경우
if (qrLoginResult == 0) {
int loginYn = loginSuccess(request, response, Integer.parseInt(userSeq));// 로그인 인증티켓 발급
if (loginYn == -1) return "error";
String lastPage = (String)kc.getCookieInfo(request, "lastPage");
if (lastPage == null) {
goMain(request);
return "/tiles/mainStart.layout";// 메인페이지로 이동
} else if (lastPage.indexOf("?") != -1) {
//인코딩 처리를 잘 해줘야한다.
String url = urlEncoder(lastPage);
return "redirect:/" + url;
} else {
return "forward:/" + lastPage + ".action";
}
} else if (qrLoginResult == 1) {
// 2. 로그인 성공 : 하지만 비밀번호를 변경해줘야한다.
// 아래에서 기본적으로 정보와 rsa키를 넘겨야한다.
int result = userRedefinedPw(request, Integer.parseInt(userSeq), ip ,ea);
if (result == 1) {
return "/login/UserLoginPwRedefine";
} else {
return "/testwaiting/kakaoerror";// 문제생겼을시에 에러페이지로 이동
}
} else if (qrLoginResult == 2) {
// 3. 보안정책을 따라야하는 경우 --> 사진을 골라야한다.
//자동로그인 방지 알고리즘 실행
request = AutoLoginBanned(request, Integer.parseInt(userSeq), ip);
return "/login/UserAutoLoginCheck";
} else return "error";
} else return "error";
} catch(Exception e) {
ea.basicErrorException(request, e);
return "error";
}
}
로직을 도식화 해보면 아래와 같다.
<시연영상>
'개발 & 구현' 카테고리의 다른 글
[c++] Elasticsearch Cluster metric 알람 구현 - 최적화 (0) | 2023.09.19 |
---|---|
[c++] Elasticsearch Cluster metric 알람 구현 (2) | 2023.09.18 |
[Python] Telegram 응답 받기 (0) | 2023.05.03 |
[Python] Telegram 메시지 보내주기 (0) | 2023.05.02 |
JAVA 메시지 보내기 - SMTP (0) | 2022.12.03 |