본문 바로가기

Spring Security

Spring Security를 활용한 OAuth 2.0 로그인 구현

이번 포스팅에서는 OAuth 2.0을 이용한 소셜 로그인을 소개해보려고 합니다.

여러가지 소셜 사이트가 있지만, Google, FaceBook, Naver를 활용하여 소셜 로그인을 진행하려고 합니다.

소셜 로그인의 경우 oauth2-client 라이브러리를 활용하여 동작이 이루어지게 되고 어떤 원리로 소셜 로그인이 가능한지 알아보도록 하겠습니다.  또한, 실제 토이프로젝트를 통해 구현된 코드로 보여드리도록 하겠습니다.

 

1. OAuth 2.0 이란??

먼저, 간단하게 OAuth에 대해서 알아보도록 하겠습니다.

예를들어, 원티드 홈페이지에 접속을 하여 회원가입 버튼을 누르게 되면 다른 소셜 사이트의 정보로 회원가입을 하게 하는 것을 볼 수 있습니다. 본인이 원하는 소셜 사이트를 선택하여 회원가입을 진행하게 되면, 간단하게 회원가입 / 로그인이 진행 될 뿐만 아니라 Google, FaceBook, Naver에서 제공하는 기능을 간편하게 사용할 수 있는 장점도 있습니다.

이 때 사용되는 프로토콜이 OAuth 2.0 입니다.

2. OAuth 2.0 구성요소 및 동작원리

OAuth 2.0의 구성요소는 크게 4가지로 구분됩니다.

구분 설명
Resource Owner Resource Owner는 보통 웹 어플리케이션을 사용하는 일반 사용자입니다.
실제 소셜 로그인 버튼을 누르는 사람들이라고 생각하면 됩니다.
Client Client라는 이름때문에 사용자라고 생각 할 수 있겠지만, OAuth 로그인 방식에서는 웹어플리케이션이 소셜 사이트에 요청하는 입장이기 때문에 저희가 개발하고 있는 웹 어플리케이션이 Client라고 생각하시면 됩니다.
Authorization Server Resource Owner의 인증을 진행해주고, Client를 확인해주며 Access Token을 발급해 주는 역할을 하는 인증서버라고 생각하시면 됩니다.
Resource Server 실제 Google, Naver와 같이 Resource Owner의 개인정보를 가지고 있는 서버라고 생각하시면 됩니다.

 

그럼 이제 OAuth 2.0의 구성요소들이 서로 어떻게 동작하는지 간단한 이미지를 통해 알아보도록 하겠습니다.

  1. 사용자는 Client 어플리케이션 화면에 있는 소셜 로그인 버튼을 클릭합니다.
  2. 클라이언트는 Authorization Server에 로그인 페이지를 요청합니다.
  3. Authorization Server는 사용자에게 로그인 페이지를 제공합니다.
  4. 사용자는 ID / PASSWORD를 입력하고 Authorization Server에 보냅니다.
  5. Authorization Server는 사용자에게 Authorization Code를 제공합니다.
  6. 사용자는 redirect_uri로 Authorization Code를 넘깁니다.
  7. Client는 인증 코드로 Access Token을 요청합니다.
  8. 정상적인 인증 코드면 Authorization Server는 Access Token을 발급해주고 회원가입 및 로그인이 이루어지게 됩니다.
  9. 추후 사용자는 리소스 요청을 하게되고 Client는 Access Token으로 자원을 얻어와 사용자에게 제공하게 됩니다.

3. OAuth 2.0 로그인 구현

지금부터는 실제 토이 프로젝트를 진행하면서 OAuth 로그인 구현해 보도록 하겠습니다.

프로젝트 설정은 Spring Boot 3.X버전과 Spring Security와 oauth2-client를 활용하여 구현하였습니다.

또한, 소셜 사이트에서 소셜 로그인을 사용하기 위한 설정은 이번 포스팅에서 다루지 않았습니다.

 

[Application.yml]

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 클라이언트 id
            client-secret: 시크릿
            scope:
              - email
              - profile
          facebook:
            client-id: 클라이언트 id
            client-secret: 시크릿
            scope:
              - email
              - public_profile
          naver:
            client-id: 클라이언트 id
            client-secret: 시크릿
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect_uri: http://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 # 회원정보를 response에 담아서 넘겨줌

application.yml에서 google, facebook, naver 3개의 소셜 사이트 정보를 기입하였는데 Naver같은 경우는 oauth2-client에서 provider를 지원해주지 않았기 때문에 provider를 따로 설정 해주었습니다.

 

[SecurityConfig]

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final PrincipalOauth2UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorizeRequest ->
                        authorizeRequest
                                .requestMatchers("/shopping").authenticated()
                                .anyRequest().permitAll()
                )
                .exceptionHandling(handler -> handler.authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException, IOException {
                        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
                    }
                }))
                .headers(
                        headersConfigurer ->
                                headersConfigurer
                                        .frameOptions(
                                                HeadersConfigurer.FrameOptionsConfig::sameOrigin
                                        )
                );
        http
                .oauth2Login(oauth2Configurer -> oauth2Configurer
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                                .userService(userService)));

        return http.build();
    }
}

oauth2 로그인을 사용하기 위해서는 oauth2Login메소드를 사용하여 PrincipalOauth2UserService를 전달해야 합니다.

 

[ PrincipalOauth2UserService ]

@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    private final BCryptPasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;

    // 후처리
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 강제회원 가입
        OAuth2UserInfo oAuth2UserInfo = null;
        if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
            oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        } else if (userRequest.getClientRegistration().getRegistrationId().equals("facebook")) {
            oAuth2UserInfo = new FaceBookUserInfo(oAuth2User.getAttributes());
        } else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
            oAuth2UserInfo = new NaverUserInfo((Map<String, Object>) oAuth2User.getAttributes().get("response"));
        } else {
            System.out.println("지원하지않음.");
        }
        String provider = oAuth2UserInfo.getProvider();
        String providerId = oAuth2UserInfo.getProviderId();
        String email = oAuth2UserInfo.getProviderEmail();
        String loginId = provider + "_" + providerId;
        String username = oAuth2UserInfo.getProviderName();
        String password = passwordEncoder.encode("겟인데어");
        MemberRole role = MemberRole.ROLE_USER;

        Member member = memberRepository.findByLoginId(loginId).orElse(null);
        if (member == null) {
            member = Member.builder()
                    .loginId(loginId)
                    .password(password)
                    .email(email)
                    .username(username)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            memberRepository.save(member);
        } else {
            System.out.println("이미 로그인을 한적이 있습니다.");
        }

        return new PrincipalDetails(member, oAuth2User.getAttributes());
    }
}

 

사용자(Resource Owner)에게 로그인 페이지를 전달해주고 사용자가 ID / PASSWORD를 입력하면 Spring Security의 인증 로직에 따라 AuthenticationFilter -> AuthenticationManager -> AuthenticationProvier -> OAuthUserService를 구현한 PrincipalOauth2UserService안에 있는 loadUser() 메소드가 실행되고 OAuth2UserRequest안에 있는 정보들로 회원가입 및 로그인을 진행하게 됩니다.

 

[PrincipalDetails]

@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {
    private Member member;
    private Map<String, Object> attributes;

    // 일반 로그인
    public PrincipalDetails(Member member) {
        this.member = member;
    }

    // oauth 로그인
    public PrincipalDetails(Member member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }
    
    // .. 생략 기존 UserDetails를 구현한 곳
    
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null;
    }
}

 

일반 로그인에는 UserDetails만 사용 되었는데, OAuth 로그인시 OAuth2User 타입으로 반환해야 하기 때문에 PrincipalDetails가 OAuth2User를 구현하도록 설계하여 일반 로그인과 OAuth 로그인 2가지를 PrincipalDetails타입으로 사용가능 하도록 설계하였습니다.

 

[OAuth2UserInfo]

public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    String getProviderEmail();
    String getProviderName();
}

소셜 사이트마다 클라이언트에게 전달해주는 데이터의 키값이 다르기 때문에 인터페이스를 하나 만들고 인터페이스를 구현하도록 설계하였습니다. getProviderId()를 보면 google과 facebook의 키값이 다르다는 것을 알 수 있습니다.

 

[GoogleUserInfo]

public class GoogleUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes; // getAttributes()

    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getProviderName() {
        return (String) attributes.get("name");
    }
}

 

[FaceBookUserInfo]

public class FaceBookUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes; // getAttributes()

    public FaceBookUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

    @Override
    public String getProvider() {
        return "facebook";
    }

    @Override
    public String getProviderEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getProviderName() {
        return (String) attributes.get("name");
    }
}

 

[NaverUserInfo]

public class NaverUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes; // getAttributes()

    public NaverUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getProviderName() {
        return (String) attributes.get("name");
    }
}

 

4. OAuth 2.0 로그인

지금부터 OAuth 2.0의 구성요소를 기반으로 실제 흐름을 따라가보도록 하겠습니다.

 

사용자(Resource Owner)가 소셜 로그인 버튼을 누르면 Client(웹 어플리케이션)은  ../oauth2/authorization/google(매체 마다 달라짐) 경로로 Authorization Server에 로그인 페이지 요청을 보내고 Authorization Server는 사용자에게 아래와 같은 로그인 페이지를 제공해줍니다.

 

사용자가 ID / PASSWORD를 입력하면 아래 이미지와 같은 부분이 실행되게 되는데 이 부분은 oauth2-client가 자동으로 처리해주기 때문에 우리는 PrincipalOauth2UserService의 loadUser() 메소드에서 후처리 로직만 개발하면 됩니다.

 

후처리 로직(회원가입)을 작성하고 실제 소셜 로그인 후 Member 테이블을 조회해보면 아래와 같이 소셜 회원가입이 된 것을 확인 할 수 있습니다.