이제는 조금씩 혼자서 이것저것을 만들 수 있게 되신 분이 개인적으로 “장비 사용량 관리 웹 어플리케이션”을 만들면서 요구사항으로 ‘장비의 상태 변경(장비 사용량 추가, 청소 이력 추가 등)을 하였을 경우 해당 상태를 변경한 사람을 알 수 있게 해 달라’는 요청을 받았다고 한다. 해당 어플리케이션은 폐쇄된 환경에서 단일 머신에서만 사용될 것이었기에 로그인과 같은 기능은 초기 개발에 고려대상이 아니었다. 잘은 몰라도 해당 요구사항을 충족하기 위해서는 로그인이 필요할 것 같다는 느낌을 받은 그분은 나에게 “로그인을 어떻게 구현하면 좋겠냐”라고 물어보았다.
아마 예전 같았으면 이상적인 회원관리와 안전한 인증, 인가 시스템을 구현하는 방법에 대해서 일장연설 했을 것 같은 내가, 이제는 “애초에 로그인이 필요한가?”라는 질문을 하였다. 사실 그렇지 않은가? 요구사항은 ‘상태를 변경시킨 사람을 알고 싶다.’이다. 그렇다면 상태를 변경 시 “나 누구인데, 이것을 변경했습니다.”와 같은 기록을 추가적으로 남겨주기만 하면 되는 것이 아닌가? 상태를 변경하기 위한 입력창에 “변경한 누구” 필드를 하나 추가해 주면 되는 일이다.
말도 안 되는 소리라고 생각할 수 있다. 그런데 정말 그러한가 잘 생각해 볼 필요가 있다. 왜 말이 안 되는가? 우리가 원했던 목표를 아주 싼 가격으로 해결했는데 말이다. 나는 오늘 “로그인을 구현하기 위해 우선 JWT부터 떠올리는 바로 당신”을 위해서 이 글을 쓰고자 한다.
Auth는 인증을 의미하는 Authentication과 인가를 의미하는 Authorization 같은 소리는 둘째 치고서 우선 우리가 로그인이 왜 필요한지에 대해서 생각해 보자. 위 사례를 가지고 생각해 보자. 우리가 원하는 것은 “누가 상태 변경을 하였는가”를 알고 싶을 뿐이다. 그렇다면 농담이 아니라 정말 상태 변경 시에 “누구”만 추가해 주면 되는 것이다. 실제로 수많은 통신에서 “서명”이라는 기술을 통해서 이 문제를 해결한다. “휴대폰 본인인증”과 같은 것도 같은 맥락이다. 그러나 우리의 목적을 달성하기 위해서 그렇게 대단하고 복잡한 기술을 사용해야 할 필요는 없다. (사실 로그인이 앞의 방법과 순서 외에는 다른 것이 없다.)
앞서 얘기한 “누구” 필드를 추가해서 입력받는 방법에서 발생할 수 있는 문제들은 아래와 같다.
“‘누구’가 해당 동작을 수행할 수 있는가?”는 지금 얘기하려는 것과는 다른 문제이니 나중에 설명하기로 하고, 고의였든 실수였든 그게 중요한 게 아니고 위 문제가 왜 문제인지를 먼저 생각해봐야 한다. “1. 오타를 발생할 수 있다.” 무엇이 오타인가? 보는 사람이 오타라고 하면 오타인 것인가? 사실 오타가 아닐 수도 있다. 차라리 “2. 없는 ‘누구’를 입력할 수 있다.”를 문제 삼는 것이 더 올바를 수 있다. “없는 누구”라는 것이 성립되기 위해서는 사전에 입력될 수 있는 “누구”가 한정돼야만 한다는 말이다. 해당 어플리케이션이 “누구”에 입력할 수 있는 것은 “사전에 정해진 한정된 사람들뿐이다.”라는 규칙을 가지고 있다면 2번은 물론이고 1번 또한 문제임을 알 수 있다.
그렇다면 “누구” 목록은 어디 있어야 하는가? “당연히 서버에 있어야 한다.”라고 말할 수 있다. 그러나 그것은 맞을 수도 있고 틀릴 수도 있다. 누가 이 어플리케이션이 서버/클라이언트 구조를 가지고 있다고 했는가? 서버가 없을 수도 있다. 그저 “사전에, 입력될 수 있는 ‘누구’ 목록이 구성돼 있고 ‘누구’는 반드시 목록 내에서만 입력된다.”라는 규칙을 가지고 있을 뿐이다. 좋다. 어플리케이션의 구성에 대해서 왈가왈부하기 시작하면 한도 끝도 없으니 일단은 얘기를 계속하기에 앞서 우리가 만드는 어플리케이션은 서버/클라이언트 구조를 가지는 “일반적인” 웹 어플리케이션이라고 가정하겠다.
“누구” 목록을 사전에 JS코드에 구성해 놓고 form이 submit 될 때 잘 입력했는지 확인만 해 주면 된다. 또는 서버에서 해당 요청을 받고 서버에 구성된 “누구” 목록과 비교하여 입력이 올바른지 확인할 수도 있다. 둘 다 할 수 있다면 더 좋다. 이제 문제는 해결되었다. 혹자 문제가 해결되지 않았다고 말할 수 있다. 아니다. 문제는 해결되었다. 중요한 것은 우리가 만들고자 하는 어플리케이션의 요구사항이다. 아무도 우리에게 정말 그 사람이 “누구”인지 확인해 달라고 한 적이 없다면 문제가 해결된 것이 맞다. 정말 많은 개발자들이 착각하는 것 중에 하나가 “올바른 개발”을 해야 한다고 생각하는 것이다. 누가 “올바른”을 정의하였는가. “올바른 개발”이라는 것은 없다. “현재 상황에 맞는 개발”만이 있을 뿐이다.
그러나 대부분의 상황에서는 3번을 문제시한다. “해당 사용자가 아닐 경우, 해당 사용자인 척 할 수 없게 해야 한다.”는 요구사항은 거의 필수 불가결하게 따라온다. 내가 “누구”임을 증명할 수 있는 정보를 “User Credentials(유저크리덴셜)”이라고 부른다. 해당 사용자가 정말 그 사용자인지를 인증할 수 있다면 모두 user credentials가 된다. 심지어 유저이름이 해당 유저임을 증명할 수 있는 수단이라면 그것 또한 user credentials가 된다. 물론 그런 상황(위 2번과 같은)은 흔하지 않다. 일반적으로는 username(id)과 password 쌍을 사용하는데 우리도 이것을 사용하면 문제를 해결할 수 있게 된다. 혹자 “그것이 로그인 아니냐.”라고 물을 수 있다. 맞다. 그저 순서가 다를 뿐이다. 이제 상태를 변경시키는 form 입력에 “누구”와 함께 “누구임을 증명할 수 있는 수단” 또한 같이 넣어주면 모든 문제가 해결되었다.
그런데 검증은 어디서 하는가? JS 코드에 사전에 준비된 username과 password 쌍 목록을 구성해 놓고서 전송 전에 검증할 수도 있고, 서버로 전송 후 서버 내에서 검증할 수도 있다. “생각할 필요도 없이 당연히 서버에서 검증해야 하는 것이 아닌가”라고 할 수 있다. 몇 번이고 반복해서 강조할 수 있다. 그것은 모두 요구사항과 그 당시의 상황 그리고 필요에 의해서 결정된다. 모든 것에는 얻는 것이 있다면 잃는 것(Trade-Off)이 존재하기 마련이다. 해당 어플리케이션이 요구하는 것은 그저 실수로 다른 사람의 이름을 입력하지 않는 것이고 지금 이상으로 유저의 수가 늘어날 일도 없고 심지어 유저의 인증코드(비밀번호가 아닌 그저 사번을 입력한다면)가 다른 사람에게 드러나도 큰 문제가 일어나는 않는 경우라고 해 보자. 그렇다면 굳이 애써서 서버에 별도의 유저 목록을 구성시킬 이유가 있을까? 없다. 그러나 일반적으로는 사용자 목록이 늘어나고 줄어들 수 있고 비밀번호가 해당 사용자 외에 다른 사람에게 드러나지 않기를 원할 것이다. 그러니 서버에 해당 방법을 구현해야 할 것이다.
우리가 여기서 절대로 간과하면 안 되는 것이 있다. 그러한 요구사항이 있었기 때문에 그렇게 구현한 것이지 그것이 “올바르기 때문”에 그렇게 한 것이 아니다. 많은 개발자들이 이러한 auth 뿐만이 아니라 수많은 상황에서 그렇게 할 필요가 없음에도 불구하고 최상위 구현 스펙에 만족시키려는 경향이 있다. 제대로 하지 못했다는 찝찝함과 죄책감에 그런 선택과 갈등을 많이 하는데 사실 절대 그렇지 않다. 자신이 현재 무엇을 개발하고 있는지 잘 생각해봐야 한다. 소 잡는 칼로 닭잡지 않고 닭 잡는 칼로 소 잡지 않는 것이 “적정기술”이다. 다른 특별한 것이 아니다.
이제 문제가 해결되었는가? 우리의 목적을 달성하였는가? 달성하였다. 애초에 요구사항이 무엇이었는지 잘 생각해봐야 한다. “1. 상태 변경 시 누가 해당 변경을 하였는지 알 수 있어야 한다.” 그러기 위해서 변경 요청마다 유저 정보를 같이 보내주었다. “2. 미리 준비된 ‘누구’만 입력 가능하고 진짜 그 ‘누구’ 임을 확인할 수 있어야 한다.” 사전에 “누구”목록을 구성하였고 누구에 해당하는 인증코드(비밀번호)를 사용하여 누구임을 보장하였다. 다만 이렇게 어플리케이션을 만들면 매우 사용하기 귀찮다는 피드백을 받을 것이다. 그러면 어떻게 하면 좀 더 사용성을 좋게 만들 수 있을까? 해당 인증절차를 미리 해놓고 그 이후 요청부터는 “그 유저”임을 알려주기만 하면 된다. 그게 바로 로그인이다.
매 요청마다 username과 password를 넘기는 것은 너무나 번거롭다. 사용자도 피곤할 것이고 보통은 로그인을 통해서 한 번만 인증하고 이후에는 인증된 유저 이름으로 요청이 이루어진다. 어떻게 가능한 것일까? 어플리케이션이 켜질 때 맨 처음 인증을 시도(앞의 3번과 같이)하고 그 인증이 올바르다면 해당 인증 정보를 클라이언트 내부에 저장한 뒤 매 요청마다 같이 보내주기만 하면 되지 않을까? 어떤 정보를 저장하는가? 당연히 username과 password 쌍이지 않을까? 우리가 앞에서 인증을 하기 위해서 보냈던 user credential이 username과 password이니까 말이다. 뭔가 이건 아닌 것 같은 느낌이 들 수 있다. 그러나 실제로 HTTP Basic Authentication이 동작하는 방법이 이러하다.
HTTP Basic Auth는 브라우저 내부에 user credential을 저장하고서 매 요청마다 WWW-Authorization header에 규격화된 형식대로 user credential을 넣어 보내지만 “어떻게”라는 것은 중요하지 않다. 유저인증에 사용했던 정보를 어딘가(JS 코드 내부, storage, cookie, worker 등 어디든)에 저장하고 인증이 필요한 요청에 같이 보내주기만 하면 된다. 이것이 다다. 매우 간단하다.
이렇게 얘기하면 “그냥 로그인창에 유저 id만 입력받고 변수 안에 저장하고 있다가 매 요청마다 X-USER-ID 같이 custom hedaer를 만들어서 보내거나 hidden form field를 만들어서 user-id라고 같이 보내주면 되는 거냐”라고 물어볼 수 있다. 맞다. 아주 완벽하게 이해하고 있다. 안될 이유가 전혀 없다. 모든 것은 현재 내가 달성하고자 하는 목표에 부합하기만 하면 된다. 정말로 사용자를 식별하는데 user-id로 충분하다면 농담이 아니라 정말로 그것만으로 충분하다. 다만 이미 앞에서 일반적인 요구사항이 그렇지 않다는 것을 확인하였기에 이에 대해서 자세히 설명하지는 않겠다.
그런데 왜 이 방법에 대해서 우리가 거부감을 느끼는지 생각해 보자. 여러 가지가 있겠지만 간단하게 생각해 보면 아래와 같을 수 있다.
우선 1번과 같이 외부의 제3자가 우리의 통신 내역을 가로채 보는 것을 막기 위해서는 암호화된 통신(HTTP over TLS = HTTPS)을 사용하는 것 외에는 특별한 방법이 없다. 암호화된 통신을 사용하지 않고 민감한 데이터를 전송하는 것은 어떤 방법을 사용해도 다 위험하다. 2번과 3번의 경우는 나중에 다루기로 하자.
우리가 다루려 하는 보안(Security)에는 크게 2가지가 있다.
1번의 경우는 앞에서 이미 해법을 얘기하였고 2번의 경우가 주된 문제이다. 내가 특정 사용자가 아닌데 그저 “나 누구요”하면 그 사람이 되지 못하게 만들어야만 한다. 그렇기에 단순히 username이 아닌 username과 password를 한쌍으로 user credential을 구성하게 하는 것이고 이 방법을 통해서 “척”하지 못하게 만들었다. 그런데 이런 정보를 매번 보내는 것은 귀찮고 그렇다고 처음 인증에 성공하면 저장한 뒤 매번 재 사용하는 것은 뭔가 찝찝하다. 그렇기에 최초의 유저인증 후 그 유저임을 대신 인증할 수 있는 임의의 코드를 전달하여 그 코드를 통해서 앞으로의 인증을 진행한다. 결국에 이 임의의 코드 또 다른 user credential이 되는 것이다. 어디서는 session-id로 불리고 어디서는 access-token으로 불린다. 이름은 불리기 나름이고 크게 중요하지 않지만 이 글에서는 앞으로 토큰(token)
으로 칭하도록 하겠다.
매 통신마다 인증을 위해서 사용하는 user credential이 username, password 쌍에서 token으로 변했을 뿐이다. 큰 틀에서는 달라진 것은 없다. 인증을 요구하는 요청마다 username, password를 같이 보내는 것과 초반에 이 username, password 쌍을 일정한 형태의 token으로 교환한 후 token을 대신해서 보내는 것이 “우리가 무엇을 하고 있는가”라는 관점에서는 크게 다르지 않다는 말이다. 다만 다른 것이 있다면 해당 어플리케이션(우리가 이용하고자 하는 전체 서비스를 의미한다.)이 로그인 절차 외에는 username, password 쌍 user credential을 인증을 위해서 받지 않겠다는 것이다.
분명히 해야 한다. 이것은 명백하게 “의도”한 것이다. 우리가 그렇게 하기로 하였기 때문에 그렇게 되는 것이지. 그래야 하기 때문에 그런 것이 아니란 말이다. 로그인 절차를 통해서 username, password 쌍을 token으로 교환하고 그것을 사용하지 할 수도 있으면서 계속해서 username, password 쌍으로도 인증을 할 수 있게 할 수는 없을까? 할 수 있다. 그렇게 구현하면 되는 것이다. 우리가 그렇게 할 수 있게 하면 된다는 것이다. 단지 그렇게 동작하지 못하도록 구현했기에 그런 것이지 그렇게 하지 못하기 때문이 아니라는 사실을 언제나 명심해야 한다.
다른 얘기지만 로그인(
login
)을sign-in
으로 표시하는 곳도 많이 있다. 둘은 같은 것을 의미한다. 회원가입(registor
,join
) 또한sign-up
으로 표시하는데 이 또한 같은 의미이다.sign
은 서명(인증)을 의미한다. 서명을 만드는 것(sign-up
)과 서명을 사용하는 것(sign-in
)과 이제 그만 서명하지 않겠다는 것(sign-out
)까지 세트로 보면 된다. 등록하는 것(registor
,join
)과 기록을 남기는 것(log-in
) 그리고 기록을 그만 남기는 것(log-out
)도 표현만 다르지 다 같은 것을 의미한다.
이제 중요한 것은 이 토큰이 어떻게 생겼고 어디에 저장해서 어떻게 전달할지 생각해봐야 한다. 우선 이 토큰도 결국은 user credentials이라는 것을 잊어서는 안 된다. 그렇다는 말은 이 토큰을 통해서 해당 사용자가 누구인지 인증하게 된다는 것이다. 그렇다면 “척”하게 해서는 안 된다. 우리가 앞에서 단순히 username만을 통해서 내가 “누구”인지를 말한 게 아니라 password를 같이 보낸 이유가 뭐였는가? 내가 아닌 다른 사람의 username을 보냄으로 내가 그 사람이 될 수 있기 때문이다. 그렇다면 이 토큰 또한 “척”하지 못하게 해야 한다.
그런데 굳이 우리가 username, password를 token으로 교체해서 사용하는 이유가 뭐였는지 다시 생각해봐야 한다. 위에서 설명하다가 말았던 것인데 우리는 어떠한 방법이든 이 user credential이 의도하지 않게 다른 사람에게 드러났을 때를 걱정해야 한다. password를 다른 사람이 알게 되면 이제 그 사람인 척 할 수 있게 된다. 그리고 생각보다 많은 다른 서비스에서도 그 사람인 척 할 수 있을 확률이 높다(비밀번호를 동일하게 쓸 경우). 만약 실제로 그런 일이 일어나게 되면 우리는 어떻게 해야 할까? 비밀번호를 모두 바꾸면 문제가 해결된다. 하지만 피해가 막심할 것이다.
그렇기에 username, password와 같이 위험한 user credential을 계속 사용하는 것이 아닌 어플리케이션 진입 시 동일하게 해당 사용자임을 인증할 수 있는 다른 형태의 user credential(token)로 교환한 뒤 그 토큰을 사용하면 상대적으로 위험이 감소될 수 있다. 물론 이 모든 것은 보안통신을 사용하고 있다는 것을 전제로 한다. 암호화되지 않은 통신을 사용한다면 그 이후 어떤 방법을 사용하든 의미가 없다. 상대적으로 안전하다고 했을 뿐 토큰이 탈취된다면 이 토큰을 가지고 누군인 척할 수 있는 것은 동일하다. 하지만 비밀번호가 드러났을 때와 다르게 해당 토큰만 폐기시키면 그 이후의 사고를 예방할 수 있게 된다. 또한 비밀번호가 드러나지도 않았다.
다시 본론으로 돌아와서 우리는 그렇기에 이 토큰을 아무나 쉽게 만들 수 있으면 안 된다는 것을 알았다. 그렇다면 어떻게 해야지 쉽게 만들 수 있지 않은 것일까? 이것을 얘기하기 전에 이 토큰은 누가 만들어서 어디서 관리해야 할까? 간단하게 생각해 보자. 클라이언트가 서버에게 요청시 해당 토큰을 같이 건네주고 서버는 이 토큰을 내부 어딘가에 기록된 목록에서 유저 정보와 교체해서 사용해야 할 것이다. 그러니 서버가 토큰을 만들고 토큰 목록 또한 서버가 관리해야 할 것이다. 결국 이 토큰과 유저정보를 일치시키는 “무언가”가 어딘가에 있을 것이다. 몇 가지를 생각해 보자.
4번이 어떻게 가능한지는 나중에 얘기하도록 하고 4번을 제외한 1, 2, 3번의 경우 특정한 key를 이용해서 실제 데이터와 교체하는 방식이다. 결국 token은 유저정보와 교체하기 위한 key가 되는 것이고 이 형태는 그 목록 내에서 유일한 값이기만 하면 된다. 숫자일 수도 있고 문자열일 수도 있다. 다른 key와 구분할 수만 있으면 된다.
그렇다면 단순하게 1, 2, 3 같이 숫자여도 괜찮을까? 되지 못할 이유는 없다. token을 만들 때(로그인하여 해당 사용자임을 인증할 수 있는 다른 user credential을 만들어 줄 때) +1 해서 그 숫자를 key 삼아서 유저정보를 저장하고 사용하면 되는 것이다. 그러나 악의적으로 아무 숫자나 넣고 요청을 보낸다면 내가 아닌 다른 사용자가 될 수도 있다는 것을 알아야 한다. 즉 쓰지 못하는 것은 아니지만 그다지 좋지 못한 방법이 되겠다.
결국은 “척”하지 못하게 한다는 것이 중요하다. 쉽게 만들지 못해야 한다. 가장 간단한 방법은 랜덤 한 문자열을 만들어 사용하는 것이다. 우리가 다시 한번 상기해야 하는 것은 이 토큰은 서버에서는 결국은 교환을 위한 key라는 점이다. 내부에서 교환만 할 수 있으면 된다. 물론 이 랜덤이 충돌에 안전(이미 사용한 것이 또 나오지 않게)하고 어느 정도 형식이 일정하면 좋을 것이다. token이라고 받았는데 형식이 완전히 다르면 사전에 걸러낼 수 있기 때문이다.
4번의 경우 토큰목록에 해당 토큰이 존재하는지 확인하는 절차를 생략할 수 있다. 어떻게 가능할까? 서버가 특정 문자열을 임의의 문자열로 교체할 수 있는 방법(암복호화, 해시)이 있으면 된다. 만약 username이 “sooran”이고 이 “sooran”이라는 문자열을 해시함수를 통과시켰을 때 “sk3hgo5”이라는 문자열(아무 의미 없다)이 나온다고 해 보자. 이 “sk3hgo5”이 어떻게 나오는지는 반드시 서버만 알고 있고 절대로 간단한 방법으로 유추할 수 없어야만 한다. 그러면 로그인 시 서버는 token으로 “sooran;sk3hgo5”와 같은 형식의 문자열을 건네주기만 하면 된다. 이후 요청에는 이 토큰을 통해서 인증할 것이고 서버는 구분자 ;
를 이용해 분리 후 변형시켜 일치하는지만 확인해 주면 끝난다. 물론 설명한 방법은 예시일 뿐임으로 실제 방법은 여러 가지가 있다.
이러한 자기 서술적 토큰을 사용했을 때, 앞의 방법(key 교환)과 크게 다른 차이점이 있다. 바로 서버에 실제로 그 데이터가 존재하는지 확인하는 절차를 생략할 수 있다는 점이다. 물론 이 또한 매번 확인하게 만들 수 있다. 그것은 구현하기 나름이다. 그러나 그렇다면 앞의 방법과 크게 다른 점이 없다. 그저 자기 서술적이고 한번 더 정말 그 토큰이 올바른 토큰인지 확인할 수 있다는 점이 다를 뿐이다. 서버에 실제로 해당 값이 존재하는지 확인하는 절차를 생략하고자 이런 방법을 썼는데 그렇지 않는다면 사용할 이유가 없다. 그러나 이런 점 때문에 서버는 이 토큰을 쉽게 폐기시키지 못한다. 존재를 확인하지 않기 때문이다. 그렇기 때문에 자기 서술적인 토큰은 스스로가 만료되는 시점까지도 서술할 수 있어야 한다. 물론, 그것도 구현하기 나름이다.
그래서 이제 이 토큰을 어떻게 주고받을 것인지 생각해봐야 한다. 토큰은 언제 어떻게 얻을 수 있는가? 로그인이라는 특별한 절차를 통해서 우리는 이 토큰을 획득할 수 있다. 그런데 우리는 다시 한번 이 로그인이라는 절차를 생각해 볼 필요가 있다. 그저 로그인이 서버에게 username과 password를 건네주고 이와 동등한 가치를 지니는 token을 교환하는 것일 수 있다. 그렇다면 username과 password가 아닌 다른 것을 통해서도 이런 절차를 진행시킬 수 있지 않을까?
물론이다. 핸드폰인증을 할 수도 있고 제3의 서비스를 통해서 인증할 수도 있을 것이다. 아니면 생체인식을 할 수도 있고 사전에 전달된 특수한 난수표를 사용할 수도 있겠다. 또한 단순 username, password를 사용 후 2차 인증을 위한 OTP를 추가 입력받을 수도 있을 것이다. 이 모든 것은 하기 나름이다. 맨 처음으로 돌아와서 “나 누구요”하기만 하면 그냥 그 사람이라고 해 줘도 로그인이라면 로그인인 것이다. 모두 결국 특정한 절차 이후 이러한 절차를 생략하기 위한 토큰을 받기 위한 것일 뿐이다.
해당 글이 “표준 웹브라우저 위에서 동작하는 클라이언트/서버 구조를 가지는 웹 어플리케이션이고 요청/응답 패턴의 통신을 사용함을 전제”하고 있기 때문에 이제부터는 좀 더 구체적으로 토큰 획득 이후에 대해서 얘기해 보자 한다.
HTTP는 통신은 지속적인 연결을 유지하지 않고 요청/응답 패턴을 사용한다. 그렇기 때문에 매 요청 간에는 어떠한 연관관계도 없다. 이를 Stateless(무상태)라고 한다. 그렇기 때문에 요청마다 “나 누구요”를 같이 보내줘야만 한다. 이 “나 누구요”를 가장 간단하게 할 수 있는 방법이 cookie이다. 애초에 cookie가 나왔던 이유가 이러한 HTTP의 특성에서도 매 요청 간의 연관관계를 만들기 위해서였다. 이 쿠키의 할당은 기본적으로 서버에서 이루어진다. 클라이언트는 서버에서 받은 응답 안에 있는 Set-Cookie
해더를 읽어 브라우저 내부에 저장하고 이후 요청부터 Cookie
해더에 포함하여 전송한다.
로그인을 위해서 user credential을 요청으로 넘기면 서버에서 확인 후 올바르다면 응답에 앞으로 사용할 token을 cookie로 담아서 넘겨주기만 하면 된다. 대부분의 고전적인 서버 프레임워크에서 이 token이 담긴 cookie의 이름을 session
이라고 한다. 원래 session은 컴퓨터에서만 사용하는 용어가 아니다. 일상적으로 회의를 세션이라고 하고 강연, 통화나 채팅 같은 경우도 세션이라고 한다. 한번 연결되면 해당 연결이 지속되는 것 또한 session이라고 얘기한다. HTTP는 요청/응답 구조로 사실 한번 요청과 응답이 오는 동안만 세션이 형성된다. 그러니 다음 요청/응답에서는 다른 세션이 되는 것이다. 그러니 cookie 이름을 session이라고 하고 같은 token을 주고받음으로 매 다른 응답/요청을 동일한 통신연결로 취급할 수 있다. 물론 session이 아닌 “user-token”과 같이 원하는 이름을 주어도 상관없다. 언제나 정하기 나름이지 정해진 것은 없다. 중요한 것은 무엇을 하는가이다.
기본적으로 쿠키는 JS를 통해서 접근이 가능하다. 그렇기에 HttpOnly
옵션을 줘서 JS에서 접근이 불가능하게 만들어줘야 안전하다. 그 외의 속성(3rd-party 관련 등)에 대해서는 언급하지 않겠다.
그렇다면 이 cookie는 언제까지 유지되는가? 우선 cookie에는 Expire
속성이 있어서 해당 cookie가 언제까지 존재할지 지정할 수 있다. 만약 지정하지 않는다면 해당 브라우저가 종료될 때 같이 제거된다. 그렇기에 로그인 유지 기간은 브라우저의 종료까지가 된다. 무한대로 지정하고 싶으면 어떻게 해야 할까? Expire를 9999년 같이 아주 긴 시간으로 지정해 주면 된다. 우선 지정하면 반드시 종료시점을 알려줘야 하으로 “없어지지 않음” 같은 설정은 없다. Expire를 잘 지정해 주면 브라우저가 종료된 후에도 유지가 되기 때문에 로그인이 유지되는 효과를 만들 수 있다.
물론 위 Expire 설정은 브라우저가 해당 쿠키를 유지하는 시간인 거지 서버가 해당 token을 가지고 있는 것과는 관련이 없다. 클라이언트가 해당 토큰을 가지고 있다고 해도 언제든지 서버에서는 해당 token이 없어질 수 있다. 몇 가지 이유가 있겠지만 서버가 해당 토큰을 유지하지 못했거나 일부로 해당 토큰을 폐기시킨 경우다. 만일 그런 경우에는 서버는 “인증이 올바르지 않다”와 같은 응답을 줘서 클라이언트가 다시 로그인할 수 있게 해줘야 할 것이다.
쿠키는 별다른 노력 없이 로그인 이후의 인증을 자동으로 요청에 끼워 보낼 수 있게 해 준다. 또한 잘 사용하면 상당히 안전하다.
사실 쿠키 외에는 특별히 브라우저에서 매 요청마다 자동으로 토큰을 보내는 방법이 없다. 그렇기에 우리는 로그인을 통해서 받은 토큰을 어딘가에 저장해 놓고서 매 요청마다 해당 토큰을 끼워 보내줘야 한다.
우선 로그인 요청에 대한 응답으로 받은 토큰을 어딘가에 저장해야 할 것이다. 몇 가지 후보군(cookie 제외)을 생각해 보자.
그리고 우리가 요청을 보낼 수 있는 일반적인 수단에 대해서도 생각해 보자.
우리의 웹 브라우저는 마음먹으면 공격할 수 있는 포인트가 너무나 많다. 태생인 문서교환 시스템에서 하나의 어플리케이션 플랫폼이 되기까지 참으로 많은 변화가 있어왔다. 지금 얘기하고자 하는것은 “어떻게 토큰을 저장하고 보내는가”가 아니다. 그런 것은 이미 충분히 앞에서 얘기했고 서로 합의된 대로 어떻게든 전달만 하면 될 것이다. 이번 문단에서 말하고자 하는것은 사실상 “안전하게 웹 브라우저에서 토큰 관리하기”인데 상세 내용을 풀기 시작하면 해당 글만큼의 글을 또 써야 하기에 별도의 글을 작성하기로 한다.
로그인 절차를 완료하면 해당 유저의 새로운 user credential(토큰)이 생성된다. 그러나 이것은 임시증표이다. 그리고 해당 토큰은 해당 토큰을 발급해 준 서버만 알고 있다. 왜냐하면 이 토큰만 가지고는 해당 유저가 누구인지 알가 없기 때문이다. 이 토큰을 가지고 어딘가에 있는 목록에서 해당 유저의 정보를 찾아와야만 한다. 서버는 매 요청마다 이 행동을 반복할 수밖에 없다. 그렇기에 가능하면 이 교환/확인 절차를 빠르게 수행해야만 한다. 물리적으로 실제로 이 데이터는 어딘가에 존재해야만 하는데 어쩔 수 없이 가까운 곳에 둘 수밖에 없는 것이다.
만약 해당 서비스의 사용자가 늘어서 서버 한대 만으로는 서비스를 정상적으로 운영할 수 없는 상황이 왔다고 해 보자. 그렇다면 여러 대의 서버를 두어서 부하를 분산시킬 수밖에 없다. 그렇다면 이 토큰 목록을 어느 서버에 두어야 할까? 여러 가지 해결책이 있을 것이다.
하나의 서버가 모든 처리를 담당하면 사실 이런 고민을 할 필요가 없을 것이다. 혹시나 이러한 것을 너무 미리 고민하시는 분이 계시다면 고민하지 말라고 얘기하고 싶다. 그 시간에 더 훌륭한 비니지스 로직에 관한 고민을 할 수 있을 것이다. 적정기술이 별것이 아니다. 소 잡는 칼로 닭 잡지 않으면 되는 것이다. 이런 것을 적용해서 실제 서비스가 나아지는 것은 사실상 없다. 그럼에도 불구하고 어쩔 수 없이 결국 이런 문제에 당면하게 된다. 그러니 하나씩 생각해 보자.
“1. 하나의 원격 디렉터리를 공유하게 해서 다 같이 관리하게 한다.” 원격 디렉터리를 구성한다는 것은 모든 서버가 하나의 네트워크 안에 존재해야 한다는 말이다. 그렇기에 같은 네트워크에 존재하지 않는다면 약간의 어려움이 있을 수 있다. 물론 VPN 등을 사용하면 이 문제를 해결할 수는 있을 것이다. 또한 해당 파일 공유 체계에 동시성 처리에 관련된 설정을 잘 만져야 할 것이고 실제 파일을 직접 읽고 쓰고 하는 것에 대한 책임을 잘 관리해야 할 것이다.
"2. 하나의 DB에 다 같이 관리하게 한다.” 위와 사실 크게 다른 것은 없다. 방법만 바뀌었을 뿐이지 하는 것은 크게 다르지 않다. 다만 직접 파일을 만지는 것보다는 DB 어플리케이션 레벨의 동시성 처리나 형식적으로 안전하다는 이점을 얻을 수 있을 것이다. 가능하면 빠른 DB를 쓰는 것이 좋을 것이다.
"3. 토큰 교환/확인만 전문으로 하는 서버를 만들고 통신하거나 항상 거치게 만든다.” 각 서버가 직접 인증을 수행하지 않고 그것만 전문으로 수행하는 서버를 만들어 처리하는 것이다. 물론 이 서버는 클라이언트에 직접적으로 드러나지는 않을 것이다. 서버 내부에서만 아는 존재일 것이다. 마치 DB가 클라이언트에 직접 노출되지 않는 것과 같다. 만약 모든 통신이 1차적으로 해당 인증서버를 거치게 만든다면 그 뒤에 있는 서버는 토큰이 아닌 이미 변화된 유저정보를 받아서 사용하게 만들 수도 있을 것이다.
“4. 자기 서술적 토큰을 사용한다.” 각 서버가 자기 서술이 가능한 토큰을 증명하는 방법을 공유해야만 가능하다. 이를 통해서 물리적인 저장소 또는 서버를 공유하지 않아도 된다는 매우 큰 장점을 가지게 된다. 심지어 인증을 스스로 할 수 있기에 통신이 발생하지 않아 매우 고속으로 처리할 수 있다. 우리가 알아야 하는 것은 검증하는데 시간이 걸릴 수 있다고 해도 통신보다 짧을 가능성이 매우 높다는 것이다.
선택은 모두 구현하는 사람의 목이다. 모든 선택에는 장단점이 존재한다. 얻는 것이 있다면 잃는 것 또한 존재하기 마련이다.
토큰은 민감한 유저정보를 대신해서 사용하는 user credential이다. 노출 시 password 보다 피해범위가 좁다는 것뿐이지 발생할 수 있는 피해는 동일하다. 그러니 매우 조심해서 다뤄져야 하고 반드시 폐기시킬 수 있는 수단이 필요하다. 우리는 이것을 로그아웃(logout, sign-out) 또는 토큰 폐기(token revoke)라고 한다.
매우 간단하게 로그아웃할 수 있는 방법은 단순하게 클라이언트에서 해당 토큰을 지워버리는 것이다. 물론 클라이언트에만 없어진 것이지 서버에는 해당 토큰이 아직도 살아있다. 그 말은 누군가 이 토큰을 가지고만 있으면 실제 해당 토큰의 주인이 토큰을 지웠든 말든 관계없이 토큰을 사용해서 인증을 수행할 수 있다는 말이다. 그렇기 때문에 반드시 서버에서도 해당 토큰을 폐기시켜줘야만 한다.
어떻게 이것이 가능할까? 서버에 로그인과 마찬가지로 로그아웃 요청을 보내는 것이다. 물론 로그인과는 다르게 에러를 내지 않아야 할 것이다. 로그인은 실패할 수 있다. 그러나 로그아웃 실패는 조금 이상하다. 우선 요청만 받고 나중에 처리될 수 있도록 조치해야지 응답자체가 실패해서는 안될 것이다.
또한 해당 토큰을 장시간 사용하지 않을 경우 자동으로 해당 토큰을 만료시킬 수 있어야 할 것이다. 주기적으로 특정 기능(스케줄러)이 토큰 최종 사용일자를 확인해서 삭제한다던가 하는 추가 동작이 존재해야 할 것이다.
문제는 자기 서술 토큰이다. 폐기를 할 대상이 서버에 없기 때문에 폐기를 할 수가 없다. 또한 분산환경에서 사용될 것을 고려한 것이기 때문에 한 곳에서 블랙리스트를 만들어 관리한다고 해도 쉽지 않을 것이다. 그러나 쉽지 않을 뿐, 불가능한 것은 아니다. 해당 토큰의 폐기 요청이 들어오면 해당 토큰을 사용하는 모든 서버에 알려줘서 블랙리스트에 추가시키는 것이다. 사실상 그 외에는 특별한 방법이 없다. 자기 서술 토큰은 자신이 만료되는 시점 또한 같이 가지고 있기 때문에 해당 기간 동안만 블랙리스트를 유지하면 된다. 물론 쉽지 않은 일이라는 것은 자명하다.
지금까지 모두 인증(Authentication)에 관한 이야기만 했다. “나 누구인데” 정말 “누구인가”를 확인할 수 있는 방법에 대해서만 얘기했다. 사실 요구사항에 따라서는 이것만 있어도 충분할 수 있다. 우선 서버는 해당 사용자가 누구인지 알았고 요청의 대상이 그 사용자 것이 아니면 요청을 거부하면 그만이다. 자신의 것만 다루면 그만인 것이다. 그러나 관리 목적의 어플리케이션은 그렇게 간단하게 끝나지 않는다.
우선 두 번째 Auth인 Authorization은 인가를 의미한다. 인증을 전제로 해당 사용자가 권한이 있는가를 확인하는 절차이다.
어플리케이션에 A, B, C 기능이 있는데 X라는 유저는 A와 C만 사용가능하고 Y라는 유저는 B와 C만 사용하게 만들고 싶다면 어떻게 해야 할까? 어쩔 수 없다. 권한표를 만들어야만 한다. 관리가능한 모든 기능의 목록을 만들어 놓고 각 유저마다 가능한지 불가능한지를 표시한 권한표를 만들어 관리할 수밖에 없다.
끔찍하다고 생각할 수 있다. 분명 더 좋은 수가 있을 것이라고 생각할 수 있다. 없다. 정도의 차이만 존재할 뿐 이것이 최선이다. 정도의 차이라고 한다면 권한 그룹(Role)을 만들어서 유저에 할당하는 정도이다. 그러나 해당 role 또한 세부 기능에 대해서는 앞에서 얘기한 것과 동일하게 권한표를 만들어 줘야 한다. 또한 만약 여러 개의 권한이 부여될 경우 서로 상충되는 권한이 있다면 어떻게 해소할지 또한 사전에 결정해야만 한다. 세분화 정도에 따라서 특정 resource를 기준으로 CRUD 마다 권한 부여를 결정할 수도 있다. 특정 리소스를 읽을 수는 있는데 변경은 불가능하다는지 하는 것 말이다.
어떻게 이것이 가능할까? 각 처리 단계(서버 내부 구조에 따라서) 별로 여러 가지 방법이 있겠지만 결국에는 해당 기능이 동작할 때 권한표를 보고 해당 유저의 가능여부를 판단해야만 한다. 그렇다면 “매 요청에다 모든 권한표를 가져와서 일일이 비교해야 하느냐”라고 물어볼 수 있다. 맞다, 그렇게 해야 한다. 최적화를 할 수는 있다. 권한표가 바뀌지 않는다면 미리 권한표를 가져와서 저장해 놓고 사용하면 된다. 물론 권한표가 변경된다면 다시 불러와야 한다. 만약 분산환경이어서 권한표를 관리하는 곳과 읽는 곳이 다를 수 있는데 이럴 경우 위 자기 서술 토큰 폐기와 같이 모든 서버에 알려줄 수밖에 없다.
물론 분산환경에서는 분산환경인 만큼 다른 방법을 사용할 수 있다. 자기 서술 토큰에 해당 권한도 서술해 놓으면 되는 것이다. Authentication과 Authorization 두 개를 모두 수행하는 것이다. 물론 이것이 가능하기 위해서는 반드시 세부 권한의 목록이 모든 서비스 내에서 사전 정의되고 공유돼 있어야만 한다.
어플리케이션 또는 하나의 서비스를 만들면서 Auth에 대해서 해 볼 수 있는 고민들을 두서없이 떠들어 봤다. 내가 지금까지 개발해 오면서 해왔던 수많은 고민들이 조금이나마 누군가에게 도움이 될 수 있을 것이라 생각하고 적어보았다. 모든 것을 적기에는 써온 글만큼 더 써야 하는 주제들(자기 서술 토큰, 클라이언트 토큰 관리, 권한 등)도 있기에 말을 줄인 것도 있는데 나중에 더 상세하게 각 세부 구성에 대해서 글을 써 보고자 한다.
위에서 열심히 auth에 대해서 설명하면서 토큰을 중심으로 설명하였지만 사실 그런 것은 필요하지 않을지도 모른다. 농담이 아니라 진짜로 그럴 수도 있다. 토큰은 어플리케이션 레벨에서 개인을 식별하기 위한 수단이다. 로그인이라는 것은 그저 토큰을 교환하는 절차일 뿐이다. 그렇다면 만약 해당 어플리케이션이 로그인을 하지 않고 이미 사용자를 식별할 수 있다면? 그렇다면 로그인 자체가 필요하지 않을 수도 있다. 예를 들어서 IP기반 인증 같은 것 말이다.
말도 안 되는 헛소리라고 생각할 수 있지만 IP 자체가 개인과 장비를 식별하는 수단으로 사용하는 폐쇄망 환경에서는 말이 된다. 애초에 해당 환경에서는 IP 발급자체가 인증과 인가인 경우가 많다. 그렇다면 이미 개인 식별이 가능한데 또 어플리케이션 레벨의 인증/인가 절차를 만들 필요가 있을까? 음.. 물론 그것은 선택하기 나름이다. 그리고 이런 경우 컴퓨터를 누군가가 몰래 쓴다면 또 소용이 없기 때문에 이중보안으로 만드는 것도 좋은 생각일 수 있다. (사실.. 컴퓨터를 뺏기지 않으면 될지 몰라..)
생각하기 나름이다. 물론 위에서 언급한 경우가 정말 단순하게 IP만 가지고 인증/인가 절차를 수행하지는 않지만 실제로 그렇게 운용하는 곳이 생각보다 많다.
OAuth는 제3의 어플리케이션에게 특정 서비스의 민감한 User Credential(특히 password)을 알려주지 않고 권한 부여를 할 수 있는 방법에 대한 프레임워크이다. 다시 한번 얘기하지만 여기서 중요한 것은 제3의 어플리케이션
이다.
실제로 OAuth의 절차를 보면 그러하다.
뭔가 생략된 것이 있긴 하지만 잠시 빼놓고 위 절차를 보자. 로그인이 무엇인가? 민감한 User Credential(일반적으로 password)
을 상대적으로 덜 민감한 User Credential(토큰)
로 교환하는 절차이지 않은가? 위 OAuth 절차가 사실상 로그인과 다른 것이 무엇인가? 다른 것이 아니라 로그인이다. 그저 유저가 직접적으로 username과 password를 알려주지 않았을 뿐이지 로그인할 수 있는 다른 수단을 제3의 어플리케이션에 준 것이다. 해당 어플리케이션은 특별한 코드를 통해서 token을 얻는 로그인을 한 것이다.
물론 위에서 생략한 것이 있는데 권한부여
이다. 유저가 실제로 서비스 A에 로그인할 때 해당 어플리케이션 X가 몇 가지 권한(특정 리소스 접근 등)을 요청한다는 사실을 반드시 알게 된다. 유저가 허가한다면 X에게 부여하는 코드를 통해 로그인할 경우 앞에서 고지된 권한이 있는 코드를 부여받게 된다. 이것이 위에서 설명한 `제3의 어플리케이션에게 특정 서비스의 민감한 User Credential(특히 password)을 알려주지 않고 권한 부여를 할 수 있는 방법에 대한 프레임워크`이다.
SNS 로그인 같은 것이 별것이 아니다. OAuth를 통해서 해당 SNS의 유저 정보를 얻어오고 그 유저정보를 User Credential 삼아서 자사 어플리케이션에 연결된 유저를 로그인시키는 것에 불과하다. 물론 해당 SNS 계정과 연결된 유저정보가 없다면 로그인 절차를 취소하고 “미리 가입된 유저에 SNS를 연동하세요”라고 얘기할지 또는 자동으로 가입시켜 줄지는 결정하기 나름이다.
OAuth는 이러한 방법에 대한 표준 규격일 뿐이다. 이 규격에는 토큰과 교환할 수 있는 코드를 얻을 수 있는 방법과 얻은 토큰이 만료 됐다면 어떻게 재발급할 수 있는지에 대한 방법 같은 것들을 포함하고 있다.
많은 사람들이 OAuth 규격대로 자사 Auth를 구성하려고 한다. 과연 그럴 필요가 있을까? 없다. 핵심은 제3의 존재
이다. 제3의 서비스가 규격화된 방식으로 서로 Auth를 교환할 수 있는 표준인데 굳이 단독 서비스에 적용할 필요는 없다. 물론 그것도 그 사람 맘이다. 그렇게 하고 싶으면 그렇게 하면 된다. 위에서 설명한 OAuth를 사용한 토큰을 획득하는 절차를 보면 사용자를 인증 페이지로 보내서 인증한 후 코드를 얻고 어플리케이션은 해당 코드를 통해서 토큰을 획득한다. 그런데 어플리케이션이 직접 사용자의 아이디, 비밀번호를 통해서 토큰을 즉시 획득하면 더 쉽지 않을까?
무슨 뚱딴지같은 소리냐고 할 수 있고 그러면 OAuth가 왜 있냐고 말할 수도 있다. 아니다. OAuth 2.0 규격에는 grant_type=password
가 떡하니 있다. 물론 이 password가 사용자의 진짜 password인지 아닌지는 중요한 게 아니다. 맞을 수도 있고 아닐 수도 있다. 그건 구현하기 나름이다. 아무튼 중요한 것은 사용자가 해당 어플리케이션에 password를 미리 알려줬다면 앱은 매번 코드를 얻거나 재발급 절차를 거치지 않고도 토큰을 교환할 수 있다는 것이다. 사실 그게 지금까지 말해왔던 일반적인 로그인과 뭐가 다르냐고 물을 수 있다.
그게 바로 중요한 것이다. OAuth 2.1에서는 grant_type=password
가 빠졌다. 이것이 의미하는 것은 “우리는 해당 방법을 제3의 어플리케이션이 권한을 획득하는 방법으로 권장하지 않기에 표준으로 정리하지 않습니다. 그러니 이를 구현하는 라이브러리나 프레임워크는 이를 반드시 구현해 줄 필요가 없습니다.”를 의미하는 것이지 “직접 구현해서 사용하지 마세요.”를 의미하지는 않는다. OAuth 표준을 사용해서 자사 Auth 시스템을 구축해도 된다. 제3의 존재가 없어도 괜찮다.
가장 중요한 것은 대상이 무엇이고 나는 무엇을 하려 하는가이다.
유익한 자료 감사합니다.