인증 인가를 구현하는 방식으로 session과 jwt 방식을 알아봤다.

이번엔 Spring Security에서 OAuth2를 이용해 인증, 인가를 구현하는 방법에 대해 공부해봤다.

OAuth2란?

구글, 페이스북, 카카오와 같은 다양한 플랫폼의 사용자 데이터에 대해 접근하기 위해

제 3자가 사용자 데이터에 대한 접근 권한을 위임받는 표준 프로토콜이다.

 

https://hudi.blog/oauth-2.0/

 

OAuth 2.0 개념과 동작원리

2022년 07월 13일에 작성한 글을 보충하여 새로 포스팅한 글이다. OAuth 등장 배경 우리의 서비스가 사용자를 대신하여 구글의 캘린더에 일정을 추가하거나, 페이스북, 트위터에 글을 남기는 기능을

hudi.blog

 

OAuth2를 이용해 인증, 인가를 구현하려면 어떻게 해야 하나요?

기존의 session, jwt 방식이 함께 사용된다고 생각하면 된다.

다만 session에 넣는 유저 정보를 OAuth2를 통해 가져온다.

 

OAuth2를 사용해 인증, 인가를 구현하면 얻을 수 있는 장점?

  1. 개발자는 로그인 폼, 회원가입, 아이디, 비밀번호 찾기 등과 같은 귀찮은 과정을 구현하지 않아도 된다.
  2. 사용자의 중요 정보를 DB에 저장하지 않아도 됨 -> 개인정보 유출 위험성을 낮출 수 있다.
  3. 사용자 또한 어플리케이션마다 계정을 만들고 관리할 필요가 없어 편리한 로그인이 가능해진다.

기본 용어

Resource Owner : 회원 정보의 소유자, 즉 실제 사용자

Client : 회원 정보를 사용하는 사용자 , 즉 우리가 만든 어플리케이션

Authorization Server : 인증용 서버

Resource Server : 회원 정보 제공 서버

 

기본 동작 방식

  1. Resource Owner 가 Client에 로그인 요청
  2. Client는 Authorization Server에 다시 로그인 요청(Clitne Id, Redirect URI, Response Type, Scope 전달)
  3. Authorization Server에서 Client에 로그인 페이지 제공
  4. Resource Owner 로그인 성공시 Authrorization code 와 함께 redirect uri로 리다이렉트
  5. redirect uri에서 Client는 Autorization Server에게 Authroization code를 사용해 Access Token 발급 요청
  6. Client에서 필요할때마다 Access Token으로 Resource 서버의 사용자 정보에 접근

이때 Spring Security를 사용하면

  • Autorization Server에 접근하는 uri 제공 (google, github, facebook)
  • Resource Server에 접근하는 uri,  Autorization code 값 저장 방법 등 제공
  • Access Token 관리 및 가져온 유저 정보 저장

등의 기능을 편리하게 사용할 수 있다.

 

이번 글의 목표

기존의 타임리프를 사용한 로그인 페이지에서 oauth2를 이용한 로그인 방식을 함께 구현해보자.

 

기존 프로젝트에 타임리프와 spring security의 formLogin 기능을 이용해서 어드민 페이지를 만들었다.

여기서 기존 로그인 방식을 유지하면서 oauth2를 이용한 소셜 로그인 기능을 추가해보자.

 

OAuth2를 사용하기 위한 설정 변경

1. 어플리케이션 등록

우리는 네이버와 구글을 이용해서 구현할 예정이다.

네이버와 구글로 소셜 로그인 기능을 사용하기 위해선 우리 서비스를 클라이언트로 등록해야 한다.

아래 링크를 참고해서 등록했다.

https://velog.io/@leeeeeyeon/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

구글 로그인, 로그아웃, 회원가입, 권한관리 기능을 구현해보자

velog.io

 

2. application.yml 추가

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ...
            client-secret: ...
            scope: profile, email
            redirect-uri: https://localhost:8080/login/oauth2/code/google
          naver:
            client-id: ...
            client-secret: ...
            scope: name, email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: https://localhost:8080/login/oauth2/code/naver
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

 

google은 스프링 시큐리티에서 기본으로 Provider를 제공해주므로 위의 4가지 정보만 설정하면 된다.

그러나 네이버는 시큐리티에서 제공하지 않으므로 Provider 설정도 직접 해줘야한다.

 

여기서 중요한 설정은 redirect-uri로 우리는 반드시 .../login/oauth2/code/google 와 같은 형식으로 uri를 등록해야한다.

그리고 여기 등록한 uri는 어플리케이션 등록시 설정하는 redirect-uri와 동일해야한다.

그 이유는 아래에 후술한다.

 

3. SecurityConfig 파일 변경

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class OAuth2SecurityConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    private final CustomFailureHandler customFailureHandler;
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authorizeRequests) -> authorizeRequests
                        .requestMatchers(
                                new AntPathRequestMatcher("/admin/login")
                        ).permitAll()
                        .anyRequest().authenticated()
                    )
                .formLogin()
                    .loginPage("/admin/login")
                    .loginProcessingUrl("/admin/login")
                    .usernameParameter("accountId")
                    .defaultSuccessUrl("/admin")
                .and()
                .logout((logoutConfig) -> logoutConfig
                    .logoutUrl("/admin/logout")
                    .logoutSuccessUrl("/admin")
                ) 
                //OAuth2를 위한 설정 추가
                .oauth2Login((oauth2) -> oauth2
                    .loginPage("/admin/login") 
                    .defaultSuccessUrl("/admin")
                    .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
                        .userService(customOAuth2UserService))); //사용자 user service 추가

        return http.build();
    }    
}

 

기존의 Spring Security 설정 파일에 oauth2Login 설정을 추가하자.

formLogin 설정과 얼추 비슷해보인다.

 

복습 - 스프링 시큐리티의 기본 동작 방식

 

기존의 formLogin을 이용한 인증 구조는 위와 같았다.

 

설정을 통해 미리 만들어놓은 Authentication Filter에서 설정해놓은 URL로 들어오는 요청을 확인하면

AuthenticationManger가 인증을 처리할 Provider를 찾고

formLogin은 UserDetailsService를 통해 유저 정보를 가져와 Authentication 객체를 만들어

SecurityContext에 저장해놓고 사용했다.

 

OAuth2를 이용한 인증/인가시 몇가지 구현 객체만 바뀔 뿐 기본적인 구조는 위의 방식과 동일하다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http //OAuth2 설정 추가
       .oauth2Login((oauth2) -> oauth2
           .loginPage("/admin/login") 
           .defaultSuccessUrl("/admin")
           .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
           .userService(customOAuth2UserService)));
}

 

위와 같이 oauth2Login 로그인 설정을 추가하면 아래와 같은 필터 체인이 추가된다.

  • OAuth2AuthorizationRequestRedirectFilter
  • OAuth2LoginAuthenticationFilter
  • (사실 몇개 더 있지만 이 두 가지만 짚고 가자)

 

OAuth2AuthorizationRequestRedirectFilter

GET http://localhost:8080/oauth2/authorization/google

다음과 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

요청 url과 파라미터를 검증해 조건에 맞으면 OAuth2AuthorizationRequest 객체를 생성하여 리턴한다.

 OAuth2AuthorizationRequest 객체의 authorizationRequestUri 주소로 사용자를 리다이렉트 한다.

이 주소에는 각 제공자에 맞는 OAuth2 로그인 페이지 URL이 담겨있다.

 

OAuth2LoginAuthenticationFilter

/login/oauth2/code/* 와 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

그런데 이 양식 어디서 많이 봤다. 바로 redirect Uri 에서 설정한 uri 양식이다.

사용자가 로그인에 성공하면 redirect URI로 이동한다.

이때 우리는 redirect URI를 OAuth2LoginAuthenticationFilter가 처리하는 uri로 설정해 둔 것이다.

 

OAuth2LoginAuthenticationFilter는 Authentication Server에서 AccessToken을 발급받고,

AccessToken을 이용해 사용자 정보를 받아와 인증 객체(Authentication)를 생성해 리턴하는 중요 로직을 담당한다.

 

이때 실제 로직은 AuthenticationProvider에게 위임한다.

 

OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationProvider 객체는 인가 코드로 OAuth2 제공자 인증 서버에서 액세스 토큰을 발급 받고,

액세스 토큰으로 사용자 정보를 받아 인증 객체를 생성하고 리턴하는 로직을 처리합니다.

이 때 액세스 토큰 발급과 사용자 정보를 받아오는 부분은 각각 

OAuth2AuthorizationCodeAuthenticationProvider 객체와 UserService 객체에게 위임합니다.

 

우리가 구현해야하는 부분

1. UserService 구현

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
    private final UserRepository userRepository;
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
        //attributes 얻기 위한 코드
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = OAuthAttributes.of(registrationId, oAuth2User);

        User user = saveOrUpdate(attributes);
        return UserInfo.ofOAuth2(user, attributes);
    }
    
    //유저 생성 서비스 로직
    private User saveOrUpdate(Map<String, Object> attributes){

        Optional<User> findUser = userRepository.findByEmail((String) attributes.get("email"));
        
        if(findUser.isPresent()){
            return findUser.get();
        }
        else{
            User user = User.fromOAuth2Attributes(attributes);
            userRepository.save(user);
            
            return user;
        }
    }
}

사용자가 로그인에 성공한 후 Access Token을 받는데 까지 성공하면 해당 Access Token을 이용해 유저 정보를 얻어올 수 있다. 이 과정은 DafultOAuth2UserService의 loadUser() 메서드가 구현하고 있으므로 사용하면 된다.

 

이렇게 가져온 유저 정보를 OAuth2User 객체 인스턴스로 반환하면

AbstractAuthenticationProcessingFilter 에서 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

그런데 코드를 잘보면 주목할 점이 몇가지 있다.

DafultOAuth2UserService에서 반환한 OAuth2User 인스턴스를 그냥 반환하면 되는데

 

  1. Map<String, Object> 형태의 attributes를 뽑아내서
  2. 일치하는 User를 찾거나 없으면 새로 생성해내고
  3. 유저 인스턴스를 다시 UserInfo 객체로 변환해 반환한다.

왜 이렇게 쓸데없는 고생을 할까?

그 이유는 우리가 기존 formLogin 방식을 같이 사용하기 때문이다.

 

기존 formLogin 방식의 UserService를 보자.

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService{
    
    private final UserRepository userRepository;
    ...
    
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        UserDetails findUser = userRepository.findByAccountId(username)
            .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다"));
        
        return findUser;
    }
    
}

UserDetailsService를 상속받아 loadUserByUsername() 메서드를 오버라이딩했다.

유저 식별자(username)이 들어오면 DB에서 해당하는 유저 정보를 꺼내와 UserDetails 형태로 반환한다.

그 후 UserDetails 인스턴스를 이용해 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

여기서 생기는 문제는 우리는 유저가 OAuth2 로그인을 할지, formLogin을 사용할지 알 수 없다!

 

그렇다면 유저 정보를 확인할 필요가 있을 때 SecurityContext에 저장된 Authentication 객체의 유저 정보가

OAuth2User 일지, UserDetails일지 꺼내서 확인하기 전까진 알 수 없다는 것이다!

 

따라서 Spring Security의 가장 핵심 기능인 Security Context에 유저 정보를 저장하고

필요한 상황이 생기면 전역적으로 사용하는 기능을 사용하기 힘들어졌다.

 

로그인한 유저 정보를 확인해야하는 곳마다 타입체크와 형변환을 하는 코드를 사용하는 것은

엄청나게 번거롭고 비효율적인 짓이기 때문이다.

 

이를 해결하기 위해 UserInfo 클래스를 만들었다.

 

2. UserInfo 클래스 구현

@Getter
public class UserInfo implements UserDetails, OAuth2User{

    private User user;
    private Map<String, Object> attributes;

    private UserInfo(User user, Map<String, Object> attributes){
        this.user = user;
        this.attributes = attributes;
    }
    
    public static UserInfo ofOAuth2(User user, Map<String, Object> attributes){
        return new UserInfo(user, attributes);
    }
    
    public static UserInfo from(User user){
        return new UserInfo(user, null);
    }
    
    //<== UserDetails ==>//
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey()));
    }
	...
    //<== Oauth2User ==>
    public String getName(){
        return user.getName();
    }
	...
}

UserInfo 클래스는 UserDails와 OAuth2User를 모두 구현하는 클래스이다.

이는 UserDails와 OAuth2User 모두 인터페이스이기 때문에 가능하다.

 

이로서 우리는 OAuth2Login에서든 FormLogin에서든 UserInfo 클래스를 반환하도록 구현하면 된다.

유저 정보가 필요할때마다 번거롭게 타입체크와 형변환을 할 필요 없이 주요 정보들을 가져올 수 있다.

 

그리고 OAuth2 방식과 formLogin 방식이 UserInfo를 생성하기 위해 필요한 매개변수가 다르기 때문에

정적 팩토리 메서드를 사용해봤다.

 

3. OAuthAttributes 구현

public class OAuthAttributes{
    
    private OAuthAttributes() {
        throw new UnsupportedOperationException();
    }
    
    public static Map<String, Object> of(String registrationId, OAuth2User oauth2User){
        
        switch(registrationId){
            case "google":
                return ofGoogle(oauth2User);
                
            case "naver":
                return ofNaver(oauth2User);
            
            default:
                throw new IllegalStateException("unvalid oauth2 login");
        }
    }
    
    private static Map<String, Object> ofGoogle(OAuth2User oauth2User){
        return oauth2User.getAttributes();
    }

    private static Map<String, Object> ofNaver(OAuth2User oauth2User){
        
        return (Map<String, Object>) oauth2User.getAttributes().get("response");
    }
}

OAuth2의 제공자마다 유저 정보 응답이 조금씩 다르다.

따라서 제공자마다 유저 정보로 넘어온 응답값을 추출해내는 과정이 필요한데 이를 하나의 클래스로 분리해봤다.

 

사실 UserService의 메서드로 넣어도 되는 메서드들이긴 하다.

근데 가만히 보니까 서비스 로직들 사이에 들어가있는게 불편해서 한 번 분리해봤다.

 

처음 판단은 이랬다.

  • OAuth2의 제공자가 지금은 구글과 네이버 뿐이지만 언제 더 늘어날지 모른다.(변경이 자주 일어날 것으로 예상)
  • 그렇다면 제공자가 늘어날때마다 UserService의 로직을 수정해야하만 한다.
  • 그러나 UserService에서 OAuth2 제공자가 추가될때 변경되어야 하는 로직은 응답값 추출과 관련된 메서드들 뿐이다.
  • 그렇다면 응답값 추출 메서드를 별도의 클래스로 분리해보면 어떨까?

그런데 분리를 하고 보니까 스태틱 메서드만 포함하고 있는 유틸리티 클래스가 생겨버렸다.

유틸리티 클래스에 대해선 논쟁이 많은 편이다. 특히 객체지향 프로그래밍과 맞지 않는다는 이야기를 많이 봤다.

 

유틸 클래스의 문제점으로 꼽히는 점은 사용하는 클래스와의 강한 결합이다.

유틸 클래스는 스태틱 메서드로만 만들어져 인스턴스를 생성하지 않고 사용한다.

그래서 유틸 클래스를 사용하는 클래스와 강하게 결합되는데

이는 유틸 클래스가 변경될 시 사용하는 모든 클래스의 변경을 유발한다.

 

그러나 UserService의 메서드를 분리해 만든 OAuth2Attributes 클래스는 UserService가 아닌 곳에서 사용될 일이 없다.

그래서 그냥 쓰기로 했다.

 

4. LoginPage 변경

<!DOCTYPE HTML>
...
<body>
    <div class="container">
        <form role="form" th:action="@{/admin/login}" method="post" th:object="${signInRequest}">
            ...
        </form>
        <div>
            <a href="/oauth2/authorization/google">Google Login</a> <br/>
            <a href="/oauth2/authorization/naver">Naver Login</a> <br/>    
        </div>
    </div> <!-- /container -->
</body>
</html>

로그인 페이지에 구글 로그인 네이버 로그인용 태그를 추가해줘야한다.

이때 링크 클릭시 url은 위의 OAuth2AuthorizationRequestRedirectFilter가 처리할 수 있도록

/oauth2/authorization/* 양식으로 해줘야한다.

5. AdminController 구현

    @GetMapping("/admin") //홈 화면
    public String newHome(@AuthenticationPrincipal UserInfo findUser, Model model){
        
        if (findUser == null){
            return "admin/home";
        }
        
        model.addAttribute("userName", findUser.getName());    
        return "admin/menu";
    }

위의 방식대로 구현하면 컨트롤러 단에선 UserInfo 만 받아온 후 형변환 없이 그대로 사용할 수 있게 된다.

 

 

기존 formLogin 방식에 oauth2Login을 사용해 소셜 로그인 기능을 추가해봤다.

기존 formLogin과 함께 사용하기 위해 Session 방식으로 구현했다.

다음으론 JWT를 이용한 OAuth2 로그인도 공부해봐야겠다.

+ Recent posts