Spring boot 3 기준 Spring Security를 다시 사용해보는 과정에서 새롭게 알게된 사실을 정리해보겠다.
1. Authentication Filter(=Jwt Filter) 적용 시 passwordencoder 자동 적용
기존 프로젝트에서는 Filter에서 사용자 입력값인 password를 일일이 encoder를 적용해 db값과 비교해주는 로직을 추가했었다.
기존 코드
/* 로그인 */
@Transactional
public TokenDto login(LoginDto loginDto){
Member member = memberRepository.findByEmail(loginDto.getEmail())
.orElseThrow(() -> new EmailNotFoundException("존재하지 않는 이메일입니다."));
// 비밀번호 일치 여부
if(!member.getPassword().equals(PasswordEncryptor.encrypt(loginDto.getPassword()))) {
throw new PasswordBadRequestException("이메일 또는 비밀번호가 일치하지 않습니다.");
}
...
}
하지만 Authentication Manager 설정만 되어있다면 이렇게 수동으로 코드를 짤 필요가 없어진다.
Security Config
private AuthenticationManager authenticationManager(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(bCryptPasswordEncoder);
return auth.build();
}
private AuthenticationFilter getAuthenticationFilter() throws Exception {
AuthenticationManagerBuilder builder = new AuthenticationManagerBuilder(objectPostProcessor);
builder.userDetailsService(userService)
.passwordEncoder(bCryptPasswordEncoder);
AuthenticationFilter authenticationFilter = new AuthenticationFilter(userService);
authenticationFilter.setAuthenticationManager(authenticationManager(builder));
return authenticationFilter;
}
Security Config에서 커스텀한 Authentication Filter(UsernamePaswwordAuthenticationFilter를 extends) 설정을 해줄 때, Authentication Manager가 필요한 데 이 때 userDetailsService와 passwordEncoder를 설정해줄 수 있다.
이렇게 설정해두면 따로 패스워드 비교하는 로직 필요 없이 Authentication Manager가 비교해준 뒤 로그인 처리를 한다.
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.csrf(...)
.authorizeHttpRequests(...)
.addFilter(getAuthenticationFilter())
.headers(...)
return http.build();
}
2. 커스텀 Authentication Filter를 적용하는 방법
1) OncePerRequestFilter를 구현하기
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
...
}
}
한번만 동작하는 필터라는 뜻으로, Filter를 구현할 때 사용된다.
@Component로 등록해서 스프링컨테이너를 통해 관리/주입할 수 있다.
UsernamePasswordAuthenticationFilter 이전에 동작해서 UsernamePasswordToken만 넘겨주면, 추후 UsernamePasswordAuthenticationFilter에서는 별다른 동작 없이 넘어가게 된다.
Filter 위치를 따로 지정해주어야 하며, success handler 및 failure handler 등을 따로 만들어 Security config에 지정해주어야한다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.successHandler(...)
}
2) UsernamePasswordAuthenticationFilter를 상속하기
@Slf4j
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final UserService userService;
public AuthenticationFilter(AuthenticationManager authenticationManager,
UserService userService) {
super.setAuthenticationManager(authenticationManager);
this.userService = userService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
...
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
}
}
- 문제점은 AuthenticationManager를 설정해주어야한다. 따라서 설정이 좀 더 복잡해질 수 있다.
따라서 @Component로 등록하면 AuthenticationManager 주입때문에 설정이 불가능해지고, Security Config에서 AuthenticationManager를 생성한 뒤에, CustomFilter를 새로 인스턴스를 만들어 설정하는 방법을 사용할 수 있다.
장점은 attemptFilter, success메소드 등을 한 필터 안에서 사용할수 있다는 점 정도가 될 것 같다.
SecurityConfig
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.addFilter(getAuthenticationFilter())
...
return http.build();
}
private AuthenticationManager getAuthenticationManager() throws Exception {
AuthenticationManagerBuilder auth = new AuthenticationManagerBuilder(objectPostProcessor);
auth.userDetailsService(userService)
.passwordEncoder(bCryptPasswordEncoder);
return auth.build();
}
private AuthenticationFilter getAuthenticationFilter() throws Exception {
return new AuthenticationFilter(
getAuthenticationManager(),
userService
);
}
3. 헤더에 대한 검증은 API gateway에서 한다.
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
@Value("${token.secret}")
private String secret;
public AuthorizationHeaderFilter() {
super(Config.class);
}
public static class Config {
}
@Override
public GatewayFilter apply(Config config) {
return (((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "No authorization Header.", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
if(!isJwtValid(jwt)) {
return onError(exchange, "JWT token is not valid.", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange);
}));
}
private boolean isJwtValid(String jwt) {
boolean returnValue =true;
String subject = null;
try {
subject = Jwts.parser()
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.parseClaimsJws(jwt)
.getBody()
.getSubject();
} catch (Exception e) {
returnValue = false;
}
if (StringUtils.isEmpty(subject)) {
returnValue = false;
}
return returnValue;
}
// Spring gateway는 Mono, Flux -> Spring WebFlux로 ServletRequest 등이 아닌 SeverRequest로 작동 (비동기 방식)
private Mono<Void> onError(ServerWebExchange exchange, String error, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(error);
return response.setComplete();
}
}
헤더에 jwt 토큰이 있는지 검사하는 것은 api gateway선에서 처리한다.
jwt 토큰값의 유효성 등은 user-service에서 진행한다. (실제 로직을 처리하는 부분)
클라이언트의 헤더에 JWT 토큰 인증이 필요한 부분은 회원가입/로그인 외의 요청.
회원가입/로그인은 사용자 정보(아이디, 비밀번호)가 필요하다.
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /\${segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /\${segment}
- AuthorizationHeaderFilter #헤더에 JWT 정보가 들어있는지 검사
- id: catalog-service
uri: lb://CATALOG-SERVICE
predicates:
- Path=/catalog-service/**
filters:
- id: order-service
uri: lb://ORDER-SERVICE
predicates:
- Path=/order-service/**
filters:
token:
secret: usertokenforjwtsforlongnumberwherecanitbegothrough