1. 微服务统一认证授权的整体架构设计思路1.1 为什么微服务架构下必须做统一认证单体应用时代,认证和授权通常就是一个拦截器加Session的事,所有请求都打在一个进程里,用户登录状态存在服务端Session中,谁访问都走同一套逻辑,简单直接。但拆成微服务之后,情况完全变了——用户服务、订单服务、商品服务各自独立部署,每个服务都有自己的进程和端口,请求经过网关分发到不同后端。这时候如果每个服务各写一套登录校验逻辑,问题会非常多。我踩过最典型的坑是:早期项目里三个服务各自维护了一份Token解析代码,结果A服务用的签名密钥和B服务不一致,用户登录后在订单页正常,跳到商品页就401。排查了半天才发现是配置没对齐。这种重复建设带来的维护成本,在服务数量超过五个之后会呈指数级上升。统一认证授权要解决的核心问题就三个:用户只需登录一次,全站通用;认证逻辑集中一处,所有服务共享;权限粒度可控,不同服务能独立判断谁能访问什么。SpringCloud生态下,标准做法是把认证中心独立成一个服务,配合API网关做统一入口,其他业务服务只负责校验Token合法性,不再关心登录流程。1.2 技术选型的取舍:为什么是Spring Security OAuth2市面上做统一认证的方案不少,早期有人用Session共享(Spring Session + Redis),也有人自己写JWT工具类。我两个方案都实际用过,说下真实感受。Session共享方案的好处是改造量小,原有登录逻辑基本不动,把Session存到Redis就行。但它的致命伤在于跨域和移动端支持差。前端如果是前后端分离部署在不同域名下,Cookie传递会涉及跨域问题,移动端App更是完全不认Cookie这套。而且Session共享本质上是把状态集中存储,服务端仍然是有状态的,跟微服务提倡的无状态理念相悖。自己写JWT工具类看起来轻量,但坑在于安全细节太多。签名算法选什么、过期时间怎么设、刷新机制怎么做、Token被盗用了怎么吊销——这些都要自己实现,稍有不慎就是安全漏洞。我见过有项目把用户密码直接塞进JWT payload里,虽然做了Base64编码,但Base64不是加密,随便解一下就能看到明文。Spring Security OAuth2的优势在于它是一套经过大量生产验证的标准化协议实现。OAuth2定义了四种授权模式,其中密码模式和授权码模式最适合做统一认证。Token的签发、校验、刷新、吊销都有成熟机制,Spring Security负责安全过滤链,两者结合基本覆盖了企业级认证的所有需求。虽然学习曲线陡一些,但一旦跑通,后续扩展非常省心。1.3 整体架构分层与数据流转整套方案的架构可以分成四层来理解。第一层是客户端层,包括浏览器前端、移动App、第三方接入系统。它们不直接访问业务服务,所有请求先打到网关。第二层是网关层,用SpringCloud Gateway实现。网关的职责有两个:一是路由转发,把请求按路径分发到对应微服务;二是Token预校验,在转发之前先检查请求头里有没有合法Token,没有就直接打回,减轻后端压力。第三层是认证中心,独立部署的OAuth2服务。它负责颁发Token、校验客户端身份、管理用户凭证。认证中心连接用户数据库,登录时校验用户名密码,通过后签发JWT格式的Access Token和Refresh Token。第四层是业务服务层,各个微服务通过Spring Security的资源服务器配置来校验Token。校验方式有两种:一种是每个服务本地用公钥验签,性能好但密钥更新麻烦;另一种是拿Token去认证中心远程校验,实时性好但多一次网络调用。生产环境通常用JWT自包含方式,认证中心用私钥签名,业务服务用公钥验签,既安全又高效。数据流转的完整链路是这样的:用户在前端输入账号密码,前端把凭证发给认证中心;认证中心校验通过后签发JWT Token返回;前端拿到Token存到本地(localStorage或内存),后续每次请求都在Header里带上Authorization: Bearer xxx;网关收到请求后先验Token有效性,通过则转发到目标服务;目标服务的Spring Security过滤器再次验签并解析出用户身份和权限,决定是否放行。注意:网关验Token和业务服务验Token不是重复劳动。网关验的是"这个Token是不是我们系统签发的",业务服务验的是"这个用户有没有权限访问这个接口"。两层校验职责不同,缺一不可。2. 认证中心核心细节与实操要点2.1 认证中心服务搭建的关键配置认证中心是整个方案的心脏,配置错了后面全盘皆输。我以实际项目中最常用的密码模式为例,把关键配置项拆开讲。首先在pom.xml里引入核心依赖。SpringCloud版本和Spring Boot版本必须严格对应,否则会出现各种诡异的类找不到问题。我一般用Spring Boot 2.7.x配SpringCloud 2021.x,Spring Security OAuth2用spring-cloud-starter-oauth2。注意Spring Security 5.x之后OAuth2的配置方式有较大变化,网上很多老教程用的是AuthorizationServerConfigurerAdapter,在新版本里已经废弃了,需要改用SecurityFilterChain和OAuth2AuthorizationServerConfigurer。认证中心的核心配置类需要继承AuthorizationServerConfigurerAdapter(如果用的是旧版本)或使用新的配置方式,主要配置三块内容:客户端详情服务、授权服务器端点、Token服务。客户端详情服务配置的是哪些客户端可以来申请Token。每个客户端有clientId、clientSecret、授权模式、作用域等属性。这里有个容易忽略的点:clientSecret必须用PasswordEncoder加密存储,不能明文写在配置里。我见过有项目直接把secret明文写在application.yml里,虽然方便但安全性堪忧,一旦配置文件泄露,攻击者就能伪造客户端申请Token。@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("web-client") .secret(passwordEncoder().encode("web-secret-2024")) .authorizedGrantTypes("password", "refresh_token") .scopes("read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .accessTokenConverter(jwtAccessTokenConverter) .allowedTokenEndpointRequestMethods(HttpMethod.POST); } }accessTokenValiditySeconds设成7200秒也就是2小时,这是我在多个项目里权衡后的经验值。设太短用户频繁掉线体验差,设太长Token被盗用风险窗口大。2小时配合Refresh Token的7天有效期,基本能覆盖一个工作日的使用场景。2.2 JWT Token的签名与增强配置JWT Token默认的payload只包含基本的客户端信息和用户信息,实际项目里往往需要往Token里塞更多内容,比如用户ID、租户ID、角色列表等。这就需要自定义TokenEnhancer。public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); additionalInfo.put("userId", getUserId(userDetails.getUsername())); additionalInfo.put("tenantId", getTenantId(userDetails.getUsername())); additionalInfo.put("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }这里有个性能陷阱要特别注意:不要在TokenEnhancer里查数据库。Token签发是高频操作,每次登录、每次刷新都会触发。如果每次都去数据库查用户信息,认证中心很容易成为瓶颈。我的做法是在用户登录认证阶段就把需要的信息加载到UserDetails的自定义实现类里,TokenEnhancer直接从UserDetails取,零数据库查询。JWT签名密钥的管理也是重点。开发环境可以用固定的对称密钥,但生产环境强烈建议用非对称加密(RSA)。认证中心持有私钥负责签名,业务服务只持有公钥负责验签。这样即使某个业务服务被攻破,攻击者也拿不到私钥,无法伪造Token。密钥长度至少2048位,我一般用2048或4096。@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // 生产环境从密钥库加载,不要硬编码 KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory( new ClassPathResource("keystore.jks"), "storepass".toCharArray()); converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwtkey")); return converter; }2.3 用户认证逻辑的定制与扩展默认的UserDetailsService是从内存或单表加载用户,实际项目里用户数据往往分散在多张表,还涉及角色、权限、组织架构等关联查询。我通常自定义一个UserDetailsService实现,把用户、角色、权限一次性组装好。@Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private PermissionMapper permissionMapper; @Override public UserDetails loadUserByUsername(String username) { User user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } if (user.getStatus() == 0) { throw new DisabledException("账号已被禁用"); } List<String> permissions = permissionMapper.selectPermissionsByUserId(user.getId()); List<GrantedAuthority> authorities = permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return new CustomUserDetails(user.getId(), user.getUsername(), user.getPassword(), user.getTenantId(), authorities); } }这里有个细节值得展开:权限的粒度设计。我见过两种极端,一种是权限粗到只有"管理员"和"普通用户"两个角色,另一种是细到每个按钮一个权限码。前者不够用,后者维护成本爆炸。我的经验是采用RBAC模型,权限码按"服务:资源:操作"三段式命名,比如order:order:create、order:order:query、product:product:delete。这样既清晰又便于网关和服务做统一判断。另外,密码加密器必须显式声明。Spring Security 5之后默认用BCrypt,但如果你从旧系统迁移过来,数据库里存的是MD5或SHA,就需要配置DelegatingPasswordEncoder做兼容。我建议新项目一律用BCrypt,强度设10轮,兼顾安全和性能。3. 网关与业务服务的Token校验实现3.1 SpringCloud Gateway集成Token预校验网关是统一入口,在这里做Token预校验能挡住大量无效请求。但要注意,网关只做基础校验——Token是否存在、格式是否正确、签名是否有效、是否过期。至于这个用户有没有权限访问这个具体接口,交给业务服务判断。网关的全局过滤器实现如下:@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final List<String> WHITE_LIST = Arrays.asList( "/auth/login", "/auth/refresh", "/public/**" ); @Autowired private JwtTokenValidator jwtTokenValidator; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (isWhiteList(path)) { return chain.filter(exchange); } String token = extractToken(exchange.getRequest()); if (StringUtils.isEmpty(token)) { return unauthorized(exchange, "缺少认证Token"); } try { Claims claims = jwtTokenValidator.validate(token); // 把用户信息透传到下游服务 ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", claims.get("userId").toString()) .header("X-Tenant-Id", claims.get("tenantId").toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException e) { return unauthorized(exchange, "Token无效或已过期"); } } @Override public int getOrder() { return -100; } }getOrder()返回-100是为了让这个过滤器在路由转发之前执行。白名单机制很关键,登录接口、刷新Token接口、公开的健康检查接口必须放行,否则会陷入"要登录先要有Token,要Token先要登录"的死循环。提示:网关透传用户信息时用自定义Header(如X-User-Id),不要用标准Header。因为标准Header可能被外部请求伪造,必须在网关层把外部传入的同名Header先清除再设置,防止请求头注入攻击。3.2 业务服务作为资源服务器的配置每个业务服务都要配置成OAuth2资源服务器,这样才能校验Token并解析用户权限。核心配置如下:@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/actuator/health").permitAll() .antMatchers(HttpMethod.GET, "/api/order/**").hasAuthority("order:order:query") .antMatchers(HttpMethod.POST, "/api/order/**").hasAuthority("order:order:create") .anyRequest().authenticated() .and().csrf().disable(); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(loadPublicKey()); return converter; } }这里用的是公钥验签模式,业务服务只需要公钥就能独立完成Token校验,不需要每次请求都去认证中心。性能上优势明显,一个请求的验签耗时在毫秒级,而远程校验至少要多一次网络往返。但公钥模式有个问题:Token吊销不及时。如果用户登出或者管理员禁用了某个账号,已经签发的Token在过期前仍然有效。解决方案有两种:一是缩短Access Token有效期,配合Refresh Token机制;二是在网关层维护一个Token黑名单,登出时把Token加入黑名单,网关校验时先查黑名单。我一般用第一种方案,简单可靠,2小时的窗口期对大多数业务可以接受。如果安全要求极高,再叠加黑名单机制。3.3 服务间调用的认证传递微服务之间经常需要互相调用,比如订单服务要调用户服务查用户信息。这种内部调用同样需要认证,不能因为是内网就裸奔。我的做法是用Feign拦截器自动传递Token。@Configuration public class FeignAuthConfig { @Bean public RequestInterceptor authRequestInterceptor() { return template -> { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest(); String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { template.header("Authorization", token); } String userId = request.getHeader("X-User-Id"); if (StringUtils.hasText(userId)) { template.header("X-User-Id", userId); } } }; } }这样订单服务调用用户服务时,会把当前请求的Token原样带上,用户服务正常校验权限。但要注意,如果是在异步线程或定时任务里发起Feign调用,RequestContextHolder拿不到请求上下文,需要手动传递Token。我一般会封装一个TokenContext工具类,用ThreadLocal存储Token,异步场景手动设置。还有一种场景是服务间的系统级调用,比如定时任务同步数据,没有用户上下文。这种调用可以用客户端凭证模式(client_credentials),给每个服务分配独立的clientId和secret,申请服务级Token。这样既能审计调用来源,又能控制服务权限。4. 常见问题排查与避坑经验实录4.1 Token校验失败的典型原因速查实际运维中,Token相关的问题占了认证故障的一大半。我把遇到过的典型问题整理成速查表,方便对照排查。现象可能原因排查方法解决方案401 UnauthorizedToken缺失或格式错误检查请求Header是否有Authorization: Bearer xxx前端确认Token已存储且正确拼接401 签名不匹配公私钥不配对用相同密钥对验签测试确认认证中心私钥与业务服务公钥来自同一密钥对401 Token已过期时钟不同步或有效期太短解码Token查看exp字段服务器间做NTP时间同步,适当延长有效期403 Forbidden权限不足查看Token中的authorities检查用户角色配置和接口权限映射网关放行但服务拒绝Header未透传在服务端打印请求Header确认网关过滤器正确透传用户信息Header刷新Token失败Refresh Token过期或已使用检查refresh_token有效期重新登录,或调整刷新策略签名不匹配这个问题我遇到过两次,一次是开发环境用了对称密钥,测试环境换成了RSA,但业务服务的公钥没更新;另一次是密钥对生成时用了不同的密钥库。排查方法很简单,把Token拿到jwt.io上解码,再用业务服务的公钥验签,能快速定位。4.2 性能优化与高并发场景处理认证中心在高并发下容易成为瓶颈,尤其是登录接口。我做过压测,单节点认证中心在4核8G配置下,纯登录接口QPS大概在800左右,如果每次登录都查多次数据库,QPS会掉到200以下。优化手段有几个。第一是缓存用户认证信息,用Redis缓存UserDetails,设置合理的过期时间,登录时先查缓存。第二是异步写日志,登录日志、审计日志不要同步写库,用消息队列异步处理。第三是认证中心水平扩展,因为JWT是无状态的,认证中心可以部署多个实例,前面挂负载均衡。但要注意,如果用非对称加密,所有实例必须共享同一套密钥。网关的Token校验也有优化空间。JWT验签是CPU密集型操作,RSA2048验签单次约0.1毫秒,看起来很快,但QPS上万时CPU占用会明显上升。优化方法是缓存验签结果,用Token的签名部分做key,缓存解析后的Claims,设置一个较短的过期时间(比如5分钟)。这样同一个Token的重复请求就不用反复验签了。@Cacheable(value = "tokenClaims", key = "#signature", unless = "#result == null") public Claims parseClaims(String token, String signature) { return Jwts.parser() .setSigningKey(publicKey) .parseClaimsJws(token) .getBody(); }4.3 安全加固的几条硬核经验认证授权是安全的第一道防线,几个加固点必须做到位。Token存储位置:前端不要存localStorage,容易被XSS攻击窃取。推荐存在内存变量里,配合HttpOnly的Refresh Token Cookie。如果非要用localStorage,至少做好XSS防护,所有用户输入都转义。HTTPS强制:认证中心和生产环境的所有服务必须走HTTPS,否则Token在传输过程中可能被截获。网关层配置HTTP自动跳转HTTPS。敏感信息脱敏:JWT的payload是Base64编码,不是加密,任何人都能解码查看。所以绝对不要往Token里放密码、身份证号、手机号等敏感信息。只放用户ID、租户ID、角色这类非敏感标识。防重放攻击:JWT本身不防重放,同一个Token在有效期内可以被反复使用。对安全要求高的接口,可以加入nonce和timestamp机制,服务端校验请求唯一性。密钥轮换:生产环境密钥要定期轮换,建议半年一次。轮换时新旧密钥并存一段时间,新Token用新密钥签,旧Token用旧密钥验,平滑过渡。注意:很多团队在项目初期为了赶进度,把认证中心和安全配置做得很粗糙,想着"以后再说"。结果上线后各种安全问题暴露,再回头改成本极高。我的建议是认证这块一开始就按生产标准做,密钥管理、HTTPS、权限粒度这些基础打牢,后面省心得多。4.4 跨域与前端集成的实操细节前后端分离项目里,跨域问题是绕不开的。认证中心、网关、前端往往部署在不同域名或端口,浏览器会拦截跨域请求。网关层统一处理CORS是最省事的方案:@Configuration public class CorsConfig { @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source); } }注意setAllowCredentials(true)和addAllowedOrigin("*")不能同时用,浏览器会拒绝。要用addAllowedOriginPattern("*")代替。这个坑我踩过,当时前端一直报CORS错误,排查半天才发现是这个组合问题。前端集成时,Token刷新逻辑要处理好。Access Token过期后,前端应该自动用Refresh Token换新的Access Token,而不是直接跳登录页。我的做法是在axios拦截器里统一处理401响应,触发刷新流程,刷新成功后重放原请求。但要注意并发请求的问题——如果同时有5个请求都返回401,不能触发5次刷新,要用一个标志位或Promise队列控制,只刷新一次,其他请求等刷新完成后重试。let isRefreshing = false; let failedQueue = []; axios.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response.status === 401 && !originalRequest._retry) { if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then(token => { originalRequest.headers['Authorization'] = 'Bearer ' + token; return axios(originalRequest); }); } originalRequest._retry = true; isRefreshing = true; try { const newToken = await refreshToken(); failedQueue.forEach(p => p.resolve(newToken)); failedQueue = []; originalRequest.headers['Authorization'] = 'Bearer ' + newToken; return axios(originalRequest); } catch (e) { failedQueue.forEach(p => p.reject(e)); failedQueue = []; redirectToLogin(); return Promise.reject(e); } finally { isRefreshing = false; } } return Promise.reject(error); } );这套刷新队列机制是我在多个项目里验证过的,能有效避免并发刷新导致的Token覆盖问题。踩过的坑是早期没做队列控制,结果多个请求同时刷新,后刷新的Token覆盖了先刷新的,导致部分请求用了已失效的Token,用户看到莫名其妙的报错。5. 权限模型设计与动态权限控制5.1 RBAC权限模型在微服务下的落地权限模型设计是认证授权的另一半,Token只解决了"你是谁",权限解决的是"你能干什么"。微服务架构下,权限模型要兼顾集中管理和服务自治。我采用的方案是集中定义、分布校验。权限码在认证中心统一管理,用户登录时把权限列表写进Token。各业务服务在接口上通过注解声明所需权限,Spring Security自动比对。这样权限的"定义权"在认证中心,"使用权"在业务服务,职责清晰。权限表设计上,核心是五张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。权限表里存权限码和描述,权限码按服务:资源:操作格式。比如订单服务的创建订单权限是order:order:create,查询订单列表是order:order:list。CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL UNIQUE COMMENT '权限码', perm_name VARCHAR(64) NOT NULL COMMENT '权限名称', service_name VARCHAR(64) NOT NULL COMMENT '所属服务', resource_type VARCHAR(32) COMMENT '资源类型', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );业务服务里用注解声明权限:@RestController @RequestMapping("/api/order") public class OrderController { @PreAuthorize("hasAuthority('order:order:create')") @PostMapping public Result<OrderVO> createOrder(@RequestBody OrderCreateDTO dto) { // 业务逻辑 } @PreAuthorize("hasAuthority('order:order:list')") @GetMapping public Result<PageResult<OrderVO>> listOrders(OrderQuery query) { // 业务逻辑 } }@PreAuthorize注解在方法执行前做权限校验,不通过直接抛AccessDeniedException,返回403。这种声明式权限控制比在代码里写if-else优雅得多,也便于统一管理。5.2 数据权限的精细化控制功能权限解决了"能不能访问这个接口",但很多场景还需要数据权限——"能看哪些数据"。比如销售只能看自己的订单,区域经理能看本区域的订单,总部能看全部。数据权限的实现思路是在Token里带上数据范围标识,业务服务在查询时动态拼接过滤条件。我一般用MyBatis拦截器实现,在SQL执行前自动追加数据权限条件。@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataPermissionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; // 判断是否需要数据权限过滤 if (ms.getId().contains("selectOrderList")) { Object parameter = invocation.getArgs()[1]; DataScope scope = getCurrentDataScope(); if (scope != DataScope.ALL) { // 动态修改SQL,追加数据范围条件 BoundSql boundSql = ms.getBoundSql(parameter); String newSql = appendDataScope(boundSql.getSql(), scope); // 替换SQL } } return invocation.proceed(); } }数据权限的粒度要提前想清楚,是行级还是列级,是本人还是本部门还是本组织。我见过项目做到一半才发现数据权限需求没考虑,回头改成本很高。建议在权限模型设计阶段就把数据权限的维度确定下来。5.3 动态权限变更的实时生效方案权限配置改了之后,怎么让已登录用户立即生效?这是个很实际的问题。如果等Token过期才生效,管理员改了权限要等2小时,体验很差。我的方案是版本号机制。用户权限变更时,递增该用户的权限版本号,存到Redis。网关校验Token时,比对Token里的版本号和Redis里的当前版本号,不一致就拒绝,强制用户重新登录或刷新Token。public boolean checkPermissionVersion(String userId, Long tokenVersion) { String key = "perm:version:" + userId; String currentVersion = redisTemplate.opsForValue().get(key); if (currentVersion == null) { return true; } return Long.parseLong(currentVersion) <= tokenVersion; }这个机制的好处是实时性好,权限一改立即生效;代价是每次请求多一次Redis查询。Redis查询很快,对性能影响可以忽略。如果追求极致性能,可以在网关本地缓存版本号,设置较短的过期时间(比如30秒),牺牲一点实时性换性能。另一个方案是主动推送,权限变更时通过消息队列通知网关,网关更新本地缓存。这个方案实时性和性能都好,但实现复杂度高,适合对实时性要求极高的场景。6. 生产环境部署与运维要点6.1 认证中心的高可用部署认证中心是单点,一旦挂掉整个系统都登录不了,所以必须做高可用。部署架构上,至少两个实例,前面挂负载均衡。因为JWT是无状态的,多实例部署没有Session同步问题,非常方便。但有几个细节要注意。密钥共享:如果用非对称加密,所有实例必须用同一套密钥对。我一般把密钥文件放在配置中心或挂载的共享存储里,启动时加载。时钟同步:多实例之间时间必须同步,否则Token的签发时间和过期时间会有偏差。用NTP服务保证时间误差在秒级以内。数据库连接:认证中心连的用户库如果也是单点,那认证中心的高可用就没意义了。用户库也要做主从或集群。健康检查接口要暴露出来,负载均衡器定期探测。/actuator/health接口返回UP才认为实例可用。我遇到过实例进程还在但数据库连接池耗尽的情况,健康检查能及时发现这种"假活"状态。6.2 日志审计与安全监控认证授权相关的操作必须留痕,出了问题才能追溯。需要记录的日志包括:登录成功/失败、Token签发/刷新/吊销、权限变更、敏感接口访问。日志格式要结构化,方便后续分析。我一般用JSON格式,包含时间戳、用户ID、操作类型、IP地址、User-Agent、结果状态等字段。{ "timestamp": "2024-01-15T10:30:00.000Z", "eventType": "LOGIN_SUCCESS", "userId": "10086", "username": "zhangsan", "clientId": "web-client", "ip": "192.168.1.100", "userAgent": "Mozilla/5.0...", "result": "SUCCESS" }安全监控方面,要重点关注几个异常指标:登录失败率突增可能是在暴力破解;同一IP短时间大量Token申请可能是攻击;非工作时间的管理员操作可能是账号被盗。这些指标配上告警,能提前发现安全事件。6.3 灰度发布与版本兼容认证中心升级时,新旧版本可能并存一段时间,Token格式或签名密钥可能有变化。灰度发布策略要设计好。如果只是业务逻辑变更,Token格式不变,那灰度很简单,新实例逐步替换旧实例即可。但如果Token格式变了(比如payload里加了新字段),就要考虑兼容性。我的做法是向后兼容,新版本能解析旧Token,旧版本忽略新字段。这样灰度期间新旧实例混跑不会出问题。密钥轮换也是类似思路。新密钥启用后,旧密钥保留一段时间,验签时先试新密钥,失败再试旧密钥。等所有旧Token都过期后,再移除旧密钥。这个过渡期至少是Access Token有效期的两倍。提示:认证中心的任何变更都要在测试环境充分验证,尤其是密钥和Token格式相关的改动。生产环境出认证故障,影响面是全站级的,比单个业务服务故障严重得多。6.4 容量规划与压测经验认证中心的容量规划要基于业务量估算。假设系统有10万用户,日活20%,每人每天登录2次,那登录请求约4万次/天。集中在早高峰2小时内,约5.5次/秒。这个量级单实例轻松扛住。但如果是对外的开放平台,QPS可能上万,就需要集群部署。压测时重点测三个接口:登录接口、Token刷新接口、Token校验接口。登录接口最重,涉及密码校验和数据库查询;刷新接口次之;校验接口最轻,但调用量最大。我压测的经验数据供参考:4核8G单实例,登录接口QPS约800,刷新接口约2000,校验接口约5000。实际规划时留50%余量,按这个数据反推需要多少实例。压测还要注意冷启动问题。认证中心刚启动时,JIT还没优化,连接池还没预热,性能只有稳定期的30%左右。所以滚动发布时要一个一个来,等前一个实例预热完成再发下一个,避免同时重启导致服务不可用。7. 个人实操体会与后续扩展方向这套SpringCloud加Spring Security OAuth2的统一认证方案,我在三个不同规模的项目里落地过,从日活几千到日活几十万都有。最大的体会是:前期设计多花一周,后期运维省半年。认证授权是基础设施,一旦上线再改,牵一发动全身。几个我认为最值得投入时间优化的点:一是密钥管理,用非对称加密加密钥库,别图省事用对称密钥;二是Token刷新机制,前端一定要做无感刷新,用户体验差别巨大;三是权限版本号,让权限变更实时生效,避免管理员改完权限还要等用户重新登录。后续扩展方向,我目前正在研究的是把认证中心和多因素认证结合,对敏感操作要求二次验证。另外就是在考虑用Spring Authorization Server替代Spring Security OAuth2,前者是官方的新一代实现,对OIDC协议支持更好,但迁移成本不低,需要评估。最后分享一个小技巧:认证中心的日志级别在生产环境设成WARN,不要开DEBUG。DEBUG级别下Spring Security会打印大量过滤链信息,日志量巨大,磁盘很快就满了。需要排查问题时临时开DEBUG,排查完立即改回去。这个坑我在生产环境踩过,半夜磁盘告警,起来一看全是认证日志。
1. 微服务统一认证授权的整体架构设计思路1.1 为什么微服务架构下必须做统一认证单体应用时代,认证和授权通常就是一个拦截器加Session的事,所有请求都打在一个进程里,用户登录状态存在服务端Session中,谁访问都走同一套逻辑,简单直接。但拆成微服务之后,情况完全变了——用户服务、订单服务、商品服务各自独立部署,每个服务都有自己的进程和端口,请求经过网关分发到不同后端。这时候如果每个服务各写一套登录校验逻辑,问题会非常多。我踩过最典型的坑是:早期项目里三个服务各自维护了一份Token解析代码,结果A服务用的签名密钥和B服务不一致,用户登录后在订单页正常,跳到商品页就401。排查了半天才发现是配置没对齐。这种重复建设带来的维护成本,在服务数量超过五个之后会呈指数级上升。统一认证授权要解决的核心问题就三个:用户只需登录一次,全站通用;认证逻辑集中一处,所有服务共享;权限粒度可控,不同服务能独立判断谁能访问什么。SpringCloud生态下,标准做法是把认证中心独立成一个服务,配合API网关做统一入口,其他业务服务只负责校验Token合法性,不再关心登录流程。1.2 技术选型的取舍:为什么是Spring Security OAuth2市面上做统一认证的方案不少,早期有人用Session共享(Spring Session + Redis),也有人自己写JWT工具类。我两个方案都实际用过,说下真实感受。Session共享方案的好处是改造量小,原有登录逻辑基本不动,把Session存到Redis就行。但它的致命伤在于跨域和移动端支持差。前端如果是前后端分离部署在不同域名下,Cookie传递会涉及跨域问题,移动端App更是完全不认Cookie这套。而且Session共享本质上是把状态集中存储,服务端仍然是有状态的,跟微服务提倡的无状态理念相悖。自己写JWT工具类看起来轻量,但坑在于安全细节太多。签名算法选什么、过期时间怎么设、刷新机制怎么做、Token被盗用了怎么吊销——这些都要自己实现,稍有不慎就是安全漏洞。我见过有项目把用户密码直接塞进JWT payload里,虽然做了Base64编码,但Base64不是加密,随便解一下就能看到明文。Spring Security OAuth2的优势在于它是一套经过大量生产验证的标准化协议实现。OAuth2定义了四种授权模式,其中密码模式和授权码模式最适合做统一认证。Token的签发、校验、刷新、吊销都有成熟机制,Spring Security负责安全过滤链,两者结合基本覆盖了企业级认证的所有需求。虽然学习曲线陡一些,但一旦跑通,后续扩展非常省心。1.3 整体架构分层与数据流转整套方案的架构可以分成四层来理解。第一层是客户端层,包括浏览器前端、移动App、第三方接入系统。它们不直接访问业务服务,所有请求先打到网关。第二层是网关层,用SpringCloud Gateway实现。网关的职责有两个:一是路由转发,把请求按路径分发到对应微服务;二是Token预校验,在转发之前先检查请求头里有没有合法Token,没有就直接打回,减轻后端压力。第三层是认证中心,独立部署的OAuth2服务。它负责颁发Token、校验客户端身份、管理用户凭证。认证中心连接用户数据库,登录时校验用户名密码,通过后签发JWT格式的Access Token和Refresh Token。第四层是业务服务层,各个微服务通过Spring Security的资源服务器配置来校验Token。校验方式有两种:一种是每个服务本地用公钥验签,性能好但密钥更新麻烦;另一种是拿Token去认证中心远程校验,实时性好但多一次网络调用。生产环境通常用JWT自包含方式,认证中心用私钥签名,业务服务用公钥验签,既安全又高效。数据流转的完整链路是这样的:用户在前端输入账号密码,前端把凭证发给认证中心;认证中心校验通过后签发JWT Token返回;前端拿到Token存到本地(localStorage或内存),后续每次请求都在Header里带上Authorization: Bearer xxx;网关收到请求后先验Token有效性,通过则转发到目标服务;目标服务的Spring Security过滤器再次验签并解析出用户身份和权限,决定是否放行。注意:网关验Token和业务服务验Token不是重复劳动。网关验的是"这个Token是不是我们系统签发的",业务服务验的是"这个用户有没有权限访问这个接口"。两层校验职责不同,缺一不可。2. 认证中心核心细节与实操要点2.1 认证中心服务搭建的关键配置认证中心是整个方案的心脏,配置错了后面全盘皆输。我以实际项目中最常用的密码模式为例,把关键配置项拆开讲。首先在pom.xml里引入核心依赖。SpringCloud版本和Spring Boot版本必须严格对应,否则会出现各种诡异的类找不到问题。我一般用Spring Boot 2.7.x配SpringCloud 2021.x,Spring Security OAuth2用spring-cloud-starter-oauth2。注意Spring Security 5.x之后OAuth2的配置方式有较大变化,网上很多老教程用的是AuthorizationServerConfigurerAdapter,在新版本里已经废弃了,需要改用SecurityFilterChain和OAuth2AuthorizationServerConfigurer。认证中心的核心配置类需要继承AuthorizationServerConfigurerAdapter(如果用的是旧版本)或使用新的配置方式,主要配置三块内容:客户端详情服务、授权服务器端点、Token服务。客户端详情服务配置的是哪些客户端可以来申请Token。每个客户端有clientId、clientSecret、授权模式、作用域等属性。这里有个容易忽略的点:clientSecret必须用PasswordEncoder加密存储,不能明文写在配置里。我见过有项目直接把secret明文写在application.yml里,虽然方便但安全性堪忧,一旦配置文件泄露,攻击者就能伪造客户端申请Token。@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("web-client") .secret(passwordEncoder().encode("web-secret-2024")) .authorizedGrantTypes("password", "refresh_token") .scopes("read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .accessTokenConverter(jwtAccessTokenConverter) .allowedTokenEndpointRequestMethods(HttpMethod.POST); } }accessTokenValiditySeconds设成7200秒也就是2小时,这是我在多个项目里权衡后的经验值。设太短用户频繁掉线体验差,设太长Token被盗用风险窗口大。2小时配合Refresh Token的7天有效期,基本能覆盖一个工作日的使用场景。2.2 JWT Token的签名与增强配置JWT Token默认的payload只包含基本的客户端信息和用户信息,实际项目里往往需要往Token里塞更多内容,比如用户ID、租户ID、角色列表等。这就需要自定义TokenEnhancer。public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); additionalInfo.put("userId", getUserId(userDetails.getUsername())); additionalInfo.put("tenantId", getTenantId(userDetails.getUsername())); additionalInfo.put("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }这里有个性能陷阱要特别注意:不要在TokenEnhancer里查数据库。Token签发是高频操作,每次登录、每次刷新都会触发。如果每次都去数据库查用户信息,认证中心很容易成为瓶颈。我的做法是在用户登录认证阶段就把需要的信息加载到UserDetails的自定义实现类里,TokenEnhancer直接从UserDetails取,零数据库查询。JWT签名密钥的管理也是重点。开发环境可以用固定的对称密钥,但生产环境强烈建议用非对称加密(RSA)。认证中心持有私钥负责签名,业务服务只持有公钥负责验签。这样即使某个业务服务被攻破,攻击者也拿不到私钥,无法伪造Token。密钥长度至少2048位,我一般用2048或4096。@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // 生产环境从密钥库加载,不要硬编码 KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory( new ClassPathResource("keystore.jks"), "storepass".toCharArray()); converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwtkey")); return converter; }2.3 用户认证逻辑的定制与扩展默认的UserDetailsService是从内存或单表加载用户,实际项目里用户数据往往分散在多张表,还涉及角色、权限、组织架构等关联查询。我通常自定义一个UserDetailsService实现,把用户、角色、权限一次性组装好。@Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private PermissionMapper permissionMapper; @Override public UserDetails loadUserByUsername(String username) { User user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } if (user.getStatus() == 0) { throw new DisabledException("账号已被禁用"); } List<String> permissions = permissionMapper.selectPermissionsByUserId(user.getId()); List<GrantedAuthority> authorities = permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return new CustomUserDetails(user.getId(), user.getUsername(), user.getPassword(), user.getTenantId(), authorities); } }这里有个细节值得展开:权限的粒度设计。我见过两种极端,一种是权限粗到只有"管理员"和"普通用户"两个角色,另一种是细到每个按钮一个权限码。前者不够用,后者维护成本爆炸。我的经验是采用RBAC模型,权限码按"服务:资源:操作"三段式命名,比如order:order:create、order:order:query、product:product:delete。这样既清晰又便于网关和服务做统一判断。另外,密码加密器必须显式声明。Spring Security 5之后默认用BCrypt,但如果你从旧系统迁移过来,数据库里存的是MD5或SHA,就需要配置DelegatingPasswordEncoder做兼容。我建议新项目一律用BCrypt,强度设10轮,兼顾安全和性能。3. 网关与业务服务的Token校验实现3.1 SpringCloud Gateway集成Token预校验网关是统一入口,在这里做Token预校验能挡住大量无效请求。但要注意,网关只做基础校验——Token是否存在、格式是否正确、签名是否有效、是否过期。至于这个用户有没有权限访问这个具体接口,交给业务服务判断。网关的全局过滤器实现如下:@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final List<String> WHITE_LIST = Arrays.asList( "/auth/login", "/auth/refresh", "/public/**" ); @Autowired private JwtTokenValidator jwtTokenValidator; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (isWhiteList(path)) { return chain.filter(exchange); } String token = extractToken(exchange.getRequest()); if (StringUtils.isEmpty(token)) { return unauthorized(exchange, "缺少认证Token"); } try { Claims claims = jwtTokenValidator.validate(token); // 把用户信息透传到下游服务 ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", claims.get("userId").toString()) .header("X-Tenant-Id", claims.get("tenantId").toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException e) { return unauthorized(exchange, "Token无效或已过期"); } } @Override public int getOrder() { return -100; } }getOrder()返回-100是为了让这个过滤器在路由转发之前执行。白名单机制很关键,登录接口、刷新Token接口、公开的健康检查接口必须放行,否则会陷入"要登录先要有Token,要Token先要登录"的死循环。提示:网关透传用户信息时用自定义Header(如X-User-Id),不要用标准Header。因为标准Header可能被外部请求伪造,必须在网关层把外部传入的同名Header先清除再设置,防止请求头注入攻击。3.2 业务服务作为资源服务器的配置每个业务服务都要配置成OAuth2资源服务器,这样才能校验Token并解析用户权限。核心配置如下:@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/actuator/health").permitAll() .antMatchers(HttpMethod.GET, "/api/order/**").hasAuthority("order:order:query") .antMatchers(HttpMethod.POST, "/api/order/**").hasAuthority("order:order:create") .anyRequest().authenticated() .and().csrf().disable(); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(loadPublicKey()); return converter; } }这里用的是公钥验签模式,业务服务只需要公钥就能独立完成Token校验,不需要每次请求都去认证中心。性能上优势明显,一个请求的验签耗时在毫秒级,而远程校验至少要多一次网络往返。但公钥模式有个问题:Token吊销不及时。如果用户登出或者管理员禁用了某个账号,已经签发的Token在过期前仍然有效。解决方案有两种:一是缩短Access Token有效期,配合Refresh Token机制;二是在网关层维护一个Token黑名单,登出时把Token加入黑名单,网关校验时先查黑名单。我一般用第一种方案,简单可靠,2小时的窗口期对大多数业务可以接受。如果安全要求极高,再叠加黑名单机制。3.3 服务间调用的认证传递微服务之间经常需要互相调用,比如订单服务要调用户服务查用户信息。这种内部调用同样需要认证,不能因为是内网就裸奔。我的做法是用Feign拦截器自动传递Token。@Configuration public class FeignAuthConfig { @Bean public RequestInterceptor authRequestInterceptor() { return template -> { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest(); String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { template.header("Authorization", token); } String userId = request.getHeader("X-User-Id"); if (StringUtils.hasText(userId)) { template.header("X-User-Id", userId); } } }; } }这样订单服务调用用户服务时,会把当前请求的Token原样带上,用户服务正常校验权限。但要注意,如果是在异步线程或定时任务里发起Feign调用,RequestContextHolder拿不到请求上下文,需要手动传递Token。我一般会封装一个TokenContext工具类,用ThreadLocal存储Token,异步场景手动设置。还有一种场景是服务间的系统级调用,比如定时任务同步数据,没有用户上下文。这种调用可以用客户端凭证模式(client_credentials),给每个服务分配独立的clientId和secret,申请服务级Token。这样既能审计调用来源,又能控制服务权限。4. 常见问题排查与避坑经验实录4.1 Token校验失败的典型原因速查实际运维中,Token相关的问题占了认证故障的一大半。我把遇到过的典型问题整理成速查表,方便对照排查。现象可能原因排查方法解决方案401 UnauthorizedToken缺失或格式错误检查请求Header是否有Authorization: Bearer xxx前端确认Token已存储且正确拼接401 签名不匹配公私钥不配对用相同密钥对验签测试确认认证中心私钥与业务服务公钥来自同一密钥对401 Token已过期时钟不同步或有效期太短解码Token查看exp字段服务器间做NTP时间同步,适当延长有效期403 Forbidden权限不足查看Token中的authorities检查用户角色配置和接口权限映射网关放行但服务拒绝Header未透传在服务端打印请求Header确认网关过滤器正确透传用户信息Header刷新Token失败Refresh Token过期或已使用检查refresh_token有效期重新登录,或调整刷新策略签名不匹配这个问题我遇到过两次,一次是开发环境用了对称密钥,测试环境换成了RSA,但业务服务的公钥没更新;另一次是密钥对生成时用了不同的密钥库。排查方法很简单,把Token拿到jwt.io上解码,再用业务服务的公钥验签,能快速定位。4.2 性能优化与高并发场景处理认证中心在高并发下容易成为瓶颈,尤其是登录接口。我做过压测,单节点认证中心在4核8G配置下,纯登录接口QPS大概在800左右,如果每次登录都查多次数据库,QPS会掉到200以下。优化手段有几个。第一是缓存用户认证信息,用Redis缓存UserDetails,设置合理的过期时间,登录时先查缓存。第二是异步写日志,登录日志、审计日志不要同步写库,用消息队列异步处理。第三是认证中心水平扩展,因为JWT是无状态的,认证中心可以部署多个实例,前面挂负载均衡。但要注意,如果用非对称加密,所有实例必须共享同一套密钥。网关的Token校验也有优化空间。JWT验签是CPU密集型操作,RSA2048验签单次约0.1毫秒,看起来很快,但QPS上万时CPU占用会明显上升。优化方法是缓存验签结果,用Token的签名部分做key,缓存解析后的Claims,设置一个较短的过期时间(比如5分钟)。这样同一个Token的重复请求就不用反复验签了。@Cacheable(value = "tokenClaims", key = "#signature", unless = "#result == null") public Claims parseClaims(String token, String signature) { return Jwts.parser() .setSigningKey(publicKey) .parseClaimsJws(token) .getBody(); }4.3 安全加固的几条硬核经验认证授权是安全的第一道防线,几个加固点必须做到位。Token存储位置:前端不要存localStorage,容易被XSS攻击窃取。推荐存在内存变量里,配合HttpOnly的Refresh Token Cookie。如果非要用localStorage,至少做好XSS防护,所有用户输入都转义。HTTPS强制:认证中心和生产环境的所有服务必须走HTTPS,否则Token在传输过程中可能被截获。网关层配置HTTP自动跳转HTTPS。敏感信息脱敏:JWT的payload是Base64编码,不是加密,任何人都能解码查看。所以绝对不要往Token里放密码、身份证号、手机号等敏感信息。只放用户ID、租户ID、角色这类非敏感标识。防重放攻击:JWT本身不防重放,同一个Token在有效期内可以被反复使用。对安全要求高的接口,可以加入nonce和timestamp机制,服务端校验请求唯一性。密钥轮换:生产环境密钥要定期轮换,建议半年一次。轮换时新旧密钥并存一段时间,新Token用新密钥签,旧Token用旧密钥验,平滑过渡。注意:很多团队在项目初期为了赶进度,把认证中心和安全配置做得很粗糙,想着"以后再说"。结果上线后各种安全问题暴露,再回头改成本极高。我的建议是认证这块一开始就按生产标准做,密钥管理、HTTPS、权限粒度这些基础打牢,后面省心得多。4.4 跨域与前端集成的实操细节前后端分离项目里,跨域问题是绕不开的。认证中心、网关、前端往往部署在不同域名或端口,浏览器会拦截跨域请求。网关层统一处理CORS是最省事的方案:@Configuration public class CorsConfig { @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source); } }注意setAllowCredentials(true)和addAllowedOrigin("*")不能同时用,浏览器会拒绝。要用addAllowedOriginPattern("*")代替。这个坑我踩过,当时前端一直报CORS错误,排查半天才发现是这个组合问题。前端集成时,Token刷新逻辑要处理好。Access Token过期后,前端应该自动用Refresh Token换新的Access Token,而不是直接跳登录页。我的做法是在axios拦截器里统一处理401响应,触发刷新流程,刷新成功后重放原请求。但要注意并发请求的问题——如果同时有5个请求都返回401,不能触发5次刷新,要用一个标志位或Promise队列控制,只刷新一次,其他请求等刷新完成后重试。let isRefreshing = false; let failedQueue = []; axios.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response.status === 401 && !originalRequest._retry) { if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then(token => { originalRequest.headers['Authorization'] = 'Bearer ' + token; return axios(originalRequest); }); } originalRequest._retry = true; isRefreshing = true; try { const newToken = await refreshToken(); failedQueue.forEach(p => p.resolve(newToken)); failedQueue = []; originalRequest.headers['Authorization'] = 'Bearer ' + newToken; return axios(originalRequest); } catch (e) { failedQueue.forEach(p => p.reject(e)); failedQueue = []; redirectToLogin(); return Promise.reject(e); } finally { isRefreshing = false; } } return Promise.reject(error); } );这套刷新队列机制是我在多个项目里验证过的,能有效避免并发刷新导致的Token覆盖问题。踩过的坑是早期没做队列控制,结果多个请求同时刷新,后刷新的Token覆盖了先刷新的,导致部分请求用了已失效的Token,用户看到莫名其妙的报错。5. 权限模型设计与动态权限控制5.1 RBAC权限模型在微服务下的落地权限模型设计是认证授权的另一半,Token只解决了"你是谁",权限解决的是"你能干什么"。微服务架构下,权限模型要兼顾集中管理和服务自治。我采用的方案是集中定义、分布校验。权限码在认证中心统一管理,用户登录时把权限列表写进Token。各业务服务在接口上通过注解声明所需权限,Spring Security自动比对。这样权限的"定义权"在认证中心,"使用权"在业务服务,职责清晰。权限表设计上,核心是五张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。权限表里存权限码和描述,权限码按服务:资源:操作格式。比如订单服务的创建订单权限是order:order:create,查询订单列表是order:order:list。CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL UNIQUE COMMENT '权限码', perm_name VARCHAR(64) NOT NULL COMMENT '权限名称', service_name VARCHAR(64) NOT NULL COMMENT '所属服务', resource_type VARCHAR(32) COMMENT '资源类型', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );业务服务里用注解声明权限:@RestController @RequestMapping("/api/order") public class OrderController { @PreAuthorize("hasAuthority('order:order:create')") @PostMapping public Result<OrderVO> createOrder(@RequestBody OrderCreateDTO dto) { // 业务逻辑 } @PreAuthorize("hasAuthority('order:order:list')") @GetMapping public Result<PageResult<OrderVO>> listOrders(OrderQuery query) { // 业务逻辑 } }@PreAuthorize注解在方法执行前做权限校验,不通过直接抛AccessDeniedException,返回403。这种声明式权限控制比在代码里写if-else优雅得多,也便于统一管理。5.2 数据权限的精细化控制功能权限解决了"能不能访问这个接口",但很多场景还需要数据权限——"能看哪些数据"。比如销售只能看自己的订单,区域经理能看本区域的订单,总部能看全部。数据权限的实现思路是在Token里带上数据范围标识,业务服务在查询时动态拼接过滤条件。我一般用MyBatis拦截器实现,在SQL执行前自动追加数据权限条件。@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataPermissionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; // 判断是否需要数据权限过滤 if (ms.getId().contains("selectOrderList")) { Object parameter = invocation.getArgs()[1]; DataScope scope = getCurrentDataScope(); if (scope != DataScope.ALL) { // 动态修改SQL,追加数据范围条件 BoundSql boundSql = ms.getBoundSql(parameter); String newSql = appendDataScope(boundSql.getSql(), scope); // 替换SQL } } return invocation.proceed(); } }数据权限的粒度要提前想清楚,是行级还是列级,是本人还是本部门还是本组织。我见过项目做到一半才发现数据权限需求没考虑,回头改成本很高。建议在权限模型设计阶段就把数据权限的维度确定下来。5.3 动态权限变更的实时生效方案权限配置改了之后,怎么让已登录用户立即生效?这是个很实际的问题。如果等Token过期才生效,管理员改了权限要等2小时,体验很差。我的方案是版本号机制。用户权限变更时,递增该用户的权限版本号,存到Redis。网关校验Token时,比对Token里的版本号和Redis里的当前版本号,不一致就拒绝,强制用户重新登录或刷新Token。public boolean checkPermissionVersion(String userId, Long tokenVersion) { String key = "perm:version:" + userId; String currentVersion = redisTemplate.opsForValue().get(key); if (currentVersion == null) { return true; } return Long.parseLong(currentVersion) <= tokenVersion; }这个机制的好处是实时性好,权限一改立即生效;代价是每次请求多一次Redis查询。Redis查询很快,对性能影响可以忽略。如果追求极致性能,可以在网关本地缓存版本号,设置较短的过期时间(比如30秒),牺牲一点实时性换性能。另一个方案是主动推送,权限变更时通过消息队列通知网关,网关更新本地缓存。这个方案实时性和性能都好,但实现复杂度高,适合对实时性要求极高的场景。6. 生产环境部署与运维要点6.1 认证中心的高可用部署认证中心是单点,一旦挂掉整个系统都登录不了,所以必须做高可用。部署架构上,至少两个实例,前面挂负载均衡。因为JWT是无状态的,多实例部署没有Session同步问题,非常方便。但有几个细节要注意。密钥共享:如果用非对称加密,所有实例必须用同一套密钥对。我一般把密钥文件放在配置中心或挂载的共享存储里,启动时加载。时钟同步:多实例之间时间必须同步,否则Token的签发时间和过期时间会有偏差。用NTP服务保证时间误差在秒级以内。数据库连接:认证中心连的用户库如果也是单点,那认证中心的高可用就没意义了。用户库也要做主从或集群。健康检查接口要暴露出来,负载均衡器定期探测。/actuator/health接口返回UP才认为实例可用。我遇到过实例进程还在但数据库连接池耗尽的情况,健康检查能及时发现这种"假活"状态。6.2 日志审计与安全监控认证授权相关的操作必须留痕,出了问题才能追溯。需要记录的日志包括:登录成功/失败、Token签发/刷新/吊销、权限变更、敏感接口访问。日志格式要结构化,方便后续分析。我一般用JSON格式,包含时间戳、用户ID、操作类型、IP地址、User-Agent、结果状态等字段。{ "timestamp": "2024-01-15T10:30:00.000Z", "eventType": "LOGIN_SUCCESS", "userId": "10086", "username": "zhangsan", "clientId": "web-client", "ip": "192.168.1.100", "userAgent": "Mozilla/5.0...", "result": "SUCCESS" }安全监控方面,要重点关注几个异常指标:登录失败率突增可能是在暴力破解;同一IP短时间大量Token申请可能是攻击;非工作时间的管理员操作可能是账号被盗。这些指标配上告警,能提前发现安全事件。6.3 灰度发布与版本兼容认证中心升级时,新旧版本可能并存一段时间,Token格式或签名密钥可能有变化。灰度发布策略要设计好。如果只是业务逻辑变更,Token格式不变,那灰度很简单,新实例逐步替换旧实例即可。但如果Token格式变了(比如payload里加了新字段),就要考虑兼容性。我的做法是向后兼容,新版本能解析旧Token,旧版本忽略新字段。这样灰度期间新旧实例混跑不会出问题。密钥轮换也是类似思路。新密钥启用后,旧密钥保留一段时间,验签时先试新密钥,失败再试旧密钥。等所有旧Token都过期后,再移除旧密钥。这个过渡期至少是Access Token有效期的两倍。提示:认证中心的任何变更都要在测试环境充分验证,尤其是密钥和Token格式相关的改动。生产环境出认证故障,影响面是全站级的,比单个业务服务故障严重得多。6.4 容量规划与压测经验认证中心的容量规划要基于业务量估算。假设系统有10万用户,日活20%,每人每天登录2次,那登录请求约4万次/天。集中在早高峰2小时内,约5.5次/秒。这个量级单实例轻松扛住。但如果是对外的开放平台,QPS可能上万,就需要集群部署。压测时重点测三个接口:登录接口、Token刷新接口、Token校验接口。登录接口最重,涉及密码校验和数据库查询;刷新接口次之;校验接口最轻,但调用量最大。我压测的经验数据供参考:4核8G单实例,登录接口QPS约800,刷新接口约2000,校验接口约5000。实际规划时留50%余量,按这个数据反推需要多少实例。压测还要注意冷启动问题。认证中心刚启动时,JIT还没优化,连接池还没预热,性能只有稳定期的30%左右。所以滚动发布时要一个一个来,等前一个实例预热完成再发下一个,避免同时重启导致服务不可用。7. 个人实操体会与后续扩展方向这套SpringCloud加Spring Security OAuth2的统一认证方案,我在三个不同规模的项目里落地过,从日活几千到日活几十万都有。最大的体会是:前期设计多花一周,后期运维省半年。认证授权是基础设施,一旦上线再改,牵一发动全身。几个我认为最值得投入时间优化的点:一是密钥管理,用非对称加密加密钥库,别图省事用对称密钥;二是Token刷新机制,前端一定要做无感刷新,用户体验差别巨大;三是权限版本号,让权限变更实时生效,避免管理员改完权限还要等用户重新登录。后续扩展方向,我目前正在研究的是把认证中心和多因素认证结合,对敏感操作要求二次验证。另外就是在考虑用Spring Authorization Server替代Spring Security OAuth2,前者是官方的新一代实现,对OIDC协议支持更好,但迁移成本不低,需要评估。最后分享一个小技巧:认证中心的日志级别在生产环境设成WARN,不要开DEBUG。DEBUG级别下Spring Security会打印大量过滤链信息,日志量巨大,磁盘很快就满了。需要排查问题时临时开DEBUG,排查完立即改回去。这个坑我在生产环境踩过,半夜磁盘告警,起来一看全是认证日志。