전체 목록
SpringMedium#78

Spring Security의 인증(Authentication) 처리 흐름을 설명해주세요.

#Spring#Security#인증#JWT
힌트

SecurityFilterChain, AuthenticationManager, UserDetailsService 흐름을 생각해보세요.

정답 및 해설

Spring Security의 인증(Authentication) 처리 흐름을 설명해주세요.

Spring Security는 Spring 기반 애플리케이션에서 인증(Authentication)과 인가(Authorization)를 담당하는 보안 프레임워크입니다. 폼 로그인, JWT 토큰, OAuth2 등 다양한 인증 방식을 지원하며, 필터 체인 기반의 확장 가능한 구조를 갖추고 있습니다. 인증 처리는 여러 컴포넌트가 역할을 분담하며 체계적으로 이루어집니다.

Spring Security 인증 전체 흐름

HTTP 요청
    │
    ▼
SecurityFilterChain
    │
    ├── SecurityContextPersistenceFilter  ← SecurityContext 복원
    ├── UsernamePasswordAuthenticationFilter  ← username/password 추출
    │       │
    │       ▼
    │   AuthenticationManager (ProviderManager)
    │       │
    │       ▼
    │   AuthenticationProvider (DaoAuthenticationProvider)
    │       │
    │       ├── UserDetailsService.loadUserByUsername()
    │       │       │
    │       │       ▼
    │       │   UserDetails 반환
    │       │
    │       └── PasswordEncoder.matches()
    │               │
    │           ┌───┴───┐
    │           │       │
    │         성공     실패
    │           │       │
    │           ▼       ▼
    │    Authentication   AuthenticationException
    │    객체 생성         발생
    │           │
    ▼           ▼
SecurityContextHolder에 저장
    │
    ▼
다음 필터 또는 컨트롤러로 전달

핵심 컴포넌트

1. SecurityFilterChain

Spring Security는 서블릿 필터 체인으로 구성됩니다. 각 요청은 등록된 순서대로 필터를 통과합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login", "/signup").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login")
            );

        return http.build();
    }
}

2. UsernamePasswordAuthenticationFilter

폼 로그인 요청(POST /login)을 처리하며, 요청에서 username과 password를 추출합니다.

// 내부 동작 (개념적 코드)
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response)
            throws AuthenticationException {

        // 1. 요청에서 username, password 추출
        String username = obtainUsername(request); // request.getParameter("username")
        String password = obtainPassword(request); // request.getParameter("password")

        // 2. 미인증 Authentication 토큰 생성
        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(username, password);

        // 3. AuthenticationManager에 인증 위임
        return this.getAuthenticationManager().authenticate(authToken);
    }
}

3. AuthenticationManager & ProviderManager

// ProviderManager: AuthenticationManager의 기본 구현체
// 여러 AuthenticationProvider를 순서대로 시도
public class ProviderManager implements AuthenticationManager {

    private List<AuthenticationProvider> providers;

    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {

        for (AuthenticationProvider provider : providers) {
            if (provider.supports(authentication.getClass())) {
                // 지원하는 Provider에 인증 위임
                return provider.authenticate(authentication);
            }
        }
        throw new ProviderNotFoundException("No AuthenticationProvider found");
    }
}

4. DaoAuthenticationProvider (핵심)

// DaoAuthenticationProvider: 기본 AuthenticationProvider 구현체
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;

    @Override
    protected UserDetails retrieveUser(String username,
                                       UsernamePasswordAuthenticationToken authentication) {
        // 5. UserDetailsService를 통해 사용자 정보 조회
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null");
        }
        return loadedUser;
    }

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) {
        // 6. 비밀번호 검증
        if (!passwordEncoder.matches(
                (String) authentication.getCredentials(),
                userDetails.getPassword())) {
            throw new BadCredentialsException("Bad credentials");
        }
    }
}

5. UserDetailsService 구현

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {

        // DB에서 사용자 조회
        User user = userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(
                        "사용자를 찾을 수 없습니다: " + username));

        // UserDetails 반환 (Spring Security가 인증에 사용)
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(user.getPassword()) // BCrypt 해시된 비밀번호
                .roles(user.getRole().name())
                .accountExpired(!user.isActive())
                .credentialsExpired(false)
                .disabled(!user.isEnabled())
                .build();
    }
}

6. SecurityContextHolder

인증에 성공하면 Authentication 객체가 SecurityContextHolder에 저장됩니다.

// 인증 성공 후 저장
SecurityContextHolder.getContext().setAuthentication(authentication);

// 이후 어디서든 현재 인증 정보 접근 가능
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

// Spring MVC에서 편리하게 접근
@GetMapping("/profile")
public ProfileDto getProfile(@AuthenticationPrincipal UserDetails userDetails) {
    return profileService.findByUsername(userDetails.getUsername());
}

JWT 기반 인증 구현

폼 로그인 대신 JWT 토큰 기반 인증을 구현하는 방법입니다.

JWT 인증 필터

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 헤더에서 JWT 토큰 추출
        String token = extractToken(request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 2. 토큰에서 username 추출
            String username = jwtTokenProvider.getUsername(token);

            // 3. UserDetails 조회
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            // 4. Authentication 객체 생성
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

            // 5. SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JWT Security 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // REST API는 CSRF 비활성화
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            // UsernamePasswordAuthenticationFilter 앞에 JWT 필터 삽입
            .addFilterBefore(jwtAuthenticationFilter,
                    UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

로그인 API 구현

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
        // 1. AuthenticationManager로 인증 수행
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getEmail(),
                        request.getPassword()
                )
        );

        // 2. SecurityContext에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. JWT 토큰 생성 및 반환
        String accessToken = jwtTokenProvider.generateAccessToken(authentication);
        String refreshToken = jwtTokenProvider.generateRefreshToken(authentication);

        return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
    }
}

인증 예외 처리

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException {
        // 미인증 접근 시 401 응답
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"error\": \"인증이 필요합니다\"}");
    }
}

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException {
        // 권한 없는 접근 시 403 응답
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"error\": \"접근 권한이 없습니다\"}");
    }
}

// Security 설정에 등록
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(customAuthenticationEntryPoint)
    .accessDeniedHandler(customAccessDeniedHandler)
);

요약 표

컴포넌트역할주요 책임
SecurityFilterChain요청 보안 처리 파이프라인URL 기반 인증/인가 설정
UsernamePasswordAuthenticationFilter인증 요청 처리username/password 추출, 인증 시작
AuthenticationManager (ProviderManager)인증 위임 관리적합한 Provider 선택 및 위임
AuthenticationProvider실제 인증 로직사용자 조회 및 자격증명 검증
UserDetailsService사용자 정보 조회DB에서 사용자 정보 로드
PasswordEncoder비밀번호 처리BCrypt 해시 비교
SecurityContextHolder인증 정보 저장소ThreadLocal로 현재 사용자 정보 보관
AuthenticationEntryPoint미인증 예외 처리401 응답 반환
AccessDeniedHandler권한 없음 예외 처리403 응답 반환
인증 방식처리 필터세션적합한 상황
폼 로그인UsernamePasswordAuthenticationFilter사용전통적 웹 애플리케이션
JWT커스텀 OncePerRequestFilter미사용 (STATELESS)REST API, SPA
OAuth2OAuth2LoginAuthenticationFilter사용소셜 로그인
Basic AuthBasicAuthenticationFilter미사용API 테스트, 내부 통신