Spring Security를 활용한 OAuth 적용기 (Google)

김태훈·2023년 8월 28일
1

Spring Security

목록 보기
3/7
post-thumbnail

이전에, Spring Security를 활용하여 실제 우리 프로젝트만의 DB를 활용하여 회원가입/로그인 과정을 구현해보았다.
하지만 피드백을 받아본 결과, 회원가입이 귀찮다는 평가가 많아서.. 급하게 3일동안 OAuth 로그인 과정을 프로젝트 접목시켰다.
적용시킨지 10일정도 됐지만.. 급히 작성해본다.

1. OAuth2를 위한 기본 준비

OAuth2를 위한 기본 준비사항으로는, OAuth(Open Authorization)을 담당하고 있는 Third-Party 리소스에 대한 권한을 받아와야 한다.
나는 구글 로그인을 활용하였으므로, 프로젝트를 위한 구글 계정을 하나 파서 구글 클라우드 API에서 여러 설정 과정을 거쳤다.
이미 이러한 설정 사항들을 수 없이 많은 게시글들이 있으므로 그것들을 참고하면 좋을 것 같다.
하지만, 중요한 설정인 리다이렉션 URI에 대해서는 잘 짚고 넘어가야만 했다. (중요하다기 보다는, 내가 헤맸던 부분들을 위주로 설명하려고 한다.)

Redirection URI는 왜 필요한 것일까?

한문장으로 정리하면, Google을 통해 인증된 사용자 정보를 클라이언트에 전송해주기 위한 수단이기 때문이다.
우리 서비스는 구글 OAuth 로그인 관련 API key값을 통해서만 로그인할 수 있을 것이고, 그 결과(프로필정보와 같은 개인 정보)를 인증된 URI를 통해서만 받아야하지 않겠는가. 그래서 승인된 URI정보가 필요한 것이다.

자 여기서, SpringSecurity가 암묵적으로 정한 URI와 맞추어야 정확히 동작한다.

일단, 이유는 묻지말고, "프로토콜://서브도메인.도메인네임/login/oauth2/code/google"로 리다이렉션 URI를 추가하자. https://www.inforum.me/login/oauth2/code/google 처럼

이는 Spring Security를 사용하기 때문이므로, 다른 프레임워크를 사용하는 사람은 반드시 이렇게 설정하지 않아도 된다.

2. Spring Security를 적용시키기 위한 준비

1) 공식문서를 받아들일 준비

다시한번 느끼는 거지만, 지금의 블로그 글은, 일주일만 지나도 최신 Spring Security버전에서 동작하지 않을 수 있다. 그렇기에, 항상 "공식문서" 를 보겠다는 마음가짐만 가졌으면 좋겠다.
일주일 전에는 6.1.2버전인데, 지금은 6.1.3이다 !
따라서, 나의 글은 공식문서를 보고도 헷갈렸던 부분을 내 스스로 정리하고 공유하기 위함임을 되새기면서, 해당 글을 읽는 사람도 헷갈렸던 부분들에 대해 명쾌히 알아갔으면 좋겠다.
https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html
진짜 이런데에 다 나와있음. 공식문서가 가장 우선임.

The redirect URI is the path in the application that the end-user’s user-agent is redirected back to after they have authenticated with Google and have granted access to the OAuth Client (created in the previous step) on the Consent page.
ㄹㅇ 소름. 다시보니 앞에 설명했던 것도 여기서 다 설명 되어있었음. 공식문서 두번 보세요. 세번 보세요.

2) Security Config에 OAuth2 login 설정정보 추가.

해당 작업은 이전에 쓴 글을 통해 간단히 넘어가겠다.
https://velog.io/@goat_hoon/OAuth-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95-%EC%9E%90%EB%8F%99-Bean-%EB%93%B1%EB%A1%9D

간단히 설명하자면,
1. application.properties 나 yml 과 같은 설정파일에 client key값과 같은 secret값들을 설정한다. 물론 모든 설정정보들을 다 추가 시켜도 된다.
2. 해당 property값들을 통해 ClientRegistrationRepository 을 @Bean 과 함께 @Configuration 어노테이션으로 싱글톤으로 설정정보를 등록시킨다.
3. SecurityConfig 설정에 oAuth2Login 으로 @Bean으로 등록한 ClientRegistrationRepository을 컨테이너에서 꺼내서 등록하면,
4. OAUth2 login 관련 Security Filter들이 작동된다.

3. OAuth2Login 관련 Security Filter 들여다 보기

두가지 필터가 순서대로 추가되었을 것이다.
1. OAuth2AuthorizationRequestRedirectFilter
2. OAuth2LoginAuthenticationFilter
다음 두가지 필터에 대해 어떻게 OAuth2 login이 이루어지는지 살펴보자.
그리고 앞서 설정했던 redirection URL설정 이유에 대해 알아보자.

1) OAuth2AuthorizationRequestRedirectFilter

This Filter initiates the authorization code grant flow by redirecting the End-User's user-agent to the Authorization Server's Authorization Endpoint.
It builds the OAuth 2.0 Authorization Request, which is used as the redirect URI to the Authorization Endpoint. The redirect URI will include the client identifier, requested scope(s), state, response type, and a redirection URI which the authorization server will send the user-agent back to once access is granted (or denied) by the End-User (Resource Owner).
By default, this Filter responds to authorization requests at the URI /oauth2/authorization/{registrationId} using the default OAuth2AuthorizationRequestResolver. The URI template variable {registrationId} represents the registration identifier of the client that is used for initiating the OAuth 2.0 Authorization Request.
The default base URI /oauth2/authorization may be overridden via the constructor OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository, String), or alternatively, an OAuth2AuthorizationRequestResolver may be provided to the constructor OAuth2AuthorizationRequestRedirectFilter(OAuth2AuthorizationRequestResolver) to override the resolving of authorization requests.

해당 Filter관련 공식문서 내용이다. 중요한 부분은 볼드체로 칠했다.
즉, Resolver를 통해서 authorization request에 대한 응답을 "oauth2/authorization/{registrationID} URI에서 한다는 소리이다.

앞에 어떠한 경로도 붙으면 작동하지 않으니 유의하자. (prefix 경로 설정정보는 붙어야만함)

이제부터 우리는 oauth2/authorization/google로 접속하게 되면, google 로그인창이 뜬다.
그러면 신기하게도, redirect되는 것을 볼 수 있는데, o/oauth2/v2/auth=?? 와 같은 URI로 redirect 된다.
이유는 무엇일까?

지금까지의 과정을 생각하면서 따라왔다면 예측할 수 있겠지만, @Bean으로 수동으로 등록한 ClientRegistrationRepository 설정정보에 적힌 authorizationURI 정보 때문이다.
authorization을 위한 uri는 실제 o/oauth2/v2/auth=?? 로 redirection된다.
이러한 redirection을 도와주는 것이 OAuth2AuthorizationRequestRedirectFilter 이다.

이제 로그인을 성공시켜 보자.

2) OAuth2LoginAuthenticationFilter

내가 가장 헤맸던 부분이다.
로그인을 성공하게 되면,
http://localhost:8080/api/users/login/oauth2/code/google?state={비밀}&code={비밀}&scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=consent

이러한 곳으로 redirect 되었는데, 나보고 어쩌라고! 자꾸 400이 떴다는 것이 가장 큰 원인이었다.

@Configuration
public class OAuth2LoginConfig {
    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    String clientId;
    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    String secretId;
    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    String redirectUri;

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(Collections.singletonList(this.googleClientRegistration()));
    }

    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
                .clientId(clientId)
                .clientSecret(secretId)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri(redirectUri)
                .scope("profile","email")
                .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
                .tokenUri("https://www.googleapis.com/oauth2/v4/token")
                .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
                .clientName("Google")
                .build();
    }
}

자 여기서 문제가 되는 부분이 어디었을까?

redirectUri

한참을 헤맸었다. 앞선 redirect URI를 보면 /api 라는 prefix 경로 다음에 /users가 보이는 것을 확인할 수 있다.
나는 해당 uri를 통해 구글에서 제공해주는 개인정보들을 controller를 통해 getMapping으로 처리하려고 했었는데, 이것이 화근이었다.

그렇다면, 이유가 무엇이었을까.

바로, 지금 말하고자 하는 OAuth2LoginAuthenticationFilter 때문이다.

일단, 공식 설명 투척
An implementation of an AbstractAuthenticationProcessingFilter for OAuth 2.0 Login.
This authentication Filter handles the processing of an OAuth 2.0 Authorization Response for the authorization code grant flow and delegates an OAuth2LoginAuthenticationToken to the AuthenticationManager to log in the End-User.
The OAuth 2.0 Authorization Response is processed as follows:
Assuming the End-User (Resource Owner) has granted access to the Client, the Authorization Server will append the code and state parameters to the redirect_uri (provided in the Authorization Request) and redirect the End-User's user-agent back to this Filter (the Client).
This Filter will then create an OAuth2LoginAuthenticationToken with the code received and delegate it to the AuthenticationManager to authenticate.
Upon a successful authentication, an OAuth2AuthenticationToken is created (representing the End-User Principal) and associated to the Authorized Client using the OAuth2AuthorizedClientRepository.
Finally, the OAuth2AuthenticationToken is returned and ultimately stored in the SecurityContextRepository to complete the authentication processing.

그리고, 코드를 뜯어보면,

다음과 같다. 즉 /login/oauth2/code/google 로 URI요청이 들어와야만 해당 filter가 작동하여, 정보들을 건내주는 것이다.

redirect uri에서 /users 를 빼면 로그인은 되는 것 처럼 보인다.

그렇다면 로그인을 하고 난 후의 정보들을 받아와서, 회원가입을 하든지, 로그인을 하든지 추가 작업이 필요할 것인데, 다음 파트에서 알아보자.

4. OAuthService & SuccessHandler

.oauth2Login(oauth2 -> oauth2
    .clientRegistrationRepository(clientRegistrationRepository)
    .userInfoEndpoint(it -> it.userService(oAuthService))
    .successHandler(oAuthAuthneticationSuccessHandler))

먼저 security config에 이렇게 추가시키자.
userInfoEndpoint란, ClientRegistrationRepository에서 등록한 userInfoUri 에서 로그인 정보를 받아오는 point를 말하는 것이고, 잘 받아오면, oAuthService에서 작업을 authentication 작업을 하고 난 후, handler로 뒤처리를 한다.

1) OAuthService

JWT관련 로그인을 구현할 때, UserDetailsService를 구현하여 AuthService를 만들었던 기억이 있다.
이전 포스트 : https://velog.io/@goat_hoon/Spring-Security%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-JWT-%EB%8F%84%EC%9E%85%EA%B8%B0

마찬가지이다. 이번엔 OAuth2UserService<OAuth2UserRequest, OAuth2User> 를 구현하자.

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final SocialMemberRepository socialMemberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        log.info("oauth2user = {}",oAuth2User);
        String email = oAuth2User.getAttribute("email");
        String nickname = UUID.randomUUID().toString().substring(0,15);
        String password = "default";
//        Role role = Role.ROLE_USER;


        Optional<SocialMember> socialMember = socialMemberRepository.findByEmail(email);

        List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();

        if(socialMember.isEmpty()){
            SocialMember savedSocialMember = SocialMember.createSocialMember(email, nickname);
            SaveMemberResponseDto savedResponse = socialMemberRepository.save(savedSocialMember);
            authorities.add(new SimpleGrantedAuthority("ROLE_FIRST_JOIN"));
            User savedUser = new User (String.valueOf(savedResponse.getId()),password,authorities);
            return new CustomUserDetails(String.valueOf(savedResponse.getId()),authorities,savedUser,oAuth2User.getAttributes());
        }
        else{
            authorities.add(new SimpleGrantedAuthority("ROLE_EXIST_USER"));
            User savedUser = new User (String.valueOf(socialMember.get().getUserId()),password,authorities);
            return new CustomUserDetails(savedUser.getUsername(),authorities,savedUser,oAuth2User.getAttributes());
        }
    }

}

먼저 소셜 로그인이므로, SocialMemberRepository라는 구현체를 직접 주입받았다. 추상화가 필요한 상황은 아니었다. 구현체를 계속 변경할 일이 없기 때문이다.
그후 loadUser 메서드를 override한다. 이는 UserDetailsService의 loadUserByUsername과 동일하다.

그후, oauth2User정보, 즉, DefaultOAuth2UserService 인스턴스를 통해 request에 담긴 구글 로그인 정보들을 받아오고, email 정보, nickname정보들을 꺼낸다. 여기서 nickname정보는 우리 서비스에서 직접 입력하게 할 것이므로 UUID를 이용하여 아무거나 집어 넣었다. (DB nickname이 not null로 설정되어있어서 null로 보내면 안되는 상황이었다)

그 후, 받은 정보들을 토대로, 로그인 정보가 이미 db에 존재하는지 존재하지 않은지에 따라 시나리오가 분기되었다.

그 코드는
Optional<SocialMember> socialMember = socialMemberRepository.findByEmail(email);
이 코드였다. 등록된 email이 있는지 체크하고, 이것이 empty인지 아닌지에 따라, ROLE을 다르게 주었다.
물론 그러라고 만든 ROLE은 아니었지만 지금 당장에는 급히 구현해야 하므로, 추후에 리팩토링하면 되지 않을까 싶다. 그리고 우리 서비스에는 딱히 ROLE까지는 필요하진 않기 때문에, 이렇게 임시방편으로 사용해도 괜찮지 않을까 생각했다.

우리 서비스에 가입한적 없으면
DB에 해당 유저정보를 저장 후, 저장된 userId 값과 ROLE_FIRST_JOIN 이라는 ROLE을 부여하여 authority에 추가시킨후 CusmtomUserDetails 인스턴스를 생성, return하여
OAuth2User 객체에 담았다.

이미 우리 서비스에 가입한 유저라면, DB에 등록된 정보를 그대로 OAuth2User 객체에 담아 return 하였다.

2) OAuthAuthneticationSuccessHandler

여기에서는 앞서 OAuthService로 부터 return된 authentication 객체를 다루는 로직이 수행되어야 한다.
어구만 봐도, OAuthAuthentication이 성공적으로 부여된 것을 Handle한다. 착착 달라붙는다.

@Component
@RequiredArgsConstructor
@Slf4j
public class OAuthAuthneticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    @Value("${jwt.domain}") private String domain;
    @Value("${oauth-signup-uri}") private String signUpURI;
    @Value("${oauth-signin-uri}") private String signInURI;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String accessToken = tokenProvider.createToken(authentication);
        if(request.getServerName().equals("localhost")){
            String cookieValue = "accessToken=" + accessToken + "; Path=/; Domain=" + domain + "; Max-Age=1800; HttpOnly";
            response.setHeader("Set-Cookie", cookieValue);
            log.info("redirect url ={}",redirectUriByFirstJoinOrNot(authentication));
            response.sendRedirect(redirectUriByFirstJoinOrNot(authentication));
        }
        else{
            String cookieValue = "accessToken="+accessToken+"; "+"Path=/; "+"Domain="+domain+"; "+"Max-Age=1800; HttpOnly; SameSite=None; Secure";
            response.setHeader("Set-Cookie",cookieValue);
            response.sendRedirect(redirectUriByFirstJoinOrNot(authentication));
        }
    }

    private String redirectUriByFirstJoinOrNot(Authentication authentication){
        OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = oAuth2User.getAuthorities();
        //사실 authority 가 ROLE_FIRST_JOIN인게 이상하긴함. 하지만 authentication 객체를 활용하기 위해서 해당 방법을 사용하였음.
        //어차피 role은 우리 로직엔 사용되지 않기 때문임.
        if(authorities.stream().filter(o -> o.getAuthority().equals("ROLE_FIRST_JOIN")).findAny().isPresent()){
            return UriComponentsBuilder.fromHttpUrl(signUpURI)
                    .path(authentication.getName())
                    .build().toString();

        }
        else{
            return UriComponentsBuilder.fromHttpUrl(signInURI)
                    .build().toString();
        }
    }
}

이 handler의 역할은 두가지이다.
1. 로그인 후 cookie값 반환하여, 로그인 유지 (쿠키안에 jwt 토큰 담음)
2. 이미 가입된 유저라면 main page로, 첫 사용자라면 nickname창으로 redirection 분기

이렇게 두가지 작업을 하기위해 success handler로 구현하였다.
이러한 handler 역할로 request와 resposne의 역할을 처리할 수 있었다.

이렇게 하여 우리 서비스의 회원가입/로그인은 어느정도 마칠 수 있었다.

profile
기록하고, 공유합시다

0개의 댓글