<이전글>
<개요>
- 가장 많이 사용되는 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 처리를 하는 것이 좋다. 과거글 참조
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/
https://www.loginradius.com/blog/engineering/guest-post/what-are-jwt-jws-jwe-jwk-jwa/
'AWS Architecture' 카테고리의 다른 글
AWS Cognito (1) - 사용자관리/OAuth 2.0 (0) | 2023.03.03 |
---|---|
AWS Java SDK - S3 File upload #2 (0) | 2022.06.07 |
AWS Java SDK - S3 File upload #1 (0) | 2022.05.19 |
AWS S3-Athena 사용중 JDBC Driver동시성 문제 #2 (0) | 2021.11.04 |
AWS SDK for Java (CloudWatchLogsAsyncClient 사용법) (0) | 2021.07.15 |