[Spring] OAuth2 + JWT 로그인 : (1) Security와 FilterChain으로 안전 인증 보장하기
계획
OAuth2.0 카카오 로그인 + JWT + Redis
전체적인 플로우 설명
- 사용자가 카카오로 로그인하면, 백엔드 서버는 카카오로부터 사용자 정보를 받아 Access Token과 Refresh Token을 생성합니다.
- Access Token은 클라이언트(프론트엔드)에서 요청 시 헤더에 넣어 사용하고, 일반적으로 짧은 유효 기간을 가집니다. (예: 15분~1시간)
- Refresh Token은 Access Token이 만료된 경우, 새로운 Access Token을 발급받을 때 사용됩니다. 일반적으로 더 긴 유효 기간을 가집니다. (예: 7일~30일)
- 토큰 저장 위치: Access Token은 클라이언트의
localStorage
또는sessionStorage
에 저장되고, Refresh Token은 보안성을 높이기 위해httpOnly
쿠키나 서버 측 DB에 저장하는 것이 좋습니다. - Access Token 만료 시 처리: Access Token이 만료되면 클라이언트는 서버로 Refresh Token을 보내 새로운 Access Token을 발급받습니다.
- Refresh Token 만료 또는 유효하지 않은 경우: Refresh Token이 만료되거나 유효하지 않은 경우, 사용자는 다시 로그인해야 합니다.
구현
1. 카카오 개발자 센터 설정 & application.yaml 수정
- 카카오 개발자 센터에서 애플리케이션을 등록합니다.
- 필요한 리다이렉트 URI를 설정합니다.
- 클라이언트 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-id
와client-secret
,jwt-secret
은 깃허브에 오픈되니 환경변수 처리- redirect-uri 설정에 있어 은근 에러가 많이 남 ⇒ 그래서 redirect-uri 에 대해 알아보자
- OAuth 2.0 인증 과정에서는 사용자가 카카오 로그인 페이지로 이동하여 인증을 마친 후, 카카오는 인증 결과(Authorization Code 또는 Access Token)를 애플리케이션으로 전달합니다. 이때,
redirect-uri
는 사용자가 인증을 마친 후 돌아올 URL을 지정하는 역할을 합니다.
- OAuth 2.0 인증 과정에서는 사용자가 카카오 로그인 페이지로 이동하여 인증을 마친 후, 카카오는 인증 결과(Authorization Code 또는 Access Token)를 애플리케이션으로 전달합니다. 이때,
- 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
를 사용
- OAuth 2.0 로그인 성공 후 사용자 정보를 처리하는
.addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), OAuth2LoginAuthenticationFilter.class)
: 기존의 Spring Security 필터 체인에 커스텀 필터를 추가- JWT 인증을 처리하는
JwtAuthenticationFilter
필터를 추가 - HTTP 요청의 헤더에서 JWT 토큰을 추출하고, 이를 검증하여 사용자를 인증
- 요청이
OAuth2LoginAuthenticationFilter
로 전달 후에 JWT 검증이 이루어짐
- 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
.loginPage("/login") // 로그인 페이지 경로 설정
.loginProcessingUrl("/oauth2/authorization/callback/kakao")
.successHandler(customSuccessHandler) // Custom Success Handler 추가
.failureUrl("/loginFailure")
- 로그인은 되는데 Database(H2)에 유저 정보가 저장되지 않는 문제 발생
- 로그 출력을 통해
customOAuth2UserService
의loadUser()
가 실행이 되지 않음을 확인
문제해결
- 인가 코드를 발급 받은 후 로그인 프로세스 실행을 시도 ⇒ redirect-uri
- redirect-uri 에서 jwt 토큰 발급 & 디비 저장
- loginProcessUrl의 default값은
/login/oauth2/code/*
- 하지만 설정한 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 인증 과정은 여러 단계로 이루어진다.
- 사용자가 소셜 로그인 요청
- 인증 서버(예: 카카오)로 리다이렉트
- 인증 완료 후 우리 서버로 콜백
-
STATELESS
모드에서는 이 과정 중의 인증 상태를 유지할 수 없어 [authorization_request_not_found] 에러가 발생 - 세션은 다음과 같은 목적으로 일시적으로 필요
- CSRF 방지를 위한 state 값 검증
- 원래 인증 요청의 세부사항 유지
- 인증 과정의 무결성 보장
참고: https://claude.ai/chat/d996c61d-b0be-4599-bd17-00226fa4ab0d
.addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), OAuth2LoginAuthenticationFilter.class)
- 기존의
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
코드에서 수정 - 재로그인 무한 루프 오류의 발생 가능성의 위험성이 있기 때문에 수정
- JWT가 만료되어 재 로그인시 경로에 접근하면 무한 루프 오류가 발생하는 문제