본문 바로가기
Back-end/Spring Boot

[Oauth2] Oauth2로 회원가입 / 로그인 기능 구현하기

by whatamigonnabe 2022. 11. 3.

Oauth2란?

간단하게 말하자면, 지금은 너무나도 익숙한 소셜 로그인 서비스이다. 어떤 특정 서비스에서 인증을 직접 진행하는 것 대신에 구글이나 네이버, (카카오...) 등의 신뢰할 수 있는 기관에서 대신 인증을 진행하는 것이다.

사실, Oauth는 여러 인증 방법(flow)를 여러 웹서비스 등의 여러 서버스에게 제공하는 '규약'이고, 이 규약을 지켜 구글 페이스북 등의 회사에서 실제 인증 서비스를 진행하는 것이다.

OAuth 2.0 is the industry-standard protocol for authorization. OAuth 2.0 focuses on client developer simplicity while providing specific authorization flows for web applications, desktop applications, mobile phones, and living room devices. This specification and its extensions are being developed within the IETF OAuth Working Group.
- Oauth 공식 홈페이지

인증 방법들 (Authorization Flows)

전체적으로는 end User가 소셜 로그인을 성공하면 발급되는 "Access Token"을 가지고 우리의 서비스가 해당 User를 신뢰하고, 또 이 토큰을 가지고 User가 동의한 정보들을 해당 기관으로부터 받아서 사용할 수 있다.

이런 과정을 이해하기 위해서는 먼저 다음의

Resource Owner : 써드 파티 리소스를 소유한 사람(End User)

Client: Resource Owner를 대신해 리소스에 접근하는 어플(우리가 개발하는 서비스)

Resource Server : 리소스를 갖고 있는 주체

Authrization Server: 인증을 처리하는 주체

Authorization Code Grant

인증 코드를 이용하는 방법이며, 가장 대표적입니다.

Resource Owner가 Authorization Server로부터 인증에 성공하면, Authorizaion Code를 Client(우리가 개발하는 서비스)로 전달합니다. 그럼 코드를 가지고 다시 Authorization Server에게 엑세스 토큰을 요청하여 받습니다. 그럼 이 토큰을 가지고 Resource server에서 권한이 주어진 정보들을 얻어서 사용합니다.

이 방식은 End User가 사용하고 있는 브라우저가 직접 통신하지 않고, 우리의 서버가 통신하기 때문에 비교적 안전한 방법이며, 그렇기 때문에 Refresh 토큰도 사용할 수 있습니다.

Implicit Grant

이 방법은 인증 코드를 사용하지 않고, 바로 엑세스 토큰을 발급 받으며, 리프레시 토큰은 발급하지 않습니다. 이 방법은 앞에서 설명한 방법보다 간편하기 때문에 사용되며, 대신의 보안성을 희생합니다. 그 이유는 Access Token이 URL을 통해 전달되기 때문입니다.

이외의 다양한 인증 방법들은 아래의 링크를 참고해주세요.

https://www.rfc-editor.org/rfc/rfc6749

SpringBoot에서 구현하기

큰 흐름은 아래와 같습니다.

SpringBoot는 몇 가지 설정을 통하면 아래 처럼 대부분의 과정을 대신 자동으로 처리해줍니다.

개발자가 Google 등의 OAuth 서비스를 제공하는 Provider에서 해당 서비스의 Client로 등록을 합니다.

이때, 인증에 성공 후 발급되는 Authorization Code를 받을 redirection uri를 설정합니다.

등록에 성공하면, ClientID와 ClientSecret을 발급받습니다.

그리고 이 두 정보를 SpringBoot의 설정파일에 저장하고, Scope(받아올 정보)를 명시합니다.

백엔드 서버 URI/oauth2/authorization/{registrationId}

(위의 URI에서 registrationId는 google / facebook / github 같은 provider입니다.)

-----------------GodpringBoot 시작------------------

EndUser, 그러니까 OAuth 용어로는 Resource Owner가 위의 링크로 접속하게 되면, 여기서 부터 스프링부트가 알아서 Authorization Server / Resource Server와 통신하며 자동으로 일정부분 처리해줍니다.

스프링부트는 개발자가 명시한 ClientID를 활용해서 Google등의 회사들이 제공하는 로그인 페이지로 이동하는 URI를 생성하여 이쪽으로 redirection을 합니다.

그럼 유저는 이곳에서 권한을 허락하고 로그인을 합니다.

로그인에 성공하게 되면, Authorization Server(ex Google)는 Authorization Code를 개발자가 Client등록할 때 설정해둔 redirection uri로 보내게 됩니다.

그럼 또 우리의 SpringBoot가 이 코드를 가지고 Authorization Server와 통신하여 AccessToken을 얻고, 앞에서 명시해둔 Scope에 따라 필요한 정보(User Info)를 얻어옵니다.

얻어온 UserInfo는 ClientRegistrationRepository에 저장하고, Authentication의 형태로 불러와서 일반적인 인증 과정처럼 SecuriyContext에 인증정보를 저장합니다.

 

--------------GodpringBoot 종료---------------------

사실상 대부분을 스프링부트가 자동으로 처리를 해주고 개발자가 처리할 부분은 지금부터입니다.

앞에서 과정중에 Customize하고 싶은 부분만 개발자가 지정하여 커스텀할 수 있습니다.

저는 인증이 성공한 후에 최초로 로그인했다면 회원가입을 시키고, 

jwt토큰을 발급해주는 걸로 Customize하겠습니다.

 

자세한 사항은 아래 공식문서를 참고해주세요.
OAuth 2.0 Login — Advanced Configuration

Authorization Provider(ex. Google)에서 Oauth2 Client 등록하고 ID/Security 받기

ID/Security를 설정파일에 저장하기

민감한 정보임으로 우선 시스템 환경변수에 저장해서 사용합니다.

#application-oauth.properties
spring.security.oauth2.client.registration.google.client-id = ${OAUTH_GOOGLE_ID}
spring.security.oauth2.client.registration.google.client-secret = ${OAUTH_GOOGLE_SECRET}
spring.security.oauth2.client.registration.google.scope = profile,email

application.properties에서 위에서 설정한 파일을 사용하도록 설정합니다.

# application.properties
spring.profile.include=oauth

의존성 추가

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    ...
}

Configuration

여기에서 초기회원인 경우 회원가입하는 CustomService와 jwt토큰을 발급할 핸들러를 명시합니다.

@Configuration
@AllArgsConstructor
public class SecurityConfiguration {
	...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http.
        	...
    		.oauth2Login() // Oauth2기능을 사용하겠다.
            //성공적으로 userInfo를 얻어온 후, userService(일반적인 DetailsService같이, 사용자 DB에서 Authentication을 load하는 역할)
            //를 커스텀하여 사용하겠다.
            .userInfoEndpoint().userServcie(customOAuth2UserService)
            //인증에 성공하면, 이 핸들러를 실행하겠다.(여기서 jwt 토큰 발급)
            .successHandler(oAuth2SuccessHandler);
           
       return http.build()
    }
	...
}

CustomOAuth2UserService

구글과 같은 Provider로부터 얻어온 정보를 가지고 우리 서비스의 유저 DB와 비교하여 처음 접속했으면, 회원가입시킵니다.

OAuth2User에 정보가 담겨 있고, getAttribute()를 통해 Map으로 저장된 사용자 정보를 볼 수 있습니다. Map의 Key와 Value 형식은 각 Provider의 설명을 참고해야합니다.

 

저는 저희 서비스의 User를 상속받고, OAuth2User를 구현한 CustomOAuthUser이라는 클래스를 따로 정의해서 사용했습니다.

Provider마다 Atrributes가 조금씩 다르기에 필요한 정보를 추출하여 같은 형태로 만들었습니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    final private UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //OAuth2UserService : AccessToken을 사용하여 UserInfo endPoint로부터 사용자 정보를 OAuth2User의 형태로 얻어온다.
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        //이것으로부터 User받는다.
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        //어떤 프로바이더에게 인증의 위임했는지 확인(ex. Google)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        log.info("registrationId = {}", registrationId);
        log.info("userNameAttributeName = {}", userNameAttributeName);
        //우리에게 필요한 사용자 정보를 담는 객체. DB에 유저 정보
        CustomOAuthUser customOAuthUser = CustomOAuthUser.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        //최초 로그인이면 회원가입 아니면 정보 업데이트
        CustomOAuthUser user = saveIfNotExist(customOAuthUser);

        return user;
    }
    
    ...
}

OAuth2SuccessfulHandler

인증에 최종적으로 성공하게되면, 이 핸들러가 작동하면 Jwt를 발급한 후, 이 정보를 URI에 담아 EndUser로 Redirect합니다. Token 유출의 위험이 있기 때문에, 클라이언트에게는 노출되지 않는 페이지에서 토큰을 브라우저에 저장하는 로직을 실행한 후, 다시 홈 화면 등으로 리다이렉트합니다.

 

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenizer jwtTokenizer;
    ...
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        var oAuth2User = (CustomOAuth2UserService.CustomOAuthUser) authentication.getPrincipal();
        redirect(request, response, oAuth2User);
    }
    
   
    private void redirect(HttpServletRequest request, HttpServletResponse response, User user) throws IOException {
        String accessToken = delegateAccessToken(user);
        String refreshToken = delegateRefreshToken(user);

        String uri = createURI("Bearer " + accessToken, refreshToken).toString();
        getRedirectStrategy().sendRedirect(request, response, uri);
    }
    
    ...
}

 

정리

처음할 때는 엄청 거대한 벽같이 느껴졌는데, 지금은 조금은 작아진 벽 같습니다ㅎㅎㅎ  쉽게 설명해 놓은 글을 찾기 어려워서 한참을 해맸는데, 역시 필요한 정보를 빠르고 정확하게 찾아내는 능력이 개발자에게 엄청 중요하다는 것을 많이 느꼈습니다.

참조

스프링부트 OAuth2 공식문서