Java/트러블 슈팅

Spring WebFlux + Spring Security 적용

oneny 2025. 5. 19. 18:26

Spring WebFlux + Spring Security

 

GitHub - f-lab-edu/idolu

Contribute to f-lab-edu/idolu development by creating an account on GitHub.

github.com

위 사이드 프로젝트는 이벤트 루프 기반 Netty 위에서 동작하는 Spring WebFlux를 사용하여 구현하고 있다. 따라서 Spring Security MVC에서는 기본적으로 서블릿(기본 구현체: 톰캣) 기반으로 한 요청당 하나의 스레드가 할당되어 처리되지만, Spring Security WebFlux는 MVC 스펙과 달리 한 요청에 여러 스레드가 할당되어 처리될 수 있기 때문에 리액티브 방식을 지원하는 스펙을 통해 구현해야 한다.

 

Spring Security 살펴보기

위 그림은 Spring Security의 인증 흐름을 시각적으로 표현한 것이다. 클라이언트 요청이 들어오면 다음과 같은 과정을 거친다.

  1. 요청을 받은 서버의 인증 필터가 요청을 가로채서 인증 관리자에게 위임한다.
  2. 인증 관리자는 인증 논리를 구현하는 인증 공급자를 이용하여 인증을 처리한다.
  3. 인증 공급자는 사용자 관리 책임을 구현하는 사용자 세부 정보 서비스를 인증 논리에 이용한다.
  4. 인증 공급자는 암호 관리를 구현하는 암호 인코더를 인증 논리에 이용한다.
  5. 인증된 사용자 정보가 보안 컨텍스트에 저장되며 인증 데이터를 유지할 수 있다.

 

SecurityConfig 설정

@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthUserDetailsService customUserDetailsService;
    private final JwtAuthenticationManager jwtAuthenticationManager;
    private final JwtServerAuthenticationConverter jwtServerAuthenticationConverter;
    private final JwtAuthenticationFailureHandler jwtAuthenticationFailureHandler;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
        AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(jwtAuthenticationManager);
        authenticationWebFilter.setServerAuthenticationConverter(jwtServerAuthenticationConverter);
        authenticationWebFilter.setAuthenticationFailureHandler(jwtAuthenticationFailureHandler);

        httpSecurity
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // session STATELESS
                .authorizeExchange(authz -> authz
                        .pathMatchers("/api/v1/auth/**").permitAll()
                        .anyExchange().authenticated())
                .addFilterBefore(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);

        return httpSecurity.build();
    }

    @Bean
    public ReactiveAuthenticationManager authenticationManager() {
        UserDetailsRepositoryReactiveAuthenticationManager manager
                = new UserDetailsRepositoryReactiveAuthenticationManager(customUserDetailsService);
        manager.setPasswordEncoder(passwordEncoder());
        return manager;
    }

    @Bean
    public UrlBasedCorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedHeaders(Collections.singletonList("*"));
        config.setAllowedMethods(Collections.singletonList("*"));
        config.setAllowedOriginPatterns(Collections.singletonList("*")); // 허용할 origin
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

React.js와 같은 SPA 프레임워크에서 로그인 요청이 들어온다고 가정하여 HTTP Basic, Form Login, CSRF 비활성화화였다.요청에 대한 인증은 공식 문서(https://docs.spring.io/spring-security/reference/reactive/authorization/authorize-http-requests.html)에 따르면 Spring Security는 WebFlux에서는 authorizeExachange를 사용하여 인증 및 요청 경로별로 permitAll(), authenticated(), hasRole() 등의 권한 조건을 지정하여 인가 규칙을 정의할 수 있다. 이는 Spring Security MVC의 authorizeRequests()와 같은 역할을 한다.

그 외 자세한 설정은 아래에서 살펴보자.

 

비밀번호 설정

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

BCryptPasswordEncoder를 사용하여 비밀번호 암호화를 간단히 구현했다. Bcrypt는 단방향 암호화 해시함수 중 하나로 해시값으로 저장된 비밀번호를 역으로 원래의 비밀번호로 복호화하는 것이 불가능하다. 또한, Cost Factor, Salt를 사용하여 공격자가 다이제스트를 쉽게 유추할 수 없도록 추가적인 보안을 강화한다. 여기서 궁금한 점은 Cost Factor와 Salt는 어디에 저장되어서 로그인 시 사용되는 것일까?

 

$[버전]$[Cost Factor]$[22자리 솔트 + 31자리 해시]
$2a$10$bO8HAUJjkYZetNEqVpm2.erwtEfUq8AyzDpjWEim/BXUDYXPn2cci 

위 다이제스트는 사용자가 회원가입 시 Bcrypt를 사용하여 저장된 암호화된 비밀번호이다. $를 구분자로 3가지의 필드가 구성되고, 이 안에 Cost Factor와 Salt가 저장되어있다.

  • 알고리즘 버전: 사용된 bcrypt 알고리즘의 버전을 의미하며 여기서는 2a가 사용되었다.
  • Cost Factor: key stretching 연산 횟수를 의미하며 여기서 10은 2^10번 반복하여 연산을 수행하는 것을 의미한다.
  • 22자리 솔트 + 31자리 해시: 솔트와 암호화된 비밀번호값이 저장되어 있다.
    • 첫 문자가 16바이트의 salt값으로 디코딩된다.

 

 

Cors 설정

@Bean
public UrlBasedCorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedHeaders(Collections.singletonList("*"));
    config.setAllowedMethods(Collections.singletonList("*"));
    config.setAllowedOriginPatterns(Collections.singletonList("*")); // 허용할 origin
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}
  • Spring Sercurity의 CORS 공식문서(https://docs.spring.io/spring-security/reference/reactive/integrations/cors.html)를 확인하면 CORS를 가장 먼저 처리되도록 보장하는 방법은 CorsWebFilter를 설정하는 것이고, CorsConfigurationSource를 사용하여 정의할 수 있다.
  • 위 코드처럼 헤더, HTTP 메서드, 자격 증명(쿠키 등) 허용하고, Origin을 설정한 후 CorsConfigurationSource를 빈으로 등록하면 CorsWebFitler를 통해 CORS 요청을 사전 필터링(현재는 전체 허용)할 수 있다.

 

로그인 구현

ReactiveUserDetailsService 구현

@Service
@RequiredArgsConstructor
public class AuthUserDetailsService implements ReactiveUserDetailsService {

    private final UserAdapter userAdapter;
    private final RoleAdapter roleAdapter;

    @Override
    public Mono<UserDetails> findByUsername(String username) {
        return userAdapter.findUserByEmail(username)
                .zipWhen(user -> roleAdapter.findRoleById(user.getRoleId()))
                .map(TupleUtils.function((userEntity, role) -> new AuthenticatedUser(
                        userEntity.getUserId(),
                        userEntity.getUsername(),
                        userEntity.getPassword(),
                        userEntity.getEmail(),
                        userEntity.getPhone(),
                       List.of(new SimpleGrantedAuthority(role.getName())))));
    }
}

사용자 세부 정보를 검색하는 구성 요소인 UserDetailsService 역시 Reactive 방식을 지원해야 한다. 위처럼 ReactiveUserDetailsService 구현체를 생성해 Mono<UserDetails>를 반환하도록 구현하면 된다.

 

ReactiveAuthenticationManager 빈 등록

@Bean
public ReactiveAuthenticationManager authenticationManager() {
    UserDetailsRepositoryReactiveAuthenticationManager manager
            = new UserDetailsRepositoryReactiveAuthenticationManager(customUserDetailsService);
    manager.setPasswordEncoder(passwordEncoder());
    return manager;
}

ReactiveAuthenticationManager를 빈으로 등록하면 해당 빈을 다른 클래스에서 주입해서 사용 시 Spring Security에서 생성한UserDetailsRepositoryReactiveAuthenticationManager 구현 클래스 객체를 사용하여 빈에서 설정한 ReactiveUserDetailsService 구현체와 PasswordEncorder를 사용하여 인증 처리를 진행할 수 있다.

 

로그인 비즈니스 로직 구현

public Mono<UserSignInResponse> signIn(UserSignInCommand command, ServerHttpResponse response) {
    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(command.getEmail(), command.getPassword());

    return authenticationManager.authenticate(authToken)
            .map(authentication -> {
                AuthenticatedUser authenticatedUser = (AuthenticatedUser) authentication.getPrincipal();
                TokenDto tokenDto = jwtTokenProvider.createNewToken(authenticatedUser.getUserId());
                response.addCookie(refreshTokenCookie(tokenDto.getRefreshToken()));
                return Tuples.of(authenticatedUser, tokenDto);
            })
            .flatMap(TupleUtils.function((authUser, tokenDto) ->
                    upsertToken(authUser.getUserId(), tokenDto.getRefreshToken())
                            .then(Mono.just(Tuples.of(authUser, tokenDto)))))
            .map(TupleUtils.function(UserSignInResponse::from));
}

private Mono<Boolean> upsertToken(Long userId, String refreshToken) {
    return redisAdapter.setValue(BASE_KEY + userId, refreshToken, refreshTokenExpireHour * 60 * 60);
}

public class UserSignInResponse {

    private String username;
    private String email;
    private String phone;
    private String accessToken; // 액세스 토큰 응답 바디 저장
}

사용자가 로그인 요청이 오면 UsernamePasswordAuthenticationToken 클래스 객체를 생성한다. 해당 클래스는 사용자 이름(이메일)과 비밀번호를 기반으로 사용자 인증을 처리하는데 중요한 역할을 한다. 보통 사용자가 입력한 사용자 이름(이메일)과 비밀번호를 전달하여 객체를 생성하고, Authenticationmanager에 전달하여 인증을 수행한다.

위 코드에서 볼 수 있듯이 로직 구현 전 SecurityConfig에서 빈으로 등록한 AuthenticationManager에 생성한 객체를 전달하여 인증을 수행한 후 Authentication을 반환받아 액세스 토큰과 리프레시 토큰을 각각 발급하여 액세스 토큰은 응답 바디로 전달하고, 리프레시 토큰은 쿠키에 저장하여 사용자에게 응답하고 있다. 액세스 토큰을 SSR 환경인 Next.js에서 사용한다면 쿠키로 제공할 수도 있지만, 현재는 리액트인 CSR 환경에서 단일로 처리한다고 가정하고 응답바디에 저장하여 응답하도록 했다.

그리고 부가적인 로직으로 upsertToken 메서드에서 레디스에 key를 userId로, 유효기간을 리프레시 토큰 만료시간과 동일하게 설정하여 리프레시 토큰을 저장하고 있다. 토큰은 클라이언트가 직접 보관하고 서버에 매 요청마다 자체적으로 전달하는 Client-Driven 방식이다. 서버측에서 제어하기 위해서는 리프레시 토큰을 디비에 저장하고, 액세스 토큰 재발급 시 리프레시 토큰 저장 여부를 확인하는 방식으로 서버측에서 제어를 할 수 있다. 리프레시 토큰이 탈취되었을 경우 DB에서 해당 키를 삭제시켜 재발급하지 못하게 방어할 수 있다.

 

JWT 인증 구현

@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
    AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(jwtAuthenticationManager);
    authenticationWebFilter.setServerAuthenticationConverter(jwtServerAuthenticationConverter);
    authenticationWebFilter.setAuthenticationFailureHandler(jwtAuthenticationFailureHandler);

    httpSecurity
            // ...
            .addFilterBefore(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);

    return httpSecurity.build();
}

위 코드를 보면 사용자 인증에 가장 중요한 구성 요소로 AuthenticationWebFilter 객체를 생성하고 있다. AuthenticationWebFilter는 WebFlux Security 환경에서 인증을 처리하기 위한 클래스로 리액티브 방식을 지원한다.

AuthenticationWebFilter는 다음 두 가지 클래스를 사용한다.

  • ServerAuthenticationConverter는 서버 요청을 통해 사용자의 자격 증명(아이디, 토큰 등) 및 권한을 포함한 Authentication 객체로 변환한다. 
  • ReactiveAuthenticationManager는 ServerAuthenticationConverter가 제공한 Authentication 객체를 받아, 구현된 인증 방식에 따라 JWT 토큰의 유효성 검증을 진행한다.

 

JwtServerAuthenticationConverter 구현

@Component
@RequiredArgsConstructor
public class JwtServerAuthenticationConverter implements ServerAuthenticationConverter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Mono<Authentication> convert(ServerWebExchange exchange) {
        return Mono.justOrEmpty(jwtTokenProvider.resolveAccessToken(exchange.getRequest()))
                .map(token -> new UsernamePasswordAuthenticationToken(token, token));
    }
}

실제로 필터 안에서 인증을 위해 사용될 ServerAuthenticationConverter를 구현해야 한다. 이 Converter는 AuthenticationWebFilter 안에서 사용되며, 이 Converter를 통해서 요청으로부터 Bearer Token을 resolve하고, 성공한다면 Authorization을 ReactiveAuthenticationManager로 넘기게 된다.

 

JwtAuthenticationManager 구현

@Component
@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserAdapter userAdapter;
    private final RoleAdapter roleAdapter;

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        return Mono.just(authentication)
                .filter(auth -> jwtTokenProvider.validateToken((String) auth.getCredentials()))
                .switchIfEmpty(Mono.error(new BadCredentialsException(INVALID_ACCESS_TOKEN.getMessage())))
                .map(auth -> (String) auth.getPrincipal())
                .flatMap(accessToken -> userAdapter.findUserBydId(jwtTokenProvider.getUserId(accessToken)))
                .zipWhen(user -> roleAdapter.findRoleById(user.getRoleId()))
                .map(TupleUtils.function((user, role) -> {
                    List<SimpleGrantedAuthority> simpleGrantedAuthorities = List.of(new SimpleGrantedAuthority(role.getName()));
                    return new UsernamePasswordAuthenticationToken(
                            new User(user.getUsername(), user.getPassword(), simpleGrantedAuthorities),
                            user.getUserId(),
                            simpleGrantedAuthorities);
                }));
    }
}

다음 ReactiveAuthenticationManager을 구현해야 한다. 위에서 설명했듯이 Convert로부터 전달받은 Authentication 객체를 사용하여 액세스 토큰이 유효한지 검사하고, 인증 성공 여부에 따라 Authentication 객체를 반환하거나 예외를 발생시킨다. 해당 로직에서 인증이 성공해야 사용자가 원하는 요청을 처리할 수 있게 된다.

 

재발급 구현

public Mono<ReIssueResponse> reissue(ServerHttpRequest request, ServerHttpResponse response) {
    String refreshToken = jwtTokenProvider.resolveRefreshToken(request);

    if (!StringUtils.hasText(refreshToken) || !jwtTokenProvider.validateToken(refreshToken)) {
        return Mono.error(new UserException(ResponseCode.INVALID_REFRESH_TOKEN));
    }

    return userAdapter.findUserBydId(jwtTokenProvider.getUserId(refreshToken))
            .flatMap(user -> existsByRefreshToken(user.getUserId())
                    .then(Mono.just(user)))
            .map(user -> {
                TokenDto tokenDto = jwtTokenProvider.createNewToken(user.getUserId());
                response.addCookie(refreshTokenCookie(tokenDto.getRefreshToken()));
                return Tuples.of(user, tokenDto);
            })
            .flatMap(TupleUtils.function((user, tokenDto) ->
                    upsertToken(user.getUserId(), tokenDto.getRefreshToken())
                            .then(Mono.just(Tuples.of(user, tokenDto)))))
            .map(TupleUtils.function(ReIssueResponse::from));
}

재발급 비즈니스 로직은 위에서 설명했듯이 리프레시 토큰을 통해 액세스 토큰을 재발급할 수 있는 로직을 작성했다. 리프레시 토큰 만료기간을 24시간으로 설정했는데 만약 리프레시 토큰까지 만료되었으면 재로그인을 하도록 사용자에게 유도한다.

리프레시 토큰이 유효하다면 해당 사용자가 유효한지 조회한 다음 로그인 비즈니스 로직과 비슷하게 액세스 토큰을 사용자에게 전달하고, 이때, 리프레시 토큰도 같이 재발급하여 레디스에 upsert하는 로직을 구성했다.

 

 

출처
[Spring] Spring security JWT 구현하기

Spring Security + Jwt 로그인 적용하기

Spring WebFlux:CORS

[Spring Security] Bcrypt의 salt는 어디에 저장될까?

Spring + Java JWT 적용하기

스프링 JWT 심화 2: 보안을 위한 JWT 진화

JWT Authentication in Spring Boot Webflux