likes
comments
collection
share

在 webflux 环境中使用 Spring Security

作者站长头像
站长
· 阅读数 14

本文正在参加「金石计划」

日积月累,水滴石穿 😄

一、为什么要在 webflux 环境中使用 Spring Security?

在微服务项目中,由于使用到了 gateway 。gateway 是接口请求的第一入口,所以可以把用户的认证和接口请求的权限验证放在网关进行认证与鉴权,于是就把 gateway 和 Security 整合一起使用。但是 gateway 框架与传统的 Spring MVC 不同,gateway 是基于 Netty 容器,采用的是 webflux 技术。

而在 webflux 环境下的认证授权和基于 SpringMVC 的方式不太一样,如:使用到的类、写法都有所差异,但整体流程是一致的!如果有过在 Spring Boot 中使用 Spring Security 的小伙伴,那在阅读本文之后,那在 webflux 中使用起来会没什么难度的。

二、两者关键对象区分

webfluxMVC描述
@EnableWebFluxSecurity@EnableWebSecurity开启对 Spring Security 的支持
ReactiveSecurityContextHolderSecurityContextHolder上下文对象
AuthenticationWebFilterAbstractAuthenticationProcessingFilter身份验证Web过滤器
AuthorizationWebFilterFilterSecurityInterceptor权限控制Web过滤器
ReactiveAuthenticationManagerAuthenticationManager身份验证管理器
ReactiveAuthorizationManagerAccessDecisionManager访问决策管理器
WebFilterFilterChainProxy对Web请求进行拦截式链式处理
ReactiveUserDetailsServiceUserDetailsService加载用户信息服务

三、用户认证

创建项目并加入依赖

Boot 版本 :2.6.14

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

创建业务接口并验证

创建业务接口并验证是否与 webflux 框架整合成功。

@RestController
public class HelloController {

    @GetMapping("hello-dev")
    public String devHello() {
        return "Hello Dev webflux Spring Security";
    }

    @GetMapping("hello-test")
    public String testHello() {
        return "Hello Test webflux Spring Security";
    }
}

启动项目,在浏览器请求我们的接口:http://localhost:8080/hello-dev

在 webflux 环境中使用 Spring Security 看到如上页面,如果学过笔者之前的 Spring Security 文章,那你应该知道怎么去找用户名和密码了。如下图,Spring Security 会在控制台会打印密码:

在 webflux 环境中使用 Spring Security

用户名默认user,输入上面控制台打印的密码,点击 Sign in 按钮,就可以访问接口了:

在 webflux 环境中使用 Spring Security

自定义账号密码

方式一:配置文件

复制日志中出现的名称: ctiveUserDetailsServiceAutoConfiguration,在 idea 中按两下 shift

在 webflux 环境中使用 Spring Security

在类 ReactiveUserDetailsServiceAutoConfiguration 中可以看到如下代码: 在 webflux 环境中使用 Spring Security

可以看到密码的来源于 User 对象,而 User 对象,由 reactiveUserDetailsService() 方法传入,但值最终都是取自 SecurityProperties 对象。 在 webflux 环境中使用 Spring Security

SecurityProperties就是一个读取配置文件的类,定义如下:

在 webflux 环境中使用 Spring Security 所以自定义用户名和密码只需在配置文件添加如下配置即可:

spring:
  security:
    user:
      name: cxyxj
      password: 123456

再次启动项目,就可以使用cxyxj用户来登录了:

在 webflux 环境中使用 Spring Security

方式二:ReactiveUserDetailsService

ReactiveUserDetailsServiceAutoConfiguration 类里,其实还看到了如何定义用户密码的代码了。

在 webflux 环境中使用 Spring Security 创建 SecurityConfig 类并在类中添加如下代码:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * @author cxyxj
 */
@Configuration
public class SecurityConfig {


    @Bean
    PasswordEncoder passwordEncoder() {
        return  NoOpPasswordEncoder.getInstance();
    }

    @Bean
    public MapReactiveUserDetailsService userDetailsService() {
        UserDetails userDetails = User.withUsername("admin").password("123").authorities("admin").build();
        return new MapReactiveUserDetailsService(new UserDetails[]{userDetails});
    }

}

再次启动项目,就可以使用admin用户来登录了。 这里使用的MapReactiveUserDetailsService 实现了 ReactiveUserDetailsService。看看其定义:

public interface ReactiveUserDetailsService {
    Mono<UserDetails> findByUsername(String username);
}

是不是感觉跟 UserDetailsService 很相似。其实这就是 webflux 中的根据用户名检索用户信息的接口。将 SecurityConfig 中的 userDetailsService 方法 代码先进行注释,编写如下代码:

import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Service
public class UserServiceImpl implements ReactiveUserDetailsService {
    @Override
    public Mono<UserDetails> findByUsername(String username) {
        UserDetails userDetails = User.withUsername("dev")
                .password("1234").authorities("dev").build();
        // 构造出一个Mono
        Mono<UserDetails> just = Mono.just(userDetails);
        return just;
    }
}

再次启动项目,就可以使用dev用户来登录了。

也可以在 SecurityConfig 中指定ReactiveUserDetailsService。在 Spring MVC 老版本的 Security 还要继承 WebSecurityConfigurerAdapter类并重写 configure(AuthenticationManagerBuilder auth)方法。

@Configuration
public class SecurityConfig {

    @Bean
    PasswordEncoder passwordEncoder() {
        return  NoOpPasswordEncoder.getInstance();
    }

//    @Bean
//    public MapReactiveUserDetailsService userDetailsService() {
//        UserDetails userDetails = User.withUsername("admin").password("123").authorities("admin").build();
//        return new MapReactiveUserDetailsService(new UserDetails[]{userDetails});
//    }

    @Autowired
    UserServiceImpl userDetailService;

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

ReactiveAuthenticationManager 就相当于MVC中的 AuthenticationManager 对象。

四、权限认证

SpringSecurity 在MVC环境下做权限控制时,需要使用HttpSecurity 对象,并通过antMatchers添加规则、通过hasRole设置权限。在WebFlux环境下也是如此使用,不过使用的是ServerHttpSecurity对象。

编码

@Bean
PasswordEncoder passwordEncoder() {
    return  NoOpPasswordEncoder.getInstance();
}

/**
 * 注释 UserServiceImpl 相关代码
 * @return
 */
@Bean
public MapReactiveUserDetailsService userDetailsService() {
    UserDetails userDetails = User.withUsername("test").password("123").roles("test").build();
    UserDetails userDetails2 = User.withUsername("dev").password("123456").roles("dev").build();
    return new MapReactiveUserDetailsService(new UserDetails[]{userDetails,userDetails2});
}

@Bean
SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {
    http.authorizeExchange()
            .pathMatchers("/hello-dev").hasRole("dev")
            .pathMatchers("/hello-test").hasRole("test")
            .and().formLogin()
            .and().cors().disable();
    return http.build();
}
  • 提供两个用户,dev 和 test。
  • 访问 /hello-dev 接口需要 dev 角色;访问 /hello-test 接口需要 test 角色。

测试

使用 dev 用户登录

在 webflux 环境中使用 Spring Security 访问 /hello-dev 接口

在 webflux 环境中使用 Spring Security

访问 /hello-test 接口

在 webflux 环境中使用 Spring Security

五、自定义响应

在 mvc 中还自定义过登录成功响应、登录失败响应、登录无权限访问响应、未登录访问认证资源响应、登出成功响应。那在 webflux 中如何自定义呢?

登录成功响应

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
 * 认证成功回调
 *
 * @author cxyxj
 */
@Component
public class CustomAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {

    @Override
    public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
        ServerWebExchange exchange = webFilterExchange.getExchange();
        ServerHttpResponse response = exchange.getResponse();
        response.getHeaders().add("Content-Type", "application/json; charset=UTF-8");
        Object principal = authentication.getPrincipal();
        ObjectMapper objectMapper = new ObjectMapper();
        DataBuffer bodyDataBuffer = null;
        try {
            bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(principal));
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return response.writeWith(Mono.just(bodyDataBuffer));
    }
}

登录失败响应

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.util.HashMap;

/**
 * 认证失败回调
 * @author cxyxj
 */
@Component
public class CustomAuthenticationFailHandler  implements ServerAuthenticationFailureHandler {

    @Override
    public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException e) {
        ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
        response.setStatusCode(HttpStatus.FORBIDDEN);
        response.getHeaders().add("Content-Type", "application/json; charset=UTF-8");
        String msg = "";
        if (e instanceof LockedException) {
            msg = "账户被锁定,请联系管理员!";
        }
        else if (e instanceof BadCredentialsException) {
            msg = "用户名或者密码输入错误,请重新输入!";
        }else {
            msg = e.getMessage();
        }
        HashMap<String, String> map = new HashMap<>();
        map.put("code", "99999");
        map.put("message", msg);
        ObjectMapper objectMapper = new ObjectMapper();
        DataBuffer dataBuffer = null;
        try {
            dataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
        } catch (JsonProcessingException jsonProcessingException) {
            jsonProcessingException.printStackTrace();
        }
        return response.writeWith(Mono.just(dataBuffer));
    }
}

已登录访问无权限资源响应

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.HashMap;

/**
 * 已登录访问没有权限的资源回调
 * @author: cxyxj
 */
@Component
public class CustomAccessDeniedHandler implements ServerAccessDeniedHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) {
        ServerHttpResponse response = exchange.getResponse();
        // 403 拒绝访问
        response.setStatusCode(HttpStatus.FORBIDDEN);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        HashMap<String, Object> map = new HashMap<>();
        map.put("code", HttpStatus.FORBIDDEN.value());
        map.put("message", "无访问权限");
        ObjectMapper objectMapper = new ObjectMapper();
        DataBuffer bodyDataBuffer = null;
        try {
            bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
        } catch (JsonProcessingException jsonProcessingException) {
            jsonProcessingException.printStackTrace();
        }
        return response.writeWith(Mono.just(bodyDataBuffer));
    }
}

未登录访问认证资源响应

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.HashMap;

/**
 * 未登录访问认证资源回调
 * @author cxyxj
 */
@Component
public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
        ServerHttpResponse response = exchange.getResponse();
        // 401 未授权
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");

        HashMap<String, Object> map = new HashMap<>();
        map.put("code", HttpStatus.UNAUTHORIZED.value());
        map.put("message", "暂未登录,请您先进行登录");
        ObjectMapper objectMapper = new ObjectMapper();
        DataBuffer bodyDataBuffer = null;
        try {
            bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
        } catch (JsonProcessingException jsonProcessingException) {
            jsonProcessingException.printStackTrace();
        }
        return response.writeWith(Mono.just(bodyDataBuffer));
    }
}

登出成功响应

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.util.HashMap;

/**
 * 登出成功回调
 * @author cxyxj
 */
@Component
public class CustomLogoutSuccessHandler implements ServerLogoutSuccessHandler {


    @Override
    public Mono<Void> onLogoutSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
        ServerHttpResponse response = webFilterExchange.getExchange().getResponse();

        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        HashMap<String, Object> map = new HashMap<>();
        map.put("code", HttpStatus.OK.value());
        map.put("message", "登出成功");
        ObjectMapper objectMapper = new ObjectMapper();
        DataBuffer bodyDataBuffer = null;
        try {
            bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
        } catch (JsonProcessingException jsonProcessingException) {
            jsonProcessingException.printStackTrace();
        }
        return response.writeWith(Mono.just(bodyDataBuffer));
    }
}

配置

@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;

@Autowired
private CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

@Autowired
private CustomAuthenticationFailHandler customAuthenticationFailHandler;

@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

@Autowired
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Bean
SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {
    http.authorizeExchange()
            .pathMatchers("/login", "/logout").permitAll()
            .pathMatchers("/hello-dev").hasRole("dev")
            .pathMatchers("/hello-test").hasRole("test")
            .anyExchange().authenticated()
            .and().formLogin()
            .authenticationFailureHandler(customAuthenticationFailHandler)
            .authenticationSuccessHandler(customAuthenticationSuccessHandler)
            .and().exceptionHandling().accessDeniedHandler(customAccessDeniedHandler).authenticationEntryPoint(customAuthenticationEntryPoint)
            .and().logout().logoutSuccessHandler(customLogoutSuccessHandler)
            .and().cors().disable()
            .csrf().disable();
    return http.build();
}

如此设置之后,默认的登录页面就失效了(是否是没有放行?)。所以,我们通过 postman 进行测试。

验证

登录失败响应测试

在 webflux 环境中使用 Spring Security 注意:参数格式要指定为 x-www-form-urlencoded

未登录访问认证资源响应

在 webflux 环境中使用 Spring Security

登录成功响应

在 webflux 环境中使用 Spring Security

通过登录成功响应的 SESSIONID 访问其他业务接口。

已登录访问有权限资源响应

在 webflux 环境中使用 Spring Security

已登录访问无权限资源响应

在 webflux 环境中使用 Spring Security

登出成功响应

在 webflux 环境中使用 Spring Security

登出成功之后,再使用该 SESSIONID 访问其他业务接口。

在 webflux 环境中使用 Spring Security


  • 如你对本文有疑问或本文有错误之处,欢迎评论留言指出。如觉得本文对你有所帮助,欢迎点赞 + 收藏。