SpringCloudGateway与JWT集成:构建高效微服务认证体系
1. 为什么需要网关与JWT的统一认证?
如果你正在搭建一个微服务系统,可能会遇到一个很头疼的问题:每个服务都要自己处理用户登录和权限检查。想象一下,你的系统有用户服务、订单服务、商品服务,用户想买个东西,得先在用户服务登录,然后订单服务还得再问一遍“你是谁?”,商品服务又来一次……这不仅麻烦,而且安全漏洞也多,性能也上不去。
我刚开始做微服务那会儿,就踩过这个坑。当时每个服务都自己搞一套Spring Security,配置起来简直是一场噩梦,改个密码策略要改五六个地方。后来发现,Spring Cloud Gateway 和 JWT 这对组合,简直就是微服务认证的“黄金搭档”。
简单来说,Spring Cloud Gateway 就像你家小区的门卫,所有访客(请求)都得先经过他。而 JWT 就是门卫发给访客的“临时通行证”。访客拿着这个通行证去拜访小区里的任何一家(微服务),那家人只需要看一眼通行证是不是门卫发的(验证签名),就知道该不该放行,而不用每次都打电话去门卫室查户口。
这样做的好处太明显了:认证逻辑集中了,所有服务都不用再操心用户是谁;性能提升了,服务端无需维护会话状态,无状态验证速度快;安全性也好了,令牌自包含、防篡改。这套体系,就是我们今天要聊的 高效微服务认证体系 的核心。
2. 环境准备与基础服务搭建
工欲善其事,必先利其器。在开始写代码之前,咱们得先把“厨房”收拾好。这里主要需要三个基础服务:Redis、Nacos注册中心和我们的认证服务。
2.1 Redis的安装与启动
Redis在这里扮演的是“缓存管家”的角色,主要用来存储资源与角色的对应关系。比如“/api/admin”这个接口需要ADMIN角色才能访问,这个映射关系就放在Redis里,网关鉴权时查起来飞快。
在Windows上安装Redis其实很简单,我习惯用免安装版。去官网下载压缩包,解压到一个你喜欢的目录,比如 F:\Redis。然后做两件事:
- 把
redis-server.exe所在的路径(比如F:\Redis)添加到系统的环境变量Path里。这样以后在任意命令行都能直接启动Redis了。 - 为了方便,我通常会写一个启动脚本。在Redis目录下新建一个
start-redis.bat文件,内容如下:
双击这个脚本,就能看到Redis服务成功启动的界面了。如果需要连接Redis进行操作,可以另开一个命令行,输入@echo off F: cd F:\Redis redis-server.exe redis.windows.confredis-cli -h 127.0.0.1 -p 6379。如果遇到中文显示乱码,记得加上--raw参数:redis-cli --raw -h 127.0.0.1 -p 6379。
2.2 创建认证服务模块
认证服务是整个体系的心脏,它负责验证用户身份并签发JWT令牌。我们创建一个独立的Spring Boot应用,我给它起名叫 service-oauth2-auth。
首先在 pom.xml 里把需要的依赖都引进来。核心依赖就这几类:
- Spring Security & OAuth2:认证授权的基石。
- JWT相关:我们用
nimbus-jose-jwt这个库来处理JWT的生成和解析,它比JJWT功能更全,社区也更活跃。 - Redis:用来做缓存。
- Nacos:服务注册与发现。
<dependencies>
<!-- Web基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 安全框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 认证服务器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
<!-- JWT处理 -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.8.1</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Nacos 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.6.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
接着是配置文件 application.yml。这里主要配置服务端口、应用名、数据库连接(如果你用户信息存在数据库)、Redis连接和Nacos地址。注意 spring.application.name 很重要,这是服务在Nacos里的名字。
server:
port: 9401
spring:
application:
name: service-oauth2-auth
cloud:
nacos:
discovery:
server-addr: localhost:8848
redis:
host: localhost
port: 6379
database: 0
# password: 你的密码,如果没有就注释掉
2.3 生成JWT密钥对
JWT令牌的安全性,很大程度上依赖于签名密钥。我们采用非对称加密(RSA),生成一对公私钥。私钥由认证服务保管,用来签名令牌;公钥则公开给网关和其他资源服务,用来验签。
用Java自带的 keytool 工具生成一个JKS格式的密钥库非常方便:
keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
执行这条命令后,会交互式地让你输入密钥库密码、名字与姓氏等信息。我一般设置密钥库密码和密钥密码都为 123456(生产环境一定要用强密码!),其他信息按提示填写或直接回车用默认值。生成 jwt.jks 文件后,把它放到认证服务项目的 src/main/resources 目录下。
3. 核心:实现OAuth2认证服务器
认证服务最核心的部分就是配置OAuth2授权服务器。这里我们会用到Spring Security OAuth2的扩展包,它虽然已经进入维护模式,但在Spring Cloud Greenwich/2020.0.0之前的版本中,依然是构建授权服务器的标准选择。
3.1 加载用户信息
首先,我们需要告诉Spring Security如何根据用户名找到用户。这通过实现 UserDetailsService 接口来完成。我创建了一个 UserServiceImpl,在项目启动时,我模拟了两个用户数据(实际项目中肯定是从数据库查)。
@Service
public class UserServiceImpl implements UserDetailsService {
private List<UserDTO> userList;
@Autowired
private PasswordEncoder passwordEncoder;
@PostConstruct
public void initData() {
// 模拟两个用户,实际应从数据库加载
String password = passwordEncoder.encode("123456");
userList = new ArrayList<>();
userList.add(new UserDTO(1L, "admin", password, 1, CollUtil.toList("ADMIN")));
userList.add(new UserDTO(2L, "user", password, 1, CollUtil.toList("USER")));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查找用户
List<UserDTO> findUserList = userList.stream()
.filter(item -> item.getUsername().equals(username))
.collect(Collectors.toList());
if (CollUtil.isEmpty(findUserList)) {
throw new UsernameNotFoundException("用户不存在");
}
// 将我们的UserDTO转换为Spring Security认识的SecurityUser
SecurityUser securityUser = new SecurityUser(findUserList.get(0));
// 这里可以检查用户状态,如是否锁定、过期等
if (!securityUser.isEnabled()) {
throw new DisabledException("账户已被禁用");
}
// ... 其他状态检查
return securityUser;
}
}
这里的 SecurityUser 类实现了 UserDetails 接口,它包装了我们的用户信息(ID、用户名、密码、权限列表等),是Spring Security内部认证流程中传递的对象。
3.2 配置授权服务器
重头戏来了,创建 Oauth2ServerConfig 类,继承 AuthorizationServerConfigurerAdapter。这个类要完成三件大事:
- 配置客户端信息:定义哪些客户端应用(比如我们的前端或移动端)可以来申请令牌。这里我们用内存存储,配置了一个
client-app。@Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("client-app") // 客户端ID .secret(passwordEncoder.encode("123456")) // 客户端密钥,必须加密 .scopes("all") // 授权范围 .authorizedGrantTypes("password", "refresh_token") // 支持的授权模式:密码模式和刷新令牌 .accessTokenValiditySeconds(3600) // 访问令牌有效期1小时 .refreshTokenValiditySeconds(86400); // 刷新令牌有效期1天 } - 配置令牌端点与增强器:指定认证管理器、用户详情服务和令牌的存储、增强方式。我们要把令牌存储为JWT格式,并加入自定义信息(比如用户ID)。
其中,@Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { TokenEnhancerChain enhancerChain = new TokenEnhancerChain(); List<TokenEnhancer> delegates = new ArrayList<>(); delegates.add(jwtTokenEnhancer); // 自定义增强器,用于添加额外信息 delegates.add(accessTokenConverter()); // JWT转换器 enhancerChain.setTokenEnhancers(delegates); endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .accessTokenConverter(accessTokenConverter()) .tokenEnhancer(enhancerChain); }accessTokenConverter()方法配置了JWT转换器,并设置了之前生成的密钥对。@Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setKeyPair(keyPair()); return converter; } @Bean public KeyPair keyPair() { // 从classpath下的jwt.jks文件中加载密钥对 KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory( new ClassPathResource("jwt.jks"), "123456".toCharArray()); return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray()); } - 配置令牌端点的安全约束:比如允许客户端以表单形式进行认证。
@Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.allowFormAuthenticationForClients(); // 允许客户端表单认证 security.tokenKeyAccess("permitAll()"); // 公开获取公钥的端点 }
3.3 暴露公钥接口
网关服务需要RSA公钥来验证JWT令牌的签名。所以认证服务需要提供一个接口,把公钥暴露出去。我创建了一个 KeyPairController:
@RestController
public class KeyPairController {
@Autowired
private KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
这个接口返回的是JWK Set格式的公钥信息,这是JWT标准推荐的方式,网关可以直接用这个URL来配置验签。
3.4 配置Spring Security安全规则
最后,我们需要配置Spring Security,允许公钥接口等端点可以被匿名访问,其他端点则需要认证。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/rsa/publicKey").permitAll() // 公钥接口放行
.anyRequest().authenticated() // 其他所有请求需要认证
.and().csrf().disable(); // 禁用CSRF,API服务通常不需要
}
// 暴露AuthenticationManager Bean,供OAuth2配置使用
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
// 配置密码编码器,使用BCrypt强哈希
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
至此,一个完整的OAuth2认证服务器就搭建好了。启动服务后,它已经可以接收密码模式的认证请求,并签发JWT令牌了。
4. 构建网关:统一的认证与鉴权入口
现在,认证服务已经能发“通行证”了。接下来,我们要在小区门口设立“门卫”——Spring Cloud Gateway。它的职责是:检查每个请求是否持有合法通行证(JWT),并判断持证人是否有权进入他想去的那栋楼(访问某个微服务接口)。
4.1 网关服务基础配置
新建一个网关服务模块,比如叫 service-gateway。pom.xml 的依赖和认证服务有所不同,重点是网关、WebFlux(Gateway基于Reactive编程模型)和OAuth2资源服务器相关的依赖。
<dependencies>
<!-- Gateway 网关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Reactive Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- OAuth2 资源服务器 (用于JWT验签) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- Redis & Nacos (同上) -->
<!-- 其他工具依赖... -->
</dependencies>
在 application.yml 中,配置路由规则和JWT公钥地址是关键:
server:
port: 7000
spring:
application:
name: service-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
discovery:
locator:
enabled: true # 开启从注册中心动态创建路由
routes:
- id: oauth2-auth-route
uri: lb://service-oauth2-auth # 指向认证服务
predicates:
- Path=/auth/** # 匹配 /auth 开头的请求
filters:
- StripPrefix=1 # 去掉路径前缀 /auth,再转发
- id: user-service-route
uri: lb://service-user # 指向用户服务
predicates:
- Path=/user-serv/**
filters:
- StripPrefix=1
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: 'http://localhost:9401/rsa/publicKey' # JWT验签公钥地址
secure:
ignore:
urls: # 白名单,无需认证的路径
- "/auth/oauth/token" # 获取token的接口本身不能要求有token
- "/user-serv/register" # 用户注册接口
- "/v2/api-docs" # API文档
这个配置做了几件事:定义了路由规则,将请求转发到对应的微服务;指定了JWT公钥的获取地址;设置了一个白名单,像登录、注册这种接口不需要携带令牌。
4.2 实现白名单过滤器
白名单配置好了,但网关默认的安全机制还是会去检查JWT。我们需要一个过滤器,在白名单路径的请求到达安全链之前,把请求头里的 Authorization(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();
// 遍历白名单
List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
for (String ignoreUrl : ignoreUrls) {
if (pathMatcher.match(ignoreUrl, uri.getPath())) {
// 如果是白名单路径,清空Authorization头
request = exchange.getRequest().mutate().header("Authorization", "").build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}
}
return chain.filter(exchange);
}
}
IgnoreUrlsConfig 就是一个简单的 @ConfigurationProperties 类,用来接收 secure.ignore.urls 的配置。这个过滤器要放在安全过滤器链的最前面。
4.3 自定义鉴权管理器
这是网关最核心的鉴权逻辑。对于非白名单的请求,网关已经通过 jwk-set-uri 配置验证了JWT令牌的签名有效性(即令牌是认证服务颁发的)。接下来,我们需要判断这个令牌对应的用户,是否有权限访问他请求的接口。
我们实现一个 ReactiveAuthorizationManager,从Redis中读取“资源-角色”的映射关系,然后与当前令牌中的用户角色进行比对。
@Component
@Slf4j
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
// 1. 获取当前请求的路径
URI uri = authorizationContext.getExchange().getRequest().getURI();
String path = uri.getPath();
log.info("鉴权检查路径: {}", path);
// 2. 从Redis中查询该路径需要的角色列表
// 这里我设计了一个小技巧:支持路径前缀匹配。
// 例如请求 /user-serv/profile/details,会依次查询 /user-serv, /user-serv/profile 等键,直到找到匹配的规则。
Object obj = null;
StringBuilder uriStr = new StringBuilder();
for (String segment : path.split("/")) {
if (!segment.isEmpty()) {
uriStr.append("/").append(segment);
obj = redisTemplate.opsForHash().get(RedisConstant.RESOURCE_ROLES_MAP, uriStr.toString());
if (obj != null) {
break;
}
}
}
if (obj == null) {
// 如果没有配置该资源的权限规则,默认拒绝访问(安全第一原则)
return Mono.just(new AuthorizationDecision(false));
}
// 3. 将Redis中存储的角色字符串转换为Spring Security需要的格式(ROLE_前缀)
List<String> requiredRoles = Convert.toList(String.class, obj);
requiredRoles = requiredRoles.stream()
.map(role -> AuthConstant.AUTHORITY_PREFIX + role)
.collect(Collectors.toList());
// 4. 获取当前认证用户的权限,并与所需权限进行比对
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(requiredRoles::contains) // 用户权限列表中是否包含任意一个所需角色
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
这里用到的 RedisConstant.RESOURCE_ROLES_MAP 是一个常量,值为 "AUTH:RESOURCE_ROLES_MAP",它是Redis中一个Hash结构的键,存储了所有受保护接口路径与所需角色的映射。这个映射数据是由认证服务在启动时初始化进去的(见下文 ResourceServiceImpl)。
4.4 全局过滤器:传递用户信息
JWT验证和鉴权都通过了,我们通常还需要把当前用户的信息(比如用户ID、用户名)传递给下游的微服务,这样业务服务就不用再解析JWT了。我们可以写一个全局过滤器来做这件事。
@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isEmpty(token) || !token.startsWith("Bearer ")) {
return chain.filter(exchange);
}
try {
// 1. 去掉 "Bearer " 前缀,解析JWT
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String payload = jwsObject.getPayload().toString();
log.debug("JWT Payload: {}", payload);
// 2. 从Payload中提取用户信息(例如用户名)
JSONObject userJson = JSONObject.parseObject(payload);
String username = userJson.getString("user_name");
String userId = userJson.getString("id"); // 这是我们在JwtTokenEnhancer中添加的自定义字段
// 3. 将用户信息添加到请求头,传递给下游服务
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Name", username)
.header("X-User-Id", userId)
.build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
log.error("解析JWT令牌失败", e);
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0; // 设置过滤器的执行顺序
}
}
这样,业务服务只需要从请求头 X-User-Id 中就能拿到当前登录用户的ID,非常方便。
4.5 网关安全配置
最后,我们需要在网关服务中启用Spring Security,并将我们自定义的鉴权管理器配置进去。
@Configuration
@EnableWebFluxSecurity // 注意这里是WebFluxSecurity
public class ResourceServerConfig {
@Autowired
private AuthorizationManager authorizationManager;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange()
.pathMatchers("/auth/oauth/token").permitAll() // 放行获取token的端点
.anyExchange().access(authorizationManager) // 其他所有请求交由自定义鉴权管理器处理
.and()
.oauth2ResourceServer() // 启用OAuth2资源服务器配置
.jwt() // 指定使用JWT
.and().and()
.csrf().disable();
return http.build();
}
}
5. 动态权限与多角色支持
在实际项目中,权限和角色关系往往是动态变化的,而且一个系统可能有多种类型的用户(比如学生、企业、管理员)。我们之前的认证服务初始化了静态的用户和权限数据,现在来把它改造成支持数据库和多角色的动态系统。
5.1 在认证服务中初始化资源权限
认证服务启动时,应该从数据库(或配置文件)加载所有需要保护的API接口及其所需的角色,并存入Redis。这样网关才能进行动态鉴权。
我在认证服务里创建了一个 ResourceServiceImpl,使用 @PostConstruct 在启动后执行初始化。
@Service
@Slf4j
public class ResourceServiceImpl {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@PostConstruct
public void initData() {
Map<String, List<String>> resourceRolesMap = new TreeMap<>();
// 这些数据 ideally 应该从数据库读取
// 管理员专属接口
resourceRolesMap.put("/admin-serv/user/list", CollUtil.toList("ADMIN"));
resourceRolesMap.put("/admin-serv/system/config", CollUtil.toList("ADMIN"));
// 企业相关接口
resourceRolesMap.put("/enterprise-serv/profile/update", CollUtil.toList("ENTERPRISE_ADMIN"));
resourceRolesMap.put("/enterprise-serv/job/publish", CollUtil.toList("ENTERPRISE_ADMIN", "ENTERPRISE_HR"));
// 学生相关接口
resourceRolesMap.put("/student-serv/resume/update", CollUtil.toList("STUDENT"));
resourceRolesMap.put("/student-serv/application/submit", CollUtil.toList("STUDENT"));
// 公共接口(已配置在白名单,这里可以不配,或配置为 PUBLIC)
// resourceRolesMap.put("/public/news", CollUtil.toList("PUBLIC"));
// 写入Redis,使用Hash结构,Key是接口路径,Value是角色列表
redisTemplate.opsForHash().putAll(RedisConstant.RESOURCE_ROLES_MAP, resourceRolesMap);
log.info("资源角色映射初始化完成,共 {} 条规则", resourceRolesMap.size());
}
}
5.2 支持多角色用户查询
原来的 UserServiceImpl 只模拟了内存用户。现在我们需要连接数据库,支持查询学生、企业管理员、系统管理员等多种角色用户。
首先,需要引入MyBatis-Plus等持久层框架,并创建对应的实体和Mapper。然后在 loadUserByUsername 方法中,我们需要判断用户名属于哪个角色表。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SecurityUser securityUser;
// 策略:按优先级查询不同表。这里示例顺序:学生 -> 企业 -> 管理员
// 实际可根据业务设计,例如通过用户名前缀或单独的关系表来区分
Student student = studentMapper.selectOne(new QueryWrapper<Student>().eq("username", username));
if (student != null) {
securityUser = new SecurityUser(student); // 使用学生的构造方法
// 更新最后登录时间等...
return securityUser;
}
EnterpriseAdmin enterpriseAdmin = enterpriseAdminMapper.selectOne(...);
if (enterpriseAdmin != null) {
securityUser = new SecurityUser(enterpriseAdmin);
return securityUser;
}
SystemAdmin systemAdmin = systemAdminMapper.selectOne(...);
if (systemAdmin != null) {
securityUser = new SecurityUser(systemAdmin);
return securityUser;
}
throw new UsernameNotFoundException("用户不存在");
}
对应的,SecurityUser 类需要增加多个构造方法,用于将从不同表查询到的实体,转换成统一的 UserDetails 对象。关键是把数据库中的角色字符串(如 "ADMIN,USER")转换成 SimpleGrantedAuthority 集合。
5.3 网关鉴权管理器的优化
网关的 AuthorizationManager 已经实现了从Redis读取规则。当支持多角色后,一个用户可能拥有多个角色(例如既是 ENTERPRISE_ADMIN 又是 ENTERPRISE_HR)。我们的鉴权逻辑 any(requiredRoles::contains) 是判断用户的角色集合中,是否包含任意一个接口要求的角色,这正好满足了多角色的需求。
如果业务上要求必须同时拥有多个角色(AND关系),则需要将逻辑改为判断是否包含所有所需角色。但根据我的经验,接口权限设计成OR关系(拥有任一角色即可访问)更为常见和灵活。
6. 实战测试与问题排查
理论讲完了,是骡子是马得拉出来遛遛。我们启动所有服务,进行端到端的测试。
6.1 获取JWT令牌
首先,确保Nacos、Redis、认证服务、网关服务都已启动。然后,我们使用 密码模式 来获取令牌。
使用Postman或curl,向网关发起请求(注意是网关的地址和端口):
- URL:
POST http://localhost:7000/auth/oauth/token - Header:
Content-Type: application/x-www-form-urlencoded - Body (form-data):
username: adminpassword: 123456grant_type: passwordclient_id: client-appclient_secret: 123456
如果一切正常,你会收到一个JSON响应,其中 data.token 字段就是长长的JWT令牌,data.refreshToken 是刷新令牌。
这里有个关键点:为什么是向 网关地址:7000/auth/oauth/token 发请求,而不是直接向 认证服务:9401/oauth/token 发?因为我们在网关配置了路由 Path=/auth/** 转发到认证服务,并且 StripPrefix=1 去掉了 /auth 前缀。这样做的好处是,对外只暴露网关一个入口,内部服务地址对客户端透明,也更安全。
6.2 使用令牌访问受保护接口
拿到令牌后,访问一个需要权限的接口,比如获取当前用户信息的接口(假设它需要USER角色)。
- URL:
GET http://localhost:7000/user-serv/currentUser - Header:
Authorization: Bearer <你的JWT令牌>
如果用户 admin 拥有 ADMIN 和 USER 角色,而该接口要求 USER 角色,那么请求应该成功,并且你可以在业务服务的控制器里,通过 @RequestHeader("X-User-Id") String userId 拿到网关传递过来的用户ID。
6.3 越权访问测试
现在我们用同一个令牌,去访问一个只允许 ADMIN 角色访问的接口,比如 /admin-serv/system/config。如果配置正确,网关的鉴权管理器会发现该令牌中的角色不包含 ADMIN,从而返回 403 Forbidden 错误。你可以在网关的日志里看到鉴权失败的记录。
6.4 令牌刷新
JWT令牌有过期时间(我们设置了3600秒)。过期后,前端不应该让用户重新登录,而应该使用 refresh_token 来获取新的 access_token。
- URL:
POST http://localhost:7000/auth/oauth/token - Body:
grant_type: refresh_tokenrefresh_token: <你之前获取的refresh_token>client_id: client-appclient_secret: 123456
成功后会返回一组新的 access_token 和 refresh_token。
6.5 常见问题与排查
在实际集成中,你可能会遇到一些坑,我这里分享几个最常见的:
-
网关返回401 Unauthorized:
- 检查点:首先确认请求是否在白名单内。如果在,检查
IgnoreUrlsRemoveJwtFilter是否生效,是否成功清空了Authorization头。 - 检查点:如果不在白名单,检查请求头
Authorization的格式是否正确,必须是Bearer <token>。 - 检查点:检查认证服务的公钥接口
http://localhost:9401/rsa/publicKey是否能正常访问,网关配置的jwk-set-uri是否正确。 - 检查点:检查JWT令牌是否已过期。
- 检查点:首先确认请求是否在白名单内。如果在,检查
-
网关返回403 Forbidden:
- 检查点:这说明令牌有效,但权限不足。首先去Redis里用命令
HGETALL AUTH:RESOURCE_ROLES_MAP查看你请求的路径对应的角色列表是否正确。 - 检查点:在认证服务日志中,查看用户登录时加载的角色是否正确。
- 检查点:在网关的
AuthorizationManager中加调试日志,打印出从Redis查到的requiredRoles和从令牌中解析出的用户权限,进行比对。
- 检查点:这说明令牌有效,但权限不足。首先去Redis里用命令
-
Redis连接或数据问题:
- 检查点:确认Redis服务是否运行,网络是否通畅。
- 检查点:确认认证服务的
ResourceServiceImpl是否成功执行,数据是否写入Redis。可以重启认证服务观察日志。 - 检查点:注意网关和认证服务使用的Redis序列化方式。如果网关用
StringRedisTemplate,而认证服务用默认的RedisTemplate<Object, Object>写入,可能会导致读取失败。建议统一配置序列化器。
-
路由匹配问题:
- 检查点:请求路径是否完全匹配网关路由配置中的
Path。注意大小写(lower-case-service-id: true只影响服务名)。使用StripPrefix过滤器时,转发给下游服务的路径是否正确。
- 检查点:请求路径是否完全匹配网关路由配置中的
我印象最深的一次排查,是网关一直返回403,但Redis里明明有数据。折腾了半天才发现,是网关鉴权管理器里从Redis取数据时,用的Key和认证服务存入的Key大小写不一致。认证服务存的是 /user-serv,网关拼出来的Key是 /user-Serv。所以,在微服务环境下,路径规范非常重要,建议统一使用小写和连字符。
7. 进阶:扩展与优化思路
基础功能跑通后,我们可以考虑一些进阶的优化和扩展,让这套体系更健壮、更易用。
7.1 集成第三方登录(如短信/邮箱验证码)
很多场景下,我们不仅需要账号密码登录,还需要短信验证码或邮箱验证码登录。这可以在OAuth2的框架下,通过自定义 授权模式(Grant Type) 来实现。
- 在认证服务端:你需要实现一个
TokenGranter,例如叫SmsTokenGranter。在这个Granter里,验证手机号和短信验证码的有效性(通常需要查Redis),验证通过后,调用UserDetailsService加载对应用户(可能需要根据手机号找到用户),然后沿用现有的流程颁发令牌。 - 在客户端配置:在
Oauth2ServerConfig的configure(ClientDetailsServiceConfigurer clients)方法中,为你信任的客户端添加新的授权类型,比如.authorizedGrantTypes("password", "refresh_token", "sms")。 - 请求方式:前端发送请求时,
grant_type参数改为sms,并带上phone和code参数。
这种方式对现有密码模式侵入最小,扩展性强。
7.2 令牌黑名单与强制下线
JWT令牌一旦签发,在过期前无法主动失效,这是它的一个缺点。如果用户修改了密码,或者管理员想强制某个用户下线,就需要引入 令牌黑名单 机制。
- 实现思路:在用户登出、修改密码或管理员强制下线时,将尚未过期的令牌的JTI(JWT ID)或整个令牌签名存入Redis,并设置一个过期时间(略长于令牌剩余有效期)。
- 网关校验:在网关的全局过滤器或自定义的
ReactiveAuthenticationManager中,在验证JWT签名通过后,额外增加一步:检查当前令牌是否在黑名单中。如果在,则直接返回401。 - 性能考虑:每次请求都查一次Redis,会带来额外开销。可以考虑使用布隆过滤器进行初步过滤,或者只对敏感操作进行黑名单校验。
7.3 细粒度权限控制(URL+Method)
我们目前只控制了URL级别的角色权限。有时,同一个URL的不同HTTP方法(GET, POST, PUT, DELETE)可能需要不同的权限。例如,GET /api/users 可能所有登录用户可看,而 POST /api/users 只有管理员能操作。
这需要对权限存储结构和鉴权逻辑进行升级:
- 存储结构:Redis中存储的Key可以设计为
METHOD:URL,例如GET:/api/users,Value为角色列表。 - 鉴权逻辑:在网关的
AuthorizationManager中,除了获取请求路径uri.getPath(),还要获取请求方法exchange.getRequest().getMethod(),然后拼接成Key去Redis查询。 - 初始化:认证服务初始化权限数据时,也需要按此格式写入。
7.4 网关集群与令牌存储
在生产环境中,网关通常是多实例部署的。我们目前将用户信息(如用户名)放在请求头里传递给下游服务。如果下游服务需要更丰富的用户信息(比如昵称、头像),每次都解析JWT或调用用户服务查询,会有性能或网络开销。
一个优化方案是引入一个共享缓存(如Redis)作为 用户信息会话存储:
- 在网关的
AuthGlobalFilter中,解析JWT得到用户ID后,以用户ID为Key,将完整的用户信息对象(从JWT或用户服务获取)存入Redis,并设置一个较短的过期时间(如5分钟)。 - 同时,将一个简短的会话ID(如UUID)放入请求头传递给下游服务。
- 下游服务收到请求后,凭这个会话ID去Redis中获取完整的用户信息。
这样做的好处是:用户信息在网关层集中缓存和管理,下游服务获取信息快,且网关集群间无需同步数据(因为共用Redis)。缺点是架构变得稍复杂,引入了缓存一致性问题。
踩过几次坑之后,我的经验是:不要过度设计。如果下游服务只需要用户ID,那么传递ID就足够了。只有当多个服务频繁需要大量用户信息时,才考虑引入会话缓存。微服务的设计原则之一是“智能端点,哑管道”,尽量让服务自治,减少对网关的依赖。
更多推荐
所有评论(0)