1. 개요
Tripot 프로젝트는 우선 아이폰 용으로 개발되었다. 기능 구현을 다 마치고 앱스토어에 올리려는데, 소셜 로그인을 사용하는 아이폰 앱 서비스는 애플 로그인을 필수로 구현해야 하는 조건이 있었다. OAuth2 자체는 프론트에서 구현을 해줬는데, 서버에서는 유저 고유id와 닉네임을 받아 회원 정보를 생성한다. 하지만 애플에서는 다음과 같이 응답이 전달된다.
{
"authorizationCode": "인증 코드",
"email": "이메일@....appleid.com",
"fullName": {
"familyName": "성",
"givenName": "이름",
"middleName": null,
"namePrefix": null,
"nameSuffix": null,
"nickname": null
},
"identityToken": "JWT",
"realUserStatus": 2,
"state": null,
"user": "user"
}
이메일 및 이름 정도만 주어지고, 나머지는 identityToken(이하 idToken)에 JWT 형식으로 주어진다. 사용자 정보를 사용하려면 이를 풀어서 검증하고, 디코딩해야 한다. 이를 해보고자 한다.
2. 검증 과정
토큰에서 정보를 얻는 과정은 다음과 같다.
- 토큰에서 header를 받는다.
- https://appleid.apple.com/auth/keys에 접속하면 공개 키 3개가 주어지는 데, 이중 토큰의 kid, alg와 같은 키를 받아온다.
- 해당 키를 이용해 signature를 검증하고, 각종 claim을 받아온다.
- claim을 검증하고, 필요한 정보를 사용한다.
2-1. header 받기
public Map<String, String> parseHeaders(String token) throws JsonProcessingException {
//JWT를 .을 기준으로 header, payload, signature 분리 -> 그 중 header 선택
String header = token.split("\\.")[0];
//header를 디코딩 및 매핑하여 리턴
return new ObjectMapper().readValue(decodeHeader(header), Map.class);
}
private String decodeHeader(String token) {
return new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
}
JWT는 header, payload, signature이 '.'을 기준으로 나누어져있다. 받은 토큰을 .을 기준으로 자르고, 맨 앞의 값을 디코딩하여 리턴한다.
2-2. 공개키를 비교하여 같은 키를 받아오기
public record ApplePublicKeyResponse(List<ApplePublicKey> keys) {
public ApplePublicKey getMatchedKey(String kid, String alg) {
return keys.stream()
.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
.findAny()
.orElseThrow(() -> new JwtErrorException(StatusCode.INVALID_TOKEN));
}
}
우선 응답 dto이다. List에는 3개의 키가 담겨있고, 토큰의 kid, alg를 넣어 같은 값을 가지는 키를 가져올 수 있다.
private ApplePublicKeyResponse getAppleAuthPublicKey() {
return WebClient.create(pkHost).get()
.uri(uribuilder -> uribuilder
.scheme("https")
.path("/auth/keys")
.build(true))
.retrieve()
.bodyToMono(ApplePublicKeyResponse.class)
.block();
}
pkHost에는 appleId 주소가 담겨있다. 이를 설정하여 요청 후 3개의 키를 받아온다.
// 공개키를 생성한다
PublicKey publicKey = null;
try {
publicKey = applePublicKeyGenerator.generatePublicKey(headers, getAppleAuthPublicKey());
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
log.error("[{}] error: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), e.getMessage());
throw new CustomException(StatusCode.OAUTH2_LOGIN_FAILURE);
}
...
ApplePublicKeyGenerator.class
public PublicKey generatePublicKey(Map<String, String> tokenHeaders, ApplePublicKeyResponse applePublicKeys)
throws AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException {
//3개의 PK 중 tokenHeaders의 kid, alg와 같은 key를 리턴
ApplePublicKey publicKey = applePublicKeys.getMatchedKey(tokenHeaders.get("kid"), tokenHeaders.get("alg"));
//해당 키의 n, e를 가져와 PK 생성
return getPublicKey(publicKey);
}
private PublicKey getPublicKey(ApplePublicKey publicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.n());
byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.e());
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes),
new BigInteger(1, eBytes));
KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty());
return keyFactory.generatePublic(publicKeySpec);
}
}
각 키는 위와 같이 구성되어 있다. 이중 n, e를 사용하여 공개 키를 생성해야 한다. kty로 어떤 알고리즘을 사용할지 명시한 후 공개키를 생성한다.
2-3. claim 받아오기
// 토큰의 signature를 검사하고 Claim 을 반환받는다.
Claims tokenClaims = jwtUtil.getTokenClaims(identityToken, publicKey);
...
JwtUtil.class
public Claims getTokenClaims(String token, PublicKey publicKey) {
try {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (MalformedJwtException e) {
throw new JwtErrorException(StatusCode.INVALID_TOKEN);
} catch (ExpiredJwtException e) {
throw new JwtErrorException(StatusCode.EXPIRED_ACCESS_TOKEN);
}
}
이제 idToken과 방금 생성한 공개 키를 사용하여 검증 후 클레임을 받아온다.
2-4. claim 검증 후 값 사용하기
// 토큰의 signature를 검사하고 Claim 을 반환받는다.
Claims tokenClaims = jwtUtil.getTokenClaims(identityToken, publicKey);
// Verify that the iss field contains https://appleid.apple.com
if (!pkHost.equals(tokenClaims.getIssuer())) {
log.error("[{}] error: issue is not correct", Thread.currentThread().getStackTrace()[1].getMethodName());
throw new JwtErrorException(StatusCode.INVALID_TOKEN);
}
// aud 필드 검사
if (!tokenClaims.getAudience().contains(clientId)) {
log.error("[{}] error: aud is not correct", Thread.currentThread().getStackTrace()[1].getMethodName());
throw new JwtErrorException(StatusCode.INVALID_TOKEN);
}
return tokenClaims.getSubject();
토큰을 받아왔다면 iss와 aud 필드를 사용하여 해당 토큰이 잘못되지 않았는지 확인 후 필요한 값을 사용한다.
3. 테스트코드
@Test
@DisplayName("소셜 로그인 - 애플 로그인 결과가 정상적으로 리턴되어야 함")
public void oauth2LoginV2UsingApple() throws Exception {
//given
String sampleAccess = "sample_access_token";
String sampleRefresh = "sample_refresh_token";
String appleProvider = "apple";
String identityToken = "identityToken";
Map<String, String> headerMap = new HashMap<>();
headerMap.put("kid", "rs0M3kOV9p");
headerMap.put("alg", "RS256");
Map<String, Object> userInfo = new HashMap<>();
Set<String> aud = new LinkedHashSet<>();
aud.add(appleClientId);
userInfo.put("iss", "https://appleid.apple.com");
userInfo.put("sub", "1234");
userInfo.put("aud", aud);
Claims claims = (Claims)((ClaimsBuilder)claims().add(userInfo)).build();;
OAuth2LoginDto oAuth2LoginDto = OAuth2LoginDto.builder()
.id(identityToken)
.nickname("nickname")
.build();
given(jwtUtil.parseHeaders(identityToken)).willReturn(headerMap);
given(jwtUtil.getTokenClaims(eq(identityToken), any(PublicKey.class))).willReturn(claims);
// given(anySet().contains(any())).willReturn(true);
given(jwtUtil.createJwt(any(LoginCreateJwtDto.class), eq("access"))).willReturn(sampleAccess);
given(jwtUtil.createJwt(any(LoginCreateJwtDto.class), eq("refresh"))).willReturn(sampleRefresh);
String content = objectMapper.writeValueAsString(oAuth2LoginDto);
//when
ResultActions actions = mockMvc.perform(
post("/api/v2/login/oauth2/{provider}", appleProvider)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
//then
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.customCode").value(StatusCode.OAUTH2_LOGIN_SUCCESS.getCustomCode()))
.andExpect(jsonPath("$.customMessage").value(StatusCode.OAUTH2_LOGIN_SUCCESS.getCustomMessage()))
.andExpect(jsonPath("$.status").value(true))
.andExpect(jsonPath("$.data.nickname").value("테스트사용자닉네임"))
.andExpect(jsonPath("$.data.isActivate").value(true));
verify(redisUtil).setDataExpire(anyString(), anyString(), anyLong());
}
통합 테스트코드이다. 토큰을 직접 생성하기는 애매한 면이 있어서 header와 claim을 받는 과정을 mock 처리했다. 그 외에는 다른 소셜 로그인의 테스트코드와 동일했다.
'Proj > Tripot' 카테고리의 다른 글
Trouble Shooting - nginx 용량 늘리기(feat. 스토리 등록 오류) (0) | 2025.03.04 |
---|---|
앱 버전 저장 및 관리하기 (0) | 2025.03.02 |
Trouble Shooting - Spring Security 프로젝트에 비로그인으로 접속 시 (0) | 2025.02.11 |
Trouble Shooting - docker compose vs docker-compose (0) | 2025.02.10 |
스프링부트 테스트코드 작성하기: 통합 테스트 (1) | 2025.01.10 |