WEB/Security

OAuth 2.0 Client - oauth2Client()

Tony Lim 2022. 12. 14. 15:09
728x90

OAuth2ClientConfigurer 초기화 이해

실제로 OAuth2ClientConfigure에서 하는일은 없고 AuthorizatinoCodeGrantConfigure의 init ,configure메소드를 호출할 뿐이다.
이중 위에서 3개는 OAuth2Client  oauth2Login api 를 설명할때 authserver로 부터 authorization code를 얻어올 떄 쓰여진다. 동일한 것을 만드는것이다. 

OAuth2AuthorizationCodeGrantFilter 가 access token을 받아오는 필터인데 이것이 oauth2Login 과 다른 부분이다. 인증처리까지는 하지 않는다.

https://tonylim.tistory.com/395

 

OAuth 2.0 Client - oauth2Login()

OAuth2LoginConfigurer 초기화 이해 fundamental 에서 나온데로 Configurer는 init , configure를 호출하면서 필터를 만들게 된다. /login/oauth2/code/{register-id} 로 요청이 들어오면 OAuth2LoginAuthenticatioFilter가 처리하게

tonylim.tistory.com

oauth2Client의 경우 oauth2Login 처럼 인증 처리까지 해주지 않고 인가까지만 해준다. 우리가 별도로 custom하게 구현을 해야하는 것이다.


OAuth2AuthorizedClient

인가를 받은 client를 의미한다. 즉 User가 client에게 리소스에 접근할 수 있는 권한을 부여한 상태이다.
Access Token 과 Refresh Token 을 clientRegistration 과 권한을 부여한 최종사용자인 principal 과 함께 묶어준다.
Accesstoken 과 ClientRegistration 으로 UserInfo endpoint도 요청할 수 있다.

OAuth2AuthorizedClientRepository 는 save ,remove ,load 를 OAuth2AuthorizedClientService에게 위임하는 역할을 한다.

OAuth2AuthorizationCodeGrantFilter의 경우 access token을 받아올떄 사용되는데 실행조건은
1. 요청 파라미터에 code 와 state 값이 존재하는지 확인
2. OAuth2AuthorizationRequest 객체가 존재하는지 확인

@GetMapping("/client")
public String client(HttpServletRequest request, Model model){

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    String clientRegistrationId = "keycloak";
    OAuth2AuthorizedClient oAuth2AuthorizedClient = authorizedClientRepository.loadAuthorizedClient(clientRegistrationId, authentication, request);
    OAuth2AuthorizedClient oAuth2AuthorizedClient1 = authorizedClientService.loadAuthorizedClient(clientRegistrationId, authentication.getName());

    System.out.println("oAuth2AuthorizedClient = " + oAuth2AuthorizedClient);
    System.out.println("oAuth2AuthorizedClient1 = " + oAuth2AuthorizedClient1);

    OAuth2AccessToken accessToken = oAuth2AuthorizedClient.getAccessToken();

    OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
    OAuth2User oauth2User = oAuth2UserService.loadUser(new OAuth2UserRequest(oAuth2AuthorizedClient.getClientRegistration(), accessToken));

    OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken
            (oauth2User, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")),clientRegistrationId);

    SecurityContextHolder.getContext().setAuthentication(authenticationToken);

    model.addAttribute("accessToken", oAuth2AuthorizedClient.getAccessToken().getTokenValue());
    model.addAttribute("refreshToken", oAuth2AuthorizedClient.getRefreshToken().getTokenValue());
    model.addAttribute("principalName", oauth2User.getName());
    model.addAttribute("clientName", oAuth2AuthorizedClient.getClientRegistration().getClientName());

    return "client";
}

여기로 왔다는것은 이미 client가 auth server로부터 authorization code + acces token 교환과정을 다 마친 인가된 상태로 도착한것이다. User는 anonymousUser로 인가만 된상태임으로 Controller에서 직접 인증 처리를 하는 과정이다.

이떄 authorizedServiceClient에서는 null 이 나오게 되는데 User가 anonymous로 인증이 되었으로 별도의 repo로 들어간다. 이때 https://github.com/spring-projects/spring-security/issues/12272

 

AuthorizedClientService#loadAuthorizedClient expected behavior when using HttpSecurity#oauth2Client · Issue #12272 · spring-pr

Expected Behavior when using http.oauth2Client() api i am aware that i should implement actual user(resource owner) authentication by myself @GetMapping("/client") public String client(Ht...

github.com

여기에 내가질문한것인데 @RegisteredOAuth2AuthorizedClient 이것을 사용하여 가져올 수도 있다.

OAuthUserService를 통해 UserInfo Endpoint에 access token 과 clientRegistration 정보를 통해 요청을 날리고 사용자 정보를 얻어온다. OAuth2AuthenticationToken 을 인증된 상태로 새로 만든다.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<head>
<meta charset="UTF-8">
<title>Insert title here</title>

</head>
<body>
<div>Welcome</div><p></p>
<div sec:authorize="isAuthenticated()"><a th:href="@{/logout}">Logout</a></div><br>
<div sec:authorize="isAuthenticated()">principalName: <span th:text="${principalName}">인가받은 클라이언트</span></div><br>
<div sec:authorize="isAuthenticated()">clientName: <span th:text="${clientName}">인가받은 클라이언트</span></div><br>
<div sec:authorize="isAuthenticated()">accessToken: <span th:text="${accessToken}">인가받은 클라이언트</span></div><br>
<div sec:authorize="isAuthenticated()">refreshToken: <span th:text="${refreshToken}">인가받은 클라이언트</span></div><br>
</body>
</html>

thymeleaf 에서 securityContext의 정보를 꺼낼 수 있는 기능이 존재한다.


DefaultOAuth2AuthorizedClientManager 소개 및 특징

OAuth2Client api 를 통해서 client가 인가받는 방식을 알아보자. Authorization_code , Implicit 말고 다른 방식으로 인가 받는 방법이다. 아래 3가지 방법
OAuth2AuthorizedClient를 전반적으로 관리하는 인터페이스

OAuth2AuthorizedClientProvider로 OAuth2.0 클라이언트에 권한 부여를 한다.
1. Client Credentials Flow ,
2. Resource Owner Passwor Flow ,
3. Refresh Token Flow

OAuth2AuthorizedClientService | Repository에 OAuth2AuthorizedClient 저장을 위임한 후 OAuth2AuthorizedClient를 최종반환해준다.
Invalid_grant 오류로 권한부여시도가 실패하면 이전에 저장된 OAuth2AuthorizedClient가 Repository에서 제거된다.

위에 3개는 인가된 client를 보관하는 용도이고
아래 6개는 client를 인가하기 위해서 쓰는 용도이다. 각각 grant type별로(위에서 언급한 3가지) 존재하는 것이다. 

client가 access token 까지 받아와서 authorized 되고 User의 authentication은 anonymous 로 인증된 상태인 경우 HttpSession에 보관이 된다.

인증을 맞치고 메모리나 db에 저장을 할 수 있게 된다.


DefaultOAuth2AuthorizedClientManager 기본 환경 구성

스프링 시큐리티의 OAuth2Login filter 에 의한 자동 인증처리를 하지 않고 DefaultOAuth2AuthorizedClientManager 클래스를 사용하여 Spring MVC 에서 직접 인증처리를 하는 로그인 기능을 구현한다.

@Configuration(proxyBeanMethods = false)
public class OAuth2ClientConfig {

    @Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests((requests) -> requests.antMatchers("/","/oauth2Login","/client","/logout").permitAll().anyRequest().authenticated());
        http
//                .oauth2Login().and()
                .oauth2Client();
        http.logout().invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .clearAuthentication(true)
                ;
        return http.build();
    }
}

1.DefaultOAuth2AuthorizedClientManager 빈 생성 및 파라미터 초기 값들을 정의한다.
2. 권한 부여 유형에 따라 요청이 이루어지도록 application.yml 설정
3. /oauth2Login 주소로 권한 부여 흐름을 요청
4. DefaultOAuth2AuthorizedClientManager에게 권한 부여 요청
5. 권한 부여가 성공하면 OAuth2AuthorizationSuccessHandler를 호출하여 인증 이후 작업을 진행
5.1) DefaultOAuth2AuthorizedClientManager의 최종 반환값인 OAuth2AuthorizedClient를 OAuth2AuthorizedClientRepository에 저장
6. OAuth2AuthorizedClient 에서 access token을 참조하여 /userinfo endpoint요청으로 user 정보가져옴
7. 사용자 정보와 권한을 가지고 인증객체를 만든후 SecurityContext에 저장하고 인증을 완료한다.
8. 인증이 성공하면 1~8 과정을 커스텀 필터를 만들어서 처리하도록 한다.

@Configuration
public class AppConfig {

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                                 OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .clientCredentials()
                        .password()
                        .refreshToken()
                        .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }

}

DefaultOAuth2AuthorizedClientManager - Resouce Owner Password

OAuth2AuthorizedCilent 이 존재하고 Access token이 아직 만료되지 않았으면 아직 인가된 상태를 유지 하는것이다. 둘다 true이면 access token을 발급받는과정을 거치게 되지만
access token만 만료되고 refresh token 이 존재하는 경우는 RefreshTokenOAuth2AuthorizedClientProvider를 사용
또한  OAuth2AuthorizedClientManager에서는 user id, pw 를 전달하는 api가 존재하지 않기에 옆에 따로 map에 넣어서 전달해야한다.

이제 인증 받은 client를 얻고 나서 OAuth2AuthorizedClientManager로 user를 인증하는과정을 알아보자

userinfo endpoint를 갔다온 후에는 OAuth2AuthentcationToken 이 인증된 user또한 들고 있게된다. 

public class OAuth2AuthenticationToken extends AbstractAuthenticationToken {
   private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
   private final OAuth2User principal;

principal들고 있고 SecurityContext에도 저장이되어서 다른 MVC 에서 참조가 가능해진다.

@Configuration
public class AppConfig {

    @Bean
    public DefaultOAuth2AuthorizedClientManager auth2AuthorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                                             OAuth2AuthorizedClientRepository clientRepository){

        OAuth2AuthorizedClientProvider oauth2AuthorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .password()
                        .clientCredentials()
                        .refreshToken()
                        .build();

        DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository,clientRepository);

        oAuth2AuthorizedClientManager.setAuthorizedClientProvider(oauth2AuthorizedClientProvider);
        oAuth2AuthorizedClientManager.setContextAttributesMapper(contextAttributesMapper());

        return oAuth2AuthorizedClientManager;

    }

    private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() {
        return oAuth2AuthorizeRequest -> {
            Map<String,Object> contextAttributes = new HashMap<>();
            HttpServletRequest request = oAuth2AuthorizeRequest.getAttribute(HttpServletRequest.class.getName());
            String username = request.getParameter(OAuth2ParameterNames.USERNAME);
            String password = request.getParameter(OAuth2ParameterNames.PASSWORD);

            if(StringUtils.hasText(username) && StringUtils.hasText(password)){

                contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME,username);
                contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME,password);
            }
            return contextAttributes;
        };
    }
}

controller에서 user id ,pw 를 받기위해서 DefaultOAuth2AuthorizedProvider를 하나 설정한다. 

contextAttributes에 저렇게 추가해줘야 request에서 authentication을 진행할떄 꺼내서 쓸 수 있다. 

@GetMapping("/oauth2Login")
public String oauth2Login(Model model, HttpServletRequest request, HttpServletResponse response) {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
            .withClientRegistrationId("keycloak")
            .principal(authentication)
            .attribute(HttpServletRequest.class.getName(), request)
            .attribute(HttpServletResponse.class.getName(), response)
            .build();

    OAuth2AuthorizationSuccessHandler successHandler = (authorizedClient, principal, attributes) -> {
        oAuth2AuthorizedClientRepository
                .saveAuthorizedClient(authorizedClient, principal,
                        (HttpServletRequest) attributes.get(HttpServletRequest.class.getName()),
                        (HttpServletResponse) attributes.get(HttpServletResponse.class.getName()));
        System.out.println("authorizedClient = " + authorizedClient);
        System.out.println("principal = " + principal);
        System.out.println("attributes = " + attributes);
    };

    oAuth2AuthorizedClientManager.setAuthorizationSuccessHandler(successHandler);

    OAuth2AuthorizedClient authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);



    if(authorizedClient != null){
        OAuth2UserService<OAuth2UserRequest,OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
        OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration,accessToken);
        OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);

        SimpleAuthorityMapper authorityMapper = new SimpleAuthorityMapper();
        authorityMapper.setPrefix("SYSTEM_");
        Set<GrantedAuthority> grantedAuthorities = authorityMapper.mapAuthorities(oAuth2User.getAuthorities());

        OAuth2AuthenticationToken oAuth2AuthenticationToken =
                new OAuth2AuthenticationToken(oAuth2User,grantedAuthorities, clientRegistration.getRegistrationId());

        SecurityContextHolder.getContext().setAuthentication(oAuth2AuthenticationToken);

        model.addAttribute("oAuth2AuthenticationToken", oAuth2AuthenticationToken);

    }

    return "home";
}

userid,pw로 access token을 받아오는것을 성공했으면 이것을 통해 userinfo endpoint에 접속을 시도하여 인증을 하게 된다.
grantedAuthorites에 prefix를 SYSTEM_ 으로 설정했으니 SYSTEM_SCOPE_EMAIL 이런식으로 앞에 추가적으로 붙어서 들어가게 된다.


Client Credenitals 권한부여

흐름이 거의 비슷함( 클래스이름만 다름)
client가 user인 상황이다. 이때 client만 인가되고 user는 anonymous로 인증이된 (사실상 인증안됨) 상태이기에 logout호출시 401 이 나는것을 확인할 수 있다.


RefreshToken 권한 부여하기

access token은 보통 보안을 위해 만료시간을 짧게 가져가게된다. 
Client의 서비스를 사용하는 User의 경우 다시또 로그인을 거쳐야하는 그런과정을 거칠필요없이 refresh token을 통해 access token을 얻어오기 때문에 편리하다.

@Configuration
public class AppConfig {

    @Bean
    public DefaultOAuth2AuthorizedClientManager auth2AuthorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                                             OAuth2AuthorizedClientRepository clientRepository){

        OAuth2AuthorizedClientProvider oauth2AuthorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .password(passwordGrantBuilder -> passwordGrantBuilder.clockSkew(Duration.ofSeconds(3600)))
                        .refreshToken(refreshTokenGrantBuilder -> refreshTokenGrantBuilder.clockSkew(Duration.ofSeconds(3600)))
                        .clientCredentials()
                        .build();

        DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository,clientRepository);

        oAuth2AuthorizedClientManager.setAuthorizedClientProvider(oauth2AuthorizedClientProvider);
        oAuth2AuthorizedClientManager.setContextAttributesMapper(contextAttributesMapper());

        return oAuth2AuthorizedClientManager;

    }

OAuth2AuthorizedClientProvider를 만들때 clockSkew를 설정하여 access token expire를 검사할때 3600초 만큼 빼서 검사하므로 현재 keycloak에서 설정해 놓은 5분을 기다리지 않고 바로 expire 시킬 수 있다.

@GetMapping("/oauth2Login")
public String oauth2Login(Model model, HttpServletRequest request, HttpServletResponse response) {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
            .withClientRegistrationId("keycloak")
            .principal(authentication)
            .attribute(HttpServletRequest.class.getName(), request)
            .attribute(HttpServletResponse.class.getName(), response)
            .build();

    OAuth2AuthorizationSuccessHandler successHandler = (authorizedClient, principal, attributes) -> {
        oAuth2AuthorizedClientRepository
                .saveAuthorizedClient(authorizedClient, principal,
                        (HttpServletRequest) attributes.get(HttpServletRequest.class.getName()),
                        (HttpServletResponse) attributes.get(HttpServletResponse.class.getName()));
        System.out.println("authorizedClient = " + authorizedClient);
        System.out.println("principal = " + principal);
        System.out.println("attributes = " + attributes);
    };

    oAuth2AuthorizedClientManager.setAuthorizationSuccessHandler(successHandler);

    OAuth2AuthorizedClient authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);

    // 권한 부여 타입을 변경하지 않고 토큰 재발금
    if (authorizedClient != null && hasTokenExpired(authorizedClient.getAccessToken())
            && authorizedClient.getRefreshToken() != null) {
        authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);
    }

password 방식으로 인증을 시도할 때 hasTokenExpired에서 위에서 설정한 3600이 지난 것으로 가정함으로 if 문에 걸리고 authorize를 재시도하지만
auth token은 만료되고 refresh token이 존재함으로 password 방식으로 진행하지 않는다.

PasswordOAuth2AuthorizationClientProivder에서 auth token 만료, refersh token 존재 조건으로 인해 null 을 return 하게되고 다음 provider인 RefreshTokenOAuth2AuthorizedClientProvider를 사용하게 된다.

// 권한 부여 타입을 변경하고 토큰 재발급
if (authorizedClient != null && hasTokenExpired(authorizedClient.getAccessToken())
        && authorizedClient.getRefreshToken() != null) {

    ClientRegistration clientRegistration = ClientRegistration.withClientRegistration
            (authorizedClient.getClientRegistration()).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .build();

    OAuth2AuthorizedClient oAuth2AuthorizedClient =
            new OAuth2AuthorizedClient(clientRegistration, authorizedClient.getPrincipalName(),
                    authorizedClient.getAccessToken(),authorizedClient.getRefreshToken());

    OAuth2AuthorizeRequest oAuth2AuthorizeRequest =
            OAuth2AuthorizeRequest.withAuthorizedClient(oAuth2AuthorizedClient)
                    .principal(authentication)
                    .attribute(HttpServletRequest.class.getName(), request)
                    .attribute(HttpServletResponse.class.getName(), response)
                    .build();

    authorizedClient = oAuth2AuthorizedClientManager.authorize(oAuth2AuthorizeRequest);
}

다음과같이 password 방식으로 한번더  authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest); 를 호출하는 것이 아니라 직접 AuthorizationGrantType을 refresh token으로 변경하여 access token을 받아올 수 있다.


필터 기반으로 구현하기

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null) {
        authentication = new AnonymousAuthenticationToken("anonymous","anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
    }

    OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
            .withClientRegistrationId("keycloak")
            .principal(authentication)
            .attribute(HttpServletRequest.class.getName(), request)
            .attribute(HttpServletResponse.class.getName(), response)
            .build();

    OAuth2AuthorizedClient oAuth2AuthorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);

    /*if (oAuth2AuthorizedClient != null && hasTokenExpired(oAuth2AuthorizedClient.getAccessToken())
            && oAuth2AuthorizedClient.getRefreshToken() != null) {
        ClientRegistration.withClientRegistration(oAuth2AuthorizedClient.getClientRegistration()).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN);
        oAuth2AuthorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);
    }*/

    if(oAuth2AuthorizedClient != null) {
        ClientRegistration clientRegistration = oAuth2AuthorizedClient.getClientRegistration();
        OAuth2AccessToken accessToken = oAuth2AuthorizedClient.getAccessToken();
        OAuth2RefreshToken refreshToken = oAuth2AuthorizedClient.getRefreshToken();

        OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oauth2User = oAuth2UserService.loadUser(new OAuth2UserRequest(
                oAuth2AuthorizedClient.getClientRegistration(), accessToken));

        SimpleAuthorityMapper simpleAuthorityMapper = new SimpleAuthorityMapper();
        Collection<? extends GrantedAuthority> authorities = simpleAuthorityMapper.mapAuthorities(oauth2User.getAuthorities());
        OAuth2AuthenticationToken oAuth2AuthenticationToken = new OAuth2AuthenticationToken(oauth2User, authorities, clientRegistration.getRegistrationId());

        authorizationSuccessHandler.onAuthorizationSuccess(oAuth2AuthorizedClient, oAuth2AuthenticationToken, createAttributes(request, response));

        return oAuth2AuthenticationToken;
    }
    return null;
}

anonymous user 로 빈 SecurityContext를 채워준다. 나머지는 똑같다. client 먼저 인증하고 access token으로 user info endpoint로 가서 user도 인증하여 OAuth2AuthenticationToken을 생성함


@RegisteredOAuth2AuthorizedClient

OAuth2AuthorizedClientArgumentResolver에서 위에서 회색부분을 대신 수행해주고 인증된 OAuth2AuthorizedClient를 주입해준다.
회색부분에서 Custom한 설정은 불가능 해진다.

 

 

 

 

728x90