OAuth2를 이용한 구글 소셜 로그인 처리(1) - 서버에서 처리

Jongwon·2023년 3월 11일
1

DMS

목록 보기
11/18
post-thumbnail

소셜 로그인을 구현하는 데는 여러 방식이 존재합니다. 서버에서 구글 인증을 진행할 수도 있고, 클라이언트에서 인증을 진행한 뒤, 데이터만 서버에서 저장할 수도 있습니다.

이번 글에서는 Spring Security에서 제공하는 oauth2-client를 이용하여 소셜 로그인을 구현하겠습니다.


🔥주의: 이번 글에서 소개하는 방식은 Rest API 서버에서 진행할 수 있는 서버 사이드 인증 방식입니다. Native앱에서는 URI기반의 리디렉션이 불가능하기 때문에 이 방식을 사용하면 안됩니다.

구글 공식문서에 적혀있는 내용입니다. 공식문서



소셜 로그인 로직

시나리오는 다음과 같습니다.

  1. 사용자가 페이지에 있는 소셜 로그인 버튼을 누릅니다. (클라이언트➡️서버)

    소셜 로그인 버튼에 연결된 링크는 다음과 같습니다.
    '도메인명(ex. http://localhost:8080)/oauth2/authorization/google?redirect_uri=xxx'

  2. 구글 로그인 페이지로 자동으로 연결됩니다. 이때 Request정보는 httpCookieOAuth2AuthorizationRequestRepository에서 쿠키로 저장하고 있습니다. (서버➡️구글)

  3. 로그인 및 권한 설정 완료 시 Authorization Code와 함께 응답이 구글로부터 서버로 리디렉션됩니다. (구글➡️서버)

  4. 저장했던 쿠키는 삭제하고, Authorization Code를 구글로 보내 Access Token으로 교환합니다. (서버➡️구글)

    3,4번의 작업은 Security Config에 지정만 하면 Spring Security가 자동적으로 진행해줍니다.

    구글에서 서버로 보내주는 리디렉션 URL은 구글 OAuth2 설정 시 지정할 수 있는데, 현재는 'http://localhost:8080/login/oauth2/code/google'로 지정하였습니다.

  5. Access Token을 통해 구글 API에 접근하여 사용자 정보를 얻어올 수 있습니다. API를 통해 이메일, 이름을 가져옵니다.

    DefaultOAuth2UserService에 있는 loadUser()메서드를 통해 구글로부터 사용자 정보를 받아올 수 있습니다.

  6. 모든 과정이 성공하면 OAuth2SuccessHandler가 실행됩니다. CustomOAuth2UserService에서 생성한 OAuth2User를 이용하여 DB에 소셜 로그인을 진행한 해당 회원이 존재하는지 확인 후, 없다면 새로 생성, 있으면 토큰을 발행하여 클라이언트가 지정한 Redirect URL로 리디렉션합니다. (서버➡️클라이언트)


🔥1,6의 Redirect URL과 3,4의 Redirect URL은 서로 다른 URL입니다.
1,6번은 클라이언트가 데이터를 받아올 주소로, 서버가 일련의 과정을 처리한 후 돌아가야할 클라이언트의 주소이고,
3,4번은 인증기관과 Spring 서버 사이의 통신을 주고받을 때 인증기관이 데이터를 보낼 서버의 주소입니다.




Dependency 설정

소셜로그인을 구현하기 위해서는 OAuth2라는 프로토콜을 사용해야 합니다. Spring Boot에서는 Dependency를 지원하고 있고, 이미 초기 설정에서 설치하였습니다.

⭐️ 혹시라도 설치하지 못하신 분들은 아래 dependency를 추가하시길 바랍니다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'


또한 구글 OAuth2 설정을 진행하고, application.yml에 아래 내용을 추가합니다.

✅application.yml

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 구글이 제공한 client-id
            client-secret: 구글이 제공한 client-secret
            scope:
              - profile
              - email



Entity 생성

이후 서비스계층에서 인증기관으로부터 받은 정보를 임시로 저장해둘 엔티티가 필요합니다. OAuth2Attributes를 정의하여 구글 이외에도 카카오, 네이버 등 인증 기관의 종류에 상관없이 하나의 서비스 계층 메서드로 처리할 수 있도록 합니다.

✅OAuth2Attribute

@Data
@Builder
public class OAuth2Attribute {

    private String provider;
    private Map<String, Object> attributes;
    private String userId;
    private String username;
    private String email;
    private String picture;
    private String nickname;

    public static OAuth2Attribute of(String provider, String usernameAttributeName, Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return OAuth2Attribute.ofGoogle(provider, usernameAttributeName, attributes);
            default:
                throw new RuntimeException("소셜 로그인 접근 실패");
        }

    }

    private static OAuth2Attribute ofGoogle(String provider, String usernameAttributeName, Map<String, Object> attributes) {

        return OAuth2Attribute.builder()
                .provider(provider)
                .attributes(attributes)
                .username(String.valueOf(attributes.get("name")))
                .email(String.valueOf(attributes.get("email")))
                .userId(String.valueOf(attributes.get(usernameAttributeName)))
                .build();
    }

    public Map<String, Object> mapAttribute() {
        Map<String, Object> map = new HashMap<>();
        map.put("userId", userId);
        map.put("username", username);
        map.put("email", email);
        map.put("provider", provider);

        return map;
    }
}

구글을 예시로 들면, 사용자를 구분하는 ID값을 "sub"라는 이름에 담아 보내줍니다. 각 인증기관마다 변수명이 다르기 때문에 usernameAttributeName이라는 파라미터를 통해 구분합니다.



Repository 생성

다음으로는 위의 Flow의 2번에 해당하는 Repository를 생성하겠습니다. 여기에서 쿠키를 처리하기 때문에 쿠키를 처리할 CookieUtil도 생성합니다.

✅CookieUtil

public class CookieUtil {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if(cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if(cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }

        return Optional.empty();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if(cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Serializable object) {
        return Base64.getUrlEncoder()
                .encodeToString(org.apache.commons.lang3.SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

쿠키를 저장 및 삭제하는 로직, 직렬화 및 역직렬화하는 로직을 수행할 수 있습니다.

자바 직렬화에 대한 설명은 아래 글을 참고하였습니다.
https://huisam.tistory.com/entry/javaserialization



✅HttpCookieOAuth2AuthorizationRequestRepository

@Repository
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public static final String OAUTH2_AUTHORIZATION_COOKIE_NAME =  "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int cookieExpireSeconds = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_COOKIE_NAME)
                .map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if(authorizationRequest == null) {
            CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_COOKIE_NAME);
            CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), cookieExpireSeconds);
        String redirectUrlAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUrlAfterLogin)) {
            CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUrlAfterLogin, cookieExpireSeconds);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_COOKIE_NAME);
        CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}

Repository에서는 요청을 직렬화하여 쿠키에 저장하고, 권한을 얻게되면 역직렬화하여 쿠키를 삭제하도록 설계하였습니다. 해당 로직은 이후 SecurityConfig에서 OAuth2Login에 대해 Repository를 사용하라고 정의할 예정입니다.



Service 생성

✅CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getRegistrationId();
        String usernameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(provider, usernameAttributeName, oAuth2User.getAttributes());

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                oAuth2Attribute.mapAttribute(),
                "email"
                );

    }
}

Service에서는 super.loadUser를 통해 구글로부터 사용자 정보를 받아오고, 이를 이용하여 OAuth2Attribute엔티티를 생성합니다.

Success시 처리

성공적으로 회원 정보를 가져왔을 때 처리할 Handler입니다.

✅OAuth2SuccessHandler

@Component
@RequiredArgsConstructor
@Log4j2
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final MemberRepository memberRepository;
    private final TokenService tokenService;
    private final ObjectMapper objectMapper;
    private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String targetUrl = determineTargetUrl(request, response, authentication);

        MemberDTO memberDTO = memberRepository.findByEmail(oAuth2User.getAttribute("email").toString())
                .map(member -> MemberMapper.INSTANCE.memberToMemberDTO(member))
                .orElse(saveNewMember(oAuth2User));    //orElse에 계정저장

        //소셜이 아닌 회원이 이메일로 저장했을 때
        if (!memberDTO.isSocial()) {
            response.sendError(404, "해당 이메일을 가진 회원이 존재합니다.");
            clearAuthenticationAttributes(request, response);
        } else {
            TokenDTO tokenDTO = tokenService.createToken(memberDTO);
            ResponseCookie refreshTokenCookie = ResponseCookie
                    .from("refresh_token", tokenDTO.getRefreshToken())
                    .httpOnly(true)
                    .secure(true)
                    .sameSite("None")
                    .maxAge(tokenDTO.getDuration())
                    .path("/")
                    .build();

            response.addHeader("Set-Cookie", refreshTokenCookie.toString());
            targetUrl = UriComponentsBuilder.fromUriString(targetUrl).queryParam("accessToken", tokenDTO.getAccessToken()).build().toUriString();
        }

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }


    protected MemberDTO saveNewMember(OAuth2User oAuth2User) {

        //userId를 나중에 변경해야함
        String userId = oAuth2User.getAttribute("userId").toString().concat(oAuth2User.getAttribute("provider").toString());
        List<Role> roles = new ArrayList<>();
        roles.add(Role.ROLE_USER);

        Member member = Member.builder()
                .provider(Provider.of(oAuth2User.getAttribute("provider").toString()))
                .social(true)
                .email(oAuth2User.getAttribute("email"))
                .username(oAuth2User.getAttribute("username"))
                .userId(userId)
                .roles(roles)
                .build();

        memberRepository.save(member);

        return MemberMapper.INSTANCE.memberToMemberDTO(member);

    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        String targetUrl = redirectUrl.orElse(getDefaultTargetUrl());

        return UriComponentsBuilder.fromUriString(targetUrl).toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }
}

회원 정보가 DB에 없다면 생성, 있다면 가져온 후 토큰을 생성하여 클라이언트에게 전달합니다. 이때, 클라이언트가 지정해주었던 리디렉션 URL로 전송해줍니다.

리디렉션 처리를 하지않고 200(OK)로 처리한 이유는 토큰을 Body에 담아서 전송해야했기 때문입니다. 하지만 REST API 방식을 지키기 위해 URL 파라미터에 토큰을 담고 리디렉션 처리를 하는 것이 더 깔끔해보입니다.



✅SecurityConfig

public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    //추가
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final CustomOAuth2UserService oAuth2UserService;
    private final HttpCookieOAuth2AuthorizationRequestRepository oauth2requestRepository;
    
...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
    //추가
        http
                .oauth2Login()
                .authorizationEndpoint().baseUri("/oauth2/authorization")
                .authorizationRequestRepository(oauth2requestRepository)
                .and()
                .redirectionEndpoint().baseUri("/login/oauth2/code/**")
                .and()
                .userInfoEndpoint().userService(oAuth2UserService)
                .and()
                .successHandler(oAuth2SuccessHandler);

마지막으로 Security Config에 추가하여 소셜 로그인 시 Security 필터를 통과할 수 있도록 합니다.



현재 진행하는 프로젝트에는 적용을 하지 않아 진행과정을 따로 보여드릴 수 없습니다. 아래 글들을 참고해주세요.


참고자료
코드 참고를 가장 많이했던 블로그
🔥https://datamoney.tistory.com/336
🔥https://ozofweird.tistory.com/entry/Spring-Boot-Spring-Boot-JWT-OAuth2-2

http://yoonbumtae.com/?p=3000
https://jyami.tistory.com/121
https://yelimkim98.tistory.com/49
https://devbksheen.tistory.com/entry/Spring-Boot-OAuth20-%EC%9D%B8%EC%A6%9D-%EC%98%88%EC%A0%9C
https://datamoney.tistory.com/336

profile
Backend Engineer

0개의 댓글