Open Feign을 이용한 카카오 OpenID Connect 구현하기

밀크야살빼자·2024년 6월 5일
0

기존 국민학교 프로젝트에서는 카카오에서 제공하는 Access Token을 활용하여 사용자를 인증하였습니다. 그러나 인증뿐만 아니라 사용자 정보 설정과 국민학교로 요청한 것이 맞는지 확인하기 위해 OpenID Connect(OIDC)을 도입하기로 결정했습니다. 또한, 멋쟁이사자처럼 프로젝트 진행하면서 사용했던 OpenFeign 기술을 함께 사용하여 구현하려고 합니다.

OAuth

OAuth는 인가를 위한 기술로, 인증은 유저가 직접하고, 권한은 클라이언트에게 부여하는 것입니다.

  1. Resource Owner가 클라이언트에 OAuth 요청(로그인 요청)
  2. 클라이언트에서는 로그인 페이지 URL제공
  3. User-Agent는 Authorization Server에 로그인 페이지 접근 요청
  4. Authorization Server는 로그인 페이지 반환
  5. Resource Owner는 인증 수행과 scope 지정 후 User-Agent에 보냄
  6. User-Agent는 인증 수행과 scope 지정
  7. 인증이 유효하다고 판단되면 Authorization Server에서는 AUthorization code를 반환
  8. client에서는 AccessToken 발급을 요청
  9. 인증이 끝나면, Authorization Server에서는 AccessToken을 반환(RefreshToken)
  10. 권한을 부여 받으면, Resource Owner가 Client에 서비스 요청을 하면 Client는 AccessToken으로 Resource Server에 리소스 요청한다.
  11. Resource Server는 AccessToken 검증 후 리소스 반환
  12. Client는 ResourceOwner에 서비스를 제공해준다.

RSA 암호화

RSA는 SSL/TLS에 많이 사용되는 공개키로, 소인수분해 하기가 힘들다는 것을 이용한 암호화 알고리즘입니다. RSA는 (공개키, 개인키)를 한 쌍으로 가지고 있으며, 공개키로 암호화한 내용은 개인키로만, 개인 키로 암호화한 내용은 공개키로만 해독할 수 있습니다.
그림과 같이 공개키는 (e,N), 개인키는 (d,N)의 쌍을 갖습니다.

예를 들어, Alice가 Bob에게 편지를 보내려고 하는데, 택배나 우편을 이용하면 보안에 문제가 있을 것이라 판단하여 안전하게 전달할 수 있는 방법을 생각합니다. 그래서 Bob이 먼저 Alice에게 열쇠와 편지 봉투를 주고, Alice는 그 안에 편지를 넣고 Bob이 준 열쇠로 잠급니다. 이렇게 하면, 해당 편지 봉투를 열 수 있는 사람은 Bob뿐이므로 안전하게 편지를 받을 수 있습니다.

이와 같이, RSA는 암호화 키(잠그는 키)와 복호화 키(열 수 있는 키)로 이루어진 두 개의 키를 사용하여 안전하게 통신할 수 있습니다.

Open Feign

Open Feign은 Netflix가 개발한 Declarative(선언적) HTTP Client 도구입니다. 이를 사용하면, 외부 API 호출이 간편해집니다. "선언적"이란 여기서 어노테이션을 사용하여 인터페이스를 정의하면 구현이 자동으로 이루어진다는 것을 의미합니다. 이러한 접근 방식은 Spring Data JPA와 유사하며, 개발을 훨씬 편리하게 만들어줍니다.
Feign은 인터페이스와 어노테이션 만으로 웹 서비스 호출을 쉽게 만든 Netflix에서 만든 기술입니다.

Open Feign 의존성을 주입하기 위해서는 build.gradle에 다음과 같이 추가해주어야 합니다.

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.8'

기존 인증 방식의 문제점

기존에는 OAuth를 통해 코드를 받으면 즉시 회원가입이 처리되었습니다. 그러나 일반적으로 OAuth 인증 후에는 즉시 회원가입으로 넘어가는 것은 드물며, OAuth의 Access Token 및 Refresh Token은 주로 OAuth 리소스 서버(예 : 카카오토의 사진, 친구 목록)에 대한 권한 부여를 위한 용도로 사용됩니다. 따라서 OAuth 인증을 통해 발급된 Access Token은 단순히 사용자의 프로필 정보를 가져오는 데에 활용되며, 국민학교에서 직접 발급한 것인지에 대한 확인이 불가능합니다. 즉, 이러한 방식으로 사용자 프로필 정보를 확인한 후 회원가입을 진행하는 것은 올바르지 않은 접근 방식입니다.

예를 들어, 카카오톡 디벨로퍼에 "국민학교2"라는 테스트 어플리케이션을 만들고 가입한 후 해당 Access Token을 국민학교 서비스에 전송하더라도, 국민학교 서비스에서는 해당 Access Token을 이용하여 사용자의 프로필 정보만을 확인하기 때문에, 실제로 회원가입 처리를 진행합니다. 이는 국민학교 서비스가 OAuth 인증을 통해 받은 Access Token이 본 서비스에서 직접 발급되었는지 여부를 확인할 수 없기 때문입니다.

따라서, 토큰 정보 보기를 요청하여 응답값으로 전달된 app_id가 국민학교 app_id와 동일한지 2차 확인하는 과정이 필요합니다.

HTTP/1.1 200 OK
{
	"id" :123456789,
	"expires_in" : 7000,
	"app_id" : 1234
	}

Step 2.의 토큰 받기는 서버까지만 해당됩니다. Access Token을 이용해 프로필 정보를 확인하며 회원가입을 하게되면, 누구든지 다른 서비스에서 발급받은 Access Token을 가지고 회원가입 API를 통해 회원가입할 수 있게 됩니다.
그러나 Step 3.에서 OIDC를 사용하게 되면, ID 토큰에 대한 유효성 검증을 하여 app_id가 동일한지 확인한 후 사용자 정보를 가져와서 로그인을 합니다.

OpenID Connect(OIDC)

OpenID Connect(OIDC)는 사용자가 안전하게 로그인할 수 있도록 하는 OAuth 2.0 기반의 표준 인증 프로토콜입니다. OIDC는 표준이기 때문에 구글, 네이버 등 다른 OAuth 제공자에서도 지원하는 경우 사용할 수 있습니다.

{
	//JWT 토큰을 받은 사람
	"iss" : "https://kauth.kakao.com",
	//앱 키, 국민학교이면 240302(예시)
	"aud" : "${APP_KEY}",
	//실제 유저의 고유 아이디(카카오 유저의 고유 번호)
	"sub" : "166959",
	"iat" : 123456789,
	"exp" : 123456789,
	"nonce" : "${NONCE}",
	"auth_time" : 1647183250

위와 같이 발급한 곳의 정보와 앱 키의 정보를 JWT 토큰에서 얻을 수 있습니다.

앱 키의 정보를 JWT 토큰의 payload에서 얻을 수 있습니다. 만약, 검증된 토큰이라면 국민학교에서 등록한 어플리케이션으로 발급한 IdToken이라는 것을 알 수 있습니다.

세션 유지가 가능하다면 회원가입시에 추가 정보가 필요할 경우, idToken을 활용하여 OAuth에 인증된 사용자임을 검증할 수 있습니다.

JWT 인증 시 하나의 키가 아닌 RSA 암호화 방식을 사용하여 공개키를 제공합니다. 이는 JWT를 암호화할 때 비밀키로 암호화하고, 인증할 때는 공개키로 복호화하는 방식입니다.

적용하기

  1. OIDC 프로바이더의 공개키 목록을 요청합니다.
  2. ID 토큰의 헤더에서 kid 값을 추출합니다.
  3. 공개키 목록에서 kid와 동일한 값을 가진 공개키를 찾습니다.
  4. 해당 공개키의 ne 값을 사용하여 RSA 공개키를 생성합니다.
  5. 생성한 공개키로 ID 토큰의 서명을 검증합니다.

공개키 목록 조회

IdToken을 받아서 인증되었는지 확인하기 위해 공개키로 검증해야 합니다.

  • 카카오에서 공개키 받는 방법
curl -v -X GET "https://kauth.kakao.com/.well-known/jwks.json"
HTTP/1.1 200 OK
{
    "keys": [
        {
            "kid": "3f96980381e451efad0d2ddd30e3d3",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw",
            "e": "AQAB"
        }, {
            "kid": "9f252dadd5f233f93d2fa528d12fea",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw",
            "e": "AQAB"
        }
    ]
}


n과 e 값을 받아 공개키를 만들 수 있으며, IdToken의 헤더 정보에 있는 kid를 사용하여 동일한 kid 값을 가진 공개키로 IdToken의 유효성을 검증해야 합니다. 또한, 공개키는 로그인 요청마다 필요하므로 지속적으로 유지해야 하는 정보입니다. 그러나 자주 요청을 보내면 차단될 수 있으므로, 공개키는 캐싱해야 합니다. (그러나 현재 캐싱 도구를 사용하지 않고 있습니다.)

@FeignClient(name = "kakao", url = "https://kauth.kakao.com")
public interface RequestKakaoOauthClient {

	//IdToken 받는 요청
  @PostMapping("/oauth/token?grant_type=authorization_code")
  KakaoTokenInfoResponse getToken(
      @RequestParam("client_id") String clientId,
      @RequestParam("redirect_uri") String redirectUri,
      @RequestParam("code") String code
  );

	//공갸키 요청
  @GetMapping("/.well-known/jwks.json")
  PublicKeysDto getPublicKeys();
}

서명 검증 전 페이로드 검증

ISS가 카카오인지 확인하고 AUD가 국민학교 애플리케이션 ID와 동일한지 확인합니다. 또한, 파싱하면서 토큰이 만료되었는지도 확인합니다. 마지막으로, DTO에 claims에 있는 사용자 이메일을 바인딩합니다.

  • setSigningKey
    • 역할 : JWT 토큰의 서명을 검증할 때 사용할 서명 키(비밀키 또는 공개키)를 설정
    • JWT 토큰이 생성될 때 서명된 키를 설정. HMAC 알고리즘의 경우 비밀키를 사용하고, RSA 알고리즘의 경우 공개키를 사용
  • requireIssuer
    • 역할 : JWT 토큰의 iss 클레임이 특정 값과 일치하는지 확인
    • 토큰의 발급자가 지정된 값과 일치하는지 확인. 발급자가 예상한 값과 다르면 예외가 발생
  • requireAudience
    • 역할 :JWT 토큰의 aud 클레임이 특정 값과 일치하는지 확인
    • 토큰의 대상 수신자가 지정된 값과 일치하는지 확인. 대상 수신자가 예상한 값과 다르면 예외가 발생
  • parseClaimsJws
    • 역할 : JWT 토큰을 파싱하고, 서명을 검증하며, 클레임을 추출
    • JWT 토큰 문자열을 입력으로 받아, 서명을 검증하고, 유효한 경우 클레임을 추출. 서명이 유효하지 않거나 토큰이 유효하지 않으면 예외 발생
  1. 서명 검증하기

    토큰의 헤더에서 kid를 가져오면, 카카오에 공개키를 요청하여 공개키 목록에서 토큰 헤더의 kid와 동일한 공개키를 찾아 서명 검증에 사용할 수 있습니다.

    공개키 목록에서 하나의 공개키를 선택한 후, 해당 공개키의 n과 e를 조합하여 공개키를 생성한 후 생성한 공개키로 검증을 수행하고, 검증이 완료되면 페이로드를 가져와 정보를 저장하거나 필요한 정보를 카카오 리소스 서버에 요청하면 됩니다.

    넘어오는 값은 base64로 인코딩되어 있어 디코딩 후 byte 배열로 변환해야 합니다. 이 byte 배열을 정수로 변환하는 작업을 수행해야 합니다. signum이 1인 경우는 양수를 의미하며, n이 큰 경우 BigInteger를 사용해야 합니다. 이와 같은 방법으로 인증된 idToken에서 바디 정보를 추출할 수 있습니다.


참고 자료

profile
기록기록기록기록기록

0개의 댓글