Spring Gateway与Sa-Token实现微服务无感鉴权实战
1. 为什么需要无感鉴权在微服务架构中鉴权一直是个让人头疼的问题。传统的做法是在每个服务里都写一遍鉴权逻辑这不仅重复劳动还容易出错。更糟的是当鉴权逻辑需要调整时你得在所有服务里都改一遍这简直就是维护的噩梦。我去年接手的一个电商项目就遇到了这种情况。最初我们用的是简单的JWT方案每个微服务都要验证token。结果随着业务扩展权限体系变得越来越复杂每次调整权限策略都得发版十几个服务运维同学都快疯了。后来我们引入了Spring Gateway Sa-Token的组合实现了真正的无感鉴权。这里的无感有两层含义对前端开发者无感 - 不需要在每次请求时手动处理token对业务服务无感 - 业务代码完全不用关心鉴权逻辑2. 技术选型为什么是Spring Gateway Sa-Token2.1 Spring Gateway的优势Spring Gateway作为Spring Cloud官方推荐的API网关相比Zuul和Nginx有几个不可替代的优势基于Spring生态与Spring Boot无缝集成支持异步非阻塞模型性能更好内置丰富的断言(Predicate)和过滤器(Filter)机制可以方便地扩展自定义逻辑特别是在鉴权场景下Gateway的GlobalFilter机制可以完美实现统一的鉴权拦截。2.2 Sa-Token的独特价值你可能听说过Shiro和Spring Security那为什么选择Sa-Token呢经过实际项目验证我发现Sa-Token有几个杀手级特性注解鉴权只需要一个SaCheckLogin就能完成方法级鉴权踢人下线内置会话管理可以强制指定用户下线多端登录轻松处理同一账号在PC、APP等多端同时登录分布式会话天然支持Redis分布式存储会话最重要的是Sa-Token的学习曲线非常平缓。相比Spring Security那复杂的配置Sa-Token几乎可以开箱即用。3. 实战搭建无感鉴权系统3.1 基础环境准备首先确保你的开发环境满足JDK 1.8Maven 3.6Redis 5.0用于会话存储Spring Boot 2.7.xSpring Cloud 2021.x在pom.xml中添加必要依赖!-- Spring Gateway -- dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency !-- Sa-Token -- dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.34.0/version /dependency !-- Sa-Token整合Redis -- dependency groupIdcn.dev33/groupId artifactIdsa-token-dao-redis/artifactId version1.34.0/version /dependency3.2 网关鉴权过滤器实现核心逻辑在自定义的GlobalFilter中Component public class AuthFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); // 1. 放行登录接口 if(path.contains(/auth/login)) { return chain.filter(exchange); } // 2. 获取token String token request.getHeaders().getFirst(Authorization); if(StringUtils.isEmpty(token)) { return unauthorizedResponse(exchange, 缺少token); } // 3. 校验token try { StpUtil.checkLoginByToken(token); } catch (NotLoginException e) { return unauthorizedResponse(exchange, token无效); } // 4. 将用户ID放入header传递给下游服务 String userId StpUtil.getLoginIdByToken(token).toString(); ServerHttpRequest newRequest request.mutate() .header(X-User-Id, userId) .build(); return chain.filter(exchange.mutate().request(newRequest).build()); } private MonoVoid unauthorizedResponse(ServerWebExchange exchange, String msg) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add(Content-Type, application/json); MapString, Object result new HashMap(); result.put(code, 401); result.put(message, msg); DataBuffer buffer response.bufferFactory() .wrap(JSON.toJSONString(result).getBytes()); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }3.3 业务服务中的权限控制在业务服务中你只需要添加注解就能实现方法级权限控制RestController RequestMapping(/order) public class OrderController { SaCheckLogin GetMapping(/list) public Result getOrderList() { // 自动从header获取用户ID String userId StpUtil.getLoginIdAsString(); // 业务逻辑... } SaCheckRole(admin) PostMapping(/cancel) public Result cancelOrder(String orderId) { // 只有admin角色可以调用 } }4. 关键问题与解决方案4.1 跨服务用户信息传递在微服务架构中用户信息需要在服务间传递。我们的方案是网关验证token后将用户ID放入header通过Feign拦截器自动传递header业务服务从header获取用户信息Feign拦截器实现Configuration public class FeignConfig { Bean public RequestInterceptor requestInterceptor() { return template - { String userId StpUtil.getLoginIdDefaultNull(); if(userId ! null) { template.header(X-User-Id, userId); } }; } }4.2 权限数据缓存策略权限数据需要频繁读取我们采用多级缓存本地Caffeine缓存有效期5分钟Redis缓存有效期30分钟数据库存储缓存更新采用发布订阅模式当权限变更时通知所有节点清除缓存。4.3 网关性能优化在高并发场景下网关可能成为瓶颈。我们做了以下优化启用响应式编程避免阻塞IO对鉴权结果进行短期缓存10秒使用Hystrix进行熔断保护对频繁访问的接口路径进行特殊处理5. 生产环境踩坑记录5.1 Cookie的SameSite问题当你的前端和后端不在同一个域名下时浏览器可能会阻止Cookie的传递。解决方案显式设置SameSiteNone必须同时设置Secure属性HTTPS或者改用header传递tokenGateway中的配置示例Bean public WebFilter cookieSameSiteFilter() { return (exchange, chain) - { exchange.getResponse() .getHeaders() .add(Set-Cookie, SameSiteNone; Secure); return chain.filter(exchange); }; }5.2 长连接超时问题网关和后端服务的HTTP连接默认有超时限制。我们遇到过因为后端处理时间过长导致网关超时的情况。解决方案spring: cloud: gateway: httpclient: pool: max-idle-time: 60s httpclient: response-timeout: 30s connect-timeout: 5s5.3 文件上传的特殊处理文件上传请求的content-type是multipart/form-data这种请求的body只能读取一次。解决方案// 在过滤器中先判断是否是文件上传 if(isFileUploadRequest(request)) { return chain.filter(exchange); } // 其他请求正常处理鉴权逻辑6. 扩展功能实现6.1 接口访问频率限制防止恶意刷接口SaCheckLogin SaCheckFrequency(value5, time60) // 60秒内最多5次 GetMapping(/detail) public Result getDetail(String id) { //... }6.2 多端登录管理允许同一个账号在PC和APP同时登录但限制同一端只能一个设备在线// 登录时指定设备类型 StpUtil.login(userId, new SaLoginModel() .setDevice(APP) // 设备标识 .setIsLastingCookie(true));6.3 动态权限控制从数据库加载权限规则Component public class DynamicAuthInterceptor implements SaInterceptor { Override public boolean preHandle(Object handler) { // 从数据库查询当前接口需要的权限 String permission getRequiredPermission(handler); return StpUtil.hasPermission(permission); } }7. 监控与运维7.1 登录日志记录记录所有登录登出事件EventListener public void onLogin(StpLoginEvent event) { log.info(用户{}登录, token值:{}, 设备:{}, event.getLoginId(), event.getTokenValue(), event.getLoginModel().getDevice()); }7.2 实时会话查询管理员可以查看当前在线用户RestController RequestMapping(/admin/session) public class SessionController { GetMapping(/list) public Result getOnlineUsers() { return Result.success(StpUtil.searchSessionPage( , 1, 20, true)); } }7.3 健康检查端点添加专门的健康检查接口management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always这套方案在我们多个生产环境中稳定运行超过一年日均处理请求量超过500万。最大的收获是业务开发完全从鉴权逻辑中解放出来只需要关注业务代码。当安全策略需要调整时也只需要修改网关一处即可生效。