📕 목차
1. iOS Kakao OAuth2 인증 정책 리스트
2. Security Policy
3. Implementation
1. iOS Kakao OAuth2 인증 정책 리스트
📌 Introduce
iOS 앱에서 Kakao OAuth2를 사용하려고 했는데,
웹 프론트와 연동할 때와 마찬가지로 code를 이용해서 OAuth2 인증 과정을 수행하려고 보니 이 방식으로는 구현할 수가 없다는 문제가 있었다.
네이티브 앱 환경에선 대부분 accessToken까지 프론트에서 강제로 받아와 버린다.
그래서 iOS와 Kakao OAuth2 인증을 위한 여러가지 대안책들을 고민해보고, 최종 선정한 정책으로 구현하는 과정을 기록해둔다.
1️⃣ iOS에서 code를 Server로 전달
- 웹 프론트와 협업할 때 가장 일반적인 Flow
- Client에서 authorization code까지 발급받고 Server로 양도하는 방법
- Web Front와 OAuth2 인증 과정을 거친다면, 가장 적합한 방법
- 만약 별도의 약관동의, 마케딩 수신여부 등의 부가 작업이 필요하다면 구현은 가능하겠지만, DB 무결성 조건을 위배하는 등의 부적절한 로직을 수반해야 할 수도 있다.
- iOS에서 Kakao OAuth2 인증을 하면 KakaoSDKUser가 authorization code 요청부터 access token까지 한 번에 처리해버린다.
- OAuth2 스펙인 PKCE 보안으로인해 authorization code를 다른데서 사용할 수 없다.
- PKCE(Proof Key for Code Change): Flow에서 인가 과정 중 인증 코드 가로채기 공격(authorization code interception attack)을 방어하기 위한 방법
- 해당 블로그에서 재밌는 내용을 봤는데, 나중에 Web 환경에서도 자체 PKCE를 구축해볼 생각이다.
- iOS 네이티브 앱 개발 방식이라면 이 방법을 시도할 수가 없다.
- code를 받기 위해 redirect 해야 하는데, iOS는 localhost가 아니고 가상 애뮬레이터 동작하므로 일반적인 방법으로 주소를 잡아줄 수 없다.
- 애초에 OAuth2 서비스 등록할 때 iOS 용도로 선택하면 redirection url을 물어보지도 않기도 하지만.
2️⃣ access token을 Server로 전달
- Client가 SDK로 access token까지 모두 발급받고, 해당 access token을 Server로 전달
- access token을 전달하는 것은 보안상 매우 위험하므로 지양해야 한다.
- access token은 인증된 사용자 정보를 가져오기 뿐 아니라, 메시지 보내기 등의 Resource Server 자원에도 사용된다. (탈취당하면 노답)
3️⃣ 사용자 정보를 Server로 전달
- 카카오 OAuth 인증을 모두 SDK에게 일임하는 방법 (모바일 표준)
- 그렇다면 관건은 로그인을 요청한 유저가 정말 유효한 정보를 보낸 건지 확인해야 한다.
- 유저 정보만으로는 사용자가 Kakao 인증을 거친 후에 요청을 보낸 건지, 대충 아무 값이나 보낸 건지 알 수 없다.
- access token을 받아서 유효한 사용자 정보인지 알아내야 하지만, (2)에서 보안 상 위험으로 배제했다.
- 문제는 access token으로 유효성 검사를 못 하면, 다른 서비스에서 access token 요청해서 사용자 정보를 받고 내 서비스에 전달해도 알 방도가 없다.
- 문서에도 Access token의 역할은 자원(Resource)에 대한 접근 권한을 획득하는 것이지, 그게 꼭 내 서비스에서 발급받은 access token이라는 걸 검증해주겠다는 언급은 없다..
- access token 정보의 app_id가 내 서비스의 app_id와 일치하는 지 유효성 검사를 해야 한다.
- 그걸 Client가 직접 조회해서 보내주면, 결국 조작 가능한 거 아닌가? 이 방법은 안 된다.
- 결국 iOS의 SDK에 모든 걸 일임해서, 마지막에 달랑 사용자 정보만을 받는 건 안 된다.
- 결론적으로 말하자면 OAuth2 프로토콜인 OIDC(Open ID Connect)를 사용할 것이다.
4️⃣ redirect url을 Server로 지정
- redirection url을 server로 지정하는 방법
- 문제는 iOS에서 redirection url을 안 받으면 본래 앱으로 돌아갈 수 있는가?
- 이건 그래도 어떻게 하려면 할 수는 있을 것 같다.
- 그렇다면, Server가 User 정보를 조회하고 token을 발급해줄 때 어떻게 다시 돌려줄 것인가?
- OAuth2 인증 전에 Client가 IP와 provider id를 넘겨줄 수는 있다.
- 가능할 지는 모르겠지만 된다 치면, Server가 Client를 강제로 redirect 시켜야 한다.
- 그런데 3XX 응답에는 Body 정보가 없으므로 access token을 header로 넘겨야 하는데, 보안에 문제가 되지 않을까? (ex. [Redirect할 Front URL]?accessToken={로그인 후 발급할 access token})
- 애초에 Android, iOS에 Redirect를 시킨다는 개념이 말이 되긴하나?
- 가정법이 굉장히 많이 필요하며, 불가능하다고 판단.
- 애초에 Web이라고 하더라도 REST API가 Front를 강제로 Redirect 시킨다는 방식부터 별로 마음에 안 드는 설계 방식.
2. Security Policy
📌 Introduce
결론적으로 SDK에게 OAuth 인증 과정을 일임하기로 했다.
하지만 누가 개발자라면 비단 의심증, 강박증, 의처증을 가지고 Client의 모든 요청에 의구심을 품으라 하지 않았던가.
따라서 Client의 정보가 유효한 지를 확인하기 위해 고민한 내용들을 정리하였다.
📌 User Info만 전달
- 유효한 사용자 정보인지 Server에서 알 방도가 없음
- 다른 서비스에서 인증받은 kakao oauth access token으로 사용자 정보를 가져온 것일 수도 있음.
- 즉, 유저 정보는 Server가 알아서 다시 조회하게 하고, Client는 "누가" 로그인 하려는 건지만 추가로 알려줘야 함.
📌 Access Token을 전달
- 위에서도 여러번 언급했듯, Resource Server의 직접 접근 권한을 갖는 access token 전달은 위험하다.
📌 OIDC(Open ID Connect) 전달
- 인가를 위한 access token과 달리, open id는 인증의 목적만을 갖는다. 즉, 탈취당한다고 해서 큰 리스크가 없다.
- Server 입장에선 OIDC 기반 사용자 정보 조회와 id 유효성 검사가 가능해진다.
📝 필요한 작업
- Client로 부터 로그인을 요청한 {user_id, id_token, 단말정보} 수신
- Kakao 공개키 목록 조회
- `GET /.well-known/jwks/json`
- Caching해서 빈번한 요청 조회 제한
- open id 유효성 검증
- ID 토큰 영역 구분자인 온점(.) 기준으로 header, payload, signature 분리
- payload를 Base64 방식으로 Decoding
- iss: `https://kauth.kakao.com`과 일치 여부 판단
- aud: service app key와 일치 여부 판단
- exp: 현재 UNIX Timestampe보다 큰 값인지 확인 (만료 여부)
- nonce: kakao login 요청 시 전달한 값과 일치하는 지 확인
- 서명 검증
- header를 Base64 방식으로 Decoding
- OIDC 공개키로 kakao 인증 서버가 서명 시 사용하는 공개키 목록 조회 (Cache miss 시)
- 공개키 목록에서 header의 kid에 해당하는 공개키 값 확인
- JWT 서명 검증 지원 라이브러리를 사용해 공개키로 서명 검증
- OICD 기반 사용자 정보 조회
- 로그인을 요청한 user_id와 id_token로 조회한 user_id의 일치 여부 판단
- DB 상에 해당 user_id 존재 여부 판단
- 존재하면 로그인 과정 (access token, refresh token 응답 돌려주고 종료)
- 존재하지 않으면 회원가입 과정 (추가 정보 입력 단계로)
- 회원가입 요청
- 기존에 조회한 사용자 정보 혹은 id_token 정보 유지
- id_token만 유지하면 귀찮은 파싱 작업을 다시 해야할 거고, 사용자 정보를 통채로 저장해두면 공간을 많이 차지함. (trade-off 고려해서 선택)
- 회원가입을 하려는 유저 정보를 유지해야 하므로, 검증이 끝난 id_token 자체를 이후 유효성 검증 key로 사용하는 게 좋을 것 같음
- DB에 사용자 저장하면 무결성이 깨지므로, redis에 임시로 저장하는 것이 낫다.
- (나라면) Redis에 {key, value} = {id_token, user_id}를 저장해두고, 추가 사용자 정보를 입력받을 것 같다.
- 다만, 이 방법에서도 문제점이 있을 수 있으므로 추후 보안 검토가 필요하다.
- 사용자 정보만 인코딩하고 값으로 넣어놓으려 했으나, 이미 검증된 id_token 다시 파싱하는 거나 무슨 차이가 있을까 싶다.
- 기존에 조회한 사용자 정보 혹은 id_token 정보 유지
3. Implementation
📌 Introduce
모든 설계는 끝났으니 구현만 하면 된다.
참고로 나는 OAuth 인증 후에 Spring Boot Server 자체 jwt를 Client로 내려줄 것이다.
(이 코드도 올려야 하는데, 학기 중에 개발이랑 개인 공부에 스터디, 멘토링까지 한다고 포스팅 할 시간이 없었다.)
📌 Design
내가 만들어야 할 것들이 무엇인지 task를 식별해보자.
1️⃣ Client로 부터 id_token, id, provider 받기
- Controller, Request Dto 정의
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth/oauth")
@Slf4j
public class OAuthApi {
private final OAuthService oAuthService;
@PostMapping("")
@PreAuthorize("isAnonymous()")
public void signIn(
@RequestParam("provider") ProviderType provider,
@RequestBody @Valid OauthSignInReq req
) {
if (ProviderType.NAVER.equals(provider)) {
} else {
}
}
@PostMapping("/{id}")
@PreAuthorize("isAnonymous()")
public void signUp(
@PathVariable("id") String id,
@RequestParam("provider") ProviderType provider,
@RequestBody @Valid OauthSignUpReq req
) {
if (ProviderType.NAVER.equals(provider)) {
} else {
}
}
}
이런 느낌이 되지 않을까.
찾아보니 Kakao, Apple, Google은 OIDC 방식을 지원하는데, Naver는 없다고 해서 따로 분리했다.
가능하다면 code를 전달받는 방식을 채택하지 싶은데, 아예 API를 분리해야 할 수도 있다. (안 된단다. 또 다른 방법 찾아야 된다 ^^)
2️⃣ id_token 유효성 검사
- 유효한 id_token인가?
- 내 서비스에서 들어온 요청이 맞는가?
- 유효성 검증을 위한 단계는 위에서 언급한 절차를 따른다.
🟡 카카오 인증 서버에서 Public Key 목록 조회
- GET `https://kauth.kakao.com/.well-known/jwks.json`
이름 | 타입 | 설명 | 필수 |
keys | JWK[] | 공개키 목록을 담은 JSON Web Key(JWK) 배열 | ✅ |
🟡 JWK
이름 | 타입 | 설명 | 필수 |
kid | String | 공개키 ID | ✅ |
kty | String | 공개키 타입, RSA 고정 | ✅ |
alg | String | 암호화 알고리즘 | ✅ |
use | String | 공개키 용도, signature 고정 | ✅ |
n | String | 공개키 모듈, 공개키는 n과 e의 쌍 | ✅ |
e | String | 공개키 지수, 공개키는 n과 e의 쌍 | ✅ |
curl -v -G GET "https://kauth.kakao.com/.well-known/jwks.json"
🟡 ID Token 정보
구분 | 설명 |
Header | ID 토큰 규격 정보 • alg: ID Token에 적용된 암호화 방식, RS256 고정 • typ: ID Token 형식, JWT 고정 • kid: ID Token 암호화 시 사용된 Public key ID |
Payload | 사용자 인증 정보 • iss: ID Token을 발급한 인증기관 정보, https://kauth.kakao.com고정 • aud: ID Token이 발급된 앱의 앱 키 • sub: ID Token에 해당하는 사용자 회원 번호 • iat: ID 토큰 발급 또는 갱신 시각 • auth_time: 사용자가 카카오 로그인을 통해 인증을 완료한 시각 • exp: 만료 시간 • nonce: 카카오 로그인 요청 시 전달받은 임의의 문자열 (ID 토큰 재생 공격 방지) • nickname: 닉네임 • picture: 프로필 사진 • email: 이메일 nickname, picture, email은 동의항목에서 사용자 동의 필요 |
Signature | 카카오 인증 서버가 kid에 해당하는 공개키로 서명한 값 RS256 방식으로 암호화된 값, ID Token 유효성 검증 시 서명 |
3️⃣ DB 조회
- 해당 {id, provider}가 이미 DB에 존재하는 User라면 로그인 (종료)
- 존재하지 않는 유저라면 회원가입 단계로 이동
- id_token과 user_id를 redis에 임시 저장
- 추가 유저 정보 요청
4️⃣ 회원가입 진행
- Client로부터 회원가입을 위해 필요한 추가 정보 수신
- 이름
- 닉네임 ✅
- 전화번호
- 이메일 ✅
- 프로필 이미지 ✅
- id_token 파싱해서 기존 정보 조회 후 DB 반영
📌 OIDC Public Key
위 블로그의 도움을 엄청나게 많이 받았다.
진짜 똑똑하신 분들..👍
다만, 해당 블로그에서 RestTemplate나 HttpClient가 아닌 Netflix에서 내놓은 오픈 소스인 Feign 방식을 적용해두었다.
HttpClient 방식으로 바꿔볼까 하다가, Feign에 대해서 찾아보니 재밌어 보여서 사용해보기로 했다.
나중에 정리도 해볼 예정. 토스에서 트러블 슈팅한 이야기도 흥미로웠다.
🟡 공개키 가져오는 API
@FeignClient(
name = "KakaoOauthClient",
url = "${oauth2.client.provider.kakao.authorization-uri}",
configuration = KakaoOauthConfig.class
)
public interface KakaoOauthClient extends OauthClient {
@Override
@Cacheable(value = "KakaoOauth", cacheManager = "oidcCacheManger")
@GetMapping("/.well-knowm/jwks.json")
OIDCPublicKeyResponse getOIDCPublicKey();
}
@Getter
@NoArgsConstructor
public class OIDCPublicKeyResponse {
List<OIDCPublicKey> keys;
}
@Configuration
@EnableRedisRepositories
@EnableCaching
public class RedisConfig {
...
@Bean
@OidcCacheManager
public CacheManager oidcCacheManger(RedisConnectionFactory cf) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()
))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
))
.entryTtl(Duration.ofDays(3));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(config).build();
}
}
- 공개키 자주 요청할 거 같으면, 캐싱해두라고 주의 사항에 적혀있으니 @Cacheable을 걸어줬다.
- 기존에 사용하던 CacheManager 때문에 Bean이 중복되는 바람에 @Qualifier 어노테이션을 커스텀한 것이다. (@Primay, @Qualifier에 대해 구글링 해보면 많이 나온다.)
- 공식 문서에 반환 타입이 Key List라고 했으니, List 타입으로 받아온다.
🟡 ID 토큰 유효성 검사
1. ID 토큰의 영역 구분자인 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리
2. 페이로드를 Base64 방식으로 디코딩
3. 페이로드의 iss 값이 https://kauth.kakao.com와 일치하는지 확인
4. 페이로드의 aud 값이 서비스 앱 키와 일치하는지 확인
5. 페이로드의 exp 값이 현재 UNIX 타임스탬프(Timestamp)보다 큰 값인지 확인(ID 토큰이 만료되지 않았는지 확인)
6. 페이로드의 nonce 값이 카카오 로그인 요청 시 전달한 값과 일치하는지 확인
7. 서명 검증
public interface OauthOIDCProvider {
/**
* ID Token의 header에서 kid를 추출하는 메서드
* @param token : idToken
* @param iss : ID Token을 발급한 OAuth 2.0 제공자의 URL
* @param aud : ID Token이 발급된 앱의 앱 키
* @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열
* @return kid : ID Token의 서명에 사용된 공개키의 ID
*/
String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce);
/**
* ID Token의 payload를 추출하는 메서드
* @param token : idToken
* @param modulus : 공개키 모듈(n)
* @param exponent : 공개키 지수(e)
* @return OIDCDecodePayload : ID Token의 payload
*/
OIDCDecodePayload getOIDCTokenBody(String token, String modulus, String exponent);
}
- 인터페이스는 위처럼 구현했다. (어차피 블로그에서 죄다 참조해온 코드다.)
@Override
public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) {
return (String) getUnsignedTokenClaims(token, iss, aud, nonce).getHeader().get(KID);
}
/**
* ID Token의 header와 body를 디코딩하는 메서드 <br/>
* payload의 iss, aud, exp, nonce를 검증하고, 실패시 예외 처리
*/
private Jwt<Header, Claims> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) {
try {
return Jwts.parserBuilder()
.requireAudience(aud) // aud 검증 (app id)
.requireIssuer(iss) // iss 검증 (카카오)
.require("nonce", nonce) // nonce 검증
.build()
.parseClaimsJwt(getUnsignedToken(token));
} catch (JwtException e) { // 이것저것 Jwt 관련 에러 처리
final AuthErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, AuthErrorCode.FAILED_AUTHENTICATION);
log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage());
throw new AuthErrorException(errorCode, e.toString());
}
}
/**
* 1번 Token의 signature를 제거하는 메서드 (header, payload만 분리)
*/
private String getUnsignedToken(String token){
String[] splitToken = token.split("\\.");
if (splitToken.length != 3) throw new AuthErrorException(AuthErrorCode.INVALID_TOKEN, "Invalid token");
return splitToken[0] + "." + splitToken[1] + ".";
}
- 나는 try-catch문이 많은 게 보기 싫어서, 전에 만들어둔 JwtErrorCodeUtil을 썼을 뿐..그냥 늘어놓아도 상관없다.
- 위 로직으로 2, 3, 4, 5, 6 모두 처리했다.
- 최종적으로 ID Token 암호화 시 사용된 공개키 ID만을 추출할 수 있다.
🟡 서명 검증
1. 헤더를 Base64 방식으로 디코딩
2. OIDC: 공개키 목록 조회하기를 통해 카카오 인증 서버가 서명 시 사용하는 공개키 목록 조회 → 처리함
3. 공개키 목록에서 헤더의 kid에 해당하는 공개키 값 확인
4. JWT 서명 검증을 지원하는 라이브러리를 사용해 공개키로 서명 검증
@Override
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));
}
/**
* 공개키로 서명을 검증하는 메서드
*/
private Jws<Claims> getOIDCTokenJws(String token, String modulus, String exponent) {
try {
return Jwts.parserBuilder()
.setSigningKey(getRSAPublicKey(modulus, exponent))
.build()
.parseClaimsJws(token);
} catch (JwtException e) {
final AuthErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, AuthErrorCode.FAILED_AUTHENTICATION);
log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage());
throw new AuthErrorException(errorCode, e.toString());
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
log.warn("Error - {}, {}", e.getClass(), e.getMessage());
throw new AuthErrorException(AuthErrorCode.INVALID_TOKEN, e.toString());
}
}
/**
* n, e 조합으로 공개키를 생성하는 메서드
*/
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 publicKeySpec = new RSAPublicKeySpec(n, e);
return keyFactory.generatePublic(publicKeySpec);
}
- getRSAPublicKey를 사용해서 n, e 조합의 공개키를 생성한다.
- 생성된 공개키를 통해 서명을 검증하여 getOIDCTokenJws 메서드에서 Jws를 파싱한다.
- 파싱한 Jws Body를 통해서 Payload의 값을 추출해낼 수 있다.
🟡 Helper 클래스
@Component
@RequiredArgsConstructor
public class OauthOIDCHelper {
private final OauthOIDCProvider oauthOIDCProvider;
/**
* ID Token의 payload를 추출하는 메서드 <br/>
* OAuth 2.0 spec에 따라 ID Token의 유효성 검사 수행 <br/>
* @param token : idToken
* @param iss : ID Token을 발급한 provider의 URL
* @param aud : ID Token이 발급된 앱의 앱 키
* @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열
* @param response : 공개키 목록
* @return OIDCDecodePayload : ID Token의 payload
*/
public OIDCDecodePayload getPayloadFromIdToken(String token, String iss, String aud, String nonce, OIDCPublicKeyResponse response) {
String kid = getKidFromUnsignedIdToken(token, iss, aud, nonce);
OIDCPublicKey key = response.getKeys().stream()
.filter(k -> k.kid().equals(kid))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No matching key found"));
return oauthOIDCProvider.getOIDCTokenBody(token, key.n(), key.e());
}
private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) {
return oauthOIDCProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce);
}
}
- 이 코드를 만든 사람이 진짜 공부를 많이 하셨구나 싶었던 부분, 앞서 만든 Provider를 바로 사용하는 게 아니라 Helper Class 내부에서 모두 처리한 후, 최종적으로 Decode된 Paload만 추출해낸다.
- kid가 일치하는 경우의 {kid, kty, alg, use, n, e}를 모두 가져와서 OIDCPublicKey에 저장한다.
- 최종적으로 모든 ID Token 유효성 검사와 서명 검증이 완료된 payload만 추출해낸다.
📌 Service에서 호출
/**
* idToken을 통해 payload를 가져온다.
*/
private OIDCDecodePayload getPayload(ProviderType provider, String idToken, String nonce) {
OauthClient oauthClient = oauthClientMapper.getOauthClient(provider);
OauthApplicationConfig oauthApplicationConfig = oauthApplicationConfigMapper.getOauthApplicationConfig(provider);
OIDCPublicKeyResponse oidcPublicKeyResponse = oauthClient.getOIDCPublicKey();
return oauthOIDCHelper.getPayloadFromIdToken(
idToken, oauthApplicationConfig.getAuthorizationUri(),
oauthApplicationConfig.getClientId(), nonce, oidcPublicKeyResponse);
}
- 이미 전부 구현되어 있는 걸 가져와서, Feign이나 OIDC에 대한 이해를 위한 학습 외엔 거의 추가로 할 게 없다..
- OauthClient가 kakao, apple, google 등에도 사용될 수 있으므로 OauthClientMapper 클래스를 사용해서 선택적으로 API를 호출하도록 했다.
- application 설정도 kakao, apple, google을 따로 가져오려고 OauthApplicationConfigMapper 클래스를 사용했다.