WIL/웹 개발

Spring Security 중심으로 보는 OAuth2 소셜 로그인

아크리미츠 2025. 10. 31. 20:07

1) Spring Security는 이렇게 생겼다

출처:  https://docs.spring.io/spring-security/site/docs/5.4.0/reference/html5/

 

요청은 서블릿 앞단의 FilterChainProxy를 거치고, URL 매칭 규칙에 따라 하나의 SecurityFilterChain이 선택되어 내부 보안 필터들이 등록 순서대로 실행된다. 각 필터는 대부분 OncePerRequestFilter를 확장해 한 요청당 한 번 동작한다. 필터 순서가 곧 보안 정책의 우선순위다.

  • 핵심 필터들의 역할
    • CorsFilter: 사전 요청 및 CORS 헤더 처리
    • CsrfFilter: 상태 저장 환경에서 CSRF 토큰 검증
    • UsernamePasswordAuthenticationFilter: 폼 로그인 자격 증명 추출 및 인증 시도
    • OAuth2LoginAuthenticationFilter: OAuth2 인가 코드 콜백 처리 및 토큰 교환 트리거
    • BearerTokenAuthenticationFilter / 커스텀 JwtAuthenticationFilter: 보호 자원 접근 시 Access Token 검증
    • AnonymousAuthenticationFilter: 인증 부재 시 익명 사용자 토큰 주입
    • ExceptionTranslationFilter: 인증/인가 예외를 포착하여 401/403으로 표준화
    • FilterSecurityInterceptor: URL 접근결정(인가) 수행
  • 구성 축
    • 인증(authentication)
    • 인가(authorization)
    • 예외 처리(exception handling)
    • 세션/컨텍스트 관리(context management)

2) 로그인 동작 방식: authenticate → SecurityContext 저장 → 재사용

출처:  https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html

2.1 입력 수집과 인증 시도(UsernamePasswordAuthenticationFilter)

  • 사용자가 /login(또는 커스텀 경로)로 아이디/비밀번호를 제출하면 UsernamePasswordAuthenticationFilterUsernamePasswordAuthenticationToken(unauthenticated)을 생성합니다.
  • 필터는 AuthenticationManager.authenticate(...)를 호출해 인증을 위임합니다.

2.2 ProviderManager와 DaoAuthenticationProvider

  • ProviderManager가 지원 가능한 AuthenticationProvider를 순서대로 호출합니다.
  • 일반적으로 DaoAuthenticationProviderUserDetailsService.loadUserByUsername로 사용자 정보를 로드하고, PasswordEncoder.matches(raw, encoded)로 비밀번호를 검증합니다.
  • 성공 시 UsernamePasswordAuthenticationToken(authenticated)을 반환하며, 이 토큰에는 Principal, Authorities, details 등이 채워집니다.

2.3 성공 처리와 SecurityContext 저장

  • AbstractAuthenticationProcessingFilter(부모)가 성공 시점에 SecurityContextHolder.getContext().setAuthentication(auth)로 컨텍스트를 채웁니다.
  • 컨텍스트의 영속화는 SecurityContextRepository가 담당합니다.
    • 상태 저장(기본): HttpSessionSecurityContextRepository가 세션에 컨텍스트를 저장합니다.
    • 상태 비저장(Stateless): NullSecurityContextRepository를 사용해 서버 세션에 저장하지 않습니다.
http
  .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  .securityContext(sc -> sc.securityContextRepository(new NullSecurityContextRepository()))
  .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
 
  • 상태 저장: 다음 요청 시작 시 SecurityContextRepository가 세션에서 컨텍스트를 불러와 SecurityContextHolder(기본 ThreadLocal 전략)에 주입합니다.
  • 상태 비저장: 매 요청마다 JWT(또는 다른 토큰)를 검증하는 필터가 Authentication을 새로 구성해 SecurityContextHolder에 넣습니다.

2.5 컨텍스트의 활용 지점(인가/표현식/메서드 보안)

  • URL 인가: FilterSecurityInterceptor에서 SecurityContextHolder.getContext().getAuthentication()을 이용해 접근 결정을 수행합니다.
  • 메서드 보안: @PreAuthorize("hasRole('ADMIN')") 등에서 현재 Authentication의 권한을 사용합니다.
  • 컨트롤러/서비스에서의 사용:
 
@GetMapping("/me")
public UserDto me(@AuthenticationPrincipal CustomUser user) { return toDto(user); }

// 또는
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Object principal = auth.getPrincipal();

2.6 실패 처리와 정리

  • 인증 실패 시 예외가 발생하고 ExceptionTranslationFilter가 이를 401로 표준화하거나 로그인 페이지로 리다이렉트합니다.
  • 로그아웃 시 LogoutFilterSecurityContext를 지우고(상태 저장의 경우 세션 무효화), 상태 비저장 설계에서는 AT 블랙리스트/RT 무효화를 통해 토큰 재사용을 차단합니다.

3) 인가 파이프라인: 누가 무엇에 접근할 수 있는가

출처:  https://codingnomads.com/spring-security-authorization
  • FilterSecurityInterceptor가 요청 URL과 매칭된 권한 요구사항(ConfigAttribute)을 확인
  • AccessDecisionManager(기본 AffirmativeBased)가 Voter들의 투표 결과로 접근 허용/거부 결정
  • 표준 Voter: RoleVoter, AuthenticatedVoter
  • 필요 시 도메인 규칙을 반영한 커스텀 Voter 추가
  • 거부 시 AccessDeniedException 발생, ExceptionTranslationFilter가 403으로 표준화

4) 예외 처리와 응답 규격

웹앱 기본값은 미인증 시 로그인 페이지로 리다이렉트하는 흐름이다. API 환경에서는 리다이렉트 대신 일관된 JSON 본문을 주는 것이 낫다. 아래처럼 /api/** 경로만 401/403 JSON으로 분기하면 된다.

4.1 JSON 핸들러

// 공통 에러 응답 DTO
public record ApiError(String code, String message) {}

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
  private final ObjectMapper om = new ObjectMapper();
  @Override
  public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException {
    res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    res.setContentType("application/json;charset=UTF-8");
    om.writeValue(res.getWriter(), new ApiError("UNAUTHORIZED", "인증이 필요합니다."));
  }
}

@Component
public class RestAccessDeniedHandler AccessDeniedHandler {
  private final ObjectMapper om = new ObjectMapper();
  @Override
  public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex) throws IOException {
    res.setStatus(HttpServletResponse.SC_FORBIDDEN);
    res.setContentType("application/json;charset=UTF-8");
    om.writeValue(res.getWriter(), new ApiError("FORBIDDEN", "권한이 없습니다."));
  }
}

4.2 SecurityFilterChain 설정

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
    RestAuthenticationEntryPoint restEntryPoint,
    RestAccessDeniedHandler restDeniedHandler) throws Exception {

  http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/**").authenticated()
        .anyRequest().permitAll()
    )
    .exceptionHandling(ex -> ex
        // /api/**는 JSON (401/403)
        .defaultAuthenticationEntryPointFor(restEntryPoint, new AntPathRequestMatcher("/api/**"))
        .defaultAccessDeniedHandlerFor(restDeniedHandler, new AntPathRequestMatcher("/api/**"))
        // 그 외는 기존 로그인 페이지 리다이렉트
        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
    );

  // JWT 필터가 있다면 UsernamePasswordAuthenticationFilter 앞에 삽입
  // http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

  return http.build();
}

5) OAuth2는 이렇게 돈다(Authorization Code)

출처:  https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
  • 인가 요청: 클라이언트가 /oauth2/authorization/{registrationId}로 진입하면 AuthorizationEndpointstate를 포함한 인가 요청을 생성해 공급자(카카오/구글 등)로 리다이렉트
  • 사용자 인증/동의: 공급자 화면에서 로그인/스코프 동의
  • 콜백: 공급자가 앱의 Redirect URI로 Authorization Code를 전달. 이때 state를 검증해 요청 위조 차단
  • 토큰 교환: OAuth2LoginAuthenticationFilter가 코드로 AT, RT 교환
  • 사용자 정보 로드: UserInfoEndpoint가 공급자 API로 사용자 프로필 조회, CustomOAuth2UserService가 도메인 사용자와 매핑
  • 성공/실패 처리: OAuth2AuthenticationSuccessHandler에서 애플리케이션 고유 토큰 발급·쿠키 세팅·리다이렉트. 실패 시 FailureHandler가 오류 경로로 전송

6) 폼 로그인 vs 소셜 로그인: 무엇이 다르고 무엇이 같은가

출처:  https://velog.io/@sorayoo/Spring-Security-Form-Login-OAuth2-Login-1

6.1 시작 지점과 트리거

  • 폼 로그인: 애플리케이션이 /login(또는 커스텀)에서 UsernamePasswordAuthenticationFilter를 통해 AuthenticationManager.authenticate(...)를 직접 트리거합니다.
  • 소셜 로그인: 클라이언트가 /oauth2/authorization/{registrationId}로 진입하면 Spring Security의 oauth2Login() 플로우가 시작되고, 콜백 시 OAuth2LoginAuthenticationFilter가 토큰 교환을 수행합니다.

6.2 자격 증명과 사용자 정보 취득

  • 폼 로그인: 자격 증명은 아이디/비밀번호이며, DaoAuthenticationProviderUserDetailsService로 사용자 조회 + PasswordEncoder로 검증합니다.
  • 소셜 로그인: 자격 증명은 공급자 발급 코드/토큰이며, DefaultAuthorizationCodeTokenResponseClient(또는 커스텀)가 토큰 교환 후 UserInfoEndpoint + CustomOAuth2UserService로 프로필을 로드합니다.

6.3 SecurityContext 채우기와 저장소

  • 공통: 인증 성공 시점에 SecurityContextHolder.getContext().setAuthentication(auth)로 컨텍스트를 채웁니다.
  • 상태 저장: HttpSessionSecurityContextRepository가 세션에 저장(둘 다 동일).
  • 상태 비저장: 둘 다 세션을 쓰지 않고, 매 요청마다 JWT 필터가 Authentication을 재구성합니다.

6.4 후처리와 토큰 정책

  • 공통: 성공/실패 핸들러를 통해 리다이렉트, 쿠키/헤더 세팅, 감사 로깅이 수행됩니다.
  • 폼 로그인: 성공 시 애플리케이션이 자체 JWT(AT/RT)를 발급해 응답합니다.
  • 소셜 로그인: 성공 핸들러(OAuth2AuthenticationSuccessHandler)에서 동일하게 애플리케이션 JWT를 발급하고, RT를 HttpOnly Secure 쿠키로 설정합니다.

6.5 동일하게 유지되는 것들(핵심 인프라 공유)

  • 인가 파이프라인, 권한 모델, SecurityContext 활용 방식은 동일합니다.
  • 관측(요청 ID, 로그인/재발급 로그), 블랙리스트/회원 상태 점검, 오픈 리다이렉트 방지 정책 또한 동일하게 적용합니다.

7) 결론

Spring Security는 필터 체인을 중심으로 인증·인가·예외 처리를 모듈화하고, OAuth2는 인가 코드 표준 시퀀스를 통해 소셜 로그인을 일관되게 추상화한다. 우리는 여기에 JWT/쿠키 정책/재발급 예외 경로/블랙리스트만 얇게 얹어 운영 가능성을 확보한다.