auth2和JWT的一些了解和使用
2024-01-03 18:49:54
要向资源服务器 发送一个认证的请求,资源服务器返回一个授权码,有了授权码就可以向认证服务器获取令牌,有了令牌就可以去访问其它资源服务器的数据。 ? ? 比如要登录一个网站,需要第三方登录的话,那就要使用第三方的认证,获取到第三方的授权码以后才可以去认证框架哪里获取命令,有了命令就可以访问网站下面的任意资源服务器的模块(其中去第三方获取 授权码是看不见的 有了授权码才可以去认证框架哪里获取令牌 ?有了令牌就可以去访问其它的资源服务,才可以访问资源)
// JSON Web Token (JWT)是一个开放标准(RFC 7519) <dependency> ? ? ? ?<groupId>io.jsonwebtoken</groupId> ? ? ? ?<artifactId>jjwt</artifactId> ? ? ? ?<version>0.9.0</version> </dependency> // 来处理数据库里面查到的数据 <dependency> ? ? ? <groupId>org.springframework.security</groupId> ? ? ? <artifactId>spring-security-data</artifactId> </dependency> // 它用主要来认证, 它只负责发放令牌, 你只要带着它要求的参数, 通过它给的路径去访问它, 他就给你一个令牌, 别的事他不管, 这岂不是太简单了, 我怎么不能自己写一个来发放令牌 <dependency> ? ? ?<groupId>org.springframework.cloud</groupId> ? ? ?<artifactId>spring-cloud-starter-oauth2</artifactId> ? ? ?<exclusions> ? ? ? ? ?<exclusion> ? ? ? ? ? ? ? ? ? <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> ? ? ? ? </exclusion> ? ? ? ? ? ?</exclusions> </dependency> // oauth2 的一些配置环境 <dependency> ? ? ?<groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.3.RELEASE</version> </dependency> // 认证和授权 <dependency> ? ? <groupId>org.springframework.cloud</groupId> ? ? <artifactId>spring-cloud-starter-security</artifactId> </dependency> // SpringBoot 应用的监控 项目开发以后都要监控数据 <dependency> ? ? <groupId>org.springframework.boot</groupId> ? ? <artifactId>spring-boot-starter-actuator</artifactId></dependency>
server: ?port: 7001 spring: ?application: ? ?name: user-auth ?datasource: ? ?driver-class-name: com.mysql.cj.jdbc.Driver ? ?url: jdbc:mysql://127.0.0.1:3306/shop_oauth?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true&serverTimezone=UTC ? ?username: root ? ?password: root ?main: ? ?allow-bean-definition-overriding: true ?cloud: ? ?nacos: ? ? ?discovery: ? ? ? ?server-addr: 127.0.0.1:8848 auth: ?ttl: 3600 ?#token存储到redis的过期时间 ?clientId: changgou ?clientSecret: changgou ?cookieDomain: localhost ?cookieMaxAge: -1 encrypt: ?key-store: ? ?location: classpath:/qianggou.jks ? ?secret: qianggou ? ?alias: qianggou ? ?password: qianggou
项目搭起来来测试数据 这些请求地址都是框架写好的 http://localhost:7001/oauth 总的 get: http://localhost:7001/oauth/authorize 授权码 ? http://localhost:7001/oauth/authorize?client_id=changgou&response_type=code&scop=app&redirect_uri=http://localhost ? post: http://localhost:7001/oauth/token 获取token
// 这个是授权账号的用户和密码通过base64 加密写成的 // 这里是在请求头里面的 authorization ? ? ? ? ? Basic Y2hhbmdnb3U6Y2hhbmdnb3U= ?// 自己传的这里产生一个token的授权账号 "Authorization","Bearer " + token ?// 这个是带token去访问的资源服务器的验证
access_token 令牌 ? ? token_type ?有MAC Token与Bearer Token两种类型,两种的校验算法不同,RFC 6750建议Oauth2采用 Bearer Token(http://www.rfcreader.com/#rfc6750) ? refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。 expires_in ?令牌有效期 防止令牌过期 ,来获取新的令牌 scope 公司或者软件的名称必须指定 jti:当前token的唯一标识
Get:?http://localhost:7004/oauth/check_token?token= 生成的token 来查看token的信息 必须要带上验证的账号 (不带不知道是谁的)
http://localhost:7001/oauth/token?grant_type=refresh_token & refresh_token = 带入生成的刷新令牌 // 这个 ? 必须要带上验证的账号 (不带不知道是谁的)
用户自己登录的数据 // 验证证号都要输入的
package com.zb.oauth.vo; // webmvc aop 的包下面 import com.alibaba.fastjson.JSON; import org.springframework.core.io.ClassPathResource; import org.springframework.security.jwt.Jwt; import org.springframework.security.jwt.JwtHelper; import org.springframework.security.jwt.crypto.sign.RsaSigner; import org.springframework.security.jwt.crypto.sign.RsaVerifier; import org.springframework.security.rsa.crypto.KeyStoreKeyFactory; import java.security.KeyPair; import java.security.interfaces.RSAPrivateKey; import java.util.HashMap; import java.util.Map; public class Test { public static void main(String[] args) { // testCreateToken(); String token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IuaIkeeIseS9oCJ9.ceWbjAoosk4qxEOUD0uEqZEUyD3h2L_58Poh21M_Y4kr3prkXj5do5UmuXS00QbeUgmeb4RPlds1yA9tU8VB9Y_5O1RrxUVtXdy6gUddLlUTpK1wkx50XBStvTsW5Yu3MMc444XM55_xFHy4xDuKnkzPU4Nzp8v0qjdo7g89-Rj0C1XB9Po4Bin3vUVPtER26-JPsTI-YDqC0N4UbwJHOkYm2_SkybSHSNUuIXJrYx3ODJ5asSeJw2j1gpwLTPK_GQjlZ3DiNOmz8XjDIzGK7uKWVCSBD0ALil_BqkfHtGMiArq3Ufw7LZNrzXV-RDkVq1BLNOZNjdCuP80-QG2zLA"; String pubkey = "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgG8pDI3o+tfb0SzoG6xeJibhV0isxDKciksOkirvR4HXBislv+hWEaSHhLmOmGgy3TbGuxrFH3teP9PGSF4b31Sy9aN+PQAPeR0yOvYy2KKkUiCwZiIZGYVoSb17hTb1T76h8J+36FSIrLkRfo3zRiZKUq86CpUwLJjnUjI4RbUB7Xk2i03JeZDnWUHQzWeIqzjR8XaPL/8UgkfR7O9iS1fxh4esWWXN0AQWHeGEcFYYL55bD+KGaXNPmdiSWNWwZ62d+T9IwzyxmW/fGhRQYIRngSvXP/P9h3QZjQFprv5MXpi1ABWmveOvLqnLJANc9HSLQgf+BSB5Q7v3xvjvaQIDAQAB-----END PUBLIC KEY-----"; // 由公钥来解密 Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(pubkey)); System.out.println(jwt.getClaims()); } public static void testCreateToken(){ //证书文件路径 String key_location="myshop.jks"; //秘钥库密码 String key_password="myshop"; //秘钥密码 String keypwd = "myshop"; //秘钥别名 String alias = "myshop"; ClassPathResource resource = new ClassPathResource(key_location); //创建秘钥工厂 KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource,key_password.toCharArray()); //读取秘钥对(公钥、私钥) KeyPair keyPair = keyStoreKeyFactory.getKeyPair(alias,keypwd.toCharArray()); //获取私钥 RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); //定义Payload Map<String, Object> tokenMap = new HashMap<>(); tokenMap.put("id", "我爱你"); Jwt jwt = JwtHelper.encode(JSON.toJSONString(tokenMap), new RsaSigner(privateKey)); // 取出令牌 String encoded = (jwt).getEncoded(); System.out.println(encoded); } }
//读取密钥的配置 都是初始化内部的 静态类来初始化数据的 @Bean("keyProp") public KeyProperties keyProperties(){ return new KeyProperties(); } @Resource(name = "keyProp") // 是j2ss提供功的 两个bean的方式都是可以的 默认是byname的形式来设置的 private KeyProperties keyProperties;
@ConfigurationProperties("encrypt") // 这个就和配置文件的配置是一样的,所以可以读取数据 public class KeyProperties { public static class KeyStore { private Resource location; private String password; private String alias; private String secret; } }
package com.guoshuxiang.oauth.service.impl; import com.guoshuxiang.oauth.service.AuthService; import com.guoshuxiang.oauth.util.AuthToken; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.Base64Utils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.web.client.RestTemplate; import java.io.UnsupportedEncodingException; import java.util.List; import java.util.Map; @Service public class AuthServiceImpl implements AuthService { @Autowired // 这里是硬编码调用 private RestTemplate restTemplate; @Autowired // nacos 注册中心来调用 动态获取微服务的模块 private DiscoveryClient discoveryClient; /** * * @param username * @param passwd 我们自己输入的 * ************** * @param clientId 配置文件中获取 * @param clientSecret * @return */ @Override // 我们自己来实现 只输入密码和账号 还有两个参数是验证密码和账号 public AuthToken login(String username, String passwd, String clientId, String clientSecret) { //获取认证微服对象 这里可能不止一个所以是集合来存储的 List<ServiceInstance> instances = discoveryClient.getInstances("user-auth"); // nacos 里面存的微服务模块不止一个 ServiceInstance serviceInstance = instances.get(0); //封装登录地址 动态获取主机 和 端口号 String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/oauth/token"; //请求参数 // 模拟普通的表单提交时 LinkedMultiValueMap formData = new LinkedMultiValueMap(); formData.add("grant_type", "password"); // 登录方式为 password 模式 formData.add("username", username); // 这里不需要 code 是因为 这个请求是放行的 formData.add("password", passwd); // 请求头参数 这个map 集合是可以一个键里面有多个值,并且这些值是可以重复的 // 模拟普通的表单提交时 LinkedMultiValueMap headerData = new LinkedMultiValueMap(); // 请求头里面的数据 authorization Basic XXXXXXX= 第三方授权证号 headerData.add("authorization", this.httpHeader(clientId, clientSecret)); System.out.println("--------------------"); System.out.println(formData); //发起远程调用 从这里获去一个map 集合 通过token 刷新 token HttpEntity<>(请求参数,一个是请求头) ResponseEntity<Map> responseEntity = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(formData, headerData), Map.class); //获取请求体里面的数据 Map body = responseEntity.getBody(); System.out.println(body); // 框架自己的验证方式 AuthToken authToken = new AuthToken(); // 页面最终展示的数据 authToken.setAccessToken(body.get("access_token").toString()); authToken.setRefreshToken(body.get("refresh_token").toString()); authToken.setJti(body.get("jti").toString()); return authToken; } /** * 这才 changgou 的用户和密码在这里使用的 * @param clientId 这里两个都是验证的账号和密码 在application.yml 中存储的 * @param clientSecret * @return */ private String httpHeader(String clientId, String clientSecret) { String key = clientId + ":" + clientSecret; // 这里是正好的拼接 // base64 spring核心包下面的类 spring 自带的 byte[] decode = Base64Utils.encode(key.getBytes()); // 加密来创建账号验证 try { String str = new String(decode, "UTF-8"); // 字符串编码 将字节码变成字符串 return "Basic " + str; // 和这个拼接起来 } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return null; // 发生异常就走这里 } }
认证和授权还是要分开来写的,在springboot中是合在一起写,因为是单体项目,不需要分开,这里是微服务项目所以是要分开使用的
@EnableResourceServer // 开始资源服务类 要访问资源必须要带上 token //激活方法上的PreAuthorize注解 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true ) // 开启校色授权 默认是关闭的
Authorization bearer + token // 这个是放到请求体中
package com.guoshuxiang.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.stream.Collectors; @Configuration @EnableResourceServer // 开始资源服务类 要访问资源必须要带上 token @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解 public class ResourceServerConfig extends ResourceServerConfigurerAdapter { //公钥 private static final String PUBLIC_KEY = "public.key"; /*** * 获取token中的令牌, 在根据公钥文件验证令牌的有效性 * 定义JwtTokenStore * @param jwtAccessTokenConverter * @return */ @Bean public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) { return new JwtTokenStore(jwtAccessTokenConverter); } /*** * 定义JJwtAccessTokenConverter * 解析token 放到springContent 上下中去 需要的时候直接获取 * @return */ @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(getPubKey()); return converter; } /** * 获取非对称加密公钥 Key * @return 公钥 Key */ private String getPubKey() { // 将数据读入到内存中 Resource resource = new ClassPathResource(PUBLIC_KEY); try { // 将二进制转化为字符流 InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream()); // 将字符流转为字符缓冲流 BufferedReader br = new BufferedReader(inputStreamReader); // stream 来处理字符串数据 '\n'为连接符,将数据换成一行 return br.lines().collect(Collectors.joining("\n")); } catch (IOException ioe) { return null; } } /*** * Http安全配置,对每个到达系统的http请求链接进行校验 * @param http * @throws Exception */ @Override public void configure(HttpSecurity http) throws Exception { //所有请求必须认证通过 http.authorizeRequests() //下边的路径放行 .antMatchers( // 给 swagger-ui 来放行数据 "/tb-user-model/userinfo/**", "/swagger-ui.html", "/swagger-ui/*", "/swagger-resources/**", "/v2/api-docs", "/v3/api-docs", "/webjars/**" ). //配置地址放行 permitAll() .anyRequest(). authenticated(); //其他地址需要认证授权 } }
这里是通过网关来请求数据,但是要符合用户正常的请求风格,需要在拦截器中对请求体的请求头重新封装数据
package com.guoshxiang.filters; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component // 一个全局的拦截器 public class PowerFilter implements GlobalFilter, Ordered { @Override // 全局拦截来给变的东西 ServerWebExchange 里面有请求和响应 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { System.out.println("=================>8888"); ServerHttpRequest request = exchange.getRequest(); //获取请求 String path = request.getURI().getPath(); // 获取请求路径 System.out.println("====>"+path); // 打印路径 // 这里是认证和用户登录都要放行 if(path.startsWith("/api/auth/login") || path.startsWith("/api/tb-user-model/userinfo")){ return chain.filter(exchange); } // 从请求体中获取token 这里的token 是不符合要求的,在上次一次请求中就失效了,在这里需要重新封装 String token = request.getHeaders().getFirst("token"); if(token!=null){ // 重新设置这个请求头中的键值对 // 由于gateway消费了一次请求后,就不能再向下一个请求传播,所以需要重新封装请求体。 request.mutate().header("Authorization","Bearer " + token); // 直接添加符合对象中,这里不需要再次重构,都是自己的对象 } // 存储到header中 // ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders->{ httpHeaders.add(headername,value) // 这里是添加到请求中去 // }) // //重置请求 // exchange.mutate().request(serverHttpRequest).build(); // 在这个里面重新构建一下 return chain.filter(exchange); } @Override public int getOrder() { return 0; } }
// 这里是框架自己调用的 框架读取数据库数据存入内存一份,我们从前端输入一份数据,两份数据正好在一起对比最后才可以显示登录成功与否 public class UserDetailsServiceImpl implements UserDetailsService { // 远程接口 TbUserDto tbUserDto = tbUserFeignClient.userInfo(username); if (tbUserDto == null) return null; // 获取密码,这里的密码在数据库中就加密过 String pwd = tbUserDto.getPassword(); String permissions = "goods_list,seckill_list"; // 最后加用户名和密码加入到 // 这里是 UserDetails -> User 我们在继承 User 对里面的数据进行封装 UserJwt userDetails = new UserJwt(username, pwd, AuthorityUtils.commaSeparatedStringToAuthorityList(permissions)); }
// 这两个都是配置类 AuthorizationServerConfigurerAdapter // 认证类 ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。 AuthorizationServerSecurityConfigurer:用来配置令牌端点(Token Endpoint)的安全约束. AuthorizationServerEndpointsConfigurer:用来配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)。 ResourceServerConfigurerAdapter // 资源访问类
下面来写授权的信息
public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("changgou") //客户端id .secret("changgou") //秘钥 .redirectUris("http://localhost") //重定向地址 .accessTokenValiditySeconds(3600) //访问令牌有效期 .refreshTokenValiditySeconds(3600) //刷新令牌有效期 .authorizedGrantTypes( "authorization_code", //根据授权码生成令牌 "client_credentials", //客户端认证 "refresh_token", //刷新令牌 "password") //密码方式认证 .scopes("app"); //客户端范围,名称自定义,必填 }
// 框架自己执行的 clients.jdbc(dataSource).clients(this.clientDetails()); //获取数据库中的验证账号
Authentication 一、两个功能 1. 作为后续验证的入参 2. 获取当前验证通过的用户信息 二、三个属性 1. principal:用户身份,如果是用户/密码认证,这个属性就是UserDetails实例 2. credentials:通常就是密码,在大多数情况下,在用户验证通过后就会被清除,以防密码泄露。 3. authorities:用户权限
AuthenticationManager
1. AuthenticationManager最常用的子类是ProviderManager,参数为Authentication 2. ProviderManager管理各种AuthenticationProvider,根据不同的Authentication调用不用的AuthenticationProvider 3. AuthenticationProvider是某种具体的认证实现 (1) DaoAuthenticationProvider实现用户/密码认证,处理UsernamePasswordAuthenticationToken类型的Authentication。 (2) JwtAuthenticationProvider实现JWT Token认证
AuthorizationServerEndpointsConfigurer 介绍 AuthorizationServerEndpointsConfigurer 是 Spring Security OAuth2 中的一个配置类,用于配置 OAuth2 认证服务器的终端点。 具体来讲,OAuth2 认证服务器包含许多终端点用于处理认证和授权请求,例如获取 access token、refresh token、授权码、校验 token 等,而 AuthorizationServerEndpointsConfigurer 类就提供了一系列方法来对这些终端点进行配置,使得 OAuth2 认证服务器在处理这些请求时能够按照我们的需要进行处理。 常用的终端点的配置方法如下: authenticationManager(AuthenticationManager authenticationManager):设置认证管理器。 设置后,将支持 password 授权类型,并验证来自客户端的用户名和密码。 tokenServices(AuthorizationServerTokenServices tokenServices):用于设置令牌管理服务。可以使用 Spring Security 提供的默认实现,也可以使用自定义实现。 exceptionTranslator(WebResponseExceptionTranslator exceptionTranslator):配置异常处理程序,用于在抛出异常时将异常信息转换为更友好的信息。 pathMapping():配置不同终端点的 URL。例如,.pathMapping("/oauth/authorize", "/custom/authorize") 将 oauth/authorize 的 URL 映射为 custom/authorize。 allowedTokenEndpointRequestMethods(HttpMethod... allowedMethods):设置允许访问令牌的请求方法,例如 POST 或 GET 等。 userApprovalHandler(UserApprovalHandler userApprovalHandler): 配置用户授权处理程序,用于处理用户是否同意授权等操作。 总之,AuthorizationServerEndpointsConfigurer 提供了一系列方法,用于配置 OAuth2 认证服务器的各个终端点和参数,从而实现符合
这些终端点通过 HTTP 协议在客户端和 OAuth2 认证服务器之间进行交互,实现了 OAuth2 协议中规定的各种授权和认证操作。在使用 Spring Security OAuth2 时,我们需要配置 AuthorizationServerEndpointsConfigurer 来设置这些终端点的相关参数和选项,以达到我们想要的认证服务。 AuthorizationServerSecurityConfigurer @Override public void configure(AuthorizationServerSecurityConfigurer security) { security .tokenKeyAccess("permitAll()") //oauth/token_key是公开 .checkTokenAccess("permitAll()") //oauth/check_token公开 .allowFormAuthenticationForClients() //表单认证(申请令牌) ; } 这段代码是在 Spring Security OAuth2 中用于配置 OAuth2 认证服务器安全性的方法。具体来说,configure(AuthorizationServerSecurityConfigurer security) 方法可以用于配置 OAuth2 认证服务器的安全策略,包括访问令牌和刷新令牌等敏感信息的保护,以及认证客户端等方面。 其中该方法具体配置的内容包括: tokenKeyAccess("permitAll()"):设置 /oauth/token_key 端点的访问权限为公开,即不需要进行身份验证就可以访问该端点,用于提供 OAuth2 认证服务器的公钥。 checkTokenAccess("permitAll()"):设置 /oauth/check_token 端点的访问权限为公开,即不需要进行身份验证就可以访问该端点,用于检查访问令牌的有效性。 allowFormAuthenticationForClients():允许客户端使用表单进行身份验证,用于申请访问令牌。如果不允许使用表单身份验证,则需要使用 HTTP 基本认证或者 OAuth2 客户端凭证进行身份验证。 通过上述配置,我们可以更加细粒度地控制 OAuth2 认证服务器的安全性,以满足实际应用的需要。如果对 OAuth2 认证服务器的安全性策略进行了合适的配置,我们可以保证 OAuth2 认证服务器的敏感信息不会被泄露,同时也可以防止未授权的访问和攻击。
ClientDetailsServiceConfigurer 用来配置客户端详情服务(ClientDetailsService),随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详细信息。
文章来源:https://blog.csdn.net/weixin_42376775/article/details/135368873
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!