OAuth 2.0 Resource Server에서 JWT 토큰을 검증하고 권한을 처리하는 방법에 대해 알아보겠습니다. Spring Security를 사용하여 Resource Server를 구성하고, JWT 토큰의 scope와 roles 클레임을 권한으로 변환하는 과정을 살펴볼 것입니다. 또한, 자주 발생하는 SSL 관련 오류와 그 해결 방법에 대해서도 다룰 예정입니다.

 

Authorization_code vs Client_credentials

OAuth 2.0의 Authorization Code 방식과 Client Credentials 방식은 서로 다른 사용 사례와 흐름을 가지고 있습니다. 두 방식의 주요 차이점은 다음과 같습니다:
  • Authorization Code 방식:
    • 사용자 개입이 필요한 인증 흐름
    • 웹 애플리케이션이나 모바일 앱에서 주로 사용
    • 리소스 소유자(사용자)의 동의를 얻어 액세스 토큰을 발급
    • 보안성이 높고 refresh token을 사용할 수 있음
  • Client Credentials 방식:
    • 클라이언트 애플리케이션이 직접 자신의 자격 증명으로 인증
    • 서버 간 통신이나 백그라운드 작업에 주로 사용
    • 사용자 컨텍스트 없이 클라이언트 자체의 권한으로 액세스 토큰을 얻음
    • 간단하지만 사용자 특정 데이터에 접근할 수 없음

 

OAuth2 Authorization Server Setup

Spring Boot와 Spring Security를 사용하여 OAuth 2.0 Authorization Server를 구현하기 위해서는 다음과 같은 핵심 설정이 필요합니다:
  • spring-boot-starter-oauth2-authorization-server 의존성을 추가합니다
  • @EnableAuthorizationServer 어노테이션을 사용하여 OAuth 2.0 권한 부여 서버 구성을 활성화합니다
  • AuthorizationServerConfigurerAdapter를 상속받는 구성 클래스를 생성하고, 클라이언트 세부 정보, 토큰 저장소, 보안 제약 조건 등을 설정합니다
  • ClientDetailsServiceConfigurer를 사용하여 클라이언트 ID, 시크릿, 권한 부여 유형, 스코프 등을 정의합니다
  • JWT 토큰을 사용하는 경우, JwtAccessTokenConverter TokenStore를 구성하여 토큰 생성 및 검증 로직을 커스터마이즈할 수 있습니다
  • 사용자 인증을 위해 UserDetailsService를 구현하고, 비밀번호 인코딩을 위한 PasswordEncoder를 설정합니다
이러한 설정을 통해 기본적인 OAuth 2.0 Authorization Server를 구현할 수 있으며, 필요에 따라 추가적인 커스터마이징이 가능합니다.

 

Configuring Resource Server with JWT

 
JWT를 사용하는 리소스 서버 구성은 Spring Security의 OAuth 2.0 지원을 통해 간단히 설정할 수 있습니다. 주요 단계는 다음과 같습니다:
  • application.yml 파일에 spring.security.oauth2.resourceserver.jwt.issuer-uri 속성을 설정하여 JWT 발급자 URI를 지정합니다
  • @EnableWebSecurity 어노테이션과 함께 SecurityFilterChain 빈을 구성하여 JWT 인증을 활성화합니다
  • JwtDecoder 빈을 커스터마이즈하여 토큰 유효성 검사 로직을 추가할 수 있습니다
  • 필요한 경우 JwtAuthenticationConverter를 구현하여 JWT 클레임을 Spring Security의 권한으로 매핑합니다
이러한 설정을 통해 리소스 서버는 Authorization Server에서 발급한 JWT를 검증하고, 토큰에 포함된 스코프나 역할에 따라 접근 제어를 수행할 수 있습니다
 
 
What is Scope and Role
OAuth 2.0에서 scope와 role은 접근 제어를 위한 중요한 개념이지만, 그 용도와 적용 방식에 차이가 있습니다:
  • Scope: 클라이언트 애플리케이션이 사용자 리소스에 접근할 수 있는 범위를 정의합니다. 예를 들어, 'read:profile', 'write:email' 등으로 세분화된 권한을 나타냅니다.
    • Scope는 OAuth 2.0 프로토콜의 표준 부분으로, 인증 서버에서 관리됩니다.
  • Role: 사용자의 조직 내 역할이나 권한 수준을 나타냅니다. 예를 들어, 'admin', 'user', 'manager' 등이 있을 수 있습니다
    • Role은 주로 애플리케이션 내부에서 정의되고 관리됩니다.
적절한 사용:
  • Scope는 클라이언트 애플리케이션의 권한을 제한하는 데 사용합니다. 예: 'read:books'
  • Role은 사용자의 전반적인 권한 수준을 정의하는 데 사용합니다. 예: 'librarian'
두 개념을 조합하여 더 세밀한 접근 제어를 구현할 수 있습니다. 예를 들어, 'librarian' 역할을 가진 사용자에게만 'write:books' 스코프를 허용하는 방식으로 사용할 수 있습니다
 
 

Handling Roles and Scopes in Tokens

OAuth 2.0 토큰에서 역할(roles)과 범위(scopes)를 처리하는 것은 리소스 서버의 중요한 기능입니다. Spring Security에서는 JwtAuthenticationConverter를 사용하여 이를 구현할 수 있습니다:
  • JWT 토큰의 'scope' 클레임을 Spring Security의 권한으로 자동 변환합니다.
  • 커스텀 'roles' 클레임을 처리하려면 JwtAuthenticationConverter를 확장하여 구현합니다.
  • GrantedAuthoritiesMapper를 사용하여 클레임을 세분화된 권한으로 매핑할 수 있습니다.
  • 보안 구성에서 @PreAuthorize 또는 hasRole() 메소드를 사용하여 엔드포인트별 권한을 설정합니다.
이러한 방식으로 토큰의 역할과 범위를 효과적으로 처리하여 세밀한 접근 제어를 구현할 수 있습니다.

Customizing Token Claims

JWT 토큰의 클레임을 커스터마이즈하는 것은 OAuth 2.0 인증 서버에서 중요한 기능입니다. Spring Security에서는 OAuth2TokenCustomizer 인터페이스를 구현하여 이를 수행할 수 있습니다
 
- @Bean 메서드를 통해 OAuth2TokenCustomizer를 구현합니다.
- customize 메서드 내에서 context.getClaims()를 사용하여 JWT 클레임에 접근합니다.
- claims.put() 메서드로 커스텀 클레임을 추가하거나 기존 클레임을 수정합니다.
- 사용자의 권한이나 역할을 클레임으로 추가하려면 context.getPrincipal()에서 정보를 추출합니다

 

이 방법을 통해 리소스 소유자의 추가 정보나 애플리케이션 특정 데이터를 토큰에 포함시킬 수 있어, 리소스 서버에서 더 세밀한 접근 제어가 가능해집니다.
 

Resolving Errors

OAuth 2.0 구현 시 JwtDecoder와 SSL 관련 오류를 해결하는 방법은 다음과 같습니다:
  • JwtDecoder 오류: NimbusJwtDecoder.withJwkSetUri()를 사용하여 JWK Set URI를 명시적으로 설정합니다.
    • 이때 URI가 올바른지 확인하고, 필요한 경우 커스텀 RestTemplate을 구성하여 추가적인 헤더나 인증을 처리할 수 있습니다.
  • SSL 인증서 오류: 개발 환경에서는 server.ssl.key-store-type=PKCS12 server.ssl.key-store=classpath:keystore.p12를 설정하여 자체 서명된 인증서를 사용할 수 있습니다.
  • 프로덕션 환경에서는 신뢰할 수 있는 인증 기관에서 발급한 유효한 SSL 인증서를 사용해야 합니다.
이러한 설정을 통해 대부분의 JwtDecoder 및 SSL 관련 오류를 해결할 수 있으며, 안전하고 신뢰할 수 있는 OAuth 2.0 인증 흐름을 구현할 수 있습니다.
 
 

Setting Up JWK URI for Validation

  • application.yml 파일에 spring.security.oauth2.resourceserver.jwt.jwk-set-uri 속성을 추가합니다.  이 URI는 일반적으로 https:///.well-known/jwks.json 형식을 따릅니다.
  • 보안 구성 클래스에서 JwtDecoder 빈을 커스터마이즈하여 JWK URI를 명시적으로 설정할 수 있습니다
    • JWT 토큰 검증을 위한 JWK (JSON Web Key) URI 설정은 OAuth 2.0 리소스 서버 구현의 중요한 부분입니다.
이러한 설정을 통해 리소스 서버는 Authorization Server에서 제공하는 공개 키를 사용하여 JWT 토큰의 서명을 검증할 수 있으며, 토큰의 무결성과 신뢰성을 보장할 수 있습니다.

 

<이전글>

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

 

<개요>

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

- 기존에는 자체적으로 사용자 관리 시스템을 운영하고 있었다. (Password Grant방식)

 그 이유는 특별한 사용자의 정보를 담지 않고 있으며 인증, Resource Server, Client가 모두 같은 System Boundary에 포함되어 있었기 때문에 가장 간편한 방식으로 개발하였다.

- 이제부터는 관리기능을 확장시키고 추가 정보를 담아야 하는 요건이 생겼으며, 이를 위해서 사용자 인증에 관련된 부분을 별도 시스템으로 분리하여 구성하는 것이 좋다고 판단하였다.

- AWS Cognito의 경우 외부연동, MFA인증등 편리한 기능을 기본적으로 가지고 있으며 JWT Token, IAM 연동도 가능한 것으로 보여서 가능성을 검토해본다.

 a. 기존 시스템을 유지하면서 개선할 것인지 (Spring Boot / Security / JWT Token)

 b. 새로 개발할 것인지 (e.g AWS Cognito)

 c. 혹은 a,b를 혼합하여 가져갈 수 있을지 (커스터마이징의 범위)

 

<내용>

1. 사용자 풀

사용자 풀

- 사용자를 생성하고 그룹을 할당할 수 있다.

- 이메일 확인 및 비밀번호 초기화 등의 기능을 기본으로 제공한다.

- 그룹은 개별적으로 추가 가능하다.

- AWS Cognito user pool은 기본적으로 대소문자를 구분하지 않도록 되어있다. (권장사항)

  만약에 대소문자를 구별하고 싶다면 Make user name case sensitive 옵션을 활성화 해야한다.

https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-case-sensitivity.html

 

User pool case sensitivity - Amazon Cognito

User pool case sensitivity Amazon Cognito user pools that you create in the AWS Management Console are case insensitive by default. When a user pool is case insensitive, user@example.com and User@example.com refer to the same user. When user names in a use

docs.aws.amazon.com

 

2. 연동자격증명 공급자

- 우리가 SNS연동을 하기 위해서는 일반적으로 App을 생성하고 할당받은 Client ID, Secret을 이용하여 API호출 및 연계개발을 하게 되는데 이부분이 쉽게 설정으로 가능하도록 되어있다. (많이 사용하는 Google / Apple / Facebook)

- 그 외에도 표준 OIDC를 준수한다면 쉽게 추가하고 속성을 매핑할 수 있는 구조로 되어있다.

자격 증명 공급자 : Google

 

3. SMS / SNS

-  스타트업에서는 내부에 문자 / 이메일 발송기능이 없는 경우도 많은데 AWS SES / SNS 등과 쉽게 연동하여 활용이 가능하다.

- 단, SMS는 현재 도쿄 리전에서만 사용이 가능하며 샌드박스 내에서는 등록된 번호로만 발신이 가능하다.

 

4. App 구성

- AWS Cognito도 결국 우리가 만든 서비스와 연결되기 위해서 여러가지 요소가 필요한데 이에 대한 부분도 제공하고 있다.

- 도메인 : Cognito 도메인 or 사용자 지정도메인 가능

- 리소스 서버 구성 (OAuth 2.0)

- 호스팅 UI, 앱 클라이언트

 

5. OAuth 2.0 

- 앱 유형을 선택하기 전에 OAuth 2.0에 대한 기본적인 이해도가 있으면 도움이 된다.

https://icthuman.tistory.com/entry/OAuth-20-Flow

 

OAuth 2.0 Flow

1. Authorization Code - 권한 부여 승인을 위해서 자체생성한 Authorization Code를 전달하는 방식 - 기본이 되는 방식 - Refresh Token 사용이 가능 2. Client Credentials - 클라이언트의 자격증명만으로 Access Token을

icthuman.tistory.com

 

6. 앱 클라이언트

- 앱 클아이언트 부분을 좀 더 자세히 살펴보면 다음과 같다.

- 클라이언트 보안키 생성유무 선택에 따라서 추후 토큰발급 엔드포인트를 호출할 때 방법에 차이가 있다.

- 세션, 토큰( Id/Access/Refresh )별 만료시간을 선택할 수 있으며 Revoke Token에 대해서 선택사항으로 정할 수 있다.

- 사용자를 찾을 수 없다고 응답을 하면 해킹에 취약해질 수 있기 때문에 최근 로그인 시스템에서는 이름 또는 암호가 잘못되었다고 묶어서 표현하고 있다.

 

6. 호스팅 UI

- 자체적으로 로그인 UI 를 제공해준다.

- 주의할 점은 사용자 풀에서 연동자격증명을 선택하였다면 여기서도 선택을 해주어야 UI 상에서 버튼이 활성화 된다.

- 해당 호스팅UI는 내가 등록한 App에서만 사용하는 것이다. 따라서 호출할 때 다음 파라미터들을 전달해주어야 정상적으로 동작한다.

- client_id, response_type, scope, redirect_uri

 

<정리>

- AWS Cognito에서 User Pool을 생성하고 Identity Providers를 추가한 뒤 호스팅 UI 를 통해서 로그인/가입까지 완료했다.

- 다음 글에서는 OAuth 2.0 기본 Flow를 따라서 Cognito 사용자 풀과 호스팅 UI를 연동하여 Authorization Code를 발급받고

- 이를 기반으로 엔드포인트에 접근하여 Token발급, UserInfo 조회하는 기능을 살펴본다.

 

<참고>

- https://aws.amazon.com/ko/cognito/details/

 

기능 | Amazon Cognito | Amazon Web Services(AWS)

 

aws.amazon.com

- https://docs.aws.amazon.com/ko_kr/cognito/latest/developerguide/what-is-amazon-cognito.html

 

Amazon Cognito란 무엇입니까? - Amazon Cognito

Amazon Cognito란 무엇입니까? Amazon Cognito는 웹 및 모바일 앱에 대한 인증, 권한 부여 및 사용자 관리를 제공합니다. 사용자는 사용자 이름과 암호를 사용하여 직접 로그인하거나 Facebook, Amazon, Google

docs.aws.amazon.com

 

<개요>

- Spring Security 의 경우 Filter Chain 의 형태로 기능을 제공하고 있으며 필요에 따라서 추가/수정/삭제가 가능하다.

 

<내용>

1. Spring Security - Filter Chain

-  Spring 에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller 로 분배된다.

-  이 때 각 Request 에 대해서 공통적으로 처리해야할 필요가 있을 때 DispatcherServlet 이전에 단계가 필요하며 이것이 Filter이다.

Filter 와 Interceptor의 차이

- Spring Security는 FilterChainProxy를 통해서 상세로직을 구현하고 있다.

Spring Security Filters

 

- 현재 Application에서 사용중인 Filter Chain은 Debug를 통해서 쉽게 확인할 수 있다.

11개의 Filter를 사용중이다.

- FilterChain이름이 의미하듯 Filter는 순서가 매우 중요하다.

 

2. 우리가 가장 많이 사용하는 UserNamePassword 구조에 대해서 좀 더 살펴보겠다.

(적당한 그림을 찾지 못해서 손으로 그려보았다.)

AuthenticationFilter

- Request가 들어왔을때 Filter를 거치게 되고, 적절한 AuthenticationToken이 존재하지 않는다면 AuthenticationProviders를 거쳐서 생성하게 되며 UserDeatilsService를 입맛에 맞게 구현하여 사용자 정보를 가져오는 부분을 구현할 수 있다.

- Spring Securty에서는 사람들이 가장 많이 사용하는 DB인증을 다음과 같이 미리 구현해 두었다. (오른쪽 파란색 박스)

 

3.  인증방식 변경 ( JWT Token)

최근에는 MSA구조의 효율성을 높이기 위해서 JWT Token 방식을 많이 사용하고 있다.  Request에 대한 인증을 별도 서버를 거치지 않고 검증가능하고 기본로직에 필요한 내용을 담을 수 있어서 편리하다.

현재 서비스에 적용중인 대략적인 구조이다.

JWT Token Filter

- JWT AuthTokenFilter에서 해당 처리를 마친 후 나머지 FilterChain을 수행하는 구조이다.

- Token을 통해서 인증 및 인가를 위한 정보를 생성하여 SecurityContextHolder를 통해서 세팅한다.

- JWT Filter의 내용을 간단히 살펴보면 아래와 같다. (보안상 TokenProvider 로직은 개별구현하는 것을 권장한다.)

public class JwtTokenFilter extends GenericFilterBean {
    private JwtTokenProvider jwtTokenProvider;
    public JwtTokenFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain)
            throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) req);
        try {
            if (null == token) {
                filterChain.doFilter(req, res);
            } else {
                if(jwtTokenProvider.validateToken(token)) {
                    Authentication auth = token != null ? jwtTokenProvider.getAuthentication(token) : null;
                    SecurityContextHolder.getContext().setAuthentication(auth);
                    filterChain.doFilter(req, res);
                } else {
                    HttpServletResponse httpResponse = (HttpServletResponse) res;
                    httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid jwt token");
                }
            }
        } catch(IllegalArgumentException | JwtException e) {
            HttpServletResponse httpResponse = (HttpServletResponse) res;
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid jwt token");
        }
    }

 

- JwtTokenFilter를 작성 후 해당 Filter를 적절한 위치에 configure하는 것이 중요하다.

public class JwtConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private JwtTokenProvider jwtTokenProvider;
    public JwtConfigurer(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
	}
}

 

<정리>

- Spring Security는 분량이 많고 내용이 복잡하기 때문에 기본개념을 이해하고 요건에 따라서 적절히 추가/삭제/수정하는 것이 필요하다.

전체적인 흐름을 알지 못하고 사용하는 경우 의도치 않게 동작할 수 있기 때문에 아래 내용은 반드시 기억하는 것이 좋다.

 1. Filter 는 DispatcherServlet 앞에 존재한다.

 2. 여러 개의 Filter를 동시에 적용할 수 있으며 순서에 주의해야 한다. 

 3. Custom Filter를 개발하였다면 로직 처리 후 FilterChain.doFilter 를 호출하여 이후단계를 수행해야 한다. (AOP @Around와 동일)

 4.Filter / Interceptor 를 필요이상으로 넣을 경우 성능에 영향을 줄 수 있다.

 

<참조>

https://devlog-wjdrbs96.tistory.com/352

https://www.fatalerrors.org/a/in-depth-understanding-of-filterchainproxy.html

+ Recent posts