현재 행운복권 프로젝트를 진행하면서 Google과 Kakao에 인증을 위임하기로 결정했다.

카카오 공식문서
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
기존에는 OAuth로 인증을 통해서 받은 AccesseToken을 통해서 사용자 정보를 가져온 뒤에 클라이언트로 전달하여 회원가입 시 닉네임, 전화번호 등 추가적인 정보를 저장할 수 있도록 하려 했다.

그렇지만 auth AccessToken은 인가(Resource에 대한 접근 권한)을 위한 토큰일 이기 때문에 인증의 증거로 간주하는 경우가 있지만 이 방법은 올바르지 않다고 생각했다. 그래서 관련 자료들을 찾아보았다.
간단히 요약해 보자면
OAuth는 애플리케이션에게 사용자가 어떻게 인증되었는지, 심지어 사용자가 아직 시스템에 있는지에 대한 정보를 제공하지 않는다.
OAuth 클라이언트는 토큰을 요청하고 받아서, 받은 토큰을 이용해 API에 접근한다. 이 과정에서 애플리케이션은 누가 승인을 했는지 알 수 없다.
앱이 사용자의 권한으로 특정 작업을 수행할 수 있도록 위임하는 데 초점을 맞추고 있다.
OAuth를 통해 발급받은 액세스 토큰을 사용하여 보호된 자원에 접근하는 것을 인증의 증거로 간주하는 경우가 있으나 잘못된 생각이다.
액세스 토큰은 꼭 인증이 이루어져야만 얻을 수 있는 것이 아니다.
이 외에도 액세스 토큰의 탈취 위험성
이러한 문제들을 해결하고자 OIDC를 적용하여 인증을 구현하고자 했다.
OpenID Connect(OIDC)는 사용자가 안전하게 로그인하는 데 사용할 수 있는 OAuth 2.0 기반의 표준 인증 프로토콜입니다.
구글, 페이스북, 카카오 등 OIDC를 지원한다면 모두 가능하다. ID 토큰은 서비스의 로그인 세션 대신 사용할 수 있는 JSON Web Token(이하 JWT) 형식의 토큰이다. 서비스는 ID 토큰에 포함된 사용자 인증 정보를 서비스에 활용하거나, ID 토큰의 유효성을 검증할 수 있다.
ID 토큰 페이로드 예시
{
"aud": "${APP_KEY}", --> 현재 행원복권의 app_id
"sub": "${USER_ID}",
"auth_time": 1661967952,
"iss": "https://kauth.kakao.com",
"exp": 1661967972,
"iat": 1661967952,
"nickname": "JordyTest",
"picture": "http://yyy.kakao.com/.../img_110x110.jpg",
"email": "jordy@kakao.com"
}
app_id의 정보(aud)를 JWT 토큰에서 얻을 수 있으므로, 행운 복권 애플리케이션으로 발급한 ID 토큰인 것을 알 수 있다.
로그인 세션 대신 사용할 수 있어 로그인과 회원가입 시 ID 토큰을 활용해서 oauth에 인증된 사용자임을 검증할 수 있다는 것이다.
카카오 OIDC 공식 문서
https://developers.kakao.com/docs/latest/ko/kakaologin/common#oidc
OpenID Connect를 사용하는 앱에서 카카오 로그인을 구현하는 과정은 다음 순서로 진행되어야 한다.
먼저 카카오 로그인의 OpenID Connect 서비스 제공자 설정을 확인합니다. 카카오 로그인은 OpenID Connect Discovery 표준 규격에 따라 서비스 제공자 설정을 담은 메타데이터(Metadata) 문서를 제공합니다.
현재 프로젝트에서 안드로이드에서 카카오 ID 토큰을 서버로 제공하지만 서버에서 테스트 및 예외 처리를 위해서 직접 ID 토큰을 받아오는 코드를 작성했다. 아래의 깃허브 프로젝트 링크를 참고하면 좋을 것 같습니다.


가장 중요한 부분이라고 생각한다. 서비스 보안을 위해 유효한 ID 토큰인지 검증하고 사용해야 한다.

-ID 토큰의 페이로드 부분 파싱(1)
private String removeSignatureFromToken(String token) {
int lastDotIndex = token.lastIndexOf(".");
if (lastDotIndex == -1) throw InvalidTokenException.EXCEPTION;
return token.substring(0, lastDotIndex + 1);
}
-페이로드 정보 확인(2,3,4,5)
private Jwt<Header, Claims> getRemovedSignatureParsedJwt(String token, String iss, String aud) {
try {
Jwt<Header, Claims> parsedJwt = Jwts.parserBuilder()
.requireAudience(aud)
.requireIssuer(iss)
.build()
.parseClaimsJwt(removeSignatureFromToken(token));
return parsedJwt;
} catch (ExpiredJwtException e) {
throw ExpiredTokenException.EXCEPTION;
} catch (Exception e) {
throw InvalidTokenException.EXCEPTION;
}
}

-OIDC: 공개키 목록 조회하기(1,2,3)
kakao 공개 키 형식
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"
}
]
}

현재 프로젝트에서 최근 복권 당첨 번호들을 가져오는 opne api를 FeignClient를 사용하고 있고 자체 로그인 플로우에서 Refreshtoken을 Redis에 저장하고 있기 때문에 공개키 목록을 Redis 캐싱 통해서 가져오도록 하였다.
@FeignClient(name = "KakaOidcClient", url = "https://kauth.kakao.com")
public interface KakaOidcClient {
@Cacheable(cacheNames = "KakaoOICD", cacheManager = "oidcKeyCacheManager")
@GetMapping("/.well-known/jwks.json")
OIDCResponse getKakaoOIDCKeys();
@Headers("Content-type: application/x-www-form-urlencoded;charset=utf-8")
@PostMapping(
"/oauth/token?grant_type=authorization_code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&code={CODE}")
OIDCResponse kakaoAuth(
@PathVariable("CLIENT_ID") String clientId,
@PathVariable("REDIRECT_URI") String redirectUri,
@PathVariable("CODE") String code);
}
-ID 토큰(JWT)을 공개키로 서명 검증(4)
ID 토큰 Header에서 kid 값 가져오기
public String getKidFromParsedJwtHeader(String token, String iss, String aud) {
String kid = (String) getRemovedSignatureParsedJwt(token, iss, aud).getHeader().get(KID);
return kid;
}
ID 토큰의 kid값과 공개키 kid값 비교
public OIDCDecodePayload extractPayloadFromIdToken(
String token, String iss, String aud, OIDCKeysResponse oidcPublicKeysResponse) {
String kid = retrieveKidFromParsedJwtIdToken(token, iss, aud);
Optional<OIDCKeyDto> matchedKeyOpt = oidcPublicKeysResponse.getKeys().stream()
.filter(o -> o.getKid().equals(kid))
.findFirst();
if (!matchedKeyOpt.isPresent()) {
throw NoSuchElementException.EXCEPTION;
}
OIDCKeyDto matchedKey = matchedKeyOpt.get();
return (OIDCDecodePayload)
jwtOIDCProvider.getOIDCTokenBody(token, matchedKey.getN(), matchedKey.getE());
}
KAKAO 공개키 목록에서의 kid 값과 ID 토큰안에 kid과 같은 공개키를 찾는다.
id 토큰에서 유저정보 가져오기
public OIDCDecodePayload getOIDCTokenBody(String token, String modulus, String exponent) {
Claims body = getOIDCTokenJws(token, modulus, exponent).getBody();
return new OIDCDecodePayload(
body.getIssuer(),
body.getAudience(),
body.getSubject(),
body.get("email", String.class),
body.get("profile",String.class));
}
서명 검증이 완료된 ID 토큰의 페이로드에 유저 정보를 가져온다.
ID 토큰(JWT) 서명 검증
public Jws<Claims> getOIDCTokenJws(String token, String modulus, String exponent) {
try {
return Jwts.parserBuilder()
.setSigningKey(getRSAPublicKey(modulus, exponent))
.build()
.parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw ExpiredTokenException.EXCEPTION;
} catch (NoSuchAlgorithmException e) {
throw RSAAlgorithmException.EXCEPTION;
}
catch (Exception e) {
log.error(e.toString());
throw InvalidTokenException.EXCEPTION;
}
}
공개키 안에 m와 e를 이용해 만든 RSA 공개키를 통해서 ID 토큰(jwt) 서명 검증 한다.
RSA 공개키 생성
private PublicKey getRSAPublicKey(String modulus, String exponent)
throws NoSuchAlgorithmException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
BigInteger n = new BigInteger(1, Base64.getUrlDecoder().decode(modulus));
BigInteger e = new BigInteger(1, Base64.getUrlDecoder().decode(exponent));
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(n,e);
return keyFactory.generatePublic(keySpec);
}
공개키 안에 m와 e를 이용하여 새로운 RSA 공개키를 생성한다. (디코딩 할때 getDecoder()를 사용했지만 에러가 발생했다. JWT 토큰을 생성하고 검증하는 과정에서는 일반적으로 URL-safe Base64 인코딩이 사용되기 때문에 Base64.getUrlDecoder()을 사용해야한다.)
OIDC로 행운 복권 프로젝트의 소셜 로그인 인증을 구현해 보았다. OAuth 에서 발급받은 AccessToken으로 인증을 대체할 때 문제점들을 알아보고 OpenID Connect를 통해서 나름 문제점들을 해결해 보았습니다. OIDC에 대한 정보가 많이 없었지만 카카오 공식문서와 잘 정된 블로그들을 참고하면서 적용할 수 있었습니다. 👍🏻
조금이라도 도움이 됐으면 좋겠습니다! 감사합니다.
프로젝트 깃허브 링크
https://github.com/Uttug-Seuja/luck-lottery-server
참고 자료
https://6991httam.medium.com/oauth%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-openid-8c46a65616e6
https://developers.kakao.com/docs/latest/ko/kakaologin/common#oidc
안녕하세요~ 글 잘 보았습니다. 궁금한게 CredentialController 코드에서 테스트용인 코드를 제외하고는 다 idToken을 파라미터로 받는것 같습니다. OIDC 로그인 중 임시코드를 리다이렉션 URL 뒤 파라미터로 같이 넘겨주고 이를 통해 Access Token과 idToken을 발급 받는걸로 압니다. 임시 코드를 발급 받는과정은 안보이는데 이는 프론트에서 실행한건가요?