[아주 간단하게 알아보는] 로그인과 보안에 대해

eora21·2024년 9월 8일
1

이 글은 비전공자의 입장에서 가볍게 쓰인 글로, 디테일함보다는 흐름에 집중하여 서술되었음을 미리 알려 드립니다.
더 쉽게 작성한 글을 첨부합니다. 두 편으로 나뉘어져 있습니다.

로그인은 무엇을 위함인가?

문득 생각해보니, '우리는 꽤나 많은 곳에 로그인이 되어있지 않은가?' 하는 생각이 듭니다.
네이버 서비스를 이용하기 위해, 카카오톡 메시지를 받기 위해, 유튜브 추천영상을 받기 위해, 또 이렇게 velog에 글을 쓰기 위해 로그인이 되어있어야 합니다.

하지만 로그인을 하지 않고 서비스를 이용할 수 있는 곳도 많기에, 로그인은 내가 나 자신임을 알리기 위한 수단이라고 볼 수도 있겠습니다. 해당 과정을 '인증'이라고 하겠습니다.

최강록님은 경연에서 사용한 재료가 들기름임을 '인증'하셨습니다.

어떻게 내 자신임을 증명할 수 있는가?

Http의 특징: Stateless

http 요청은 stateless로, 정보를 유지하지 않습니다.

각 요청은 독립적으로 수행 및 판단되며, 서버는 이전에 어떤 요청을 받았는지 기억하지 못 합니다.

클라이언트: 터치드 1집 얼마에요?
서버: 49,000원입니다.
클라이언트: 구매하겠습니다.
서버: ..뭘요?
클라이언트: 2,000원 할인 쿠폰 쓸게요.
서버: ..어디에요?

따라서 모든 요청에 '무엇을 원하는지' 매번 적어줘야 합니다.

클라이언트: 터치드 1집 얼마에요?
서버: 49,000원입니다.
클라이언트: 터치드 1집 구매하겠습니다.
서버: 재고가 확인되었습니다.
클라이언트: 터치드 1집 구매에 2,000원 할인 쿠폰 쓸게요.
서버: 2,000원 할인 쿠폰 확인하였습니다. 터치드 1집 구매에 사용됩니다.

물론 내 자신임을 인증하는 것도 여기에 포함되겠죠?
영화의 한 장면으로 예시를 들어보겠습니다.

최익현: 니 내가 누군지 아나?! 내가 느그 서장이랑 임마!
형사: 저기 선생님.. 실례지만 저희 서장님과 관계가 어떻게..?
최익현: 느그 서장 남천동 살제?! 어?
형사: 예..
최익현: 내가 임마! 느그 서장이랑 임마! 어저께도! 어? 같이 밥 묵고! 어? 사우나도 같이 가고! 어?
형사: 아 그래예? 그라믄.. (아, 우리 서장님과 아시는 분이구나)

이를 클라이언트와 서버로 살짝 변경해보겠습니다.

클라이언트: 니 내가 누군지 아나?!
서버: 모릅니다.
클라이언트: 느그 서장 남천동 살제?! 어?
서버: 그렇습니다.
클라이언트: 내가 임마! 느그 서장이랑 임마! 어저께도! 어? 같이 밥 묵고! 어? 사우나도 같이 가고! 어?
서버: 서장님을 아시는 분이군요. 확인하였습니다.

이처럼 요청 시 특정 정보를 토대로 '이 요청은 특정 사용자의 요청이구나'를 알 수 있어야 합니다.

왜 보안에 신경써야 할까?

혹시 위의 예시를 보고 갸우뚱하지 않으셨나요?
인증은 '내가 누구인지'를 알려야 하는데, 서버는 서장님과의 관계를 기반으로 이를 유추하고 있습니다.

실제 인증을 속일 경우

'내가 나임을 알리는 작업'은 만만치 않습니다. 아무리 인증 절차가 까다로워져도, 누군가 흉내낼 수만 있다면 무용지물이거든요.

전의 예시를 다시 볼까요? 최익현님은 서장님과 실제 아시는 분으로, 최익현님이 '내가 어떠한 사람이다'를 나타내고 있습니다.

저는 어떨까요? 저는 해당 경찰서의 서장님과 아무런 관계가 없습니다.
하지만 현재 인증 절차는 서장님과의 관계이며, 이를 통해 현재의 대우를 개선시켜달라는 요청을 하고 있습니다.

해당 빈틈을 이용하여, 저 또한 같은 요청을 보내본다면 어떻게 될까요?

김주호: 제가 누구인지 모르시죠? 저는 서장님과 관계가 있습니다.
형사: 저기 선생님.. 실례지만 저희 서장님과 관계가 어떻게..? (인증 절차)
김주호: 서장님은 남천동에 사시죠?
형사: 예.. (인증 중)
김주호: 저는 서장님과 어저께도 같이 밥 먹고, 사우나도 같이 다녀왔습니다.
형사: 아 그래예? 그라믄.. (인증 완료)

분명 저는 서장님과 모르는 관계이지만, 최익현님을 흉내냈을 뿐임에도 인증이 되었습니다.

이를 다시 서버와 클라이언트의 예시로 확인해볼까요?

클라이언트: 제가 누군지 아시나요?
서버: 모릅니다.
클라이언트: 저는 서장님과 관계가 있는 김주호입니다.
서버: 아, 그러시군요. 반갑습니다.
클라이언트: 저는 서장님과 관계가 있는 김주호이며, 현재의 대우가 마음에 들지 않습니다.
서버: 서장님을 아시는 분이군요. 확인하였습니다.

이처럼 인증 절차 중 하나인 관계를 속였더니, 손쉽게 인증이 되었습니다.

실제 클라이언트와 서버도 이러한 절차를 기반으로 인증을 수행합니다. ID와 PW, FaceID, OAuth2 등 많은 인증 수단이 있지만 이는 결국 특정한 절차들에 속합니다.

만약 이러한 인증 절차가 빈약한 경우, 누구나 손쉽게 인증받을 수 있게 될 겁니다.

굉장히 튼튼한 인증 절차를 지니고 있다고 생각해봅시다. 문제가 없을까요?
아쉽게도 인증 이후의 요청에는 문제가 생길 수 있습니다. 클라이언트의 요청 중 특정 정보를 바탕으로 '인증된 사용자인가?'를 확인하기 때문이에요.

맨 처음에 HTTP의 Stateless에 대해 말씀드린 이유가 이것입니다. 실제 인증인증 후 요청은 명확히 따지면 다르기 때문이에요.

인증 후 요청을 속일 경우

사실, 이는 현실적으로도 해결하기 힘든 부분입니다. '요청하는 사람이 진짜 본인이 맞는가?'를 매번 확인하기에는 굉장히 번거롭고 복잡하거든요.

아주 단적인 예시를 들어보겠습니다.
만약 여러분의 친구가 말을 걸어오는데, 겉모습은 같지만 속은 외계인이라면? 여러분은 알아차릴 수 있을까요?

'지구를 지켜라!'는 주인공인 병구가 '겉모습은 인간이지만 사실은 외계인'인 존재를 찾으려는 영화입니다.
포스터와는 다르게 실제 영화는 수위가 있는 편이니, 혹시나 보실 분들은 마음의 준비를 하시길..

분하다! 당신은 외계인을 구별해내지 못 했어요. 겉모습도, 목소리도, 입술을 잘근잘근 물어뜯던 친구의 습관도 완벽했거든요.

그래서 인증 절차를 강화했습니다. 만반의 준비를 하던 당신은 DNA 검사기를 구했습니다.
어떻게 구했냐구요? 애초에 예시가 외계인인데, 그런 디테일한 건 신경쓰지 말자구요 우리.

다음날, 친구가 다가옵니다. 외계인에게 한번 속으신 당신은 눈앞의 친구를 의심하곤 DNA 검사기를 들이밀어봅니다.
OK가 떴습니다. 외계인이 아니었군요!
'눈앞의 사람은 인증된 내 친구야'라며 안도의 한숨을 내쉽니다.

하지만 여러분이 잠깐 한눈을 판 사이, 외계인이 당신의 친구를 어디론가 데려갔습니다. 그리고 친구분이 서있던 장소에는 슬그머니 외계인이 자리하네요.

외계인이 웃으며 당신에게 말을 겁니다. '아, 그러고보니 너희 집 현관 열쇠 어디에 둔다고 했더라?'
네, 당신은 다시 한 번 외계인에게 속으셨습니다.

과장된 예시를 들긴 했으나, 실제적로도 많이 일어날 수 있는 헛점입니다. 제가 여러분의 ID와 PW를 몰라도, 인증 정보만 알 수 있으면 여러분을 흉내낼 수 있거든요.

이제부터는 인증 과정과 인증 정보를 어떻게 관리해야 안전하게 사용할 수 있는지 말씀드리겠습니다. 다만 인증과정 자체는 워낙 잘 처리해주는 프레임워크나 라이브러리가 많기 때문에 인증 정보를 기준으로 말씀드리도록 하겠습니다.

인증 정보

인증 정보는 인증된 사용자에게 부여하는 정보입니다. '정상적으로 인증되었으니, 이후 요청에 해당 정보를 첨부해주세요!'라고 부여해주는 정보이죠.

여러 방법들이 있지만, 많이 쓰이는 Session과 JWT에 대해 설명드려보도록 하겠습니다. 두 방법 모두 일종의 토큰을 사용하는 형식이기 때문에, 토큰이 무엇인지부터 간단하게 말씀드려볼게요.

토큰을 아주 간단하게 정의하자면 '손목에 차는 놀이공원 이용권'입니다.
여러분이 놀이공원에 가서 놀이기구를 이용하려면 이용권이 있으셔야 합니다. 해당 이용권은 '이러이러한 놀이기구만 이용 가능' 혹은 자유이용권으로 '모든 놀이기구를 이용 가능'하다는 정보들을 바탕으로 부여될 거에요.

하지만 여기서 중요한 건, '손목에 찼다'는 점입니다. 이용권 자체에는 놀이기구 정보가 있을 수도, 없을 수도 있어요.

이용권에 적힌 바코드를 통해 놀이공원 서버에 접속해서 이용 가능성을 체크할 수도 있고, 이용권 색을 통해서 비교적 간단하게 체크할 수도 있겠죠. 이처럼 체크하는 방식은 여러 방법이 있겠으나, 중요한 건 사용자의 손목에 찬 이용권을 기반으로 체크한다는 겁니다.

여러분은 인증을 통해 인증 정보인 토큰을 부여받았습니다.
그럼 이 토큰을 통해 어떻게 '내가 나임을' 확인받을 수 있는지 알아보겠습니다.

Session

Session은 서버에서 유지하는 사용자의 연결 정보입니다.
인증 과정이 성공적으로 완료되고 나면 서버는 의미를 지니지 않는 토큰인 SessionId를 생성해요. 의미를 지니지 않는 이유는, 사용자의 정보를 토대로 이를 추론하지 못 하게 만들기 위함이에요. 중복될 확률이 극히 적은 UUID 등을 사용한답니다.

서버는 해당 SessionId를 Key로 설정하고, Value에는 사용자의 정보를 기입해요. 사용자의 닉네임이나 id 등 자주 쓰는 값 혹은 내부적으로 필요한 값들을 넣어둡니다.

그 후 서버는 클라이언트에게 SessionId를 전달해요. 주로 브라우저의 Cookie에 담길 수 있도록 설정합니다.

아니요, Cookie 사용은 필수가 아닙니다! 다만 Cookie의 특성이 Session을 사용하기 쉽게 만들어 주기 때문이에요.

Cookie는 브라우저에서 관리하는 정보 파일이에요. 주로 서버에서 부여하는 특정 값들을 이용하기 쉽도록 기록해 두는 공간이라 생각하면 쉽습니다.

만약 특정 사이트에 대한 HTTP 요청이 발생했다면, 브라우저는 해당 사이트에 대해 유지하고 있는 Cookie가 있는지 확인해요. Cookie가 존재한다면 해당하는 값을 HTTP 요청의 헤더에 자동으로 기입해 준답니다.

처음에 언급한 HTTP의 Stateless 특성, 기억하시죠? 요청에는 매번 인증된 사용자인지 확인하는 절차가 필요합니다. Cookie에 SessionId가 저장되어 있다면, 브라우저는 매 요청마다 SessionId를 자동으로 보내줄 거에요.

서버는 해당 SessionId를 통해 이 사용자가 누구인지 조회합니다. 이로 인해 '이 요청을 한 사용자는 누구인가?'를 파악할 수 있게 되는 거죠.

SessionId를 Cookie에 담지 않아도, 서버에서 확인할 수 있는 방식으로 전송한다면 문제가 없습니다! 다만 앞서 언급한 것처럼 Cookie의 자동 첨부 기능이 굉장히 편리하기에 같이 사용한다고 보시면 되겠습니다!

세션 저장소 특징

보통 세션 저장소는 인메모리 저장소를 사용하는데, 이는 두가지 이유가 있습니다.

  1. 빠른 Session 정보 획득을 위해
  2. 세션 저장소의 값들이 모두 날아가도, 사용자에게 미치는 영향이 미미하기 때문에

세션 저장소가 갑자기 다운되어 모두 사라져도, 사용자의 연결 정보가 사라진 것뿐이기에 사용자는 재로그인을 수행하면 됩니다. 즉, 크리티컬하지 않은 데이터이기에 메모리로만 기록해도 충분한 값인 거죠.

장점

클라이언트가 지니고 있는 인증 정보는 SessionId 뿐이에요. 자세한 정보들은 모두 서버가 지니고 있죠.
따라서 사용자 정보 유출에 보다 안전합니다. (물론, SessionId를 다른 사람이 획득하게 되면 위험합니다!)

단점

사용자가 증가할수록, 세션 저장소가 부담해야 하는 Session들의 개수가 많아집니다. 또한 해당 정보에 접근하려는 요청도 늘어나겠죠. 따라서 리소스를 신경써야 합니다.

만약 서비스가 세계적으로 흥행해서, 1,000만명의 사용자가 동시에 요청을 보낸다고 가정해봅시다! 세션 저장소에는 그만큼 많은 사용자들의 Session 정보가 기입됩니다.

아무리 세션 저장소를 잘 쪼개고, 공간을 확장시켜도 언젠가는 한계점에 도달하게 될 것 같아요. 끝까지 버티고 버티다가, 문득 이런 생각이 뇌리를 스칩니다.

'각 연결 정보를 서버가 아니라 클라이언트가 유지하게 하면 어떨까..?'

JWT

앞서 소개드렸던 Session이 '서버에서 유지하는 사용자의 연결 정보'였다면, JWT는 클라이언트에서 유지하는 사용자의 연결 정보입니다.

JWT는 Json Web Token의 줄임말입니다. (JWT 토큰은 Json Web Token Token으로 겹말 아닌가..?)

JWT 역시 토큰의 일종이에요. 따라서 매 요청마다 같이 첨부해주어야 한답니다.
다만, 의미를 지니지 않던 토큰인 SessionId와는 다르게 JWT는 의미를 지니고 있어요! JWT는 base64라는 값으로 인코딩이 되어있는데, 이를 디코딩하면 내부 구조를 확인할 수 있습니다.

jwt.io를 통해 간단한 구조를 확인할 수 있습니다. 알고리즘과 토큰타입을 지정하는 HEADER, 데이터를 기록하는 PAYLOAD, 헤더와 페이로드를 secret 값으로 해시화한 SIGNATURE로 나뉘어집니다.

장점

JWT는 SessionId와는 다르게 '디코딩 가능한 토큰'입니다. 연결 정보를 클라이언트에서 지니고 있고, 필요한 요청마다 같이 보내주도록 구성되어 있어요. 서버에서 더 이상 연결 정보를 지니고 있지 않아도 되므로, 세션 저장소가 필요가 없게 됩니다!

따라서 서버의 리소스를 아낄 수 있으며, 분산환경에서 큰 이점을 지닙니다.

구글 클라우드, 유튜브, gmail 등은 지원 서비스 성향이 모두 다르지만 동일한 구글 계정으로 이루어집니다.
만약 세션으로 이를 유지한다면, 모든 서비스에서 세션 정보를 파악하도록 구성을 해야 할 거에요. 아무리 빠른 세션 저장소를 사용한다 해도 수많은 요청에 의해 부하가 걸릴 수 있고, 만약 세션 저장소가 다운되어 데이터가 날아갔다면 모든 사용자의 로그인이 풀릴 것입니다.

그러나 JWT를 사용한다면 JWT의 헤더, 페이로드, 시그니처를 검증만 하면 되므로 보다 나은 플로우를 지닐 수 있게 됩니다.

단점

JWT의 헤더, 페이로드, 시그니처는 누구나 확인할 수 있습니다.
만약 페이로드에 사용자의 민감 정보를 넣어뒀다면, 손쉽게 어떤 내용으로 이루어져 있는지 확인할 수 있습니다.

위 이미지처럼, 비록 시그니처가 invalid함에도 불구하고 페이로드는 그대로 확인할 수 있는 것을 알 수 있습니다. 암호화가 아닌 Base64 인코딩으로 이루어져 있기 때문입니다.

앞서 시그니처는 '헤더와 페이로드를 secret 값으로 해시화'한 값이라고 말씀드렸죠? 이 말은 즉 secret만 알고 있다면 데이터 조작이 가능하다는 뜻입니다.

서버는 헤더와 페이로드에 대해 secret 값으로 해시해보고, 해당 결과와 시그니처가 일치하면 통과시켜주는 방식으로 JWT를 사용하기 때문이에요.

만약 secret 값이 유출되었다면, JWT를 임의로 생성하여 서버에 요청할 수도 있습니다.
서버에서 정보를 유지하는 세션보다는 보안적으로 위험할 수 있어요.

JWT를 안전하게 유지하는 법은?

우선 secret 값이 절대 유출되지 않게 막아야겠죠? 그 외에도 여러 유의사항이 있습니다.

민감정보 담지 않기 or 암호화

페이로드에 민감정보를 담지 않는 게 제일 좋습니다만, 어쩔 수 없이 특정 정보들을 담아야 할 때가 있습니다. 이럴 때는 양방향 암호화를 통해 클라이언트가 민감정보를 알 수 없도록 만드는 등의 방법을 사용하는 게 좋아요.

Refresh Token

앞서 JWT가 토큰이라고 이야기 드렸죠? 이렇게 원하는 정보에 접근하기 위한 토큰을 액세스 토큰이라고도 합니다.

액세스 토큰의 문제점은, 지정된 규약에 맞으면 언제나 통과된다는 점이에요.
위에서도 확인했듯, 전송된 시그니처와 서버에서 만든 확인용 시그니처가 일치하면 언제든 통과되겠죠.

앞서 예시로 들었던 놀이공원 이용권을 다시 가져와볼게요.
누군가가 갑자기 다가와서, 여러분의 손목에 있던 자유이용권을 강제로 뜯어가 버렸어요!
물론 여러분은 결제했던 영수증을 토대로 다시 이용권을 받을 수 있겠죠.
하지만 다시 매표소로 가서, 영수증을 보여드리고, 이를 확인받고, 이용권을 다시 생성하고, 여러분의 손목에 채워주는 과정이 필요합니다.

반면 여러분의 이용권을 강탈해 간 사람은, 해당 이용권을 바탕으로 하루 종일 온갖 놀이기구를 이용할 수 있게 될 거에요.

이렇게 액세스 토큰이 탈취되어 자유롭게 사용되는 것을 막기 위해, 리프레시 토큰을 이용할 수 있습니다.
리프레시 토큰은 액세스 토큰을 재발급받기 위한 토큰이에요. 말 그대로 갱신용 토큰이죠.

JWT 내 페이로드에 만료시간을 작성한다고 가정해보겠습니다. 한 5분 정도로요.
또한 리프레시 토큰도 같이 보내줍니다. 리프레시 토큰 또한 의미를 지니지 않은 UUID로 만들어 볼게요.

여러분은 이용권(액세스 토큰)은 오른손 손목에 차고, 재발급권(리프레시 토큰)은 누가 훔쳐가지 않도록 자켓 안주머니에 넣었습니다.

앗, 또 누군가가 여러분의 이용권을 뜯어가 버렸어요!
하지만 여러분은 다시 매표소까지 가서 번거로운 과정을 거치지 않아도 돼요. 놀이기구를 타기 전 재발급권을 보여주고, 바로 손목에 이용권을 채웠습니다. 정말 편하군요!

반면 여러분의 이용권을 강탈해간 사람은 5분동안만 자유를 즐길 수 있습니다.

Refresh Token Rotation

하지만, 만약 재발급권까지 훔쳐가는 '소매치기의 달인'이라면 어떨까요?

여러분은 예전에 그랬듯 다시 매표소로 가서, 영수증을 보여드리고, 이를 확인받고, 이용권과 재발급권을 다시 생성하고, 이용권은 손목에, 재발급권은 자켓 더 깊숙히 넣어야 할 거에요.

반면 여러분의 재발급권을 훔쳐간 사람은, 계속해서 이용권을 만들어내고 많은 놀이기구를 이용하겠죠. 결국 신경써야 할 토큰만 늘어버린 꼴이 되어버렸어요..!

이를 해결하기 위해선 어떻게 해야 할까요? 이용권을 위한 재발급권을 위한 재재발급권이 필요한 걸까요?

음.. 재재발급권이 강탈당하면 어쩌죠?
그러면 이용권을 위한 재발급권을 위한 재재발급권을 위한 재재재발급권을..

놀이공원은 고민을 거듭하다 한가지 방법을 떠올렸어요.
바로, 재발급권을 일회용으로 만들고 이를 실제 구매한 사람 정보와 매칭시키는거죠!

여러분의 이용권이 만료되어 재발급권을 제출할 경우, 놀이공원은 갱신된 이용권과 갱신된 재발급권을 전달해줍니다.

소매치기의 달인이 다시금 재발급권을 훔쳐갔다고 해보죠! 해당 재발급권에는 ZXCV라고 쓰여 있었습니다.

여러분은 매표소로 가서 이용권과 ASDF 재발급권을 수령했습니다. 또한, 여러분 정보에 현재 'ASDF 재발급권'을 지니고 있음이라고 기록해둡니다.

소매치기의 달인은 ZXCV 재발급권을 통해 이용권을 얻었습니다! 5분 동안은 여기저기 돌아다니며 마음껏 놀이기구를 사용했어요.

5분이 지나고, 소매치기의 달인은 당당하게 ZXCV 재발급권을 내밉니다. 직원은 해당 재발급권을 통해 이용권을 발급하려 합니다.

하지만 이미 ZXCV 재발급권은 만료되었다고 뜨는군요. 직원은 '잠시만 기다리세요'라 말하며 경찰을 불렀습니다.
저런, 결국 소매치기의 달인은 놀이공원에서 쫒겨났습니다.

이러한 방식을 Refresh Token Rotation이라고 합니다. 공격자가 '탈취한 리프레시 토큰'을 통해 계속 액세스 토큰을 발급받는 상황을 막기 위함입니다.

만약 액세스 토큰이 만료되어 리프레시 토큰을 검증할 때, 어딘가(DB, 인메모리 등)에 기록된 리프레시 토큰과 다른 경우 해당 계정을 '보안 위험' 상태로 변경하여 공격자의 행동을 무력화 시킬 수 있도록 구성할 수 있습니다.

하지만 완벽한 방법은 아닙니다. 소매치기의 달인이 5분간은 마음껏 활개치고 다녔던 것처럼, 만료되지 않은 액세스 토큰을 강제로 무력화시킬 수는 없습니다.

이를 완벽하게 막기 위해선 '이러한 액세스 토큰은 더 이상 통과시키지 마세요'같은 방법이 필요합니다.
하지만 해당 방법은 '통과시키지 않을 액세스 토큰'을 기록하고 검증하는 절차가 필요합니다. 액세스 토큰의 장점이 굉장히 옅어지게 되죠. 만약 해당 작업이 더 고도화 되어야 한다면, 차라리 세션 방식을 사용하는 게 낫습니다. 액세스 토큰(JWT)을 사용하던 이유는 stateless 요청에서 서버의 부하를 줄이기 위함인데, 매번 확인하고 검증하는 과정이 추가된다면 (세션을 사용하던 것보다는 조금 나을 수 있겠지만) 서버에 리소스를 사용해야 하기 때문이죠.

즉, 정답은 없습니다. 여러분의 서비스 방향성에 따라 세션을 사용할지, 단순 토큰을 사용할지, JWT를 사용할지, 리프레시 토큰을 같이 사용할지, 블랙리스트 토큰을 유지할지 등 어떠한 방법이어도 괜찮습니다. 비용과 연산을 고려하셔서 선택하시면 되겠습니다.

부록: 액세스 토큰, 리프레시 토큰은 어디에 어떻게 저장하는 게 좋을까?

만약 JWT를 사용하실 분들을 위해 좀 더 기술적인 내용을 작성해 보겠습니다.

브라우저에 데이터를 저장하는 방법은 브라우저 메모리, 세션 스토리지, 로컬 스토리지, 쿠키 등이 있습니다.

브라우저 메모리는 브라우저가 동작시키는 js 메모리에 넣는 방법이지만 새로고침 시 메모리가 초기화되어 저장된 값이 사라집니다.

세션 스토리지는 새로고침에도 유지되지만 해당 탭에 종속되어 있기 때문에 탭을 닫으면 제거되고, 다른 탭에서 작업하는 경우 각기 다른 스토리지를 소유하기 때문에 데이터가 공유되지 않습니다.

로컬 스토리지는 해당 브라우저가 관리하는 영역이기에, 탭을 닫거나 다른 탭에서 작업해도 문제가 생기지 않습니다.

쿠키도 로컬 스토리지와 마찬가지로 브라우저가 관리하지만, 만료일을 지정 가능하며 http 요청 시 (해당 사이트에서 지정한 쿠키가 있는 경우) 자동으로 헤더를 통해 제출됩니다.

서비스마다 방향성이 다를 수 있기에 정답은 없지만, 액세스 토큰은 브라우저 메모리나 세션 스토리지, 로컬 스토리지에 넣어 관리하고 리프레시 토큰은 HTTP-OnlySameSite=Strict 혹은 SameSite=Lax로 설정하여 공격을 대비할 수 있습니다.
HTTP-Only는 js를 통해 해당 쿠키 값을 읽지 못하게 하며, SameSite=Strict는 같은 사이트에서의 요청이 아니라면 전송되지 않게 합니다. SameSite=Lax는 외부 사이트에서 해당 사이트로의 접속 등 주로 GET 요청이 발생하는 경우 전송되지만, form 제출 등의 POST 요청 등 민감할 수 있는 요청에는 전송되지 않습니다.

요청을 안전하게 처리하는 법은?

지금까지는 사용자의 인증 정보를 요청하는 방법들에 대해 알아보았습니다.
하지만, 여러분의 요청을 중간에 가로채서 요청정보를 획득하거나, 혹은 여러분이 원치 않는 요청을 강제로 만들어버리면 어떻게 될까요?

이번에는 클라이언트의 요청을 안전하게 관리하는 방법에 대해 알아보겠습니다.

대표적인 공격인 XSS 공격과 CSRF 공격에 대해 알아보겠습니다.

XSS 공격

XSS 공격은 사이트의 취약점을 알아내어 임의의 스크립트를 심는 방법입니다.
만약 여러분이 SPA(Single Page Application)을 구성중이라고 가정하겠습니다.
사용자가 업로드한 게시글을 보기 위해 아래와 같은 코드를 작성했습니다.

...
<div id="content"></div>
...

<script>
  ...
  const userInput = await fetch(`/content/${contentId}`);
  document.getElementById("content").innerHTML = userInput;
  ...
</script>

특정 contentId에 입력된 값을 가져오고, 이를 content라는 id를 지닌 div에 그대로 넣는 코드를 작성했습니다.

해당 코드는 굉장한 위험성을 지니고 있습니다. 바로, 사용자가 html형태의 글을 작성했다면 해당 스크립트가 동작할 수 있다는 뜻이죠.

"<script>
  alert('XSS 공격!');
</script>

사용자가 위와 같은 스크립트 형태의 글을 작성한 경우, 해당 글을 클릭한 사용자는 XSS 공격!이라는 얼럿 창을 강제로 보게 됩니다.

해당 예제에서는 스크립트를 자유롭게 동작시킬 수 있다는 게 큰 문제가 될 수 있습니다.
악의적인 스크립트를 작성한다면 메모리, 세션 스토리지, 로컬 스토리지에 모두 접근이 가능합니다. 쿠키 또한 HttpOnly를 설정하지 않았을 경우 역시 접근할 수 있죠.

이를 통해서 사용자의 인증 정보를 수집하고, 해당 인증 정보를 통해 사이트를 마음껏 이용 가능하다면..?

굉장히 큰 보안 이슈가 발생하게 될지도 모릅니다.

const sanitizedInput = DOMPurify.sanitize(userInput);
document.getElementById('content').textContent = sanitizedInput;

위와 같은 예시에서는 이러한 방식으로 XSS를 방지할 수 있습니다.

요즘은 브라우저나 프레임워크, 모듈 단에서 XSS 공격을 방지해주기도 합니다. 다만 개발자가 이를 인지하지 못한 채 특정 기능을 꺼버리거나 경고를 무시하는 경우, 혹은 직접 개발하다가 취약점을 드러내게 되는 경우 많은 문제가 일어날 수 있습니다.

// 안전한 사용: React는 기본적으로 이스케이프 처리
<div>{userInput}</div>

// XSS 공격에 취약한 코드: dangerouslySetInnerHTML 사용
<div dangerouslySetInnerHTML={{ __html: userInput }} />

리액트에서는 기본적으로 XSS 공격을 방지해주지만, dangerouslySetInnerHTML을 사용하면 XSS 공격에 취약해집니다.

// Pug 템플릿 엔진의 이스케이프 기능 비활성화
pug.render("p!= userInput", { userInput: "<script>alert('XSS')</script>" });

SSR을 위해 pug를 쓸 때에도 !=를 통해 이스케이프를 비활성화하면 XSS 공격에 취약점을 지니게 됩니다.

// 잘못된 필터링 함수: 단순히 <, > 기호만 필터링
function sanitize(input) {
  return input.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

// 필터링 우회를 통해 XSS 공격 가능
const userInput = `<img src="x">`;
document.body.innerHTML = sanitize(userInput);

< 문자와 > 문자에 대해서만 필터링했다 한들, img 태그 내의 onerror를 통해서도 스크립트를 강제 실행시킬 수 있습니다.

따라서 개발자는 현재 사용하고 있는 프레임워크, 라이브러리가 어떻게 안전함을 보장해주는지, 현재 작성하고 있는 코드가 보안적으로 취약함을 드러내는지를 특별히 신경써야 합니다.

CSRF 공격

CSRF 공격은 공격하고자 하는 target 사이트의 취약점을 노리는 대신, 다른 사이트를 통해 target 사이트로 원치 않는 요청을 보내는 방법입니다.

공격자가 우리 사이트의 취약점을 알아냈다고 합시다. 앞서 예시를 든 것처럼, html 구조로 글을 작성한 경우 스크립트가 강제로 동작된다는 점을 알아채 버렸네요.

하지만 우리 사이트에는 특별히 건질 만 한 데이터가 없다고 판단합니다.
그렇다면 '에이, 텄다 텄어'하고 넘어갈까요?

해당 취약점을 통해 특정 은행의 api를 동작시키도록 구성할 수 있지 않을까요?

<form action="<https://example-bank.com/transfer>" method="POST">
  <input type="hidden" name="amount" value="1000000">
  <input type="hidden" name="to" value="attacker-account">
</form>
<script>
  document.forms[0].submit();
</script>

만약 해당 글을 조회한 사용자가 'example-bank'에 로그인되어 있었을 경우, 사용자의 1,000,000원이 공격자의 계정으로 넘어가게 됩니다.

이러한 공격을 방지하기 위해 CSRF 토큰이 존재합니다.

CSRF 토큰

CSRF 토큰은 CSRF 공격을 대비하여, 사용자마다 랜덤한 토큰을 지정해둡니다.
그 후 해당 토큰값이 요청에 그대로 불러와지는지 확인하는 방법입니다.

다른 사이트를 통한 요청에는 해당 토큰값이 담기지 않도록 하면, '이 요청이 진짜 우리 사이트 내에서 발생한 요청인가?'를 확인할 수 있게 됩니다.

해당 토큰을 만들고 검증하는 형태도 여러 방법이 있습니다.

세션 검증 패턴

세션을 만들 때 랜덤한 CSRF 토큰을 만듭니다.
SSR이라면 해당 페이지 html 혹은 스크립트의 변수나 속성으로 해당 토큰값을 지정합니다.
그 후 요청을 보낼 때 input hidden으로 해당 토큰값을 전달할 수 있도록 합니다.

CSR이라면 쿠키로 CSRF 토큰을 브라우저에 전달합니다. 이 때 HTTP-OnlySameSite=Strict를 같이 설정합니다.

해당 토큰값과 세션에 저장된 토큰값을 비교하고, 일치할 때만 해당 요청을 수행할 수 있도록 합니다.

하지만 이 방법은 세션 방식을 사용할 때에만 유용합니다.

더블 서밋 쿠키 패턴

임의의 CSRF 토큰을 만들고 SameSite=Strict만 걸어 쿠키로 설정합니다.
그 후 요청을 보낼 때 해당 쿠키값을 읽어서 커스텀 헤더 혹은 바디(비추천)에 쿠키값을 그대로 전송합니다.

서버에서는 쿠키를 통해 보내진 원본 토큰값과 커스텀 헤더 혹은 바디로 전송된 복사 토큰값을 비교하여 일치한다면 요청을 수행할 수 있도록 합니다.

세션 방식을 사용하지 않을 때에도 사용할 수 있는 방법입니다. 하지만 해당 쿠키가 HTTP-Only가 아니어야 하므로, XSS 취약점에는 무방비하다는 단점이 있습니다.

바디를 비추천하는 이유는 추후 서술하겠습니다.

번외: 무방비한 로그인 CSRF 공격

CSRF 토큰을 통해 외부 요청들에 대해서 잘 대비하였군요!
이제 안전한 걸까요?

혹시, 로그인 요청 자체에 CSRF 공격이 수행되리라고 생각해보신 적 있으신가요?

앞서 설명한 보안 설정법들은 '로그인 이후'의 방어수단일 뿐, 로그인 이전의 방어수단이 아닙니다.
로그인 자체도 보호하지 않으면 큰 일이 벌어질 수 있습니다.

  • A 사이트에 신용카드 등록 요청
  • 여러 리다이렉트를 거치던 도중 특정 취약점을 통해 A 사이트에 공격자 계정으로 강제 로그인
  • 사용자는 알아차리지 못한 채 신용카드 정보 기입
  • 해당 신용카드는 공격자의 계정으로 등록됨

이처럼 무분별한 로그인에 대해서 방어 대책을 세우지 않았다면, 문제가 발생할 수 있습니다.

preSession

로그인 이전에도 세션을 만들고, 앞서 설명드린 CSRF 토큰을 통해 '실제 우리 사이트에서 일어난 로그인 요청이 맞는가?'를 판단하는 방법입니다.
굉장히 쉽지만, 로그인하지 않은 사용자들을 상대로도 세션을 만들어야 한다는 단점이 있습니다.

HTTPS + Referer

HTTPS와 Referer 헤더를 통한 방어 기법입니다.

실제 로그인 요청이 일어난 곳의 정보를 확인하고, 우리 사이트의 로그인 페이지라면 통과시킬 수 있는 방법입니다.

자세한 내용은 로그인 csrf 공격은 왜 막아야 하고, 어떻게 막을 수 있는가?를 참조해 주시기 바랍니다.

CAPTCHA

로그인 페이지에 CAPTCHA를 추가하고, 해당 CAPTCHA가 통과되어야 로그인 요청을 보낼 수 있도록 구성합니다.

외부 요청에서는 CAPTCHA를 강제로 통과시킬 수 없기 때문에 안전한 방법 중 하나입니다.

2023년 10월부터 Cloudflare의 Turnstile이 무료가 되었기에, 해당 서비스를 이용해보는 것도 좋겠네요.

번외: Body에 담기는 CSRF 토큰의 취약점

body에 CSRF 토큰을 포함하여 전송하는 경우, 해당 토큰은 BREACH 공격에 굉장히 취약해집니다.
BREACH 공격이란 http의 압축률을 비교하여 본문을 유추할 수 있는 공격입니다.

만약 CSRF 토큰이 ABCD인 경우, 요청의 쿼리 파라미터에 글자를 대입해봅니다.
만약 쿼리 파라미터와 body의 CSRF 토큰 글자가 일치하는 경우 압축률에 변동이 있게 됩니다.

/someRequest?param=A
기존의 압축률과 변동이 있음을 확인할 수 있습니다. 따라서 CSRF 토큰의 첫 글자가 A라고 유추해볼 수 있습니다.

/someRequest?param=AA
해당 요청으로는 압축률이 변하지 않습니다. 다르게 보내봅니다.

/someRequest?param=AB
압축률이 변동하는 걸 확인할 수 있습니다. 이로 인해 CSRF의 일부가 AB인 것을 알게 됩니다.

이처럼 같은 요청을 반복하며 하나씩 글자를 확인하는 공격을 BREACH라고 합니다.
해당 공격은 HTTP 압축을 비활성화하거나, 압축 데이터 앞뒤로 임의의 공백을 추가시켜 매번 사이즈에 변동을 주거나, 토큰값을 매번 변경시켜 전달하는 것입니다.

Spring의 SockJS에 Security를 적용할 경우 토큰값의 변경 없이도 매번 다른 정보를 body에 담을 수 있는 XorCsrf 토큰을 사용합니다.

자세한 내용은 이전에 작성한 XorCsrf 토큰이란 무엇이고, 왜 써야 하는가(feat. BREACH Attack)를 참고해 주시기 바랍니다.

Reference

https://velog.io/@iamtaehoon/sagah
https://velog.io/@jellyjw/%EB%A1%9C%EC%BB%AC%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%84%B8%EC%85%98%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%BF%A0%ED%82%A4%EC%9D%98-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EC%B0%A8%EC%9D%B4%EC%A0%90
https://velog.io/@haizel/XSS%EC%99%80CSRF-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B0%8F-%EB%8C%80%EC%9D%91-%EB%B0%A9%EC%95%88

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글

관련 채용 정보