[Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect)
📌 OpenID Connect (OIDC) 는 무엇인가?
OAuth 2.0 프로토콜을 기반으로 한 사용자 인증 프로토콜
accessToken 이외에도 id_token을 사용하여 토큰으로 사용자가 누구인지 확인할 수도 있다.
OIDC는 표준 프로토콜(스펙)이기 때문에 다른 OAuth 공급자와 호환된다.
보통 소셜 로그인 요청 시 scope 에 `open id` 를 추가해주면
엑세스 토큰과 리프레시 토큰 이외에 id token 을 추가로 응답해준다.
🔗 공개 키 가져오기
각 OAuth Provider 들은 공개키 목록의 JSON 파일을 제공한다.
구글, 카카오, 애플의 공개키 목록 url 은 다음과 같다.
https://www.googleapis.com/oauth2/v3/certs
📌 Kakao
https://kauth.kakao.com/.well-known/jwks.json
📌 Apple
https://appleid.apple.com/auth/keys
아래는 애플의 공개키를 가져오는 FeignClient 예시이다
📝 AppleOIDCClient
@FeignClient(
name = "AppleOIDCClient",
url = "https://appleid.apple.com",
configuration = AppleOAuthConfig.class)
public interface AppleOIDCClient {
@GetMapping("/auth/keys")
OIDCPublicKeysResponse getAppleOIDCOpenKeys();
}
애플의 OIDC 정보들을 가져오는 Feign Client
해당 url 로 들어가면 Apple 이 제공하는 공개키 리스트가 있다.
다음과 같은 DTO에 저장시켰다.
@Getter
@NoArgsConstructor
public class OIDCPublicKeysResponse {
List<OIDCPublicKeyDto> keys;
}
@Getter
@NoArgsConstructor
public class OIDCPublicKeyDto {
private String kid;
private String alg;
private String use;
private String n;
private String e;
}
🔨 id_token 검증하기
OIDC 방식 로그인에서는
이를 decode 했을 때 나오는 정보들을 검증할 필요가 있다.
아래 글을 많이 참고했다.
>> [스프링] spring oauth Open ID Connect with kakao
OIDC 는 앞서 말했다시피 SPEC 이기 때문에
한 번 구현해두면 OAuth Provider 별로 동일하게 사용 가능하다!
📝 JwtOIDCProvider
private String getUnsignedToken(String token) {
String[] splitToken = token.split("\\.");
if (splitToken.length != 3) throw new BaseException(INVALID_TOKEN);
return splitToken[0] + "." + splitToken[1] + ".";
}
Header, Payload, Signature 를 분리하고 검증한다.
이후 Header 와 Payload 값만 가져옴
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 BaseException(ACCESS_TOKEN_EXPIRED);
} catch (Exception e) {
log.error(e.toString());
throw new BaseException(INVALID_TOKEN);
}
}
이후 iss와 aud 및 토큰 만료를 검증한다.
public String getKidFromUnsignedTokenHeader(String token, String iss, String aud) {
return (String) getUnsignedTokenClaims(token, iss, aud).getHeader().get("kid");
}
이번엔 공개키 목록에서 사용할 kid 를 알아내기 위해
헤더에서 key id 를 가져온다.
이후 OAuthOIDCHelper 에서 이 메소드를 사용할 것이다
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));
}
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 new BaseException(ACCESS_TOKEN_EXPIRED);
} catch (Exception e) {
log.error(e.toString());
throw new BaseException(INVALID_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);
}
이번엔 암호화 과정이다
kid 로 가져온 공개키에서 n 과 e 를 조합하여 직접 공개키를 새로 만든다.
n과 e는 base64 로 인코딩된 값이 넘어오기 때문에 디코딩이 필요하다.
또한 n과 e는 값이 큰 수이기 때문에 BigInteger 를 사용한다.
이러한 과정으로 새로 만든 공개키로 서명하여 id token 의 body 를 검증할 수 있다.
📝 OAuthOIDCHelper
@Service
@RequiredArgsConstructor
public class OAuthOIDCHelper {
private final JwtOIDCProvider jwtOIDCProvider;
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(() -> new BaseException(INVALID_TOKEN));
return jwtOIDCProvider.getOIDCTokenBody(
token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE());
}
private String getKidFromUnsignedIdToken(String token, String iss, String aud) {
return jwtOIDCProvider.getKidFromUnsignedTokenHeader(token, iss, aud);
}
}
앞서 구현한 로직으로
헤더에서 kid 를 뽑아 공개키 리스트 중에서 선택한다.
이후 n 과 e 정보를 가지고 공개키를 구하고
oauth 에 인증된 사용자인지 토큰을 검증한다.
📝 OIDCDecodePayload
@Getter
@AllArgsConstructor
public class OIDCDecodePayload {
private String iss;
private String aud;
private String sub;
private String email;
}
id token 를 decode 했을 때 나오는 필드들
이로써 id token 을 검증하고 body 정보를 가져올 수 있다!