📡 백엔드/🌱 Spring Boot

[Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect)

gengminy 2023. 3. 25. 22:21

OpenID Connect (OIDC) Flow Chart

 

📌 OpenID Connect (OIDC) 는 무엇인가?

OAuth 2.0 프로토콜을 기반으로 한 사용자 인증 프로토콜

accessToken 이외에도 id_token을 사용하여 토큰으로 사용자가 누구인지 확인할 수도 있다.

OIDC는 표준 프로토콜(스펙)이기 때문에 다른 OAuth 공급자와 호환된다.

 

보통 소셜 로그인 요청 시 scope 에 `open id` 를 추가해주면

엑세스 토큰과 리프레시 토큰 이외에 id token 을 추가로 응답해준다.

 

 

🔗 공개 키 가져오기

각 OAuth Provider 들은 공개키 목록의 JSON 파일을 제공한다.

구글, 카카오, 애플의 공개키 목록 url 은 다음과 같다.

📌 Google

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 정보를 가져올 수 있다!