上篇文章主要讲解Oauth2模块、user-service模块、feign模块,那么作为重中之重的gateway,我们将其做成资源服务器来进行开发。
一、资源服务器的实现方式
资源服务器在实际开发有两种实现方式:
(1)gateway做网关转发,不做资源服务器,由各个微服务模块自己去做资源服务器;
(2)gateway做网关转发 并且 做资源服务器。
前者方案使得每一个微服务模块都需要导入oauth2相关依赖,并且做处理,过于繁琐且耦合高。
所以本文章在接下来介绍,也就是文章的重点,并且会介绍到如何解决通过gateway去认证授权,跳转到oauth2认证授权后,跳转不回或重定向不到gatway的bug。
二、gateway模块
1、模块结构
2、pom
<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--加载bootstrap 文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--客户端负载均衡loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.white</groupId>
<artifactId>common</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
3、bootstrap
server:
port: 10000
spring:
application:
name: gateway
profiles:
active: dev
cloud:
gateway:
routes:
- id: user
uri: lb://user-service # 客户端负载均衡 loadbalancer
predicates:
- Path=/user/**,/admin/**
- id: order
uri: lb://order-service
predicates:
- Path=/order/**
- id: oauth
uri: lb://oauth-service
predicates:
- Path=/uaa/**
nacos:
discovery:
server-addr: localhost:8848
redis:
host: 127.0.0.1
port: 6379
security:
oauth2:
resourceserver:
jwt:
#配置RSA的公钥访问地址 端口对应上篇文章的oauth2模块服务的端口
jwk-set-uri: 'http://localhost:8101/uaa/rsa/publicKey'
main:
web-application-type: reactive
4.GatewayApp启动类
@SpringBootApplication(exclude= {DataSourceAutoConfiguration.class})
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GatewayApp
{
public static void main( String[] args )
{
SpringApplication.run(GatewayApp.class,args);
}
}
5.IgnoreUrlsConfig
package com.white.gateway.config;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class IgnoreUrlsConfig {
public List<String> getUrls() {
ArrayList<String> objects = new ArrayList<>();
objects.add("/uaa/**");
objects.add("/user/**");
return objects;
}
}
6.IgnoreUrlsRemoveJwtFilter
package com.white.gateway.filter;
import com.white.gateway.config.IgnoreUrlsConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import var.TokenVar;
import java.net.URI;
import java.util.List;
/**
* 白名单路径访问时需要移除JWT请求头
*/
@Component
public class IgnoreUrlsRemoveJwtFilter implements WebFilter {
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
URI uri = request.getURI();
PathMatcher pathMatcher = new AntPathMatcher();
//白名单路径移除JWT请求头
List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
for (String ignoreUrl : ignoreUrls) {
if (pathMatcher.match(ignoreUrl, uri.getPath())) {
request = request.mutate().header(TokenVar.TOKEN_HEAD, "").build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}
}
return chain.filter(exchange);
}
}
7.AuthGlobalFilter
注意:这里拦截了路径为/oauth/authorize,在其进行跳转的时候构建响应包装类,解决通过gateway去oauth认证时,oauth成功登录后跳转不回gateway网关的bug。
package com.white.gateway.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.hutool.json.JSONObject;
import com.alibaba.cloud.commons.lang.StringUtils;
import com.nimbusds.jose.JWSObject;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import var.TokenVar;
import java.text.ParseException;
import java.util.Objects;
/**
* 将登录用户的JWT转化成用户信息的全局过滤器
*/
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//TODO
String path = exchange.getRequest().getPath().value();
System.out.println("拦截到的路径:::" + path);
if (path.contains("/oauth/authorize") || path.contains("/auth/authorize") || path.contains("/auth/loginBySms")) {
//构建响应包装类
HttpResponseDecorator responseDecorator = new HttpResponseDecorator(exchange.getRequest(), exchange.getResponse(), "http://localhost:10000");
return chain
.filter(exchange.mutate().response(responseDecorator).build());
}
//认证信息从Header 或 请求参数 中获取
ServerHttpRequest serverHttpRequest = exchange.getRequest();
String token = serverHttpRequest.getHeaders().getFirst(TokenVar.TOKEN_HEAD);
if (Objects.isNull(token)) {
token = serverHttpRequest.getQueryParams().getFirst(TokenVar.TOKEN_HEAD);
}
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
try {
//从token中解析用户信息并设置到Header中去
String realToken = token.replace(TokenVar.TOKEN_PREFIX, "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
// 黑名单token(登出、修改密码)校验
JSONObject jsonObject = JSONUtil.parseObj(userStr);
String jti = jsonObject.getStr("jti");
Boolean isBlack = redisTemplate.hasKey(TokenVar.TOKEN_BLACKLIST_PREFIX + jti);
if (isBlack) {
}
// 存在token且不是黑名单,request写入JWT的载体信息
ServerHttpRequest request = serverHttpRequest.mutate().header(TokenVar.USER_TOKEN_HEADER, userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
e.printStackTrace();
}
return chain.filter(exchange);
}
public class HttpResponseDecorator extends ServerHttpResponseDecorator {
private String proxyUrl;
private ServerHttpRequest request;
public HttpResponseDecorator(ServerHttpRequest request, ServerHttpResponse delegate, String proxyUrl) {
super(delegate);
this.request = request;
this.proxyUrl = proxyUrl;
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
HttpStatus status = this.getStatusCode();
if (status.equals(HttpStatus.FOUND)) {
String domain = "";
if (StringUtils.isBlank(proxyUrl)) {
domain = request.getURI().getScheme() + "://" + request.getURI().getAuthority();
} else {
domain = proxyUrl;
}
String location = getHeaders().getFirst("Location");
String replaceLocation = location.replaceAll("^((ht|f)tps?):\\/\\/(\\d{1,3}.){3}\\d{1,3}(:\\d+)?", domain);
getHeaders().set("Location", replaceLocation);
}
this.getStatusCode();
return super.writeWith(body);
}
}
@Override
public int getOrder() {
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
}
}
8.ResourceServerManager
注意:这里的鉴权采用了对redis数据进行读取后,匹配当前的路径和请求方式是否与redis数据中一致,在此前提下判断当前的token是否与redis中存放的身份权限一致或包含其中,如果包含或一致才可以放行去请求资源,否则请求资源失败。
至于redis的数据从哪来,下面程序中是模拟添加了一个,实际开发中,在MySQL数据库创建一张相应的请求方式+路径,以及请求时用户必须要有的权限是什么的数据表,通过初次查询缓存到redis中,之后就可以通过redis进行数据读取了,内存速度快。
package com.white.gateway.config;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import jdk.nashorn.internal.runtime.GlobalConstants;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @ResourceServerManager.java的作用:鉴权管理器的相关配置
* 负责被ResourceServerConfig.java文件引用
* @author: white文
* @time: 2023/5/30 0:57
*/
@Component
@AllArgsConstructor
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Autowired
private RedisTemplate redisTemplate;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
if (request.getMethod() == HttpMethod.OPTIONS) { // 预检请求放行
return Mono.just(new AuthorizationDecision(true));
}
PathMatcher pathMatcher = new AntPathMatcher();
String method = request.getMethodValue();
String path = request.getURI().getPath();
String restfulPath = method + ":" + path; // RESTFul接口权限设计: https://www.cnblogs.com/haoxianrui/p/14961707.html
String token = request.getHeaders().getFirst("Authorization");
// 如果token为空 或 不是以"bearer "为前缀 则无效并且需要鉴权
if (!StrUtil.isNotBlank(token) || !StrUtil.startWithIgnoreCase(token, "Bearer ") ) {
log.info("token为空 或 不是以 bearer 为前缀 则无效并且需要鉴权");
return Mono.just(new AuthorizationDecision(false));
}
log.info("鉴权开始");
/**
* 鉴权开始
*
* 缓存取 [URL权限-角色集合] 规则数据
* urlPermRolesRules = [{'key':'GET:/admin/*','value':['ADMIN','TEST']},...]
*/
Map<String, Object> urlPermRolesRules = redisTemplate.opsForHash().entries("auth:resourceRolesMap");
if (urlPermRolesRules.isEmpty()) {
log.info("空的,我手动加一些上去");
ArrayList<String> objects = new ArrayList<>();
objects.add("TEST");
objects.add("USER");
redisTemplate.opsForHash().put("auth:resourceRolesMap","GET:/admin/*",objects);
urlPermRolesRules = redisTemplate.opsForHash().entries("auth:resourceRolesMap");
}
// 根据请求路径获取有访问权限的角色列表
List<String> authorizedRoles = new ArrayList<>(); // 拥有访问权限的角色
boolean requireCheck = false; // 是否需要鉴权,默认未设置拦截规则不需鉴权
for (Map.Entry<String, Object> permRoles : urlPermRolesRules.entrySet()) {
String perm = permRoles.getKey();
System.out.println("路径:"+perm+" 值:"+permRoles.getValue().toString());
// 判断传过来的 方法:路径 是否在redis缓存中
if (pathMatcher.match(perm, restfulPath)) {
List<String> roles = Convert.toList(String.class, permRoles.getValue());
// 加入授权数组中
authorizedRoles.addAll(roles);
if (requireCheck == false) {
requireCheck = true;
}
}
}
// 没有设置拦截规则放行
if (requireCheck == false) {
return Mono.just(new AuthorizationDecision(true));
}
// 判断JWT中携带的用户角色是否有权限访问
Mono<AuthorizationDecision> authorizationDecisionMono = mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authority -> {
String roleCode = StrUtil.removePrefix(authority,"ROLE_");// ROLE_ADMIN移除前缀ROLE_得到用户的角色编码ADMIN
if (String.valueOf("ADMIN").equals(roleCode)) {
return true; // 如果是超级管理员则放行
}
boolean hasAuthorized = CollectionUtil.isNotEmpty(authorizedRoles) && authorizedRoles.contains(roleCode);
return hasAuthorized;
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
return authorizationDecisionMono;
}
}
9.ResourceServerConfig
在该文件下,如果想实现 token无效或者已过期自定义响应(ServerAuthenticationEntryPoint) 和 自定义未授权响应(ServerAccessDeniedHandler) 的话,可以自定义配置返回前端的相关配置,以下代码没做实现,采用程序默认返回给前端的401,如下效果:
package com.white.gateway.config;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ArrayUtil;
import com.white.gateway.filter.IgnoreUrlsRemoveJwtFilter;
import lombok.Setter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;
import java.security.interfaces.RSAPublicKey;
import java.util.List;
/**
* 资源服务器配置
*/
@Configuration
@EnableWebFluxSecurity
@Slf4j
public class ResourceServerConfig {
@Autowired
private ResourceServerManager resourceServerManager;
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Autowired
private IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
//TODO 对白名单路径,直接移除JWT请求头
http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
//白名单配置
.pathMatchers(Convert.toStrArray(ignoreUrlsConfig.getUrls())).permitAll()
//鉴权管理器配置
.anyExchange().access(resourceServerManager)
.and().csrf().disable();
return http.build();
}
/**
* @link https://blog.csdn.net/qq_24230139/article/details/105091273
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
* 需要把jwt的Claim中的authorities加入
* 方案:重新定义权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
10.RedisConfig
package com.white.gateway.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import javax.annotation.Resource;
/*
* Redis配置
* 解决redis在业务逻辑处理层RedisCon上不出错,缓存序列化问题
* @author: white
* */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
@Resource
RedisConnectionFactory redisConnectionFactory;
@Bean
public RedisTemplate<String,Object> redisTemplate(){
System.out.println("gateway 读取redis配置");
RedisTemplate<String,Object> redisTemplate=new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
//Json序列化配置
//1、String的序列化
StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
// key采用String的序列化方式
redisTemplate.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
redisTemplate.setHashKeySerializer(stringRedisSerializer);
//2、json解析任意的对象(Object),变成json序列化
Jackson2JsonRedisSerializer<Object> serializer=new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper mapper=new ObjectMapper(); //用ObjectMapper进行转义
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//该方法是指定序列化输入的类型,就是将数据库里的数据按照一定类型存储到redis缓存中。
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
// value序列化方式采用jackson
redisTemplate.setValueSerializer(serializer);
// hash的value序列化方式采用jackson
redisTemplate.setHashValueSerializer(serializer);
return redisTemplate;
}
}
11、TokenVar
package var;
public interface TokenVar {
public static final String APP_SECRET ="white";
public static final String TOKEN_HEAD="Authorization"; // 认证信息Http请求头
public static final String TOKEN_PREFIX = "Bearer "; // JWT令牌前缀
/**
* JWT存储权限前缀
*/
String AUTHORITY_PREFIX = "ROLE_";
/**
* JWT存储权限属性
*/
String AUTHORITY_CLAIM_NAME = "authorities";
/**
* 后台client_id
*/
String ADMIN_CLIENT_ID = "api-admin";
/**
* 前端client_id
*/
String PORTAL_CLIENT_ID = "api-portal";
/**
* 后台接口路径匹配
*/
String ADMIN_URL_PATTERN = "/admin/**";
/**
* Redis缓存权限规则key
*/
String RESOURCE_ROLES_MAP_KEY = "auth:resourceRolesMap";
/**
* 用户信息Http请求头
*/
String USER_TOKEN_HEADER = "user";
/**
* 黑名单
*/
String TOKEN_BLACKLIST_PREFIX = "blacklist";
}
三、测试
与第二篇文章的测试相同,只不过我们将地址的端口改成10000,意思是通过网关去请求oauth认证授权。
1、请求授权码
http://localhost:10000/uaa/oauth/authorize?client_id=123&response_type=code&scop=all&redirect_uri=http://localhost:10000
自动跳转到:http://localhost:10000/uaa/login
2、请求令牌
3、验证令牌
4、刷新令牌
5.请求资源,不带token
在前面的过滤中,我们仅仅对/user/**,/uaa/**两个路径进行白名单路径设置,而在user-service模块中,小编还设置一个/admin/**的一个路径,该路径没有被设置进白名单,并且在redis的设置中,该路径需要权限为:admin。
现在不带token来请求/admin/1,如下,结果为401:
6.请求资源,带有token
现在我们带着token,并且token中的用户信息权限为admin
四、进一步调优
到此,gateway+oauth整合完成了,接下来会在评论区出下一章,下一章会针对oauth的推出登录如何解决以及如何整合第三方应用进行登录(如:gitee平台)。