1. 为什么需要网关与JWT的统一认证?

如果你正在搭建一个微服务系统,可能会遇到一个很头疼的问题:每个服务都要自己处理用户登录和权限检查。想象一下,你的系统有用户服务、订单服务、商品服务,用户想买个东西,得先在用户服务登录,然后订单服务还得再问一遍“你是谁?”,商品服务又来一次……这不仅麻烦,而且安全漏洞也多,性能也上不去。

我刚开始做微服务那会儿,就踩过这个坑。当时每个服务都自己搞一套Spring Security,配置起来简直是一场噩梦,改个密码策略要改五六个地方。后来发现,Spring Cloud GatewayJWT 这对组合,简直就是微服务认证的“黄金搭档”。

简单来说,Spring Cloud Gateway 就像你家小区的门卫,所有访客(请求)都得先经过他。而 JWT 就是门卫发给访客的“临时通行证”。访客拿着这个通行证去拜访小区里的任何一家(微服务),那家人只需要看一眼通行证是不是门卫发的(验证签名),就知道该不该放行,而不用每次都打电话去门卫室查户口。

这样做的好处太明显了:认证逻辑集中了,所有服务都不用再操心用户是谁;性能提升了,服务端无需维护会话状态,无状态验证速度快;安全性也好了,令牌自包含、防篡改。这套体系,就是我们今天要聊的 高效微服务认证体系 的核心。

2. 环境准备与基础服务搭建

工欲善其事,必先利其器。在开始写代码之前,咱们得先把“厨房”收拾好。这里主要需要三个基础服务:Redis、Nacos注册中心和我们的认证服务。

2.1 Redis的安装与启动

Redis在这里扮演的是“缓存管家”的角色,主要用来存储资源与角色的对应关系。比如“/api/admin”这个接口需要ADMIN角色才能访问,这个映射关系就放在Redis里,网关鉴权时查起来飞快。

在Windows上安装Redis其实很简单,我习惯用免安装版。去官网下载压缩包,解压到一个你喜欢的目录,比如 F:\Redis。然后做两件事:

  1. redis-server.exe 所在的路径(比如 F:\Redis)添加到系统的环境变量 Path 里。这样以后在任意命令行都能直接启动Redis了。
  2. 为了方便,我通常会写一个启动脚本。在Redis目录下新建一个 start-redis.bat 文件,内容如下:
    @echo off
    F:
    cd F:\Redis
    redis-server.exe redis.windows.conf
    
    双击这个脚本,就能看到Redis服务成功启动的界面了。如果需要连接Redis进行操作,可以另开一个命令行,输入 redis-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。这个类要完成三件大事:

  1. 配置客户端信息:定义哪些客户端应用(比如我们的前端或移动端)可以来申请令牌。这里我们用内存存储,配置了一个 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天
    }
    
  2. 配置令牌端点与增强器:指定认证管理器、用户详情服务和令牌的存储、增强方式。我们要把令牌存储为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());
    }
    
  3. 配置令牌端点的安全约束:比如允许客户端以表单形式进行认证。
    @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-gatewaypom.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: admin
    • password: 123456
    • grant_type: password
    • client_id: client-app
    • client_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 拥有 ADMINUSER 角色,而该接口要求 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_token
    • refresh_token: <你之前获取的refresh_token>
    • client_id: client-app
    • client_secret: 123456

成功后会返回一组新的 access_tokenrefresh_token

6.5 常见问题与排查

在实际集成中,你可能会遇到一些坑,我这里分享几个最常见的:

  1. 网关返回401 Unauthorized

    • 检查点:首先确认请求是否在白名单内。如果在,检查 IgnoreUrlsRemoveJwtFilter 是否生效,是否成功清空了 Authorization 头。
    • 检查点:如果不在白名单,检查请求头 Authorization 的格式是否正确,必须是 Bearer <token>
    • 检查点:检查认证服务的公钥接口 http://localhost:9401/rsa/publicKey 是否能正常访问,网关配置的 jwk-set-uri 是否正确。
    • 检查点:检查JWT令牌是否已过期。
  2. 网关返回403 Forbidden

    • 检查点:这说明令牌有效,但权限不足。首先去Redis里用命令 HGETALL AUTH:RESOURCE_ROLES_MAP 查看你请求的路径对应的角色列表是否正确。
    • 检查点:在认证服务日志中,查看用户登录时加载的角色是否正确。
    • 检查点:在网关的 AuthorizationManager 中加调试日志,打印出从Redis查到的 requiredRoles 和从令牌中解析出的用户权限,进行比对。
  3. Redis连接或数据问题

    • 检查点:确认Redis服务是否运行,网络是否通畅。
    • 检查点:确认认证服务的 ResourceServiceImpl 是否成功执行,数据是否写入Redis。可以重启认证服务观察日志。
    • 检查点:注意网关和认证服务使用的Redis序列化方式。如果网关用 StringRedisTemplate,而认证服务用默认的 RedisTemplate<Object, Object> 写入,可能会导致读取失败。建议统一配置序列化器。
  4. 路由匹配问题

    • 检查点:请求路径是否完全匹配网关路由配置中的 Path。注意大小写(lower-case-service-id: true 只影响服务名)。使用 StripPrefix 过滤器时,转发给下游服务的路径是否正确。

我印象最深的一次排查,是网关一直返回403,但Redis里明明有数据。折腾了半天才发现,是网关鉴权管理器里从Redis取数据时,用的Key和认证服务存入的Key大小写不一致。认证服务存的是 /user-serv,网关拼出来的Key是 /user-Serv。所以,在微服务环境下,路径规范非常重要,建议统一使用小写和连字符。

7. 进阶:扩展与优化思路

基础功能跑通后,我们可以考虑一些进阶的优化和扩展,让这套体系更健壮、更易用。

7.1 集成第三方登录(如短信/邮箱验证码)

很多场景下,我们不仅需要账号密码登录,还需要短信验证码或邮箱验证码登录。这可以在OAuth2的框架下,通过自定义 授权模式(Grant Type) 来实现。

  1. 在认证服务端:你需要实现一个 TokenGranter,例如叫 SmsTokenGranter。在这个Granter里,验证手机号和短信验证码的有效性(通常需要查Redis),验证通过后,调用 UserDetailsService 加载对应用户(可能需要根据手机号找到用户),然后沿用现有的流程颁发令牌。
  2. 在客户端配置:在 Oauth2ServerConfigconfigure(ClientDetailsServiceConfigurer clients) 方法中,为你信任的客户端添加新的授权类型,比如 .authorizedGrantTypes("password", "refresh_token", "sms")
  3. 请求方式:前端发送请求时,grant_type 参数改为 sms,并带上 phonecode 参数。

这种方式对现有密码模式侵入最小,扩展性强。

7.2 令牌黑名单与强制下线

JWT令牌一旦签发,在过期前无法主动失效,这是它的一个缺点。如果用户修改了密码,或者管理员想强制某个用户下线,就需要引入 令牌黑名单 机制。

  1. 实现思路:在用户登出、修改密码或管理员强制下线时,将尚未过期的令牌的JTI(JWT ID)或整个令牌签名存入Redis,并设置一个过期时间(略长于令牌剩余有效期)。
  2. 网关校验:在网关的全局过滤器或自定义的 ReactiveAuthenticationManager 中,在验证JWT签名通过后,额外增加一步:检查当前令牌是否在黑名单中。如果在,则直接返回401。
  3. 性能考虑:每次请求都查一次Redis,会带来额外开销。可以考虑使用布隆过滤器进行初步过滤,或者只对敏感操作进行黑名单校验。

7.3 细粒度权限控制(URL+Method)

我们目前只控制了URL级别的角色权限。有时,同一个URL的不同HTTP方法(GET, POST, PUT, DELETE)可能需要不同的权限。例如,GET /api/users 可能所有登录用户可看,而 POST /api/users 只有管理员能操作。

这需要对权限存储结构和鉴权逻辑进行升级:

  1. 存储结构:Redis中存储的Key可以设计为 METHOD:URL,例如 GET:/api/users,Value为角色列表。
  2. 鉴权逻辑:在网关的 AuthorizationManager 中,除了获取请求路径 uri.getPath(),还要获取请求方法 exchange.getRequest().getMethod(),然后拼接成Key去Redis查询。
  3. 初始化:认证服务初始化权限数据时,也需要按此格式写入。

7.4 网关集群与令牌存储

在生产环境中,网关通常是多实例部署的。我们目前将用户信息(如用户名)放在请求头里传递给下游服务。如果下游服务需要更丰富的用户信息(比如昵称、头像),每次都解析JWT或调用用户服务查询,会有性能或网络开销。

一个优化方案是引入一个共享缓存(如Redis)作为 用户信息会话存储

  1. 在网关的 AuthGlobalFilter 中,解析JWT得到用户ID后,以用户ID为Key,将完整的用户信息对象(从JWT或用户服务获取)存入Redis,并设置一个较短的过期时间(如5分钟)。
  2. 同时,将一个简短的会话ID(如UUID)放入请求头传递给下游服务。
  3. 下游服务收到请求后,凭这个会话ID去Redis中获取完整的用户信息。

这样做的好处是:用户信息在网关层集中缓存和管理,下游服务获取信息快,且网关集群间无需同步数据(因为共用Redis)。缺点是架构变得稍复杂,引入了缓存一致性问题。

踩过几次坑之后,我的经验是:不要过度设计。如果下游服务只需要用户ID,那么传递ID就足够了。只有当多个服务频繁需要大量用户信息时,才考虑引入会话缓存。微服务的设计原则之一是“智能端点,哑管道”,尽量让服务自治,减少对网关的依赖。

更多推荐