뮤피에서는 기존에 OAuth를 사용하여 소셜 로그인을 구현하였다. 소셜 로그인 구현 방식은 다음과 같다.
- Client가 Authroization Code를 받아서, Server에 보낸다.
- Server는 받은 Code로 Resource Server에 접근하기 위한 Access Token을 Authorization Server로부터 발급받는다.
- 발급받은 Access Token으로 Resource Server에게서 정보를 가져온다.
- 로그인 혹은 회원가입 처리를 한다.
OAuth의 문제점
OAuth를 사용하면 발급받은 Access Token이 뮤피에게서 받은 것인지는 알 수가 없다. 단순히 Resource Server에 접근하기 위한 Access Token이기 때문이다. 이는 OAuth가 인증이 아닌 “인가”에 초점을 맞추기 때문에 발생한 문제이기도 하다.
또한 사용자의 정보를 얻기 위해 Authorization Server와 Resource Server 모두 거쳐야 해서 매번 네트워크를 두 번 거치기 때문에 효율적이지 않다.
Open ID Connect?
그에 비해 Open ID Connect (OIDC) 방식은 “인증”에 초점을 맞춘다. 그렇기 때문에 kakao에서 제공한 토큰에 사용자 정보가 포함되어 있고, 해당 토큰이 올바른 정보인지 확인하는 작업만 거치면 되는 것이다.
OIDC 구현하기
전체적인 플로우는 다음과 같다. Google 로그인이라고 가정하겠다.
- Client에게서 Google에서 발급받은 ID Token을 받는다.
- Google에서 제공하는 공개키를 가져오고, 캐싱한다.
- ID Token을 검증하고 Header에 포함된 kid를 가져온다.
- Google에게서 받은 공개키와 ID Token에서 추출한 kid를 비교하여 해당하는 공개 키를 찾는다.
- 해당 키의 modulus와 exponent로 RSA로 암호화된 public key를 생성한다.
- 생성한 key로 ID Token에서 Claim을 가져온다.
- 가져온 Claim에서 추출한 정보를 토대로 회원 가입을 진행한다.
GoogleOAuthHelper
private final OAuthOIDCHelper oauthOIDCHelper;
private final GoogleOAuthClient googleOAuthClient;
public OIDCDecodePayload getOIDCDecodePayload(String token) {
// key 찾기
OIDCPublicKeysResponse oidcPublicKeysResponse = googleOAuthClient.getGoogleOIDCOpenKeys();
return oauthOIDCHelper.getPayloadFromIdToken(
token,
iss,
aud,
oidcPublicKeysResponse
);
}
GoogleOAuthHelper는 googleOAuthClient.getGoogleOIDCOpenKeys() 로 Google에서 제공하는 공개키를 가져온 뒤 Claim에서 추출한 정보를 가져온다.
GoogleOAuthClient
@Component
@RequiredArgsConstructor
public class GoogleOAuthClient {
private final RestTemplate restTemplate;
@Cacheable(cacheNames = "GoogleOIDC", cacheManager = "oidcCacheManager")
public OIDCPublicKeysResponse getGoogleOIDCOpenKeys() {
ResponseEntity responseEntity = restTemplate.exchange(
"<https://www.googleapis.com/oauth2/v3/certs>",
HttpMethod.GET,
null,
OIDCPublicKeysResponse.class
);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return responseEntity.getBody();
} else {
// 에러 처리
throw new RuntimeException("GoogleOIDCOpenKeys request failed");
}
}
}
GoogleOAuthClien는 RestTemplate을 사용하여 Google에서 제공하는 공개키를 가져오고 캐싱하는 역할을 한다.
OAuthOIDCHelper
@Component
@RequiredArgsConstructor
public class OAuthOIDCHelper {
private final JwtOIDCProvider jwtOIDCProvider;
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);
OIDCPublicKeyDto oidcPublicKeyDto =
oidcPublicKeysResponse.getKeys().stream()
.filter(o -> o.getKid().equals(kid))
.findFirst()
.orElseThrow();
return jwtOIDCProvider.getOIDCTokenBody(
token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE());
}
}
OAuthOIDCHelper는 getKidFromUnsignedIdToken(token, iss, aud) 로 kid를 가져온 뒤(4), kid가 일치하는 공개키의 N과 E (modulus와 exponent)로 ID Token의 Claim에서 추출한 정보(7)를 가져온다.
JwtOIDCProvider - getKidFromUnsignedTokenHeader
@RequiredArgsConstructor
@Component
public class JwtOIDCProvider {
private static final String KID = "kid";
public String getKidFromUnsignedTokenHeader(String token, String iss, String aud) {
return (String) getUnsignedTokenClaims(token, iss, aud).getHeader().get(KID);
}
private Jwt<Header, Claims> getUnsignedTokenClaims(String token, String iss, String aud) {
try {
return Jwts.parserBuilder()
.requireAudience(aud)
.requireIssuer(iss)
.build()
.parseClaimsJwt(getUnsignedToken(token));
} catch (ExpiredJwtException e) {
throw new IllegalArgumentException("만료된 Id Token 입니다.");
} catch (Exception e) {
throw new IllegalArgumentException("잘못된 Id Token 입니다.");
}
}
private String getUnsignedToken(String token) {
String[] splitToken = token.split("\\\\.");
if (splitToken.length != 3) throw new IllegalArgumentException("잘못된 Id Token 입니다.");
return splitToken[0] + "." + splitToken[1] + ".";
}
}
iss(google의 인증 url), aud(client id)로 토큰을 검증한 뒤, Signature 부분을 제외한 Header와 Claim를 가져온다. 그리고 해당 토큰의 Header에서 kid를 추출한다.
JwtOIDCProvider - getOIDCTokenBody
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("name", String.class),
body.get("nickname", String.class),
body.get("picture", String.class)
);
}
private Jws<Claims> getOIDCTokenJws(String token, String modulus, String exponent) {
try {
token = token.replace("—", "--");
return Jwts.parserBuilder()
.setSigningKey(getRSAPublicKey(modulus, exponent))
.build()
.parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw new IllegalArgumentException("만료된 Id Token 입니다.");
} catch (Exception e) {
throw new IllegalArgumentException("잘못된 Id Token 입니다.");
}
}
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);
}
- Google에서 가져온 공개키의 N과 E로 RSA 암호화 Public Key를 생성한다. - getRSAPublicKey
- 생성한 키로 ID Token의 Claim을 추출한다. - getOIDCTokenJws
- Claim에서 추출한 정보를 토대로 회원가입하는 데에 사용할 객체를 생성한다. - OIDCDecodePayload
마무리
플로우와 코드를 이해하는 데에 꽤 시간이 걸렸던 것 같다. OAuth보다 장점이 많은 대신 ID Token을 검증하는 데에 노력을 많이 쏟아야 했지만 배운 점도 많았다.
테스트하다 보니 카카오가 던져주는 id_token에 우연히 '-'가 연속으로 두 개 들어간 토큰을 받았는데, 어떤 이유에서인지 이 문자가 '—' (엠-대시, EM DASH)로 바뀌어서 받아져서 오류가 발생하는 재밌는 이슈가 발생하기도 했었다.
땜빵하는 식으로 코드를 써놓긴 했는데 좋아보이진 않는다. 메서드로 빼서 사용하는 것이 좋아 보인다. (할 일 ++)
참고
'BE > Spring Boot' 카테고리의 다른 글
템플릿 메서드 패턴을 활용한 소셜 로그인 리팩토링하기 (with OIDC) (2) | 2023.11.28 |
---|---|
@Cacheable로 API 성능 개선하기 (with Redis, Scheduler, AOP) (0) | 2023.11.11 |
Jwt와 Interceptor 사용 시 CORS 에러가 발생하는 이유 (0) | 2023.06.09 |
로그인 시 id가 없을 때 status code는 200? 404? (0) | 2023.03.31 |
Spring Boot + Docker로 배포하기 (0) | 2022.07.24 |