개요
이전 글에서 JWT에 대해 알아봤었습니다. JWT는 Stateless라는 장점이 있지만, 보안성이 취약하다는 문제가 있었습니다. 저는 제 서비스에서 해당 문제를 보완하기 위해 DPop(Demonstration of Proof-of-Possession)이라는 방법을 이용했습니다. DPop이 뭔지 자세히 알아보겠습니다.
DPop?
DPop은 공개키(비대칭키) 인증 방식을 이용해 토큰을 보낸 클라이언트를 검증하여 인증된 사람만 키를 사용할 수 있게 하는 방식입니다. 추가로 검증을 요청하는 URI, 생성 시간, HTTP Mthod 등 검사하는 항목을 늘려 정확히 원하는 요청만 인증 처리가 가능하도록 합니다. 일단 DPop의 검증 구조를 이해하려면 비대칭키에 대한 이해가 선행되어야 하므로 간략하게 설명하겠습니다.
비대칭키과 DPop에서의 활용
비대칭키는 모두에게 공개하는 공개키(Public Key)와 소유자만 보관하는 비공개키(Private Key)를 만들어 암호화와 복호화를 진행하는 암호화 방법입니다.
공개키를 이용해 암호화 한 데이터는 비공개키를 가진 사람만 해독이 가능하고, 그 반대도 가능하다는 특징이 있습니다.
DPop은 비대칭키의 이 특성을 활용합니다.
DPop은 인증을 담당하는 JWT와 요청한 클라이언트를 검증하는 DPop Proof 두 가지 토큰이 존재합니다.
인증 JWT에 공개키의 Thumprint를 담고, Dpop Proof에는 공개키의 JWK(Json Web Key) 정보를 담은 뒤 Proof 키를 비공개키로 서명합니다.
서버에서는 DPop에서 JWK를 이용해 공개키 정보를 가져오고, DPop의 서명을 검증 하고 인증 JWT의 공개키 Thumprint도 비교하여 올바른 요청인지 검증합니다.
더 자세한 프로세스는 아래에서 설명하겠습니다.
우선 각 키의 구조부터 살펴보겠습니다.
용어정리
- JWK (JSON Web Key): 키를 Json으로 표현한 표준 방식입니다.
- JKT (JWK Thumbprint): JWK의 Json 표현을 해시 후 Base64로 인코딩 한 값 입니다.
DPop Proof
DPop Proof키는 기본 구조는 JWT를 따르나, 몇가지 claim들이 수정되거나 추가됩니다.
Header
1
2
3
4
5
6
7
8
9
10
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "85-WaeriOF1T6xl7Pt0LqL-YqtYw36myrLQyJMSNvLU",
"y": "x7kV-qLeUnYO18l-VLv-kI84AIIa8vfmzR70nq4EMJg"
}
}
- tpy: JWT의 타입을 정의합니다. “dpop+jwt”로 정의됩니다.
- alg: 기존 그대로 Signature에 사용된 알고리즘을 정의합니다.
- jwk: 해달 키 서명에 사용된 비공개키의 공개키 jwk 정보를 담는 곳 입니다. 여기서 가져온 공개키 JWK에서 Thumprint를 구해 인증 JWT와 일치하는지 비교하여 요청한 사용자를 검증합니다.
Payload
1
2
3
4
5
6
7
8
{
"jti": "d17baf5e-48fc-4c13-8f27-c74ab9ed13d1",
"htm": "GET",
"htu": "https://api.example.com/v1/payments",
"iat": 1758812523,
"ath": "VX9oGOtY5YbUunzegH4-hdSdehCuutDfYPXCtApntMo",
"nonce": "DPoP-Nonce-Example-123"
}
- jti: Proof의 재사용을 방지하기 위한 고유 식별자입니다. 보통 인증 후 서버에서 짧게 (1분 등) 기록하여 이전에 사용된적이 있는지 검사합니다.
- htm: 해당 요청의 HTTP Method를 기록합니다.
- htu: 해당 요청의 URL을 기록합니다. 이때 쿼리와 fragment는 제거하는 normalize가 적용되어야 합니다.
- iat: Proof가 생성된 시각입니다. 서버에서는 iat가 얼마나 경과했는지 체크 후, 일정 시간을 넘기면 인증을 거부합니다. 보통 매우 짧게 수 초로 설정됩니다.
- ath: 인증 JWT를 해싱 후 base64로 인코딩한 문자열 입니다. 서버에서 인증 JWT에 똑같이 작업 후 일치하는지 검증합니다. 해당 claim은 인증 JWT가 있는 요청에만 포함시킵니다. 인증 JWT를 발급하는 요청과 같은 요청에는 포함되지 않을 수 있습니다.
- nonce(선택): 서버에서 전 요청에서 보낸 nonce를 넣어 재사용을 방지합니다. DPop은 요청 처리 후 Response Header에 DPop-Nonce라는 키로 새 nonce를 보내는데, 이 nonce 값을 다음 요청시 포함시키는 방식입니다.
Signature
JWT 방식대로 앞에 두 영역을 결합해서 Base64로 인코딩 후 서명합니다. 이때 서명키는 비공개키로 진행합니다. 서버에서는 Header에 있는 공개키로 검증하여 맞게 서명되었는지 확인합니다.
JWT (인증키) 구조
인증을 위한 JWT는 Payload의 cnf.jkt를 제외하면 기존 JWT와 구성이 똑같습니다.
cnf.jkt
1
2
3
4
5
6
{
"sub": "...",
"iss": "https://server.example.com",
"exp": 1562266216,
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
}
여기가 공개키의 JKT를 넣어두는 곳 입니다. 해당 JKT를 DPop에 있는 JWK에서 구한 thumprint와 비교하여 요청한 사용자를 검증합니다.
프로세스
위에서 구조를 설명하면서 간접적으로 설명했지만, 한번 더 정리해 보겠습니다.
인증 JWT 요청
우선 인증 처리를 위한 JWT 생성을 요청해야 합니다. 순서를 정리하면 다음과 같습니다.
- 공개키/비공개키 생성
클라이언트가 대칭키 생성 알고리즘을 이용해 공개키와 비공개키를 생성하고 안전한 저장소에 저장합니다. 모바일에서는 보통 SecureStorage와 같은 곳에 저장합니다. - DPop Proof 생성
위에서 설명한 구조대로 Proof 생성 후 비대칭키로 서명합니다. htu는 발급 요청 api url, htm은 발급 요청 HTTP Method, JWK는 공개키 JWK 등 규칙에 맞게 구성 후 완성시킵니다. - 발급 요청 및 인증 JWT 발급
요청 헤더에 DPop이라는 키에 Proof 키를 value로 넣어 보냅니다. id/pw와 같은 1차적인 인증이 성공하면 Proof를 검증하여 한번 더 확인 후 서버에서 인증 JWT를 발급합니다. 검증시에는 htu, htm이 맞는지, iat가 허용 범위 내인지, jti는 이전에 사용된적이 없는지 등 여러 단계를 거칩니다. 인증 JWT 발급시 같이 전달된 공개 JWK에서 JKT를 구해 cnf.jkt에 추가합니다. - (nonce 사용시) 응답 및 nonce 값 저장
응답 헤더에 있는 DPop-Nonce의 값을 가져와 저장해둡니다. 해당 데이터는 다음 요청시 활용됩니다.
인증 JWT 활용
- Proof 생성
몇가지 claim이 추가되는것 말고는 인증 JWT 요청시와 동일한 방법으로 생성합니다. 첫 번쨰로 인증 JWT의 해싱-base64 인코딩 문자열을 Payload에 ath라는 claim으로 추가되어야 합니다. 두 번째는 nonce 사용시에만 진행하면 되는데, 이전 요청에서 받은 nonce 값을 Payload에 nonce라는 claim으로 추가합니다. - 요청
발급시와 마찬가지로 헤더에 넣어 요청하면 됩니다. 이때 인증 토큰 역시 같이 넣어 요청합니다. - (nonce 사용시) 응답 및 nonce 값 저장
마찬가지로 발급시와 동일하게 처리하면 됩니다.
여기까지 내용은 IETF(국제 인터넷 표준화 기구)에 RFC 9449로 정리되어있는 내용입니다. 더 자세한 내용이 궁금하시면 헤당 문서를 직접 보시는것도 좋은 방법입니다. 표준 DPop의 장단점을 정리해 보겠습니다.
장점
우선 클라이언트가 직접 비대칭키를 만들고 관리한다는 점에서 이론상 기기 자체가 해킹당하는게 아니면 키를 모두 탈취당하더라도 지속적인 사용이 어렵습니다. 공개키를 알더라도 결국 비대칭키를 모르기때문에, 다음 요청을 다시 보내는게 불가능합니다. 거기에 iat, jti, nonce와 같은 여러 재사용 검증도 여러번 진행됩니다. 이는 설령 키를 모두 탈취당해도 단 한번만 사용이 가능하다는 의미입니다.
단점
그러나 높은 보안성 만큼 지속적으로 인증때마다 저장소에 읽기/저장 작업이 필요합니다.
이는 Stateless의 장점을 많이 희생시키게 되어 인증에 더 많은 시간과 자원을 사용하게 된다는 단점이 있습니다.
그래서 저는 In-Memory DB인 Redis를 활용하여 최대한 IO시간을 줄이려고 시도했습니다.
그리고 비대칭키 특성상 암호화-복호화에 적지 않은 자원이 들어갑니다.
추가적으로 표준 DPop도 기존 JWT의 로그아웃 문제가 동일하게 존재합니다.
인증 키도 결국 제한 시간 내에서만 쓸 수 있는데, 해당 기간이 끝나면 강제로 로그아웃 해야합니다.
물론 Proof키의 여러 검증으로 인증 JWT의 유효기간 자체를 길게하는 방법도 있겠지만, 저는 인증키의 유효기간을 길게 하는것보다 AT-RT구조를 활용해서 개발했습니다.
실제 구현
여기부터는 표준이 아닌 제가 구현한 방식을 간략하게 설명드리고자 합니다. DPop이 활용된 사례가 한글로 정리된게 많이 없어서 사례를 공유드리고자 작성하게 되었습니다.
구조
저는 AT-RT 방식에 DPop을 추가하는 방식으로 진행했습니다. AT는 20분, RT는 30일로 잡고, AT가 사용될 때 마다 RT의 남은 시간을 일정 기간 초기화 하여 더 오래 유지되도록 만들었습니다.
기기 등록
최초 인증시 기기마다 고유 id를 만든 뒤, 기기의 공개키를 서버에 등록하게 했습니다. 등록된 기기는 90일 TTL을 걸고, 기기에서 인증 요청이 처리될 때 마다 90일로 TTL을 초기화 시키는 방식으로 90일간 사용되지 않으면 기기는 자동으로 로그아웃되게 만들었습니다. 서버에 기기를 등록함으로써 사용자가 언제든 사용하지 않는 기기를 강제 로그아웃 할 수 있게 만들었습니다.
RT
RT를 JWT 형식으로 만들지 않고, 무작위 문자열을 생성한 뒤 해싱하여 만들었습니다. 여러 기기에서 다중로그인이 가능하도록 만들기 위해서 하나의 RT만 존재하게 만들었고, 어차피 DPop Proof키를 이용한 검증이 가능해서 무작위 문자열로 생성하게 되었습니다. 기기 id와 RT를 매핑하는 데이터를 Redis에 저장해서 한 유저가 여러 기기에서 로그인해도 하나의 RT만 사용하게 만들었습니다.
그 외 다른부분은 JWT+DPop 방식과 동일합니다. 저는 멀티로그인 + 기기관리 기능을 위해 살짝 변형했습니다. JWT 방식의 보안성때문에 걱정되시는 분들은 DPop을 한번 사용해 봐도 좋을 것 같습니다.