본문 바로가기
BackEnd

Spring Security +OAuth2가 어떻게 동작하는지 알아보자.

by Ropung 2024. 10. 16.
Oauth 인증 방식은 [표준]이 공식적으로 정리가 되어있다.
카카오톡, 구글, 내가 만든 인증 서버든 OAuth 인증 방식을 사용한다면, 모두 동일한 표준스펙으로 구현해야 한다.
다만, 스펙이 너무 방대하고 디테일해서 인증 서버를 구현하기에는 힘들다.
Spring-security 프로젝트에서는 OAuth인증 기능 쉽게 구현할 수 있도록 라이브러리나 프레임워크를 제공한다.
이 글에서는 Spring-security + OAuth2를 이용 할 때 인증인가 흐름을 살펴보려고 한다.

Spring Security란?


스프링 시큐리티는 인증과 인가 기능을 가진 프레임워크이다. 스프링의 하위 프레임워크로 Filter를 활용하여 처리를 하고 있다.  
Spring 기반 애플리케이션을 보호하기 위해 사실상 표준으로 사용된다.  
(자세한 설명은 공식문서를 참조)

 

OAuth2란?


인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹 사이트 상의 자신들의 정보에 대해 웹 사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는 접근 위임을 위한 개방형 표준이다.

간단하게 말하면 구글, 카카오톡, 네이버 등 외부 계정을 기반으로 간편하게 인증하는 기능이라 할 수 있겠다.  
토큰을 기반으로 인증인가 하는 프로토콜(규약)이라 이해하면 될 것 같다.  
(자세한 설명은 블로그 참조)

개요


이 글은 프레임워크를 한바퀴 둘러보며 흐름을 파악을 정리한 글이다.   
원래는 구현 코드와 함께 작성하려했으나 워낙 양이 방대하여 흐름정도만 간단하게 정리해 보았다. 

 

플로우 다이어그램


전체적인 흐름은 아래 그림과 같다.


좀 더 간단히 보면 아래와 같다.

요약

1. 사용자가 /oauth2/authorization/{registrationId}로 진입 → Spring Security에서 OAuth2 요청 시작.  
2. Spring Security가 OAuth2 제공자로 리디렉션 → 사용자가 로그인 및 권한 부여 수행.  
3. 제공자가 authorization code를 클라이언트로 리디렉션.  
4. 클라이언트가 authorization code로 access token 요청.  
5. 클라이언트가 access token으로 사용자 정보 요청 → OAuth2User 생성.  
6. 인증 성공 후 사용자 애플리케이션으로 리디렉션 → 인증 상태 유지.  

 

먼저 Spring Security가 클라이언트 요청을 처리하는 과정을 살펴보자


Spring Security는 필터 기반의 보안 구조를 사용한다.  
(그 밖에 다양한 컴코넌트를 사용하지만 인증/인가 부분만 살펴보자)

Spring은 자체적으로 서블릿 필터를 직접 관리할 수 없다.  
필터는 서블릿 컨테이너(Tomcat, Jetty 등)에 의해 관리되며, 기본적으로 Spring기능(의존성주입, AOP, 트랜잭션)을 지원하지 않는다.  
다만, Spring 1.2 부터 DelegatingFilterProxy가 나오면서 서블릿 필터 역시 스프링에서 관리가 가능해졌다.

좀 더 딥하게 보고 싶다면 아래 [공식문서]에서 확인 가능하다.

DelegatingFilterProxy 가 하는일


Spring 컨텍스트와 서블릿 필터간의 다리 역할을 하며 Spring 빈 생명주기와 서블릿 필터를 통합시킨다.  
서블릿 컨테이너가 필터를 초기화할 때, Spring 컨텍스트에서 관리되는 실제 필터 빈을 찾아 요청 처리를 위임한다.  

이를 통해 Spring의 기능(의존성 주입, AOP 등)을 필터 로직에서 사용 할 수 있게 해준다.  
DelegatingFilterProxyspringSecurityFilterChain 이름을 가진 빈을 사용해 필터 체인으로 요청을 위임한다.

 

FilterChainProxy - Bean name: springSecurityFilterChain


FilterChainProxy는 기본적으로 springSecurityFilterChain이라는 이름의 빈을 찾아 등록된다.  
Spring Boot에서 자동으로 등록되며, Spring 컨텍스트에서 관리된다.

클라이언트 요청이 들어오면 요청을 가로채어 SecurityFilterChain에 있는 여러 개의 필터들을 거치고 특정 보안 로직을 실행한 후, 어플리케이션으로 전달하게 된다.

요약

1. Spring Security는 인증/인가를 서블릿 필터에서 처리한다.  
2. 서블릿 필터를 직접적으로 쓸 수 없어서 DelegatingFilterProxy를 위임해서 사용하는데 springSecurityFilterChain이름을 가진 빈을 사용해 필터 체인으로 요청을 위임한다.  
3. FilterChainProxy의 빈 이름은 springSecurityFilterChain으로 등록되며, 들어오는 요청을 가로챈다.

 

 

흐름을 살펴보자


FilterChainProxy는 서블릿 필터를 위임받은 클래스이다.  
사용자의 요청을 가로채어 doFilter()를 통해 첫 번째 진입한다.  흐름을 코드로 살펴보자.
(자세한 내용은 주석으로 적어놨다.)


FilterChainProxy.java

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    
    // FILTER_APPLIED 속성을 통해 해당 요청이 이미 필터 체인을 통과했는지 확인한다.
    // clearContext는 이 요청이 첫 요청인지를 나타냄 (필터가 이미 적용된 경우 false)
    boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
    
    // 필터 체인이 이미 적용된 경우, doFilterInternal()을 호출하고 즉시 종료한다.
    if (!clearContext) {
        doFilterInternal(request, response, chain);
        return;
    }

    // 필터 체인이 처음 적용되는 경우, FILTER_APPLIED 속성을 설정하여 첫 요청임을 표시하고,
    // 필터 체인을 통해 요청을 처리한다.
    try {
        // 필터가 이미 적용되었음을 표시하기 위해 FILTER_APPLIED 속성 설정
        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
        
        // 요청을 HttpFirewall을 통해 필터링 하여 잠재적인 공격을 방어한다.
        // 그리고 doFilterInternal()로 실제 필터 체인 실행
        doFilterInternal(request, response, chain);
    }
    catch (Exception ex) {
        // 예외가 발생한 경우, throwableAnalyzer를 사용해 예외의 원인을 분석
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
        
        // RequestRejectedException이 있는지 확인
        Throwable requestRejectedException = this.throwableAnalyzer
            .getFirstThrowableOfType(RequestRejectedException.class, causeChain);
        
        // RequestRejectedException이 없는 경우, 예외를 다시 던짐
        if (!(requestRejectedException instanceof RequestRejectedException)) {
            throw ex;
        }
        
        // RequestRejectedException이 발생한 경우, 해당 요청을 requestRejectedHandler로 처리
        this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response,
                (RequestRejectedException) requestRejectedException);
    }
    finally {
        // SecurityContextHolder의 보안 컨텍스트를 정리 (메모리에서 제거)
        this.securityContextHolderStrategy.clearContext();
        
        // FILTER_APPLIED 속성을 제거하여 다음 요청에서 다시 필터가 적용될 수 있도록 함
        request.removeAttribute(FILTER_APPLIED);
    }
}

private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    
    // HttpFirewall을 사용하여 잠재적인 공격을 방어하고, 안전한 요청 객체 생성
    FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
    
    // 안전한 응답 객체 생성
    HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
    
    // 현재 요청에 적용할 필터 목록을 가져옴 (예: 인증, 인가 등 보안 필터)
    List<Filter> filters = getFilters(firewallRequest);
    
    // 필터가 없거나 필터 목록이 비어 있으면, 원래의 FilterChain으로 요청을 전달
    if (filters == null || filters.size() == 0) {
        if (logger.isTraceEnabled()) {
            // Trace 레벨 로깅: 필터 체인이 없음을 기록
            logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
        }
        
        // firewallRequest를 초기 상태로 리셋
        firewallRequest.reset();
        
        // filterChainDecorator를 사용해 chain에 장식(데코레이터)을 추가하여 원래 필터 체인 실행
        this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse);
        return;
    }

    // 필터가 존재하는 경우: 요청에 대한 보안 처리가 진행됨을 로그에 기록
    if (logger.isDebugEnabled()) {
        logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
    }

    // 필터 체인이 완료된 후, 원래의 FilterChain으로 요청을 넘겨주는 reset 필터 체인을 정의
    FilterChain reset = (req, res) -> {
        if (logger.isDebugEnabled()) {
            // Debug 레벨 로깅: 요청이 보안 처리가 완료되었음을 기록
            logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest)));
        }
        
        // 보안 필터 체인에서 나갈 때, path stripping을 비활성화하며 요청을 원래 상태로 리셋
        firewallRequest.reset();
        
        // 원래의 필터 체인으로 요청을 넘김
        chain.doFilter(req, res);
    };

    // filterChainDecorator를 사용해 장식된 필터 체인을 실행하며,
    // 필터 목록을 통해 각 필터가 차례로 실행됨
    this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse);
}

 

 

필터는 어떻게 처리되고 어떤 종류들이 있을까?


Spring Security에서 사용하는 필터들은 HTTP 요청과 응답을 처리하며, 인증, 인가, 세션 관리, CSRF 등의 필터를 제공한다.  
Spring Security는 여러개의 필터 체인으로 구성하여 순차적으로 요청을 처리한다.

필터 이름 설명
UsernamePasswordAuthentication 폼 기반 로그인을 처리하는 필터
BearerTokenAuthenticationFilter OAuth2 또는 JWT 기반 인증에서 사용되는 Bearer 토큰을 처리하는 필터
OAuth2AuthorizationRequestRedirectFilter Oauth2 로그인 요청을 처리하여 외부 OAuth2 인증 서버로 리디렉션하는 필터
OAuth2LoginAuthenticationFilter  OAuth2 로그인 후 리디렉션된 사용자를 인증하는 필터
CorsFilter CORS-Origin Resource Sharing 설정을 처리하는 필터
등등  그 밖에 무수한 필터가 있지만, 너무 많아 생략


필터 체인안에 구성된 필터들이 순차적으로 요청을 처리하는 정도로만 이해했다.  
(순서가 정해져 있다고하는데 중요한 필터 순서대로 필터링하는 정도만 이해하면 될  것 같다.)

정리

큰틀에서 보았을 때, 클라이언트 요청을 받고 서블릿 필터에서 요청을 가로채 필터 체인에 등록된 필터들을 순서대로 필터링 거치는 스프링 시큐리티의 인증/인가를 살펴보았다.

이제 전체적인 흐름을 단계별로 살펴보자. 

 



1. 소셜 로그인(요청)


1. Resource Owner(소유자)는 해당하는 Oauth2 로그인 버튼을 누른다.
2. 버튼을 누르면 연결해둔 http://{도메인주소}/oauth2/authorization/{registrationId} URL로 Client(서버)에게 요청을 보낸다.  

// 백엔드 서버의 경우 포트는 8080으로 예시를 들었다.
<div>
    <button
      onClick={() => {
        window.location.href =
          'http://localhost:8080/oauth2/authorization/google';
        console.log('구글 로그인');
      }}
    >
      구글로그인
    </button>
<div/>

 

3. Client(서버)는 요청을 받아 작업을 처리 할 것 이다.  

 

잠깐, 우리는 URL에 대한 작업을 하지 않았다.. 왜 동작하는 것일까?


URL을 받을 컨트롤러도 안만들었고 ..
우리(개발자)는 위에 해당하는 주소의 API를 받는 작업을 하지 않았다.

 

일반적인 API 요청을 받는 컨트롤러 작업


Spring Boot에서 프론트의 API 작업을 위해 보통은 Controller(Api)를 만들어 API 요청을 받아 처리한다.  

@RequiredArgsConstructor
@RestController
public class AuthApi { // AuthController
    @GetMapping("/oauth2/authoriztion/{registrationId}")
    public LoginResponse loginSuccess(@RequestParam String registrationId) {
     // 비즈니스 로직 ...
        return null;
    }
}

 

위 예시처럼 일반적으론 API 요청을 받을 컨트롤러를 만들것이다.

 

하지만 만들 필요없다 대부분의 기능을 Spring-Security는 내부적으로 구현하여 지원해준다.


/oauth2/authoriztion/{registrationId} 에 매핑되는 컨트롤러를 개발자가 만들 필요없이 Security 프레임워크가 제공해준다.  
(그 밖에 토큰 인증, 인가 코드받기, 등등을 해줘서 개발자가 비즈니스 로직에 좀 더 집중 할 수 있게 해준다.)

OAuth 인증 방식을 사용한다면, 모두 동일한 표준스펙으로 구현해야 한다.

이것을 직접 구현하려고 한다면 스펙이 너무 방대하고 디테일해서 인증 서버를 구현하기에는 환경상 무리가 있어서
좀 더 중요한 비즈니스 로직에 집중하는게 시간을 아끼는 길이라 생각한다.

OAuth2 필터링을 살펴보자


 

OAuth2AuthorizationRequestRedirectFilter.java

 

public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
     // 승인 요청에 사용되는 기본 베이스 {@code URI}
public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";

// 그 밖에 필드들...

/** 
 * BASE_URI과 clientRegistrationRepository가 있는데 
 * /oauth2/authorization/ 뒤에 붙는 registrationId를 가져와 생성자를 만드는거라 유추 할 수 있다.
 */
public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository) {
this(clientRegistrationRepository, DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
}

/**
 * Constructs an {@code OAuth2AuthorizationRequestRedirectFilter} using the provided
 * parameters.
 * @param clientRegistrationRepository the repository of client registrations
 * @param authorizationRequestBaseUri the base {@code URI} used for authorization
 * requests
 * 
 */
public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository,
        String authorizationRequestBaseUri) {
    Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
    Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty");
    // google, kakao 등의 registrationId를 가져오는 부분
    this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository,
            authorizationRequestBaseUri);
}



// 그 밖에 매서드들...

 

유저의 요청을 받은 URL clientRegistrationRepository를 조회해서 google, kakao 등의 registrationId를 가져와 생성자를 만든다.

clientRegistrationRepository는 인터페이스로 구성되어있는데 실질적인 registationId를 추출하는 작업은

DefaultOAuth2AuthorizationRequestResolver 클래스에서 하는것으로 보인다.

DefaultOAuth2AuthorizationRequestResolver 클래스의 resolve() 매서드는 clientRegistrationRepositorty.findByRegistrationId(..)에서 request에 담겨있는 registationId를 찾는다.

clientRegistrationRepositorty의 구현체인, InMemoryClientRegistrationRepository 클래스에 있는 findByRegistrationId() 매서드를 오버라이딩 해서 구현했다.

이 구현체의 경우 메모니 내에서 OAuth2 클라이언트 등록 정보를 관리한다. ClientRegistrationRepository 의 디폴트 구현체라고 한다.
(검증해보진 않았다.)
findByRegistrationId() 매서드의 반환하는 타입 ClientRegistration 클래스를 까보면 ..

반환타입의 ClientRegistration 클래스를 까보면 registrationId 가 나온다.
위 클래스에 있는 getRegistrationId() 매서드로 아이디를 가져오는데  

yaml에서 설정한 registration 하위 계층인 google: , facebook: 에 해당된다.
그 밖에 설정도 있는데 공식문서를 참고하면 커스텀도 가능하다.

그렇다면 goolge의 값 즉, registrationId를 불러오는 코드도 있지않을까?
(궁금해서 더 파보았다.)

찾아보니 Spring Boot가 yaml을 읽어와 만드는 과정을 자동으로 처리한다고 한다.
(GitHub core.adoc 파일에 233줄에서 찾아 볼 수 있었다.)

정리하면, OAuth2LoginConfig 클래스에서 yaml 파일을 읽어서 OAuth2가 자동 구성된다 정도로 볼 수 있겠다.

공식문서를 보는게 좀 더  자세 할 것 같아서 더이상 파보진 않았다.
(코어 레벨까지 파면 역시 공식문서에 무조건 있는것 같다.)

요약

1. 로그인요청이 보내는 URL에 해당하는 /oauth2/authoriztion/{registrationId}를 시큐리티 내부적으로 처리하는 로직이 있다.
2. 내부적으로 URL을 분리하여 registrationId를 찾아 해당하는 google, kakao 등을 찾아 내부적으로 처리하더라~

 

2. 소셜 로그인 페이지로 리다이렉트

위에서의 과정으로 URL로 요청을 보내면 필터가 OAuth2AuthorizationRequestResolver를 사용하여 OAuth2 인증 서버로 리디렉션 할 인가 요청을 생성한다. 이 요청에는 클라이언트 ID, 요청된 권한(scope), 상태(state) 등이 포함된다.

왜 이렇게 설계했을까?

이유를 한번 생각해보면, 오너측에선 그냥 로그인 할거에요 요청만 받았지 그 유저가 로그인하는 Thired-Party(구글, 카카오)에서 어떤 권한(전화번호,주소 등)을 줘야하는지 허락을 받지 않았다. 어쨋든 유저한테 허락을 받고 Thired-Party한테 받기 떄문에 우리는 허락을 받기위한 구글에서 만든 페이지로 리다이렉트해서 유저에게 권한(인증)을 얻어야 한다.

DefaultRedirectStrategy.java

필터체인의 필터가 돈 뒤 인증/인가 처리가 완료되면 웹 브라우저를 OAuth2 인증 서버(예: Google 로그인 페이지)로 리다이렉트 한다.
(이때, RedirectStrategy가 사용되는데, 애는 시큐리티에서 HTTP요청을 다른 URL로 리디렉션 하는데 사용되는 전략 인터페이스이다.)

일반적으로 DefaultRedirectStrategy구현체를 사용하여 클라이언트에 설정된 리디렉션을 보낸다.
프론트 페이지는 리디렉션으로 설정된 Oauth2(구글, 카카오톡 등
)로그인 페이지로 이동하고 우리가 잘아는 OAuth2 로그인 창이 뜬다.

(만약, 오류가 뜬다면 yaml에서 설정 된 값이 잘못되었는지 확인해보자)

 

3. 권한허가


구글(개발자 콘솔), 카카오톡(개발자 사이트) 등에서 설정한 권한을 사용자에게 허가 및 인증받는다.
(따로 설정하기보단 링크로 대체했다)

사용자가 로그인 및 권한을 허가한다.

 

4. 승인요청,  리다이렉트


사용자가 로그인 및 권한 요청을 구글 애플리케이션이 인가하면 OAuth2 리디렉션으로 설정한(아래 사진) URL에 인증코드를 담아 발급한다.

 

위와 마찬가지로, 구글에서 리다이렉트 되는 URL에 대응되는 컨트롤러를 만들지 않았다.


OAuth2(예시: 구글)에서는 /login/oauth2/code/google?code=AUTH_CODE&state=STATE 이런식의 코드로 Client에게 리다이렉트 한다. 개발자는 URL(/login/oauth2/code/google)에 매핑하는 컨트롤러를 만들 필요없이, 시큐니티에 내부적으로 구현이 되어있는것을 사용하면 된다.

OAuth2LoginAuthenticationFilter.java

위 클래스는 구글에서 넘기는 리다이렉트 된 요청을 처리하는 필터 클래스이다.
Google이 반환한 URL + 인가 코드(Authoriztion Code: .../google?code=AUTH_CODE&state=STATE)를 받는다.

 

5. 접속 요청, 6. 엑세스 토큰 발급


위에서 전달 받은 Authoriztion Code를 받아, OAuth2LoginAuthenticationFilter 필터는 인가코드를 추출한다.

OAuth2LoginAuthenticationFilter.java의 attemptAuthentication() 매서드

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
    // 1. 파라미터 맵을 추출하여 MultiValueMap 형태로 변환
    MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());

    // 2. 요청이 OAuth2 Authorization Response인지 확인 (인가 코드와 같은 필수 파라미터 포함 여부 체크)
    if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
        OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }

    // 3. 클라이언트가 이전에 생성한 Authorization Request를 세션 등에서 가져옴 (인가 코드 매칭에 필요)
    OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
        .removeAuthorizationRequest(request, response);
    if (authorizationRequest == null) {
        // 3-1. Authorization Request가 존재하지 않는다면 예외 처리
        OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }

    // 4. Client Registration 정보를 가져옴 (Google 등 클라이언트 설정 정보)
    String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
    ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
    if (clientRegistration == null) {
        // 4-1. Client Registration이 존재하지 않는 경우 예외 처리
        OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
                "Client Registration not found with Id: " + registrationId, null);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }

    // 5. 현재 요청 URI를 기준으로 리디렉션 URI를 재구성 (토큰 교환에 필요)
    String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
            .replaceQuery(null)
            .build()
            .toUriString();

    // 6. 인가 응답을 OAuth2AuthorizationResponse 객체로 변환 (인가 코드와 상태 값 포함)
    OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);

    // 7. OAuth2LoginAuthenticationToken 생성 (인가 코드와 클라이언트 정보를 포함한 인증 요청 객체)
    Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
    OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
            new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
    authenticationRequest.setDetails(authenticationDetails);

    // 8. AuthenticationManager를 사용하여 OAuth2LoginAuthenticationToken을 인증 처리 (토큰 교환 수행)
    OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
        .getAuthenticationManager()
        .authenticate(authenticationRequest);

    // 9. 인증 결과로부터 최종 OAuth2AuthenticationToken 생성 (Principal 정보와 함께)
    OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
        .convert(authenticationResult);
    Assert.notNull(oauth2Authentication, "authentication result cannot be null");
    oauth2Authentication.setDetails(authenticationDetails);

    // 10. Authorized Client를 생성하고 저장 (액세스 토큰 및 리프레시 토큰 저장)
    OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
            authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
            authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());

    // 11. Authorized Client 정보를 저장소에 저장 (세션 또는 데이터베이스)
    this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);

    // 12. 최종 인증 객체 반환 (SecurityContext에 저장)
    return oauth2Authentication;
}

 

필터에 이 매서드는는 인가 코드를 추출하고 OAuth2LoginAuthenticationToken (7)을 생성한다.

실제로 토큰을 직접 처리하지않고..

AuthenticationManager에게 위임한다.  토큰 작업은 실제로 구현체인 OAuth2LoginAuthenticationProvider 에서 토큰 관련 작업을 수행한다.

OAuth2LoginAuthenticationProvider.java

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    // 1. OAuth2LoginAuthenticationToken으로 형변환 (인증 요청 객체)
    OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
    
    // 2. OpenID Connect 처리 확인
    // OpenID Connect 요청인 경우 (scopes에 "openid"가 포함된 경우), null을 반환하여 
    // OidcAuthorizationCodeAuthenticationProvider가 처리하도록 넘깁니다.
    // 이는 OpenID Connect와 OAuth2의 처리 로직을 분리하기 위함입니다.
    if (loginAuthenticationToken.getAuthorizationExchange()
        .getAuthorizationRequest()
        .getScopes()
        .contains("openid")) {
        // OpenID Connect 요청이므로 null을 반환
        return null;
    }

    // 3. OAuth2AuthorizationCodeAuthenticationToken 객체 선언
    OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
    try {
        // 4. OAuth2AuthorizationCodeAuthenticationProvider를 통해 인가 코드를 액세스 토큰으로 교환
        // OAuth2AuthorizationCodeAuthenticationToken을 생성하여 인증 요청 수행
        authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
            .authenticate(
                new OAuth2AuthorizationCodeAuthenticationToken(
                    loginAuthenticationToken.getClientRegistration(), // 클라이언트 등록 정보 (Google 등)
                    loginAuthenticationToken.getAuthorizationExchange() // 인가 요청 및 응답 정보
                )
            );
    } catch (OAuth2AuthorizationException ex) {
        // 5. 토큰 교환 과정에서 예외가 발생한 경우 처리
        // OAuth2AuthorizationException에서 발생한 에러를 OAuth2AuthenticationException으로 변환하여 던짐
        OAuth2Error oauth2Error = ex.getError();
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
    }

    // 6. 액세스 토큰 추출
    // 성공적으로 교환된 액세스 토큰을 가져옵니다.
    OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
    Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
    
    // 7. 사용자 정보 로드
    // 액세스 토큰을 이용해 사용자 정보를 가져옵니다.
    OAuth2User oauth2User = this.userService.loadUser(
        new OAuth2UserRequest(
            loginAuthenticationToken.getClientRegistration(), // 클라이언트 등록 정보
            accessToken, // 교환한 액세스 토큰
            additionalParameters // 추가 매개변수 (예: id_token 등)
        )
    );

    // 8. 사용자 권한 매핑
    // 로드된 사용자 권한을 Spring Security의 권한 객체로 매핑합니다.
    Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
        .mapAuthorities(oauth2User.getAuthorities());

    // 9. 최종적으로 인증된 OAuth2LoginAuthenticationToken 생성
    OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
        loginAuthenticationToken.getClientRegistration(), // 클라이언트 등록 정보
        loginAuthenticationToken.getAuthorizationExchange(), // 인가 요청 및 응답 정보
        oauth2User, // 로드된 사용자 정보
        mappedAuthorities, // 매핑된 사용자 권한 정보
        accessToken, // 액세스 토큰
        authorizationCodeAuthenticationToken.getRefreshToken() // 리프레시 토큰 (옵션)
    );
    
    // 10. 인증 세부 정보 설정
    // 인증 요청 시 포함된 세부 정보를 인증 결과에 설정합니다.
    authenticationResult.setDetails(loginAuthenticationToken.getDetails());
    
    // 11. 인증된 토큰 반환
    // 인증된 OAuth2LoginAuthenticationToken을 반환하여 이후 SecurityContext에 저장됩니다.
    return authenticationResult;
}

OAuth2LoginAuthenticationProvider는 Token을 받아 OAuth2 인증을 수행하고, 토큰을 교환한다.

OAuth2AccessTokenResponseClient 를 사용하여 실제 HTTP 요청이 수행된다.

엑세스 토큰 요청을 할때 HTTP POST 방식으로 전송되며, 인가 코드, 리디렉션 URI, 클라이언트 ID, 시크릿 등이 포함된다.
이러한 정보들을 떄려서 성공적으로 요청이 처리되면, 엑세스 토큰과 함께 사용자 정보가 반환된다.

 

7. 리소스 요청, 8. 리소스 응답

발급받은 엑세스토큰을 가지고 OAuth2LoginAuthenticationProvider가 OAuth2UserService를 사용하여 OAuth2 서버에서 사용자 정보 엔드포인트에 요청한다.

OAuth2LoginAuthenticationProvider (7번 주석)에서 OAuth2UserService가 이 요청을 처리하고 사용자 정보를 받아온다.

사용자 정보는 OAuth2User(SecurityContext) 객체에 저장되며, 이를 통해 애플리케이션은 해당 사용자를 인증된 상태로 처리한다.
이 과정 또한 시큐리티가 자동으로 처리하며, 사용자 정보는 엑세스 토큰을 사용해 요청된 후 애플리케이션에서 관리된다.

 

10. 후처리

마지막으로 받은 정보를 백엔드 비즈니스 로직에서 잘 풀어서 적절하게 사용하면 될 것 같다.
참조 링크는 구현에 많은 도움을 받아서 넣어놨다.

마치며.. Spring Security + OAuth2를 완벽하게 알진 못했지만 흐름정도 파악하는 것만으로 값진 경험이였다.
분석하며 하다보니 집중력이 떨어져서 후반에는 자세한 설명 및 깊이있게 코드를 까보진 않아서 조금 아쉬웠지만,
이정도 이해하고 쓰기에는 크게 문제는 없을 것 같다.

다음 포스팅은 Spring Security + JWT + OAuth2 + Redis 구현부분을 다룰 예정이다.

'BackEnd' 카테고리의 다른 글

OAuth 2.0가 뭐야?  (6) 2024.10.08