- API가 사용자 이름과 비밀번호를 요청
- DB에서 해당 사용자가 있는지 확인
- 해당 사용자가 권한이 있는지 확인
위의 방식은 HTTP에서 치명적 문제가 있다.
HTTP는 Stateless 하기 때문 -> 새로운 요청마다 DB를 방문하여 다시 인증해야 하기 때문에 DB와 서버에 부하를 준다.
- 위의 절차에 따라 사용자 인증 완료 시 서버에서 세션 ID를 메모리에 저장 후 해당 세션 ID를 클라이언트에게 리턴
- 클라이언트는 세션 ID를 서버로 전송하여 식별
하지만 위의 과정은 또 다른 문제를 야기했다.
인프라가 확장되면서 서버는 여러대가 필요했고 서버 여러대가 메모리를 공유하지 못하는 문제가 발생했다. 즉, 1번 서버 메모리에 클라이언트의 인증 세션 ID를 저장하고 2번 서버에 접근할 경우 인증 프로세스는 실패한다. 물론 Redis 서버에 인증 세션 ID를 모두 저장하거나 WAS 끼리 서로 메모리를 공유하도록 통신을 이용하는 방법이 있긴 하지만 이 또한 통신 비용이 들고 추가적인 개발이 필요하기 때문에 선호되지 못했다.
그래서 우리는 HTTP의 Stateless한 특성을 수용하고 더 나은 솔루션을 찾아야 했다.
- 인증 서버에 사용자 이름과 비밀번호 전송
- 인증 완료됐을 경우 인증 토큰을 만들어서 리턴
- API 요청시마다 토큰을 이용하여 인증받은 사용자임을 확인
하지만 이것도 문제가 있긴하다. 인증 서버가 유저에 대한 정보를 모두 가지고 있다는 것은 보안데 대해 안전하다는 장점도 있지만 유저에 대한 정보를 가져올 때마다 인증 서버에 요청해야 한다는 것이다. 이 또한 서버 통신 비용과 추가 개발이 발생하게 된다.
JWT는 인증 가능한 토큰에 사용자에 대한 정보 자체를 포함시켜서 발행한 토큰이다. 그래서 Stateless해도 사용 가능한 토큰의 장점에 DB나 세션에 접근하지 않아도 인증 가능하다는 장점이 추가되었다.
요즘엔 SNS 로그인을 많이 이용하는 추세이다. 이건 위에서 설명한 OAUTH 방식과 JWT 방식을 혼용해서 사용한다.
네이버, 구글, 카카오 등의 인증 서버에 로그인을 해서 OAUTH 토큰을 발급받는다. 해당 토큰을 이용해서 인증 서버에 사용자에 대한 정보를 요청한다. 그리고 요청된 정보와 사용자에게 입력 받은 정보를 이용해서 JWT 토큰을 만든다.
HEADER.PAYLOAD.SIGNATURE
wsin에서 junit 테스트에 사용중인 JWT token을 decode해보면 다음과 같다.
HEADER
JWT를 어떻게 검증할지. alg는 서명시 사용하는 알고리즘
PAYLOAD
JWT의 내용
SIGNATURE
헤더와 페이로드를 합친 문자열을 서명한 값.
헤더의 alg에 정의된 알고리즘과 비밀키를 이용해 생성
위의 값들은 json객체의 경우 직렬화 한 후에 Base64 URL-Safe
로 인코딩된다.
JWT 토큰을 생성하는 데 사용하는 알고리즘은 크게 두가지가 있다.
HMAC
: 단일 secret key
RSA
: 비대칭 키 (private key, public key pair)
비대칭 키 방식인 RSA를 사용하면 공개키는 모두에게 공개되어 있기 때문에 다른 사람들이 데이터를 조작하지 않고도 데이터의 무결성을 확인할 수 있다.
서버가 토큰을 외부의 조작으로부터 보호하는 것은 단일 키 방식은 HMAC으로도 충분하지만 토큰이 신뢰할 수 있는 특정 서버에서 생성되었음을 다른 사람에게 입증해야 할 경우 RSA 기반 방식을 사용해야 한다. 하지만 대부분 상황에서는 이런 입증이 필요없기 때문에 HMAC 방식으로 해도 충분하다. JWT 토큰 인증으로 해당 서버의 신뢰성이 증명되기 때문이다.
RSA 기반 방식으로 JWT 토큰 인증 방식을 구현해보자.
public class JwtTest extends UnitTest
{
private static ECPublicKey EC_PUBLIC_KEY;
private static ECPrivateKey EC_PRIVATE_KEY;
@BeforeAll
public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidAlgorithmParameterException
{
// public key, private key key pair 생성
final KeyPairGenerator pemKeyPairGenerator = KeyPairGenerator.getInstance("EC"); //ECDSA 알고리즘 의미
pemKeyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"));
final KeyPair keypair = pemKeyPairGenerator.generateKeyPair();
String pemPublicKey = Base64.encodeBase64String(keypair.getPublic().getEncoded());
String pemPrivateKey = Base64.encodeBase64String(keypair.getPrivate().getEncoded());
// PEM 형식의 키를 자바의 ECPublicKey, ECPrivateKey로 변환
final KeyFactory keyPairGenerator = KeyFactory.getInstance("EC");
EC_PUBLIC_KEY = (ECPublicKey) keyPairGenerator.generatePublic(new X509EncodedKeySpec(Base64.decodeBase64(pemPublicKey)));
EC_PRIVATE_KEY = (ECPrivateKey) keyPairGenerator.generatePrivate(new PKCS8EncodedKeySpec(Base64.decodeBase64(pemPrivateKey)));
}
@Test
void createAndVerifyJwt() throws NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, SignatureException
{
// create header
final ObjectMapper objectMapper = new ObjectMapper();
final Map<String, Object> header = Maps.newLinkedHashMap();
header.put("kid", "키 아이디");
header.put("typ", "JWT"); // 타입
header.put("alg", ES256); // 알고리즘
final String headerStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(header));
// create payload
final Map<String, Object> payload = Maps.newLinkedHashMap();
payload.put("iss", "JWT를 만든 곳");
payload.put("iat", 0); //JWT 생성 시간
final String payloadStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(payload));
// create signature
final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format");
signature.initSign(EC_PRIVATE_KEY);
signature.update((headerStr + "." + payloadStr).getBytes());
byte[] signatureBytes = signature.sign();
final String signatureStr = Base64.encodeBase64URLSafeString(signatureBytes);
// create jwt
final String jwt = headerStr + "." + payloadStr + "." + signatureStr;
// verify jwt
final String[] splitJwt = jwt.split("\\.");
final String headerStrFromJwt = splitJwt[0];
final String payloadStrFromJwt = splitJwt[1];
final String signatureStrFromJwt = splitJwt[2];
signature.initVerify(EC_PUBLIC_KEY);
signature.update((headerStr + "." + payloadStr).getBytes());
// 검사
assert signature.verify(Base64.decodeBase64(signatureStrFromJwt));
}
}
위의 코드에서 JWT 토큰을 구현할 때 header, payload, signature를 모두 Base64 URL-Safe
를 이용해서 인코딩했다. 해당 인코딩은 Base64
인코딩에서 +는 -로, /는 _로 대체하여 인코딩하여 URL, cookie, header 등 다양하게 쓰일 수 있게 되었다.
MSA 환경에서는 수많은 서비스 간의 API 호출이 발생하기 때문에 JWT 토큰을 사용하는 것이 훨씬 효과적이다. 서버 사이의 통신이 일어날 때마다 DB에 방문할 필요 없이 토큰 자체에 든 값을 이용해 권한 체크를 하기 때문이다. 이로써 권한 서비스와의 의존성을 줄일 수 있다.
https://meetup.toast.com/posts/239
https://smoh.tistory.com/347