OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践
前期内容导读:
- Java开源RSA/AES/SHA1/PGP/SM2/SM3/SM4加密算法介绍
- Java开源AES/SM4/3DES对称加密算法介绍及其实现
- Java开源AES/SM4/3DES对称加密算法的验证说明
- Java开源RSA/SM2非对称加密算法对比介绍
- Java开源RSA非对称加密算法实现
- Java开源SM2非对称加密算法实现
- Java开源接口微服务代码框架
- Json在开源SpringBoot/SpringCloud微服务框架中的最佳实践
- 加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践
- 链路追踪在开源SpringBoot/SpringCloud微服务框架的最简实践
- 在前面详细介绍的基础上,且代码全部开源后,这次来完整介绍下oauth2在微服务解决方案中到底是如何改造的。
- 应该是先有业务,才会有微服务设计。此开源的微服务设计见Java开源接口微服务代码框架 文章,现把核心设计摘录如下:
1. 开源代码整体设计
+------------+
| bq-log |
| |
+------------+
Based on SpringBoot
|
|
v
+------------+ +------------+ +------------+ +-------------------+
|bq-encryptor| +-----> | bq-base | +-----> |bq-boot-root| +-----> | bq-service-gateway|
| | | | | | | |
+------------+ +------------+ +------------+ +-------------------+
Based on BouncyCastle Based on Spring Based on SpringBoot Based on SpringBoot-WebFlux
+
|
v
+------------+ +-------------------+
|bq-boot-base| +-----> | bq-service-auth |
| | | | |
+------------+ | +-------------------+
ased on SpringBoot-Web | Based on SpringSecurity-Authorization-Server
|
|
|
| +-------------------+
+-> | bq-service-biz |
| |
+-------------------+
说明:
bq-encryptor
:基于BouncyCastle
安全框架,已开源 ,加解密介绍
,支持RSA
/AES
/PGP
/SM2
/SM3
/SM4
/SHA-1
/HMAC-SHA256
/SHA-256
/SHA-512
/MD5
等常用加解密算法,并封装好了多种使用场景、做好了为SpringBoot所用的准备;bq-base
:基于Spring框架的基础代码框架,已开源 ,支持json
/redis
/DataSource
/guava
/http
/tcp
/thread
/jasypt
等常用工具API;bq-log
:基于SpringBoot框架的基础日志代码,已开源 ,支持接口Access日志、调用日志、业务操作日志等日志文件持久化,可根据实际情况扩展;bq-boot-root
:基于SpringBoot,已开源 ,但是不包含spring-boot-starter-web
,也不包含spring-boot-starter-webflux
,可通用于servlet
和netty
web容器场景,封装了redis
/http
/定时器
/加密机
/安全管理器
等的自动注入;bq-boot-base
:基于spring-boot-starter-web
(servlet,BIO),已开源 ,提供常规的业务服务基础能力,支持PostgreSQL
/限流
/bq-log
/Web框架
/业务数据加密机加密
等可配置自动注入;bq-service-gateway
:基于spring-boot-starter-webflux
(Netty,NIO),已开源 ,提供了Jwt Token安全校验能力,包括接口完整性校验
/接口数据加密
/Jwt Token合法性校验等;bq-service-auth
:基于spring-security-oauth2-authorization-server
,已开源 ,提供了JwtToken生成和刷新的能力;bq-service-biz
:业务微服务参考样例,已开源 ;
2. 微服务逻辑架构设计
+-------------------+
| Web/App Client |
| |
+-------------------+
|
|
v
+--------------------------------------------------------------------+
| | Based On K8S |
| |1 |
| v |
| +-------------------+ 2 +-------------------+ |
| | bq-service-gateway| +-------> | bq-service-auth | |
| | | | | |
| +-------------------+ +-------------------+ |
| |3 |
| +-------------------------------+ |
| v v |
| +-------------------+ +-------------------+ |
| | bq-service-biz1 | | bq-service-biz2 | |
| | | | | |
| +-------------------+ +-------------------+ |
| |
+--------------------------------------------------------------------+
说明:
bq-service-gateway
:基于SpringCloud-Gateway
,用作JwtToken鉴权,并提供了接口、数据加解密的安全保障能力;bq-service-auth
:基于spring-security-oauth2-authorization-server
,提供了JwtToken生成和刷新的能力;bq-service-biz
:基于spring-boot-starter-web
,业务微服务参考样例;k8s
在上述微服务架构中,承担起了服务注册和服务发现的作用,鉴于k8s
云原生环境构造较为复杂,实际开源的代码时,以Nacos
(为主)/Eureka
做服务注册和服务发现中间件;- 以上所有服务都以docker容器作为载体,确保服务有较好地集群迁移和弹性能力,并能够逐步平滑迁移至k8s的终极目标;
- 逻辑架构不等同于物理架构(部署架构),实际业务部署时,还有DMZ区和内网区,本逻辑架构做了简化处理;
3. OAuth2服务的业务场景及方案设计
- 很久以前,主要的鉴权方式是账号密码认证,适用于前后端交互,通过浏览器的session来绑定会话。但是随着业务场景的复杂化,有些是不需要经过浏览器的,如接口和接口之前的调用;有些还会涉及三方交互,没法简单地通过账号密码来认证;
- OAuth2是一种比较优雅的鉴权方式,适用于上面提到的三方交互。比如微信小程序,就会涉及到小程序前端和服务后端以及微信官方后端的鉴权;
- OAuth2同样也适用于系统与系统间的鉴权,如:2个系统间只有接口调用,则非常适用于通过OAuth2来做接口鉴权;
- 一个系统中,如果账号密码认证需要单独的一套认证体系和独有的代码,而接口和三方认证需要另外的认证体系和代码,对于大团队非常正常,对于小团队而言则是一场灾难。
- OAuth2共有4种授权模式:授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials) ;如果不涉及第三方系统的后端授权,建议采取客户端模式。
3.1 业务场景介绍
- 系统中已经落地了OAuth2作为接口服务的认证方式,现在需要新增管理页面,用于更好地对系统参数进行配置;
- 系统不仅仅要支持PC Web页面访问,还要支持移动端页面访问,包括微信小程序和手机浏览器访问;
- 不仅仅要支持账号密码登录,还要支持二维码扫码登录;
3.2 整体方案设计
- 系统最开始使用的OAuth2认证框架是
Spring Authorization Server
,2021年底被官方下架了,替代为Spring-Security-OAuth2-Authorization-Server
; - 考虑到系统的稳定性,继续沿用JDK1.8,
Spring-Security-OAuth2-Authorization-Server
只能选用0.2.3
,则必须把SpringBoot/SpringCloud
版本由2.7.x+
/3.1.x+
,降至2.5.x+
/3.0.x+
; - 继续改造并使用OAuth2认证来支持账号密码登录登录和二维码扫码登录,并且还要支持JwtToken的刷新,以支持页面会话的续期;
- 对JwtToken内置的字段进行改造,需要区分各个不同的渠道,以方便后续单独对某一个渠道做单独的权限控制或者业务逻辑处理;
3.3 方案详细设计
- 从
Spring Authorization Server
到Spring-Security-OAuth2-Authorization-Server
的扩展点变动非常大,相当于重写一个新的OAuth2服务; - 因为前期只是接口服务,所以采用了客户端模式,而客户端模式是不支持Token刷新的,所以还需要自己来定制JwtToken生成;
- 为了安全考虑,JwtToken生成时,就一次性生成正常JwtToken和刷新JwtToken,二者的时效不同,正常JwtToken默认15分钟有效,刷新JwtToken半小时有效,正常JwtToken不能访问JwtToken刷新接口,刷新JwtToken只能访问JwtToken刷新接口;
- 由前端使用正常JwtToken调用后端服务时,后端返回JwtToken过期时,前端可使用刷新JwtToken调用刷新接口重新获取一个正常JwtToken和一个刷新JwtToken。刷新JwtToken也过期时,则需要返回登录界面重新登录;
- 二维码登录与OAuth2认证几乎没有任何联系,相当于需要不依赖框架就构造出一个JwtToken来;
- 对JwtToken内置的字段进行扩展,支持自定义扩展字段;
4. OAuth2服务的技术实现
-
Spring-Security-OAuth2-Authorization-Server
主要是通过配置服务生成过滤器而形成一套完整的权限控制体系的,当前的配置能力开发得相对较少,需要采取多种方式灵活地扩展。本人先后采取了反射和非开放的扩展点来达成这一目的,但是相比较而言,后者更优雅一些。
4.1 JwtToken的扩展实现
通过自定义的加密算法来生成JWK(JSON Web Keys),逻辑如下:
-
在SpringBoot yaml 中定义RSA2048公钥和私钥:
bq: auth: ignoreUrls: /auth/user/*,/${spring.application.name}/monitor/* channels: jwt: serviceId: 6e3c6f31b6894254ae0cd887deaf3318 pubKey: ENC([key]8081087ac1...) priKey: ENC([key]ac44126761...) #jwt访问地址 url: /oauth/token #jwk访问地址 authUrl: /oauth/jwk #token过期时间(s) connTimeout: 1800 #刷新token的过期时间(s) timeout: 3600
-
新增配置服务
@Slf4j @Configuration public class JwtConfigurer { @Bean(CommonBootConst.JWT_CHANNEL_CONFIG) @ConfigurationProperties(prefix = "bq.channels.jwt") public Channel jwtChannel() { return new Channel(); } @Bean public JwkMgr jwkMgr(@Qualifier(CommonBootConst.JWT_CHANNEL_CONFIG) Channel channel) { return new JwkMgr(channel); } @Bean public JwtMgr jwtMgr(JwkMgr jwkMgr) { return new JwtMgr(jwkMgr); } }
-
新增jwk管理器服务JwkMgr :
public final class JwkMgr { public JwkMgr(Channel channel) { byte[] pubBytes = Hex.decode(channel.getPubKey()); if (null != channel.getPriKey()) { byte[] priBytes = Hex.decode(channel.getPriKey()); this.priJwk = genRsaKey(priBytes, pubBytes, channel.getServiceId()); } } /** * 生成标准的JWK对象 * * @return JWK秘钥对象 */ public JWK getJwk() { return this.priJwk; } /** * 生成JWK对象 * * @param priKey 私钥(非必传时,表示仅需公钥验证) * @param pubKey 公钥 * @param kid 秘钥id(可重新设置,重启后对所有客户端生效) * @return JWK秘钥对象 */ private static RSAKey genRsaKey(byte[] priKey, byte[] pubKey, String kid) { RSAPublicKey rsaKey = (RSAPublicKey)ENCRYPTION.toPubKey(pubKey); RSAKey.Builder builder = new RSAKey.Builder(rsaKey); if (null != priKey) { PrivateKey rsaPriKey = ENCRYPTION.toPriKey(priKey); builder.privateKey(rsaPriKey); } if (null == kid) { kid = IdUtil.uuid(); } return builder.keyID(kid).build(); } /** * 加密算法 */ private final static BaseSingleSignature ENCRYPTION = EncryptionFactory.RSA.createAlgorithm(); /** * 私钥JWK */ private JWK priJwk; }
-
扩展oauth2框架中jwk和jwt生成,配置服务为ServerConfigurer :
@Slf4j @EnableWebSecurity @Configuration(proxyBeanMethods = false) public class ServerConfigurer { /** * 注入秘钥管理服务 * * @param jwkMgr 秘钥管理服务({@link com.biuqu.boot.startup.auth.configure.JwtConfigurer#jwkMgr(Channel)}) * @return 秘钥管理服务 */ @Bean public JWKSource<SecurityContext> jwkSource(JwkMgr jwkMgr) { JWKSet jwkSet = new JWKSet(jwkMgr.getJwk()); return (jwkSelector, context) -> jwkSelector.select(jwkSet); } /** * 注入 jwt token生成器 * * @param jwkSource 秘钥上下文 * @return jwt token生成器 */ @Bean public JwtGenerator jwtGenerator(JWKSource<SecurityContext> jwkSource) { //1.oauth2-server0.2.3匹配的springboot和springboot-security是2.5.12,无法使用NimbusJwtEncoder JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwsEncoder(jwkSource)); jwtGenerator.setJwtCustomizer(tokenCustomizer); return jwtGenerator; } @Autowired private OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer; }
-
定制的Jwt字段服务JwtCustomizerServiceImpl 如下:
@Slf4j @Service public class JwtCustomizerServiceImpl implements OAuth2TokenCustomizer<JwtEncodingContext> { @Override public void customize(JwtEncodingContext context) { String clientId = context.getRegisteredClient().getClientId(); ClientResource param = new ClientResource(); param.setAppId(clientId); ClientResource clientResource = clientService.get(param); context.getClaims().claim(AuthConst.JWT_RESOURCES, clientResource.getResources()); context.getClaims().claim(JwtClaimNames.JTI, IdUtil.uuid()); context.getClaims().claim(AuthConst.JWT_TYPE, AuthConst.JWT_TYPE_TOKEN); context.getClaims().claim(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue()); //获取复制出来的jwt body键值对 Map<String, Object> claims = context.getClaims().build().getClaims(); Object sourceType = JwtSourceType.SDK.name(); if (claims.containsKey(AuthConst.JWT_SOURCE_TYPE)) { sourceType = claims.get(AuthConst.JWT_SOURCE_TYPE); } context.getClaims().claim(AuthConst.JWT_SOURCE_TYPE, sourceType); } /** * 注入client信息查询服务 */ @Autowired private BaseBizService<ClientResource> clientService; }
-
扩展框架的过滤器,新增过滤器管理器服务SecurityFilterMgr :
@Slf4j @Component public final class SecurityFilterMgr { /** * 构建定制的过滤器链 * * @param http 基于请求的安全对象 * @param authManager 安全认证管理对象 * @return 过滤器链(Filter模式) * @throws Exception 初始化过滤器时的异常 */ public SecurityFilterChain custom(HttpSecurity http, AuthenticationManager authManager) throws Exception { SecurityFilterChain filterChain = http.build(); for (Filter filter : filterChain.getFilters()) { if (filter instanceof BearerTokenAuthenticationFilter) { ((BearerTokenAuthenticationFilter)filter).setAuthenticationFailureHandler(authFailureHandler); } else if (filter instanceof OAuth2TokenEndpointFilter) { //1.加自定义属性 OAuth2TokenEndpointFilter tokenFilter = (OAuth2TokenEndpointFilter)filter; tokenFilter.setAuthenticationSuccessHandler(authSuccessHandler); //2.新增刷新转换器 AuthenticationConverter authConverter = new DelegatingAuthenticationConverter( Arrays.asList(new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), new OAuth2ClientCredentialsAuthenticationConverter(), refreshAuthConverter)); tokenFilter.setAuthenticationConverter(authConverter); //3.新增刷新认证器 if (authManager instanceof ProviderManager) { ProviderManager providerManager = (ProviderManager)authManager; List<AuthenticationProvider> providers = providerManager.getProviders(); providers.add(refreshAuthProvider); } } } return filterChain; } /** * 新增的刷新token认证器 */ @Autowired private AuthenticationProvider refreshAuthProvider; /** * 新增的刷新token转换器 */ @Autowired private AuthenticationConverter refreshAuthConverter; /** * 认证成功的处理器 */ @Autowired private AuthenticationSuccessHandler authSuccessHandler; /** * 认证失败的异常处理器 */ @Autowired private AuthenticationFailureHandler authFailureHandler; }
-
在认证成功的过滤器中需要保留前面定制的字段,新增主要服务JwtRespMapConverterImpl :
@Slf4j @Component public class JwtRespMapConverterImpl extends BaseJwtRespMapConverter { @Override protected ResultCode<JwtResult> toJwtResult(Map<String, Object> parameters) { String jwt = parameters.get(OAuth2ParameterNames.ACCESS_TOKEN).toString(); //1.添加扩展字段 JwtToken jwtToken = JwtUtil.getJwtToken(jwt); parameters.put(AuthConst.JWT_RESOURCES, jwtToken.getResources()); parameters.put(AuthConst.CLIENT_ID, jwtToken.toClientId()); parameters.put(JwtClaimNames.JTI, jwtToken.getJti()); //2.生成刷新token String refreshJwt = jwtTokenGen.genRefreshJwt(jwt); parameters.put(OAuth2ParameterNames.REFRESH_TOKEN, refreshJwt); return super.toJwtResult(parameters); } /** * jwt token生成器 */ @Autowired private JwtTokenGen jwtTokenGen; }
-
再把上述的扩展点扩展进过滤器的配置服务ServerConfigurer ,上面已列举,此处仅摘要关键方法:
/** * 注入认证管理器 * * @param authConf 认证配置 * @return 认证管理器 * @throws Exception 初始化异常 */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConf) throws Exception { return authConf.getAuthenticationManager(); } @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain serverChain(HttpSecurity http, AuthenticationManager authManager, JWKSource<SecurityContext> jwkSource, JwtGenerator jwtGen) throws Exception { //1.前后端分离,禁用会话管理和csrf(跨站攻击) http.sessionManagement().disable(); http.csrf().disable(); //2.添加匿名访问的url(应该包括jwk) Set<String> anonymous = Sets.newHashSet(); if (!CollectionUtils.isEmpty(ignoreUrls)) { anonymous.addAll(ignoreUrls); } String[] anonUrls = anonymous.toArray(new String[] {}); http.authorizeRequests(registry -> registry.antMatchers(anonUrls).permitAll().anyRequest().authenticated()); //3.设置服务端配置(指定jwt生成器等) OAuth2AuthorizationServerConfigurer<HttpSecurity> serverConf = new OAuth2AuthorizationServerConfigurer<>(); http.apply(serverConf); serverConf.tokenGenerator(jwtGen); //设置认证信息匹配失败的异常 serverConf.clientAuthentication(clientConf -> clientConf.errorResponseHandler(this.failureHandler)); //设置token生成失败的异常 serverConf.tokenEndpoint(tokenConf -> tokenConf.errorResponseHandler(this.failureHandler)); //设置全局处理异常 http.exceptionHandling(exceptionHandler -> exceptionHandler.accessDeniedHandler(this.exceptionHandler)); //4.设置业务请求的jwt解析配置 http.oauth2ResourceServer(resourceConf -> { resourceConf.bearerTokenResolver(new DefaultBearerTokenResolver()); OAuth2ResourceServerConfigurer<HttpSecurity>.JwtConfigurer resourceJwtConf = resourceConf.jwt(); resourceJwtConf.decoder(OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource)); //设置资源解析失败的异常(主要是资源带的token解析/认证失败) resourceConf.accessDeniedHandler(this.exceptionHandler); }); return filterMgr.custom(http, authManager); }
综上:
- 通过yaml配置RSA2048公钥和私钥就可以自动生成Jwk秘钥对,并通过框架的扩展点去扩展JwtToken的字段生成;
- JwtToken自定义的字段扩展点里,是通过自定义的服务去实现的,这样就可以完全自主的控制JwtToken字段的生成,但是过程非常繁琐,须好好阅读源码;
4.2 认证和鉴权的分离实现
- 前面讲了OAuth2生成的Token方式与以前的会话方式不同,OAuth2的认证方式不限于浏览器会话,而JwtToken则非常适用于认证(生成会话)和鉴权(鉴定会话权限)的分离;
4.2.1 OAuth2服务Token认证实现
- 前面已经讲了定制字段的JwtToken的生成,展开说说如何使用自定义的认证数据源,自定义的认证数据源服务ClientRepositoryServiceImpl 如下:
@Slf4j @Service public class ClientRepositoryServiceImpl implements RegisteredClientRepository { @Override public void save(RegisteredClient registeredClient) { } @Override public RegisteredClient findById(String id) { return null; } @Override public RegisteredClient findByClientId(String clientId) { if (StringUtils.isEmpty(clientId)) { log.error("invalid client id."); return null; } ClientResource clientParam = new ClientResource(); clientParam.setAppId(clientId); ClientResource clientResource = clientService.get(clientParam); if (clientResource.isEmpty()) { log.error("invalid client."); return null; } RegisteredClient.Builder clientBuilder = Oauth2Builder.build(clientId, jwtChannel.getConnTimeout()); clientBuilder.clientSecret(pwdEncoder.encode(clientResource.getAppKey())); return clientBuilder.build(); } /** * jwt配置 */ @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG) private Channel jwtChannel; /** * 注入带缓存的业务查询服务 */ @Autowired private BaseBizService<ClientResource> clientService; /** * 注入密码编码服务 */ @Autowired private PasswordEncoder pwdEncoder; }
- 对应的真实数据源服务ClientResourceServiceImpl 为:
@Service public class ClientResourceServiceImpl extends BaseBizService<ClientResource> { @Override public ClientResource get(ClientResource model) { ClientResource client = super.get(model); if (!client.isEmpty()) { UrlResource urlParam = new UrlResource(); urlParam.setAppId(model.getAppId()); UrlResource urlResource = urlService.get(urlParam); client.setResources(urlResource.getUrls()); } return client; } @Override protected ClientResource queryByKey(String key) { ClientResource client = ClientResource.toBean(key); return dao.get(client); } /** * 注入url服务 */ @Autowired private BaseBizService<UrlResource> urlService; /** * 注入dao */ @Autowired private BizDao<ClientResource> dao; }
- 由于我们使用的Client Credentials模式不支持生成JwtToken,自定义的刷新JwtToken服务JwtTokenGen 为:
@Slf4j @Component public class JwtTokenGen { /** * 基于当前的jwt对象,生成新的属性Jwt * * @param jwt 当前的Jwt * @return 新Jwt */ public String genRefreshJwt(String jwt) { try { return genRefreshJwt(SignedJWT.parse(jwt)); } catch (Exception e) { log.error("failed to gen refresh token.", e); } return null; } /** * 基于当前的jwt对象,生成新的属性Jwt * * @param jwt 当前的Jwt * @return 新Jwt */ public String genRefreshJwt(SignedJWT jwt) { Map<String, Object> claims = JwtGenUtil.buildClaims(jwt, jwtChannel.getTimeout(), AuthConst.JWT_TYPE_REFRESH); claims.remove(AuthConst.JWT_RESOURCES); return JwtGenUtil.genJwt(claims, jwkMgr.getJwk()); } /** * jwt配置 */ @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG) private Channel jwtChannel; /** * jwk管理器 */ @Autowired private JwkMgr jwkMgr; }
4.2.2 Gateway服务Token鉴权实现
- 在按照接口授权的服务中,如果授权的接口数量较少,则可以把授权的url加入JwtToken的扩展字段resources,则网关可以直接鉴权;否则需要网关从公共缓存中获取权限列表。网关的过滤器TokenGatewayFilter 为:
@Slf4j @Component public class TokenGatewayFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String url = request.getURI().getPath(); PathMatcher pathMatcher = new AntPathMatcher(); boolean ignore = this.whitelist.stream().anyMatch(pattern -> pathMatcher.match(pattern, url)); log.info("url:{},whitelist:{},result:{}", url, JsonUtil.toJson(this.whitelist), ignore); if (!ignore) { String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); boolean refreshType = false; //判断是否为token接口 if (pathMatcher.match(jwtChannel.getUrl(), url)) { String grantType = request.getQueryParams().getFirst("grant_type"); log.info("url[{}]'s grant type:{}", url, grantType); refreshType = "jwt_refresh".equalsIgnoreCase(grantType); //不是刷新token接口调用时,就认定是申请token接口,直接放过 if (!refreshType) { return chain.filter(exchange); } } boolean result = jwtMgr.valid(authorization); log.info("token[{}] valid result:{}", authorization, result); if (!result) { log.error("token auth failed."); return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase); } JwtToken jwtToken = JwtUtil.getJwtToken(authorization); if (null == jwtToken) { log.error("parse token failed."); return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase); } //token才能请求业务接口 boolean validBizType = !refreshType && !jwtToken.isRefresh(); //刷新token只能请求刷新token boolean validRefreshType = refreshType && jwtToken.isRefresh(); if (!validBizType && !validRefreshType) { log.error("[{}]token type not matched.", url); return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase); } } return chain.filter(exchange); } /** * 是否驼峰式json(默认支持) */ @Value("${bq.json.snake-case:true}") private boolean snakeCase; /** * 不用做鉴权的白名单 */ @Resource(name = GatewayConst.WHITELIST) private Set<String> whitelist; /** * 注入jwt管理器 */ @Autowired private PubJwtMgr jwtMgr; /** * jwt配置 */ @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG) private Channel jwtChannel; }
注意:鉴于不确定resources数据量大小,所以此处就没有判断当前请求的url是否在resources列表中。
4.3 Basic认证加密实现
- 为了安全考虑,在鉴权网关的过滤器SecureAuthGatewayFilter 中非常容易实现Basic认证的加密:
@Slf4j @Component public class SecureAuthGatewayFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { //解析出该请求的摘要配置和加密配置 ServerHttpRequest request = exchange.getRequest(); String url = request.getURI().getPath(); //配置转发后,对header中的认证头做校验和解密 String encId = request.getHeaders().getFirst(GatewayConst.HEADER_ENC_ID); if (authConf.getUrl().equals(url) && this.authConf.needDec()) { String encAlg = encId; if (StringUtils.isEmpty(encAlg)) { encAlg = this.authConf.getDec(); } String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); log.info("current auth encrypt[{}][{}]=[{}].", encAlg, authorization, clientEncryptor.encrypt(encAlg, authorization)); String decAuth = clientEncryptor.decrypt(encAlg, authorization); if (StringUtils.isEmpty(decAuth)) { log.error("[{}]decrypt auth header failed.", url); return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase); } HttpHeaders headers = new HttpHeaders(); headers.put(HttpHeaders.AUTHORIZATION, Lists.newArrayList(decAuth)); String body = exchange.getAttribute(GatewayConst.BODY_CACHE_KEY); if (StringUtils.isEmpty(body)) { body = StringUtils.EMPTY; } byte[] data = body.getBytes(StandardCharsets.UTF_8); request = FluxRequestWrapper.wrap(request, authConf.getRedirect(), headers, data); } return chain.filter(exchange.mutate().request(request).build()); } /** * 是否驼峰式json(默认支持) */ @Value("${bq.json.snake-case:true}") private boolean snakeCase; /** * 认证配置 */ @Autowired private EncryptConfig authConf; /** * 注入安全服务服务 */ @Autowired private ClientSecurity clientEncryptor; }
4.4 刷新Token接口的扩展实现
- 前面介绍了Token接口的刷新Token一并生成,我们还需要新增一个刷新Token接口,前面在SecurityFilterMgr 中已经介绍了刷新接口的Converter和Provider的注入,这里就展开介绍二者的具体实现。
4.4.1 OAuth2服务的刷新Token实现
- 刷新Converter服务JwtRefreshAuthConverterImpl 代码为:
@Slf4j @Component public class JwtRefreshAuthConverterImpl implements AuthenticationConverter { @Override public Authentication convert(HttpServletRequest request) { String uri = request.getRequestURI(); OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REFRESH_TOKEN, uri); // grant_type (REQUIRED) String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); if (!AuthConst.REFRESH_GRANT_TYPE.getValue().equals(grantType)) { log.error("no jwt refresh type find:{}.", uri); return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); // refresh_token (REQUIRED) String refreshToken = BEARER_JWT_RESOLVER.resolve(request); String jwtType = AuthConst.JWT_TYPE_TOKEN; try { Map<String, Object> claims = SignedJWT.parse(refreshToken).getJWTClaimsSet().getClaims(); if (claims.containsKey(AuthConst.JWT_TYPE)) { jwtType = claims.get(AuthConst.JWT_TYPE).toString(); } } catch (Exception e) { log.error("failed to parse jwt refresh.", e); throw new OAuth2AuthenticationException(error); } if (!AuthConst.JWT_TYPE_REFRESH.equalsIgnoreCase(jwtType)) { log.error("invalid jwt refresh type"); throw new OAuth2AuthenticationException(error); } // scope (OPTIONAL) String scope = request.getParameter(OAuth2ParameterNames.SCOPE); if (StringUtils.isEmpty(scope)) { log.error("no jwt refresh scope find:{}", uri); throw new OAuth2AuthenticationException(error); } Set<String> requestedScopes = Sets.newHashSet(StringUtils.split(scope, " ")); return new OAuth2RefreshTokenAuthenticationToken(refreshToken, clientPrincipal, requestedScopes, null); } /** * jwt token解析器 */ private static final DefaultBearerTokenResolver BEARER_JWT_RESOLVER = new DefaultBearerTokenResolver(); }
主要参考了OAuth2的源码。
- 刷新Provider服务JwtRefreshAuthProviderImpl 代码为:
@Slf4j @Component public class JwtRefreshAuthProviderImpl implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2RefreshTokenAuthenticationToken refreshTokenAuth = (OAuth2RefreshTokenAuthenticationToken)authentication; JwtAuthenticationToken principal = getAuthenticatedClient(refreshTokenAuth); RegisteredClient client = Oauth2Builder.build(principal.getName(), jwtChannel.getConnTimeout()).build(); if (!client.getAuthorizationGrantTypes().contains(AuthConst.REFRESH_GRANT_TYPE)) { log.error("invalid grant in configs:{}", refreshTokenAuth.getGrantType()); throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); } Map<String, Object> addParameters = refreshTokenAuth.getAdditionalParameters(); OAuth2Authorization.Builder authBuilder = Oauth2Builder.build(client); authBuilder.authorizationGrantType(new AuthorizationGrantType(refreshTokenAuth.getGrantType().getValue())); authBuilder.attributes(parameters -> parameters.putAll(addParameters)); OAuth2Authorization authorization = authBuilder.build(); Set<String> authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); if (!CollectionUtils.containsAny(refreshTokenAuth.getScopes(), authorizedScopes)) { log.error("invalid scope:{}", refreshTokenAuth.getScopes()); throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder().registeredClient(client).principal(principal) .providerContext(ProviderContextHolder.getProviderContext()).authorization(authorization) .authorizedScopes(authorizedScopes).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrant(refreshTokenAuth); OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build(); JwtGenerator tokenGenerator = ApplicationContextHolder.getBean(JwtGenerator.class); OAuth2Token auth2Token = tokenGenerator.generate(tokenContext); if (auth2Token == null) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.SERVER_ERROR); } String jwt = auth2Token.getTokenValue(); Instant issuedAt = auth2Token.getIssuedAt(); Instant expiresAt = auth2Token.getExpiresAt(); OAuth2AccessToken.TokenType tokenType = OAuth2AccessToken.TokenType.BEARER; OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, jwt, issuedAt, expiresAt, authorizedScopes); String refreshJwt = tokenGen.genRefreshJwt(jwt); Instant refreshExpiresAt = Instant.ofEpochSecond(issuedAt.getEpochSecond() + jwtChannel.getTimeout()); OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(refreshJwt, issuedAt, refreshExpiresAt); return new OAuth2AccessTokenAuthenticationToken(client, principal, accessToken, refreshToken, addParameters); } @Override public boolean supports(Class<?> authentication) { return OAuth2RefreshTokenAuthenticationToken.class.isAssignableFrom(authentication); } /** * 获取认证通过的token对象 * * @param authentication 认证对象 * @return 认证后的token对象 * @throws OAuth2AuthenticationException 认证失败异常 */ private static JwtAuthenticationToken getAuthenticatedClient(OAuth2RefreshTokenAuthenticationToken authentication) throws OAuth2AuthenticationException { JwtAuthenticationToken clientPrincipal = null; Object principal = authentication.getPrincipal(); if (principal instanceof JwtAuthenticationToken) { clientPrincipal = (JwtAuthenticationToken)principal; } if (clientPrincipal != null && clientPrincipal.isAuthenticated()) { return clientPrincipal; } throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } @Autowired private JwtTokenGen tokenGen; /** * jwt配置 */ @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG) private Channel jwtChannel; }
主要参考并综合了OAuth2几种类型的实现,并需要新增一个AuthorizationGrantType:
jwt_refresh
。文章来源:https://www.toymoban.com/news/detail-500028.html
4.4.2 Gateway服务的刷新Token鉴权实现
- 参见4.2.2章节的实现。
4.5 微信二维码扫码认证实现
- 前面介绍了OAuth2认证返回参数中添加刷新JwtToken字段的设计,其中刷新JwtToken完全是借助框架生成的JwtToken字段改造而来的;
- 前面也介绍了OAuth2认证时,针对Client Credentials模式时,通过扩展框架而支持生成新的JwtToken逻辑,其中也是借助框架的能力去生成JwtToken的;
- 而微信二维码扫码认证发起端在手机上,session生成可能是在PC上,也可能是在其他人的手机上,且跟Client Credentials完全没有关系,则需要自己阅读源码找出其中的关键逻辑,完全不依赖框架生成JwtToken,核心逻辑如下:
@Slf4j public final class JwtGenUtil { /** * 生成JwtToken base64字符串 * * @param claims jwt body集合 * @param jwk 秘钥 * @return JwtToken base64字符串 */ public static String genJwt(Map<String, Object> claims, JWK jwk) { try { JWTClaimsSet jwtClaimsSet = JWTClaimsSet.parse(claims); return genJwt(jwtClaimsSet, jwk); } catch (Exception e) { log.error("failed to gen jwt token.", e); } return null; } /** * 生成JwtToken base64字符串 * * @param jwtClaimsSet jwt body集合 * @param jwk 秘钥 * @return JwtToken base64字符串 */ public static String genJwt(JWTClaimsSet jwtClaimsSet, JWK jwk) { try { JWSSigner jwsSigner = SIGNER_FACTORY.createJWSSigner(jwk); SignedJWT signedJwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), jwtClaimsSet); signedJwt.sign(jwsSigner); return signedJwt.serialize(); } catch (Exception e) { log.error("failed to gen jwt token.", e); } return null; } /** * 基于现有的Jwt构建新的Jwt claims * * @param jwtToken jwt参数 * @return 新的Jwt claims */ public static Map<String, Object> buildClaims(JwtToken jwtToken) { Map<String, Object> claims = buildClaims(jwtToken.getExp(), jwtToken.getJwtType()); claims.put(JwtClaimNames.SUB, jwtToken.getSub()); claims.put(JwtClaimNames.AUD, jwtToken.getAud()); claims.put(JwtClaimNames.ISS, StringUtils.EMPTY); claims.put(AuthConst.JWT_RESOURCES, jwtToken.getResources()); return claims; } /** * 基于现有的Jwt构建新的Jwt claims * * @param signedJwt 签名后的jwt * @param expire 有效时长(s) * @param jwtType jwt类型(token/refresh) * @return 新的Jwt claims */ public static Map<String, Object> buildClaims(SignedJWT signedJwt, long expire, String jwtType) { Map<String, Object> claims = Maps.newHashMap(); try { claims.putAll(signedJwt.getJWTClaimsSet().getClaims()); Map<String, Object> newClaims = buildClaims(expire, jwtType); claims.putAll(newClaims); } catch (Exception e) { log.error("failed to gen jwt token.", e); } return claims; } /** * 基于现有的Jwt构建新的Jwt claims * * @param expire 有效时长(s) * @param jwtType jwt类型(token/refresh) * @return 新的Jwt claims */ public static Map<String, Object> buildClaims(long expire, String jwtType) { Map<String, Object> claims = Maps.newHashMap(); long validTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()); long expireTime = validTime + expire; claims.put(JwtClaimNames.IAT, validTime); claims.put(JwtClaimNames.NBF, validTime); claims.put(JwtClaimNames.EXP, expireTime); claims.put(JwtClaimNames.JTI, IdUtil.uuid()); claims.put(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue()); claims.put(AuthConst.JWT_TYPE, jwtType); return claims; } private JwtGenUtil() { } /** * 默认的签名工厂 */ private static final JWSSignerFactory SIGNER_FACTORY = new DefaultJWSSignerFactory(); }
- 由于本开源代码框架暂没有申请微信小程序,也没有前端页面部分,略去了扫码交互的逻辑。有兴趣的朋友可以私信我,我另写相关的内容。
5. OAuth2服务的扩展思考
- OAuth2可以很好地解决后端服务与系统间的认证、涉及资源和认证管理等多方参数的认证,在Web页面场景下,是否还需要使用传统的账号密码认证呢?尽管
spring-security-oauth2-authorization-server
官方给出的demo中有登录和OAuth2完全分离的例子; - Spring实现的OAuth2框架中,考虑到Client Credentials是给系统和系统后端间的交互,所以不支持刷新JwtToken,因为JwtToken过期时,重新调用下OAuth2认证接口即可,无须刷新;但是当我们把它扩展到Web页面的认证时,则需要自己把刷新JwtToken
扩展进框架,同时限制扩展场景的使用,确保认证的安全; - 永远相信业务场景是合理的,永远不要停止对技术的追求。本人先后经历了3
个阶段才达成了今天这个框架的目标:- 第1阶段:不够了解框架,改造1-2周后,直接失败放弃了,没有搞定jar包之间的兼容性问题;
- 第2阶段:后面又有一次机会,通过艰苦努力,终于通过反射成功完成了上述需求的改造;
- 第3阶段:觉得前面的做法不够优雅,又决定放弃反射,完全通过框架可能的,甚至包括未公开说明的扩展点来达成改造,终于也成功了;
5.1 对Web页面服务的扩展思路
- 从接口服务到支持Web页面的认证及鉴权,需要对系统做多方面的考虑,比如:会话管理机制、权限控制方案,甚至包括前端技术选型、前端部署优化等,但是原则只有一个:尽量少改动代码、尽量少影响原有的业务流程,否则系统非常容易失控;
- 在想不清楚可能出现何种异常时,则需要多预埋扩展点,保证系统在出现问题时,能够及时通过某些扩展点解决掉遗漏问题;
5.1.1 增加分布式的sessionId
- 系统已为无状态的分布式微服务架构,需要确保一个OAuth2服务生成的JwtToken同样可以被OAuth2服务识别成功。这就需要使用到分布式会话管理。
注意:文章来源地址https://www.toymoban.com/news/detail-500028.html
- 只有带有页面时,才需要通过OAuth2服务继续查询资源信息,才需要分布式会话管理,否则直接通过网关就可以鉴权完成,再也用不上OAuth2服务了;
- OAuth2服务负责认证,网关负责鉴权,不代表OAuth2服务就不能鉴权,这只是我们大部分场景上的设计,目的是提升效率。实际上,OAuth2服务中,无论是刷新JwtToken接口还是资源(权限、用户、菜单等)获取接口,都先用通过OAuth2服务的鉴权;
- 分布式会话有很多种方案,最简单优雅的方式是方式是引入
spring-session-data-redis
,参见Spring集成redis实现分布式会话。- 加入如下引用:
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
- yaml添加引用:
#session超时时间设置为1000秒 server: session: timeout: 1000 #设置session的存储方式,none为存在本地内存中,redis为存在redis中 spring: session: store-type: redis #namespace用于区分不同应用的分布式session redis: namespace: oauth2 #session更新到redis的模式,分为on_save和immediate,on_save是当执行执行完响应以后才将数据同步到redis,immediate是在使用session的set等操作时就同步将数据更新到redis,建议使用on_save flush-mode: on_save
- 加入如下引用:
5.1.2 增加不同端的标记
- 考虑到系统的扩展性,需要对不同端的认证增加不同的标记。在前面介绍的
定制的Jwt字段服务
章节中已经介绍了,目前的接口服务的JWT_SOURCE_TYPE
是JwtSourceType.SDK.name()
。
5.2 对会话/权限/菜单的管理
- 方法在上面基本上已经介绍过了。如果做好了不同端的标记处理,此业务目标就容易做得更好。比如针对不同端做不同的超时时间管理;
- 限于篇幅和开源素材所限,暂不列举代码,但会把思路讲清楚。
5.2.1 OAuth2服务对会话/权限/菜单的管理
- 在OAuth2服务中需要对用户的session信息进行缓存,包括用户的权限、菜单信息等,并基于JwtToken有效期作为这些缓存数据的有效期;
- 框架当下使用的是redis缓存,目的就是异步实时共享给网关鉴权使用,同时避免了调用OAuth2服务;
5.2.2 Gateway服务对会话/权限/菜单的管理
- 网关服务需要补上redis相关缓存的查询api,模型和服务;
- 当网关服务校验JwtToken过期时,则禁止相关访问并报错,网关不做会话/权限/菜单等查询校验以外的事情;
6 技术框架的演进
- 撰写本文的时候,特意去看了下spring-security-oauth2-authorization-server 框架,当下最新版本已是1.1.0,Spring版本为6.0.10,JDK为17,发展非常快,这是个不错的趋势;
- 在实际项目中,并不会随意追新,以本项目的介绍为例,JDK为8,Spring的版本5.3.23,也算是跟随比较紧密的了。一般只有当版本停止维护了,或者有严重安全漏洞时,才会考虑升级,而且只会升级最小的安全版本,直到没有安全版本可用了,才考虑大的升级;
- 本项目如果要升级成最新的
spring-security-oauth2-authorization-server
版本,则要替换JDK,升级Spring版本,升级SpringBoot版本,升级SpringCloud版本,升级Nacos等其它三方件版本……工作量巨大,而且还可能导致功能不正常,升级风险巨高; - 作为一名技术人,还是得时常关注最新的技术变化,我的理解,
0.2.3
到1.1.0
,就是一个框架从不成熟走向成熟了。欣慰至极。
7. 参考资料
- [1] 理解OAuth 2.0
- [2] OAuth2–流程/原理
- [3] 一文理解 JWT、JWS、JWE、JWA、JWK、JOSE
- [4] SpringBoot入门系列(二十八)使用Redis实现分布式Session共享
- [5] Spring集成redis实现分布式会话
到了这里,关于OAuth2在开源SpringBoot微服务框架的实践的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!