1. 从零开始搭建你的第一个OAuth2认证服务器大家好我是老张一个在Java安全领域摸爬滚打了十年的老码农。今天咱们不聊那些虚头巴脑的理论直接上手用Spring Security OAuth2搭建一个真正能跑起来的认证服务器。我见过太多教程上来就是一堆概念看得人头大结果代码一跑就报错。这次咱们换个方式我带着你一步步走把每个配置都掰开了揉碎了讲清楚。首先你得明白OAuth2认证服务器是干嘛的。简单说它就是个“发牌中心”。当你的App客户端想访问用户在微信资源服务器里的头像时不能直接问用户要微信密码吧这时候OAuth2认证服务器就出场了用户告诉认证服务器“我允许这个App访问我的头像”认证服务器就给App发一张“临时通行证”Access Token。App拿着这个通行证去找微信微信一看是认证服务器盖的章就放行了。整个过程用户不用泄露密码安全又方便。咱们先来创建一个Spring Boot项目。我习惯用Spring Initializr选上Web、Security、OAuth2这几个依赖。如果你用的是Mavenpom.xml里关键依赖长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.security.oauth.boot/groupId artifactIdspring-security-oauth2-autoconfigure/artifactId version2.6.8/version /dependency注意版本号我写这篇文章时用的是2.6.8你实际操作时可能要用更新的版本。版本不匹配是我踩过的第一个坑有些API在新版本里变了老代码就跑不起来。创建完项目第一个要配置的是用户体系。Spring Security默认有个内存用户但实际项目肯定得连数据库。咱们先弄个简单的内存用户热热身后面再换成数据库。在配置类里加这么一段Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Bean Override public UserDetailsService userDetailsService() { UserDetails user User.withDefaultPasswordEncoder() .username(zhangsan) .password(123456) .roles(USER) .build(); return new InMemoryUserDetailsManager(user); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }这个配置创建了一个用户叫“zhangsan”密码是“123456”。注意withDefaultPasswordEncoder()这个方法它只是方便测试生产环境千万别用我当年有个项目就因为这个被安全扫描扫出问题密码居然没加密存储被甲方一顿批。接下来是重头戏认证服务器配置。新建一个类叫AuthorizationServerConfig加上EnableAuthorizationServer注解。这个注解一加Spring就会自动帮我们注册几个关键端点/oauth/authorize获取授权码、/oauth/token获取令牌、/oauth/check_token校验令牌等。Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Autowired private AuthenticationManager authenticationManager; Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(myclient) .secret({noop}mysecret) .authorizedGrantTypes(password, refresh_token) .scopes(read, write) .accessTokenValiditySeconds(3600) .refreshTokenValiditySeconds(86400); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager); } }这段配置定义了一个客户端“myclient”密钥是“mysecret”。{noop}表示不加密同样只用于测试。授权类型我选了password密码模式和refresh_token刷新令牌这是最常用的组合。scopes定义了权限范围你可以理解为这个客户端能干什么事。最后两个时间设置很重要访问令牌1小时过期刷新令牌1天过期。到这里一个最基础的认证服务器就搭好了。启动项目用Postman测试一下。发送POST请求到http://localhost:8080/oauth/token带上这些参数grant_type: password username: zhangsan password: 123456 client_id: myclient client_secret: mysecret你会收到一个JSON响应里面包含access_token和refresh_token。恭喜你第一个令牌诞生了这个令牌现在还是存在内存里的重启服务就没了。别急咱们后面会讲怎么持久化。注意在实际项目中密码模式要慎用。它需要客户端收集用户的用户名密码只适用于高度信任的客户端比如你自己的手机App。如果是第三方应用应该用授权码模式。2. 令牌存储四大金刚内存、Redis、JDBC、JWT怎么选令牌生成出来了得找个地方存起来。Spring Security OAuth2给了我们四种选择我管它们叫“四大金刚”。每种都有适用场景选错了性能差十倍不止。内存存储InMemoryTokenStore这是默认选项简单粗暴。令牌就放在应用内存里重启就丢。我一般只在开发调试时用因为太方便了不用装Redis不用建表拿来就能测。但千万别上生产单机还好一旦部署多实例用户在这个实例登录跑到另一个实例就失效了体验极差。Redis存储RedisTokenStore这是我用得最多的方案。Redis读写快还能设置过期时间天生适合存令牌。配置起来也不复杂先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在配置文件里配Redis连接spring: redis: host: localhost port: 6379 password: 你的密码最后在认证服务器配置里指定用RedisBean public TokenStore tokenStore() { return new RedisTokenStore(redisConnectionFactory); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore()); }实测下来Redis方案能轻松扛住千级QPS。但要注意内存占用令牌多了Redis会爆。我建议设置合理的过期时间并监控内存使用率。JDBC存储JdbcTokenStore适合对持久化要求高的场景。令牌存数据库不怕重启丢失。但性能是硬伤每次校验令牌都要查库。配置前要先建表官方提供了建表语句在spring-security-oauth2的jar包里找schema.sql。不过直接用它可能会报错因为有些字段类型MySQL不支持得改改CREATE TABLE oauth_access_token ( token_id VARCHAR(256) DEFAULT NULL, token BLOB, authentication_id VARCHAR(256) DEFAULT NULL, user_name VARCHAR(256) DEFAULT NULL, client_id VARCHAR(256) DEFAULT NULL, authentication BLOB, refresh_token VARCHAR(256) DEFAULT NULL );注意我把LONGVARBINARY改成了BLOBVARCHAR(256)长度也调整了。配置代码长这样Bean public TokenStore tokenStore(DataSource dataSource) { return new JdbcTokenStore(dataSource); }JWT存储JwtTokenStore这是现在的网红方案。JWT令牌自带用户信息资源服务器不用每次都找认证服务器校验自己就能验签。但有个坑令牌一旦发出就无法撤销只能等过期。所以刷新令牌的时间别设太长。配置JWT需要生成密钥对。我用的是Java的keytool工具keytool -genkeypair -alias mykey -keyalg RSA -keypass 123456 -keystore jwt.jks -storepass 123456然后把生成的jwt.jks文件放到resources目录下。配置代码Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); KeyStoreKeyFactory keyStoreKeyFactory new KeyStoreKeyFactory(new ClassPathResource(jwt.jks), 123456.toCharArray()); converter.setKeyPair(keyStoreKeyFactory.getKeyPair(mykey)); return converter; } Bean public TokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); }四种方案怎么选我画了个决策图单机开发用内存高并发用Redis要强一致性用JDBC微服务架构用JWT。但实际项目中我经常混用用Redis存刷新令牌用JWT做访问令牌取长补短。3. 实战配置从内存到数据库的完整迁移光说不练假把式咱们来真的。假设你现在有个用内存存储的项目要上线得改成数据库存储。我带你走一遍完整流程这些都是我趟过的坑。第一步客户端信息入库。之前是在代码里写死的clients.inMemory() .withClient(myclient) .secret({noop}mysecret) // ... 其他配置现在要改成从数据库读。先建表表结构就用官方提供的oauth_client_details但记得改字段类型适应MySQLCREATE TABLE oauth_client_details ( client_id VARCHAR(128) PRIMARY KEY, resource_ids VARCHAR(256), client_secret VARCHAR(256), scope VARCHAR(256), authorized_grant_types VARCHAR(256), web_server_redirect_uri VARCHAR(256), authorities VARCHAR(256), access_token_validity INT, refresh_token_validity INT, additional_information VARCHAR(4096), autoapprove VARCHAR(256) );插一条测试数据注意密码要加密。我一般写个单元测试来生成加密后的密码Test public void testPasswordEncoder() { PasswordEncoder encoder new BCryptPasswordEncoder(); System.out.println(encoder.encode(mysecret)); }把输出的密文存到数据库。然后修改配置Bean public ClientDetailsService clientDetailsService(DataSource dataSource) { JdbcClientDetailsService clientDetailsService new JdbcClientDetailsService(dataSource); clientDetailsService.setPasswordEncoder(passwordEncoder()); return clientDetailsService; } Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(clientDetailsService); }这样客户端信息就交给数据库管理了。加新客户端直接插表就行不用改代码重启服务。第二步令牌存储改JDBC。配置刚才已经讲过了但这里有个细节默认的表结构没有索引数据多了查询会慢。我建议加几个索引CREATE INDEX idx_auth_id ON oauth_access_token(authentication_id); CREATE INDEX idx_user_name ON oauth_access_token(user_name); CREATE INDEX idx_client_id ON oauth_access_token(client_id);第三步授权码也存数据库。默认授权码是在内存里的单机没问题集群就麻烦了。配置很简单Bean public AuthorizationCodeServices authorizationCodeServices(DataSource dataSource) { return new JdbcAuthorizationCodeServices(dataSource); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authorizationCodeServices(authorizationCodeServices) // ... 其他配置 }但说实话授权码存数据库意义不大。因为授权码只用一次用完就删生命周期很短。除非你特别在意高可用否则内存够用了。迁移过程中最常见的错误是“无效的客户端”。八成是密码没加密或者加密方式不对。Spring Security的密码加密有几种格式{noop}表示明文{bcrypt}是BCrypt加密。数据库里存的必须带上前缀不然它不认识。还有个坑是字段长度。官方schema里有些字段是VARCHAR(256)在MySQL里会报错因为MySQL的索引限制是767字节。我一般改成VARCHAR(128)够用了。4. 安全加固令牌端点与权限控制认证服务器建好了但不能谁都能访问。想象一下如果/oauth/token端点裸奔在外网黑客分分钟给你刷爆。所以得加防护。首先控制哪些端点能公开访问。默认情况下/oauth/token_key和/oauth/check_token是关闭的。但资源服务器需要调用check_token来验证令牌所以得打开。在认证服务器配置里加这段Override public void configure(AuthorizationServerSecurityConfigurer security) { security.tokenKeyAccess(permitAll()) .checkTokenAccess(isAuthenticated()); }这样/oauth/token_key完全公开因为要获取公钥/oauth/check_token需要认证。但注意这个认证是客户端认证不是用户认证。也就是说资源服务器要用自己的client_id和client_secret来调用这个端点。怎么认证两种方式。一是在请求头里加Basic AuthAuthorization: Basic bXljbGllbnQ6bXlzZWNyZXQ这个字符串是client_id:client_secret的Base64编码。二是在请求体里传参数client_idmyclientclient_secretmysecret我推荐用Basic Auth更标准也更安全。接下来是防止暴力破解。有人可能会用脚本疯狂尝试密码得限流。Spring Security没自带限流但我们可以用Spring的ControllerAdvice配合Guava的RateLimiterControllerAdvice public class RateLimitInterceptor implements HandlerInterceptor { private final RateLimiter limiter RateLimiter.create(10.0); // 每秒10次 Override public boolean preHandle(HttpServletRequest request, HttpServletRequest response, Object handler) { if (request.getRequestURI().contains(/oauth/token)) { if (!limiter.tryAcquire()) { response.setStatus(429); // Too Many Requests return false; } } return true; } }然后注册这个拦截器。这样就能防止有人刷接口了。令牌本身也要防泄露。我建议做到以下几点第一强制使用HTTPS防止令牌在传输中被窃听。第二设置合理的过期时间访问令牌短一些比如1小时刷新令牌长一些比如7天。第三提供令牌撤销机制万一泄露了能及时作废。说到撤销OAuth2有个/oauth/revoke端点但默认没开启。要自己实现的话可以继承DefaultTokenServicesRestController public class TokenRevocationEndpoint { Autowired private ConsumerTokenServices tokenServices; PostMapping(/oauth/revoke) public ResponseEntity? revokeToken(RequestParam(token) String token) { if (tokenServices.revokeToken(token)) { return ResponseEntity.ok().build(); } return ResponseEntity.badRequest().build(); } }这样用户发现令牌泄露可以主动撤销。不过JWT令牌没法撤销这是它的硬伤。所以重要系统我一般不用纯JWT而是用“JWT Redis”的混合方案JWT里只放基本信息撤销状态存Redis。最后别忘了日志和监控。所有令牌的发放、使用、撤销都要记日志。我习惯用AOP在TokenEndpoint周围加切面Aspect Component public class TokenEndpointAspect { Around(execution(* org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.*(..))) public Object logTokenRequest(ProceedingJoinPoint joinPoint) throws Throwable { // 记录请求参数脱敏后 // 记录IP、时间、客户端 Object result joinPoint.proceed(); // 记录响应结果 return result; } }这些安全措施看起来繁琐但都是血泪教训。我有个项目没做限流上线第一天就被爬虫刷了十几万次CPU直接打满。从那以后所有对外接口必加防护。5. 高级技巧自定义令牌与扩展字段有时候默认的令牌不够用比如你想在令牌里加用户部门、角色列表这些信息。Spring OAuth2提供了扩展机制我来教你几种实用技巧。第一种往JWT里加自定义字段。这可能是最常用的需求。实现一个TokenEnhancerComponent public class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { if (authentication.getPrincipal() instanceof UserDetails) { UserDetails user (UserDetails) authentication.getPrincipal(); MapString, Object additionalInfo new HashMap(); additionalInfo.put(department, 研发部); additionalInfo.put(employeeId, 1001); // 如果是JWT令牌 if (accessToken instanceof DefaultOAuth2AccessToken) { ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); } } return accessToken; } }然后在配置里启用它Bean public TokenEnhancerChain tokenEnhancerChain() { TokenEnhancerChain chain new TokenEnhancerChain(); chain.setTokenEnhancers(Arrays.asList(customTokenEnhancer, jwtAccessTokenConverter())); return chain; } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.tokenEnhancer(tokenEnhancerChain()); }这样生成的JWT里就会多出department和employeeId字段。资源服务器解析JWT时就能拿到这些信息不用再查数据库。第二种自定义令牌格式。默认的JWT字段可能不符合你们公司的规范。比如你想把user_name改成username可以继承DefaultAccessTokenConverterpublic class CustomAccessTokenConverter extends DefaultAccessTokenConverter { Override public MapString, ? convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) { MapString, Object response (MapString, Object) super.convertAccessToken(token, authentication); // 重命名字段 response.put(username, response.remove(user_name)); // 添加自定义字段 response.put(issued_at, System.currentTimeMillis() / 1000); return response; } }然后在JWT转换器里设置Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setAccessTokenConverter(new CustomAccessTokenConverter()); converter.setKeyPair(keyPair()); return converter; }第三种动态Scope。默认Scope是在客户端配置里写死的但有些场景需要动态Scope。比如同一个客户端不同用户有不同的权限范围。这需要自定义TokenGranterpublic class DynamicScopeTokenGranter extends ResourceOwnerPasswordTokenGranter { public DynamicScopeTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { super(authenticationManager, tokenServices, clientDetailsService, requestFactory); } Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { // 根据用户信息计算动态scope SetString dynamicScopes calculateDynamicScopes(); // 合并到请求中 tokenRequest.setScope(dynamicScopes); return super.getOAuth2Authentication(client, tokenRequest); } private SetString calculateDynamicScopes() { // 你的业务逻辑 return new HashSet(Arrays.asList(read, write)); } }注册这个Granter稍微复杂点要重写AuthorizationServerEndpointsConfigurer的getDefaultTokenGranters方法。篇幅所限这里不展开了有兴趣可以查官方文档。第四种令牌绑定设备。移动端安全要求高经常需要绑定设备。实现思路是在令牌里加设备指纹校验时比对。首先在登录时传设备信息grant_typepasswordusernamexxxpasswordxxxdevice_idabc123然后在自定义的TokenEnhancer里把这个设备ID存起来。校验令牌时从数据库或Redis里查这个设备ID是否被注销。如果用户换了设备要求重新登录。这些高级技巧用好了能解决很多实际问题。但也要注意别过度设计我见过有人往JWT里塞了整个用户对象导致令牌太大每次请求都多传几KB数据。一般来说只放必要的信息其他信息让资源服务器按需查询。6. 生产环境部署性能优化与监控告警项目要上线了但直接拿开发配置去生产肯定不行。我总结了几条生产环境必备的优化项都是实战中踩坑总结的。第一连接池配置。如果用JDBC存储令牌数据库连接池必须配。我常用HikariCP在application.yml里配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000这些数字不是拍脑袋来的。maximum-pool-size根据你的数据库性能和并发量来定一般公式是CPU核心数 * 2 磁盘数。connection-timeout设30秒防止网络抖动时线程一直等。第二Redis优化。如果用Redis存储这几个配置很重要spring: redis: lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5 timeout: 2000msRedis连接池大小和数据库类似。timeout设2秒超时就报错别让请求一直挂着。还有一定要开持久化不然重启Redis令牌全丢。在redis.conf里配置save 900 1 save 300 10 save 60 10000意思是900秒内至少1次修改就保存300秒内至少10次修改就保存60秒内至少10000次修改就保存。根据你的业务量调整。第三JWT密钥管理。生产环境不能用固定的密钥文件得定期轮换。我一般这么实现准备两套密钥新旧并存。认证服务器用新密钥签发但用两套密钥验证。等所有旧令牌都过期了再淘汰旧密钥。代码大概长这样Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); // 从配置中心或数据库读取当前有效的密钥 ListKeyPair keyPairs keyService.getActiveKeyPairs(); // 第一个是当前签名用的 converter.setKeyPair(keyPairs.get(0)); // 设置验证用的密钥对 converter.setVerifierKey(getPublicKeyString(keyPairs)); return converter; } private String getPublicKeyString(ListKeyPair keyPairs) { // 把所有公钥拼成一个字符串 StringBuilder sb new StringBuilder(); for (KeyPair kp : keyPairs) { sb.append(-----BEGIN PUBLIC KEY-----\n); sb.append(Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); sb.append(\n-----END PUBLIC KEY-----\n); } return sb.toString(); }第四监控指标。没有监控的系统就是在裸奔。至少要监控这些指标令牌发放速率、令牌验证失败率、接口响应时间、数据库/Redis连接数。用Spring Actuator暴露指标management: endpoints: web: exposure: include: health,metrics,prometheus metrics: export: prometheus: enabled: true然后配Grafana看板。我常用的几个关键看板令牌发放的QPS、95分位响应时间、错误码分布。一旦发现异常比如错误率突然升高马上告警。第五灰度发布。认证服务器升级不能直接全量重启正在用的令牌会失效。我的做法是新老版本并行运行一段时间新令牌用新版本签发老令牌两个版本都能验证。等老令牌都过期了再下线老版本。具体实现可以用Nginx做流量分发根据令牌的签发时间决定走哪个版本。或者更简单点用Spring的ProfileProfile(v1) Bean public TokenServices tokenServicesV1() { // 老版本实现 } Profile(v2) Bean public TokenServices tokenServicesV2() { // 新版本实现 }启动时指定profile两个实例跑不同的配置。第六容灾备份。认证服务器挂了整个系统就瘫了。必须做高可用。我的方案是认证服务器无状态前面挂负载均衡。令牌存储用Redis集群一主多从。数据库做主从复制。这样任何一个节点挂掉都能自动切换。最后说一个很多人忽略的点文档。接口怎么调用错误码什么意思限流规则是什么这些都要写成文档。我习惯用Swagger生成API文档再补充一些业务说明。新同事接手时看文档比看代码快多了。部署上线只是开始真正的挑战在运维阶段。有一次我们令牌服务突然变慢查了半天发现是Redis big key问题有个用户生成了上百万个令牌明显是刷接口导致Redis内存暴涨。后来加了令牌数量限制单个用户最多同时有10个有效令牌问题才解决。所以监控和告警不是摆设是真的能救命的。