在 webflux 环境中使用 Spring Security
日积月累,水滴石穿 😄
一、为什么要在 webflux 环境中使用 Spring Security?
在微服务项目中,由于使用到了 gateway 。gateway 是接口请求的第一入口,所以可以把用户的认证和接口请求的权限验证放在网关进行认证与鉴权,于是就把 gateway 和 Security 整合一起使用。但是 gateway 框架与传统的 Spring MVC 不同,gateway 是基于 Netty 容器,采用的是 webflux 技术。
而在 webflux 环境下的认证授权和基于 SpringMVC 的方式不太一样,如:使用到的类、写法都有所差异,但整体流程是一致的!如果有过在 Spring Boot 中使用 Spring Security 的小伙伴,那在阅读本文之后,那在 webflux 中使用起来会没什么难度的。
二、两者关键对象区分
webflux | MVC | 描述 |
---|---|---|
@EnableWebFluxSecurity | @EnableWebSecurity | 开启对 Spring Security 的支持 |
ReactiveSecurityContextHolder | SecurityContextHolder | 上下文对象 |
AuthenticationWebFilter | AbstractAuthenticationProcessingFilter | 身份验证Web过滤器 |
AuthorizationWebFilter | FilterSecurityInterceptor | 权限控制Web过滤器 |
ReactiveAuthenticationManager | AuthenticationManager | 身份验证管理器 |
ReactiveAuthorizationManager | AccessDecisionManager | 访问决策管理器 |
WebFilter | FilterChainProxy | 对Web请求进行拦截式链式处理 |
ReactiveUserDetailsService | UserDetailsService | 加载用户信息服务 |
三、用户认证
创建项目并加入依赖
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
看到如上页面,如果学过笔者之前的 Spring Security 文章,那你应该知道怎么去找用户名和密码了。如下图,Spring Security 会在控制台会打印密码:
用户名默认user
,输入上面控制台打印的密码,点击 Sign in 按钮,就可以访问接口了:
自定义账号密码
方式一:配置文件
复制日志中出现的名称: ctiveUserDetailsServiceAutoConfiguration
,在 idea 中按两下 shift
在类 ReactiveUserDetailsServiceAutoConfiguration
中可以看到如下代码:
可以看到密码的来源于 User 对象,而 User 对象,由 reactiveUserDetailsService()
方法传入,但值最终都是取自 SecurityProperties
对象。
而SecurityProperties
就是一个读取配置文件的类,定义如下:
所以自定义用户名和密码只需在配置文件添加如下配置即可:
spring:
security:
user:
name: cxyxj
password: 123456
再次启动项目,就可以使用cxyxj
用户来登录了:
方式二:ReactiveUserDetailsService
在 ReactiveUserDetailsServiceAutoConfiguration
类里,其实还看到了如何定义用户密码的代码了。
创建
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 用户登录
访问
/hello-dev
接口
访问 /hello-test
接口
五、自定义响应
在 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 进行测试。
验证
登录失败响应测试
注意:参数格式要指定为
x-www-form-urlencoded
未登录访问认证资源响应
登录成功响应
通过登录成功响应的 SESSIONID 访问其他业务接口。
已登录访问有权限资源响应
已登录访问无权限资源响应
登出成功响应
登出成功之后,再使用该 SESSIONID 访问其他业务接口。
- 如你对本文有疑问或本文有错误之处,欢迎评论留言指出。如觉得本文对你有所帮助,欢迎点赞 + 收藏。
转载自:https://juejin.cn/post/7220662065294770233