[Spring] OAuth2 + JWT 로그인 : (1) Security와 FilterChain으로 안전 인증 보장하기

5 분 소요

계획

OAuth2.0 카카오 로그인 + JWT + Redis

전체적인 플로우 설명
  1. 사용자가 카카오로 로그인하면, 백엔드 서버는 카카오로부터 사용자 정보를 받아 Access Token과 Refresh Token을 생성합니다.
  2. Access Token은 클라이언트(프론트엔드)에서 요청 시 헤더에 넣어 사용하고, 일반적으로 짧은 유효 기간을 가집니다. (예: 15분~1시간)
  3. Refresh Token은 Access Token이 만료된 경우, 새로운 Access Token을 발급받을 때 사용됩니다. 일반적으로 더 긴 유효 기간을 가집니다. (예: 7일~30일)
  4. 토큰 저장 위치: Access Token은 클라이언트의 localStorage 또는 sessionStorage에 저장되고, Refresh Token은 보안성을 높이기 위해 httpOnly 쿠키나 서버 측 DB에 저장하는 것이 좋습니다.
  5. Access Token 만료 시 처리: Access Token이 만료되면 클라이언트는 서버로 Refresh Token을 보내 새로운 Access Token을 발급받습니다.
  6. Refresh Token 만료 또는 유효하지 않은 경우: Refresh Token이 만료되거나 유효하지 않은 경우, 사용자는 다시 로그인해야 합니다.


구현

1. 카카오 개발자 센터 설정 & application.yaml 수정

  1. 카카오 개발자 센터에서 애플리케이션을 등록합니다.
  2. 필요한 리다이렉트 URI를 설정합니다.
  3. 클라이언트 ID와 시크릿을 얻습니다.

참고: https://kimgyeonglock.github.io/api/api4/


application.yaml 코드
spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${CLIENT_ID}
            client-secret: ${CLIENT_SECRET}
            redirect-uri: "http://localhost:8080/oauth2/authorization/callback/kakao"
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname
              - account_email
              - profile_image
            client-name: Kakao
            client-authentication-method: client_secret_post
        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
  thymeleaf:
    cache: false
    prefix: classpath:/templates/
    suffix: .html
    mode: HTML
  datasource:
    url: jdbc:h2:tcp://localhost/~/shoppingmall
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

jwt:
  secret: ${JWT_SECRET}
  access-token-validity: 1800000  # Access Token 만료시간 30분
  refresh-token-validity: 604800000  # Refresh Token 만료시간 7일
  • clinet-idclient-secret, jwt-secret은 깃허브에 오픈되니 환경변수 처리
  • redirect-uri 설정에 있어 은근 에러가 많이 남 ⇒ 그래서 redirect-uri 에 대해 알아보자
    • OAuth 2.0 인증 과정에서는 사용자가 카카오 로그인 페이지로 이동하여 인증을 마친 후, 카카오는 인증 결과(Authorization Code 또는 Access Token)를 애플리케이션으로 전달합니다. 이때, redirect-uri는 사용자가 인증을 마친 후 돌아올 URL을 지정하는 역할을 합니다.

2cfa3d9d-897d-44dd-b25a-edd437eabb80

  • redirect-uri와 end-point에 대해 헷갈렸음.

2. Spring Security 설정

  • SecurityConfig 클래스를 생성하여 보안 설정을 구성
  • OAuth2 로그인을 활성화하고 카카오 로그인을 추가
config/SecurityConfig.java 코드
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final RedisService redisService;
    private final CustomSuccessHandler customSuccessHandler;

    /**
     * 애플리케이션의 보안 정책을 정의하고 필터 체인을 구성
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

//        http
//                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
//                    @Override
//                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
//
//                        CorsConfiguration configuration = new CorsConfiguration();
//
//                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
//                        configuration.setAllowedMethods(Collections.singletonList("*"));
//                        configuration.setAllowCredentials(true);
//                        configuration.setAllowedHeaders(Collections.singletonList("*"));
//                        configuration.setMaxAge(3600L);
//
//                        configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));
//                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));
//
//                        return configuration;
//                    }
//                }));

        http
                .csrf(AbstractHttpConfigurer::disable) //csrf disable
                .formLogin(AbstractHttpConfigurer::disable) //form 로그인 방식 disable
                .httpBasic(AbstractHttpConfigurer::disable) //http basic 인증 방식 disable
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/", "/index", "/login", "/oauth2/**").permitAll()  // 인증 없이 접근 가능한 경로
                        .anyRequest().authenticated() // 그 외의 요청은 인증이 필요함
                )
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
                                .userService(customOAuth2UserService)
                        )
                        .loginPage("/login")  // 로그인 페이지 경로 설정
                        .loginProcessingUrl("/oauth2/authorization/callback/kakao")
                        .successHandler(customSuccessHandler)  // Custom Success Handler 추가
                        .failureUrl("/loginFailure")
                )
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // Stateless로 세션 설정 (react) IF_REQUIRED (thymeleaf)
                .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), OAuth2LoginAuthenticationFilter.class)
                .addFilterBefore(new CustomLogoutFilter(jwtTokenProvider, redisService), LogoutFilter.class);
        return http.build();
    }
}


<코드 뜯어보기>

  • @EnableWebSecurity: Spring Security 설정을 활성화
    • Spring Security가 제공하는 보안 기능을 커스터마이징
  • filterChain: 애플리케이션의 보안 정책을 정의하고 필터 체인을 구성
    • 필터 체인이 뭔데?
    • 왜 Bean을 넣어야 하는가? (Configuration이 자동 Bean 처리 안해주나?)
  • csrf(AbstractHttpConfigurer::disable): csrf 보호 기능을 비활성화
    • Cross-Site Request Forgery(CSRF)
    • REST API 기반 애플리케이션에서는 CSRF 보호를 비활성화
      클라이언트가 주로 RESTful API를 통해 데이터를 전송하고, CSRF 공격이 주로 브라우저 기반 애플리케이션에서 발생하기 때문에 필요하지 않을 때 비활성화

경로별 인가 작업

  • requestMatchers("~").permitAll(): ~ 경로의 모든 요청에 대해 인증 없이 접근을 허용
  • .anyRequest().authenticated(): 그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 설정

  • .oauth2Login(): OAuth 2.0 기반 로그인 기능을 활성화
  • .userInfoEndpoint(): OAuth 2.0 로그인 후 사용자 정보를 처리할 엔드포인트를 설정
    • OAuth 2.0 로그인 성공 후 사용자 정보를 처리하는 CustomOAuth2UserService를 사용
  • .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), OAuth2LoginAuthenticationFilter.class) : 기존의 Spring Security 필터 체인에 커스텀 필터를 추가
    • JWT 인증을 처리하는 JwtAuthenticationFilter 필터를 추가
    • HTTP 요청의 헤더에서 JWT 토큰을 추출하고, 이를 검증하여 사용자를 인증
    • 요청이 OAuth2LoginAuthenticationFilter로 전달 후에 JWT 검증이 이루어짐

<알아보기>

필터 체인(Security Filter Chain)이란?

Spring Security에서 HTTP 요청(HttpServletRequest)이 애플리케이션에 도달하기 전에 필터를 통해 보안 검사를 수행하는 과정

여러 필터가 차례로 연결된 구조를 가지며, 각 필터는 특정 보안 기능(인증, 인가, CSRF 방어 등)을 담당한다.

Spring Security의 대표적인 필터:

  • UsernamePasswordAuthenticationFilter: 폼 기반 로그인 처리 필터.
  • JwtAuthenticationFilter: JWT를 이용해 인증 정보를 추출하고 검증하는 커스텀 필터.
  • CsrfFilter: CSRF 공격 방어를 담당하는 필터.
  • OAuth2LoginAuthenticationFilter: OAuth 2.0 로그인 시 인증 과정을 처리하는 필터.

filterChain 메서드가 Bean으로 등록되어야 하는가?

Spring Security는 보안 설정을 자동으로 처리하는 WebSecurityConfigurerAdapter를 더 이상 사용하지 않고, SecurityFilterChain을 Bean으로 등록하여 보안 정책을 설정하도록 권장하고 있다.

@Configuration 클래스에서 @Bean을 사용하여 SecurityFilterChain을 등록하면 Spring Security가 이를 자동으로 인식하고 애플리케이션의 보안 정책으로 적용한다.

  • @Configuration이 클래스에 붙어 있더라도, 메서드를 Bean으로 등록하기 위해서는 반드시 @Bean 애너테이션이 필요

Cross-Site Request Forgery(CSRF)란?

CSRF는 악의적인 사용자가 사용자의 세션을 가로채어 요청을 위조하는 공격 기법

예를 들어, 사용자가 A 사이트에 로그인한 상태에서 악의적인 B 사이트로 접속한 경우, B 사이트는 사용자의 A 사이트 세션을 가로채어 요청을 보낼 수 있다. 이로 인해 사용자가 의도하지 않은 요청이 실행될 수 있다.


REST API 기반 애플리케이션에서 CSRF 보호 비활성화 이유

  • RESTful API는 주로 상태가 없는(stateless) 방식으로 작동하므로, 브라우저의 세션을 유지하지 않으며, 이로 인해 CSRF 공격 가능성이 낮다.
  • RESTful API는 대부분 API 요청 시 Authorization 헤더에 JWT 토큰을 포함하여 인증하므로, CSRF 방어 메커니즘이 필요하지 않을 수 있다.
  • JWT 토큰 방식 → stateless 방식으로 세션 관리 → CSRF 설정 꺼도 됨

특정 경로에 대해 인증 없이 접근 허용

인증 없이 접근을 허용하는 곳: .permitAll() (거의 대부분인 것 같다.)

인증된 사용자만 접근할 수 있도록 제한: .authenticated() (myPage)

.authorizeHttpRequests(authz -> authz
    .requestMatchers("/api/myPage").authenticated()
    .anyRequest().permitAll()
)

Troubleshooting

image

.loginPage("/login")  // 로그인 페이지 경로 설정
.loginProcessingUrl("/oauth2/authorization/callback/kakao")
.successHandler(customSuccessHandler)  // Custom Success Handler 추가
.failureUrl("/loginFailure")
  • 로그인은 되는데 Database(H2)에 유저 정보가 저장되지 않는 문제 발생
  • 로그 출력을 통해 customOAuth2UserServiceloadUser() 가 실행이 되지 않음을 확인

문제해결

  • 인가 코드를 발급 받은 후 로그인 프로세스 실행을 시도 ⇒ redirect-uri
    • redirect-uri 에서 jwt 토큰 발급 & 디비 저장
  • loginProcessUrl의 default값은 /login/oauth2/code/*

image 1

  • 하지만 설정한 application.yaml에서 설정한 redirect-uri는 “oauth2/authorization/callback/kakao
    • 해당 url로는 로그인 프로세스를 실행하지 않았던 것이다
  • loginProcessingUrl의 값을 변경해주어 문제 해결!

  • 번외
    • .loginPage("/login"): 설정된 값이 없다면 기본 설정으로 loginPage를 생성. 직접 페이지를 만든다면 설정해줘야 함 (thymeleaf를 쓴다는 것에 한함, failureUrl 동일)

참고: https://hoons-dev.tistory.com/140


.sessionManagement((session) -> session
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // Stateless로 세션 설정 (react) IF_REQUIRED (thymeleaf)
  • [authorization_request_not_found] 오류 발생
    • OAuth2 인증 과정에서 인증 요청이 예상한 대로 저장되거나 검색되지 않음
  • OAuth2 인증 과정은 여러 단계로 이루어진다.
    1. 사용자가 소셜 로그인 요청
    2. 인증 서버(예: 카카오)로 리다이렉트
    3. 인증 완료 후 우리 서버로 콜백
  • STATELESS 모드에서는 이 과정 중의 인증 상태를 유지할 수 없어 [authorization_request_not_found] 에러가 발생

  • 세션은 다음과 같은 목적으로 일시적으로 필요
    1. CSRF 방지를 위한 state 값 검증
    2. 원래 인증 요청의 세부사항 유지
    3. 인증 과정의 무결성 보장

참고: https://claude.ai/chat/d996c61d-b0be-4599-bd17-00226fa4ab0d


.addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), OAuth2LoginAuthenticationFilter.class)
  • 기존의 .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); 코드에서 수정
  • 재로그인 무한 루프 오류의 발생 가능성의 위험성이 있기 때문에 수정
    • JWT가 만료되어 재 로그인시 경로에 접근하면 무한 루프 오류가 발생하는 문제

참고: 스프링 OAuth2 클라이언트 JWT 17 : 재로그인 무한 루프 오류

카테고리:

업데이트: