1. 인증(Authentication)
서로 다른 사용자의 요청을 구분하기 위해, 서버는 클라이언트의 요청에 따라 사용자 신원을 확인해야 합니다. 이것이 바로 인증입니다.
인간과 컴퓨터의 상호작용에서 인증은 특정 정보에 접근하기 위해 사용자에게 로그인을 요구하는 것을 의미합니다. 사용자 신원을 확인하기 위해 사용자는 사용자 부류와 서버만이 알고 있는 정보(즉, 인증 요소), 예를 들어 사용자 이름/비밀번호를 제공해야 합니다.
웹 환경에서 흔히 사용되는 인증 방안은 크게 두 가지로 나뉩니다.
-
세션 기반 인증
-
토큰 기반 인증
세션 기반 방안에서는 로그인 성공 후, 서버가 사용자의 신원 정보를 세션에 저장하고 세션 ID를 쿠키를 통해 클라이언트에 전달합니다. 이후의 데이터 요청에는 쿠키가 포함되며, 서버는 쿠키에 담긴 세션 ID를 통해 사용자 신원을 식별합니다.
토큰 기반 방안에서는 서버가 사용자 신원 정보를 바탕으로 토큰을 생성하여 클라이언트에 발급합니다. 클라이언트는 토큰을 보관하고 이후 데이터 요청 시 토큰을 함께 보냅니다. 서버는 요청을 받은 후 토큰을 검증하고 파싱하여 사용자 신원을 파악합니다. 과정은 다음과 같습니다.
[caption id="attachment_1957" align="alignnone" width="810"]
token based login[/caption]
P.S. 사용자 이름/비밀번호는 지식 요소에 해당하며, 그 외에도 소유 요소와 유전 요소가 있습니다.
-
지식 요소: 사용자가 로그인할 때 반드시 알고 있어야 하는 것들 (예: 사용자 이름, 비밀번호 등)
-
소유 요소: 사용자가 로그인할 때 반드시 소지하고 있어야 하는 것들 (예: OTP 토큰, ID 카드 등)
-
유전 요소: 개인의 생물학적 특징 (예: 지문, 홍채, 얼굴 등)
P.S. 인증(Authentication)과 인가(Authorization)는 다릅니다. 전자는 신원을 확인하고, 후자는 권한을 확인합니다.
2. 토큰(Token)
인증에서의 토큰은 신분증과 같습니다. 서버에 의해 발급 및 검증되며, 유효 기간 내에는 합법성을 가집니다. 즉, "사람"(사용자)이 아닌 "증명"(토큰)을 보고 판단합니다.
세션 방안에서 사용자 신원 정보(세션 기록 형태)는 서버에 저장됩니다. 반면 토큰 방안에서 정보는(토큰 형태) 클라이언트에 저장되며, 서버는 토큰의 합법성만 검증합니다. 이러한 차이는 단일 로그인(SSO, Single Sign On) 시나리오에서 가장 명확하게 드러납니다.
-
세션 기반 SSO: 세션 동기화와 쿠키 공유를 고민해야 합니다. 예를 들어 로그인 성공 후 응답 쿠키의 domain을 형제 애플리케이션 도메인을 아우르는 와일드카드 형태로 설정하고, 모든 애플리케이션이 인증 서비스로부터 세션을 동기화해야 합니다.
-
토큰 기반 SSO: 토큰 공유를 고민해야 합니다. 예를 들어 형제 애플리케이션으로 이동할 때 URL을 통해 토큰을 전달하는 방식입니다.
토큰은 암호화된 세션 기록과 비슷하며, 사용자 ID 등의 신원 정보와 토큰 발급 시간, 유효 기간 등 토큰 합법성 검증을 위한 메타 정보를 포함합니다. 예를 들어:
{
// 身份信息
user_id: 9527,
// Token元信息
issued_at: '2012年3月5号12点整',
expiration_time: '1天'
}
// 加密后
895u3485y3748%^HGdsbafjhb
해당 토큰을 포함한 모든 요청은 서버에서 사용자 9527의 메시지로 간주됩니다. 하루 뒤 토큰이 만료되어 효력을 잃을 때까지 서버는 해당 신원을 인정합니다.
토큰의 형태는 다양하지만, 그중 JSON Web Token은 매우 인기 있는 토큰 규격입니다.
3. JSON Web Token
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.
요약하자면, 두 당사자 간에 전달될 클레임(claims)을 안전하게 표현하기 위한 간결하고 URL 안전한 통신 규격(JWT)으로, URL을 통해 전송할 수 있습니다.
P.S. 클레임은 임의의 메시지가 될 수 있습니다. 예를 들어 사용자 인증 시나리오의 "나는 사용자 XXX이다", 친구 요청에서의 "사용자 A가 사용자 B를 친구로 추가함" 등입니다.
토큰 형식
JWT 토큰은 Header, Payload, Signature의 세 부분으로 나뉩니다. 예를 들어:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
두 개의 . 문자가 세 부분을 구분합니다.
Header.Payload.Signature
의미상으로 Header는 토큰 유형, 암호화 방식(알고리즘) 등 토큰과 관련된 기본 메타 정보를 나타냅니다. 구체적으로는 다음과 같습니다 (alg는 필수이며 나머지는 선택 사항입니다).
-
typ: Token type -
cty: Content type -
alg: Message authentication code algorithm
Payload는 토큰이 담고 있는 데이터와 기타 토큰 메타 정보를 나타냅니다. 규격에서 정의한 표준 필드는 다음과 같습니다.
-
iss: Issuer, 발급자 -
sub: Subject, 토큰 정보 주제 -
aud: Audience, 수신자 -
exp: Expiration Time, 만료 시간 -
nbf: Not (valid) Before, 활성 시간 -
iat: Issued at, 발급 시간 -
jti: JWT ID, 고유 식별자
이 필드들은 선택 사항이며, Payload는 유효한 JSON이기만 하면 됩니다.
생성
토큰의 세 부분은 각각 다음과 같습니다.
Base64로 인코딩된 Header.Base64로 인코딩된 Payload.앞의 두 부분을 지정된 알고리즘으로 암호화한 결과
예를 들어:
// JOSE Header
const header = JSON.stringify({"typ":"JWT", "alg":"HS256"});
// JWT Claims Set
const claims = JSON.stringify({
"iss":"joe", "exp":1300819380, "http://example.com/is_root":true
});
JOSE Header와 JWT Claims Set을 각각 Base64로 인코딩하여 JWT 토큰의 Header와 Payload 부분을 얻습니다.
const tokenHeader = Buffer.from(header).toString('base64');
const tokenPayload = Buffer.from(header).toString('base64');
이어서 Header와 Payload를 . 문자로 연결하고, HMAC SHA-256 알고리즘(Header의 alg 필드에 지정된 알고리즘)으로 암호화하여 Signature 부분을 얻습니다.
// https://www.npmjs.com/package/jwa
const jwa = require('jwa');
const hmac = jwa('HS256');
const toSign = `${tokenHeader}.${tokenPayload}`;
const tokenSignature = hmac.sign(toSign, 'mySecret');
마지막으로 Signature를 . 문자로 맨 뒤에 연결합니다.
const token = `${toSign}.${tokenSignature}`;
결과물:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
P.S. JWT.IO를 통해 해당 토큰을 파싱하고 검증할 수 있습니다.
P.S. 주의할 점은, Base64 인코딩된 Header와 Payload에서 마지막의 등호(padding trailing equals)를 제거해야 한다는 것입니다.
전송
서버는 토큰을 생성한 후 응답 본문(Response Body)에 담아 클라이언트에 전달합니다.
클라이언트는 이를 받아서 LocalStorage/SessionStorage에 저장하고, 이후 데이터를 요청할 때 요청 헤더의 Authorization 필드에 토큰을 담아 서버로 보냅니다.
Authorization: Bearer <jwt_token>
서버는 데이터 요청을 받으면 Authorization 필드에서 토큰을 추출하여 합법성을 검증하고, 토큰 내용을 파싱하여 사용자 신원을 확인합니다.
검증
토큰의 합법성 검증을 위해 몇 가지를 확인해야 합니다.
-
토큰이 만료되었는가
-
자신이 발급한 것이 맞는가
Payload 부분에서 (직접 Base64 디코딩하여) iat, nbf, exp 세 가지 시간 관련 필드를 추출하여 다음 관계를 만족하는지 확인합니다.
iat(발급 시간) <= nbf(활성 시간) < 현재 시간 < exp(만료 시간)
이어서 토큰의 앞 두 부분(Header.Payload)을 가져와 서명(Signature)을 다시 계산한 뒤, 계산 결과가 일치하는지 확인합니다.
파싱
토큰의 합법성이 확인되면, 단순히 Payload를 Base64로 디코딩하여 토큰이 담고 있는 데이터를 파악할 수 있습니다. 예를 들어:
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ';
const [_, rawPayload,] = token.split('.');
const payload = JSON.parse(new Buffer(rawPayload, 'base64').toString());
// { iss: 'joe',
// exp: 1300819380,
// 'http://example.com/is_root': true,
// otherField: 'etc.' }
4. 로그인
세션 방안에서 쿠키 메커니즘은 로그인을 매우 단순하게 만듭니다(클라이언트는 거의 인지하지 못합니다). 사용자 이름과 비밀번호를 Post로 보내고 200 응답을 받으면 그 후로는 로그인된 사용자입니다.
반면 토큰 방안에서는 반드시 토큰을 쿠키에 기록하는 것은 아닙니다. 예를 들어 SSO 시나리오에서는 URL을 통해 애플리케이션으로 직접 전달될 수도 있습니다. 따라서 로그인 후의 인증 자격 증명은 클라이언트 입장에서 명시적이며, 클라이언트는 토큰을 직접 관리해야 합니다.
-
토큰 저장
-
데이터 요청 시 토큰 포함
-
이동 시 형제 애플리케이션에 토큰 공유
-
로그아웃 시 토큰 삭제
마찬가지로 데이터를 요청할 때 반드시 쿠키를 통해 토큰을 전달하는 것이 아니라, 요청 헤더의 Authentication 필드를 사용하기도 합니다.
5. 데이터 요청
데이터 요청을 보낼 때 토큰을 Bearer <jwt_token> 형식으로 Authorization 필드에 넣으면 됩니다.
Authorization: Bearer <jwt_token>
P.S. *Bearer(소유자 인증)*는 토큰 인증이라고도 불리며, 우리가 잘 아는 Basic(기본 인증)이나 Digest(요약 인증)와 유사한 HTTP 기반 인증 방식 중 하나입니다.
서버는 요청을 받으면 해당 필드에서 토큰을 추출하여 검증하고, 검증 통과 시 요청한 데이터나 작업 결과를 응답으로 보냅니다.
6. 로그아웃
세션 기반 인증에서 로그아웃 작업은 세션에서 해당 기록을 삭제하는 것입니다. 세션 모드에서는 사용자의 로그인 상태가 서버의 세션 기록을 기준으로만 판단되므로, 서버가 이 관계를 잊게 만들면 이후 클라이언트가 보내는 어떠한 신호도 인식하지 않게 됩니다.
하지만 토큰 인증은 다릅니다. 토큰은 완전한 상태 정보를 담고 있으며, 서버의 역할은 토큰을 발급하는 인증 기관(CA, Certificate Authority)에 더 가깝습니다. 이미 발급된 토큰은 자동으로 만료되기 전까지는 합법적이며, 서버는 토큰 검증만으로는 합법적인 토큰과 이미 로그아웃된(하지만 여전히 합법적인 규격의) 토큰을 구분할 수 없습니다.
그렇다면, 토큰을 즉시 무효화할 수 있는 방법이 있을까요?
사실 HTTPS가 의존하는 SSL 인증서도 이와 같은 문제를 겪고 있으며, 그래서 Certificate revocation list(인증서 폐기 목록)가 존재합니다.
암호학에서 인증서 폐기 목록(또는 CRL)은 "예정된 만료 날짜 이전에 발급 인증 기관(CA)에 의해 취소되어 더 이상 신뢰해서는 안 되는 디지털 인증서의 목록"입니다.
CRL은 인증서 블랙리스트와 같아서, 즉시 폐기해야 할 합법적 인증서를 기록합니다. CA는 인증서를 검증하기 전에 블랙리스트를 먼저 확인하여 폐기된 토큰을 걸러냅니다.
예를 들어:
// 1. 블랙리스트 관리
const tokenBlackLists = [];
function invalidateToken(token) {
if (!isTokenInvalidated(token)) {
tokenBlackLists.push(token);
}
}
function removeInalidatedToken(token) {
if (isTokenInvalidated(token)) {
tokenBlackLists.splice(tokenBlackLists.indexOf(token), 1);
}
}
function isTokenInvalidated(token) {
return tokenBlackLists.includes(token);
}
// 2. 로그아웃 시 블랙리스트 추가
router.get('/logout', ensureAuthenticated, (req, res, next) => {
// 토큰 무효화
invalidateToken(res.locals.token);
res.status(200).json({
status: 'success'
});
});
// 3. 만료 시 블랙리스트에서 제거
// 4. 작업 시 블랙리스트 여부 확인
decodeToken(token, (err, payload) => {
if (err) {
// 만료된 토큰은 블랙리스트에서 제거
removeInalidatedToken(token);
return res.status(401).json({
status: 'Token has expired'
});
} else {
// 무효화된 토큰인지 확인
if (isTokenInvalidated(token)) {
return res.status(401).json({
status: 'Token has been invalidated'
});
}
//...
}
});
P.S. 위 예시에서 블랙리스트는 메모리에만 저장되므로 서버 재시작 시 유실됩니다. 더 완벽한 구현은 블랙리스트 추가/제거(만료 시) 작업을 데이터베이스에 기록하고, 검증 시에는 메모리 캐시를 사용하며, 재시작 시 데이터베이스에서 다시 로드하는 방식이어야 합니다.
블랙리스트 외에도 다음과 같은 일반적인 전략이 있습니다.
-
클라이언트 토큰 삭제: 발급된 토큰을 삭제합니다. 토큰이 사라지면 로그인 상태도 사라집니다. 하지만 서버는 여전히 해당 토큰을 ��법적으로 간주하므로 완벽하게 안전하지는 않습니다.
-
매우 짧은 만료 시간의 토큰 사용 및 잦은 갱신: 만료 시간이 충분히 짧다면 자동 만료가 즉시 만료와 비슷한 효과를 냅니다. 하지만 너무 짧으면 상태를 유지한다는 장점이 퇴색됩니다.
-
토큰에 로그아웃 시간 포함: 로그아웃 시간을 비밀번호처럼 데이터베이스에 저장하고 검증합니다. 비밀번호 변경처럼 토큰을 즉시 무효화할 수 있지만, 추가적인 저장/조회/검증 필드가 필요하여 성능에 영향을 줄 수 있습니다.
필요하다면 이 4가지 전략을 동시에 사용할 수 있습니다. 예를 들어 어떤 전략을 사용하든 클라이언트 토큰은 당연히 삭제해야 합니다.
P.S. JWT를 즉시 무효화하는 방법에 대한 더 많은 논의는 다음을 참조하세요.
7. FAQ
-
JWT의 Payload는 안전한가요?
안전하지 않습니다. 단순히 Base64로 인코딩된 것이므로 평문 전송과 다름없습니다. 따라서 민감한 데이터를 담지 마세요.
-
사용자가 입력한 비밀번호를 클라이언트에서 암호화해야 하나요?
암호화할 필요 없이 평문으로 전송하세요. 클라이언트 비밀번호 보안은 SSL(HTTPS)이 보장합니다.
-
서버는 받은 비밀번호를 어떻게 암호화해야 하나요?
일반적으로 해시 솔팅(Hash Adding Salt) 방식을 사용합니다. 자세한 내용은 Adding Salt to Hashing: A Better Way to Store Passwords를 참조하세요.
아직 댓글이 없습니다