두둥 프로젝트와 , 디프만 낙낙 프로젝트를 진행하면서 ,
회원가입하는 과정이 oauth 서버에서 인증뿐만아니라,
프로필 설정등의 중간 과정이 필요했었고, 이를 적절한 방법으로 구현하기 위해
oauth 스펙중 하나인 OIDC 를 사용해보기로 했다.
이글을통해서 직접 구현하는 방법을 공유하고자 한다.
목차
1. 문제점
1.1. Oauth AccessToken을 이용하는 여러가지 해결방안
1.2. Oauth AccessToken으로 회원가입 할 때의 문제점
2. Open ID Connect
3. 적용하기
3.1. 공개키 목록 조회하기 , feign으로 캐싱하기
3.2. ID 토큰 유효성 검증하기
3.2.1 서명 검증전 페이로드 검증
3.2.2 서명 검증
1. 문제점
oauth 인증 과정을 수행하게 되면, 보통 code 요청을 받게되면
바로 회원가입 처리를 하되, 상태값으로 실 가입전이라고 구분하는 방법을 많이 쓰는것 같다.
하지만 보통의 서비스에선 사실 oauth 인증후에 바로 회원가입으로 끝나는 어플은 보기 힘들다.
위 사진처럼 oauth로 카카오에서 정보를 가져오더라도 , 약관동의나 마케팅 수신여부 . 닉네임 프로필 사진등을 고르거나,
부족한 정보(주소지같은 부가정보)들을 oauth 인증 이후에 따로 채우고 있다.
1.1. Oauth AccessToken을 이용하는 여러가지 해결방안
oauth에 인증이 되어있다는 상태를 알려면
백엔드에서는
1. oauth 정보를 따로 저장하기
2. 실제 유저의 동의를 받고 회원가입을 한것은 아니지만, 유저관련데이타를 미리 생성하기
3. Oauth의 accessToken 으로 유저동의받고 회원가입 하기 ( 백엔드에서 Oauth accessToken 검증 )
4. registerToken jwt 를 따로만들어서 회원가입할 수 있는 토큰으로 oauth 인증 했다는 토큰 발급하기
등등의 방법으로 이문제를 풀 수 있을것 같다.
여러가지 구현방법은 있겠지만 보고서 드는생각은 깔끔하지 못한것같다.
1,2,3,4 번의 방법 모두다 결국 어떤형태로든 클라이언트한테 인증이 되었다는 상태를 검증할수 있는
쿠키든 토큰이든 클라이언트가 들고있다가 회원가입때 같이 보내줘야만 한다. ( 회원가입용 api를 따로 만들어야하니깐 )
1.2. Oauth AccessToken으로 회원가입 할 때의 문제점
3. Oauth의 accessToken 으로 유저동의받고 회원가입 하기 ( 백엔드에서 Oauth accessToken 검증 )
의 문제점을 확인해보자.
Oauth 인증을 통해서 나오는 accessToken,refreshToken 은
Oauth 리소스 서버 ( 카카오톡의 사진, 친구목록, 메시지 보내기 ) 등 에 쓰이는 토큰이고,
Oauth 인증 과정을 거쳐서 회원가입, 로그인을 한뒤에 자신의 서버에서 인증용 토큰을 발급해줘야한다.
즉 Oauth 인증과정을 수행후나오는 accessToken은 Oauth한 서버의 리소스에 접근하는 용도로 쓰이는 것이다.
GET/POST /v2/user/me HTTP/1.1
Host: kapi.kakao.com
Authorization: Bearer ${ACCESS_TOKEN}/KakaoAK ${APP_ADMIN_KEY}
Content-type: application/x-www-form-urlencoded;charset=utf-8
보통의 서버에서는 위 사용자 정보가져오기로
회원가입에 필요한 정보들을 얻어오면서 , 회원가입을 시킬것이다.
여기서 중요한점은 응답값에 Oauth AccessToken이 두둥에서 발급되었다는 사실을 확인을 할 수 없다는점이다.
오로지 해당 사용자에대한 프로필 정보만 내려온다.
그럼 발급된 AccessToken으로 사용자의 프로필 정보를 확인한 뒤에 회원가입을 시키는것은.
매우 잘못된 로직이다.
내가 만약 테스트 어플리케이션으로 카카오톡 디밸로퍼스에 두둥2 라고 만든뒤.
가입을 한뒤에 해당 AccessToken을 기존 두둥 서버에 보내도 회원가입이 된다.
그저 프로필 정보만 확인하므로.
curl -v -X GET "https://kapi.kakao.com/v1/user/access_token_info" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
그래서 따로 토큰 정보보기를 요청해서
HTTP/1.1 200 OK
{
"id":123456789,
"expires_in": 7199,
"app_id":1234
}
응답값으로 넘어온 app_id가 우리 두둥의 app_id 인지 이차로 확인하는 과정이 필요하다.
이런 형태가 다른 구글 같은 oauth에도 적용할 수 있을까?
토큰 정보보기같은 api는 카카오가 친절하게 만들어준것이지 Oauth의 스펙이 아니다.
정말 중요한 그림이라 가져왔다.
사실 Code 까지 받는건 클라이언트에서 진행해도된다.
어차피 Code는 사용자 브라우저에 url에 뜬다.
그러나 Step 2. 의 토큰 받기는 서버까지만의 그림으로 그려져있다.
AccessToken 을받아서 클라이언트한테 넘겨주고,
그 AccessToken 을 회원가입때 프로필 정보확인하며 회원가입시키는건
누구나 회원가입 Api로 다른 서비스에서 발급받은 AccessToken으로 회원가입 할 수 있다는점이다.
그래서 그림도 서버에서 토큰 받기로 되어있다.
즉 Oauth AccessToken은 인가(리소스 서버에 접근을 하기위한) 토큰일 뿐이다.
2. Open ID Connect ( OIDC )
위 사진의 Step 3. 사용자 로그인 처리의 OpenID Connect 사용시 ID 토큰 유효성 검증이라고 적혀져 있다.
OpenID Connect(OIDC)는 사용자가 안전하게 로그인하는 데 사용할 수 있는
OAuth 2.0 기반의 표준 인증 프로토콜입니다.
중요한점은 인증 프로토콜이다. 표준이라서 구글에서등 OIDC를 지원하는 곳이라면 다가능하다.
OIDC 의 토큰은 jwt 형태를 따른다. (AccessToken,RefreshToken 은 jwt 형식이 아니여도된다)
{
// 이 jwt 토큰을 받은사람
"iss": "https://kauth.kakao.com",
// 내앱키 , 두둥이면 230322(예시임) 이런식
"aud": "${APP_KEY}",
// 실제 유저의 고유 아이디 ( 카카오 유저의 고유 번호 )
"sub": "166959",
"iat": 1647183250,
"exp": 1647190450,
"nonce": "${NONCE}",
"auth_time": 1647183250
}
내용의 정보를 보면 위와같은 정보를 받을 수 있다.
발급한 곳의 정보와, 내 앱키의 정보를 jwt 토큰에서 얻을 수 있다.
내 앱키의 정보를 jwt 토큰에서 얻을 수 있으므로,
검증된 토큰이라면 내 두둥서버에서 등록한 어플리케이션으로
발급한 id_token이라는점을 알 수 있다.
즉 위와 같은 점 때문에 로그인 세션 유지로 쓸수 있다는 말이다.
세션 유지가 가능하다면 회원가입시에 추가 정보가 필요할경우,
IdToken(OIDC)을 활용해서 oauth에 인증된 사용자임을 검증 할 수 있다는 점이다.
jwt 에서 정보를 얻을 수 있다는 말은, header, payload 부분에 있다는 말이다.
즉 누구에게나 공개된 정보다. jwt.io 에가서 발급된 idToken을 넣어봐도 나오는 정보다.
그럼, 이 정보가 안전하게 인증된 정보라는것을 어떻게 알 수 있는가?
jwt 인증을 할때 내 시크릿키로 암호화 복호화하듯이
카카오 서버에서도 키를 준다. 근데 하나의 키가아니라 RSA 암호화 방식으로 공개키를 준다.
즉 jwt 암호화 할땐 비밀키로 암호화를 하고, 그정보를 인증되었는지 확인할땐 공개키로 복호화를 하는것이다.
1. 메타데이터 확인
2. 카카오 로그인 구현
3. ID 토큰 유효성 검증하기
바로 위링크에서는 구현 방법으로 위와같이 나뉘어져있고,
2. 카카오 로그인 구현은
설정에서 Open Id connect 를 허용해주고 각자의 서비스에서 잘 구현했다고 가정하고,
코드를 보내 토큰 발급요청 후에 AccessToken,RefreshToken,IdToken이
발급된다고 가정한 상태에서 구현방법을 설명하도록 하겠다.
3. 적용하기
두둥 프로젝트에서는 api 클라이언트로 feign을 사용중이다.
http템플릿써서 구현해도 캐싱방식에서만 구현의 편의성이 달라지지 다 똑같이 구현할 수 있다는 점 말씀드리고 싶다.
3.1. 공개키 목록 조회하기 , feign으로 캐싱하기
IdToken을 받았을 때에 , 인증되었는지에 대한 확인을 하길 위해선 공개키로 확인을 해야한다.
그 공개키를 받아오는 api는 아래 문서에서 확인가능하다.
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 를 받아서 공개키를 만들 수 있고,
kid 또한 중요하다.
공개키가 하나인것이아니라 IdToken의 헤더정보를 보면 kid 정보가 있으며
그 kid와 동일한 키값을 가진 공개키로 ID 토큰 유효성 검증을 해야한다.
또한 공개키는 로그인요청마다 필요하므로, 계속 가지고 있어야되는 정보인데 자주보내면 요청이 차단된다고 한다.
//KakaoOauthClient
@FeignClient(
name = "KakaoAuthClient",
url = "https://kauth.kakao.com",
configuration = KakaoKauthConfig.class)
public interface KakaoOauthClient {
@Cacheable(cacheNames = "KakaoOICD", cacheManager = "oidcCacheManager")
@GetMapping("/.well-known/jwks.json")
OIDCPublicKeysResponse getKakaoOIDCOpenKeys();
}
//RedisCacheConfig
@Bean
public CacheManager oidcCacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
// TTL 일주일로 설정
.entryTtl(Duration.ofDays(7L));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
위처럼 feign 클라이언트요청에 캐싱을 해버릴 수 있다. 얼마나 간편한가? 한번에 끝나버렸다.
위와같은 방식으로 공개키 목록을 로그인 요청시에 레디시 캐시저장소에서 꺼내올 수 있는 환경을 구성했다.
3.2. ID 토큰 유효성 검증하기
3.2.1 서명 검증전 페이로드 검증
카카오 토큰 유효성 검증하기에서 안내하는 방식이다.
1. ID 토큰의 영역 구분자인 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리
2. 페이로드를 Base64 방식으로 디코딩
3. 페이로드의 iss 값이 https://kauth.kakao.com와 일치하는지 확인
4. 페이로드의 aud 값이 서비스 앱 키와 일치하는지 확인
5. 페이로드의 exp 값이 현재 UNIX 타임스탬프(Timestamp)보다 큰 값인지 확인(ID 토큰이 만료되지 않았는지 확인)
6. 페이로드의 nonce 값이 카카오 로그인 요청 시 전달한 값과 일치하는지 확인
7. 서명 검증
# 서명 검증은 다음 순서로 진행합니다.
1. 헤더를 Base64 방식으로 디코딩
2. OIDC: 공개키 목록 조회하기를 통해 카카오 인증 서버가 서명 시 사용하는 공개키 목록 조회
3. 공개키 목록에서 헤더의 kid에 해당하는 공개키 값 확인
# 공개키는 일정 기간 캐싱(Caching)하여 사용할 것을 권장하며, 지나치게 빈번한 요청 시 요청이 차단될 수 있으므로 유의
4. JWT 서명 검증을 지원하는 라이브러리를 사용해 공개키로 서명 검증
이중에서 6번 nonce 관련은 구현하지 않았다. 참고바란다.
1~5번은 한번에 처리가 가능하다.
1. 우선은 인증전에 누구나 다 얻을 수 있는 payload를 가져온다.
//JwtOICDProvider
private String getUnsignedToken(String token) {
String[] splitToken = token.split("\\.");
if (splitToken.length != 3) throw InvalidTokenException.EXCEPTION;
return splitToken[0] + "." + splitToken[1] + ".";
}
위 이슈에서 가져온 코드다.
Header.body.VerifySignature
세부분에서 머리랑 몸통만 가져온다.
- 2,3,4,5 번
// JwtOIDCProvider
private Jwt<Header, Claims> getUnsignedTokenClaims(String token, String iss, String aud) {
try {
return Jwts.parserBuilder()
.requireAudience(aud) //aud(두둥 카카오톡 어플리케이션 아이디) 가 같은지 확인
.requireIssuer(iss)//iss(이슈어)가 카카오인지 확인
.build()
.parseClaimsJwt(getUnsignedToken(token));
} catch (ExpiredJwtException e) { //파싱하면서 만료된 토큰인지 확인.
throw ExpiredTokenException.EXCEPTION;
} catch (Exception e) {
log.error(e.toString());
throw InvalidTokenException.EXCEPTION;
}
}
정말 한번에 처리가 되지않는가?
- iss(이슈어)가 카카오인지 확인
- aud(두둥 카카오톡 어플리케이션 아이디) 가 같은지 확인
- 파싱하면서 만료된 토큰인지 확인.
2,3,4,5번이 한번에 끝나버렸다.
3.2.2 서명 검증하기
우린 이제 Header와 payload (머리와 몸통 )에 해당하는 정보를 인증이 되지 않은 토큰에서 얻어올 수 있다.
인증이 되지않은 토큰에서 얻을 정보는 지금은 하나다.
공개키 목록 에서 쓸 kid를 가져오는 것이다.
// JwtOIDCProvider
private final String KID = "kid";
public String getKidFromUnsignedTokenHeader(String token, String iss, String aud) {
return (String) getUnsignedTokenClaims(token, iss, aud).getHeader().get(KID);
}
kid를 가져오게되면 서명 검증에 어떤 공개키를 써야할지 결정할 수 있다.
// kakao Oauth helper
public OIDCDecodePayload getOIDCDecodePayload(String token) {
// 공개키 목록을 조회한다. 캐싱이 되어있다.
OIDCPublicKeysResponse oidcPublicKeysResponse = kakaoOauthClient.getKakaoOIDCOpenKeys();
return oauthOIDCHelper.getPayloadFromIdToken(
//idToken
token,
// iss 와 대응되는 값
oauthProperties.getKakaoBaseUrl(),
// aud 와 대응되는값
oauthProperties.getKakaoAppId(),
// 공개키 목록
oidcPublicKeysResponse);
}
@Helper
@RequiredArgsConstructor
public class OauthOIDCHelper {
private final JwtOIDCProvider jwtOIDCProvider;
// OauthOIDC는 스펙이기때문에 OauthOIDCHelper 하나로 카카오,구글 다 대응 가능하다.
// KakaoOauthHelper 등에서 아래 소스들을 사용한다.
// kid를 토큰에서 가져온다.
private String getKidFromUnsignedIdToken(String token, String iss, String aud) {
return jwtOIDCProvider.getKidFromUnsignedTokenHeader(token, iss, aud);
}
public OIDCDecodePayload getPayloadFromIdToken(
String token, String iss, String aud, OIDCPublicKeysResponse oidcPublicKeysResponse) {
String kid = getKidFromUnsignedIdToken(token, iss, aud);
// KakaoOauthHelper 에서 공개키를 조회했고 해당 디티오를 넘겨준다.
OIDCPublicKeyDto oidcPublicKeyDto =
oidcPublicKeysResponse.getKeys().stream()
// 같은 kid를 가져온다.
.filter(o -> o.getKid().equals(kid))
.findFirst()
.orElseThrow();
// 검증이 된 토큰에서 바디를 꺼내온다.
return jwtOIDCProvider.getOIDCTokenBody(
token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE());
}
}
// JwtOIDCProvider
// 공개키로 토큰 검증을 시도한다.
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 (Exception e) {
log.error(e.toString());
throw InvalidTokenException.EXCEPTION;
}
}
// OIDCDecodePayload 를 가져온다. 스펙이라 공통으로 사용할 수 있다.
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));
}
앞써 비검증된 토큰에서 header정보와 payload 정보를 가져올 수 있었다.
Header 정보에서 kid 를 가져와 공개키를 어느키에 매칭시킬지 구한다.
공개키 목록에서 검증을 진행할 공개키 하나를 구한뒤에,
n, e를 조합해서 공개키를 직접만들어야한다.
만든 공개키로 검증을 진행한뒤에 페이로드를 가져오고나서
정보를 저장하던, 필요한 정보를 카카오 리소스 서버에 더 요청하든 하면된다.
// JwtOIDCProvider
// 제일 핵심이 되는 소스이다 n ,e 값으로 Rsa 퍼블릭 키를 연산 할 수 있다.
// 진짜.. 힘들게 만든 소스다..잘 쓰시길 바란다..
private Key getRSAPublicKey(String modulus, String exponent)
throws NoSuchAlgorithmException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] decodeN = Base64.getUrlDecoder().decode(modulus);
byte[] decodeE = Base64.getUrlDecoder().decode(exponent);
BigInteger n = new BigInteger(1, decodeN);
BigInteger e = new BigInteger(1, decodeE);
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(n, e);
return keyFactory.generatePublic(keySpec);
}
위소스는 넘겨온 n , e 로 공개키를 구하는 방식이다.
base64로 인코딩 된 값이 넘어오기때문에 디코드를 하면서 byte 어레이를 구하고
바이트에서 정수로 변환하는 작업을 거쳐야한다. n 숫자가 큰편이니 BigInteger를 써야한다.
signum 1 은그냥 양수라는 뜻이다.
이렇게 해서 인증된 idToken에서 바디 정보를 빼올 수 있다.
그 뒤에 처리는 자유롭게 하시면될것 같다.
이렇게 IdToken을 활용해서 로그인 세션 유지를 하는 방법을 알아보았다.
중요한점은
Oauth 의 accessToken은 리소스서버에 인가를 위한 것이고,
IdToken은 인증을 위한것이며,
두둥에서는 회원가입 시에 약관 동의를 사용자에게 구하기위해서
Rsa 공개키방식으로 토큰 검증을 할 수 있는 idToken을 활용하여
실 회원가입전 id token을 활용해서 oauth에 인증된 사용자임을 검증 하고 있다.
위 소스들은 두둥 프로젝트에서 참고 가능하다.
아마 위 예시 소스로는 복잡해서, 구현하기 힘드실것같다..
내용보고 소스 레포지토리 가셔서 참고하시는게 더 좋을것같다.
찾아봐도 OIDC 를 직접 구현한 자료가 없어서
카카오 문서보면서 일일히 구현을 했다.
다행이도 캐싱과같은건 feign클라이언트를 쓰고있어서 편하게 했고,
복잡했던 과정들이 한방에 처리되는 경우도 있어서 어렵지 않게 구현을했다. 하루쯤 걸렸던것같다.
카카오의 경우 aud(앱키)가 앱환경에서는 해당 네이티브 앱 키로 나온다.
낙낙은 어플리케이션이였는데 배포하고 나서 알았다...!
웹 환경과 앱 환경 둘다 제공하는 환경이라면 네이티브 앱 키도 꼭 대응하시길 바란다.
'스프링' 카테고리의 다른 글
[스프링] spring redisson 분산락 Aop 적용기 (0) | 2023.03.08 |
---|---|
[스프링] 멀티모듈 jacoco , sonarqube (cloud) 세팅 (2) | 2023.03.08 |
[스프링] spring feign client wiremock test (1) | 2023.03.07 |
[스프링] spring batch 도커로 세팅하기 with jenkins (0) | 2023.03.07 |
[스프링] spring thymeleaf to pdf 이미지,한글 적용하기 (1) | 2023.03.06 |