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

created at 2023-04-13

인증/권한 설정

JWT 토큰을 강제로 expire 할 수 없다는 점이 까다로운것 같아요. 예로 비밀번호를 업데이트하면, 해당 비밀번호 이전의 토큰은 여전히 사용하능하다는 단점이 존재합니다. 따라서 어쩔수 없이 토큰을 refresh할 수 있는 수단이 필요한데, 수정이 빠른 redis db를 통해 refresh하도록 설정할 수 있어요. 하지만 일단 이는 제외하고, Netty reactor 기반 Spring-security를 설정하는 부분을 공유하고자 합니다.(Netty 기반 Spring-security 는 기존 Tomcat 기반과 설정이 다르기 떄문이죠)

Netty Reactor 기반 Spring-Security 설정

1. ReactiveSecurityConfig.java

/**
 * 보안에 필요한 전반적인 것들을 여기에 작성합니다.
 * <p>
 * authentication manager, security context repository, 허용이 필요한 url등.
 * <p>
 *
 *  @EnableReactiveMethodSecurity은 DefaultMethodSecurityExpressionHandler클래스를 애플리케이션 컨텍스트에 등록해 줍니다.
 *  methodSecurityExpressionHandler
 */
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {

    private final ApplicationContext applicationContext;

    /**
     * ServerHttpSecurity 는 Spring-Security 의 WebFlux 전용 클래스입니다.
     * 해당 클래스를 이용하여 모든 요청에 대해 인증 여부 체크를 정의할 수 있습니다.
     * 해당 클래스에 필터를 추가하여, 요청에 인증용 토큰이 존재할 경우 인증이 되도록 설정할 수 있습니다.
     *
     * @implNote 
     * [SecurityWebFilterChain클래스를 생성하기 전에 구성해야하는 부분]
     * 
     * DefaultMethodSecurityExpressionHandler
     * authenticationEntryPoint: 애플리케이션이 인증을 요청할 때 해야 할 일들을 정의
     * accessDeniedHandler: 인증된 사용자가 필요한 권한을 가지고 있을 않을 때 헤야 할 일들을 정의
     *
     * @param http
     * @return
     */
    @Bean
    @DependsOn({"methodSecurityExpressionHandler"})
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         JwtTokenProvider jwtTokenProvider,
                                                         ReactiveAuthenticationManager reactiveAuthenticationManager) {
        DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
        // 권한인증 필터 추가 : myPermissionEvaluator()
        defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
        return http
                // exchange 로 인증필터를 거칠 때, 에러가 throw 된다면,
                // 아래의 예외 헤더를 전송합니다
                .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())
                // JwtToken 을 검증하는 필터
                .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();
    }

    /**
     * 퍼미션 정보들, 즉 'ROLE_ADMIN' 이냐 'ROLE_USER' 이냐를 ThreadLocal에서 값을 빼와서 실제로 비교하는 역할을 수행합니다. 
     * 즉, 아래의 ReactiveUserDetailsService 는 인증 및 권한을 저장하는 역할을 수행하고, PermissionEvaluator 는 hasRole() 메소드가 호출될 때 권한을 검증하는 역할을 수행하는 것이죠!
     * 
     * @return
     */
    @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;
            }
        };
    }


    /**
     * ReactiveUserDetailsService 는 실제 validation 을 수행하는 로직으로써,
     * DB에서 값을 BcryptEncryption을 통해 비교하고 user_role 및 기다 정보들을 ThreadLocal에 저장합니다.
     * 
     * 여기서 SecurityContextHolder 에 저장된 ThreadLocal 값을 바탕으로,
     * 위의 hasRole("ROLE_ADMIN") 와 같은 로직들을 controller 메소드에 접근 이전 수행하게 됩니다.
     *  
     * @param userRepository
     * @return
     */
    @Bean
    public ReactiveUserDetailsService userDetailsService(UserRepository userRepository) {
        return 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);
        };
    }

    /**
     * ReactiveAuthenticationManager 은 위에서 Bean 으로 정의한 userDetailsService 와 연결되어 로직을 수행합니다.
     * 
     * 
     * @param userDetailsService
     * @param passwordEncoder
     * @return
     */
    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
                                                                       PasswordEncoder passwordEncoder) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder);
        return authenticationManager;
    }

}

2. JwtTokenProvider.java

@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());
        
        User principal = new User(claims.getSubject(), "", authorities);

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

    public boolean validateToken(String token, ServerWebExchange exchange) {
        try {
            Jws<Claims> claims = Jwts
                    .parserBuilder().setSigningKey(this.secret).build()
                    // parseClaimsJws 은 자동적으로 expiration을 체크해줍니다
                    .parseClaimsJws(token);
            // 저는 해당 userId를 세션에 넣고 컨트롤러에서 빼서 쓸 거에요!
            // 즉, jwt 토큰에 저장된 기본정보만을 가지고 여러 메소드가 쉽게 동작할 수 있도록 설정한 것이죠.
            // (WebFlux 이벤트 방식으로 넣어줘야합니다)
            exchange.getSession().subscribe(s -> {
                s.getAttributes().put("userId", claims.getBody().getSubject());
            });
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.debug("JWT 토큰 사용 불가능: {}", e.getMessage());
        }
        return false;
    }
}

3. JwtTokenAuthenticationFilter.java

/**
 * JWT 토큰이 http request header에 있는지 확인하기 위한 필터
 * 토근이 있을 경우, 유효성 체크 후, 토큰을 이용하여 인증 정보를 만듭니다
 */
@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) {
        String token = resolveToken(exchange.getRequest());
        if(StringUtils.hasText(token) && this.jwtTokenProvider.validateToken(token, exchange)) {
            Authentication authentication = this.jwtTokenProvider.getAuthentication(token);

            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;
    }
}

4. CustomUserDetails.java

@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;
    }
}

5. LoginService.java

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
    private final JwtTokenProvider jwtTokenProvider;
    private final ReactiveAuthenticationManager authenticationManager;
    
    // 드디어 Login!
    // Bean에 저장된 ReactiveAuthenticationManager 를 가져와서 로직을 수행합니다
    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());
        
        // 매니저에서 authenticate 수행하면 이전에 우리가 만들었던 DB 체크 및 권한설정을 수행합니다.
        // 그리고 이후, jwtTokenProvider::createToken 으로 토큰을 만들게 되죠. Payload 형식은 이미 코드에 기재를 해놓았습니다.
        // 마지막으로 헤더의 Authorization 에 토큰을 집어넣고 반환해줍시다 
        return authenticationManager.authenticate(authentication)
                .map(jwtTokenProvider::createToken)
                .map(token -> {
                    response.getHeaders().add("Authorization","Bearer "+token);
                    return new LoginResponseDto(token);
                });
    }
}