WEB/Security

OAuth 2.0 Client - oauth2Login()

Tony Lim 2022. 11. 22. 12:07
728x90

OAuth2LoginConfigurer 초기화 이해

fundamental 에서 나온데로 Configurer는 init , configure를 호출하면서 필터를 만들게 된다. 

/login/oauth2/code/{register-id} 로 요청이 들어오면 OAuth2LoginAuthenticatioFilter가 처리하게 된다. 
access token 교환시 거치는 필터이다.

Filter는 AuthenticationProvider에게 위임할 뿐이고 provider가 실질적인 auth server와 통신한다.

OidcACAProvider는 id token으로 인증처리가능하게 해주는 방법을 제공해준다.

authorization_code를 받아오는 filter이다.
/oauth2/authorization 으로 요청이 오면 동작하게 되어있다. 이곳으로 먼저 요청이오고 위에
/login/oauth2/code/{register-id}으로 (spring security에서는 자동으로 redirect로 아마올듯?) 오게되면 access token을 받아오고 OAuth2LoginAuthenticationProvider가 OAuth2UserService와 access token을 통해서 인증까지 처리한후에 User를  redirect uri로 돌려보낼것이다.

권한부여코드 요청필터가 2개의 객체가 존재하는데 이는 http.oauth2Login , http.oauth2Client 이렇게 선언하면 둘다 해당필터가 필요하기에 존재한다.


OAuth2.0 Login Page 생성

DefaultLoginPagegeneratingFilter에서 기본 oauth2.0 로그인 페이지를 만들어준다.

@EnableWebSecurity
public class OAuth2ClientConfig {

    @Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(authreq ->authreq
//                .mvcMatchers("/loginPage")
//                .permitAll()
                .anyRequest().authenticated());
        http.oauth2Login(Customizer.withDefaults());
        return http.build();
   }
}

기본값을 쓰면 application.yml 에 작성한 client, provider 정보로 oauth2 로그인 창을 띄워준다. 
주석으로 처리된 custom 로그인 창을 쓰려면

public HttpSecurity oauth2Login(Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer)
      throws Exception {
   oauth2LoginCustomizer.customize(getOrApply(new OAuth2LoginConfigurer<>()));
   return HttpSecurity.this;
}

Cutomizer는 functional interface로 customize 함수 하나를 가지고 있는데 이는 OAuth2LoginConfigurer 클래스가 제네릭에 적용되어있다. 해당 클래스의 loginPage(String loginPage) 메소드를 호출하면 된다. 

http.oauth2Login(a -> a.loginPage("/loginPage"));

Authoriation Code 요청하기

OAuth2AuthorizationRequestRedirectFilter에서 client가 user의 브라우저를 통해 auth server의 권한 부여 엔드포인트로 리다이렉트하여 권한 코드 부여 흐름을 시작한다.

AuthoriztionRequestMatcher : /oauth2/authorization/{registrationId} -> auth code를 얻어올수있는 endpoint , 이 정보가있어야 위 필터가 동작할 수 있다.

 

DefaultOAuth2AuthorizationRequestResolver

웹 요청에 오면 OAuth2AuthorizationRequest 객체를 완성한다. 
 /oauth2/authorization/{registrationId} 에서 registrationID를 추출하고 ClientRegistration에서 client 정보를 가져와 request 날릴 준비를 하게 된다. 

저장을 하는 이유 state 가 같은지 확인을 하기 위함이다. 다르면 auth server로 부터 받아온 auth code를 거절해야한다.

code 요청을 먼저하기위해 oauth2/authorization/{registrationId} 로 요청을 client(app)에게 날리면 OAuth2AuthorizationRequestRedirectFilter를 거쳐서 DefaultOAuth2AuthorizationRequestResolver에서 Request uri 와 RequestMatcher에 적혀있는 uri가 일치하면 계속진행한다.

만약 일치하지않아서 OAuth2AuthorizationCodeGrantFilter로 빠지게되면 code ,state가 존재하는 지 검사하고 없으면 FilterSecurityInterceptor로 가게된다. 하지만 인증이 안된상태로 (/home , /admin 등)으로 도착한 상태이기 때문에 인증 예외가 발생해버린다. 다시 인증을 하라고 LoginUrlAuthenticationEntryPoint로 보내게되는데 기본URL이 /oauth2/authorization/{registrationID} 임으로 다시 OAuth2AuthorizationRequestRedirectFilter로 이동하게 된다. 
즉, 처음에 정상적으로 /oauth2/authorization/{registrationID} 여기로 요청을 보내면 matches? 에서 y를 타고 위로 올라가게되지만 이상한 url 로 요청을 보내더라도 matches? 에서 n을 타고 다시 oauth2 code요청을 수행하게 되는것이다.

mathces yes를 타고가면
/{action}/oauth2/code/{registrationId} = redirection uri 이고 action을 login으로 치환하게 된다.  
HttpSessionOAuth2AuthoriztionRepository 를 통해서 OAuth2AuthorizationRequest를 세션에 저장하게 된다. 나중에 auth server와 state비교 및 상태를 유지하기 위함이다.
기본적으로 세션을 사용하지만 쿠키에 저장을 할 수 있다.


Access Token 교환하기

OAuth2LoginAuthenticationFilter 

auth server로부터 redirect되면서 전달된 code를 다시 auth server에게서 access token으로 교환하고 OAuth2LoginAuthenticationToken 에 저장후 AuthenticationManager에 위임하여 UserInfo 정보를 요청해서 최종 User를 인증 한다.

인증된 User를 OAuth2AuthorizedClient 로 만들고 OAuth2AuthorizedClientRepository에 저장한다. 

 

 

OAuth2LoginAuthenticationProvider

auth server로부터 redirect 된이후 access token 을 교환하고 이 토큰을 사용하여 UserInfo 처리를 담당한다.
Scope에 openid가 포함되어 있으면 OidcAuthorizationCodeAuthenticationProvider 를 호출하고 아니면 OAuth2AuthorizationCodeAuthenticationProvider를 호출한다.

OAuth2AuthorizationCodeAuthenticationProvider = auth server에서 authorization code 와 access token 의 교환을 담당

 

Access Token 요청 전체 흐름

code를 받아온후에 redirect된이후의 과정이다.
code를 보내기위해 만들었던 request를 세션에서 OAuth2AuthorizationRequest를 꺼내온다. 
OAuth2AuthorizationResponse는 사진의 code, sesesion state ,state가 저장이 되어있다.

request + response 를 통해 OAuth2LoginAuthenticationToken 아직 인증이 안된 Authentication 객체를 만든다.(이놈은 access token을 통해 userinfo를 가지고 와서 인증해야함 따라서 access token을 먼저 가지고와야함)
OAuth2LoginAuthenticationProvider에서 해당 객체를 인증 하기위해서 OAuth2AuthorizationCodeAuthentication Authenticaitno 객체를 또 만든다. 그리고 이것은 Access token을 받아오기위해 존재함으로 OAuth2AuthorizationCodeAuthenticaitonProvider에서 acces token , response token을 받아와 인증한후에 이걸 기반으로 이제 UserInfo 정보를 가져와서OAuth2LoginAuthenticationToken을 인증하게 된다.

그럼 제일 처음OAuth2LoginAuthenticationFilter 까지로 올라가서 OAuth2AuthenticationToken을 인증을 성공한것이 된다. 지금까지 얻어온 모든 결과를 바인딩한후에 SecurityContext에 저장을 하여 참조할 수 있게한다.


Oauth2.0 User 모델 소개

OAuth2UserService = access token을 이용해서 UserInfo endpoint 요청을 하여 인증처리가 다되면 OAuth2User 타입의 객체를 return 한다.
DefaultOAuth2UserService , OidcUserService 2개 존재한다.

DefaultOAuth2UserService 는 access token을 통해 resource server에 접근하여 userinfo 정보를 가져와 OAuth2User 타입을 반환한다.
OidcUserService 는 ID Token 을통해 인증처리를 하며 필요시 DefaultOAuth2UserService 를 통해 UserInfo를 가져오고 OidcUser타입을 반환한다.

 

1. 받아온 access token으로 auth server로부터 userInfo를 조회후 OAuth2User 생성하여서 반환
2. openid 의 경우 
2.1 auth server까지 가지 않고 받아온 id token으로 인증처리, 이때 access token도 받아오긴함
2.2 access token 으로 사용자 정보를 조회해 와서 인증을 처리할 수 있음

 

OAuth2User

OAuth2.0 Provider에 연결된 사용자 주체를 나타낸다.
기본 구현체는 DefaultOAuth2User이며 인증 이후 Authentication의 principal 에 저장된다.

OidcUser

OAuth2User 를 상소한 인터페이스이며 OIDC Provider에 연결된 사용자 주체를 나타낸다.
기본 구현체는 DefaultOidcUser 이며 DefaultOAuth2User 를 상속하고 있으며 인증 이후 Authentication 의 principal 에 저장된다.

 

OAuth 로그인을 통해 인증 받은 최종사용자의 Principal 에는 OAuth2User or OidcUser 가 저장이된다.
OAuth2UserAuthority는 auth server로부터 수신한 scope 정보를 집계해서 권한정보를 구성한다. attributes가 사용자 정보(name, phonenumber 등)
OidcUser 객체를 새성할 떄 ID token 이 필요한데 이 토큰은 JWS로 서명이 되어있다. 검증후에 OidcUser를 생성해야한다. claims가 사용자 정보 (name, phonenumber 등 하지만 attributes랑 필드가 좀 다르다.)

 

   @GetMapping("/user")
    public OAuth2User user(String accessToken) {

        ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("keycloak");
        OAuth2AccessToken auth2AccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.MIN, Instant.MAX, Set.of("profile","email"));
        OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, auth2AccessToken);

        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = defaultOAuth2UserService.loadUser(oAuth2UserRequest);

        return oAuth2User;
    }

    @GetMapping("/oidc")
    public OidcUser oidc(String accessToken, String idToken) {
        ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("keycloak");
        OAuth2AccessToken auth2AccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.MIN, Instant.MAX, Set.of("read"));

        Map<String, Object> idTokenClaims = new HashMap<>();
        idTokenClaims.put(IdTokenClaimNames.ISS, "http://localhost:8080/realms/oauth2");
        idTokenClaims.put(IdTokenClaimNames.SUB, "OIDC");
        idTokenClaims.put("preferred_username", "user");
        OidcIdToken oidcIdToken = new OidcIdToken(idToken, Instant.MIN, Instant.MAX, idTokenClaims);

        OidcUserService oidcUserService = new OidcUserService();
//        oidcUserService.setAccessibleScopes(Set.of("read"));
        oidcUserService.setOauth2UserService(new DefaultOAuth2UserService());
        OidcUser oidcUser = oidcUserService.loadUser(new OidcUserRequest(clientRegistration, auth2AccessToken, oidcIdToken));

        return oidcUser;
    }

원래는 Spring security에서 code를 받아오자마자 알아서 access token 까지 받아오고 user info도 받아오는 과정을 거치지지만 수동으로 코드를 짜본것이다.

oidc 의 경우 access token , idtoken 둘다 받는것을 확인 할 수 있다.  2개다 위에서 언급한것처럼 쓸 대가 있기 때문이다.

둘다 postman에서 code요처을 받은 다음에 accesstoken 을 요청으로 날려주는 방식으로 테스트를 진행할 수 있다.


UserInfo 엔드포인트 요청하기

access token을 받은 상태에서 시작한다. UserInfo에서 받아온 값을 OAuth2User로 converting 시킨다. 이때 scope는 권한으로 맵핑되는것을 확인 할 수 있다.

DefaultOAuth2User에서 attributes에 application.yml에서 provider.userNameAttribute 에 설정한 preferred_username 이 존재하는 것을 확인할 수 있다.

JwtDecoder에서 공개키로 id token을 검증하게된다. 검증 후에 OidcldToken 으로 만든다.
access token에 OIDC 사양에 부합하는 scope가 있으면 auth sever userinfo endpoint에 요청을 날려서 가져오고
없으면 바로 인증 처리를 해버린다.

userinfo에서 OAuth2User를 만드는것은 위의 과정과 동일하다. Authorities 에서 openid 가 추가 되고 DefaultOidcUser를 만든다.
이때 keycloak인 auth server를 들려야 attribute 에 prefrerred_username이 추가된다. 들르지 않고 open id 에서 (scope에 profile ,email 등이 없을때) 바로 인증하면 prefrered_username이 존재하지 않아서 에러가 발생한다.


OpenID Connect 로그아웃

client 는 로그아웃 엔드포인트를 사용하여 웹 브라우저에 대한 세션과 쿠키를 지운다.
client는 로그아웃을 성궁후 OidcClientInitiatedLogoutSuccessHandler 를 호출하여 OpenId Provider 세션 로그아웃을 요청한다.
OpenID Provider는 로그아웃이 성공하면 지정된 위치로 redirect 한다.
auth server 메타데이터 사양에 있는 로그아웃 엔드포인트는 end_session_endpoint로 정의되어 있다.

@Configuration(proxyBeanMethods = false)
public class OAuth2ClientConfig {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;

    @Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
        http.oauth2Login(Customizer.withDefaults());
        http.logout()
                .logoutSuccessHandler(oidcLogoutSuccessHandler())
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID");

        return http.build();
    }

    private OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler successHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
        successHandler.setPostLogoutRedirectUri("http://localhost:8081/login");
        return successHandler;
    }
}

실제 로그아웃을 하는 LogoutFilter에서 LogoutHandler를 구현한 여러 handler들이 기본적으로 쿠케 , 세션 , SecurityContext를 지운다.
그 이후 open ID Provider endpoint에게 session을 종료해달라는 요청을 날리게 된다.(end_session_endpoint)
요청에는 postLogoutRedirectUri 도 포함이 되어 로그아웃이 된이후에 설정된대로 로그인 페이지로 가게된다.
해당 postLogoutRedirectUri 또한 auth server에 (keycloak인 경우에 Clients 설정에서 Valid post logout redriect URIs에서) 설정을 해줘야한다.


Spring MVC 인증 객체 참조하기

2가지 방법으로 참조가능하다.

 

Authentication

public void dashboard(Authentication authentication) {}

oauth2Login()으로 인증을 받으면 Authentication은 OAuth2AuthenticationToken 타입의 객체로 바인딩 된다.
principal에는 OAuth2User 타입 혹은 OidcUser 타입의 구현체가 저장된다.
DefaultOAuth2User는 /userInfo 엔드포인트 요청으로 받은 User Claim 정보로 생성된 객체이다. 
DefaultOidcUser는 Open ID Connect인증을 통해 ID Token 및 클레임 정보가 포함된 객체이다.

 

@AuthenticationPrincipal

public void dashboard(@AuthenticationPrincipal Oauth2User principal or OidcUser principal){}

AuthenticationPrincipalArgumentResolver 클래스에서 요청을 가로채어 바인딩 처리를 한다.
Authentication 를 SecurityContext 로 부터 꺼내어 와서 principal 속성에 OAuth2User 혹은 OidcUser 타입의 객체를 저장한다.

@GetMapping("/user")
public OAuth2User user(Authentication authentication) {
    OAuth2AuthenticationToken authentication1 = (OAuth2AuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    OAuth2AuthenticationToken authentication2 = (OAuth2AuthenticationToken) authentication;
    OAuth2User oAuth2User = authentication1.getPrincipal();
    return oAuth2User;
}

@GetMapping("/oauth2User")
public OAuth2User oAuth2User(@AuthenticationPrincipal OAuth2User oAuth2User) {
    System.out.println("oAuth2User = " + oAuth2User);
    return oAuth2User;
}
728x90