📡 백엔드/🌱 Spring Boot

[Spring] 스프링 시큐리티 + JWT로 카카오 로그인 구현하기, 프론트엔드와 연결

gengminy 2022. 9. 4. 18:51

 

지난 번에 DefaultOAuth2Service 를 상속하여

스프링 시큐리티에 등록하는 방식으로 카카오 로그인을 구현해보았다

 

하지만 이런 식으로 하게 되면

스프링 내장 함수을 이용하면서 편리하게 구현을 할 수는 있지만

구조가 너무 추상적이고 컨트롤러에서 통제하기가 어려웠다

 

그래서 이번에는 OAuth 인증 과정의 본질을 뜯어보면서 하나하나 구현해보았다

 

 

💾 이전 글 (DefaultOAuth2Service 이용)

https://gengminy.tistory.com/39

 

[Spring] OAuth2Service + 스프링 시큐리티 + JWT로 카카오 로그인 구현하기

📌 OAuth "OpenID Authorization"의 약자 비밀번호를 제공하지 않으면서 웹사이트나 어플리케이션 접근 권한을 부여할 수 있는 로그인 방식 기존 아이디와 비밀번호를 통한 로그인 방식은 보안상 취약

gengminy.tistory.com

 

 

🚀 Kakao Developers 설정

더보기

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

시작하기

 

어플리케이션 추가하기 누르고 앱 이름과 사업자명 입력

이후 만든 어플리케이션 클릭해서 이동

 

 

REST API 키 나중에 사용해야 하니까 이 페이지 기억해두기

 

좌측 메뉴 카카오 로그인 -> 활성화 설정 상태 ON 으로 변경

이후 아래 Redirect URI 설정으로 진입

 

일단 로컬 환경에서 테스트를 진행해야 하기 때문에 로컬호스트로 설정
리액트에서 localhost:3000 사용하니까 그 쪽으로 등록해준다

이전 글에서는 localhost:8000 사용해서 서버 포트로 들어갔는데

이번에는 리액트랑도 완벽하게 연결하기 위해 바꾸었다

로그인 시도할 때 이 URI 를 통해 인가 코드가 반환이 된다

이 URI 은 클라이언트 쪽에서 사용해야 하니까 잘 기억해두기

 

 

카카오 로그인 -> 동의항목 에서

OAuth 를 통한 회원가입 진행 시 받아올 정보를 설정

 

 

🚀 인증 구조

이미지 출처 : https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

 

클라이언트 <-> 백엔드 서버 <-> 카카오 api 서버가

수 차례 통신하면서 로그인 과정을 진행하게 된다

본질은 같아서 하나만 구현해보면 나머지는 쉽게 할 수 있을 거 같다

 

 

🔨 프론트엔드 (React)

📝 KakaoLogin.js

const KakaoLogin = () => {
  const location = useLocation();
  const param = useParams();

  const KAKAO_BASEURL = process.env.REACT_APP_KAKAO_BASEURL;
  const KAKAO_RESTAPI_KEY = process.env.REACT_APP_KAKAO_RESTAPI_KEY;
  const KAKAO_REDIRECT_URI = process.env.REACT_APP_KAKAO_REDIRECT_URI;

  const loginUri =
    'https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=' +
    KAKAO_RESTAPI_KEY +
    '&redirect_uri=' +
    KAKAO_BASEURL +
    KAKAO_REDIRECT_URI;

  return (
    <>
      <NavBar />
      <h1>카카오 로그인</h1>
      <a href={loginUri}>카카오 로그인</a>
    </>
  );
};

export default KakaoLogin;

아까 카카오 디벨로퍼에서 설정했던 정보를

dotenv 파일에 다 담아두고 사용 중이다

이 정보를 이용해서 카카오 로그인 요청 시 해당 url로 넘어가게 하면 된다

 

해당 url 로 넘어가면 유저에게 카카오 계정 로그인을 요청하고

카카오 계정 로그인이 완료되면 다음 스텝으로 넘어간다

 

 

📝 KakaoProcess.js

const KakaoProcess = () => {
  const params = new URL(document.location).searchParams;
  const code = params.get('code'); //쿼리파라미터 code 가져오기
  const setAccessToken = useCookies(['accessToken'])[1];

  useEffect(() => {
    async function dispatchLogin() {
      try {
        AuthApi.requestKakaoLogin(code).then(res => {
          if (res.status === 200) {
            const date = new Date();
            const { accessToken } = res.data.data;
            setAccessToken('accessToken', accessToken, {
              expires: new Date(date.setDate(date.getDate() + 3)),
              path: '/',
              secure: true,
            });
            console.log(res.data.data);
          }
        });
      } catch (error) {
        console.error(error);
      }
    }
    dispatchLogin();
  }, []);

  return (
    <>
      <NavBar />
      <h1>로그인 처리중입니다</h1>
    </>
  );
};

export default KakaoProcess;

카카오 계정 로그인이 완료되면 query parameter 로

해당 유저의 계정 코드가 같이 넘어오는데

이를 캐치해서 백엔드 서버에 axios post 하는 방식으로 진행했다

 

아까 설정한 redirect url + code 가 넘어온다

 

 

📝 AuthApi.js

const AuthApi = {
  ...
  ...
  requestKakaoLogin: async body => {
    const data = await axios.post(
      'http://localhost:8000/auth/login/kakao',
      body,
      { headers: { 'Content-Type': 'application/json' } }
    );
    return data;
  },
};

export default AuthApi;

일단 테스트 중이니 localhost:8000으로 날리는 방식

서버에서는 /auth/login/kakao 를 컨트롤러에서 받아서 처리한다

 

간혹 content-type 이 urlencoded 방식이라고 안되는 경우가 있는데

헤더에 application/json 방식을 정의해주면 해결된다

 

 

이제 백엔드 서버 로직을 보자

 

 

🔨 백엔드 (Spring)

📝 KakaoController.java

@Slf4j
@Controller
@RequestMapping("/auth/login")
@RequiredArgsConstructor
public class KakaoController {

    private final KakaoService kakaoService;
    private final AuthService authService;
    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @ApiOperation(value = "카카오 코드를 포함한 로그인 요청")
    @PostMapping(value = "/kakao", produces = "application/json; charset=utf-8")
    @ResponseBody
    public CommonApiResponse<TokenResponseDto> postKakaoLoginWithCode(
            @RequestBody KakaoRequest kakaoRequest,
            HttpServletResponse response
    ) {
        KakaoTokenDto kakaoTokenDto = kakaoService.getKakaoAccessToken(kakaoRequest.getCode());
        KakaoUserDto kakaoUserDto = kakaoService.getKakaoUser(kakaoTokenDto.getAccessToken());
        TokenResponseDto tokenResponseDto = authService.loginKakao(kakaoUserDto);
        return CommonApiResponse.of(tokenResponseDto);
    }
}

처리하는 엔드포인트는 /auth/login/kakao

KakaoRequest 라는 Dto를 받아서 처리한다

별건 없고 String code 를 가지고 있는 Dto 이다

 

나머지 로직은 서비스 단에서 구현했다

 

반환값은 JWT 관련 Dto 인데

엑세스 토큰과 리프레시 토큰을 반환한다

 

 

📝 KakaoRequest.java

@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KakaoRequest {
    private String code;
}

입력받는 dto 정의

프론트의 유저 code 를 가지고 있다

 

 

📝 KakaoService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = false)
@Slf4j
public class KakaoService {

    private final WebClient webClient;
    private final UserRepository userRepository;

    @Value("${KAKAO_BASEURL}")
    private String KAKAO_BASEURL;
    @Value("${KAKAO_RESTAPI_KEY}")
    private String KAKAO_RESTPAPI_KEY;
    @Value("${KAKAO_REDIRECT_URI}")
    private String KAKAO_REDIRECT_URL;

    public KakaoTokenDto getKakaoAccessToken(String code) {
        String getTokenURL =
                "https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id="
                        + KAKAO_RESTPAPI_KEY + "&redirect_uri=" + KAKAO_BASEURL + KAKAO_REDIRECT_URL + "&code="
                        + code;

        try {
            KakaoTokenDto kakaoTokenDto =
                    webClient.post()
                            .uri(getTokenURL)
                            .retrieve()
                            .bodyToMono(KakaoTokenDto.class).block();
            return kakaoTokenDto;
        } catch (Exception e) {
            e.printStackTrace();
            throw new BadRequestException(ErrorCode.KAKAO_BAD_REQUEST);
        }
    }

    public KakaoUserDto getKakaoUser(String kakaoAccessToken) {
        String getUserURL = "https://kapi.kakao.com/v2/user/me";

        try {
            KakaoUserDto kakaoUserDto =
                    webClient.post()
                            .uri(getUserURL)
                            .header("Authorization", "Bearer " + kakaoAccessToken)
                            .retrieve()
                            .bodyToMono(KakaoUserDto.class)
                            .block();
            
            return kakaoUserDto;
        } catch (Exception e) {
            e.printStackTrace();
            throw new BadRequestException(ErrorCode.KAKAO_BAD_REQUEST);
        }
    }
}

getKakaoAccessToken 에서는

카카오 유저 코드를 이용해 카카오 계정 관련 권한이 담긴 엑세스 토큰을 가져온다

 

처음할 때 혼동이 있을 수 있는데

이 엑세스 토큰은 카카오 api 서버에서 카카오 계정 관련 서비스를 이용하기 위함이지

우리가 만드는 서비스 관련 엑세스 토큰이 아니다

 

여기서는 서버에서도 프론트의 axios 와 같이

서버에서 카카오 api 서버와 통신하는 웹 클라이언트가 필요하다

 

이 때 사용하는게 RestTemplete 과 WebClient 인데

RestTemplete 은 지원이 끊기니 사용하지 않는게 좋다고 들어서

Webflux의 WebClient 를 사용했다

 

 

📝 WebClientConfig.java

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
                .responseTimeout(Duration.ofMillis(5000))
                .doOnConnected(connection -> {
                    connection.addHandlerLast(new ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
                            .addHandlerLast(new WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS));
                });

        WebClient webClient = WebClient.builder()
                .defaultHeader(HttpHeaders.CONTENT_TYPE, String.valueOf(MediaType.APPLICATION_JSON))
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();

        httpClient.warmup().block();

        return webClient;
    }
}

웹 클라이언트를 싱글톤 빈으로 사용하기 위한 설정 파일이다

여러가지 WebClient 생성 관련 설정을 해줄 수 있다

 

의존성은 다음을 build.gradle 에 추가해주면 된다

implementation 'org.springframework.boot:spring-boot-starter-webflux'

 

처음에 얻는 엑세스 토큰으로

다시 카카오 api 서버에 해당 유저를 가져오는 요청을 보낼 수 있다

 

이를 kakaoUserDto 에 저장했다

 

 

📝 KakaoUserDto.java

@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class KakaoUserDto {

    @JsonProperty("id")
    private String authenticationCode;

    @JsonProperty("connected_at")
    private Timestamp connectedAt;

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @JsonProperty("properties")
    private Properties properties;

    @Getter
    @ToString
    public static class KakaoAccount {
        private String email;
    }

    @Getter
    @ToString
    public static class Properties {
        private String nickname;
    }
}

카카오 계정 로그인한 유저가 정보 제공에 동의했다면

그 정보들이 다 넘어와 각 필드에 저장된다

 

 

📝 AuthService.java

...
...
  @Override
    @Transactional
    public TokenResponseDto loginKakao(KakaoUserDto kakaoUserDto) {
        String provider = "kakao";

        log.info("로그인 시도");

        Optional<User> user = userRepository.findByEmailAndProvider(
                kakaoUserDto.getKakaoAccount().getEmail(),
                provider
        );

        if (user.isPresent()) {
            log.info("가입된 회원");
            /* 이미 가입된 회원 */
            TokenResponseDto tokenResponseDto = jwtTokenProvider.generateSocialToken(user.get());
            return tokenResponseDto;
        } else {
            /* 새로 가입할 회원 */

            String email = kakaoUserDto.getKakaoAccount().getEmail();
            String name = kakaoUserDto.getProperties().getNickname();

            User newUser = User.builder()
                    .email(email)
                    .name(name)
                    .role(UserRole.ROLE_USER)
                    .provider(provider)
                    .authenticationCode(kakaoUserDto.getAuthenticationCode())
                    .build();
            userRepository.save(newUser);

            log.info("새로운 회원");
            

            TokenResponseDto tokenResponseDto = jwtTokenProvider.generateSocialToken(newUser);
            return tokenResponseDto;
        }
    }
...
...

이제 이전에 만든 로그인 관련 서비스인 AuthService 에 일부 로직을 추가해준다

KakaoUserDto 를 받아서 해당 유저의 소셜 로그인을 처리한다

 

만약 DB 상 유저가 있다면 로그인을 시키고

그렇지 않다면 회원가입 시킨다

 

이후 해당 유저의 정보가 담긴 JWT를 생성해서 리턴해주면 된다

 

 

이제 프론트에서 요청을 보내면

 

이런식으로 엑세스 토큰과 리프레시 토큰이 잘 넘어오게 된다

이를 리다이렉션 시켜서 다시 서비스를 시작하면 된다

 

OAuth 구조 이해하기가 힘들어서 일단 구현하면서 부딪혀봤는데 꽤 어려웠다

아무래도 너무 추상화된 코드 보다는 이런식으로 하나하나 뜯어서 하는게 성에 차는 거 같다

 

이제 다른 소셜 로그인도 구현해 봐야지