Dev./Spring

[Spring] Spring Security + JWT로 로그인 기능 구현

limitation01 2025. 12. 9. 09:07

전체적인 흐름

 

  1. 클라이언트가 /auth/login 으로 이메일/비밀번호 전송
  2. 서버에서 사용자 검증 후 JWT 발급
  3. 이후 요청들은 Authorization: Bearer token헤더에 JWT를 담아서 호출
  4. 커스텀 JwtAuthFilter가 토큰을 검증하고 userId를 꺼냄
  5. @AuthUser 파라미터로 컨트롤러에 userId를 바로 주입
    → 컨트롤러/서비스는 JWT 구조를 모른 채 로그인된 사용자 ID만 다룸

설계

JwtUtil - JWT 생성 및 검증

역할

  • 토큰 생성
  • 토큰 검증
  • Claims / subject 추출

설계 기준

  • subject에는 userId를 넣음 (주 식별자)
  • claim으로 email 정도만 추가
  • validate()는 예외 캐치로 단순 처리
@Component
public class JwtUtil {

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

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

    private SecretKey key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String generateToken(Long userId, String email) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("email", email)
                .issuedAt(new Date(now))
                .expiration(new Date(now + expiration))
                .signWith(key)
                .compact();
    }

    public Claims getClaims(String token) {
        return Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public boolean validate(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

  • subject를 userId로 통일해서 로그인한 사용자 = Long userId로만 단순하게 생각
  • 나머지 email, role 같은 것들은 필요해지면 claim으로 점진적으로 추가

PrincipalDetails - UserDetails 구현체

역할

  • Spring Security가 인식하는 인증된 사용자 객체
  • Authentication.getPrincipal() 로 접근 가능
  • 컨트롤러에서 @AuthenticationPrincipal 로 직접 꺼내 사용 가능

설계 기준

  • User 엔티티를 그대로 들고 있게 함
    • 필요한 모든 유저 정보는 전부 User 엔티티에서 꺼낼 수 있음
    • 서비스나 컨트롤러가 userId만 필요한 경우에도 편리함
  • 권한(authorities)은 최소 ROLE_USER만 설정
@Getter
public class PrincipalDetails implements UserDetails {

    private final User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_USER"));
    }

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

    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return true; }
}

PrincipalDetailsService - UserDetailsService 구현체

역할

  • JWT에서 userId를 꺼낸 뒤 → DB에서 유저를 다시 조회하는 단계
  • User not found, disabled 등 상태 체크 가능

설계 기준

  • username 파라미터를 userId로 사용
    • JWT subject = userId이므로 userIdString을 username처럼 사용
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userIdString) throws UsernameNotFoundException {
        Long userId = Long.valueOf(userIdString);

        User user = userRepository.findById(userId)
                .orElseThrow(() -> new UsernameNotFoundException("Not Found User"));

        return new PrincipalDetails(user);
    }
}

JwtAuthenticationFilter - 핵심 필터

역할

  • 토큰 파싱 → 검증 (Authorization → Bearer 토큰만 허용)
  • 토큰 유효시
    • JWT subject(userId) 추출
    • DB에서 사용자 정보(UserDetails) 조회
    • Authentication 생성
  • SecurityContextHolder에 Authentication 등록

설계 기준

  • shouldNotFilter()로 인증 제외 경로 관리
    • 로그인/회원가입은 인증 필요 없음
  • Authentication을 직접 만들어 넣음
    • Spring Security는 SecurityContextHolder에 인증 객체가 존재하는가를 기준으로
      요청이 인증되었다고 판단
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final PrincipalDetailsService principalDetailsService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/auth/login") || uri.startsWith("/auth/signup");
    }

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

        String token = resolveToken(request);

        if (token != null && jwtUtil.validate(token)) {

            Claims claims = jwtUtil.getClaims(token);
            String userId = claims.getSubject();

            UserDetails userDetails = principalDetailsService.loadUserByUsername(userId);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");

        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

 

SecurityConfig  - 필터 연결

역할

  • Spring Security의 필터 체인에 JwtAuthenticationFilter 추가
  • 세션 사용 안 함 (stateless)
  • 인증이 필요한 URL만 보호
  • 나머지는 permitAll
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/auth/login", "/auth/signup").permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

컨트롤러에서 사용자 정보 받기

@GetMapping("/me")
public UserResponse me(@AuthenticationPrincipal PrincipalDetails principal) {
    return new UserResponse(principal.getUser());
}