<이전글>

https://icthuman.tistory.com/entry/AWS-Cognito-1-%EC%82%AC%EC%9A%A9%EC%9E%90%EA%B4%80%EB%A6%ACOAuth-20

 

AWS Cognito (1) - 사용자관리/OAuth 2.0

- 대부분의 서비스에서 공통적으로 필요한 기능이 사용자 관리/로그인 기능이다. 단순한 것 같지만 생각보다 많은 리소스가 필요한 시스템이다. (특히 권한연계까지 들어갈 경우) - 기존에는 자

icthuman.tistory.com

 

<개요>

- 가장 많이 사용되는 Authorization Code Grant방식을 이용해서 JWT Token 을 발급받는다.

- 토큰으로부터 claims를 얻고 유효한지 검증한다.

- 기존방식 토큰과의 호환성을 검토한다. (JWT / JWS, Secret Key / RSA)

 

<내용>

1. Authorization Code Request

- Cognito의 설정을 마쳤으니 호스팅UI를 통해서 로그인을 시도한다.

- 반드시 다음값을 파라미터로 넘겨주어야 한다.

 a. client_id : Cognito에서 생성한 클라이언트 ID

 b. response_type : code

 c. scope : resource에 접근할 수 있는 범위

 d. redirect_uri : authorization_code를 받아서 리다이렉트할 주소

 

2. Authorization Code Redirect ( redirect_uri )

- 해당 코드값을 통해서 /oauth2/token에 접근한다.

- access_token, id_token 등이 발급되면 응답을 처리한다.

- 코드로 살펴보면 다음과 같다.

CognitoTokenRequest cognitoTokenRequest = cognitoTokenRequestFactory.newCognitoTokenRequest(code);

        Mono<String> ret = webClient
                .post()
                .uri("/oauth2/token")
                .headers(h -> h.setBasicAuth(cognitoTokenRequest.getBasicAuth()))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromObject(cognitoTokenRequest.getMap()))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new RuntimeException(clientResponse.bodyToMono(String.class).block())))
                .bodyToMono(String.class)
                .onErrorReturn(null);
        ;

        CognitoTokenResponse cognitoTokenResponse = null;
        String temp = ret.block(Duration.ofMillis(cognitoTokenApiTimeout));
        try {
            cognitoTokenResponse = objectMapper.readValue(temp , CognitoTokenResponse.class );
        } catch (JsonProcessingException e) {
            log.error("error cognito's response is not valid", e.getLocalizedMessage());
        }
        return CompletableFuture.completedFuture(cognitoTokenResponse);

- 일반적으로 MediaType.APPLICATION_FORM_URLENCODED 방식으로 호출할 경우 MultiValueMap 을 사용하게 되는데 해당 값을 FirstCollection 형태로 처리하고 Factory를 통해서 만들도록 하여 소스를 간결하게 처리했다.

- 또한 /oauth2/token에 접근하기 위해서는 basicAuth로 인증을 해야 하는데 이 때 필요한 Secret값은 다음과 같은 형태로 생성한다.

Base64.getEncoder().encodeToString( (clientId+":" + clientSecret).getBytes() );

- Response는 다음과 같은 형태로 확인할 수 있다.

{
"access_token":
    "id_token":
    "tokenType":
    "expiresIn":
}

- 이 때도 주의해야 하는 점이 있는데 AWS Cognito의 응답구조는 cameCase가 아니라 snakeCase로 넘어온다

ObjectMapper objectMapper = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);

- Mono,CompletableFuture에 대해 부연설명을 하자면 Spring WebFlux에서는 비동기/논블로킹을 일반적인 처리형태로 가져간다.

 다만 이번 소스처럼 일부 동기화 구간이 필요할 경우 block() 을 사용하게 되고 이때 별도의 쓰레드로 대기하도록 ComletableFuture를 활용하여 @Async 처리를 하는 것이 좋다. 과거글 참조

https://icthuman.tistory.com/entry/Spring-WebClient-%EC%82%AC%EC%9A%A9-2-MVC-WebClient-%EA%B5%AC%EC%A1%B0

 

Spring WebClient 사용 #2 (MVC + WebClient 구조)

- Spring 이후 버전에서는 RestTemplate가 deprecated될 예정이며 WebClient 사용을 권장하고 있다. - 현재 구성 중인 시스템에는 동기/비동기 API가 혼재되어 있으면서, 다양한 Application / DB를 사용중이기 때

icthuman.tistory.com

 

3. JWT Token 

- JWT Token은 {header}.{body}.{signature} 의 형태로 구성되어 있으며 Cognito에서 응답받은 accessToken토큰의 내용을 확인해보면 다음과 같다.

- Header부분에 해당 토큰을 생성할 때 사용된 알고리즘과 공개키(비대칭)방식일 경우 kid가 포함된다.

  이를 통해서 서명을 검증할 수 있으며 공개키는 발급자가 제공하는 uri에서 확인할 수 있고, 일반적으로 다음과 같다.

   {issuer_uri}/.well-known/jwks.json

 

- 알고리즘의 대칭 / 비대칭 알고리즘에 대해서는 따로 설명해야 할 만큼 긴데 단순히 요약하면

 a. 대칭 알고리즘 : 서로 같은 키를 사용

 b. 비대칭 알고리즘 : 서로 다른 키를 사용

  e.g) 개인키로 암호화하여 서명을 첨부하고, 공개키를 통해서 서명을 검증할 수 있다.

 

4. JWT Token Encode / Decode (Java)

- io.jsonwebtoken 의 DefaultJwtParser를 살펴보면 세부로직을 좀더 잘 파악할 수 있다.

-  기본 내부에서 사용하던 토큰은 이와 같은 방식으로 되어 있으며 secretKey를 사용하는 대칭 알고리즘 방식이었다.

Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken);

(변수)  = claims.getBody().get("userId");
...
...

그러다 보니 같은 방식으로는 Cogtino의 토큰을 처리할 수 없어서 일단 다음과 같은 방식으로 변경하였다.

Jwt<Header, Claims> preclaims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));


private static String getUnsignedTokenFromOriginalToken(String token){
        String[] splitToken = token.split("\\.");
        String unsignedToken = splitToken[0] + "." + splitToken[1] + ".";
        return unsignedToken;
} // {[0]}.{[1]}.

- 이와 같이 {signature} 부분을 제외하고 body부분을 읽어올 수 있으며 Header에 대한 정보도 같이 처리가 가능하다.

 다만 누군가 토큰을 위조할 수 있는 가능성이 있어서 아래 Verifier에서 약간 보완을 해보았다.

 

5. JWT / JWS / JWE / JWK

- JWT Token에 대해서 살펴보면 관련된 용어들이 자주 나오는데 간단히 살펴보면 다음과 같다.

 

 A. JWT : 인증을 위한 일반적인 메커니즘. JWS 나 JWE 로 구현된다. (implements)

 [header].[payload].[signature]

 

 B. JWS(JSON Web Signature)

 Claim의 내용이 노출되고 디지털 서명을 하는 방식이다. (Client 가 Claim을 사용하기 위해서 일반적으로 JWS를 사용한다.)

 다음 Signature 에서 일반적으로 사용되는 알고리즘이다.

  • HMAC using SHA-256 or SHA-512 hash algorithms (HS256, HS512)
  • RSA using SHA-256 or SHA-512 hash algorithms (RS256, RS512)

 

C. JWE(JSON Web Encryption)

Claim 자체를 암호화시키는 방식

"header":
{
    "alg" : "RSA-OAEP",                --------------------> For content encryption 
    "enc" : "A256GCM"                  --------------------> For content encryption algorithm
},
 "encrypted_key" : "qtF60gW8O8cXKiYyDsBPX8OL0GQfhOxwGWUmYtHOds7FJWTNoSFnv5E6A_Bgn_2W"
"iv" : "HRhA5nn8HLsvYf8F-BzQew",       --------------------> initialization vector
"ciphertext" : "ai5j5Kk43skqPLwR0Cu1ZIyWOTUpLFKCN5cuZzxHdp0eXQjYLGpj8jYvU8yTu9rwZQeN9EY0_81hQHXEzMQgfCsRm0HXjcEwXInywYcVLUls8Yik",
"tag" : "thh69dp0Pz73kycQ"             --------------------> Authentication tag
}

 

D. JWK(JSON Web Key)

private key로 만들어진 signature 를 검증하기 위해서 public key를 제공하는 구조이다.

{
"alg":"RSA",

"mod": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs
tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI
SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",

"exp":"AQAB",

"kid":"2011-04-29"
}

 

 

6. Customize Token Verifier

private LoginUserDetails makeUserDetailsFromToken(String token) throws TimeoutException, ExecutionException, InterruptedException {
        Jwt<Header, Claims> preclaims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));
        String iss = (String)preclaims.getBody().get("iss");

        if(StringUtils.hasText(iss) && iss.startsWith("https://cognito-idp.ap-northeast-2.amazonaws.com")){  // 1. Cognito Token

            CognitoUserInfoResponse cognitoUserInfoResponse = cognitoService.getAuthInfoFromAccessToken(token).get(3, TimeUnit.SECONDS);
            if(cognitoUserInfoResponse== null || cognitoUserInfoResponse.getSub().isEmpty()){
                return null;
            }
            Jwt<Header, Claims> claims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));
        	...
            Collection<GrantedAuthority> authorityList = new ArrayList<>();
            List<String> cognitoGroups = (List<String>)claims.getBody().get("cognito:groups");
            if(null != cognitoGroups) {
                for (String group : cognitoGroups) {
                    authorityList.add(new SimpleGrantedAuthority(group));
                }
            }
            return new LoginUserDetails(......, authorityList);
        }else{                                                                                                // 2. Legacy Token
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            ...
            Collection<GrantedAuthority> authorityList = new ArrayList<>();
            List<String> authorities = (List<String>)claims.getBody().get("roles");
            if(null != authorities) {
                for (String auth : authorities) {
                    authorityList.add(new SimpleGrantedAuthority(auth));
                }
            }
            return new LoginUserDetails(..., authorityList);
        }
    }

 

 A. 발급자가 Cognito라면

 -- 발급된 토큰이 정상인지 확인요청을 한다. (/oauth/userInfo)

   => 이부분은 사실 원격요청을 하지 않고 제공되는 공개키, pem을 통해서 로컬에서 검증이 가능하지만 현재 java로는 직접 구현해야 하는 부분이 많고, 자주는 아니지만 공개키는 변경될 수 있기 때문에 키값을 확인하기 위해서는 결국 원격요청이 필요해서 쉽게 가기로 했다. 

 -- "cognito:groups" 정보를 이용해서 권한을 세팅한다.

 B. 기존 토큰이라면

 - secretKey를 이용해서 검증하고

 - "roles" 정보를 이용해서 권한을 세팅한다.

 

<정리>

이렇게 만들면 기존의 JWT Token(HS256) 와 신규 Cognito Token (RSA256) 를 모두 소화할 수 있다.

그러나 만들어 놓고보니 소스가 지저분하다. ResourceServer에서 권한을 확인하여 API접근제어를 해야하는데 좋은 구조는 아니다.

  - 로직이 복잡하고(구조 특성상 해당모듈을 그대로 copy해가는 방식이 될텐데 유지보수성이...영), secretKey 공유의 문제도 있다.

  - 꼭 두 가지 토큰을 소화할 필요가 있을까?

  - 기존 방식은 외부 사용자가 고려되지 않은 방식이고, OAuth 2.0은 출발자체가 3rd Party연계를 간편하게 하기 위한 방법이다.

 

다양한 확장을 위해서 인증은 각각의 방식을 유지하고 서비스를 사용자에 따라서 분리하는 것이 해결책이라는 판단을 내렸다.

- 내부 사용자가 이용하며 Client, Resource Server가 같은 Boundary에 있는 레거시의 경우 기존 토큰을 사용하고

- 외부 사용자가 이용해야 하는 API 서비스는 별도로 분리 구성하며, 이 서비스에서는 OAuth 2.0만을 활용하여 인가/인증을 하도록 한다.

  (이부분은 AWS API Gateway내에서 자격증명으로 Cognito를 연계하는 방식과 시너지를 낼 수 있을 것 같다.)

 

- 이를 위해서 보다 쉽게 Resource Server를 세팅하는 법을 Spring Security에서 제공하고 있는데 다음글에서 좀 더 자세히 적용해보고 서비스를 어떻게 분리하는 것이 보다 효과적일지 살펴본다.

 

<참조>

https://aws.amazon.com/ko/premiumsupport/knowledge-center/decode-verify-cognito-json-token/

 

Cognito JSON 웹 토큰의 서명 디코딩 및 확인

Amazon Cognito JSON 웹 토큰의 서명을 디코딩 및 확인하려면 어떻게 해야 합니까? 최종 업데이트 날짜: 2022년 9월 6일 Amazon Cognito 사용자 풀을 애플리케이션의 인증 방법으로 사용하고 싶습니다. 클라

aws.amazon.com

https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html

 

Verifying a JSON web token - Amazon Cognito

Amazon Cognito might rotate signing keys in your user pool. As a best practice, cache public keys in your app, using the kid as a cache key, and refresh the cache periodically. Compare the kid in the tokens that your app receives to your cache. If you rece

docs.aws.amazon.com

https://www.loginradius.com/blog/engineering/guest-post/what-are-jwt-jws-jwe-jwk-jwa/

 

What are JWT, JWS, JWE, JWK, and JWA? | LoginRadius Blog

Learn about the JOSE framework and its specifications, including JSON Web Token (JWT), JSON Web Signature (JWS), JSON Web Encryption (JWE), JSON Web Key (JWK), and JSON Web Algorithms (JWA). For easier reference, bookmark this article.

www.loginradius.com

 

+ Recent posts