JWT(JSON Web Token)
사용자의 인증 상태를 관리하는 토큰 시스템
→ 클라이언트와 서버 간의 인증 정보를 유지하고, 이 정보를 토대로 검증
→ 단, JWT는 보안 메커니즘이 없기 때문에, 이는 개발자가 로직을 직접 구현
Spring Security
웹 애플리케이션 보안을 위한 다양한 기능 제공
→ 사용자 인증 및 인가 / CSRF 방어 / 세션 관리 등등
JWT의 간편한 토큰 관리와 Spring Security의 강력한 보안 기능을 함께 사용함으로써 보안과 관련된 복잡한 로직을 직접 구현하는 수고 ↓↓↓
Java 17
/ Spring Boot 3.2.3
/ JWT 0.11.5
/ Spring Security 6.2.2
/ Spring Data JPA
Security는 Filter 기반으로 애플리케이션의 보안을 담당한다.
먼저 Filter란 무엇인지 알아보자!
Web 애플리케이션에서 관리되는 영역으로 Client로 부터 오는 요청과 응답에 대해
최초/최종 단계의 위치이며 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가한다.
→ DispatcherServlet 이전 단계에 위치하므로, Spring MVC 구조와 분리되어 로직 처리
→ 주로 범용적으로 처리해야 하는 작업들, ex) 로깅 및 보안 처리에 활용
- Filter Chain
→ 여러 Filter가 Chain 형식으로 단계를 거쳐서 로직을 수행
→
Spring
의 모든 호출은DispatcherServlet
을 통과하여, 각 요청을 담당하는Controller
로 분배→ 공통적으로 처리해야할 필요가 있을 때,
DispatcherServlet
이전에 단계가 필요하며 이것이 Filter→
Spring Security
는FilterChainProxy
를 통해서 상세로직을 구현
UsernamePasswordAuthenticationFilter
란?
Security Filter Chain
중 username과 password를 확인하여, 인증하는Filter
- 사용자가 username과 password를 제출하면
UsernamePasswordAuthenticationFilter
는 인증된 사용자의 정보가 담기는 인증 객체인Authentication
의 종류 중 하나인UsernamePasswordAuthenticationToken
을 만들어AuthenticationManager
에게 넘겨 인증을 시도- 실패시,
SecurityContextHolder
비우기- 성공시,
SecurityContextHolder
에Authentication
세팅
SecurityContextHolder
란?
인증이 완료된 사용자의 상세 정보(Authentication)를 저장
→ 인증된 사용자 정보 (Principal
)
→Principle
을 관리하는Authentication
→Authentication
을 관리하는SecurityContext
→SecurityContext
를 관리하는SecurityContextHolder
→SecurityContextHolder
는 전략에 따라 저장 방식이 다르며, 기본적으로ThreadLocal
(쓰레드마다 가지고 있는 고유의 저장소)에 저장한다.
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// SecurityContextHolder의 Context에 사용자의 정보 저장
Authentication
란?
- principal : 사용자를 식별
- Username/Password 방식으로 인증할 때 일반적으로 UserDetails 인스턴스
- credentials : 주로 비밀번호, 대부분 사용자 인증에 사용한 후 비우기
- authorities : 사용자에게 부여한 권한을 GrantedAuthority로 추상화하여 사용
검증된 UserDetails는 UsernamePasswordAuthenticationToken 타입의 Authentication를 만들 때 사용되며 해당 인증객체는 SecurityContextHolder에 세팅
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser(){
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getAuthority();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
}
UsernamePasswordAuthenticationToken
는Authentication
을 implements한AbstractAuthenticationToken
의 하위 클래스로, 인증객체를 만드는데 사용
검증이 완료된 사용자의 정보를 UserDetailsImpl(UserDetails 구현체)에 담아서 반환
Custom후, Bean으로 등록
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
Controller
즉, Spring MVC 이전 단계에 존재하며, 로그인 혹은 접근 권한을 검증
- 인증, 인가 성공 시:
Controller
로 Client 요청 전달
- Client 요청 + 사용자 정보 (
UserDetails
)- 인증, 인가 실패 시:
Controller
로 Client 요청 전달되지 않음
- Client 에게
Error Response
보냄
1. 클라이언트 로그인 요청 : username, password 정보를 HTTP body 로 전달 (POST 요청)
→ 로그인 URL은SecurityConfig
에서 설정
2. 인증관리자 (Authentication Manager
)는 요청에 담겨 온 username을UserDetailsService
로 전달하여, 회원의 상세 정보 요청
3.UserDetailsService
에서 전달 받은 값을 통해 회원 조회 (DB)@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("Not Found " + username)); return new UserDetailsImpl(user); }
→ 있는 경우,
UserDetails
를 인증관리자에게 반환 / 없는 경우, 예외 던지기
4. 인증 관리자는 로그인 요청에서 받은 username과 password를UserDetails
의 User 정보와 비교하여 인증 처리
→ 성공 시, 세션에 저장 / 실패 시, Error 발생
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
// HttpSecurity는 인증, 인가에 필요한 설정을 변경하는 역할
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin(Customizer.withDefaults());
return http.build();
}
}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 인증 전, 인가 필요
SecurityConfig
(@Cofiguration - 싱글톤 빈) 생성 시,
JwtUtil
, UserDetails
, UserDetailsService
등을 주입받아 생성
- 필요시, CustomHandler, EntryPoint 등록
- 필요한 Bean 등록 (AuthenticationFilter
, AuthorizationFilter
...)
- SecurityFilterChain
을 통해 필터 설정, 순서 정리
→ HttpSecurity
를 통해 필요 설정 바꾸기
→ 예외 핸들링 설정
SecurityFilterChain
에서 설정한 Filter에 따라 Filter 로직 실행
Filter는 JWT를 활용하여 커스텀
→ JwtAuthenticationFilter
/ JwtAuthorizationFilter
AuthenticationFilter
과 AuthorizationFilter
에서 HttpServletRequest
에 담긴 요청(username, password) 검증을 통해 인증 / 인가 수행
→ 여기서 던지는 예외가 있다면, SecurityConfig
에서 핸들링
→ AuthenticationFilter
를 Bean으로 등록할 때,
AbstractAuthenticationProcessingFilter
를 상속한
UsernamePasswordAuthenticationFilter
를 상속한
JwtAuthenticationFilter
의 AuthenticationManager
를 세팅
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
→ AuthenticationManager
는 생성시, AuthenticationConfiguration
가 필요하며,
이는 SecurityConfig를 생성할 때, SpringSecurity가 @Configuration으로 등록한 AuthenticationConfiguration
를 사용
클라이언트가 특정 URL로 요청을 보내면, 요청은 먼저 스프링 시큐리티의
FilterChainProxy
에 의해 받아진다.FilterChainProxy
는 스프링 시큐리티의 필터 체인을 관리하고, 요청을 인증 및 인가 처리를 위해 필터들을 순차적으로 통과시킨다.
이때,SecurityConfig
클래스는 스프링 시큐리티의 구성을 담당한다.SecurityConfig
클래스는 주로WebSecurityConfigurerAdapter
를 상속받아 구현되며, configure() 메서드를 오버라이드하여 보안 설정을 정의한다. 이 설정은HttpSecurity
객체를 통해 이루어지며, URL 패턴과 접근 권한을 설정할 수 있다.SecurityConfig
클래스에서 설정한 내용은FilterChainProxy
에 의해 필터 체인에 적용되어 요청의 보안 처리에 반영된다.
요청이 필터 체인을 통과하면, 스프링 프레임워크는 해당 URL에 매핑된 컨트롤러 또는 핸들러로 요청을 전달한다. 이때, URL 매핑은 스프링의DispatcherServlet
에 의해 처리된다.DispatcherServlet
은 요청을 적절한 컨트롤러로 라우팅하고, 컨트롤러의 처리 결과를 클라이언트에게 응답한다. 따라서 요청은DispatcherServlet
을 통해 해당 URL로 넘어가고, 컨트롤러가 실제로 요청을 처리하게 된다.한줄 정리
클라이언트의 요청은
FilterChainProxy
를 거쳐SecurityConfig
에서 정의한 보안 처리를 수행한 후,DispatcherServlet
을 통해 해당 URL로 전달되고, 컨트롤러가 요청을 처리