Springboot SpringSecurity+JWT+OAuth2 카카오 로그인 구현 관련

Springboot SpringSecurity+JWT+OAuth2 소셜 로그인 구현

안녕하세요 개인 프로젝트로 소셜 로그인을 구현 중에 있는데 제가 생각한 방법이 맞는지 의문이 들어서 여쭤봅니다
Springboot SpringSecurity+JWT+OAuth2로 소셜 로그인 구현을 진행하고 있습니다.

프론트 화면 단은 구현 안하고, 백엔드 서버 구현에만 집중을 하고 있습니다.
일반 회원의 회원가입, 로그인 후 스프링시큐리티와 JWT 통해 Access, Refresh 토큰 발급하는 것까진 진행을 했는데,
카카오 로그인 및 소셜 로그인에서 조금 막막해서 질문 드립니다ㅠ

http://localhost:1224/oauth2/authorization/kakao

이 주소로 요청을 하면, CustomOAuth2UserService 클래스에서 유저 정보를 저장하고,

OAuth2LoginSuccessHandler 클래스를 통해 JWT Access, Refresh 토큰을 발행해서
쿼리 파라미터로 넘겨주는 로직으로 진행을 했는데 이렇게 해도 괜찮은 걸까요?

잘하고 있는건지 감이 잘 안와서 여쭤봅니다

로그인에 성공한다면 이런 식으로 리다이렉트 해줍니다…

http://localhost:1224/?loginSuccess=true&accessToken=액세스토큰&refreshToken=리프레시토큰
그 이후에 게시글 작성이나 다른 비즈니스 관련 서비스들은 이 쿼리 파라미터의 액세스 토큰이 필요하구요

CustomOAuth2UserService 일부

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

        //DefaultOAuth2User 서비스를 통해 User 정보를 가져와야 하기 때문에 대리자 생성
        OAuth2User oAuth2User = super.loadUser(userRequest);

        //서비스를 구분해주는 코드 (구글/네이버/카카오)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        //OAuth2 로그인 진행시 키가 되는 필드값 프라이머리키와 같은 값 (네이버 카카오 지원 x)
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        //OAuth2UserService를 통해 가져온 데이터를 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(
                registrationId
                , userNameAttributeName
                , oAuth2User.getAttributes());

        //로그인 한 유저 정보
        Member member  = saveOrUpdate(attributes);

        //httpSession의 유저 속성을 설정
        httpSession.setAttribute("user", new SessionUser(member));

        // 로그인한 유저를 리턴함
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    // 유저를 저장하고, 이미 있는 데이터라면 업데이트 처리하는 메서드
    private Member saveOrUpdate(OAuthAttributes attributes) {
        Member user = memberRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName()))
                .orElse(attributes.toEntity());

        return memberRepository.save(user);
    }

}

OAuth2LoginSuccessHandler 일부

@Component
@Transactional
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

        OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = oauth2User.getAttributes();
        Object id = attributes.get("id");
        Object sub = attributes.get("sub");
        String providerId = null;

        if(id != null){
            providerId = String.valueOf(id);
        }else if(sub != null){
            providerId = String.valueOf(sub);
        }

        Optional<Member> findMember = memberRepository.findByProviderId(providerId);

        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString("http://localhost:1224");

        if (findMember.isEmpty()) {

            String redirectionUri = uriBuilder
                    .queryParam("loginSuccess", false)
                    .build()
                    .toUriString();

            response.sendRedirect(redirectionUri);
        } else {

            Member member = findMember.get();
            TokenResponse tokenResponse = jwtTokenProvider.createToken(authentication);
            refreshTokenRepository.deleteAll();

            // RefreshToken을 DB에 저장 (식별을 위해 memberId 함께 저장)
            RefreshTokenEntity refreshToken = RefreshTokenEntity.builder()
                    .memberId(member.getId())
                    .token(tokenResponse.getRefreshToken())
                    .build();
            refreshTokenRepository.save(refreshToken);

            String redirectionUri = uriBuilder
                    .queryParam("loginSuccess", true)
                    .queryParam("accessToken", tokenResponse.getAccessToken())
                    .queryParam("refreshToken", tokenResponse.getRefreshToken())
                    .build()
                    .toUriString();

            response.sendRedirect(redirectionUri);
        }

    }
}

안녕하세요.

보통 OAuth2LoginSuccessHandler 에서는 서비스측 인가 완료 이후, 메인페이지로 이동하거나, 로그인이 요청된 페이지로 이동 시킵니다.

해당 로직에서 접근토큰과 리프래시 토큰을 GET URL에 포함되어 리디렉션 하는것은 보안에 취약 합니다. 이들 정보가 노출되지 않도록 세션이나 DB에 저장하여 꺼내 쓰도록 하시는게 좋을것 같습니다.

답변 감사합니다
혹시 액세스 토큰만 쿼리 파라미터로 넘겨주고,
리프레시 토큰은 DB와 쿠키에 저장하도록 로직을 변경하는 것은 괜찮을까요?
레퍼런스를 찾아보고 있는데 쿠키에 저장하는 방식들도 보여서 궁금합니다

몇가지 궁금한점이 있는데요

  1. 접근토큰 발급이 이미 백앤드에서 이루어 지셨기에 파라미터로 다시 전달 받으실 이유가 없어 보입니다.
    이렇게 개발 하셔야 하는 이유가 있을까요?

  2. 리프래시 토큰을 보유하고 있어야 하는 이유가 있을까요?
    보통 서비스측에서 사용자 접근토큰이 필요한 배치성 업무나 사용자 요청에 따라 지속적으로 사용할 필요가 있을 때, 이를 갱신하기 위한 용도로 저장하지만, 저장할 이유가 없을 때는 보유하고 계실 필요가 없는 정보 입니다.

사용자 접근토큰과 리프래시 토큰은 유출되지 않도록 주의 하여야 합니다.

개발 및 사용편의를 위해 쿠키에 암호화하여 저장하는 방법도 있지만, 사용하지 않는 정보라면 저장하지 않는것이 좋으며 필요한 경우, 백앤드에서만 사용하시는것을 추천 드리며 특히 리프래시토큰의 경우 반드시 백엔드에서만 사용하도록 권장드립니다.

접근토큰을 쿠키에 저장하더라도 백앤드에서만 사용하실 목적이시라면 허용 IP 주소 설정 기능을 통해 보안을 강화하실수도 있습니다.