
스프링 시큐리티는 클라이언트의 요청이 여러 개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.
클라이언트 요청 -> 서블릿 필터 -> 서블릿 (컨트롤러)
Delegating Filter Proxy
서블릿 컨테이너(톰캣)에 존재하는 필터 체인에 DelegatingFilter를 등록한 뒤 모든 요청을 가로챈다.

서블릿 필터 체인의 DelegationFilter -> Security 필터 체인 (내부 처리 후) -> 서블릿 필터 체인의 DelegationFilter
가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리다이렉션, 서블릿으로 요청 전달을 진행한다.

Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthentication 필터에서 회원 검증 진행을 시작한다.
(회원 검증의 경우 usernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailService를 통해 받음)
JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화 되어 있는 해당 필터는 동작하지 않는다.
따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야 한다.
LoginFilter
package com.example.securityjwt.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
// 클라이언트 요청에서 username, password 추출
String username = obtainUsername(req);
String password = obtainPassword(req);
System.out.println(username);
// 스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
// token을 검증을 위한 AuthenticationManager로 전달
return authenticationManager.authenticate(authToken);
}
// 로그인 성공시 실행하는 메서드 (여기서 JWT를 발급하면 됨)
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication authentication) {}
// 로그인 실패시 실행하는 메서드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse res, AuthenticationException failed) {}
}
현재 방식은 form-data로 데이터를 전송받음. json으로 받으려면
LoginDTO loginDTO = new LoginDTO();
try {
ObjectMapper objectMapper = new ObjectMapper();
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
loginDTO = objectMapper.readValue(messageBody, LoginDTO.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
이런 방식으로 응용 가능
SecurityConfig
package com.example.securityjwt.config;
import com.example.securityjwt.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // 시큐리티를 위한 config 파일
@RequiredArgsConstructor
public class SecurityConfig {
// AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() { // 비밀번호 암호화
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// csrf disable => session을 stateless 상태로 구현하기 때문에 csrf를 방어하지 않아도 상관없다.
http
.csrf((auth) -> auth.disable());
// Form 로그인 방식 disable
http
.formLogin((auth) -> auth.disable());
// http basic 인증 방식 disable
http
.httpBasic((auth) -> auth.disable());
// 경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()); // 다른 요청은 로그인한 사용자만 접근
// usernamePasswordAuthenticationFilter를 대체해서 커스텀한 필터를 넣기 때문에 at 사용
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
// 세션 설정 (stateless하게 관리)
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
CustomUserDetails (DTO)
package com.example.securityjwt.dto;
import com.example.securityjwt.entity.UserEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class CustomUserDetails implements UserDetails {
private final UserEntity userEntity;
public CustomUserDetails(UserEntity userEntity) {
this.userEntity = userEntity;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
CustomUserDeatilsService (Service)
package com.example.securityjwt.config.service;
import com.example.securityjwt.dto.CustomUserDetails;
import com.example.securityjwt.entity.UserEntity;
import com.example.securityjwt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userData = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return new CustomUserDetails(userData);
}
}
참고자료: 개발자 유미 스프링 시큐리티 JWT