引言
原本打算将Security模块与gateway模块分开写的,但想到gateway本来就有过滤的作用 ,于是就把gateway和Security结合在一起了,然后结合JWT令牌对用户身份和权限进行校验。
Spring Cloud的网关与传统的SpringMVC不同,gateway是基于Netty容器,采用的webflux技术,所以gateway模块不能引入spring web包。虽然是不同,但是在SpringMVC模式下的Security实现步骤和流程都差不多。
依赖
Spring? cloud gateway模块依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--JWT的依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<type>jar</type>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
代码基本结构

认证执行流程
一、Token工具类
public class JWTUtils {
private final static String SING="XIAOYUAN";
public static String creatToken(Map<String,String> payload,int expireTime){
JWTCreator.Builder builder= JWT.create();
Calendar instance=Calendar.getInstance();//获取日历对象
if(expireTime <=0)
instance.add(Calendar.SECOND,3600);//默认一小时
else
instance.add(Calendar.SECOND,expireTime);
//为了方便只放入了一种类型
payload.forEach(builder::withClaim);
return builder.withExpiresAt(instance.getTime()).sign(Algorithm.HMAC256(SING));
}
public static Map<String, Object> getTokenInfo(String token){
DecodedJWT verify = JWT.require(Algorithm.HMAC256(SING)).build().verify(token);
Map<String, Claim> claims = verify.getClaims();
SimpleDateFormat dateTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String expired= dateTime.format(verify.getExpiresAt());
Map<String,Object> m=new HashMap<>();
claims.forEach((k,v)-> m.put(k,v.asString()));
m.put("exp",expired);
return m;
}
}
二、自定义User并且实现Spring Security的User接口,以及实现UserDetail接口
public class SecurityUserDetails extends User implements Serializable {
private Long userId;
public SecurityUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities, Long userId) {
super(username, password, authorities);
this.userId = userId;
}
public SecurityUserDetails(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities, Long userId) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
this.userId = userId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
}
@Component("securityUserDetailsService")
@Slf4j
public class SecurityUserDetailsService implements ReactiveUserDetailsService {
private final PasswordEncoder passwordEncoder= new BCryptPasswordEncoder();;
@Override
public Mono<UserDetails> findByUsername(String username) {
//调用数据库根据用户名获取用户
log.info(username);
if(!username.equals("admin")&&!username.equals("user"))
throw new UsernameNotFoundException("username error");
else {
Collection<GrantedAuthority> authorities = new ArrayList<>();
if (username.equals("admin"))
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));//ROLE_ADMIN
if (username.equals("user"))
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));//ROLE_ADMIN
SecurityUserDetails securityUserDetails = new SecurityUserDetails(username,"{bcrypt}"+passwordEncoder.encode("123"),authorities,1L);
return Mono.just(securityUserDetails);
}
}
}
这里我为了方便测试,只设置了两个用户,admin和晢user,用户角色也只有一种。
二、AuthenticationSuccessHandler,定义认证成功类
@Component
@Slf4j
public class AuthenticationSuccessHandler extends WebFilterChainServerAuthenticationSuccessHandler {
@Value("${login.timeout}")
private int timeout=3600;//默认一小时
private final int rememberMe=180;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@SneakyThrows
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
ServerWebExchange exchange = webFilterExchange.getExchange();
ServerHttpResponse response = exchange.getResponse();
//设置headers
HttpHeaders httpHeaders = response.getHeaders();
httpHeaders.add("Content-Type", "application/json; charset=UTF-8");
httpHeaders.add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
//设置body
HashMap<String, String> map = new HashMap<>();
String remember_me=exchange.getRequest().getHeaders().getFirst("Remember-me");
ObjectMapper mapper = new ObjectMapper();
List<? extends GrantedAuthority> list=authentication.getAuthorities().stream().toList();
try {
Map<String, String> load = new HashMap<>();
load.put("username",authentication.getName());
load.put("role",list.get(0).getAuthority());//这里只添加了一种角色 实际上用户可以有不同的角色类型
String token;
log.info(authentication.toString());
if (remember_me==null) {
token=JWTUtils.creatToken(load,3600*24);
response.addCookie(ResponseCookie.from("token", token).path("/").build());
//maxAge默认-1 浏览器关闭cookie失效
redisTemplate.opsForValue().set(authentication.getName(), token, 1, TimeUnit.DAYS);
}else {
token=JWTUtils.creatToken(load,3600*24*180);
response.addCookie(ResponseCookie.from("token", token).maxAge(Duration.ofDays(rememberMe)).path("/").build());
redisTemplate.opsForValue().set(authentication.getName(), token, rememberMe, TimeUnit.SECONDS);//保存180天
}
map.put("code", "000220");
map.put("message", "登录成功");
map.put("token",token);
} catch (Exception ex) {
ex.printStackTrace();
map.put("code", "000440");
map.put("message","登录失败");
}
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(mapper.writeValueAsBytes(map));
return response.writeWith(Mono.just(bodyDataBuffer));
}
}
当用户认证成功的时候就会调用这个类,这里我将token作为cookie返回客户端,当客服端请求接口的时候将带上Cookie,然后gateway在认证之前拦截,然后将Cookie写入Http请求头中,后面的授权在请求头中获取token。(这里我使用的cookie来保存token,当然也可以保存在localStorage里,每次请求的headers里面带上token)
这里还实现了一个记住用户登录的功能,原本是打算读取请求头中的表单数据的Remember-me字段来判断是否记住用户登录状态,但是这里有一个问题,在获取请求的表单数据的时候一直为空,因为Webflux中请求体中的数据只能被读取一次,如果读取了就需要重新封装,前面在进行用户认证的时候已经读取过了请求体导致后面就读取不了(只是猜测,因为刚学习gateway还不是很了解,在网上查了很多资料一直没有解决这个问题),于是我用了另一个方法,需要记住用户登录状态的时候(Remember-me),我就在前端请求的时候往Http请求头加一个Remember-me字段,然后后端判断有没有这个字段,没有的话就不记住。
三、AuthenticationFaillHandler? ,认证失败类
@Slf4j
@Component
public class AuthenticationFaillHandler implements ServerAuthenticationFailureHandler {
@SneakyThrows
@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");
HashMap<String, String> map = new HashMap<>();
map.put("code", "000400");
map.put("message", e.getMessage());
log.error("access forbidden path={}", webFilterExchange.getExchange().getRequest().getPath());
ObjectMapper objectMapper = new ObjectMapper();
DataBuffer dataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
return response.writeWith(Mono.just(dataBuffer));
}
}
四、SecurityRepository ,用户信息上下文存储类
@Slf4j
@Component
public class SecurityRepository implements ServerSecurityContextRepository {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
return Mono.empty();
}
@Override
public Mono<SecurityContext> load(ServerWebExchange exchange) {
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
log.info(token);
if (token != null) {
try {
Map<String,Object> userMap= JWTUtils.getTokenInfo(token);
String result=(String)redisTemplate.opsForValue().get(userMap.get("username"));
if (result==null || !result.equals(token))
return Mono.empty();
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
Collection<SimpleGrantedAuthority> authorities=new ArrayList<>();
log.info((String) userMap.get("role"));
authorities.add(new SimpleGrantedAuthority((String) userMap.get("role")));
Authentication authentication=new UsernamePasswordAuthenticationToken(null, null,authorities);
emptyContext.setAuthentication(authentication);
return Mono.just(emptyContext);
}catch (Exception e) {
return Mono.empty();
}
}
return Mono.empty();
}
}
当客户端访问服务接口的时候,如果是有效token,那么就根据token来判断用户权限,实现ServerSecurityContextRepository?类的主要目的是实现load方法,这个方法实际上是传递一个Authentication对象供后面ReactiveAuthorizationManager<AuthorizationContext>来判断用户权限。我这里只传递了用户的role信息,所以就没有去实现ReactiveAuthorizationManager这个接口了。
Security框架默认提供了两个ServerSecurityContextRepository实现类,WebSessionServerSecurityContextRepository和NoOpServerSecurityContextRepository,Security默认使用WebSessionServerSecurityContextRepository,这个是使用session来保存用户登录状态的,NoOpServerSecurityContextRepository是无状态的。
五、AuthenticationEntryPoint ,接口认证入口类
如果客户端没有认证授权就直接访问服务接口,然后就会调用这个类,返回的状态码是401
@Slf4j
@Component
public class AuthenticationEntryPoint extends HttpBasicServerAuthenticationEntryPoint {
@SneakyThrows
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json; charset=UTF-8");
HashMap<String, String> map = new HashMap<>();
map.put("status", "00401");
map.put("message", "未登录");
ObjectMapper objectMapper = new ObjectMapper();
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
return response.writeWith(Mono.just(bodyDataBuffer));
}
}
六、AccessDeniedHandler ,授权失败处理类
当访问服务接口的用户权限不够时会调用这个类,返回HTTP状态码是403
@Slf4j
@Component
public class AccessDeniedHandler implements ServerAccessDeniedHandler {
@SneakyThrows
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.FORBIDDEN);
response.getHeaders().add("Content-Type", "application/json; charset=UTF-8");
HashMap<String, String> map = new HashMap<>();
map.put("code", "000403");
map.put("message", "未授权禁止访问");
log.error("access forbidden path={}", exchange.getRequest().getPath());
ObjectMapper objectMapper = new ObjectMapper();
DataBuffer dataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(map));
return response.writeWith(Mono.just(dataBuffer));
}
}
七、AuthorizationManager ,鉴权管理类
@Slf4j
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext authorizationContext) {
return authentication.map(auth -> {
//SecurityUserDetails userSecurity = (SecurityUserDetails) auth.getPrincipal();
String path=authorizationContext.getExchange().getRequest().getURI().getPath();
for (GrantedAuthority authority : auth.getAuthorities()){
if (authority.getAuthority().equals("ROLE_USER")&&path.contains("/user/normal"))
return new AuthorizationDecision(true);
else if (authority.getAuthority().equals("ROLE_ADMIN")&&path.contains("/user/admin"))
return new AuthorizationDecision(true);
//对客户端访问路径与用户角色进行匹配
}
return new AuthorizationDecision(false);
}).defaultIfEmpty(new AuthorizationDecision(false));
}
}
返回new AuthorizationDecision(true)代表授予权限访问服务,为false则是拒绝。
八、LogoutHandler,LogoutSuccessHandler?登出处理类
@Component
@Slf4j
public class LogoutHandler implements ServerLogoutHandler {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Override
public Mono<Void> logout(WebFilterExchange webFilterExchange, Authentication authentication) {
HttpCookie cookie=webFilterExchange.getExchange().getRequest().getCookies().getFirst("token");
try {
if (cookie != null) {
Map<String,Object> userMap= JWTUtils.getTokenInfo(cookie.getValue());
redisTemplate.delete((String) userMap.get("username"));
}
}catch (JWTDecodeException e) {
return Mono.error(e);
}
return Mono.empty();
}
}
@Component
public class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
@SneakyThrows
@Override
public Mono<Void> onLogoutSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
//设置headers
HttpHeaders httpHeaders = response.getHeaders();
httpHeaders.add("Content-Type", "application/json; charset=UTF-8");
httpHeaders.add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
//设置body
HashMap<String, String> map = new HashMap<>();
//删除token
response.addCookie(ResponseCookie.from("token", "logout").maxAge(0).path("/").build());
map.put("code", "000220");
map.put("message", "退出登录成功");
ObjectMapper mapper = new ObjectMapper();
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(mapper.writeValueAsBytes(map));
return response.writeWith(Mono.just(bodyDataBuffer));
}
}
九、CookieToHeadersFilter ,将Cookie写入Http请求头中
@Slf4j
@Component
public class CookieToHeadersFilter implements WebFilter{
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
try {
HttpCookie cookie=exchange.getRequest().getCookies().getFirst("token");
if (cookie != null) {
String token = cookie.getValue();
ServerHttpRequest request=exchange.getRequest().mutate().header(HttpHeaders.AUTHORIZATION,token).build();
return chain.filter(exchange.mutate().request(request).build());
}
}catch (NoFoundToken e) {
log.error(e.getMsg());
}
return chain.filter(exchange);
}
}
这里需要注意的是,如果要想在认证前后过滤Http请求,用全局过滤器或者局部过滤器是不起作用的,因为它们总是在鉴权通过后执行,也就是它们的执行顺序始终再Security过滤器之后,无论order值多大多小。这时候必须实现的接口是WebFilter而不是GlobalFilter或者GatewayFilter,然后将接口实现类添加到WebSecurityConfig配置中心去。
十、WebSecurityConfig,配置类
@EnableWebFluxSecurity
@Configuration
@Slf4j
public class WebSecurityConfig {
@Autowired
SecurityUserDetailsService securityUserDetailsService;
@Autowired
AuthorizationManager authorizationManager;
@Autowired
AccessDeniedHandler accessDeniedHandler;
@Autowired
AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
AuthenticationFaillHandler authenticationFaillHandler;
@Autowired
SecurityRepository securityRepository;
@Autowired
CookieToHeadersFilter cookieToHeadersFilter;
@Autowired
LogoutSuccessHandler logoutSuccessHandler;
@Autowired
LogoutHandler logoutHandler;
@Autowired
com.example.gateway.security.AuthenticationEntryPoint authenticationEntryPoint;
private final String[] path={
"/favicon.ico",
"/book/**",
"/user/login.html",
"/user/__MACOSX/**",
"/user/css/**",
"/user/fonts/**",
"/user/images/**"};
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.addFilterBefore(cookieToHeadersFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER);
//SecurityWebFiltersOrder枚举类定义了执行次序
http.authorizeExchange(exchange -> exchange // 请求拦截处理
.pathMatchers(path).permitAll()
.pathMatchers(HttpMethod.OPTIONS).permitAll()
.anyExchange().access(authorizationManager)//权限
//.and().authorizeExchange().pathMatchers("/user/normal/**").hasRole("ROLE_USER")
//.and().authorizeExchange().pathMatchers("/user/admin/**").hasRole("ROLE_ADMIN")
//也可以这样写 将匹配路径和角色权限写在一起
)
.httpBasic()
.and()
.formLogin().loginPage("/user/login")//登录接口
.authenticationSuccessHandler(authenticationSuccessHandler) //认证成功
.authenticationFailureHandler(authenticationFaillHandler) //登陆验证失败
.and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)//基于http的接口请求鉴权失败
.and().csrf().disable()//必须支持跨域
.logout().logoutUrl("/user/logout")
.logoutHandler(logoutHandler)
.logoutSuccessHandler(logoutSuccessHandler);
http.securityContextRepository(securityRepository);
//http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance());//无状态 默认情况下使用的WebSession
return http.build();
}
@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager() {
LinkedList<ReactiveAuthenticationManager> managers = new LinkedList<>();
managers.add(authentication -> {
// 其他登陆方式
return Mono.empty();
});
managers.add(new UserDetailsRepositoryReactiveAuthenticationManager(securityUserDetailsService));
return new DelegatingReactiveAuthenticationManager(managers);
}
}
十一、测试
首先没有登录访问服务

然后登录?

访问服务

访问另一个接口

|