Skip to main content Link Menu Expand (external link) Document Search Copy Copied

Reactive 프로그래밍에서는 Spring Security 설정이 기본과는 다릅니다. 별로 레퍼런스가 없길래 아래와 같이 코드를 공유합니다.

1. LoginResponseDto

@Builder
@Data
public class LoginResponseDto {
    private String token;
}

2. LoginRequestDto

@Data
public class LoginRequestDto {
    private String username;
    private String password;
}

3. CustomUserDetails

@Getter
@Setter
@Slf4j
public class CustomUserDetails implements UserDetails {

    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    private List<String> permissions = new ArrayList<>();

    public CustomUserDetails() {
    }

    public CustomUserDetails(String username) {
        this.username = username;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        permissions.stream().forEach(permission -> {
            authorities.add(new SimpleGrantedAuthority(permission));
        });
        // authorities.forEach(a->{
        //     log.info("getAuthorities={}",a.getAuthority());
        // });
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

3. LoginService

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
    private final JwtTokenProvider jwtTokenProvider;
    private final ReactiveAuthenticationManager authenticationManager;


    public Mono<LoginResponseDto> login(LoginRequestDto loginRequestDto, ServerHttpResponse response) {

        if (loginRequestDto.getPassword() == null || loginRequestDto.getUsername() == null)
            return Mono.error(new ServerWebInputException("User Input Invalidation"));

        Authentication authentication = new UsernamePasswordAuthenticationToken(loginRequestDto.getUsername(),
                loginRequestDto.getPassword());
        return authenticationManager.authenticate(authentication)
                .map(jwtTokenProvider::createToken)
                .map(token -> {
                    response.getHeaders().add("Authorization","Bearer "+token);
                    return new LoginResponseDto(token);
                });
    }
}

4. ReactiveSecurityConfig

@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {

    private final ApplicationContext applicationContext;

    /**
     * ServerHttpSecurity는 스프링 시큐리티의 HttpSecurity와 비슷한 웹플럭스용 클래스다.
     * 이 클래스를 이용하여 모든 요청에 대해 인증 여부 체크를 정의할 수 있다.
     * 이 클래스에 필터를 추가하여, 요청에 인증용 토큰이 존재할 경우 인증이 되도록 설정할 수 있다.
     *
     * SecurityWebFilterChain클래스를 생성하기 전에 DefaultMethodSecurityExpressionHandler클래스가 먼저 구성되어 있어야 한다.
     * <p>
     * authenticationEntryPoint: 애플리케이션이 인증을 요청할 때 해야 할 일들을 정의함.
     * accessDeniedHandler: 인증된 사용자가 필요한 권한을 가지고 있을 않을 때 헤야 할 일들을 정의함.
     *
     * @param http
     * @return
     */
    @Bean
    @DependsOn({"methodSecurityExpressionHandler"})
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         JwtTokenProvider jwtTokenProvider,
                                                         ReactiveAuthenticationManager reactiveAuthenticationManager) {
        DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
        defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
        return http
                .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
                        .authenticationEntryPoint((exchange, ex) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                            });
                        })
                        .accessDeniedHandler((exchange, denied) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                            });
                        }))
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .authenticationManager(reactiveAuthenticationManager)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                // authenticate
                .addFilterAt(new JwtTokenAuthenticationFilter(jwtTokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
                .authorizeExchange(exchange -> exchange
                        // 승인 목록
                        .pathMatchers(HttpMethod.OPTIONS).permitAll() // 사용가능 Method
                        .pathMatchers(HttpMethod.POST,"/user").permitAll() // 회원가입
                        .pathMatchers("/login").permitAll() // 로그인
                        // 권한 필터
                        .pathMatchers("/admin/**").hasRole("ADMIN") // admin 만 접근가능하도록 권한 설정
                        .pathMatchers("/**").hasAnyRole("USER","ADMIN") // 다른 모든 method 권한 설정
                        .anyExchange().authenticated()
                )
                .build();
    }

    @Bean
    public PermissionEvaluator myPermissionEvaluator() {
        return new PermissionEvaluator() {
            @Override
            public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
                log.info("has Permission?");
                if(authentication.getAuthorities().stream()
                        .filter(grantedAuthority -> {
                            log.info("My Authorities={}", grantedAuthority.getAuthority());
                            log.info("permission={}", permission);
                            return grantedAuthority.getAuthority().equals((String) permission);
                        })
                        .count() > 0) {
                    log.info("Yes Authorities={}", authentication.getAuthorities());
                    return true;
                }
                return false;
            }

            @Override
            public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
                return false;
            }
        };
    }



    @Bean
    public ReactiveUserDetailsService userDetailsService(UserRepository userRepository) {
        return username -> {
            log.info("Finduser : {}",username);
            Optional<User> findUser = userRepository.findById(username);
            if (!findUser.isPresent()) {
                return Mono.empty();
            }

            User user = findUser.get();

            CustomUserDetails userDetails = new CustomUserDetails();
            userDetails.setUsername(user.getUserId());
            userDetails.setPassword(user.getUserPw());
            userDetails.setEnabled(true);
            userDetails.setAccountNonExpired(true);
            userDetails.setCredentialsNonExpired(true);
            userDetails.setAccountNonLocked(true);
            userDetails.setPermissions(Arrays.asList(user.getRole()));
            return Mono.just(userDetails);
        };
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
                                                                       PasswordEncoder passwordEncoder) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder);
        return authenticationManager;
    }

}

5. JwtTokenProvider

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "permissions";

    @Value("${token.expiration_time}")
    String expirationTime;

    @Value("${token.secret}")
    String secret;


    /***
     * jwt payload
     * {
     *   "sub": "userId",
     *   "permissions": ["ROLE_USER","ROLE_ADMIN"],
     *   "iat": 1680778900,
     *   "exp": 1680865300
     * }
     */
    public String createToken(Authentication authentication) {
        String username = authentication.getName();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        // Claims = sub + expiration + role
        Claims claims = Jwts.claims().setSubject(username);
        if (authorities != null) {
            claims.put(AUTHORITIES_KEY
                    , authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")));
        }

        Long expirationTimeLong = Long.parseLong(expirationTime);
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(username)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Authentication getAuthentication(String token) {

        Claims claims = Jwts.parserBuilder().setSigningKey(this.secret).build().parseClaimsJws(token).getBody();

        Object authoritiesClaim = claims.get(AUTHORITIES_KEY);

        // 토큰에서 permission 체크
        Collection<? extends GrantedAuthority> authorities = authoritiesClaim == null ? AuthorityUtils.NO_AUTHORITIES
                : AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesClaim.toString());

        authorities.forEach(c->{
            log.info("권한획득: {}",c.toString());
        });

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts
                    .parserBuilder().setSigningKey(this.secret).build()
                    .parseClaimsJws(token);
            //  parseClaimsJws will check expiration date. No need do here.
            log.info("JWT 토큰 만료 시간: {}", claims.getBody().getExpiration());
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.info("JWT 토큰 사용 불가능: {}", e.getMessage());
            log.trace("TRACE", e);
        }
        return false;
    }
}

6. JwtTokenAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter implements WebFilter {
    public static final String HEADER_PREFIX = "Bearer ";

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        log.info("JWT 토큰 필터");
        String token = resolveToken(exchange.getRequest());
        if(StringUtils.hasText(token) && this.jwtTokenProvider.validateToken(token)) {
            Authentication authentication = this.jwtTokenProvider.getAuthentication(token);
            authentication.getAuthorities().forEach(a->{
                log.info("JWT 토큰으로 부터 얻는 Authorities={}",a.getAuthority());
            });
            return chain.filter(exchange)
                    .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
        }
        return chain.filter(exchange);
    }

    // Header에서 JWT 토큰을 Bear 프리픽스 떼서 가져옵니다
    private String resolveToken(ServerHttpRequest request) {
        String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}