23년 2월 19일 TIL & 개발노트 (이중 로그인 관련 고민과 해결)

Posted by , May 05, 2023
TIL
Series ofTIL

thumbnail

이중 로그인(중복 로그인) 문제를 해결하는 과정

흑우집합소는 정책 상 이중 로그인을 허용하지 않기로 정했다.
예를 들어 A라는 사용자가 크롬에서 로그인하고, 다시 사파리에서 로그인을 했을 때 이를 이중 로그인이라 칭한다.
(때에 따라선 중복 로그인이라 표현하기도 한다.)

뭐 개발좀 해본 사람이라면 다들 알것이다.
근데 계정 로그인 이후 단순하게 정보를 열람하거나, Read성향에 촛점이 맞춰진 서비스라면 이중 로그인은 문제가 되지 않는다.

하지만 내가 개발하는 흑우집합소는 추후 게시글 작성 기능 등 CreateUpdate 그리고 Delete기능을 가진다.
이 경우 이중 로그인 문제는 고려해야 할 문제다.

어떤 부분을 고려해야 할까?

먼저 개발하는 서비스의 로그인 방식부터 확인해봐야 할 것이다.
로그인 방식에는 다양한 방법이 존재한다.

그리고 그 로그인 방식에 따라 이중 로그인 처리 방법이 약간 다르고,
처리하는 방식이 쉬울수도, 까다로울 수도 있다.

여기 아래서 부터는 Next.Js 기준으로 설명을 진행한다.


서버 세션 방식

만약 로그인 방식이 고전 클래식 스타일인 서버 세션에 저장하는 방식이라면 구현이 더 용이할 것이다.

왜냐하면 로그인 정보를 세션에 넣어두고, 새로운 로그인 정보가 올 경우 기존에 있는 정보는 무효화 하면 되기 때문이다.
한가지 포인트는 **서버 세션에 무엇을 넣는가?**인데 일단 서비스마다 로그인 정보는 다르니까 이 부분은 넘긴다.

어떤 정보인지는 아래서 자세히 다뤄보겠다.
단지 간단히 언급하면 클라이언트의 고유 값이 필요하다는 것이다.
이는 아래의 서버 내 저장하는 데이터세션을 참고하자.


SNS 로그인 방식

이 경우 SNS 로그인 방식의 구현 방법에 따라 약간 까다로울 수 있다.
구글, 애플, 네이버 등 SNS 로그인을 제공하는 OAuth를 통해서 로그인을 진행한 경우 로그인 정보를 받고 JWT를 제공받을 것이다.

보통은 JWT를 많이 준다.
많이 사용하는 Access와 Refresh 두 가지를 이용하는 방식이다.

근데 서비스 내에서 사용자 정보를 보관하지 않거나, 간단하게 정보만 받는 경우는 매우 드물고...
보통 사용자 정보를 서비스 내에 저장하고 두기 때문에 위에서 언급한 방법과 유사하게 구현이 된다.

OAuth에서 제공하는 JWT를 사용하는 경우 서버 세션에 해당 JWT정보를 주고,
다른 브라우저에서 로그인 한 경우 서버에서 저장한 JWT를 비교하는 방식으로 기존에 로그인 한 정보를 파기할 수 있다.

만약 JWT 검증을 OAuth측에 맡길 경우 이 부분은 서버측에서 다른 이중 로그인 관련 정보를 들고 있어야 한다.

하지만 OAuth측에서 제공하는 사용자 정보만 받고 보안 인증을 직접 운영한다면,
위의 서버 세션 방식이나 아래의 JWT 방식을 사용할 것이다.


JWT 방식

만약 서버 세션 방식을 사용하지 않고, 토큰 방식 중 하나인 JWT를 사용하는 경우 생각할 게 많아진다.
JWT는 한번 발급을 하면 서버측에서 제어할 수 없다.

의존할 것은 JWT 내에 있는 Expired Date에 의존할 수 밖에 없다.
근데 이중 로그인은 저 만료시간이 중요하지 않다.

언제 로그인을 할 지는 무작위로 발생하기 때문이다.
그래서 많은 고민이 드는 방법이다.

내 개인적인 경험 바운더리 안에서 순수 JWT를 사용할 경우 이중 로그인을 막을 수 없다.
그래서 서버 세션을 쓰던 디비에서 로그인 정보를 관리하던 해야 한다.

이럴 경우 JWT를 쓰는 의미는 사라진다.
로그인 정보를 서버 내에 있는 자원(세션, 디비)에 의존하지 않으려 사용한 것이기 때문이다.

그래서 JWT를 쓰는 경우 이중 로그인을 막으려면 결국 서버 자원에 의존할 수 밖에 없다.


서버 내 저장하는 데이터

서비스마다 로그인 고유 정보는 다르니 빼기로 하고...
그럼 해당 브라우저로 접근했을 때 A와 B 로그인 정보가 다름을 확인하는 고유 값은 어떻게 만들어야 하는 것인가?

필자는 아래와 같은 고민을 하면서 방법을 찾아봤다.


UserAgent

중요한 것은 클라이언트를 구분하는 정보인데, 보통 생각하면 UserAgent 값을 생각할 수 있다.
UserAgent는 사용자의 브라우저 정보를 담고 있다.

그래서 로그인 정보에 UserAgent를 담아볼까 생각했다.
하지만 이건 좋은 방법은 아니다.

근데 만약 크롬의 새로운 윈도우 또는 다른 방식으로 띄울 경우 UserAgent는 유일한 값이 아닐 수 있다.
웹개발자라면 명심하겠지만 절대 사용자를 믿지 말라 라는 말을 기억할 것이다.

그래서 UserAgent는 고유 값으로 쓸 수 없기에 이는 빼기로 했다.


토큰 또는 UUID(고유 식별자)

일종의 토큰을 생성해서 로그인 시 서버로 전달한다면?

이 생각이 들었다.
그래서 이 방식을 접목하려 했다.

먼저 고려해야 할 것은 토큰을 생성해주는 주체는 어디가 되어야 하는가? 이다.

서버로 올 경우 매번 로그인 페이지에 접근할 때 토큰을 서버에게 요청해야 한다. (Axios 등으로) 그리고 SNS 로그인을 사용할 경우 약간의 문제가 생긴다.

SNS 로그인 방식을 사용할 경우 해당 OAuth 주체에게 그 정보를 어떻게 제공해야 하는가가 문제였다.
헤더에 정보를 넣어서 보내봤는데 정보가 제대로 안왔고, 추후 OAuth 주체가 정책을 변경하면 문제가 생긴다.

그럼 클라이언트에서 정보를 생성한다면?
근데 결국 클라이언트도 SNS 로그인 방식 때문에 위와 같은 문제에 봉착한다. 그렇다면 어떻게 해야 할까?


해결 방법을 찾다.

SNS 로그인 방식으로 설명한다.
다른 방법은 그냥 서버에 정보를 저장하면 끝이기 때문이다.
단 서버에 어떤 정보를 저장하는지 관점으로 보면 좋을 듯 하다.

일단 일차적으로 찾은 방법은...
SNS 로그인 이후 콜백에서 클라이언트로 정보를 보낼 때 서버측에서 토큰이나 UUID 정보를 전달하는 방법이었다.

그리고 저 토큰이나 UUID를 쿠키나 로컬 스토리지에 저장하고, 서버와 통신할 때 헤더에 같이 보내면 된다.
근데 이렇게 할 경우 이런 문제가 발생할 수 있다.

  1. 악의를 가진 사용자가 서버에서 온 토큰 또는 UUID를 탈취해서 사용할 경우?
  2. 토큰이나 UUID 자체가 평문이라 복사해서 바로 사용할 수 있다면?

그래서 나는 몇 가지 고민을 하다가 아래와 같은 스텝을 생각해봤다.
프론트, 백엔드 둘 다 약간의 작업이 필요하다.


1. 프론트엔드 측 토큰 생성

초기 페이지에 들어오면 프론트엔드측에서 토큰을 하나 만들어서 LocalStorage와 쿠키에 저장을 해둔다.
토큰은 암호화(해시 아님)해서 저장한다.

쿠키를 사용하는 이유는 Next.Js에서 ServerSide 측에서 처리할 때 LocalStorage 쪽은 접근할 수 없다.
하지만 쿠키는 접근이 가능하기에 두 영역에 저장을 해준다.

참고로 Iron-Session 등을 사용하면 보안 처리에 더 수월하다.

암호화 하는 이유는 위에서 언급한 문제를 막기 위해서이다.
흑우집합소는 Next.Js를 사용하는데 app.tsx에서 아래 일련의 작업을 수행한다.

useEffect(() => {
  //B-Code가 없다면 심어준다.
  if (!isExistKeyInLocalStorage(LOCAL_STORAGE_KEY_B_CODE)) {
    const encryptBCode = cryptoEncrypt(makeClientBCode())
    setLocalStorageData(LOCAL_STORAGE_KEY_B_CODE, encryptBCode)
    makeClientCookie("b-code", encryptBCode)
  }
  //존재하면 쿠키로 구워준다.
  else {
    const bCode = getLocalStorageData(LOCAL_STORAGE_KEY_B_CODE)
    makeClientCookie("b-code", bCode)
  }
}, [])

위에서 b-code라는 것이 이중로그인을 막기 위한 토큰 역할을 하는 값이다.
이는 다른 방식으로 구현해도 무방하다.


2. 사용자의 로그인 수행

사용자가 로그인 페이지에 접근해서 SNS 로그인을 실행한다.
여기선 특별한 작업은 없다.


3. 백엔드 측 SNS 로그인 처리

백엔드에서는 SNS 로그인 콜백을 받은 이후 해당 사용자의 서버측 토큰을 하나 생성해서 로그인 정보와 함께 프론트단에 보내준다.

SNS 로그인 콜백에서 각 정보를 처리하는 과정에서 세션이나 디비에 사용자 정보를 넣을 때 백엔드 토큰을 담아준다.
구현방식에 따라 다르겠지만 마지막 로그인 정보(LastLoginInfo) 등을 세션이나 디비에 사용할 것이다.


4. 프론트엔드에서 SNS 로그인 결과 수신

클라이언트는 로그인 정보 수신 후 바로 Axios를 통해 1번에서 생성한 클라이언트 측 토큰을 보내준다.
이 작업은 Next.Js의 서버사이드 측(Route Api)에서 작업이 진행된다.

보통 SNS 로그인은 창을 띄우던 내부 페이지를 쓰던 Get 메서드로 서버측 URL을 호출하게 된다.
그리고 서버측에서 SNS 콜백 처리 후 프론트엔드 측의 특정 페이지를 호출하게 한다.

흑우집합소의 경우 Next.Js를 사용하여, Route Api쪽으로 정보를 전달한다. 그리고 여기서 정상적으로 로그인 정보를 수신했다면, Axios를 통해서 서버측에 암호화된 클라이언트 토큰을 보내준다.


5. 백엔드의 마지막 로그인 정보 변경

서버측에서 프론트엔드의 토큰 정보를 받으면 복호화 후 이를 조합해서 클라이언트 고유의 값을 만들어서 저장한다.
그리고 디비 또는 세션에 정보를 갱신 후 프론트엔드 측으로 돌려준다.

물론 당연한 이야기겠지만 https를 사용해도 이 값은 암호화 해주는 것이 좋다.
프론트엔드에서는 받은 값을 복호화 후 이를 LocalStorage와 Cookie에 갱신해준다.


6. 데이터 전송 시

이제 저 값을 프론트엔드에서 가지고 있다가 Axios 등으로 통신을 할 때 헤더에 같이 실어서 보내면 된다.
그리고 서버 측에서는 헤더 값을 JWT 확인할 때 같이 이중 로그인도 확인하면 된다.


정리

코드가 없이 구구절절 설명으로만 적어서 이해가 약간 까다로울 수 있다.
근데 내가 사용하는 코드를 다 공개하기엔 내가 못찾은 취약점이 발견될 수도 있고,
보안상 외부에 노출되는 것은 좋지 않기에 이 부분은 양해를 구한다.

근데 이 정도 설명만으로도 이중 로그인 해결에 실마리를 얻을 수 있지 않을까 싶다.
이중 로그인의 핵심은 접근자의 고유 값을 구분함이다.

이렇게 복잡하게 안해도 StackOverFlow에서는 **crypto.randomUUID()**등을 통해서 값을 만들 수 있다.
하지만 난 내 방식대로 구현을 해보고 싶어서 위와 같이 구현했다.

JWT를 통해서 구현했던 분들도 이중 로그인 해결을 위해선 서버측에 정보를 써야 하는 부분은 동일하기 때문에,
결국 세션이나 디비를 활용해야 하는 부분에 대해 납득하기 어려울(?) 수 있다.

물론 JWT내에 클라이언트와 서버 측 정보를 넣어서 처리할 수도 있다.
근데 이렇게 해도 결국 마지막 로그인 정보의 상태 값을 서버에서는 어딘가 들고 있어야 하는 부분은 변함이 없다.

서비스가 방대해지면 다른 좋은 방법들(Redis를 통해 세션 구현 등)을 통해서 구현해야 한다.
하지만 작은 규모의 서비스라면 이런 방식들로도 쉽게(?) 해결할 수 있다.

이로써 이중 로그인 해결에 대한 개인적 고민, 그리고 해결 방안에 대해 포스팅을 남겼다.
나와 비슷한 고민을 하는, 또는 자신만의 실마리를 찾는 분들께 도움이 되었으면 한다.

혹시 더 좋은 방법이나, 개선점이 있다면 댓글로 알려주시면 감사하겠다.