Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Archives
Today
Total
관리 메뉴

코딩블로그

밥메이트-Feign Client로 OIDC구현(1) 본문

밥메이트

밥메이트-Feign Client로 OIDC구현(1)

_hanbxx_ 2023. 12. 19. 19:59
728x90

본격적으로 "밥메이트" 프로젝트에 OIDC를 도입한 내용에 관해 정리를 해보려고 한다.

1. 공개키 목록 조회, feign client로 캐싱하기

우선 Feign Client란?

Feign Client란 Netflix에서 개발한 Http Client이다. 현재는 오픈소스로 전환되어 SpringCloud 프레임워크의 프로젝트 중 하나로 들어가있다

 

 

장점

  1. Spring Cloud의 starter-openfeign을 사용할 경우 SpringMVC에서 제공되는 어노테이션을 그대로 사용할 수 있다
  2. RestTemplate보다 간편하게 사용할 수 있고 가독성이 좋다
  3. 요청에 대한 커스텀이 간편하다

단점

  1. 동기적으로 동작한다. 즉, 하나의 요청이 끝나야 다음 동작이 가능하다
로그인 부분에만 feign client를 사용하기로 했으므로 도입하였다

 

프로젝트에 적용하기

build.gradle에 의존성 추가하기

	implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.8'
	implementation('io.github.openfeign:feign-jackson:12.1')

 

FeignConfig

@Configuration
public class FeignClientConfig {
    @Bean
    public Decoder feignDecoder() {
        return new JacksonDecoder(customObjectMapper());
    }

    private ObjectMapper customObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, false);
        return objectMapper;
    }

}

 

RequestKakaoTokenClient

@FeignClient(
        name = "RequestKakaoTokenClient",
        url = "https://kauth.kakao.com",
        configuration = RequestKakaoTokenErrorDecoder.class)
public interface RequestKakaoTokenClient {
    @PostMapping(
            "/oauth/token?grant_type=authorization_code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&code={CODE}&client_secret={CLIENT_SECRET}")
    KakaoTokenInfoDto getToken(
            @PathVariable("CLIENT_ID") String clientId,
            @PathVariable("REDIRECT_URI") String redirectUri,
            @PathVariable("CODE") String code,
            @PathVariable("CLIENT_SECRET") String client_secret);

    @GetMapping("/.well-known/jwks.json")
    PublicKeysDto getPublicKeys();
}

 

RedisCacheConfig파일

@RequiredArgsConstructor
@EnableCaching
@Configuration
public class RedisCacheManagerConfig {

    private final RedisConnectionFactory redisConnectionFactory;

    @Bean
    public CacheManager redisCacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofDays(1L));

        RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .withInitialCacheConfigurations(customConfigurationMap(redisCacheConfiguration))
                .build();
        return redisCacheManager;
    }

    /* 커스텀하여 만료기간 설정 */
    private Map<String, RedisCacheConfiguration> customConfigurationMap(RedisCacheConfiguration redisCacheConfiguration) {
        Map<String, RedisCacheConfiguration> customConfigurationMap = new HashMap<>();
        customConfigurationMap.put(KAKAO_PUBLIC_KEYS, redisCacheConfiguration.entryTtl(Duration.ofDays(1L)));
        customConfigurationMap.put(REFRESH_TOKEN_KEY, redisCacheConfiguration.entryTtl(Duration.ofDays(14L)));
        return customConfigurationMap;
    }
}

 

예제 요청

curl -v -X GET "https://kauth.kakao.com/.well-known/jwks.json"

 

예제 응답

HTTP/1.1 200 OK
{
    "keys": [
        {
        	//중요한 값 kid
            "kid": "3f96980381e451efad0d2ddd30e3d3",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw",
            "e": "AQAB"
        }, {
        	//중요한 값 kid
            "kid": "9f252dadd5f233f93d2fa528d12fea",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw",
            "e": "AQAB"
        }
    ]
}
IDToken을 받았을 때, 인증되었는지 확인하기 위해서 공개키로 확인해야 한다. 이때 공개키는 일정 주기 또는 특별한 이슈 발생 시 변경될 수 있다. 주기적으로 최신 공개키 목록을 조회한 후, 일정 기간 캐싱(Caching)하여 사용할 것을 권장한다. 지나치게 빈번한 공개키 목록 조회 요청 시, 요청이 차단될 수 있기 때문이다.

 

(나는 FeignClient를 이용하여 캐싱을 했다 .

위와같은 코드들로 공개키 목록을 로그인 요청시에 레디시 캐시저장소에서 꺼내올 수 있는 환경을 구성했다)

 

FeignClient출처: https://techblog.woowahan.com/2630/

 

우아한 feign 적용기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 저는 비즈인프라개발팀에서 개발하고 있는 고정섭입니다. 이 글에서는 배달의민족 광고시스템 백엔드에서 feign 을 적용하면서 겪었던 것들에 대해서 공유 하고자 합니다

techblog.woowahan.com

 

2. ID토큰 유효성 검증하기

1. 인증 전에 누구나 다 얻을 수 있는 payload를 가져온다
2.  페이로드를 Base64 방식으로 디코딩
3. 페이로드의 iss 값이 https://kauth.kakao.com와 일치하는지 확인
4. 페이로드의 aud 값이 서비스 앱 키와 일치하는지 확인
5. 페이로드의 exp 값이 현재 UNIX 타임스탬프(Timestamp)보다 큰 값인지 확인(ID 토큰이 만료되지 않았는지 확인)
6. 서명 검증하기
@RequiredArgsConstructor
@Component
public class JwtIdTokenProvider {
    
    public String getKid(String idToken){
        try{
        //1번 누구나 얻을 수 있는 payload가져오기
            System.out.println(idToken);
            String[] idTokenParts = idToken.split("\\.");
            String encodedHeader = idTokenParts[0];
            //2번 BASE64로 디코딩
            String decodedHeader = new String(Base64.getUrlDecoder().decode(encodedHeader), StandardCharsets.UTF_8);
            ObjectMapper objectMapper = new ObjectMapper();

 ....
 }

 

/* iss, aud, 만료시간 검증 & 서명검증 & 유저정보 가져오기 */
public UserInfoFromIdToken getUserInfo(String idToken, RSAPublicKey publicKey, String iss, String aud) {
    try {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(publicKey)
                .requireIssuer(iss) //4.issuer가 카카오인지 확인
                .requireAudience(aud) // 3.해당 서비스 애플리케이션 아이디가 같은지 확인
                .build()
                .parseClaimsJws(idToken)
                .getBody();
         //OIDC의 payload를 가져온다
        return UserInfoFromIdToken.builder()
                .email(claims.get("email", String.class))
                .build();

    } catch (SignatureException exception) {
        throw new InvalidSignatureTokenException();
    }catch (IncorrectClaimException exception){
        throw new IncorrectIssuerTokenException();
    }catch (ExpiredJwtException exception) { //5.만료된 토큰인지 확인
        throw new ExpiredTokenException();
    } catch (Exception exception){
        throw new InvalidTokenException();
    }
}

 

6번: 서명 검증하기

public UserInfoFromIdToken execute(String loginType, String idToken){
    String kid = jwtIdTokenProvider.getKid(idToken);
    PublicKeysDto publicKeys = new PublicKeysDto();
    String iss = new String();
    String aud = new String();
    switch (loginType) {
        case "kakao":
            PublicKeysDto keys = publicKeyProcessor.getCachedKakaoPublicKeys(); //캐싱된 공개키 목록 조회
            publicKeys = keys; //공개키 목록
            iss = kakaoProperties.getIss(); // iss와 대응
            aud = kakaoProperties.getAppKey(); // aud와 대응
            System.out.println(iss);
            System.out.println(aud);
            break;
        case "google":

            break;
        default:
            throw new NotSupportedLoginTypeException();
    }
    //같은 kid를 가져온다
    PublicKeyDto key = publicKeys.getKeys().stream()
            .filter(k -> k.getKid().equals(kid))
            .findFirst()
            .orElseThrow(() -> new IncorrectIssuerTokenException());
    System.out.println(iss);
    System.out.println(aud);
    //검증된 토큰에서 바디를 꺼내온다
    return jwtIdTokenProvider.getUserInfo(idToken, publicKeyProcessor.generatePublicKey(key), iss, aud);
}

 

공개키 목록에서 검증을 진행할 공개키 하나를 구하고 나서 n,e를 조합해서 공개키를 직접 만들어야한다

 

728x90

'밥메이트' 카테고리의 다른 글

밥메이트-Feign Client로 OIDC구현(0)  (0) 2023.12.19