개발하는 자몽

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

Java & Kotlin/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;
    }
}
  • DefaultOAuth2UserServiceOAuth2UserService의 구현체로 표준 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를 적용했다면 securesameSite를 위 코드처럼 설정하고, HTTPsecurefalse로, sameSiteLAX로 설정해야 한다.
  • CustomOAuth2UserService에서 반환했던 OAuth2Userauthentication.getPrincipal()로 꺼낼 수 있다.
  • 위 클래스까지 통과하면 카카오 로그인 과정이 성공적으로 처리된 것이다.

 

이후 과정

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

 

 

 

Comments