거누의 개발노트

Spring Boot OAuth2.0 적용기 본문

Spring

Spring Boot OAuth2.0 적용기

Gogozzi 2022. 7. 4. 12:54
반응형

프로젝트가 진행되고 일주일이 지나서야 블로그를 적을 시간이 났다.

내가 맡은 기능은 로그인/회원가입, 소셜 로그인/회원가입 기능을 맡았다.

많은 구글페이지를 찾아봤지만, 아래만한 곳이 없었다.

https://deeplify.dev/back-end/spring/oauth2-social-login#%EC%A0%84%EC%B2%B4-%EC%8B%9C%ED%80%80%EC%8A%A4-%EB%8B%A4%EC%9D%B4%EC%96%B4%EA%B7%B8%EB%9E%A8

 

[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)

스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.

deeplify.dev

해당 블로그를 클론 코딩한 Git 레포

https://github.com/geonoo/Oauth2

 

GitHub - geonoo/Oauth2: oauth2.0 적용기

oauth2.0 적용기. Contribute to geonoo/Oauth2 development by creating an account on GitHub.

github.com

자세한 코드는 git에서 확인할 수 있습니다.

 

해당 내용들의 프로세스를 토대로 어떤 코드가 어떤 기능을 하고 삽질했던 것들을 정리해 보려고 한다.

1. 사용자가 소셜 로그인 버튼 클릭!

  • http://localhost:8080/oauth2/authorization/{provider-id}?redirect_uri=http://localhost:3000
  • 위 URL로 버튼 만들기

2. Redirect : Authorization Code Request with Back-End Redirect Url
    리다이렉트와  인가코드를 요청한다.

3. 소셜로그인 할 수 있는 Redirect Url과 인코드를 반환해준다.

4. 소셜로그인을 할 수 있는 로그인 페이지를 제공해준다. ( 해당 로그인 페이지는 카카오, 구글, 네이버에서 열리는 페이지이다. )

카카오라면 이러한 페이지를 볼 수 있다.

5. 사용자는 로그인 및 동의를 진행하면 소셜 인가서버로 내용이 전달이 된다.

6. 소셜 인가서버에서는 인가 코드를 반환해준다.

7. 받은 인가코드와 함께 Access-Token을 요청한다.

8. Access-Token을 받는다.

9. Access-Token와 함께 사용자 정보를 요청한다.

10. 사용자 정보를 받는다.

  • Back-End 서버에서는 받은 유저정보를 DB에 저장한다.
  • JWT 토큰과 Refresh 토큰을 만든다.
  • Refresh토큰을 DB에 저장한다.

11. 마지막으로 다시 프론트로 Redirect 시키면서 만들었던 토큰들도 반환해준다. Refresh토큰을 쿠키에 넣고, JWT토큰은 URL 파라미터로 주는 방식이다.

 

위 방식이 얼추 이해가 된다면 아래 전체 프로세스를 봤을때, 이해가 될 것이다.

각 개발 사이트의 설정 부터 사용자 정보를 가져오는 부분까지 이해하는데, 많은 시간이 걸렸던 것 같다.

1. 구글 정보는 전부 받아지는데, 카카오, 네이버는 User정보를 보내주는 형식이 달라서 Json을 읽어주는 곳을 수정해야했다.

public class GoogleOAuth2UserInfo extends OAuth2UserInfo {

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

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

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

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

    @Override
    public String getImageUrl() {
        return (String) attributes.get("picture");
    }
}
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

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

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");

        if (properties == null) {
            return null;
        }

        return (String) properties.get("nickname");
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        return (String) kakaoAccount.get("email");
    }

    @Override
    public String getImageUrl() {
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");

        if (properties == null) {
            return null;
        }

        return (String) properties.get("thumbnail_image");
    }
}
public class NaverOAuth2UserInfo extends OAuth2UserInfo {

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        if (response == null) {
            return null;
        }

        return (String) response.get("id");
    }

    @Override
    public String getName() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        if (response == null) {
            return null;
        }

        return (String) response.get("name");
    }

    @Override
    public String getEmail() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        if (response == null) {
            return null;
        }

        return (String) response.get("email");
    }

    @Override
    public String getImageUrl() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        if (response == null) {
            return null;
        }

        return (String) response.get("profile_image");
    }
}

2. 우리 사이트에서 Email인증을 통해 가입한 유저가 만약 소셜로그인을 하려고 한다면, 같은 이메일 주소라면 바로 소셜로그인이 될 수 있게 하는 로직도 필요했다.

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User user = super.loadUser(userRequest);

        try {
            return this.process(userRequest, user);
        } catch (AuthenticationException ex) {
            throw ex;
        } catch (Exception ex) {
            ex.printStackTrace();
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
        }
    }

    private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
        ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());

        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
        User savedUser = userRepository.findByUserId(userInfo.getId());
        //이미 가입된 회원
        if (savedUser != null) {
            if (providerType != savedUser.getProviderType()) {
                throw new OAuthProviderMissMatchException(
                        "Looks like you're signed up with " + providerType +
                                " account. Please use your " + savedUser.getProviderType() + " account to login."
                );
            }
        } else {
            //이미 로컬로 가입한 회원
            Optional<User> optionalUser = userRepository.findByEmail(userInfo.getEmail());
            if (optionalUser.isPresent()) {
                //소셜로그인 할 수 있게 변경
                savedUser = optionalUser.get();
                savedUser.updateSocialId(userInfo.getId(), providerType);
            }else{
                //최초 가입
                savedUser = createUser(userInfo, providerType);
            }
        }

        return UserPrincipal.create(savedUser, user.getAttributes());
    }

    private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
        User user = new User(
                userInfo.getId(),
                userInfo.getEmail(),
                "Y",
                userInfo.getImageUrl(),
                providerType,
                RoleType.USER
        );
        log.info("before saveAndFlush");
        return userRepository.saveAndFlush(user);
    }

}

3. OAuth2AuthenticationSuccessHandler에서 jwt토큰과 refresh토큰을 함께 보내주는 방식을 선택했다. 또한 가입한 유저의 닉네임은 우리 서비스에서 중복을 허용되면 안되기 때문에, 닉네임을 입력할 수 있게 구분해주는 flag를 보내주었다.

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

        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new IllegalArgumentException("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
        }

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

        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
        ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());

        OidcUser user = ((OidcUser) authentication.getPrincipal());
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
        if (userInfo.getEmail() == null){
            throw new CustomException(ErrorCode.NEED_EMAIL);
        }
        Collection<? extends GrantedAuthority> authorities = ((OidcUser) authentication.getPrincipal()).getAuthorities();

        RoleType roleType = hasAuthority(authorities, RoleType.ADMIN.getCode()) ? RoleType.ADMIN : RoleType.USER;

        String username = userRepository.findByUserId(userInfo.getId()).getUsername();

        Date now = new Date();
        AuthToken accessToken = tokenProvider.createAuthToken(
                userInfo.getEmail(),
                roleType.getCode(),
                username,
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        // refresh 토큰 설정
        long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

        AuthToken refreshToken = tokenProvider.createAuthToken(
                appProperties.getAuth().getTokenSecret(),
                new Date(now.getTime() + refreshTokenExpiry)
        );

        // DB 저장
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByEmail(userInfo.getEmail());
        if (userRefreshToken != null) {
            userRefreshToken.setRefreshToken(refreshToken.getToken());
        } else {
            userRefreshToken = new UserRefreshToken(userInfo.getEmail(), refreshToken.getToken());
            userRefreshTokenRepository.saveAndFlush(userRefreshToken);
        }

//        쿠키에 넣지 않고 파라미터로 넘겨주는 방식으로 변경
        String nickCheck = (username == null) ? "N" : "Y";
        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("access", accessToken.getToken())
                .queryParam("refresh", refreshToken.getToken())
                .queryParam("nick", nickCheck)
                .build().toUriString();
    }

 

처음에 예전방식의 카카오 로그인으로 구현했다가, 해당 소스를 보고 작성했던 카카오 로그인은 걷어 내게 되었다.

위 삽질이 없었으면 조금더 빨리 구현할 수 있지 않았을까 라고 생각하지만, 처음 삽질이 있어서 이 로직을 이해하는데, 어느정도 도움이 되었다고 생각한다.

 

프로젝트를 시작하고 벌써 일주일이 지났다. 앞으로도 이러한 문제들을 해결하면서 꾸준히 작성해 나가야겠다.

반응형
Comments