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 |
| OAuth2 | OAuth2LoginAuthenticationFilter | 사용 | 소셜 로그인 |
| Basic Auth | BasicAuthenticationFilter | 미사용 | API 테스트, 내부 통신 |