朱晔和你聊Spring系列S1E10:强大且复杂的Spring Security(含OAuth2三角色+三模式完整例子)...

Spring Security功能多,组件抽象程度高,配置方式多样,导致了Spring Security强大且复杂的特性。Spring Security的学习成本几乎是Spring家族中最高的,Spring Security的精良设计值得我们学习,但是结合实际复杂的业务场景,我们不但需要理解Spring Security的扩展方式还需要去理解一些组件的工作原理和流程(否则怎么去继承并改写需要改写的地方呢?),这又带来了更高的门槛,因此,在决定使用Spring Security搭建整套安全体系(授权、认证、权限、审计)之前还是需要考虑一下将来我们的业务会多复杂,我们徒手写一套安全体系来的划算还是使用Spring Security更好。

短短的一篇文章不可能覆盖Spring Security的方方面面,在最近的工作中会比较多接触OAuth2,因此本文以这个维度来简单阐述一下如果使用Spring Security搭建一套OAuth2授权&SSO架构。

 

OAuth2简介

 

OAuth2.0是一套授权体系的开放标准,定义了四大角色:

1.      资源拥有者,也就是用户,由用于授予三方应用权限

2.      客户端,也就是三方应用程序,在访问用户资源之前需要用户授权

3.      资源提供者,或者说资源服务器,提供资源,需要实现Token和ClientID的校验,以及做好相应的权限控制

4.      授权服务器,验证用户身份,为客户端颁发Token,并且维护管理ClientID、Token以及用户

其中后三项都可以是独立的程序,在本文的例子中我们会为这三者建立独立的项目。OAuth2.0标准同时定义了四种授权模式,这里介绍最常用的三种,也是后面会演示的三种(在之后的介绍中令牌=Token,码=Code,可能会混合表达):

1.      不管是哪种模式,通用流程如下:

a)      三方网站(或者说客户端)需要先向授权服务器去申请一套接入的ClientID+ClientSecret

b)     用任意一种模式拿到访问Token(流程见下)

c)      拿着访问Token去资源服务器请求资源

d)     资源服务器根据Token查询到Token对应的权限进行权限控制

2.      授权码模式,最标准最安全的模式,适合和外部交互,流程是:

a)      三方网站客户端转到授权服务器,上送ClientID,授权范围Scope、重定向地址RedirectUri等信息

b)     用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)

c)      授权完成后重定向回到之前客户端提供的重定向地址,附上授权码

d)     三方网站服务端通过授权码+ClientID+ClientSecret去授权服务器换取Token(Token含访问Token和刷新Token,访问Token过去后用刷新Token去获得新的访问Token)

e)      你可能会问这个模式为什么这么复杂,为什么安全呢?因为我们不会对外暴露ClientSecret,不会对外暴露访问Token,使用授权码换取Token的过程是服务端进行,客户端拿到的只是一次性的授权码

3.      密码凭证模式,适合内部系统之间使用的模式(客户端是自己人,客户端需要拿到用户帐号密码),流程是:

a)      用户提供帐号密码给客户端

b)     客户端凭着用户的帐号密码,以及客户端自己的ClientID+ClientSecret去授权服务器换取Token

4.      客户端模式,适合内部服务端之间使用的模式:

a)      和用户没有关系,不是基于用户的授权

b)     客户端凭着自己的ClientID+ClientSecret去授权服务器换取Token

下面,我们来搭建程序实际体会一下这几种模式。

 

搭建授权服务器

 

首先来创建一个父POM,内含三个模块:

(代码太长不贴了,详见github)

然后我们创建第一个模块,资源服务器:

(代码太长不贴了,详见github)

这边我们除了使用了Spring Cloud的OAuth2启动器之外还使用数据访问、Web等依赖,因为我们的资源服务器需要使用数据库来保存客户端的信息、用户信息等数据,我们同时也会使用thymeleaf来稍稍美化一下登录页面。

现在我们来创建一个配置文件application.yml:

server:
 
port: 8080

spring:
 
application:
   
name: oauth2-server
 
datasource:
   
url: jdbc:mysql://localhost:3306/oauth?useSSL=false
   
username: root
   
password: root
   
driver-class-name:com.mysql.jdbc.Driver

可以看到,我们会使用oauth数据库,授权服务器的端口是8080。

数据库中我们需要初始化一些表:

1.      用户表users:存放用户名密码

2.      授权表authorities:存放用户对应的权限

3.      客户端信息表oauth_client_details:存放客户端的ID、密码、权限、允许访问的资源服务器ID以及允许使用的授权模式等信息

4.      授权码表oauth_code:存放了授权码

5.      授权批准表oauth_approvals:存放了用户授权第三方服务器的批准情况

DDL如下:

(代码太长不贴了,详见github)

在之后演示的时候会看到这些表中的数据。这里可以看到我们并没有在数据库中创建相应的表来存放访问令牌、刷新令牌,这是因为我们之后的实现会把令牌信息使用JWT来传输,不会存放到数据库中。基本上所有的这些表都是可以自己扩展的,只需要继承实现Spring的一些既有类即可,这里不做展开。

下面,我们创建一个最核心的类用于配置授权服务器:

package me.josephzhu.springsecurity101.cloud.oauth2.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.approval.JdbcApprovalStore;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
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 org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.sql.DataSource;
import java.util.Arrays;

@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter{
   
@Autowired
   
private DataSource dataSource;
   
@Autowired
   
private AuthenticationManagerauthenticationManager;

   
/**
     *
代码1
     * @param
clients
    
* @throws Exception
     */
   
@Override
   
public void configure(ClientDetailsServiceConfigurerclients) throws Exception {
        clients.jdbc(
dataSource);
    }

   
/**
     *
代码2
     * @param
security
    
* @throws Exception
     */
   
@Override
   
public void configure(AuthorizationServerSecurityConfigurersecurity) throws Exception {
        security.checkTokenAccess(
"permitAll()")
               .allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

   
/**
     *
代码3
     * @param
endpoints
    
* @throws Exception
     */
   
@Override
   
public void configure(AuthorizationServerEndpointsConfigurerendpoints) throws Exception {
        TokenEnhancerChaintokenEnhancerChain =
new TokenEnhancerChain();
       tokenEnhancerChain.setTokenEnhancers(
                Arrays.asList(tokenEnhancer(),jwtTokenEnhancer()));

       endpoints.approvalStore(approvalStore())
               .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
               .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(
authenticationManager);
    }

   
@Bean
   
public AuthorizationCodeServicesauthorizationCodeServices() {
       
return new JdbcAuthorizationCodeServices(dataSource);
    }

   
@Bean
   
public TokenStoretokenStore() {
       
return new JwtTokenStore(jwtTokenEnhancer());
    }

   
@Bean
   
public JdbcApprovalStoreapprovalStore() {
       
return new JdbcApprovalStore(dataSource);
    }

   
@Bean
   
public TokenEnhancertokenEnhancer() {
       
return new CustomTokenEnhancer();
    }

   
@Bean
   
protected JwtAccessTokenConverterjwtTokenEnhancer() {
        KeyStoreKeyFactorykeyStoreKeyFactory =
new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray());
        JwtAccessTokenConverter converter=
new JwtAccessTokenConverter();
       converter.setKeyPair(keyStoreKeyFactory.getKeyPair(
"jwt"));
       
return converter;
    }

   
/**
     *
代码4
     */
   
@Configuration
   
static class MvcConfig implements WebMvcConfigurer{
       
@Override
       
public void addViewControllers(ViewControllerRegistryregistry) {
            registry.addViewController(
"login").setViewName("login");
        }
    }
}

分析下这个类:

1.      首先我们可以看到,我们需要通过注解@EnableAuthorizationServer来开启授权服务器

2.      代码片段1中,我们配置了使用数据库来维护客户端信息,当然在各种Demo中我们经常看到的是在内存中维护客户端信息,通过配置直接写死在这里,对于实际的应用我们一般都会用数据库来维护这个信息,甚至还会建立一套工作流来允许客户端自己申请ClientID

3.      代码片段2中,针对授权服务器的安全,我们干了两个事情,首先打开了验证Token的访问权限(以便之后我们演示),然后允许ClientSecret明文方式保存并且可以通过表单提交(而不仅仅是Basic Auth方式提交),之后会演示到这个

4.      代码片段3中,我们干了几个事情:

a)      配置我们的Token存放方式不是内存方式、不是数据库方式、不是Redis方式而是JWT方式,JWT是Json Web Token缩写也就是使用JSON数据格式包装的Token,由.句号把整个JWT分隔为头、数据体、签名三部分,JWT保存Token虽然易于使用但是不是那么安全,一般用于内部,并且需要走HTTPS+配置比较短的失效时间

b)     配置了JWT Token的非对称加密来进行签名

c)      配置了一个自定义的Token增强器,把更多信息放入Token中

d)     配置了使用JDBC数据库方式来保存用户的授权批准记录

5.      代码片段4中,我们配置了登录页面的视图信息(其实可以独立一个配置类更规范)

针对刚才的代码,我们需要补充一些东西到资源目录下,首先需要在资源目录下创建一个templates文件夹然后创建一个login.html登录模板:

<!DOCTYPE html>
<
html xmlns:th="http://www.thymeleaf.org" class="uk-height-1-1">
<
head>
    <
meta charset="UTF-8"/>
    <
title>OAuth2Demo</title>
    <
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/2.26.3/css/uikit.gradient.min.css"/>
</
head>

<
body class="uk-height-1-1">

<
div class="uk-vertical-align uk-text-centeruk-height-1-1">
    <
div class="uk-vertical-align-middle" style="width: 250px;">
        <
h1>LoginForm</h1>

        <
p class="uk-text-danger" th:if="${param.error}">
           
用户名或密码错误...
        </
p>

        <
form class="uk-panel uk-panel-box uk-form" method="post" th:action="@{/login}">
            <
div class="uk-form-row">
                <
input class="uk-width-1-1 uk-form-large" type="text" placeholder="Username" name="username"
                      
value="reader"/>
            </
div>
            <
div class="uk-form-row">
                <
input class="uk-width-1-1 uk-form-large" type="password" placeholder="Password" name="password"
                      
value="reader"/>
            </
div>
            <
div class="uk-form-row">
                <
button class="uk-width-1-1 uk-button uk-button-primary uk-button-large">Login</button>
            </
div>
        </
form>

    </
div>
</
div>
</
body>
</
html>

然后,我们需要使用keytool工具生成密钥,把密钥文件jks保存到目录下,然后还要导出一个公钥留作以后使用。刚才在代码中我们还用到了一个自定义的Token增强器,实现如下:

 
 

这段代码非常简单,就是把用户信息以userDetails这个Key存放到Token中去(如果授权模式是客户端模式这段代码无效,因为和用户没关系)。这是一个常见需求,默认情况下Token中只会有用户名这样的基本信息,我们往往需要把有关用户的更多信息返回给客户端(在实际应用中你可能会从数据库或外部服务查询更多的用户信息加入到JWT Token中去),这个时候就可以自定义增强器来丰富Token的内容。

到此授权服务器的核心配置已经完成,现在我们再来实现一下安全方面的配置:

 
 

这里我们主要做了两个事情:

1.      配置用户账户的认证方式,显然,我们把用户存在了数据库中希望配置JDBC的方式,此外,我们还配置了使用BCryptPasswordEncoder加密来保存用户的密码(生产环境的用户密码肯定不能是明文保存)

2.      开放/login和/oauth/authorize两个路径的匿名访问,前者用于登录,后者用于换授权码,这两个端点访问的时候都在登录之前

最后配置一个主程序:

 
 

至此,授权服务器的配置完成。

 

搭建资源服务器

 

先来创建项目:

 
 

配置及其简单,声明资源服务端口8081

 
 

还记得在资源文件夹下放我们之前通过密钥导出的公钥文件,类似:

 
 

先来创建一个可以匿名访问的接口GET /hello:

 
 

再来创建一个需要登录+授权才能访问到的一些接口:

 
 

这里我们配置了三个接口,并且通过@PreAuthorize在方法执行前进行权限控制:

1.      GET /user/name接口读写权限都可以访问

2.      GET /user接口读写权限都可以访问,返回整个OAuth2Authentication

3.      POST /user接口只有写权限可以访问,返回之前的CustomTokenEnhancer加入到Token中的额外信息,Key是userDetails,这里也演示了使用TokenStore来解析Token的方式

下面我们来创建核心的资源服务器配置类:

 
 

这里我们干了四件事情:

1.      @EnableResourceServer启用资源服务器

2.      @EnableGlobalMethodSecurity(prePostEnabled= true)启用方法注解方式来进行权限控制

3.      代码1,声明了资源服务器的ID是foo,声明了资源服务器的TokenStore是JWT以及公钥

4.      代码2,配置了除了/user路径之外的请求可以匿名访问

我们想一下,如果授权服务器产生Token的话,资源服务器必须是要有一种办法来验证Token的,如果是非JWT的方式,我们可以这么办:

1.      Token可以保存在数据库或Redis中,资源服务器和授权服务器共享底层的TokenStore来验证

2.      资源服务器可以使用RemoteTokenServices来从授权服务器的/oauth/check_token端点进行Token校验(还记得吗,我们之前开放过这个端口)

现在我们使用的是不落地的JWT方式+非对称加密,需要通过本地公钥进行验证,因此在这里我们配置了公钥的路径。

最后创建一个启动类:

 
 

至此,资源服务器配置完成,我们还在资源服务器中分别建了两个控制器,用于测试匿名访问和收到资源服务器权限保护的资源。

 

初始化数据配置

 

现在我们来看一下如何配置数据库实现:

1.      两个用户,读用户reader具有读权限,写用户writer具有读写权限

2.      两个权限,读和写

3.      三个客户端:

a)      userservice1这个客户端使用密码凭证模式

b)     userservice2这个客户端使用客户端模式

c)      userservice3这个客户端使用授权码模式

首先是oauth_client_details表:

640?wx_fmt=png

INSERTINTO `oauth_client_details` VALUES ('userservice1', 'foo', '1234', 'FOO','password,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');

INSERTINTO `oauth_client_details` VALUES ('userservice2', 'foo', '1234', 'FOO','client_credentials,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL,'true');

INSERTINTO `oauth_client_details` VALUES ('userservice3', 'foo', '1234', 'FOO','authorization_code,refresh_token', 'https://baidu.com', 'READ,WRITE', 7200,NULL, NULL, 'false');

如之前所说,这里配置了三条记录:

1.      它们能使用的资源ID都是foo,对应我们资源服务器userservice的配置

2.      它们的授权范围都是FOO,可以拿到的权限是读写(但对于用户关联的模式,最终拿到的权限还取决于客户端权限和用户权限的交集)

3.      通过grant_types字段配置了支持的不同的授权模式,这里我们为了便于测试观察给三个客户端各自配置了一个模式,你完全可以为一个客户端配置支持OAuth2.0的那四种模式

4.      userservice1和2我们配置了用户自动批准授权(不会弹出一个页面要求用户进行授权那种)

然后是authorities表,其中我们配置了两条记录,配置reader用户具有读权限,writer用户具有写权限:

640?wx_fmt=png

INSERTINTO `authorities` VALUES ('reader', 'READ');

INSERTINTO `authorities` VALUES ('writer', 'READ,WRITE');

最后是users表配置了两个用户的账户名和密码:

640?wx_fmt=png

INSERTINTO `users` VALUES ('reader','$2a$04$C6pPJvC1v6.enW6ZZxX.luTdpSI/1gcgTVN7LhvQV6l/AfmzNU/3i', 1);

INSERTINTO `users` VALUES ('writer','$2a$04$M9t2oVs3/VIreBMocOujqOaB/oziWL0SnlWdt8hV4YnlhQrORA0fS', 1);

还记得吗,密码我们使用的是BCryptPasswordEncoder加密(准确说是哈希),可以使用一些在线工具进行哈希

 

演示三种授权模式

 

客户端模式

 

POST请求地址:

http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=userservice2&client_secret=1234

如下图所示,直接可以拿到Token:

640?wx_fmt=png

这里注意到并没有提供刷新令牌,刷新令牌用于避免访问令牌失效后还需要用户登录,客户端模式没有用户概念,没有刷新令牌。我们把得到的Token粘贴到https://jwt.io/#debugger-io查看:

640?wx_fmt=png


如果粘贴进去公钥的话还可以看到Token签名验证成功:

640?wx_fmt=png

也可以试一下,如果我们的授权服务器没有allowFormAuthenticationForClients的话,客户端的凭证需要通过Basic Auth传而不是Post过去:

640?wx_fmt=png

还可以访问授权服务器来校验Token:

http://localhost:8080/oauth/check_token?client_id=userservice1&client_secret=1234&token=...

得到如下结果:

640?wx_fmt=png

 

密码凭证模式

 

POST请求地址:
http://localhost:8080/oauth/token?grant_type=password&client_id=userservice1&client_secret=1234&username=writer&password=writer

得到如下图结果:

640?wx_fmt=png

再看下Token中的信息:

640?wx_fmt=png

可以看到果然包含了我们TokenEnhancer加入的userDetails自定义信息。

 

授权码模式

 

首先打开浏览器访问地址:

http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=https://baidu.com

注意,我们客户端跳转地址需要和数据库中配置的一致,百度的URL我们之前已经在数据库中有配置了,访问后页面会跳转到登录界面,使用reader:reader登录:

640?wx_fmt=png

由于我们数据库中设置的是禁用自动批准授权的模式,所以登录后来到了批准界面:

640?wx_fmt=png

点击同意后可以看到数据库中也会产生授权通过记录:

640?wx_fmt=png

然后我们可以看到浏览器转到了百度并且提供给了我们授权码:

https://www.baidu.com/?code=O8RiCe

数据库中也记录了授权码:

640?wx_fmt=png

然后POST访问:http://localhost:8080/oauth/token?grant_type=authorization_code&client_id=userservice3&client_secret=1234&code=O8RiCe&redirect_uri=https://baidu.com

可以得到访问令牌:

640?wx_fmt=png

虽然userservice3客户端可以有READ和WRITE权限,但是我们登录的用户reader只有READ权限,最后拿到的权限只有READ

 

演示资源服务器权限控制

 

首先我们可以测试一下我们的安全配置,访问/hello端点不需要认证可以匿名访问:

640?wx_fmt=png

访问/user需要身份认证:

640?wx_fmt=png

不管以哪种模式拿到访问令牌,我们用具有读权限的访问令牌GET访问资源服务器如下地址(请求头加入Authorization: Bearer XXXXXXXXXX,其中XXXXXXXXXX代表Token):

http://localhost:8081/user/

可以得到如下结果:

640?wx_fmt=png

以POST方式访问http://localhost:8081/user/显然是失败的:

640?wx_fmt=png

我们换一个具有读写权限的令牌来试试:

640?wx_fmt=png

果然可以成功,说明资源服务器的权限控制有效。

 

搭建客户端程序

 

在之前,我们使用的是裸HTTP请求手动的方式来申请和使用令牌,最后我们来搭建一个OAuth客户端程序自动实现这个过程:

 
 

配置文件如下:

 
 

客户端项目端口8082,几个需要说明的地方:

1.      本地测试的时候一个坑就是我们需要配置context-path否则可能会出现客户端和授权服务器服务端Cookie干扰导致CSRF防御触发的问题,这个问题出现后程序没有任何错误日志输出,只有开启DEBUG模式后才能看到DEBUG日志里有提示,这个问题非常难以排查,也不知道Spring为啥不把这个信息作为WARN级别

2.      作为OAuth客户端,我们需要配置OAuth服务端获取令牌的地址以及授权(获取授权码)的地址,以及需要配置客户端的ID和密码,以及授权范围

3.      因为使用的是JWT Token,我们需要配置公钥(当然,如果不在这里直接配置公钥的话也可以配置公钥从授权服务器服务端获取)

 

首先实现MVC的配置:

 
 

这里做了两个事情:

1.      配置RequestContextListener用于启用session scope的Bean

2.      配置了index路径的首页Controller

然后实现安全方面的配置:

 
 

这里我们实现的是/路径和/login路径允许访问,其它路径需要身份认证后才能访问。

然后我们来创建一个控制器:

 
 

这里可以看到:

1.      对于securedPage,我们把用户信息作为模型传入了视图

2.      我们引入了OAuth2RestTemplate,在登录后就可以使用凭据直接从资源服务器拿资源,不需要繁琐的实现获得访问令牌,在请求头里加入访问令牌的过程

在开始的时候我们定义了index页面,模板如下:

 
 

现在又定义了securedPage页面,模板如下:

 
 

接下去最关键的一步是启用@EnableOAuth2Sso,这个注解包含了@EnableOAuth2Client:

 
 

此外,我们这里还定义了OAuth2RestTemplate,网上一些比较老的资料给出的是手动读取配置文件来实现,最新版本已经可以自动注入OAuth2ProtectedResourceDetails。

最后是启动类:

 
 

 

演示单点登录

 

启动客户端项目,打开浏览器访问http://localhost:8082/ui/securedPage:

可以看到页面自动转到了授权服务器的登录页面:

640?wx_fmt=png

点击登录后出现如下错误:

640?wx_fmt=png

显然,之前我们数据库中配置的redirect_uri是百度首页,需要包含我们的客户端地址,我们把字段内容修改为4个地址:

https://baidu.com,http://localhost:8082/ui/login,http://localhost:8083/ui/login,http://localhost:8082/ui/remoteCall

刷新页面,登录成功:

640?wx_fmt=png

我们再启动另一个客户端网站,端口改为8083,然后访问同样地址:

640?wx_fmt=png

可以看到同样是登录状态,SSO单点登录测试成功,是不是很方便。

 

演示客户端请求资源服务器资源

 

最后,我们来访问一下remoteCall接口:

640?wx_fmt=png

可以看到输出了用户名,对应的资源服务器服务端是:

换一个用户登录试试:

640?wx_fmt=png

 

总结

 

本文以OAuth 2.0这个维度来小窥了一下Spring Security的功能,介绍了OAuth 2.0的基本概念,体验了三种常用模式,也使用Spring Security实现了OAuth 2.0的三个组件,客户端、授权服务器和资源服务器,实现了资源服务器的权限控制,最后还使用客户端测试了一下SSO和OAuth2RestTemplate使用,所有代码见我的Github https://github.com/JosephZhu1983/SpringSecurity101,希望本文对你有用。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值