본문 바로가기
Spring|Spring-boot

OAuth 2.0 Spring Security 연동, 기능 파악

by oncerun 2023. 6. 27.
반응형

노션에 작성하고 복사 붙여 넣기 하는 게 더 이쁜 것 같다...

 

현재까지 개발 상황 : https://github.com/sungil-yu/oauth

 

GitHub - sungil-yu/oauth: Spring Boot 3.1, JDK 17, OAuth 2.0, OIDC Practice

Spring Boot 3.1, JDK 17, OAuth 2.0, OIDC Practice. Contribute to sungil-yu/oauth development by creating an account on GitHub.

github.com

 

2023.06.21 - [Spring|Spring-boot] - OAuth 2.0 동작방식, 개발환경 구성, 클라이언트 등록, 프런트 개발

 

OAuth 2.0 동작방식, 개발환경 구성, 클라이언트 등록, 프론트 개발

개발환경 OAuth 2.0 Naver, Google, Facebook OAuth2.0 연동 docker Spring Security 3.1 Spring Data JPA JDK 17 Spring boot 3.1 MySQL OIDC thymeleaf 테스트 개발환경 JUnit5 H2 DB 목표 1. JDK 17의 도입된 새로운 문법 및 API를 활용한다

chinggin.tistory.com

 

 

개발을 위해 로그인 프로세스를 간략하게 정리해본다.

 

  • User가 Client를 통해 리소스 서버의 인증 요청을 보낸다. 
  • 리소스 서버의 인증 과정을 마무리한 후 사전에 정의된 redirect_url에서 Access Token을 발급받기 위한 과정 거침
  • Token에 있는 사용자 정보로 회원 저장 및 인증을 마무리한다.
  • 인가에 이를 활용한다. 

 

 

User가 Client를 통해 리소스 서버에 자신임을 인증하는 과정을 개발

 

나는 여기서 어떻게 인증요청을 해야 할지 약간 망설였다. 이 부분도 서버로 보내서 응답받고 클라이언트를 redirect 시켜야 하나?라는 생각이 들었는데,

 

이때 넘겨지는 Query Parameter의 값이 보안 상 중요한 값이 아니다. 따라서 <a> 태그를 활용하여 즉시 사용할 수 있게 하였다.

 

 <a href="https://accounts.google.com/o/oauth2/v2/auth?cluent_id=183986738709-8oses99dcre7kldsuks651es2aaao195.apps.googleusercontent.com&redirect_uri=https://localhost:8080/login/oauth2/code/google&
scope=email profile openid&response_type=code&client_id=183986738709-8oses99dcre7kldsuks651es2aaao195.apps.googleusercontent.com" class="google-button">구글로 계속하기</a>

 

--- 추가.

 

위 부분처럼 직접 처리하는 게 아니라. 서버에 다음과 같이 요청을 보내도록 해야 합니다. 그래야 Spring Security가 많은 과정을 설정 파일을 기반으로 스스로 진행해 줍니다. 

 

즉 application.yml에 정의한  spring.security.oauth2.client.registration. 속성 값을 토대로 Oauth2 ClientAutoConfiguration 클래스가 활성화된다고 합니다.

<a th:href="@{/oauth2/authorization/google}" class="google-button">구글로 계속하기</a>

 

해당 URL은 공식문서를 보면 쉽게 변경할 수 있습니다. 

 

리소스 서버의 인증 과정을 마무리한 후 사전에 정의된 redirect_url에서 Access Token을 발급받기 위한 과정 거침

 

git pull 이후 시작하려는데 오류가 발생한다.

 


Description:

Parameter 0 of method setFilterChains in org.springframework.security.config.annotation.web.configuratio n.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientR egistrationRepository' that could not be found.


Action:
Consider defining a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' in your configuration.

 

 

ClientRegistrationRepository Bean이 필요한데, 해당 빈을 찾을 수 없으니 해당 빈을 구성하라고 안내한다. 

 

 

http.authorizeHttpRequests(authorizeRequests -> authorizeRequests
                .requestMatchers("/login", "/css/**", "/", "/img/**", "/download/**", "/static/**", "/h2-console/**").permitAll()
                .anyRequest().authenticated())
        .formLogin(AbstractHttpConfigurer::disable)
        .oauth2Login(oauth2 -> oauth2
                .loginPage("/login").permitAll()
                .authorizationEndpoint(authorization -> authorization
                        .baseUri("/login/oauth2/authorization")
                )
        );

 

이 코드에서 oauth2 Login이라는 부분에서 문제가 발생한 것 같으니 ClientRegistrationRepository가 뭔지 확인해 보고 적용해 보자.

 

 

https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/oauth2/client/registration/ClientRegistrationRepository.html

 

ClientRegistrationRepository (spring-security-docs 6.1.1 API)

A repository for OAuth 2.0 / OpenID Connect 1.0 ClientRegistration(s). NOTE: Client registration information is ultimately stored and owned by the associated Authorization Server. Therefore, this repository provides the capability to store a sub-set copy o

docs.spring.io

 

여기에 따르면 다음과 같이 설명되어 있다 조금 쉽게 번역해 본다.

 

" 유저의 등록 정보는 관련된 권한 부여 서버에 저장되고 소유됩니다. 결과적으로 ClientRegistrationRepository는 유저의 등록 정보의 부분 복사본을 인증 서버 외부에 저장하는 능력을 제공해야 한다."

 

그런데 스프링 부트를 사용하면 속성 값을 토대로 ClientRegistrationRepository 빈을 정의해 주는 것 같습니다.

 

스프링을 사용하는 경우에는 해당 빈을 정의해야 합니다.

 

에러의 원인은 git pull을 통해 받았을 때 application-oauth2.yml이 없어서 발생한 오류로 보입니다. 

 

application-oauth2.yml을 추가하니 스프링 부트가 자동 구성을 통해 ClientRegistrationRepository 빈을 등록하여 오류가 발생하지 않습니다.

 

다음과 같이 코드를 변경하고 기본값을 사용하면 어떻게 되나 확인해 봤습니다.

  http.authorizeHttpRequests(authorizeRequests -> authorizeRequests
                    .requestMatchers("/login", "/css/**", "/", "/img/**", "/download/**", "/static/**", "/h2-console/**").permitAll()
                    .anyRequest().authenticated())
            .oauth2Login(Customizer.withDefaults());

default UI

 

이는 loginPage() 메서드를 통해 커스텀 로그인 페이지를 설정하지 않으면 DefaultLoginPageGeneratingFilter 클래스의 

generateLoginPageHtml() 메서드가 동작하여 생성해 주는 로그인 페이지입니다.

 

 

해당 페이지의 link를 봐서 어떻게 서버에 요청을 보내야 하는지 크롬 개발자 도구로 url을 훔쳐보도록 하겠습니다.

 

 

 

/oauth2/authorization/google 이렇게 요청하네요? 저도 a의 href 태그를 변경해 보겠습니다.

 

파라미터를 넘겨주어야 하는데, 이를 어디다가 넘겨줘야 할까요? 서버에 전달한다는 건 서버 통신을 사용한다는 거 아닌가요? 

 

답은 application-oauth2.yml에 정의해 주면 됩니다. 

redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
  - email
  - profile
  - openid
redirect-uri: "{baseScheme}://{baseHost}{basePort}{basePath}/authorized/{registrationId}"


.* Test 환경인 localhost가 아닌 경우 https 통신만을 지원한다고 하는 것 같습니다.

 

이제 제대로 되는군요. 실제 해당 요청을 서버에 받기 위해선 spring security의 requestMatchers에 "/ouath2/**"는 permitAll을 해주어야 합니다.

 

즉 OAuth2.0 EndPoint 요청은 인증을 하지 않는다고 명시하는 겁니다.

.requestMatchers("/oauth2/**").permitAll()

 

다음은 구현하지 않았지만 로그인을 시도해 보니 다음과 같은 오류가 발생합니다.

이 오류는 https로 redirect_uri로 구글에서 설정해서 발생한 문제로 https가 아닌데 http로 요청해서 header를 파싱 하지 못한 것 같습니다. 다시 http로 변경해 주고 시도하니 정상처리 되었습니다.

 

다음과 같이 Spring Security에 설정해 주고 성공 시 defaultSuccessUrl로 이동하도록 합니다.

.oauth2Login( oauth2 -> oauth2
        .loginPage("/login")
        .defaultSuccessUrl("/main")
        .failureUrl("/login?error=true")
        .permitAll()

 

만약 사용자가 인증 전에 권한이 필요한 페이지를 방문한 경우 로그인 후 해당 페이지로 리다이렉션 됩니다.

 

만약 보안페이지에 있었는지 상관없이 사용자를 항상 특정 url로 보내려면 defaultSuccessUrl을 사용하면 됩니다.

 

만약 사용자 지정 핸들러를 사용하려면 AuthenticationSuccessHandler 또는 AuthenticationFailureHandler를 구현하는 클래스를 만들고 메서드를 재정의 한 다음 빈을 설정해야 한다고 합니다. 

 

 

중간에 내부 과정이 너무 생략되어 있습니다. 그 과정을 살펴보자.

 

아무리 생각해도 oauth 2.0 인증을 위한 컨트롤러를 자동으로 구현해 주는 것이나, 인증 후 리다이렉트 시켜주는 부분은 

org.springframework.boot:spring-boot-starter-oauth2-client

 

해당 라이브러리를 사용하는 걸로 보이는데,  

 

다음 인터페이스를 조금 살펴보자.

 

/**
 * Implementations of this interface are responsible for obtaining the user attributes of
 * the End-User (Resource Owner) from the UserInfo Endpoint using the
 * {@link OAuth2UserRequest#getAccessToken() Access Token} granted to the
 * {@link OAuth2UserRequest#getClientRegistration() Client} and returning an
 * {@link AuthenticatedPrincipal} in the form of an {@link OAuth2User}.
 *
 * @param <R> The type of OAuth 2.0 User Request
 * @param <U> The type of OAuth 2.0 User
 * @author Joe Grandja
 * @since 5.0
 * @see OAuth2UserRequest
 * @see OAuth2User
 * @see AuthenticatedPrincipal
 */
@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {

   /**
    * Returns an {@link OAuth2User} after obtaining the user attributes of the End-User
    * from the UserInfo Endpoint.
    * @param userRequest the user request
    * @return an {@link OAuth2User}
    * @throws OAuth2AuthenticationException if an error occurs while attempting to obtain
    * the user attributes from the UserInfo Endpoint
    */
   U loadUser(R userRequest) throws OAuth2AuthenticationException;

}

 

 

이 인터페이스의 구현은 클라이언트에 부여된 액세스 토큰을 사용하여 UserInfo 엔드포인트에서 최종 사용자(리소스 소유자)의 사용자 속성을 가져오고 OAuth2 User의 형태로 AuthenticatedPrincipal을 반환하는 역할을 담당합니다.

 

매개변수가 OAuth2 UserRequest 타입의 객체입니다. 이 객체에 소셜에서 사용자 인증을 마친 최종 access token을 가지고 있다고 합니다. 

 

 

 

.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
        .userService(customOAuth2AuthorizedClientService)
)

 

다음과 같이 userInfoEndpoint를 사용하면, 다음과 같은 과정이 내부적으로 처리되는 것 같습니다.

 

1. Authorization Code Grant 방식에서 리소스 오너가 로그인을 진행한 이후

2. 승인된 정보가 맞다면 권한 승인 코드(Authorization Code)를 전달해 주고,

3. Client는 해당 code 및 사전 secret key를 추가해 Access Token을 요청하고 이를 응답받는다. 

 

 

다음과 같이 Spring Security debug 모드를 사용하여 요청 시 어떠한 필터를 타는지 확인해 보았습니다.

 

************************************************************

Request received for GET '/oauth2/authorization/google':

org.apache.catalina.connector.RequestFacade@3bf15554

servletPath:/oauth2/authorization/google
pathInfo:null
headers: 
host: localhost:8080
connection: keep-alive
sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
sec-fetch-site: same-origin
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
referer: http://localhost:8080/login
accept-encoding: gzip, deflate, br
accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
cookie: Idea-cfee5284=dba12f15-79f0-4988-938b-899396a4a630; JSESSIONID=40918576F931EA2068D6CD31A7D7497D


Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  CsrfFilter
  LogoutFilter
  OAuth2AuthorizationRequestRedirectFilter
  OAuth2LoginAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]


************************************************************

 

 

아래는 주어진 스프링 Security 필터 체인에 대한 간단한 설명입니다:

1. DisableEncodeUrlFilter: URL 인코딩 비활성화 필터로, 인코딩 된 URL을 다시 디코딩하지 않도록 설정합니다.

2. WebAsyncManagerIntegrationFilter: WebAsyncManager를 SecurityContext에 통합하기 위한 필터입니다. 비동기적인 웹 요청 처리 시 SecurityContext를 유지하기 위해 사용됩니다.

3. SecurityContextHolderFilter: SecurityContext를 설정 및 제거하는 필터입니다. 각 요청에 대해 인증 및 인가 관련 정보를 저장하고 관리합니다.

4. HeaderWriterFilter: 응답 헤더에 보안 관련 헤더를 추가하는 필터입니다. 예를 들어, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy 등의 헤더를 설정할 수 있습니다.

5. CsrfFilter: CSRF(Cross-Site Request Forgery) 공격 방지를 위한 필터입니다. 요청에 CSRF 토큰이 필요한지 확인하고 검증합니다.

6. LogoutFilter: 로그아웃을 처리하는 필터로, 사용자를 로그아웃하고 세션을 무효화합니다.

7. OAuth2 AuthorizationRequestRedirectFilter: OAuth 2.0 인증 요청을 처리하는 필터로, OAuth 2.0 인증 과정에서 사용자를 인증 서버로 리디렉션 합니다.

8. OAuth2 LoginAuthenticationFilter: OAuth 2.0 인증 완료 후 사용자 정보를 처리하는 필터입니다. 인증 서버로부터 받은 사용자 정보를 기반으로 인증 및 인가를 처리합니다.

9. RequestCacheAwareFilter: 요청 캐시를 처리하는 필터로, 인증이 필요한 요청이 인증되지 않은 경우에도 요청을 캐시 하여 나중에 인증을 처리할 수 있도록 합니다.

10. SecurityContextHolderAwareRequestFilter: HttpServletRequest를 SecurityContextHolder와 함께 사용할 수 있도록 처리하는 필터입니다.

11. AnonymousAuthenticationFilter: 익명 사용자를 처리하는 필터로, 인증되지 않은 사용자에게 임의로 익명 사용자 인증을 부여합니다.

12. ExceptionTranslationFilter: 예외를 처리하는 필터로, 인증 및 인가 예외를 캐치하고 적절한 처리를 수행합니다.

13. AuthorizationFilter: 인가를 처리하는 필터로, 사용자의 권한과 요청된 자원의 권한을 비교하여 접근을 허용하거나 거부합니다.

 

 

이 중에서 OAuth2 AuthorizationRequestRedirectFilter 필터가 로그인 버튼 클릭 시 리다이렉트시키는 필터인 것 같습니다.

 

 

해당 클래스의 주요 코드를 좀 살펴보겠습니다.

 

private RedirectStrategy authorizationRedirectStrategy = new DefaultRedirectStrategy();

private OAuth2AuthorizationRequestResolver authorizationRequestResolver;

private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
   try {
      OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
      if (authorizationRequest != null) {
         this.sendRedirectForAuthorization(request, response, authorizationRequest);
         return;
      }
   }catch{ ..... }
   
   {...}
   
try {
	filterChain.doFilter(request, response);
}

 

 

 

 

HttpServletRequest 객체를 제공받아 OAuth2AuthorizationRequest 객체를 생성합니다. 

 OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
 
 
 
 ...
 
private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId,
        String redirectUriAction) {
    if (registrationId == null) {
        return null;
    }
    ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
    if (clientRegistration == null) {
        throw new InvalidClientRegistrationIdException("Invalid Client Registration with Id: " + registrationId);
    }
    OAuth2AuthorizationRequest.Builder builder = getBuilder(clientRegistration);

    String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);

    // @formatter:off
    builder.clientId(clientRegistration.getClientId())
            .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
            .redirectUri(redirectUriStr)
            .scopes(clientRegistration.getScopes())
            .state(DEFAULT_STATE_GENERATOR.generateKey());
    // @formatter:on

    this.authorizationRequestCustomizer.accept(builder);

    return builder.build();
}

 

ClientRegistartionRepository를 어떻게 default로 구현하나 살펴봤는데, 인 메모리로 구현하는 것 같다.  

 

설정파일을 기준으로 객체들을 생성하여 인메모리에 올려놓고, 요청 시 특정 URL에서 regestrationId를 통해 ClientRegistration 객체를 가져온 이후, redirect를 위한 OAuth2AuthorizationRequest  객체를 빌드하여 반환하여 사용하는구나.

 

 if (authorizationRequest != null) {
     this.sendRedirectForAuthorization(request, response, authorizationRequest);
     return;
}

 

authorizationRequest가 없다는 건, OAuth 2.0 요청이 아닌 경우를 대비한 것으로 생각되고, 실제 OAuth 2.0 요청이라면

다음 메서드가 실행된다.

 

private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
        OAuth2AuthorizationRequest authorizationRequest) throws IOException {
    if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
        this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
    }
    this.authorizationRedirectStrategy.sendRedirect(request, response,
            authorizationRequest.getAuthorizationRequestUri());
}

 

 

public static final AuthorizationGrantType AUTHORIZATION_CODE = new AuthorizationGrantType("authorization_code");

 

현재는 이 타입에 해당된다.  그렇기 때문에 다음 메서드가 실행된다.

@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,
      HttpServletResponse response) {
   Assert.notNull(request, "request cannot be null");
   Assert.notNull(response, "response cannot be null");
   if (authorizationRequest == null) {
      removeAuthorizationRequest(request, response);
      return;
   }
   String state = authorizationRequest.getState();
   Assert.hasText(state, "authorizationRequest.state cannot be empty");
   request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest);
}

 

세션에 SessionId + ". AUTHORIZATION_REQUEST"라는 세션키와 authorizationRequest 객체를 저장한다. 

 

기본적으로 세션 방식으로 인증정보를 저장한다는 걸 기억하자. 

 

 Authorization Code Grant 방식이 아니면 즉시 다음 메서드가 실행됩니다.

 

public interface RedirectStrategy {

   /**
    * Performs a redirect to the supplied URL
    * @param request the current request
    * @param response the response to redirect
    * @param url the target URL to redirect to, for example "/login"
    */
   void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException;

}

 

redirect URI는 인증서버의 URI를 사용하는군요. 아마 provider인 경우 제공되기 때문에 별도 설정 없이 가능한 것 같습니다. 

 

즉 이 필터는 사용자 요청에 따라 해당 registration의 로그인 페이지로 리다이렉트 시키는 것 까지가 책임입니다. 

 

 

 

이후 OAuth2 LoginAuthenticationFilter 필터가 작동됩니다. 

 

An implementation of an AbstractAuthenticationProcessingFilter for OAuth 2.0 Login.
This authentication Filter handles the processing of an OAuth 2.0 Authorization Response for the authorization code grant flow and delegates an OAuth2LoginAuthenticationToken to the AuthenticationManager to log in the End-User.
The OAuth 2.0 Authorization Response is processed as follows:
Assuming the End-User (Resource Owner) has granted access to the Client, the Authorization Server will append the code and state parameters to the redirect_uri (provided in the Authorization Request) and redirect the End-User's user-agent back to this Filter (the Client).
This Filter will then create an OAuth2LoginAuthenticationToken with the code received and delegate it to the AuthenticationManager to authenticate.
Upon a successful authentication, an OAuth2AuthenticationToken is created (representing the End-User Principal) and associated to the Authorized Client using the OAuth2AuthorizedClientRepository.
Finally, the OAuth2AuthenticationToken is returned and ultimately stored in the SecurityContextRepository to complete the authentication processing.

 

이 과정에서 유저가 권한 서버의 인증을 받아 클라이언트에게 권한을 주면, 해당 필터에서

code를 받아 AccessToken을 발급받습니다. 

 

OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
        redirectUri);
Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
        new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
        .getAuthenticationManager().authenticate(authenticationRequest);
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
        .convert(authenticationResult);
Assert.notNull(oauth2Authentication, "authentication result cannot be null");
oauth2Authentication.setDetails(authenticationDetails);
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
        authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
        authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());

this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
return oauth2Authentication;

 

 

 

이제 인증을 했으니 우리가 원하도록 로직을 작성하는 부분이 필요합니다. 

예를 들어 추가 정보를 통해 데이터베이스에 유저를 저장하거나, 특정 권한을 부여하는 행위를 해야겠지요.

 

 

 

 

 

[참고] https://wildeveloperetrain.tistory.com/248

[참고] https://www.baeldung.com/spring-security-5-oauth2-login

 

반응형

댓글