Spring Security + OAuth (1): OAuth2UserService

Ajisai·2024년 6월 12일
0

Spring Security

목록 보기
6/7

이 글을 시작으로 작성할 내용

  • 스프링 시큐리티에서 인증의 (대략적인) 흐름
  • 스프링 시큐리티로 OAuth 로그인을 구현하는 방법
  • Access Token과 Refresh token을 운용하는 방법
  • 인증 토큰을 Cookie로 관리하는 방법

...이 SSAFY에서의 마지막 프로젝트를 하며 알게 된 내용이다.
이번 시리즈로 이 내용을 공유(겸 미래의 나를 위한 기록)하려고 했는데, 몰라서 못 쓰는 내용이 좀 있다.

있어야 되는데 못 쓰는 내용

  • Next.js에서 Cookie를 운용하는 방법
    • 백엔드에서 암만 붙여도 도무지 붙질 않았는데, 이유를 몰랐다. 사실 아직도 모른다.
    • Next.js의 경우 NextCookie라는 걸로 쿠키를 관리해야 하는데 그걸 쓰지 않아서 그런 것으로 추정된다. 확실치 않다.
    • 그래서 프론트에서 응답으로 받아서 직접 쿠키에 붙이는 방식을 채택했다.
  • 인증 성공 후 sendRedirect()를 해줘야 한다.
    • 그런데 왜 해줘야 하는지, 하지 않는 다른 방법은 없는지는 모른다.
    • 이건 Servlet에 대한 이해가 부족한 탓으로 생각돼서, 추후에 Servlet에 대한 학습 후 보충할 예정.

없어도 되지만 있으면 좋은데 못 쓰는 내용

  • Security Filter, ServletFilter, Interceptor, Aspect
    • 모두 어떤 요청이 Controller에 도달하기 전에 동작하는 걸로 알고 있는데, 이 부분도 Servlet에 대한 이해가 필요한 부분으로 생각되어 나중에 작성할 예정.
      • 사실 Spring을 이해하려면 Servlet에 대한 이해가 불가피하다.
    • 몰라도 당장 Spring Security를 사용하는 데에는 크게 문제되지 않는다.

물론 빠진 내용까지 한 번에 쓰면 좋겠지만, 이걸 다 학습하다가는 정작 본 내용을 잊어버릴 것 같아, 그 전에 기록해두려고 한다.
그래서 일단 이 글에 포함되는 내용은 '사용법'에 가깝다.
그렇다고 내부적인 동작 시퀀스를 아예 배제하지는 않겠지만 약간의 디테일은 생략될 수 있다.

양해를 구할 내용

인증과 인가를 혼용할 겁니다. 별 이유는 없고 그냥 헷갈려서 의도치 않게 그렇게 될 것 같습니다.
어 이건 인가인데 왜 인증이라고 썼지? 이건 인증인데 왜 인가로 썼지? 싶으시다면 제발 지적해주세요


Spring security 인증의 흐름(SecurityConfig.filterChain)

@Configuration
@EnableWebSecurity(debug = false)
@RequiredArgsConstructor
public class SecurityConfig {

    public final static String ACCESS_TOKEN_HEADER = "Authorization";
    public final static String REFRESH_TOKEN_HEADER = "RefreshToken";

    private final AuthTokenProvider authTokenProvider;
    private final AuthTokenService authTokenService;
    private final MemberService memberService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        ...

        // OAuth 설정
        http
            .oauth2Login( // OAuth2.0 로그인 활성화
                httpSecurityOAuth2LoginConfigurer ->
                    httpSecurityOAuth2LoginConfigurer
                        .userInfoEndpoint(
                            userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2MemberService())
                        )
                        .successHandler(oAuth2SuccessHandler())
                        .failureHandler(oAuth2FailureHandler())
            );


        return http.build();
    }
    
    @Bean
    public CustomOAuth2MemberService customOAuth2MemberService() {
        return new CustomOAuth2MemberService(memberService);
    }

    @Bean
    public CustomOAuth2SuccessHandler oAuth2SuccessHandler() {
        return new CustomOAuth2SuccessHandler(authTokenProvider);
    }

    @Bean
    public CustomOAuth2FailureHandler oAuth2FailureHandler() {
        return new CustomOAuth2FailureHandler();
    }
}
  • CORS, 예외 처리(exceptionHandling()) 등 인증과 관련없는 부분은 생략했다(원래 있어야 함).
  • CustomXXX는 직접 작성한 부분들이다. 다른 패키지에 작성되어 있고, 여기서는 @Bean으로 등록하기만 한다.
    - 이 세 가지는 실질적으로 인증 과정을 수행하는 객체들이다. 바로 아래에서 서술한다.

개략적인 설정

  1. OAuth 로그인을 활성화해야 한다(oauth2Login()).
  2. user info endpoint를 설정한다(userInfoEndpoint()).
  3. OAuth 로그인 성공 및 실패 시 실행할 내용(oAuth2SuccessHandler(), oAuth2FailureHandler())을 작성한다.

사실 이 모든 것 이전에 Client ID니 뭐니 하는 다른 부가적인(그리고 많은) 설정이 필요하다.
또 구글, 카카오 등 Resource server에서 내 서비스 등록도 해야 한다.
그런데 이것들은 스프링 시큐리티의 구조와는 별 상관이 없으므로 다음 글에서 기술한다.

user info endpoint

  • 사용자 정보를 받아오는 endpoint를 설정한다.
  • endpoint라는 말이 쓰여 URL로 지정할 것 같은 느낌이지만, Spring 내부에서 처리하므로 특정 객체로 등록한다.
  • 정확히는 org.springframework.security.oauth2.client.userinfo.OAuth2UserService 인터페이스의 구현체로 등록한다.
  • 구현체의 종류는 다음이 있다.
    • DelegatingOAuthUserService
      • 다른 OAuth2UserService 인터페이스의 구현체로 인증을 위임(delegate)하는 역할만 한다.
      • 안써봤다(사실 어떤 경우에 쓰는지도 잘 모르겠다).
    • OidcUserService
      • OIDC; Open ID Connect 인증에 쓰인다.
      • 구글 로그인이라 치면 구글에 OAuth로 인가받고, 추가 요청으로 액세스 토큰을 받아오는 경우에 해당한다.
      • 나의 경우 액세스 토큰을 내가 발급했으므로 이걸 쓰지 않았다.
      • 하지만 구글 로그인에서는 왜인지 액세스 토큰을 받아왔다.
        • Spring security에서 기본적으로 재요청을 하는 듯 하다. 나중에 언급하겠지만 구글의 경우 기본적으로 이미 되어 있는 설정이 있어서, 그 때문인 것으로 추정되나 정확한 이유는 모르겠음..
      • 카카오 로그인의 경우 sub(OAuth 로그인에서의 식별자) 값을 받으려면 OIDC를 활성화하고 재요청을 해야 했다.
        • 하지만 어떤 이유인지 이게 안 돼서, 기본적으로 제공되는 id 값을 활용했다(이것도 식별자이긴 하다).
          무슨 값인지는 모르겠지만, 정수값인 걸로 봐서는 그냥 AUTO_INCREMENT 값이 아닌가 싶다(뇌피셜).
    • DefaultOAuth2UserService
      • 기본적인 OAuth 인가에 쓰인다. 나도 이걸 썼다.
  • 설계 관점에서 보면 로직을 수행하는 객체인 Service가 여기에 해당한다.
    • 인증 로직을 수행한다.

org.springframework.security.oauth2.client.userinfo.OAuth2UserService

package org.springframework.security.oauth2.client.userinfo;

import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;

@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {
    U loadUser(R userRequest) throws OAuth2AuthenticationException;
}

위와 같이 선언되어 있다.
여기서 알아볼 것은 OAuth2User 인터페이스, OAuth2UserRequest 클래스, loadUser 메소드다.

  • OAuth2User 인터페이스
    • org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal을 상속받는다.
    • Principal은 인증 정보를 의미한다.
    • OAuth2User는 Resource server(구글 등 인증을 해주는 서버)로부터 인증 결과로 받아오는 인증 정보를 표현하는 인터페이스다.
    • 근데 OAuth2User 자체는 사실 빈 껍데기고, 실질적으로 사용하는 것은 구현 클래스다.
      • 같은 이유로 DefaultOAuth2User 클래스를 사용했다.
  • OAuth2UserRequest
    • OAuth2.0으로 인증 요청을 보내기 위한 객체.
      ...이지만 사실 여기에 OAuth2 인증은 누가 해줬는지, 액세스 토큰, 그 외 추가 파라미터 등 인증 정보들이 담겨 있다.
  • loadUser
    • OAuth2UserRequest로 받아진 인증 정보를 필요에 따라 처리한 후 OAuth2User 타입으로 반환한다.
    • 여기서 반환된 OAuth2User 기반으로 Authentication 객체가 만들어지고, 이것이 SecurityContext에 등록됨으로써 인증이 완료된다.

인증 성공 및 실패

인증의 성공 여부는 OAuth2UserService가 수행하는 일련의 로직의 성공 여부와 같다.
즉 다음이 모두 성공해야 한다.

  1. Client(나) Resource server로 인가 요청을 보낸다.
  2. 인증 정보를 받아 온다(받아온 정보는 oauthUserRequest에 있다).
  3. loadUser()를 통해 SecurityContext에 Authentication 객체가 등록된다.

실패 요인은 다양하다.

  • 그냥 설정이 잘못 되어 있다.
  • Client에서 Resource server로 요청을 보내는 과정에서 네트워크 상의 문제가 발생했다.
  • 사용자가 인증을 잘못 했다.
  • 로그인 확인 페이지에서 뒤로 가기를 했다가 다시 왔다.
  • 그냥 네트워크가 이상하다(라우터가 망가졌거나, 스위치가 이상하거나, 랜카드가 이상하거나, ...).

성공 핸들러

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.Authentication;

public interface AuthenticationSuccessHandler {
    default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        this.onAuthenticationSuccess(request, response, authentication);
        chain.doFilter(request, response);
    }

    void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException;
}
  • org.springframework.security.web.authentication.AuthenticationSuccessHandler(위 인터페이스)의 구현 객체
  • 성공 시, 그러니까 SecurityContextAuthentication이 등록된 상태에 수행할 작업을
    onAuthenticationSuccess() 내부에 작성한다.
    • 등록된 AuthenticationAuthentication authentication parameter로 받아온다.
  • 인증 성공은 사실 로그인 성공이기 때문에 토큰을 발급하는 등의 로직을 수행할 수 있다.

실패 핸들러

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;

public interface AuthenticationFailureHandler {
    void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException;
}
  • org.springframework.security.web.authentication.AuthenticationSuccessHandler(위 인터페이스)의 구현 객체
  • 인증 실패 시, 수행할 작업을 onAuthenticationSuccess() 내부에 작성한다.
  • 이 경우는 인증 과정에서 예외가 발생한 경우이며, 발생한 예외는 AuthenticationException exception parameter로 받아온다.

요약

개략적으로 쓰려고 헀지만 쓰다 보니 좀 주절주절 복잡하게 서술한 것 같다.
동작 과정을 요약하면 다음과 같다.

  1. service 객체가 Resource server로 인증 요청을 하고 인증 정보를 받아와 반환한다.
  2. 반환된 인증 정보를 바탕으로 인증이 수행된다(SecurityContext에 인증 객체가 등록된다).
  3. 성공 시 성공 핸들러가, 실패 시 실패 핸들러가 동작한다.

약간의 디테일을 추가하면

  1. OAuth2UserService가 Resource server로 인증 요청을 하고 인증 정보를 받아온다.
  2. 받아온 인증 정보는 OAuth2UserService.loadUser()의 파라미터(OAuth2UserRequest 객체)를 통해 받아와지고,
    필요에 따라 적당한 처리 후 인증 정보 OAuth2User의 구현 타입으로 반환한다.
  3. 반환된 인증 정보를 바탕으로 인증이 수행된다(SecurityContext에 인증 객체가 등록된다).
  4. 인증 성공 시 oauth2Login()에서 설정된 AuthenticationSuccessHandler의 구현체가,
    실패 시 설정된 AuthenticationFailureHandler의 구현체가 동작한다.

OAuth 만의 동작 과정은 아니다.

사실 이 흐름은 그냥 Spring security의 흐름이라고 봐도 무방하다. 즉 일반적인 ID + PW 로그인도 똑같은 흐름이다.
그냥 구현하는 인터페이스 이름만 다를 뿐(UserDetailsService, UserDetails 등) 기본적인 인증 과정은 비슷하다.
다만 성공 핸들러, 실패 핸들러는 같은 인터페이스를 구현한다.

다음 글에서는 설정을 다룬다.

profile
Java를 하고 싶었지만 JavaScript를 하게 된 사람

0개의 댓글

관련 채용 정보