전체적인 흐름
- 클라이언트가 /auth/login 으로 이메일/비밀번호 전송
- 서버에서 사용자 검증 후 JWT 발급
- 이후 요청들은 Authorization: Bearer token헤더에 JWT를 담아서 호출
- 커스텀 JwtAuthFilter가 토큰을 검증하고 userId를 꺼냄
- @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());
}