Java/Spring

[Spring Security] OAuth2 카카오 로그인 구현 with JWT

jaamong 2024. 12. 29. 15:43

⚠️이 포스트는 지식 공유보다는 본인이 나중에 다시 보기 위해 작성하는 기록용입니다...

 

 

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    ...
}

 

application.yml

spring:
  security.oauth2:
    client.registration:
      kakao:
        client-name: Kakao
        client-id: ${CLIENT_ID}
        client-secret: ${CLIENT_SECRET}
        client-authentication-method: client_secret_post
        redirect-uri: ${REDIRECT_URI}
        authorization-grant-type: authorization_code
        scope:
          - account_email
    client.provider:
      kakao:
        authorization-uri: https://kauth.kakao.com/oauth/authorize
        token-uri: https://kauth.kakao.com/oauth/token
        user-info-uri: https://kapi.kakao.com/v2/user/me
        user-name-attribute: id

Spring Security에서 제공하는 OAuth2 기능을 사용한다면 yml 또는 properties 파일에 OAuth2 클라이언트, 서버 정보를 작성해야 한다.

  • `spring.security.oauth2.client.registration.{registrationId}`
  • `spring.security.oauth2.client.provider.{registrationId}`

Getting Started ❘ Spring Boot and OAuth2

 

 

SecurityConfig

@Configuration
@EnableMethodSecurity
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final String[] PERMIT_ALL_URL = {
            "/favicon.ico/**",
            "/h2-console/**",
            "/api/health/**",
            "/api/users/login",
            "/api/users/access-token",
            "/oauth2/authorization/kakao",
    };

    private final CorsConfig corsConfig;

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    private final OAuth2UserService<OAuth2UserRequest, OAuth2User> customOAuth2UserService;
    private final AuthenticationSuccessHandler customOAuth2SuccessHandler;
    private final AuthenticationFailureHandler customAuthExceptionHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource()))
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(req -> req
                        .requestMatchers(PERMIT_ALL_URL).permitAll()
                        .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(e -> e
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler)
                )
                .oauth2Login(config -> config
                        .userInfoEndpoint(endpointConfig -> endpointConfig
                                .userService(customOAuth2UserService))
                        .successHandler(customOAuth2SuccessHandler)
                        .failureHandler(customAuthExceptionHandler)
                );

        return http.build();
    }
}

별도로 설정하지 않는 한, 클라이언트의 카카오 로그인 요청은  `/oauth2/authorization/kakao` 경로로 들어온다.

The `OAuth2AuthorizationRequestRedirectFilter` uses an `OAuth2AuthorizationRequestResolver` to resolve an `OAuth2AuthorizationRequest` and initiate the Authorization Code grant flow by redirecting the end-user’s user-agent to the Authorization Server’s Authorization Endpoint.

The primary role of the `OAuth2AuthorizationRequestResolver` is to resolve an `OAuth2AuthorizationRequest` from the provided web request. The default implementation `DefaultOAuth2AuthorizationRequestResolver` matches on the (default) path `/oauth2/authorization/{registrationId}`, extracting the registrationId, and using it to build the `OAuth2AuthorizationRequest` for the associated ClientRegistration.

Initiating the Authorization Request, Authorization Grant Support, Spring Docs

 

 

CustomOAuth2FailureHandler

@Slf4j
@Component
public class CustomOAuth2FailureHandler implements AuthenticationFailureHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        log.info("[CustomOAuth2SuccessHandler.onAuthenticationFailure] oauth2 authentication fail");
        configureResponse(response);
        objectMapper.writeValue(response.getWriter(), makeResponseBody(exception));
    }

    private ResponseDto<String> makeResponseBody(AuthenticationException exception) {
        String message = "카카오 로그인에 실패했습니다. (exception = " + exception + ") ";
        return new ResponseDto<>(HttpStatus.UNAUTHORIZED, message);
    }

    private void configureResponse(HttpServletResponse response) {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
    }
}
  • 로그인 요청이 들어왔을 때 인증에 실패하면 `AuthenticationFailureHandler` 필터가 호출된다. 
  • 해당 필터는 `SecurityFilterChain`에 등록된 `OAuth2Login` 용도의 `userService`보다 앞에 위치한다.
  • 여기에서 발생한 에러는 `AuthenticationEntryPoint`에서 처리되며, 이는 `@RestControllerAdvice`가 적용된 클래스에서 처리할 수 있다.

 

CustomOAuth2UserService & OAuth2CustomUser

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

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        try {
            log.info("[CustomOAuth2UserService.loadUser] receive oauth2 login request");

            // obtain user info from the provider using access token
            Map<String, Object> originAttributes = super.loadUser(userRequest).getAttributes();

            // OAuth2 서비스 id (kakao, ...)
            String registrationId = userRequest.getClientRegistration().getRegistrationId();

            // Provider 에서 제공하는 attribute 추출
            OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);
            String email = attributes.email();

            // 회원 가입 또는 로그인
            registerIfNewUser(email);

            List<SimpleGrantedAuthority> authorities = UserRole.CUSTOMER.getAuthorities();
            return new OAuth2CustomUser(authorities, originAttributes, "id", email);
        } catch (RuntimeException e) {
            log.error("[CustomOAuth2UserService.loadUser] exception = ", e);
            throw new OAuth2AuthenticationException(e.getMessage());
        }
    }

    private void registerIfNewUser(String email) {
        Optional<UserEntity> optionalUser = userRepository.findByUsername(email);

        // 이미 등록된 사용자면 패스
        if (optionalUser.isPresent()) return;

        // 등록된 사용자가 아니면 저장
        UserEntity user = UserEntity.builder()
                .username(email)
                .userRole(UserRole.CUSTOMER)
                .build();

        userRepository.save(user);
    }
}
public record OAuth2CustomUser(
        List<SimpleGrantedAuthority> authorities,
        Map<String, Object> attributes,
        String registrationId,
        String email) implements OAuth2User, Serializable {

    @Override
    public Map<String, Object> getAttributes() {
        return this.attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getName() {
        return this.registrationId;
    }
}
  • `DefaultOAuth2UserService`는 `OAuth2UserService`의 구현체로 표준 OAuth2.0 공급자를 지원한다.
    • 인증을 진행하기 위한 인증 서버(카카오)와 사용자를 저장하기 위한 데이터베이스를 호출하기 위해 `OAuth2UserService`를 구현한다.
  • 구현체에서는 사용자가 정의한 `User` 객체를 상속하고 `OAuth2User`를 구현한 것을 반환해야 한다.
How to Add a Local User Database

Many applications need to hold data about their users locally, even if authentication is delegated to an external provider. We don’t show the code here, but it is easy to do in two steps.

1. Choose a backend for your database, and set up some repositories (using Spring Data, say) for a custom `User` object that suits your needs and can be populated, fully or partially, from external authentication.

2. Implement and expose `OAuth2UserService` to call the Authorization Server as well as your database. Your implementation can delegate to the default implementation, which will do the heavy lifting of calling the Authorization Server. Your implementation should return something that extends your custom `User` object and implements `OAuth2User`.

Hint: add a field in the `User` object to link to a unique identifier in the external provider (not the user’s name, but something that’s unique to the account in the external provider).

Getting Started | Spring Boot and OAuth2

 

 

CustomOAuth2SuccessHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {

    @Value("${oauth2.login.redirect-url}")
    private String redirectURL;

    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        log.info("[CustomOAuth2SuccessHandler.onAuthenticationSuccess] oauth2 authentication success");

        OAuth2CustomUser oAuth2User = (OAuth2CustomUser) authentication.getPrincipal();
        String email = oAuth2User.email();

        // issue a refresh token
        UserEntity user = userService.getByUsername(email);
        ResponseCookie responseCookie = userService.authenticate(user);

        // configure HttpServletResponse
        log.info("[CustomOAuth2SuccessHandler.onAuthenticationSuccess] Redirect URL = {}", redirectURL);
        configureResponse(response, responseCookie);
        response.sendRedirect(redirectURL);
    }

    private void configureResponse(HttpServletResponse response, ResponseCookie cookie) {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setStatus(HttpStatus.OK.value());
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }
}
  • `CustomOAuth2UserService`가 성공하면 `SuccessHandler`로 넘어오게 된다. 
    • 로그인에 성공했으므로 리프레시 토큰을 발급한다.
    • 발급한 리프레시 토큰은 쿠키에 담아서 전달한다.
      • 쿠키 설정 시 CORS를 고려하여 주의해야 할 점이 있다.
        ResponseCookie.from("refresh_token", refreshToken)
            .httpOnly(true)
            .secure(true) 
            .maxAge(600) // 10분
            .sameSite(Cookie.SameSite.NONE.attributeValue())
            .path("/")
            .build();
      • 백엔드 서버에 HTTPS를 적용했다면 `secure`와 `sameSite`를 위 코드처럼 설정하고, HTTP면 `secure`를 `false`로, `sameSite`를 `LAX`로 설정해야 한다.
  • `CustomOAuth2UserService`에서 반환했던 `OAuth2User`를 `authentication.getPrincipal()`로 꺼낼 수 있다.
  • 위 클래스까지 통과하면 카카오 로그인 과정이 성공적으로 처리된 것이다.

 

이후 과정

클라이언트는 쿠키에 담긴 리프레시 토큰을 가지고 서버에게 액세스 토큰 발급을 요청한다.