2015년 1월 22일 목요일

Google 서비스 계정 액세스토큰을 C/C++로 얻어내기

OAuth2에서 인증을 흔히 3단계 과정으로 얻어내는데, 이것을 "three-legged OAuth(줄여서 3LO)"라고 한다. 여기에서 반드시 사람이 OAuth를 제공하는 측 인증화면에 인증질을 하는게 보통이다. 그러나 서비스를 개발하면서 자동인증이 필요할 때가 있다. 서비스 감사 등의 부분에서 말이다. 이때 구글 OAuth2는 2LO를 지원한다. 단, 특수 계정이 필요하고, 지원 API가 한정적이다. (보안이슈 등)

참조: Using OAuth 2.0 for Server to Server Applications

위 문서에서 다른 건 필요 없고, 개발 콘솔에서 <서비스 계정>을 생성하고, P12(PKCS#12) 파일을 다운로드 받아놓는 것에 주목하자.

문서에 보면 OAuth 주소 어찌고 저찌고... 요청응답 순서는 이렇지만, 그러지 말고 구글에서 미리 만들어놓은 클라이언트 라이브러리 가져다 써라 어쩌라 되어 있다. 좋다. 구글은 미리 예쁘게 Java, Python, PHP 등으로 클라이언트 라이브러리를 짜놨다. 하지만 C/C++은 없더라.

그래서 구글이 만들어놓은 PHP 클라이언트 라이브러리 소스 뜯어 보면서 액세스 토큰을 C/C++로 얻어내보았다.

사용라이브러리는 아래와 같다.

그러나 예제 소스에는 거의 의사코드 수준으로만... ㅋㅋㅋ

Google OAuth2에 서비스 계정을 만드려면, JWT(Json Web Token, 발음 주의: 좃)이라는 포맷으로 요청을 해야한다.

JWT


참조: http://jwt.io/
(여기에서도 C/C++ 라이브러리는 찾아 볼 수가 없다)

영어 다시 해석하려면 어려우니까 미리 말을 남겨놔야겠다. ㅋㅋ JWT는 3개 구간이 있다.

  • Header - 사용 알고리즘 정의
  • Claim - OAuth에 전달할 값
  • Signature - Header + Claim을 비대칭키로 해시한다.
각 구간은 Base64(URL에 안전한 인코딩으로 패딩문자 없이...실은 별 상관 없지만)로 인코딩한다.

Header


{"alg":"RS256","typ":"JWT"}

  • alg: 서명 알고리즘. 구글은 RS256(RSA+SHA256)만 사용한다.
  • typ: 무조건 JWT이다. 궁금증을 갖지 말자. ㅋㅋㅋ
헤더는 언제나 고정문자열이니 Base64 인코딩 결과도 언제나 같다. 귀찮으면 아래 값을 사용하자.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

Claim

클레임 걸지 말자
{
  "aud": "https://accounts.google.com/o/oauth2/token",
  "scope": "https://picasaweb.google.com/data/",
  "iat": 1000,
  "exp": 4600,
  "iss": "1000-blarblar@developer.gserviceaccount.com",
  "sub": "myaccount@purewell.biz"
}

좀 많다... 맨 처음에 준 문서를 참조해도 좋지만, 나중에 영어 해석하기 귀찮으니까...

  • aud: 토큰을 사용할 URL
  • scope: 액세스 토큰을 사용할 수 있는 API 범위. 여러 URL을 지정할 수 있으며, 각 URL은 띄어쓰기(SPACE)로 구분한다.
  • iat: JWT을 만든 시간. UNIX시간(time() 함수로 얻을 수 있는 시간)
  • exp: 액세스 토큰 만료 시간. 구글은 최대 3600초를 주며, 역시 UNIX시간이니 time()+3600 정도로 하면 좋다.
  • iss: 서비스 계정(이메일형태). 개발 콘솔에서 얻을 수 있으며, @developer.gserviceaccount.com 으로 끝난다.
  • sub: (옵션) 액세스 할 실제 계정을 설정할 수 있다.
이것도 잘 만들어서 Base64 인코딩 해놓자.

Signature

어후 씨... 전자서명 짜잉나.
JWT를 잘 만든 녀석인지 검증하기 위해 위에서 정의한 알고리즘으로 서명하는 것이다. 서명을 하기 전에 Header와 Claim을 각각 Base64 인코딩하고 점(.)으로 연결한 문자열을 만든다.

Base64Encode([Header]).Base64Encode([Claim])


이것을 프로젝트 생성할 때 잘 받아줬던 P12파일에 보관한 비대칭키로 서명한다. 서명 결과는 키 크기나 알고리즘에 의존하지만, 이 문서를 작성하고 있는 시점에 128바이트가 나왔다.
나온 서명은 바이너리이며, 이것 역시 Base64로 예쁘게 인코딩한다.

최종

재료를 모두 준비했으면, 아래 형태로 잘 합치도록 하자.

Base64Encode([Header]).Base64Encode([Claim]).Base64Encode([Signature])


꽤 긴 문자열이 나오는데, 그것이 JWT이다.

코드

대충대충 코드를 만들어보자. (당연히 온전한 코드가 아니니 실망하자...응?)
특히 Base64 인코딩 함수는 각자 만들어 쓰자. (알려주기 귀찮다)


// 헤더 만들기
std::string
getHeader(void)
{
 Json::Value root;
 root["alg"] = "RS256";
 root["typ"] = "JWT";

 Json::FastWriter writer;
 auto str(writer.write(root));

 return Base64EncodeUrl(str);
}

// 클레임 만들기
std::string
getClaim(void)
{
 Json::Value root;
 root["aud"] = "https://accounts.google.com/o/oauth2/token";
 root["scope"] = "https://picasaweb.google.com/data/"; // 여기에 원하는 API 범위를 띄어쓰기로 구분해서 넣자.
 root["iat"] = Json::Int64(time(nullptr));
 root["exp"] = Json::Int64(time(nullptr) + 3600);
 root["iss"] = "1000-blarblar@developer.gserviceaccount.com";
 root["sub"] = "myaccount@purewell.biz";

 Json::FastWriter writer;
 auto str(writer.write(root));

 return Base64EncodeUrl(str);
}

대충 이렇게 만들고... 서명을 만드려면 키를 로드해야겠지...

EVP_PKEY*
loadPKCS12(const char* path, const char* passwd)
{
 EVP_PKEY* pkey(nullptr);
 PKCS12* pkcs(nullptr);

 do {
  auto in(BIO_new_file(path, "rb"));
  if ( not in ) return false;

  pkcs = d2i_PKCS12_bio(in, nullptr);


  BIO_free(in);
 } while (false);

 if ( not pkcs ) return false;

 int res( PKCS12_parse(pkcs, passwd, &pkey, nullptr, nullptr) );
 PKCS12_free(pkcs);

 // 나중에 EVP_PKEY*는 EVP_PKEY_free()로 정리하도록 하자.
 return pkey;
}

OpenSSL은 문서도 예제도 참 빈약하다. 그래서 매번 소스를 까보거나 테스트 코드 짜서 확인 해보곤 한다. 이 글에는 그러한 삽질을 뒷 세대들이 하지 않았으면 하는 작은 소망이 담겨 있다.

자자 이제 서명을 만들어보자구.


std::string
makeSign(const std::string& in, EVP_PKEY* pkey)
{
 EVP_MD_CTX ctx;
 EVP_MD_CTX_init(&ctx);
 EVP_DigestSignInit(&ctx, nullptr, EVP_sha256(), nullptr, pkey);
 EVP_DigestSignUpdate(&ctx, (unsigned char*)in.c_str(), in.size());

 size_t outlen(0);
 EVP_DigestSignFinal(&ctx, nullptr, &outlen);
 if ( outlen )
 {
  unsigned char out[outlen];
  EVP_DigestSignFinal(&ctx, out, outlen);
  EVP_MD_CTX_cleanup(&ctx);
  std::string str((char*)out, outlen);
  return Base64EncodeUrl(str);
 }

 EVP_MD_CTX_cleanup(&ctx);
 return std::string();
}

JWT를 만들어 보실까...

std::string
makeJWT(void)
{
 auto pkey(loadPKCS12("./myproject.p12", "notasecret"));
 auto header(getHeader());
 auto claim(getClaim());
 auto body(header + std::string(".") + claim);
 auto sign(makeSign(body, pkey));
 body += std::string(".") + sign;

 return body;
}

거의 다 왔다. 포기하지 말자!

이제 https://accounts.google.com/o/oauth2/token에 urlencoded로 요청한다. 요청은 cURL을 이용해서 POST로 호출한다. 소스는... 퇴근을 위해 생략한다. 다만 파라매터 설명은 빼먹지 않고...


  • grant_type: "어떤 형태로 권한을 요청할 것인가"인데, 문서에는 뭔가 길다란 문자열을 주어졌지만, PHP소스에는 당당하게 "assertion"이 주어진다.
  • assertion_type: "그럼 assertion은 어떤 형태인가?"인데, "http://oauth.net/grant_type/jwt/1.0/bearer"로 한다.
  • assertion: 아까 만든 JWT 문자열을 여기에 넣어준다.
그리고 힘차게 쿼리를 하면 아래와 같은 예쁜 결과를 받아 볼 수 있다.

HTTP/1.0 200 OK
Accept-Ranges: none
Alternate-Protocol: 443:quic,p=0.02
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Disposition: attachment; filename="json.txt"; filename*=UTF-8''json.txt
Content-Type: application/json; charset=utf-8
Date: Thu, 22 Jan 2015 09:37:01 GMT
Expires: Fri, 01 Jan 1990 00:00:00 GMT
Pragma: no-cache
Server: GSE
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
Content-Length: XXX

{
  "access_token" : "ya29.blarblarblar",
  "token_type" : "Bearer",
  "expires_in" : 3600
}

만세!!

access_token을 C/C++로 얻어냈다! ㅠㅠ 이제 API를 무자비하게 호출해주자~♡