access token, refresh token 토큰 기반 인증 방식 | JWT, HTTP header
카카오 로그인을 구현하면서 토큰 기반으로 로그인 상태를 유지시키는 기능을 구현해보려고 합니다. 이전에는 계속 세션 기반으로만 직접 구현해 봤고, 토큰 기반으로는 이미 구현된 로직을 활용했던 경험밖에 없었습니다. 그래서 토큰 기반으로 구현을 하다 보니 왜 액세스 토큰을 저장 안 해도 되는 거지?라는 의문이 들더라고요. 지금은 로직을 다 구현한 상태인데, 다 구현하고 생각해 보니 그냥 내가 비즈니스 로직만 알았고, 인증 방식의 개념 정의를 몰라서 생긴 의문이었던 것 같더라고요... 3년 전에 한번 공부했던 적이 있었는데.. 또또또 다 잊고 말았지 뭐야뭐야... 그래서 다시 한번 상기 겸! 블로그에 정리합니다.
세션 기반 vs 토큰 기반
세션 기반
사용자가 로그인할 때 서버에서 고유한 세션 ID를 생성해서 이를 사용자의 브라우저에 쿠키로 저장하고, 이후 사용자가 요청을 보낼때마다 세션 ID를 포함하는 형태입니다. 클라이언트 측에서 매번 세션 값을 일일이 담아서 보내는 로직을 짜야하는 건 아니고 (토큰이랑 다름) 브라우저가 자동으로 요청마다 쿠키를 포함해 서버에 전송합니다. 보통 세션 ID는 난수 생성기와 암호화 알고리즘을 사용해 생성합니다. 서버 측에서는 세션 미들웨어 같은 걸 사용해서 request 객체에 session 속성을 추가하기에 해당 값을 읽어 들일 수 있습니다.
서버 측에선 세션을 메모리 또는 DB에 저장할 수 있는데, 메모리에 저장할 경우 서버가 재시작된다면 로그인이 풀려버리고, DB에 저장하게 된다면 서버 간 세션 공유가 가능해서 사용자가 여러 서버에 접근하더라도 일관된 세션 및 로그인 상태를 유지할 수 있습니다. 이 부분은 동일한 도메인인데 서버가 분산되어 있을 경우에 용이할 것 같습니다. 간단하게 세션 관리 미들웨어(express-session, createCookieSessionStorage)를 사용할 수 있습니다
단점은 세션이 서버에 저장되기에 서버 메모리가 소비되는 형태이고, 토근 기반처럼 로그인을 갱신하는 형태가 아니다보니 세션이 만료되면 무조건 자동 로그아웃이 된다는 점입니다. 이 부분은 사용자에게 좋지 못한 경험이 된다고 볼 수 있습니다.
장점은 세션을 오로지 서버측에서 관리를 하기에 보안이 강화된다는 점입니다
토큰 기반
사용자가 로그인할때 서버에서 인증 토큰(JWT)을 발급해서 클라이언트에 전달하는 방식입니다. 전달 방식은 JSON 또는 Cookie를 쓸 수 있습니다. 전달받은 클라이언트는 로컬 스토리지 또는 쿠키에 저장합니다. 이후 사용자는 서버에 요청을 보낼 때 http header authorization에 해당 token 값을 포함해서 보냅니다. 그러면 서버는 token이 요청값에 있는지 없는지 여부를 따져 로그인한 유저임을 판단하여 응답 값을 보냅니다.
서버에서는 토큰에 대해 별도로 관리를 하지 않습니다. (리프레시 토큰은 관리 대상이 될 수 있지만) 그저 http header에 token이 있냐 없냐만 구별하는게 특징입니다. 토큰을 저장해서 관리해야 하는 경우는 블랙리스트 같은 경우가 있을 수 있다고 합니다. 리프레시 토큰은 새로운 액세스 토큰을 발급하기 위해 장기적으로 있어야 하는 토큰이기에 서버 메모리에 저장하거나, DB에 기록해 유효성을 검증하는 게 좋습니다.
다만, 토큰을 클라이언트에서 관리하다보니 만약 내가 PC웹에서 로그인해서 자동 로그인처럼 사이트를 사용 중이었다면, 모바일 웹에서 해당 사이트에 들어갈 경우 로그인이 안되어있는 상태가 됩니다.
장단점
[세션 기반]
- 실시간 세션 관리 가능 (세션 무효화, 세션 삭제 등등)
- 서버 부담
- 확장성 문제
[토큰 기반]
- 서버가 인증 상태를 관리하지 않기에 확장성과 성능면에서 유리 (앱에서 많이 사용)
- CORS 제약이 적은 편
- 탈취에 대한 위험성이 높음
- 빠른 로그아웃 처리는 어려움
보안 취약점
사실 세션 기반, 토큰 기반 둘 다 보안에 딱히 좋은 건 아닙니다. 둘 다 탈취, 조작 위험이 있습니다. 사실상 그냥 개발자 도구 열리는 순간부터 안전한 건 1도 없음^^ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ그래서 생성자만 알 수 있는 시크릿키와 함께 암호화를 꼭 해야 하고, 세션이든 토큰이든 만료 시간을 짧게 설정해서 주기적으로 갱신하는 게 좋을 것 같습니다.
JWT
인증에 필요한 정보들을 암호화시킨 JSON 토큰 (a.k.a 알 수 없는 긴 문자열)
JWT는 점을 구분자로 세가지 문자열의 조합입니다.
- header : 타입, 해시 알고리즘 종류
- alg : 서명 암호화 알고리즘
- typ : 토큰 유형
- payload : 서버에서 첨부한 사용자 권한 정보와 데이터 (로그인한 사용자의 정보)
- sub : 토큰 제목
- iss : 데이터 발행자
- iat : 발행된 시간
- exp : 만료 시간
- aud : 토큰 대상자
- nbf : 토큰이 처리되지 않아야 할 시점
- jti : 토큰의 고유 식별자
- 공개 클레임 (public claims) : 사용자가 정의할 수 있는 공개용 정보 전달 (Ex. "https://my-domain": true)
- 비공개 클레임 (private claims) : 해당하는 당사자들 간에 정보를 공유하기 위해 만들어진 사용자 지정 클레임. 외부에 공개돼도 상관없지만 해당 유저를 특정할 수 있는 정보를 담음
- signature : header, payload를 base64 url-safe encode를 한 이후 header에 명시된 해시 함수를 적용하고, private key로 서명한 전자 서명
- 단순히 인코딩 된 값이기 때문에 제 3자가 복호화가 가능하지만, 환경변수로 관리하는 비밀키가 유출되지 않으면 복호화가 불가능
jwt.sign(payload, process.env.JWT_SECRET_KEY, { expiresIn: '1h' })
Nest.js에서 jsonwebtoken 라이브러리를 이용한 코드
sign이란 함수를 이용해서 토큰을 만들 수 있습니다. payload는 앞서 말한 공개 클레임, 비공개 클레임 값들을 설정해 주고, 두번째 인자값인 process.env.JWT_SECRET_KEY은 개인키, 식별값이며 절대로 외부에 공개되면 안 되는 키입니다.
access token, refresh token
실제로 개발할 땐 JWT 토큰을 2가지로 만들어서 발행해야 합니다. 앞서 말했다시피 액세스 토큰은 탈취 위험이 있습니다. 그래서 유효 기간을 짧게 두어야 하는데, 이렇게 될 경우 사용자는 로그인을 자주 해야 하는 일이 발생합니다. 과연 이게 유저 경험에 좋은가? 아닙니다. 그래서 유저 모르게 계속 로그인 상태를 유지할 수 있게끔 액세스 토큰을 갱신해 줄 리프레시 토큰이 하나 더 필요하게 된 것입니다.
액세스 토큰은 유효기간을 1시간에서 길게는 일주일, 한달 정도로 설정하고, 리프레시 토큰은 1년 정도로 가져가는 기업들이 있습니다. 이건 설정하기 나름입니다. 리프레시 토큰까지 만료된다면 로그아웃 로직을 태우면 됩니다.
- 사용자 로그인 시도
- 서버에서 액세스 토큰, 리프레시 토큰 발급
- 클라이언트 로컬 스토리지 또는 쿠키에 저장
- 클라이언트는 서버에 API 요청할 때마다 http header에 액세스 토큰을 담아서 요청
- 서버는 액세스 토큰이 있는지 없는지 판별
- 헤더에 액세스 토큰이 없다면? -> 잘못된 사용자 접근 및 호출 -> 로그아웃 및 남아있는 토큰 삭제
- 헤더에 액세스 토큰이 있다면? -> 요청한 데이터 반환
- 액세스 토큰이 있지만 만료되었다면?
- 서버는 에러를 반환
- 클라이언트는 리프레시 토큰으로 재요청
- 서버 DB 또는 메모리에 저장되어 있는 리프레시 토큰이 유효한지 확인
- 액세스 토큰 새로 생성 후 반환
- 클라이언트는 새로 받은 액세스 토큰으로 API 재요청
- 액세스 토큰이 있지만 만료되었고 리프레시 토큰도 만료되었다면?
- 로그아웃 및 서버는 남아있는 토큰 삭제
왜 토큰을 HTTP header에 담아야 하는가?
const result = await axios.post(
`${process.env.API_ENDPOINT}/getList`,
data,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
클라이언트에서 서버에 API 요청을 보낼 때 이렇게 header에 토큰을 보내줘야 하는데, 문득 왜... 이래야 하지?라는 생각이 들더라고요 그냥 토큰을 body로 보낼 수도 있는 거 아닌가? 싶어서 알아보게 되었습니다
- 웹 표준 준수
- 보안 강화
크게 이 2가지 이유 때문입니다.
웹 표준
HTTP 표준에서 인증 토큰을 authorization 헤더에 포함하여 서버로 보내는 방식을 권장합니다. Bearer는 authorization 헤더에서 인증 수단의 일종이며, 사용자를 대표하는 접근 권한을 부여받았음을 의미합니다.(= 이 토큰을 소지한 사람에게 접근 권한이 있다) OAuth 2.0에서도 사용하는 웹 인증 표준에서 가장 널리 사용되는 방식입니다
보안 강화
토큰을 헤더에 담으면 서버가 HttpOnly 쿠키나 보안 조치와 함께 보호가 가능합니다. 또한, CSRF 방지 및 토큰을 URL에 포함할 경우 북마크, 공유, 보안 문제가 발생합니다
(다음 포스팅은 이 개념들을 머릿속에 담아둔채 카카오 로그인, 로그인 상태 유지를 위한 토큰 발급, 갱신, 삭제에 대한 이야기로 이어집니다)