[Spring Boot] 상품 관리 페이지에 카카오 로그인 기능 추가하기

Sungjin Cho·2일 전
0

Spring Boot

목록 보기
15/15
post-thumbnail

Spring OAuth 카카오 로그인

로그인 과정

카카오 로그인을 구현하기에 앞서 kakao developers의 문서 > 카카오 로그인 > 이해하기 를 통해 카카오 로그인이 어떤 방식으로 이루어지는지 알아야한다.

  • 카카오 로그인
    1. 사용자 클라이언트에서 사용자가 카카오 로그인 버튼을 선택하면, 서비스는 카카오 API 플랫폼 서버로 인가 코드 발급을 요청합니다.
    2. 카카오 API 플랫폼 서버는 사용자에게 인증을 요청하고, 성공 시 사용자에게 동의 화면으로 인가를 요청합니다.
    3. 인가 완료 후 카카오 API 플랫폼 서버는 인가 코드를 포함한 Redirect URI로 사용자를 리다이렉트 시킵니다.
    4. 서비스가 Redirect URI에 포함된 인가 코드로 토큰 발급을 요청하면, 카카오 API 플랫폼은 사용자와 서비스 앱을 연결하고 서비스에 사용자의 토큰을 발급합니다.
  • 회원 확인 및 등록
    1. 서비스가 발급받은 사용자의 토큰으로 사용자 정보 가져오기 API를 요청하면, 카카오 API 플랫폼 서버에서 해당 사용자의 정보를 응답합니다.
    2. 서비스는 제공받은 사용자 정보로 사용자의 기존 회원 여부 확인 절차를 수행합니다. 결과에 따라 회원 등록 후에, 또는 바로 서비스 로그인 처리합니다.
  • 서비스 로그인
    1. 서비스 서버에서 사용자 클라이언트에 서비스 로그인 세션을 발급합니다.
    2. 사용자 클라이언트는 발급받은 세션으로 사용자를 로그인 완료 처리하고 로그인된 서비스 화면으로 이동시킵니다.

처음 카카오 로그인을 구현할 때 헷갈렸던 점은 인가 코드 발급을 요청하고 사용자 동의를 얻은 이후 인가 코드를 사용하여 토큰을 발급 받는 것이었다. 총 두 번의 코드, 토큰을 발급 요청한다는 것을 알아야 한다.

그 이후로는 토큰을 통해 사용자 정보를 가져오고 기존 회원 여부를 확인 하여 없다면 유저를 만들고 있다면 토큰을 가지고 로그인된다.

초기 설정

1. 카카오 애플리케이션 세팅

내 애플리케이션에 들어가서 애플리케이션 추가하기를 통해 애플리케이션을 만든다.

정보를 입력하고 저장한다.

동의항목에서 사용할 항목을 설정한다. 내 경우에는 닉네임만 필수로 하고 프로필 사진은 선택으로 하였다. 비활성화된 항목에 대해 권한을 얻기 위해서는 앱 권한 신청에서 비즈 앱 전환이 필요하다.

동의 단계와 동의 목적을 알맞게 작성한다.

Redirectt URI를 등록한다. 원하는 URI를 등록하면 되지만 일반적으로 /oauth2/kakao/callback 과 같은 형식을 주로 사용한다.

이후에 서술하겠지만 간단하게 이 URL에서는 로그인 성공 후 리다이렉트 되어 이 URL의 쿼리 파라미터로 인가 코드가 포함되서 넘어온다.
여기서 넘어온 인가 코드를 파싱해서 https://kauth.kakao.com/oauth/token 이 경로에 토큰을 요청할 때 사용된다.

내 애플리케이션 > 앱 설정 > 앱 키 중에서 REST API 키를 사용할 예정이니 확인한다.

0. 의존성 추가

dependencies {
    // security
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'

    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

기본 Spring Boot의 의존성과 Security 관련 의존성, jwt 토큰 관련 의존성을 추가한다.

0-1. REST API 키, RedirectURI 세팅

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String redirectUri;

이런식으로 컨트롤러 단에서 client id와 redirect uri를 정의한다. 이때 clientId는 위에서 미리 말한 REST API 키를 사용한다.

spring:
  config:
    activate:
      on-profile: common
  application:
    name: gajapos_product
# 생략
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            scope:
              - profile_nickname
            authorization-grant-type: authorization_code
            client-name: Kakao
            client-authentication-method: client_secret_post
# 생략
---

spring:
  config:
    activate:
      on-profile: local
  security:
    oauth2:
      client:
        registration:
          kakao:
            redirect-uri: http://localhost:8080/api/v1/oauth2/kakao/callback
 # 생략
 
 ---
spring:
  config:
    activate:
      on-profile: prod
  security:
    oauth2:
      client:
        registration:
          kakao:
            redirect-uri: http://wms.gajapos.kr/api/v1/oauth2/kakao/callback

나의 경우 application.yml 에 이런식으로 common과 prod, local 프로필을 설정하여 구분을 해놓았다.

0-1. 프론트 로그인 화면 구현

  const handleLogin = async () => {
    try {
      const config = {
        method: "get",
        url: `${SPRING_API_URL}/oauth2/kakao`,
      };

      const response = await axios.request(config);
      const kakaoLoginUrl = response.data;
      window.location.href = kakaoLoginUrl;
    } catch (error) {
      console.error("Error initiating Kakao login:", error);
    }
  };

프론트 단에서 리액트로 이러한 화면을 하나 간단하게 만들어주었다. Login with Kakao 이미지를 클릭하면 /api/v1/oauth2/kakao 로 api를 요청한다.

1. 인가 코드 발급 요청

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/v1/oauth2")
public class AuthController {

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String redirectUri;

    @Value("${app.frontend.url}")
    private String frontendUrl;

    private final AuthService authService;

    @GetMapping("/kakao")
    public ResponseEntity<String> kakaoConnect() {
        String url = "https://kauth.kakao.com/oauth/authorize" +
                "?client_id=" + clientId +
                "&redirect_uri=" + redirectUri +
                "&response_type=code";
        return ResponseEntity.ok(url);
    }

		// 생략 ..
}

위 API로 요청이 들어오면 카카오 로그인 페이지 URL을 생성한다.

이 페이지는 카카오에서 자체적으로 만들어지는 페이지고 여기서 로그인을 할 경우

String url = "https://kauth.kakao.com/oauth/authorize" +
"?client_id=" + clientId +
"&redirect_uri=" + redirectUri +
"&response_type=code";

위 메서드에서 만든 URL로 페이지가 이동된다.

2. 로그인 하기

    @GetMapping("/kakao/callback")
    public void kakaoCallback(@RequestParam String code, HttpServletResponse response) throws IOException {
        try {
            String kakaoAccessToken = authService.getAccessToken(code);
            KakaoInfo kakaoInfo = authService.getKakaoInfo(kakaoAccessToken);
            MemberResponse memberResponse = authService.ifNeedKakaoInfo(kakaoInfo, kakaoAccessToken);

            String redirectUrl = frontendUrl + "/oauth/kakao/callback?accessToken=" + memberResponse.accessToken() +
                    "&refreshToken=" + memberResponse.refreshToken() +
                    "&id=" + memberResponse.id() +
                    "&nickname=" + URLEncoder.encode(memberResponse.nickname(), StandardCharsets.UTF_8);
            response.sendRedirect(redirectUrl);
        } catch (Exception e) {
            log.error("Error in Kakao callback", e);
            response.sendRedirect(frontendUrl + "/login?error=kakao_login_failed");
        }
    }

위 페이지에서 계속하기를 클릭하면 앞서 카카오 애플리케이션에서 설정한 RedirectURI와 clientId(REST API 키), 인가 코드, client secret 코드 (보안에서 Client Secret을 설정한 경우)를 body에 넣어 https://kauth.kakao.com/oauth/token URL에 요청을 보내 access token을 받을 수 있다. 자세한 비즈니스 로직은 서비스 단에 구현하였다.

public String getAccessToken(String code) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", clientId);
        body.add("redirect_uri", redirectUri);
        body.add("code", code);
        body.add("client_secret", clientSecret);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);

        return jsonNode.get("access_token").asText();
    }

이렇게 access token을 받았다면 카카오 사용자 정보를 가져온다.

public record KakaoInfo(
        Long id,
        String nickname
) {
}
    public KakaoInfo getKakaoInfo(String accessToken) throws JsonProcessingException {
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);

        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();

        return new KakaoInfo(id, nickname);
    }

앞서 발급 받은 token을 사용하여 본인 인증을 하고 reponse에서 유저 정보를 반환 받는다. 그러고 ifNeedKakaoInfo 메서드를 통해 카카오 유저 정보가 DB에 존재하는지를 확인한다. 고유한 kakao_id를 가지고 DB에서 member를 조회한 후 없다면 새로 유저를 생성하고 존재한다면 access token만 갱신해준다.

 @Transactional
    public MemberResponse ifNeedKakaoInfo(KakaoInfo kakaoInfo, String kakaoAccessToken) {
        ProductRegistMember member = productRegistMemberRepository.findByKakaoId(kakaoInfo.id())
                .orElse(null);

        if (member == null) {
            String rawPassword = UUID.randomUUID().toString();
            String encryptedPassword = passwordEncoder.encode(rawPassword);

            member = ProductRegistMember.builder()
                    .kakaoId(kakaoInfo.id())
                    .password(encryptedPassword)
                    .nickname(kakaoInfo.nickname())
                    .role(EUserRole.KAKAO_USER)
                    .provider(EProvider.KAKAO)
                    .buyYn(false)
                    .build();
        }

        member.setKakaoAccessToken(kakaoAccessToken);
        log.info("Kakao access token service: {}", kakaoAccessToken);

        // JWT 토큰 생성
        String refreshToken = jwtTokenProvider.createRefreshToken(member.getKakaoId().toString());
        String accessToken = jwtTokenProvider.createAccessToken(member.getKakaoId().toString());
        member.updateRefreshToken(refreshToken);
        productRegistMemberRepository.save(member);

        // JWT 토큰을 클라이언트에 반환
        return new MemberResponse(member.getKakaoId().toString(), member.getNickname(), accessToken, refreshToken);
    }

이때 랜덤한 UUID로 비밀번호를 생성 후 한번 더 인코딩해서 저장하는 로직을 사용하였다.

또한 카카오에서 발급 해준 토큰은 jwt 형태가 아니기 때문에 jwt filter에서 api 요청을 검증해서 사용하기 위해 access token을 jwt 로 변환한다. refresh token은 토큰의 만료시간이 지나 access token이 만료되었을 때 access token을 새로 발급 받는데 사용하도록 한다.

이렇게 사용자 정보 조회, 토큰 처리가 끝나면

public record MemberResponse(
        String id,
        String nickname,
        String accessToken,
        String refreshToken
) {
}

이와 같은 사용자 정보를 반환한다.

컨트롤러에서는 서비스에서 이렇게 반환된 정보를 가지고 생성된 토큰과 사용자 정보를 포함하여 프론트엔드 URL로 리다이렉트 한다.

/oauth/kakao/callback 프론트엔드의 이 URL로 리다이렉트 된다.

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/login" element={<KakaoLoginPage />} />
        <Route path="/oauth/kakao/callback" element={<OAuthCallback />} />
        <Route
          path="/"
          element={
            <ProtectedRoute>
              <ProductManagement />
            </ProtectedRoute>
          }
        />
      </Routes>
    </Router>
  );
}

아래와 같이 토큰을 세션 스토리지에 저장하는 등의 처리가 이루어진다.

const OAuthCallback = () => {
  const navigate = useNavigate();
  const location = useLocation();

  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const accessToken = params.get("accessToken");
    const refreshToken = params.get("refreshToken");
    const id = params.get("id");
    const nickname = params.get("nickname");

    if (accessToken && refreshToken && id && nickname) {
      sessionStorage.setItem("access_token", accessToken);
      sessionStorage.setItem("refresh_token", refreshToken);
      sessionStorage.setItem("user_id", id);
      sessionStorage.setItem("user_nickname", nickname);

      navigate("/");
    } else {
      navigate("/login", {
        state: { error: "Login failed. Please try again." },
      });
    }
  }, [location, navigate]);

  return (
    <Box
      display="flex"
      flexDirection="column"
      alignItems="center"
      justifyContent="center"
      height="100vh"
    >
      <CircularProgress />
      <Typography variant="body1" style={{ marginTop: "1rem" }}>
        처리 중...
      </Typography>
    </Box>
  );
};

export default OAuthCallback;

이 페이지는 바로 넘어가게 구현을 해놓았다. 로딩이 충분히 빠르기 때문에 바로 넘어가진다.

3. 로그아웃

로그아웃 버튼을 클릭하면

const handleLogout = async () => {
    try {
      const token = sessionStorage.getItem("access_token");
      if (!token) {
        console.error("No access token found");
        navigate("/login");
        return;
      }

      await axios.post(
        `${SPRING_API_URL}/oauth2/logout`,
        {},
        {
          headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
          },
        }
      );

      clearSessionStorage();
      navigate("/login");
    } catch (error) {
      if (axios.isAxiosError(error)) {
        if (error.response) {
          console.error("Logout failed with status:", error.response.status);
          if (error.response.status === 401) {
            clearSessionStorage();
            navigate("/login");
          } else {
            console.error(
              "Unexpected error during logout:",
              error.response.data
            );
          }
        } else if (error.request) {
          console.error("No response received:", error.request);
        } else {
          console.error("Error setting up the request:", error.message);
        }
      } else {
        console.error("Non-Axios error during logout:", error);
      }
    }
  };

이 함수가 호출되면서 로그아웃 API를 호출하면서 세션에 저장되어 있던 정보를 모두 제거한다.

    @PostMapping("/logout")
    public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) {
        try {
            String token = authHeader.replace("Bearer ", "");
            authService.logout(token);
            return ResponseEntity.ok().body("Successfully logged out");
        } catch (ExpiredJwtException e) {
            log.error("JWT expired", e);
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("JWT expired");
        } catch (JwtException e) {
            log.error("Invalid JWT", e);
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid JWT");
        } catch (Exception e) {
            log.error("Logout failed", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Logout failed: " + e.getMessage());
        }
    }

마찬가지로 header에 있는 Bearer 토큰을 제거하고 로그아웃 메서드를 호출한다.

 @Transactional
    public void logout(String token) throws JwtException {
        try {
            String userId = jwtTokenProvider.getUserId(token);
            ProductRegistMember member = productRegistMemberRepository.findById(Long.parseLong(userId))
                    .orElseThrow(() -> new RuntimeException("User not found"));

            String kakaoAccessToken = member.getKakaoAccessToken();
            if (kakaoAccessToken != null) {
                logoutFromKakao(kakaoAccessToken);
            }

            member.setKakaoAccessToken(null);
            member.updateRefreshToken(null);
            productRegistMemberRepository.save(member);
        } catch (ExpiredJwtException e) {
            log.error("JWT expired during logout", e);
            throw e;
        } catch (JwtException e) {
            log.error("Invalid JWT during logout", e);
            throw e;
        }
    }
    
  private void logoutFromKakao(String kakaoAccessToken) {
      HttpHeaders headers = new HttpHeaders();
      headers.add("Authorization", "Bearer " + kakaoAccessToken);
      headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

      HttpEntity<String> entity = new HttpEntity<>("", headers);

      try {
          ResponseEntity<String> response = restTemplate.exchange(
                  "https://kapi.kakao.com/v1/user/logout",
                  HttpMethod.POST,
                  entity,
                  String.class
          );

          if (!response.getStatusCode().is2xxSuccessful()) {
              log.error("Kakao logout failed with status: {}", response.getStatusCode());
              throw new RuntimeException("Kakao logout failed: " + response.getStatusCode());
          }
      } catch (HttpClientErrorException e) {
          log.error("Kakao logout failed", e);
          throw new RuntimeException("Kakao logout failed: " + e.getMessage(), e);
      }
  }

서비스의 로그아웃 메서드는 위와 같다.

access token이 올바른지 검증 후 로그아웃이 이루어진다. logoutFromKakao 메소드를 호출하여 카카오의 자체 url에 로그아웃 요청을 보내고 access token, refrsh token을 제거한다.

4. refresh token을 사용한 access token 재생성

    @PostMapping("/token/refresh")
    public ResponseEntity<String> refreshToken(@RequestHeader("Authorization") String token) {
        try {
            String refreshToken = token.replace("Bearer ", "");
            String newAccessToken = authService.refreshAccessToken(refreshToken);
            return ResponseEntity.ok(newAccessToken);
        } catch (JsonProcessingException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Token refresh failed");
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }
    @Transactional
    public String refreshAccessToken(String refreshToken) throws JsonProcessingException {
        ProductRegistMember member = productRegistMemberRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new RuntimeException("Invalid refresh token"));

        if (!jwtTokenProvider.validateToken(refreshToken)) {
            throw new RuntimeException("Refresh token has expired");
        }

        // 새로운 액세스 토큰 생성
        String newAccessToken = jwtTokenProvider.createAccessToken(member.getKakaoId().toString());

        // 새로운 리프레시 토큰 생성 (선택적)
        String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getKakaoId().toString());
        member.updateRefreshToken(newRefreshToken);
        productRegistMemberRepository.save(member);

        // 새로운 액세스 토큰과 리프레시 토큰을 함께 반환
        return String.format("{\"accessToken\":\"%s\",\"refreshToken\":\"%s\"}", newAccessToken, newRefreshToken);
    }

access token이 만료되면 이 엔드포인트를 호출해서 access token을 refresh token을 검증 후 refresh 한다.

이 엔드포인트를 호출하는 로직은 프론트엔드 측에서 api를 호출할 때 인터셉터를 통해 요청을 가로채서 만료 여부를 확인하여 만료되었다면 /token/refresh 엔드포인트로 먼저 요청을 보내고 원본 요청을 보내도록 한다.

const setupAxiosInterceptors = () => {
  axios.interceptors.response.use(
    (response) => response,
    async (error) => {
      const originalRequest = error.config;

      if (error.response.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;
        try {
          const refreshToken = sessionStorage.getItem("refresh_token");
          const response = await axios.post(
            `${SPRING_API_URL}/oauth2/token/refresh`,
            {},
            {
              headers: { Authorization: `Bearer ${refreshToken}` },
            }
          );

          const newAccessToken = response.data;
          sessionStorage.setItem("access_token", newAccessToken);

          originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
          return axios(originalRequest);
        } catch (refreshError) {
          sessionStorage.removeItem("access_token");
          sessionStorage.removeItem("refresh_token");
          sessionStorage.removeItem("user_id");
          sessionStorage.removeItem("user_nickname");

          window.location.href = "/login";
          return Promise.reject(refreshError);
        }
      }

      return Promise.reject(error);
    }
  );
};

5. Security Config를 통한 api 호출 권한 설정

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/api/v1/oauth2/kakao").permitAll()
                        .requestMatchers("/api/v1/oauth2/kakao/callback").permitAll()
                        .requestMatchers("/api/v1/oauth2/token/refresh").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

로그인, RedirectURI, 리프레쉬의 경우만 인증이 필요 없도록 하고 나머지 api의 호출은 인증이 필요하도록 하는 Security Config의 체인을 정의하였다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}

위의 jwt 인증 필터를 통해 토큰을 검증하도록 한다.

6. 기타

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder() {
            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.toString().equals(encodedPassword);
            }
            @Override
            public String encode(CharSequence rawPassword) {
                return rawPassword.toString();
            }
        };
    }

비밀번호의 encoding에 사용된 passwordEncoder이다. Security Config 내부에 정의하였다.

@Component
public class JwtTokenProvider {

    private final Key key;
    private final long validityInMilliseconds;

    public JwtTokenProvider(@Value("${spring.jwt.token.secret-key}") String secretKey,
                            @Value("${spring.jwt.token.expiration-time}") long validityInMilliseconds) {
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
        this.validityInMilliseconds = validityInMilliseconds;
    }

    public String createToken(Long kakaoId) {
        Claims claims = Jwts.claims().setSubject(kakaoId.toString());
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getUsername(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public String getUserId(String jwtToken) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(jwtToken)
                .getBody()
                .getSubject();
    }

    public String createAccessToken(String username) {
        Claims claims = Jwts.claims().setSubject(username);
        claims.put("type", "access"); // 추가 정보
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String createRefreshToken(String username) {
        Claims claims = Jwts.claims().setSubject(username);
        claims.put("type", "refresh"); // 추가 정보
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds * 2); // 리프레시 토큰의 유효 기간을 다르게 설정

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    public Authentication getAuthentication(String token) {
        Long kakaoId = Long.parseLong(getUsername(token));
        return new UsernamePasswordAuthenticationToken(kakaoId, "", new ArrayList<>());
    }
}

토큰 생성, 검증, 유효성 확인, 인증 객체 반환 등 Security 에서 사용되는 메서드들을 정의한 클래스이다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "product_regist_member")
@DynamicUpdate
public class ProductRegistMember {
    @Id
    @Column(name = "kakao_id", nullable = false)
    private Long kakaoId;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "nickname", nullable = false)
    private String nickname;

    @Column(name = "role", nullable = false)
    @Enumerated(EnumType.STRING)
    private EUserRole role;

    @Column(name = "provider", nullable = false)
    @Enumerated(EnumType.STRING)
    private EProvider provider;

    @Column(name = "refresh_token")
    private String refreshToken;

    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @Column(name = "kakao_access_token")
    private String kakaoAccessToken;

    @Column(name = "buy_yn", nullable = false)
    private Boolean buyYn;

    @Builder
    public ProductRegistMember(Long kakaoId, String password, String nickname, EUserRole role, EProvider provider, String kakaoAccessToken, Boolean buyYn) {
        this.kakaoId = kakaoId;
        this.password = password;
        this.nickname = nickname;
        this.role = role;
        this.provider = provider;
        this.createdDate = LocalDateTime.now();
        this.refreshToken = null;
        this.kakaoAccessToken = kakaoAccessToken;
        this.buyYn = false;
    }

    public void updateRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public void setKakaoAccessToken(String kakaoAccessToken) {
        this.kakaoAccessToken = kakaoAccessToken;
    }
}

유저 엔티티를 정의한 클래스이다.

7. 최종 시현

로그인 버튼을 클릭하고 url을 살펴보았을 때 kakaoCallback 함수가 호출되면서

    @GetMapping("/kakao/callback")
    public void kakaoCallback(@RequestParam String code, HttpServletResponse response) throws IOException {
        try {
            String kakaoAccessToken = authService.getAccessToken(code);
            KakaoInfo kakaoInfo = authService.getKakaoInfo(kakaoAccessToken);
            MemberResponse memberResponse = authService.ifNeedKakaoInfo(kakaoInfo, kakaoAccessToken);

            String redirectUrl = frontendUrl + "/oauth/kakao/callback?accessToken=" + memberResponse.accessToken() +
                    "&refreshToken=" + memberResponse.refreshToken() +
                    "&id=" + memberResponse.id() +
                    "&nickname=" + URLEncoder.encode(memberResponse.nickname(), StandardCharsets.UTF_8);
            response.sendRedirect(redirectUrl);
        } catch (Exception e) {
            log.error("Error in Kakao callback", e);
            response.sendRedirect(frontendUrl + "/login?error=kakao_login_failed");
        }
    }

accessToken, refreshToken 등을 담아 정의한 redirectUrl로 요청이 전송되는 것을 볼 수 있다.

로그인 성공

트러블 슈팅

kakao access token의 형식

왜인지 모르겠지만 당연히 kakao에서 넘겨주는 token이 jwt형식이라고 생각하고 코드를 작성하다 에러가 난 부분이 있었다. 확인 결과 kakao에서 인가 코드를 통해 발급 요청해서 받은 토큰은 jwt 형식이 아니였고 이를 JwtAuthenticationFilter에서 처리하기 위해서는 받은 토큰을 jwt 토큰 형태로 변환하는 과정이 필요했다.
결과적으로 access token을 db에 저장할 때는 처음 받았던 토큰 그대로 받고, 검증 등 사용할 때는 jwt로 변환하여 사용하도록 코드를 수정하는 작업이 필요했다.

느낀점

1. 프론트

대략 2년전, 1년전쯤 한번씩 카카오 로그인을 구현한 적이 있었다. 이때는 프론트를 다룰줄도 몰랐기 때문에 블로그의 Spring 코드만 따라해서 인가 코드를 발급 받고 그 인가 코드를 Spring 에서 로그로 찍고 url을 직접 입력해 로그인 페이지로 접속 했던 기억이 있다.
확실히 프론트 화면을 구현하니 어떤 과정으로 로그인이 이루어지는지 더 쉽게 확인할 수 있었다.

2. 혼자 해보자

또한 이전에는 스터디에서 다른 사람의 도움을 받아 구현했던 반면, 이번에는 완전히 혼자서 로그인부터 로그아웃, refresh 로직까지 구현했다는 점에서 Spring OAuth에 대한 이해를 조금 더 잘할 수 있게 되었다고 생각한다.

0개의 댓글