Back-End/Spring

OIDC로 소셜 로그인 구현 [Spring Boot]

Meluu_ 2025. 6. 14. 20:08

✔️ OIDC(OpenID Connect) 란 ? 


OpenID Connect 1.0은 OAuth 2.0 [RFC6749] 프로토콜 기반의 간단한 신원 계층입니다 . 클라이언트는 권한 부여 서버에서 수행한 인증을 기반으로 최종 사용자의 신원을 확인하고, 상호 운용 가능하고 REST 방식과 유사한 방식으로 최종 사용자의 기본 프로필 정보를 얻을 수 있습니다.                                                                                            - openid.net -

 

OIDC(OpenID Connect)에서 로그인 제공자(예: 소셜 서버)는 공개 키를 JWKS(JSON Web Key Set) 형식으로 제공하며, 클라이언트는 로그인 후 받은 ID Token의 서명을 이 공개 키로 검증함으로써 토큰의 위조 여부를 확인하고, 사용자 신원의 진위를 보장할 수 있다.

 

 

✔️ 진행과정


공개키 얻는 과정 1
공개키 얻는 과정 2

간단하게 그림으로 그려봤다.

 

클라이언트는 소셜 로그인 후 소셜서버(Provider)에게 IDToken을 발급받는다.

ID토큰을 스프링 서버에 보내어 로그인 과정을 진행한다.

스프링 서버는 ID 토큰을 받으면 OIDC 문서 정보를 확인하여 공개키 목록 조회 URL을 획득하고

해당 URL에 공개키 조회 요청을 보내어 공개키 목록을 얻는다.

얻은 공개키 목록중에서 사용자가 보낸 ID 토큰의 kid와 같은 값을 가지는 공개키를 찾아낸다.

찾아낸 공개키의 n(공개키의 모듈)과 e(공개키의 지수)를 이용하여 RSAPublicKey를 만들어 실제 공개키를 얻는다.

얻은 공개키로 ID토큰을 검증한다.

소셜서버의 iss, aud(시크릿 키 or 클라이언트 키)도 함께 검증한다. 

 

✔️ 구현


구글에 검색해보면 많은 카카오 로그인 구현방법이 있을 것이다. 

그것을 참조해주고 여기서는 OIDC에 대해서만 정리한다. 

카카오 developers에 가서 카카오 로그인으로 이동한다. 

OpenId Connect 활성화를 설정해준다. 

 

그리고 Jwt 토큰 설정도 해주어야하는데 검증을 편리하게 하기 위해서이다. 

dependencies {
	// jwt 설정
    implementation("io.jsonwebtoken:jjwt-api:0.11.5")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
}

의존성 설정을 해주자. 

 

 

스프링 구현 

SocialService

public interface SocialService {
    public UserInfo getUserInfo(String token); // 유저 정보를 조회
}

유저 정보를 가져오는 인터페이스 

 

KakaoService

@Slf4j
@RequiredArgsConstructor
public class KakaoService implements SocialService {

    private final String kakaoAppKey; // 시크릿 키 
    private final ObjectMapper objectMapper; // json 파싱
    private final OIDCService service; // OIDC 공개키 서비스 
    private final JwtUtils jwtUtils; // JWT 토큰 관련 유틸 


    // 유저 정보 가져오기
    @Override
    public UserInfo getUserInfo(String token)  {
        // 토큰에서 kid를 공개키 목록에 있는지 확인 후 공개키 정보를 가져옴
        String header = token.split("\\.")[0];
        // ID 토큰 헤더 디코딩
        byte[] decode = Base64.getUrlDecoder().decode(header);

        try {
            JsonNode jsonNode = objectMapper.readTree(decode);
            String kid = jsonNode.get("kid").asText(); // kid 추출

            OIDCPublicKeyDto keyDto = service.getPublicKeyByKid(kid, SocialType.KAKAO); // kid에 맞는 공개키 탐색
            // 검증
            Claims claims = jwtUtils.validationIdToken(token, kakaoAppKey, SocialType.KAKAO.getIss(), keyDto);

            return new KakaoUserInfo(claims.getSubject(),
                    claims.get("email", String.class));

		// json 파싱 예외 잡기
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }


}

여기서는 카카오의 경우만 다루기에 카카오 서비스를 보여준다. 

안드로이드를 대상으로 했기에 네이티브 키를 kakaoAppKey에 담는다.

 

핵심은 전달받은 id토큰에서 header를 꺼내서 base64로 디코딩 후 

Json 데이터에서 kid를 추출한 후 

OIDCService에 보내어 kid에 맞는 공개키를 찾고 검증하는 것이다. 

 

OIDCPublicKeyDto

public record OIDCPublicKeyDto(String kid, String kty, String alg, String use, String n, String e) {
}

공개키 Dto

위의 데이터를 담는다. 

 

OIDCPublicKeysDto

public record OIDCPublicKeysDto(OIDCPublicKeyDto[] keys) {
}

공개키 배열 Dto 

 

SocialType

@Getter
public enum SocialType {
    KAKAO("kakao", "https://kauth.kakao.com"),
    GOOGLE("google", "https://accounts.google.com"),
    NAVER("naver", null);

    private final String type;
    private final String iss;

    SocialType(String type, String iss) {
        this.type = type;
        this.iss = iss;
    }

SocialType enum 

Privder와 iss 값이 있다.

Naver는 못찾겠어서 null로 설정했다. 

 

OIDCService

@Slf4j
public class OIDCService {

    private final RestTemplate restTemplate = new RestTemplate();
    private final ObjectMapper mapper = new ObjectMapper();

    // OIDC 문서 정보 가져오기
    // SpringEL언어 사용
    @Cacheable(value = "OIDC_JWKS", key = "#socialType")
    public String getJwksUrl(SocialType socialType)  {

        String url = switch (socialType) {
            case KAKAO -> "https://kauth.kakao.com/.well-known/openid-configuration";
            case GOOGLE -> "https://accounts.google.com/.well-known/openid-configuration";
            case NAVER -> "https://nid.naver.com/.well-known/openid-configuration";
        };

        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);

        try {
            JsonNode jsonNode = mapper.readTree(response.getBody());
            return jsonNode.get("jwks_uri").asText();

        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

    }


    /**
     * 캐시전략 사용
     * 공개키를 획득
     * @return
     */
    public OIDCPublicKeysDto getOpenIdPublicKeys(SocialType socialType){
        String jwksUrl = getJwksUrl(socialType);

        ResponseEntity<OIDCPublicKeysDto> response = restTemplate.getForEntity(
                jwksUrl,
                OIDCPublicKeysDto.class
        );

        if (response.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("공개키 획득 실패");
        }

        log.info("body = {}", Arrays.toString(response.getBody().keys()));
        return response.getBody();
    }

    /**
     *  kid : 공개키 id로  공개키 목록중 맞는 걸 찾아서
     *  반환한다.
     * @param kid
     * @return
     */
    public OIDCPublicKeyDto getPublicKeyByKid(String kid, SocialType socialType) {
        OIDCPublicKeyDto[] keys = getOpenIdPublicKeys(socialType).keys();

        return Arrays.stream(keys)
                .filter(k -> k.kid().equals(kid))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("일치하는 공개키가 없습니다."));
    }

    /**
     * 주어진 공개키 정보로 공개키를 조합해서 생성후 반환한다.
     * @param dto
     * @return
     */
    public static PublicKey createRsaPublicKey(OIDCPublicKeyDto dto) {
        BigInteger moduls = new BigInteger(1, Base64.getUrlDecoder().decode(dto.n()));
        BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(dto.e()));
        RSAPublicKeySpec spec = new RSAPublicKeySpec(moduls, exponent); // RSA 공개키 스펙(형태) 객체

        try {
            // key 타입은 RSA이며
            // RSA256은 RSA 키 타입 + SHA-256을 사용하는 방식
            return KeyFactory.getInstance("RSA").generatePublic(spec);

            // 예외 처리 
        } catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

}

restTemplate로 OIDC 문서 URL에 GET 요청을 보내 문서를 얻는다.

아래와 같은 응답을 받게 된다. 

해당 문서에서 jwks_uri를 얻는다. 여기 주소가 실제 공개키 주소를 가지고 있는 API 주소다.

해당 주소에 요청을 보내면, 실제 RSA 공개키 정보(n, e 등)를 포함한 JWK 리스트를 얻을 수 있다.

이렇게 여러개의 목록이므로 클라이언트가 보낸 ID토큰의 kid와 맞는 공개키를 찾는다. 

 

n, e를 통해 RSA 공개키 스펙을 얻고 keyFactory에서 RSA 키 타입의 공개키를 얻은 후 반환한다.

n이 매우 큰 값이므로 BigInter 타입으로 받는다. 

 

 

@Cacheable

Redis의 캐시전략을 사용하여 소셜 타입에 따라 JWKS를 캐시화하였다. 

 

하지만 언젠가는 바뀔 수 있으므로 검증 실패시 새로운 JWKS를 조회하는 로직을 짜야한다. 

여기서는 그런것은 배제하고 진행한다.

예외도 편의상 런타임으로 설정해놓는다. 

 

JwtUtils

public class JwtUtils {
    // 자체 서버 발급 메서드들
    // 여기서는 id토큰 검증만 필요하므로 생략
    
    public Claims validationIdToken(String idToken, String aud, String iss, OIDCPublicKeyDto dto) {
        PublicKey key = OIDCService.createRsaPublicKey(dto);

        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .requireIssuer(iss)
                    .requireAudience(aud)
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(idToken);

            Claims claims = jws.getBody();
            log.info("claims = {}", claims);

            return claims;

        } catch (ExpiredJwtException e) {
            throw new InvalidTokenException("만료된 토큰 : " + e);
        } catch (JwtException e) {
            throw new RuntimeException("검증 실패 :", e);
        }
    }   
}

JwtUtils의 검증 메서드

OIDCService를 이용하여 공개키를 만들고 

iss, aud, id토큰을 공개키로 검증한다. 

aud 는 여기 앱 키에 키다. 

iss는 OIDC 문서 조회시 얻을 수도 있고 보통은 잘 안바뀌어서 

한번 조회해서 값을 저장해도 된다. 

 

검증이 됐다면 claims를 반환한다. 

claims에는 id토큰에 있던 사용자 정보가 있으므로 꺼내서 사용하면 된다. 

 

 

요약

 

구글도 동일하게 진행하면 된다. 

구글의 경우 Client_id 값과 SocialType.GOOGLE로 보내주는것 외에 KakaoService에서 바뀌는게 없다.

OIDCPublicKeyDto keyDto = service.getPublicKeyByKid(kid, SocialType.GOOGLE);
Claims claims = jwtUtils.validationIdToken(token, client_id, SocialType.GOOGLE.getIss(), keyDto);

 

🔖 학습내용 출처


 

카카오 문서

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com