7.4 JSON Web Token与OAuth2

OAuth2是一个基于令牌的验证框架,但具有讽刺意味的是,它并没有为如何定义其规范中的令牌提供任何标准。为了矫正OAuth2令牌标准的缺陷,一个名为JSON Web Token(JWT)的新标准脱颖而出。JWT是因特网工程任务组(Internet Engineering Task Force,IETF)提出的开放标准(RFC-7519),旨在为OAuth2令牌提供标准结构。JWT令牌具有如下特点。
    小巧——JWT令牌编码为Base64,可以通过URL、HTTP首部或HTTP POST参数轻松传递。
    密码签名——JWT令牌由颁发它的验证服务器签名。这意味着可以保证令牌没有被篡改。
    自包含——由于JWT令牌是密码签名的,接收该服务的微服务可以保证令牌的内容是有效的,因此,不需要调用验证服务来确认令牌的内容,因为令牌的签名可以被接收微服务确认,并且内容(如令牌和用户信息的过期时间)可以被接收微服务检查。
    可扩展——当验证服务生成一个令牌时,它可以在令牌被密封之前在令牌中放置额外的信息。接收服务可以解密令牌净荷,并从它里面检索额外的上下文。
    Spring Cloud Security为JWT提供了开箱即用的支持。但是,要使用和消费JWT令牌,OAuth2验证服务和受验证服务保护的服务必须以不同的方式配置。这个配置并不困难,接下来让我们来看一下不一样的地方。
    注意
 
我选择将JWT配置保存在本章的GitHub存储库的一个单独分支中(名为JWT_Example)。这是因为标准的Spring Cloud Security OAuth2配置和基于JWT的OAuth2配置需要不同的配置类。
    
7.4.1 修改验证服务以颁发JWT令牌
    对于要受OAuth2保护的验证服务和两个微服务(许可证服务和组织服务),需要在它们的Maven pom.xml文件中添加一个新的Spring Security依赖项,以包含JWT OAuth2库。这个新的依赖项是:
    
<dependency>   
<groupId>org.springframework.security</groupId>   
<artifactId>spring-security-jwt</artifactId> 
</dependency>
    
添加完Maven依赖项之后,需要先告诉验证服务如何生成和翻译JWT令牌。为此,将要在验证服务中创建一个名为JWTTokenStoreConfig的新配置类
(在authentication-service/src/java/com/thoughtmechanix/authentication/security/JWTTokenStoreConfig.java中)。
代码清单7-8展示了这个类的代码。
    
代码清单7-8 创建JWT令牌存储
@Configuration
public class JWTTokenStoreConfig {
 
    @Autowired
    private ServiceConfig serviceConfig;
 
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
⇽--- @Primary注解用于告诉Spring,如果有多个特定类型的bean(在本例中是DefaultTokenService),那么就使用被@Primary标注的bean类型进行自动注入  
    @Bean
    @Primary
    ⇽--- 用于从出示给服务的令牌中读取数据
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        return defaultTokenServices;
    }
 
    ←充当JWT到OAuth2服务器之间的转换器。
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        ⇽--- 定义将用于签署令牌的签名密钥  
        converter.setSigningKey(serviceConfig.getJwtSigningKey());
        return converter;
    }
    
    @Bean
    public TokenEnhancer jwtTokenEnhancer() {
        return new JWTTokenEnhancer();
    }
}
 
JWTTokenStoreConfig类用于定义Spring将如何管理JWT令牌的创建、签名和翻译。因为tokenServices()将使用Spring Security的默认令牌服务实现,所以这里的工作是固定的。我们要关注的是 jwtAccessTokenConverter()方法,它定义了令牌将如何被翻译。关于这个方法,需要注意的最重要的一点是,我们正在设置将要用于签署令牌的签名密钥。
    对于本例,我们将使用一个对称密钥,这意味着验证服务和受验证服务保护的服务必须要在所有服务之间共享相同的密钥。该密钥只不过是存储在验证服务Spring Cloud Config条目(https://github.com/carnellj/config-repo/blob/master/authenticationservice/authenticationservice.yml)中的随机字符串值。这个签名密钥的实际值是
    signing.key: "345345fsdgsf5345"
 
注意
 
Spring Cloud Security支持对称密钥加密和使用公钥/私钥的不对称加密。本书不打算使用公钥/私钥创建JWT。遗憾的是,关于JWT、Spring Security和公私钥的文档很少。如果读者对实现上面讨论的内容感兴趣,我强烈建议读者查看Baeldung.com,它非常好地解释了JWT和公钥/私钥如何创建。
    在代码清单7-8的JWTTokenStoreConfig中,我们定义了如何创建和签名JWT令牌。现在,我们需要将它挂钩到整个OAuth2服务中。在代码清单7-2中,我们使用OAuth2Config类来定义OAuth2服务的配置,我们创建了用于服务的验证管理器,以及应用程序名称和密钥。接下来,我们将使用一个名为JWTOAuth2Config的新类(在authentication-service/src/main/java/ com/thoughtmechanix/authentication/security/JWTOAuth2Config.java中)替换OAuth2Config类。
    
代码清单7-9展示了JWTOAuth2Config类的代码。
 
代码清单7-9 通过JWTOAuth2Config类将JWT挂钩到验证服务中
// 为了简洁,省略了import语句
@Configuration
public class JWTOAuth2Config extends AuthorizationServerConfigurerAdapter {
 
    @Autowired
    private AuthenticationManager authenticationManager;
 
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Autowired
    private TokenStore tokenStore;
 
    @Autowired
    private DefaultTokenServices tokenServices;
 
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;
 
    @Autowired
    private TokenEnhancer jwtTokenEnhancer;
 
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
         ⇽--- 代码清单7-8中定义的令牌存储将在这里注入
        endpoints.tokenStore(tokenStore)                             //JWT
                 ⇽--- 这是钩子,用于告诉Spring Security OAuth2代码使用JWT     
                .accessTokenConverter(jwtAccessTokenConverter)       //JWT
                .tokenEnhancer(tokenEnhancerChain)                   //JWT
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
    }
 
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
 
        clients.inMemory()
                .withClient("eagleeye")
                .secret("thisissecret")
                .authorizedGrantTypes("refresh_token", "password", "client_credentials")
                .scopes("webclient", "mobileclient");
    }
}
    
现在,如果重新构建验证服务并重新启动它,应该会返回一个基于JWT的令牌。图7-9展示了调用验证服务的结果,现在它使用JWT。
 
 
图7-9 来自验证调用的访问和刷新令牌现在是JWT令牌
    实际的令牌本身并不是直接作为JSON返回的。相反,JSON体使用Base64进行了编码。如果读者对JWT令牌的内容感兴趣,可以使用在线工具来解码令牌。我喜欢使用一个叫Stormpath的公司的在线工具,这个工具是一个在线的JWT解码器。图7-10展示了解码令牌的输出结果。
 
 
图7-10 使用http://jswebtoken.io可以解码内容
    
注意
    
    了解JWT令牌已签名但未加密非常重要。任何在线JWT工具都可以解码JWT令牌并公开其内容。我之所以提到这一点,是因为JWT规范允许开发人员扩展令牌,并向令牌添加额外的信息。不要在JWT令牌中暴露敏感信息或个人身份信息(Personally Identifiable Information,PII)。
 
7.4.2 在微服务中使用JWT
    到目前为止,我们已经拥有了创建JWT令牌的OAuth2验证服务。下一步就是配置许可证服务和组织服务以使用JWT。这很简单,只需要做两件事。
    (1)将spring-security-jwt依赖项添加到许可证服务和组织服务的pom.xml文件(参见7.4.1节,以获取需要添加的确切的Maven依赖项)。
    (2)在许可证服务和组织服务中创建JWTTokenStoreConfig类。这个类几乎与验证服务使用的类相同(参见代码清单7-8)。本书不会重复讲解相同的东西,读者可以在licensing-service/ src/main/com/thoughtmechanix/licensing-service/security/JWTTokenStoreConfig.java和organization- service/src/main/com/thoughtmechanix/organization-service/security/JWTTokenStoreConfig.java中看到JWTTokenStoreConfig类的例子。
 
我们需要做最后一项工作。因为许可证服务调用组织服务,所以需要确保OAuth2令牌被传播。这项工作通常是通过OAuth2RestTemplate类完成的,但是OAuth2RestTemplate类并不传播基于JWT的令牌。为了确保许可证服务能够做到这一点,需要添加一个自定义的RestTemplate bean来完成这个注入。这个自定义的RestTemplate可以在licensingservice/src/ main/java/com/thoughtmechanix/licenses/Application.java中找到。
代码清单7-10展示了这个自定义bean的定义。
    
代码清单7-10 创建自定义的 RestTemplate类以注入JWT令牌
public class Application {
 
    @Autowired
    private ServiceConfig serviceConfig;
    private static final Logger logger = LoggerFactory.getLogger(Application.class);
 
    @Primary
    @Bean
    public RestTemplate getCustomRestTemplate() {
        RestTemplate template = new RestTemplate();
        List interceptors = template.getInterceptors();
        if (interceptors == null) {
            ⇽--- UserContextInterceptor会将Authorization首部注入每个REST调用
            template.setInterceptors(Collections.singletonList(new UserContextInterceptor()));
        } else {
            interceptors.add(new UserContextInterceptor());
            template.setInterceptors(interceptors);
        }
 
        return template;
    }
 
 
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
    
在前面的代码中,我们定义了一个使用ClientHttpRequestInterceptor的自定义RestTemplate bean。回想一下第6章,ClientHttpRequestInterceptor是一个Spring类,它允许在基于REST的调用之前挂钩要执行的功能。这个拦截器类是第6章中定义的UserContextInterceptor类的变体。
这个类在licensing-service/src/main/java/com/thoughtmechanix/ licenses/utils/UserContextInterceptor.java中。
代码清单7-11展示了这个类。
   
 代码清单7-11 UserContextInterceptor将注入JWT令牌到REST调用
 public class UserContextInterceptor implements ClientHttpRequestInterceptor {
 
    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {
 
        HttpHeaders headers = request.getHeaders();
        headers.add(UserContext.CORRELATION_ID, UserContextHolder.getContext().getCorrelationId());
        ⇽--- 将授权令牌添加到HTTP首部
        headers.add(UserContext.AUTH_TOKEN, UserContextHolder.getContext().getAuthToken());
 
 
        return execution.execute(request, body);
    }
}  
 
UserContextInterceptor使用了第6章中的几个实用工具类。记住,每个服务都使用一个自定义servlet过滤器(名为UserContextFilter)来从HTTP首部解析出验证令牌和关联ID。在代码清单7-11中,我们使用已解析的UserContext.AUTH_TOKEN值来填入传出的HTTP调用。
 
就是这样。有了这些功能部件,现在就可以调用许可证服务(或组织服务),并将Base64编码的JWT添加到HTTP Authorizationt首部中,其值为Bearer<<JWT-Token>>,服务将正确地读取和确认JWT令牌。
   
7.4.3 扩展JWT令牌
    如果读者仔细观察图7-10中的JWT令牌,那么就会注意到EagleEye的organizationId字段(图7-11展示了图7-10中展示的JWT令牌的放大图)。这不是标准的JWT令牌字段,而是额外的字段,是在创建JWT令牌时通过注入新字段添加的。
 
图7-11 使用organizationId扩展JWT令牌的示例
    通过向验证服务添加一个Spring OAuth2令牌增强器类,可以很容易地扩展JWT令牌。这个类是JWTTokenEnhancer,其源代码可以在authentication-service/src/main/java/com/thoughtmechanix/ authentication/security/JWTTokenEnhancer.java中找到。代码清单7-12展示了这段代码。
    
代码清单7-12 使用JWT令牌增强器类添加自定义字段
⇽--- 需要扩展TokenEnhancer类   
 public class JWTTokenEnhancer implements TokenEnhancer {
    @Autowired
    private OrgUserRepository orgUserRepo;
    
    ⇽--- getOrgId()方法基于用户名查找用户的组织ID
    private String getOrgId(String userName){
        UserOrganization orgUser = orgUserRepo.findByUserName( userName );
        return orgUser.getOrganizationId();
    }
    ⇽--- 要进行这种增强,需要覆盖enhance()方法  
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> additionalInfo = new HashMap<>();
        String orgId =  getOrgId(authentication.getName());
 
        additionalInfo.put("organizationId", orgId);
         ⇽--- 所有附加的属性都放在HashMap中,并设置在传入该方法的accessToken变量上
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        return accessToken;
    }
}
    
需要做的最后一件事是告诉OAuth2服务使用JWTTokenEnhancer类。首先,需要为JWTTokenEnhancer类公开一个Spring bean。通过在代码清单7-8中定义的JWTTokenStoreConfig类中添加一个bean定义来实现这一点:
    
package com.thoughtmechanix.authentication.security; 
@Configuration 
public class JWTTokenStoreConfig {    
 // 为了简洁,省略了类的其余部分     
@Bean  
public TokenEnhancer jwtTokenEnhancer() {         
return new JWTTokenEnhancer();     
}
    
一旦将JWTTokenEnhancer作为bean公开,那么就可以将它挂钩到代码清单7-9所示的JWTOAuth2Config类中。这一点在JWTOAuth2Config类的configure()方法中完成。
代码清单7-13展示了对JWTOAuth2Config类的configure()方法的修改。
    
代码清单7-13 挂钩TokenEnhancer
@Configuration
public class JWTOAuth2Config extends AuthorizationServerConfigurerAdapter {
 
    @Autowired
    private AuthenticationManager authenticationManager;
 
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Autowired
    private TokenStore tokenStore;
 
    @Autowired
    private DefaultTokenServices tokenServices;
 
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;
    ⇽--- 自动装配在TokenEnhancer类中
    @Autowired
    private TokenEnhancer jwtTokenEnhancer;
 
 
 
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        ⇽--- Spring OAuth允许开发人员挂钩多个令牌增强器,因此将令牌增强器添加到                                                                                    TokenEnhancerChain类中
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
 
        endpoints.tokenStore(tokenStore)                             //JWT
                .accessTokenConverter(jwtAccessTokenConverter)       //JWT
                 ⇽--- 将令牌增强器挂钩到传入configure()方法的endpoints参数   
                .tokenEnhancer(tokenEnhancerChain)                   //JWT
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
    }
 
 
 
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
 
        clients.inMemory()
                .withClient("eagleeye")
                .secret("thisissecret")
                .authorizedGrantTypes("refresh_token", "password", "client_credentials")
                .scopes("webclient", "mobileclient");
    }
}
 
    到目前为止,我们已将自定义字段添加到JWT令牌中。接下来的问题是,如何从JWT令牌中解析自定义字段?
    7.4.4 从JWT令牌中解析自定义字段
    本节将转到Zuul网关,以说明如何解析JWT令牌中的自定义字段。具体来说,我们将修改第6章中介绍的TrackingFilter类,以从流经网关的JWT令牌中解码 organizationId字段。
    要完成这一点,我们将要引入一个JWT解析器库,并添加到Zuul服务器的pom.xml文件中。有多个令牌解析器可供使用,这里选择JJWT库来进行解析。这个库的Maven依赖项是
    <dependency> 
    <groupId>io.jsonwebtoken</groupId>   
    <artifactId>jjwt</artifactId>   
    <version>0.7.0</version> 
    </dependency>
    添加完JJWT库后,可以向TrackingFiler类(在zuulsvr/src/main/java/com/thoughtmechanix/ zuulsvr/filters/TrackingFilter.java 中)添加一个名为getOrganizationId()的新方法。代码清单7-14展示了这个新方法。
    
代码清单7-14 从JWT令牌中解析出organizationId
    
    private String getOrganizationId(){
 
        String result="";
        if (filterUtils.getAuthToken()!=null){
            ←从HTTP头Authorization解析出令牌。
            String authToken = filterUtils.getAuthToken().replace("Bearer ","");
            try {
                  ⇽--- 传入用于签署令牌的签名密钥,使用JWTS类解析令牌 
                Claims claims = Jwts.parser()
                        .setSigningKey(serviceConfig.getJwtSigningKey().getBytes("UTF-8"))
                        .parseClaimsJws(authToken).getBody();
                ⇽--- 从令牌中提取出organizationId   
                result = (String) claims.get("organizationId");
            }
            catch (Exception e){
                e.printStackTrace();
            }
        }
        return result;
    }
 
实现了getOrganizationId()方法之后,我们就将System.out.println添加到TrackingFilter的run()方法中,以打印从流经Zuul网关的JWT令牌中解析出来的organizationId。接下来,我们就来调用任何启用网关的REST端点。我使用GET方法调用http://localhost:5555/api/licensing/v1/organizations/e254f8c-c442-4ebe-a82a- e2fc1d1ff78a/licenses/f3831f8c-c338-4ebe-a82a-e2fc1d1ff78a。记住,在进行这个调用时,仍然需要创建所有HTTP表单参数和HTTP授权首部,来包含Authorization首部和JWT令牌。
    
图7-12展示了已解析的organizationId在命令行控制台的输出。
 
 
图7-12 Zuul服务从流经的JWT令牌中解析出组织ID
    
7.5 关于微服务安全的总结
    虽然本章介绍了OAuth2规范,以及如何使用Spring Cloud Security实现OAuth2验证服务,但OAuth2只是微服务安全难题的一部分。在构建用于生产级别的微服务时,应该围绕以下实践构建微服务安全。
    (1)对所有服务通信使用HTTPS/安全套接字层(Secure Sockets Layer,SSL)。
    (2)所有服务调用都应通过API网关。
    (3)将服务划分到公共API和私有API。
    (4)通过封锁不需要的网络端口来限制微服务的攻击面。
    图7-13展示了这些不同的实践如何配合起来工作。上述列表中的每个编号项都与图7-13中的数字对应。
 
 
图7-13 微服务安全架构不只是实现OAuth2
    让我们更详细地审查前面列表和图7-13中列出的每个主题领域。
    1.为所有业务通信使用HTTPS/安全套接字层(SSL)
    在本书的所有代码示例中,我们一直使用HTTP,这是因为HTTP是一个简单的协议,并且不需要在每个服务上进行安装就能开始使用该服务。
 
在生产环境中,微服务应该只通过HTTPS和SSL提供的加密通道进行通信。HTTPS的配置和安装可以通过DevOps脚本自动完成。
    
注意
    
    如果应用程序需要满足信用卡支付的支付卡行业(Payment Card Industry,PCI)的合规性要求,那么就需要为所有的服务通信实现HTTPS。在构建服务时,要尽早就使用HTTPS,这要比将应用程序和微服务部署到生产环境之后再进行项目迁移容易得多。
 
2.使用服务网关访问微服务
    客户端永远不应该直接访问运行服务的各个服务器、服务端点和端口。相反,应该使用服务网关作为服务调用的入口点和守门人。在微服务运行的操作系统或容器上配置网络层,以便仅接受来自服务网关的流量。
    记住,服务网关可以作为一个针对所有服务执行的策略执行点(PEP)。通过像Zuul这样的服务网关来进行服务调用,让开发人员可以在保护和审计服务方面保持一致。服务网关还允许开发人员锁定要向外界公开的端口和端点。
    
3.将服务划分到公共API和私有API
一般来说,安全是关于构建访问和执行最小权限概念的层。最小权限是用户应该拥有最少的网络访问权限和特权来完成他们的日常工作。为此,开发人员应该通过将服务分离到两个不同的区域(即公共区域和私有区域)来实现最小权限。
    公共区域包含由客户端使用的公共API(EagleEye应用程序)。公共API微服务应该执行面向工作流的小任务。公共API微服务通常是服务聚合器,在多个服务中提取数据并执行任务。
    公共微服务应该位于它们自己的服务网关后面,并拥有自己的验证服务来执行OAuth2验证。客户端应用程序应该通过受服务网关保护的单一路由访问公共服务。此外,公共区域应该有自己的验证服务。
    私有区域充当保护核心应用程序功能和数据的壁垒,它应该只通过一个众所周知的端口访问,并且应该被封锁,只接受来自运行私有服务的网络子网的网络流量。除此之外,私有区域应该拥有自己的服务网关和验证服务。公共API服务应该对私有区域验证服务进行验证。所有的应用程序数据至少应该在私有区域的网络子网中,并且只能通过驻留在私有区域的微服务访问。
    私有API网络区域应该要被封锁到什么程度
    许多组织采取的方法是,他们的安全模型应该有一个坚硬的外在中心,但拥有一个更柔软的内表面。这意味着,一旦流量进入私有API区域,私有区域中的服务之间的通信就可以不加密(不需要HTTPS),也不需要验证机制。大多数时候,这都是为了方便和加快开发。拥有的安全性越高,调试问题的难度就越大,从而增加管理应用程序的整体复杂性。
    我倾向于对这个世界抱有一种偏执的看法。(我在金融服务行业工作了8年,因此自然而然地变得多疑。)我宁愿牺牲额外的复杂性(可以通过DevOps脚本来减轻这种复杂性),强制在私有API区域中运行的所有服务都使用SSL,并通过私有区域中运行的验证服务进行验证。读者需要问自己的问题是,是否愿意看到自己的组织因为遭受网络入侵而登上当地报纸的头版?
    4.通过封锁不需要的网络端口来限制微服务的攻击面
    许多开发人员并没有重视为了使服务正常运行而需要打开的端口的最少数量。请配置运行服务的操作系统,只允许打开入站和出站访问服务所需的端口,或者服务所需的一部分基础设施(监视、日志聚合)。
 
不要只关注入站访问端口。许多开发人员忘记了封锁他们的出站端口。封锁出站端口可以防止数据在服务本身被攻击者破坏的情况下从服务中泄露。另外,要确保查看公共API区域和私有API区域中的网络端口访问。
    
7.6 小结
    OAuth2是一个基于令牌的验证框架,用于对用户进行验证。
    OAuth2确保每个执行用户请求的微服务不需要在每次调用时都出示用户凭据。
    OAuth2为保护Web服务调用提供了不同的机制,这些机制称为授权(grant)。
    要在Spring中使用OAuth2,需要建立一个基于OAuth2的验证服务。
想要调用服务的每个应用程序都需要通过OAuth2验证服务注册。
    每个应用程序都有自己的应用程序名称和密钥。
    用户凭据和角色存储在内存或数据存储中,并通过Spring Security访问。
    每个服务必须定义角色可以采取的动作。
    Spring Cloud Security支持JSON Web Token(JWT)规范。
    JWT定义了一个签名的JSON标准,用于生成OAuth2令牌。
    使用JWT可以将自定义字段注入规范中。
    保护微服务涉及的不仅仅是使用OAuth2,还应该使用HTTPS加密服务之间的所有调用。
    使用服务网关来缩小可以到达服务的访问点的数量。
    通过限制运行服务的操作系统上的入站端口和出站端口数来限制服务的攻击面。
 
posted @ 2019-12-03 10:53  mongotea  阅读(469)  评论(0编辑  收藏  举报