콘텐츠로 이동

Spring Security & JWT

Spring Security + JWT

왜 쓰는가?

로그인, 권한 제어는 거의 모든 서비스의 필수 요소다. Spring Security는 ==인증(Authentication)==과 ==인가(Authorization)==를 표준화된 방식으로 제공한다.

핵심 개념

개념 설명
인증 (Authentication) 누구인가? (로그인)
인가 (Authorization) 무엇을 할 수 있는가? (권한)
SecurityFilterChain HTTP 요청 처리 필터 체인
UserDetails Spring Security의 사용자 정보 인터페이스
UserDetailsService DB에서 사용자 정보를 로드하는 인터페이스

Security 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // REST API는 CSRF 불필요
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // JWT는 세션 미사용
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()    // 인증 없이 접근 가능
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

UserDetails 구현

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final Member member;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()));
    }

    @Override
    public String getPassword() { return member.getPassword(); }

    @Override
    public String getUsername() { return member.getEmail(); }

    @Override
    public boolean isAccountNonExpired() { return true; }
    // ... 나머지 기본 true
}

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) {
        Member member = memberRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));
        return new CustomUserDetails(member);
    }
}

JWT 흐름

로그인 요청 (email, password)
  → AuthController
  → UserDetailsService.loadUserByUsername()
  → PasswordEncoder.matches()
  → 성공: JwtProvider.generateToken()
  → 응답: accessToken, refreshToken

이후 요청
  → JwtAuthFilter (요청 헤더에서 토큰 추출)
  → JwtProvider.validateToken()
  → SecurityContextHolder에 인증 정보 저장
  → 컨트롤러 진입

JWT 구현

@Component
public class JwtProvider {

    @Value("${app.jwt.secret}")
    private String secret;

    @Value("${app.jwt.expiration}")
    private long expiration;

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSignKey())
            .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(getSignKey()).build().parseSignedClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public String extractUsername(String token) {
        return Jwts.parser().verifyWith(getSignKey()).build()
            .parseSignedClaims(token).getPayload().getSubject();
    }

    private SecretKey getSignKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }
}

Refresh Token 전략

// Access Token: 짧은 만료 (15분~1시간)
// Refresh Token: 긴 만료 (7~30일), DB 또는 Redis에 저장

POST /api/auth/refresh
  → Refresh Token 검증
  → DB에 저장된 토큰과 비교
  → 새 Access Token 발급

단점 / 주의할 점

상황 문제 해결
JWT 탈취 만료 전까지 사용 가능 짧은 만료 + Refresh Token 로테이션
로그아웃 처리 JWT는 서버에서 무효화 불가 Redis 블랙리스트 또는 짧은 만료
Secret Key 노출 모든 토큰 위조 가능 환경 변수 관리, 충분한 길이의 키
ROLE_ 접두사 Spring Security는 ROLE_ 접두사 필요 hasRole("ADMIN") → DB에 ADMIN 저장

내부 동작 원리

Spring Security는 필터 체인이다

Spring Security는 서블릿 필터(Filter) 기반으로 동작한다. HTTP 요청이 스프링 컨트롤러에 도달하기 전에, 필터 체인을 통과하면서 인증/인가를 처리한다.

클라이언트 HTTP 요청
[Servlet Filter Chain — Tomcat 레벨]
  DelegatingFilterProxy   ← 스프링이 서블릿 필터에 연결하는 브릿지
  FilterChainProxy         ← Spring Security의 핵심, 아래 필터들을 순서대로 실행
  ┌─────────────────────────────────────────────────┐
  │ Spring Security Filter Chain (주요 필터만)       │
  │ 1. SecurityContextPersistenceFilter              │  ← SecurityContext 로딩/저장
  │ 2. UsernamePasswordAuthenticationFilter          │  ← 폼 로그인 처리
  │ 3. JwtAuthenticationFilter (커스텀)              │  ← JWT 토큰 검증 (우리가 추가)
  │ 4. ExceptionTranslationFilter                   │  ← 인증/인가 예외 처리
  │ 5. AuthorizationFilter                          │  ← 권한 검사 (가장 마지막)
  └─────────────────────────────────────────────────┘
  DispatcherServlet → 컨트롤러

SecurityContextHolder — 인증 정보 저장소

SecurityContextHolder는 현재 인증된 사용자 정보를 저장하는 ThreadLocal 기반 저장소다. 요청을 처리하는 스레드에 "지금 누가 로그인해 있는가"를 기억해 두는 것이다.

JWT 필터에서 토큰 검증 성공 :
  UsernamePasswordAuthenticationToken auth
      = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
  SecurityContextHolder.getContext().setAuthentication(auth)
  // ThreadLocal에 저장

컨트롤러에서 사용:
  @GetMapping("/me")
  public MemberResponse me(@AuthenticationPrincipal CustomUserDetails user) {
      // SecurityContextHolder에서 자동으로 꺼내줌
      return MemberResponse.from(user.getMember());
  }

요청 처리 완료 :
  SecurityContextPersistenceFilter가 SecurityContextHolder.clearContext() 호출
   다음 요청에서 이전 사용자 정보가 남지 않도록 정리

JWT 인증 필터 — 전체 흐름

모든 HTTP 요청
  → JwtAuthenticationFilter.doFilterInternal()
       ① Authorization 헤더에서 "Bearer {token}" 추출
       ② JwtProvider.validateToken(token) → 서명, 만료 검증
       ③ JwtProvider.extractUsername(token) → 이메일 추출
       ④ UserDetailsService.loadUserByUsername(email) → DB 조회
       ⑤ SecurityContextHolder에 인증 정보 저장
  → 다음 필터로 넘어감

AuthorizationFilter (마지막 필터):
  → SecurityContextHolder에 인증 정보 있는지 확인
  → "/api/admin/**" 요청 → ROLE_ADMIN 권한 있는지 확인
  → 없으면 403 Forbidden

AuthenticationManager 위임 체인

폼 로그인 방식일 때 UsernamePasswordAuthenticationFilter가 인증을 처리하는 구조.

UsernamePasswordAuthenticationFilter
  → AuthenticationManager.authenticate(token)
       → ProviderManager (AuthenticationManager 구현체)
           → 등록된 AuthenticationProvider 목록 순회
           → DaoAuthenticationProvider 선택
               → UserDetailsService.loadUserByUsername(username)
               → PasswordEncoder.matches(rawPassword, encodedPassword)
               → 성공 → Authentication 객체 반환
               → 실패 → BadCredentialsException

JWT vs 세션 인증의 차이: - 세션: 서버 메모리에 세션 저장 → SecurityContextPersistenceFilter가 세션에서 인증 정보 복원 - JWT: 서버에 상태 없음(Stateless) → 매 요청마다 JWT를 검증해 인증 정보를 새로 만듦 → SessionCreationPolicy.STATELESS로 설정하면 세션을 전혀 사용하지 않음