(八)Spring Cloud Alibaba 2023.x:网关统一鉴权与登录实现

发布于:2025-09-09 ⋅ 阅读:(24) ⋅ 点赞:(0)

目录

🔆前言

讨论

统一 API 集成 vs 子服务自治

API 模块依赖 vs 自定义 Feign

实践

实现用户服务

1.配置文件设置application.yml

2.pom.xml主要依赖引入

2.实现用户控制器UserController.class并定义接口

3.登录逻辑实现

网关服务实现统一鉴权

1.远程的application-dev.yml路由配置

2.pom.xml引入Spring Security安全框架

3.实现redis序列化配置类 RedisConfig.class

4.实现spring security配置类SecurityConfig.class

5.实现统一登录鉴权过滤器类

实现公共服务cloud-common

1.pom.xml引入依赖

2.redis序列化类型类RedisConfig.class

(与上面的网关中配置的一样)

3.用户上下文工具类 UserContextHolder.class

4.统一请求头过滤器RequestHeaderFilter.class

5.cloud-common公共服务的依赖引入

调试


🔆前言

本系列的 Spring Cloud Alibaba 微服务实践 已经更新到第七篇。最近入职的新项目同样采用了 Spring Cloud Alibaba 体系,但由于项目架构由其他同事预先搭建完成,其中的一些设计与我的理解和最佳实践有所不同。作为后来者,我无法对现有项目做大规模调整,因此选择在个人的开源项目和系列博客中进行补充与优化,以便更贴近标准化的微服务实践,并发挥其应有优势。

前七篇主要介绍了各组件的集成,但要打造一个真正 开箱即用的微服务脚手架,还需要补齐基础功能,让项目在拉取后即可快速进入业务开发。接下来的章节将逐步完善这些能力。

讨论

在动手实现代码之前,需要先明确两个关键问题:

统一 API 集成 vs 子服务自治

第一个需要讨论的问题是在微服务架构中,API 接口该如何管理与使用。这直接关系到后续统一网关鉴权的设计。目前常见的做法主要有两种:

  1. 统一 API 管理服务:所有接口统一在一个服务中定义与实现,具体业务数据再由该服务调用其他子服务完成。(这是当前公司项目采用的方案,由同事在前期搭建完成)

  2. 子服务高度自治:每个子服务独立对外提供接口,接口职责与业务逻辑严格划分,服务之间只通过调用或消息进行交互。(本篇将采用此方案)

对于为什么本篇采用第二种方案,下面总结了几点

1.单点故障风险

  • 统一 API 服务承担所有接口聚合,一旦某台 API 服务故障,它上面承载的所有接口都会不可用,影响项目整体吞吐量。
  • 分而治之,每个业务服务独立运行,单个服务故障只影响该业务相关接口,故障范围小,易排查。

2.运维和扩展困难

  • API 服务水平扩展需要整体重新部署,接口服务都要重新构建,增加运维复杂度。
  • 业务服务独立扩展,根据不同业务压力动态扩容。例如支付压力大,就只扩展支付服务,提高吞吐量更精准。

3.网络请求和延迟问题

  • API 服务聚合逻辑增加了一层网络请求,多次跨服务调用导致响应时间不可预测,延迟不可控。
  • 业务服务内部处理自己的数据,直达调用,延迟更低且可预期。

4.微服务理念和解耦

  • 虽然每个服务都需要独立维护、扩展和配置,但这是微服务的本质——解耦、独立部署、按业务扩容。
  • 运维成本增加是不可避免的,是微服务成熟和可扩展的必经过程。

5.聚合逻辑的权衡

  • API 服务适合轻量级聚合或统一接口出口,但业务逻辑复杂的跨服务聚合,应尽量在业务服务内部完成,避免 API 服务成为性能瓶颈

在微服务项目中,推荐使用子服务自治方案,是为了降低服务耦合、减少单点故障风险、提高扩展性和性能,同时更符合微服务解耦与独立部署的理念。

API 模块依赖 vs 自定义 Feign

第二个需要讨论的问题是:在微服务项目中,如何使用 OpenFeign 调用其他子服务的接口。目前常见的做法主要有两种:

  1. 独立 API 子模块:每个子服务维护一个单独的 API 模块(通常只包含接口定义和对象),其他服务如需调用时,直接引入对应的 API 依赖。这种方式可以避免接口与 DTO 的重复定义,保证调用方与被调用方的一致性。(这是当前公司项目采用的方案,由同事在前期搭建完成)
  2. 调用方自定义 Feign 接口:被调用子服务只需对外暴露 REST 接口,调用方在自身服务中定义 FeignClient 来调用这些接口。与接口相关的 DTO/VO 则可放入公共模块,供双方共享。这种方式更加灵活,但需要调用方自己维护接口定义。(本篇将采用此方案)

对于为什么本篇采用第二种方案,下面总结了几点

  1. 服务耦合性

    API 模块依赖增加服务间耦合,可能引发依赖冲突,维护和排查复杂;自定义 Feign 接口松耦合,只在需要的服务中定义接口,扩展性好。

  2. 版本管理与依赖更新

    API 模块更新需要重新打包并在所有依赖服务中重新加载,否则可能出现版本不一致问题;自定义 Feign 接口避免这种版本冲突,公共模块只提供 DTO/VO 和工具类。

  3. 接口使用灵活性

    API 模块依赖让所有服务都必须引入相同接口,使用不灵活;自定义 Feign 接口只在真正需要的服务中定义,按需使用,符合微服务设计理念。

在微服务架构中,推荐使用 自定义 Feign 接口 的方案。虽然在初期需要多写一些接口定义,但可以保证服务松耦合、扩展性好,并且避免版本依赖冲突,为后续统一网关鉴权和业务扩展提供了稳固基础。


上述问题在当前公司项目中几乎都出现过,确实带来诸多困扰。作为后来者,我只能适应现状并进行改进,希望看到这篇文章的小伙伴在项目初期能够谨慎选择架构方案,从而减少后期维护成本和复杂度。下面正式开始代码实现。

实践

代码实现我们分为三个部分:新增用户服务实现登录功能、网关服务实现统一鉴权、新增公共基础服务

源码获取:GitHub - RemainderTime/spring-cloud-alibaba-base-demo: 基于spring cloud alibaba生态快速构建微服务脚手架

实现用户服务

下面是用户服务的大致结构,本篇对主要逻辑进行代码展示,具体可访问GitHub源码进行获取

注:nacos远程的公共配置文件都放在了项目的doc文件中,可自行导入到自己的配置中心,每个子服务的配置文件都有对应的local版本,也可自行修改为dev后缀导入到配置中心

1.配置文件设置application.yml

spring:
  application:
    name: cloud-user
  profiles:
    active: dev
  cloud:
    nacos:
      discovery: #注册中心
        server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
        username: ${NACOS_USERNAME:nacos}
        password: ${NACOS_PWD:nacos}
        namespace: 74193cd9-fac4-4f2a-addc-47c60508b15c
        cluster-name: DEFAULT  # 集群名称,保持一致
      config:
        server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
        username: ${NACOS_USERNAME:nacos}
        password: ${NACOS_PWD:nacos}
        file-extension: yml
        namespace: 74193cd9-fac4-4f2a-addc-47c60508b15c
  config:
    import:
      - nacos:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
      - nacos:redis-common.yaml

应该发现了我们把nacos配置文件放在了application.yml中,而没有bootstrap.yml配置了,本项目其他子服务的配置文件都进行了优化,具体可以查看项目源码

        因为我们项目使用的Spring boot版本为3.3.5,在Spring Boot 2.4+之后已经废弃bootstrap.yml。原因之一就是Spring Boot 本来有一套 application.yml + spring.profiles.active 的配置体系, bootstrap.yml 算是 Spring Cloud 强行加的,跟 Spring Boot 的设计理念不统一。因此Spring Boot 2.4+之后引入spring.config.import 统一声明外部配置源并且支持 optional:(可选加载,不存在也不会报错)

(如果你偏要使用原来的,则需要单独引入bootstrap的maven依赖也可以实现以前的方式)

下面是代码类基本结构组成

登录用户的token生成使用的是JWT框架,并存储在redis中,这也是比较常用的方案登录方案

2.pom.xml主要依赖引入

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--数据库 mysql-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
        <!-- orm mybatis plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        </dependency>
        <!-- 数据库数据源动态切换组件 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
        </dependency>
        <!-- 公共模块 -->
        <dependency>
            <groupId>com.xf</groupId>
            <artifactId>cloud-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

2.实现用户控制器UserController.class并定义接口

@RestController
@RequestMapping("/user")
public class UserController {

	@Autowired
	private UserService userService;
    /**
     * 登录
     * @param req
     * @return
     */
	@PostMapping("/login")
	public RetObj login(@RequestBody LoginInfoReq req){
		return userService.login(req);
	}

    /**
     * 获取登录用户名称
     * @return
     */
    @GetMapping("/getUserName")
    public RetObj getUserName(){
        return RetObj.success(UserContextHolder.getName());
    }
}

3.登录逻辑实现

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

	@Autowired
	private RedisTemplate redisTemplate;

	@Override
	public RetObj login(LoginInfoReq req) {
		//校验登录账号密码
		LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper();
		queryWrapper.eq(User::getAccount, req.getAccount());
		queryWrapper.eq(User::getPassword, req.getPassword());
		User user = this.baseMapper.selectOne(queryWrapper);
		if (Objects.isNull(user)) {
			return RetObj.error("账号或密码错误");
		}
		LoginUser loginUser = new LoginUser();
		loginUser.setId(user.getId());
		loginUser.setAccount(user.getAccount());
		loginUser.setName(user.getName());
		loginUser.setPhone(user.getPhone());
		//生成token
		String token = JwtTokenUtils.createToken(user.getId());
		loginUser.setToken(token);
		//缓存token
		redisTemplate.opsForValue().set("alibaba-token:" + token, JSON.toJSONString(loginUser), 3600, TimeUnit.SECONDS);
		redisTemplate.opsForValue().set("alibaba_user_login_token:" + user.getId(), token, 3600, TimeUnit.SECONDS);
		return RetObj.success(loginUser);
	}
}

:这里可能以及出现获取用户名称的接口中对象UserContextHolder和redis找不到依赖了,不要着急,因为我们已经把这些提取到了公共服务cloud-common中,后面第三点中会讲到,因为这些功能其他子服务也会用到。

网关服务实现统一鉴权

本微服务项目的实现方案就是,所以接口都先请求到网关服务,然后由网关服务中配置的路由分发到对应的子服务,所以只需要在网关服务中实现统一鉴权,其他子服务不在重复鉴权。

1.远程的application-dev.yml路由配置

server:
  port: 9090
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true  # 开启自动服务发现
      routes:
        - id: cloud-producer
          uri: lb://cloud-producer
          predicates:
            - Path=/cloud-producer/**  # 匹配路径 /test/...
          filters:
            - StripPrefix=1
        - id: cloud-consumer
          uri: lb://cloud-consumer
          predicates:
            - Path=/cloud-consumer/**
          filters:
            - StripPrefix=1
        - id: cloud-user
          uri: lb://cloud-user
          predicates:
            - Path=/cloud-user/**
          filters:
            - StripPrefix=1
    loadbalancer: #开启负载均衡
      nacos:
        enabled: true
      retry:
        enabled: true # 启用负载均衡重试机制 负载均衡策略默认为轮询,想要修改策略新版本中需要手动java代码配置,新版本中配置文件的方式不支持了,更灵活但略复杂
logging:
  level:
    org.springframework.cloud.gateway: DEBUG
    org.springframework.cloud.nacos.discovery: DEBUG

可以看出每个服务都要单独的路由前缀,filters属性的值为- StripPrefix=1,表示路由到具体的服务后会把前缀自动去掉,如登录接口:http://localhost:9090/cloud-user/user/login

2.pom.xml引入Spring Security安全框架

在网关服务原来的基础上引入spring security安全框架,虽然现在只是用作了CSRF的禁用配置接口放行功能,但是如果有小伙伴需要实现权限系统也能很好的实现,并且作为spring全家桶成员也更顺畅友好

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>

网关服务单独引入了redis,因为作为网关服务不和其他业务服务公用cloud- common服务,需要单独处理,因为网关服务和其他业务服务公用的内容很少,并且getaway网关内部使用的是webflux依赖,而业务服务基本都是使用的web依赖,会存在依赖冲突等问题

3.实现redis序列化配置类 RedisConfig.class

@Configuration
public class RedisConfig {
	@Bean
	public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
		RedisTemplate<String, Object> template = new RedisTemplate<>();
		template.setConnectionFactory(connectionFactory);
		// key 使用 String 序列化
		template.setKeySerializer(new StringRedisSerializer());
		template.setHashKeySerializer(new StringRedisSerializer());
		// value 使用 JSON 序列化(推荐 GenericJackson2JsonRedisSerializer)
		GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
		template.setValueSerializer(serializer);
		template.setHashValueSerializer(serializer);
		template.afterPropertiesSet();
		return template;
	}
}

4.实现spring security配置类SecurityConfig.class

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
	@Bean
	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
		http
				.csrf(csrf -> csrf.disable())  // 禁用 CSRF
				.authorizeExchange(exchanges -> exchanges
						.pathMatchers("/**").permitAll() //放行所有接口
						.anyExchange().authenticated()
				);
		return http.build();
	}
}

5.实现统一登录鉴权过滤器类

@Slf4j
@Component
public class AuthFilter implements GlobalFilter, Ordered {
    private static final List<String> EXCLUDE_PATH_LIST = Arrays.asList("/cloud-user/user/login");
    @Resource
    private RedisTemplate redisTemplate;
    private static final String SECRET_KEY = "expected-secret";
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String requestURI = request.getURI().getPath();
        // 白名单直接放行
        if (EXCLUDE_PATH_LIST.stream().anyMatch(requestURI::startsWith)) {
            //重新请求头方法,并设置自定义请求头数据
            ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
                @Override
                public HttpHeaders getHeaders() {
                    HttpHeaders headers = new HttpHeaders();
                    headers.putAll(super.getHeaders());  // 复制原始 headers
                    headers.set("X-Internal-Auth", SECRET_KEY); // 安全加 header
                    return headers;
                }
            };
            return chain.filter(exchange.mutate()
                    .request(decorator)
                    .build());
        }
        // 获取 Token
        String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (token == null || !token.startsWith("Bearer")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        // 校验 Token
        token = token.substring(7);
        String key = "alibaba-token:" + token;
        String userInfoJson = (String) redisTemplate.opsForValue().get(key);
        if (Objects.isNull(userInfoJson)) {
            // 登录校验失败,直接返回 JSON 响应
            String body = "{\"code\":500,\"msg\":\"请先登录\"}";
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
            exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
            return exchange.getResponse().writeWith(Mono.just(buffer));
        }
        // 修改请求头
        ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.putAll(super.getHeaders());  // 复制原始 headers
                headers.set("X-Internal-Auth", SECRET_KEY); // 安全加 header
                //Header 默认只支持 ISO-8859-1,直接放中文 JSON 会被错误解码,所以传输时加密转码UTF-8
                String base64 = Base64.getEncoder().encodeToString(userInfoJson.getBytes(StandardCharsets.UTF_8));
                headers.set("X-UserInfo", base64); // 添加用户信息
                return headers;
            }
        };
        return chain.filter(exchange.mutate()
                .request(decorator)
                .build());
    }
    @Override
    public int getOrder() {
        return -100; // 保证在最前面执行
    }
}

1.从代码可以看出,我们重写了请求头对象中的方法,在原本的请求头参数重追加了X-Internal-Auth属性,为了保证所有接口比心在通过网关转发,不能直接访问具体的子服务,在下级服务过滤器中获取校验(当然也可以在部署时只开放网关端口也可以避免绕过网关直接访问,加上X-Internal-Auth也起到双重防护

2.在登录成功后也将用户信息加入到了请求头中,在下级服务过滤器中获取

实现公共服务cloud-common

对于公共服务使用的定义,一般只创建静态工具类、公共通用对象、公共统一配置类以及一些变动少,不涉及具体业务逻辑处理,不引入其他第三方依赖框架,只引入基础核心依赖,这样避免与其他服务过高的耦合性和不必要的版本冲突。

注:公共服务类没有配置文件

1.pom.xml引入依赖

    <dependencies>
        <!-- Web Starter 仅用于 Filter/Servlet API -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>provided</scope> <!-- 避免网关引入冲突 -->
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- JSON 工具 -->
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.49</version>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

2.redis序列化类型类RedisConfig.class

(与上面的网关中配置的一样)

3.用户上下文工具类 UserContextHolder.class

存储登录用户信息

public class UserContextHolder {

    private static final ThreadLocal<Map<String, String>> context = new ThreadLocal<>();
    // 设置用户信息
    public static void set(Map<String, String> userInfo) {
        context.set(userInfo);
    }
    // 获取用户信息
    public static Map<String, String> get() {
        return context.get();
    }
    public static String getUserId() {
        Map<String, String> userInfo = context.get();
        return userInfo != null ? userInfo.get("id") : null;
    }
    public static String getName() {
        Map<String, String> userInfo = context.get();
        return userInfo != null ? userInfo.get("name") : null;
    }
    // 清理
    public static void clear() {
        context.remove();
    }
}

4.统一请求头过滤器RequestHeaderFilter.class

验证接口网关防护以及用户信息获取

@Configuration
public class RequestHeaderFilter implements Filter {

    private static final String INTERNAL_SECRET = "expected-secret";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        String header = req.getHeader("X-Internal-Auth");
        if (!INTERNAL_SECRET.equals(header)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "非法访问接口,禁止绕过网关访问");
        }
        String userBase64 = req.getHeader("X-UserInfo"); // 返回 String
        if(!StringUtils.isEmpty(userBase64)){
            //用户信息转码
            String userJson = new String(Base64.getDecoder().decode(userBase64), StandardCharsets.UTF_8);
            Map<String, String> map = JSON.parseObject(userJson, new TypeReference<>() {});
            //将用户信息设置到自定义context中
            UserContextHolder.set(map);
        }
        chain.doFilter(request, response);
    }
}

5.cloud-common公共服务的依赖引入

只需要在每个业务子服务中引入依赖即可生效

        <!-- 公共模块 -->
        <dependency>
            <groupId>com.xf</groupId>
            <artifactId>cloud-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

注:因为本微服务项目是在同一个主体项目下创建并管理的子服务,所以可直接引入公共依赖,而如果每个子服务是单独的项目,不在一个版本管理体系中,那么公共服务模块需要先打包成jar包到本地或远程仓库,在通过maven方式引入依赖。

自此本篇的全部代码内容基本都完成了,让我们启动服务调用登录接口试试

调试

启动网关服务cloud- getaway以及用户服务cloud- common

1.调用登录接口返回

2.调用获取登录用户名称接口


网站公告

今日签到

点亮在社区的每一天
去签到