【spring-security-OAUTH2-Client2Gitee详解】

spring security OAUTH2全景图

第一步:在Gitee授权服务器上“创建应用”其实也就是注册client到授权服务器

进入gitee,创建自己的第三方应用 https://gitee.com/oauth/applications/new

  1. 进入gitee,创建自己的第三方应用 https://gitee.com/oauth/applications/new

点击第三方应用

点击创建应用

2填写应用相关信息,勾选应用所需要的权限。其中: 回调地址是用户对client确认授权后,码云将浏览器重定向到的应用client的地址,也是回传授权码的地址。

【注意】上图的应用回调地址就是我们注册的应用client的接收授权码和access-token的地址其中,/login/oauth2/code必须是这样,这是由Client引入的oauth2AuthenticationFilter的内部默认路径。而后面的giteeregistrationid,我们在自己的Client代码中配置的一致,Clientoauth2AuthenticationFilter要靠这个值去对应配置在程序中的clientidclientSecret然后发给gitee认证服务器。且将来gitee授权服务器收到授权请求后,会将配置的这个应用回调地址与请求参数中的redirect_uri匹配,正确才回传授权码以及access_token。

创建应用过程中你提供的参数包括这些

应用主页:http://localhost:8080

应用回调地址:http://localhost:8080/login/oauth2/code/gitee    【红色的由Oauth2Authenticationfilter默认的】而“”gitee”是我们在应用代码中配置的。

第二步:开发Client应用项目

pom主要依赖

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-oauth2-client</artifactId>  【注】oauth2应用client依赖的starter

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-security</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>

</dependency>

2.进行client应用的安全配置

@Configuration

public class ProjectConfig extends WebSecurityConfigurerAdapter {

@Override

protected void configure(HttpSecurity http) throws Exception {

http.oauth2Login();    【指定认证方式:配置filterchain,向filterChain中加入了一个Oauth2AuthenticationFilter的过滤器】

http.authorizeRequests()  开始配置授权

.anyRequest() 

.authenticated();【任何请求,必须都是经过认证的合法用户。认证会用oauth2authenticationFilter的使用的authenticationProvider就是去Gitee授权服务器上一边认证user,同时也为Client授权访问资源服务器gitee

3.client应用上要注册一个client实例。

ClientRegistration cr =

ClientRegistration.withRegistrationId("gitee")  【起个名字,代表client,如clientId和clientSecret】

.clientId("1b7fad4402b92690721ee8ead02b92690721d")  【此处要换成你在gitee上创建应用得到的】

.clientSecret("819c94b402b92690721ee8ead02b402b92690721ee8ead02b9293d15")【此处要换成你在gitee上创建应用得到的】

.scope(new String[]{"user_info "})    【读取用户权限,参见你gitee上创建应用时的授权勾选】

.authorizationUri("https://gitee.com/oauth/authorize")   【这要看gitee的api,是user认证以及client认证获取授权码的地址】

.tokenUri("https://gitee.com/oauth/token ") 【这要看gitee的api,是client得到授权码后去换token的gitee地址】

.userInfoUri("https://gitee.com/api/v5/user") 【资源服务器api地址-也是client用access-token去获取用户user详情的“用户详情资源服务器地址”-这里也是gitee】】

.userNameAttributeName("id")

.clientName("gitee")  【为我们的应用client起了个名字】

.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)  【注是授权码模式】

.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")  本应用配置的gitee发回授权码的地址

http://localhost:8080/login/oauth2/code/gitee

.build();

redirectUriTemplate在OAuth2AuthenticationFilter的逻辑中【这是你依赖的filter逻辑写死的】,action的值就是login.而registrationId是我们配的gitee。这个地址要和Gitee上创建应用时配置的应用回调地址完全一致。http://localhost:8080/login/oauth2/code/gitee

完整安全配置文件

@Configuration

public class ProjectConfig extends WebSecurityConfigurerAdapter {



    @Bean   【供filter- OAuth2LoginAuthenticationFilter使用】

    public ClientRegistrationRepository clientRepository() {

        ClientRegistration c = clientRegistration();

        return new InMemoryClientRegistrationRepository(c);

    }

    private ClientRegistration clientRegistration() {

    ClientRegistration cr = ClientRegistration.withRegistrationId("gitee") 【标识一个Client,filter据此请求路径查找clientid和clientsecret】

            .clientId("1b7fad4489aeb5411f52fdf20694a5d4489aeb5411f52fdf20698eee8ead02b92690721d")

            .clientSecret("819489aeb5411f52fdf2069d2b54489aeb5411f52fdf2069a83489aeb5411f52fdf")

            .scope(new String[]{"user_info"}) 【要看gitee上注册client时的勾选权限的范围】

            .authorizationUri("https://gitee.com/oauth/authorize")  【user到gitee认证和获得授权码地址】

            .tokenUri("https://gitee.com/oauth/token")   【client到gitee申请token的地方】

            .userInfoUri("https://gitee.com/api/v5/user")【client带着token到gitee获取用户信息的地方】

            .userNameAttributeName("id")

            .clientName("gitee")

            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) 【授权码方式】

            .redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")

            .build();

redirectUriTemplate在Filter的逻辑中,action就是login.而registrationId是我们配的gitee。Gitee上应用回调地址要和这个地址完全一致。http://localhost:8080/login/oauth2/code/gitee

    return cr;

}

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.oauth2Login();  【增加OAuth2LoginAuthenticationFilter】

        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

                              

}

}

4.定义受spring security保护的资源  【但这个是Client应用本身要保护的资源,不是需要access_token去访问的资源服务器的资源。Client应用接收到access_token只是为了到资源服务器取得user_info,取得user_info后再封装OAuth2AuthenticationToken封装到authentication->securityContext.再由OauthAuthenticationFilter通过securityContextHolder将securityContext放入Reqeust线程中。所以Client应用的controller能取出OAuth2AuthenticationToken

@Controller
public class MainController {
    private Logger logger =
            Logger.getLogger(MainController.class.getName());
    @GetMapping("/")
    public String main(OAuth2AuthenticationToken token) {
        logger.info(String.valueOf(token));
        return "main.html";
    }
}

我们可以看一下Oauth2provider通过giteee认证服务器user登录授权后返回的OAuth2AuthenticationToken 是通过access_token从资源服务器获取的user_info来构造的

至此,access_token被Client使用获得user_info,将usernme,authorities,isAuthenticated=true后。之后的令牌就是OAuth2AuthenticationToken 了。而不是access_token

3.client应用的securityFilterChain中不再有usernamePasswordAuthenticationFilterbasicAuthenticationFitler,因为你配置的本clienthttp.oauth2Login().那么本client认证凭证就由oauth2AuthenticationFilter来提供oauth2AuthenticationToken了。

org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken@52eddef4:

Principal: Name: [5244586],

【注意二:ROLE_USER是本client分配的默认的用户user角色,而认证过的用户username来自user_info中的id。另外带scope_的权限是用于访问gitee的资源服务器的】

【注意三】当user被client的OAuth2AuthenticationFitler重定向到【授权服务器gitee后,】首先由gitee承担了user的认证,然后user授权给了本client去访问gitee资源服务器的资源,获得user_info后相当于  得到了UserDetails.不必在用请求中密码加密对比userDetails中的密码。因为user认证过程是在授权服务器进行的。

scope_权限是user授权给clientid去访问资源服务器的权限,role_基于用户user,它们的作用都是访问控制被授权给第三方Client访问的资源服务器的API一定可以被该用户user访问;能被该用户user访问的API则不一定可以被授权给第三方client访问。另外,user的username就是拿到user_info的id,而client应用中的默认的Role_user角色,这样user就有带有username和authorities的令牌了

就是说client应用不再维护userDetails了,而是靠授权服务器gitee提供的资源服务器上的user_info来的userDetails

第三部分  流程详解:

先看图

解释流程:

①.浏览器发出请求  ①http://localhost:8080/  本client的FilterSecurityInterceptor发现你的配置protected void configure(HttpSecurity http) throws Exception {

    http.oauth2Login();



    http.authorizeRequests()

            .anyRequest().authenticated();

}

任何请求都要认证,所以抛出异常,到达ExceptionTranslationFilter-->authenticationEntryPoint 而由于你配置了http.oauth2Login()所以,就将浏览器重定向到你配置的clientRegistry地址

.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")

也就是:http://localhost:8080/oauth2/authorization/gitee

这也是Oauth2AuthenticationFilter会拦截的地址。

【只要把授权服务器当做一个userDetails的数据库就行了】

首先发回让浏览器重定向到Client认证地址

HTTP/1.1 302

Set-Cookie: JSESSIONID=25D9E74527189708AD15B5997D4D4F50; Path=/; HttpOnly

Location: http://localhost:8080/oauth2/authorization/gitee

Content-Length: 0

② Client 发出重定向到浏览器的响应(如上)  。让浏览器请求Client中Oauth2AuthenticationFilter特定地址。

redirect: http://localhost:8080/oauth2/authorization/gitee

【这个gitee就是我们为client配置的registrationid,而/oauth2/authorization是filter固定的,而gitee是我们配置的registrationid,所以,跟上】

③浏览器向client发出Get 请求http://localhost:8080/oauth2/authorization/gitee

④Client的Oauth2AuthenticationFilter根据url路径中的gitee(对应的registrationid取出clientid和clientsecret,scope其他“我们代码配置的client”信息,)再次向浏览器发出重定向,这次带着clientid和scope等配置在client中的client信息以及权限scope在重定向响应指令中中,这次是。换句话说,Client通过重定向浏览器将client的信息发往Gitee。从而能让user到gitee上为该Clientid授权。而user要在gitee上登录和授权,从而Gitee能知道,浏览器user同意授权给client去访问(gitee上维护着username/password和clientid/clientsecret)。

redirect:登录 - Gitee.com

【千万注意redirect_uri是当做字符串发送的,没有变化过,而且redirect_uri字符串是由Client生成的(代码配置)。redirect_uri作用是当浏览器user带着clientidscope权限等到Gitee,首先浏览器user要在Gitee上通过user认证授权-输入usergitee上用户名密码同时点击同意授权。后Gitee的回调地址和重定向地址。

gitee认证user通过并且userclientid同意授权后。Gitee再向浏览器user发重定向,将浏览器重定向到redirect_uri的地址来发送code;另外,无论是浏览器还是Client,和Gitee交互都是HTTPS

⑤浏览器按照指令发出重定向到gitee请求授权码

Get 请求登录 - Gitee.com

【注意】之后的请求是在浏览器userGitee之间用HTTPs交互的

【注】其中redirect_uri是当做字符串发给gitee的,将来用户输入自己gitee上的username和password登录了gitee,并授权通过后。Gitee将确认参数来的redirect_uri和clientid对应的注册在gitee上的client的redirect_uri是否一致。如果一致,gitee再次向浏览器发出向这个地址的重定向:redirect_uri=http://localhost:8080/login/oauth2/code/gitee 。浏览器根据这个重定向响应指令发出对地址redirect_uri的请求,从而将授权码转交给client

用户在gitee上认证并授权后,gitee服务器向浏览器发送重定向就是上面的参数redirect_uri的值来的

redirect: http://localhost:8080/login/oauth2/code/gitee并带上授权码

省略一些过程

⑥Gitee发现浏览器user没有登录Gitee--则提示用户登录-输入用户在gitee上usernma/password.

⑦Gitee向浏览器发送确认权限授权的页面,

⑧浏览器用户user点击确认信息给Gitee

⑨当Gitee认证服务器认证了user,同时也获得了clientid和用户授权的权限后,发回重定向指令到浏览器,带着【授权码】--实际上是  HTTP 302响应

redirecthttp://localhost:8080/login/oauth2/code/gitee?code=2b718c9f54787a79b6a851e075c3bbab71761e604ae35c29d0f089fbbbe6d926&state=mxYRhF2DBDff03dhxR0eFkO5Lf6Th67bnM6kaY7dxRE%3D

⑩接着浏览器被重定向,向Client发出请求,带着授权码GET请求

http://localhost:8080/login/oauth2/code/gitee?code=2b718c9f54787a79b6a851e075c3bbab71761e604ae35c29d0f089fbbbe6d926&state=mxYRhF2DBDff03dhxR0eFkO5Lf6Th67bnM6kaY7dxRE%3D HTTP/1.1

【千万注意】这个地址http://localhost:8080/login/oauth2/code/gitee

就是我们代码中配置的

【注意】redirectUriTemplate其中:
1. baseUrl的值http://localhost:8080/就是我们的应用Client的根路径。
2.{action}不是我们配置的,而是OauthAuthenticationFilter拦截请求Filter中固定的-login,不同的路径对应不同拦截功能。我们没有自己写filter,采用的是默认的。我们只要配置了http.oauth2Login();就会有此默认的拦截器OauthAuthenticationFilter,他的逻辑是固定的。
3.oauth2/code也是OauthAuthenticationFilter固定的。因为我们没有自己写filter。
4.gitee是我们为Client起的名字,OauthAuthenticationFilter拦截请求后,会根据路径上的这个名字gitee 这个id取出clientid clientsecret  权限scope等等。
【所以********】 我们在Gitee上配置的客户端clientid和clientsecret一定要和Client传来的一致,且¥¥¥¥我们在gitee上配置的应用回调Url才是Gitee发出请求的依据。Gitee是根据配置的这个回调Url来进行请求的。所以,这个回调url必须和我们应用client配置一致才对。gitee对用户user认证并得到user对client的scope确认后,才会根据【gitee上的应用回调地址做出请求】。
而我们.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")这里的配置是配置应用client。当收到这样的请求,client会向gitee发出post请求。以便用得到的授权码到gitee上换取access_token【重点】所以gitee上的用户配置的回调地址决定了Gitee向哪个地址发送认证码
而我们代码配置的.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}"

是这个地址的决定的Oauth2AuthenticationFilter的有效拦截功能启用。 两个地址必须一致才能收到Gitee来的授权码

11   当Client filter收到授权码后,拦截,根据到来的请求http://localhost:8080/login/oauth2/code/gitee:中的registrationid--gitee取出参数,比如获取token的 url以及收到的code,以及clientid对应的client_secret以及redirect_uri等。
然后向Gitee发送Post请求,其中Redirect_uri是:{baseUrl}/{action}/oauth2/code/{registrationId}也就是http://localhost:8080/login/oauth2/code/gitee.是Gitee对授权码和clientid/clientsecret认证后发回access_token给应用client的地址。
Client的拦截器Oauth2AuthenticationFilter根据请求,从back-channel向Gitee发送POST请求.
POST https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret} .
我用postman 数据要放在body中,要不会返回错误:“授权方式无效,或者登录回调地址无效、过期或已被撤销”。

接着停留在Oauth2AuthenticationFilter 中处理方法中,等待Gitee的后端back-channel来的Gitee的response

12  Gitee对授权码和clientid secret等做验证后,按照为gitee设定的应用地址(创建应用时设定的)响应结果

13.Gitee认证client和code后返回给client

200 ok

{

access_token: “266ffabcef cdefcd2342 cdefcd23”

refresh_token:”353434355345abcdefcd234234234efefef3534545”

scope: “user_inf”  之前user向gitee确认的同意授权给Client的细分的权限

}

Oauth2AuthenticationFilter从back-channel收到了来自Gitee授权服务器的access_token

14.Client应用的filter根据配置的Gitee资源服务器的api利用 access_token去取得user_inf 根据应用回调Url返回用户信息.【至此完成了】用户认证。我们client的用户认证已经设置成了Oauth2AuthenticationFilter认证方式,不再是usernamePasswordAuthenticationFilter

Client中的Oauth2AuthenticationFilter相当于通过

Oauth2AuthenticationManager

Oauth2AuthenticationProvider

UserDetailsService

层层返回UserDetails【到Oauth2AuthenticationFilter,然后Oauth2AuthenticationFilter增加一个默认角色Role_user】后将user_info打包为

Oauth2AuthenticationToken然后放到

SecurityContext-->再通过securityContextHolder放入Request主线程中供后续使用。

然后Oauth2AuthenticationFilter将用access_token获取的

user_info包装成了Oauth2AuthenticationToken(里面既有相当于username的user

id,也有user本身的角色Role_user,也有user授权给client去访问资源服务器的Scope权限)并且设置为isAuthenticated.放入-->securityContext--> 通过securityContextHolder放入Reqeust请求线程中供后续使用。

到此时都是Client从后端back-channel按照你的配置自动地完成user认证的

只是Oauth2authenticationProvider收到access_token后取得用户信息。只有取回user_info然后,将user_info 内容取出,并设置Oauth2AuthenticationToken 中的username,authorities并将Oauth2AuthenticationToken为isAuthenticated为true)。后才是认证完成。只是Oauth2AuthenticationFitler从后端用access_token从back-channel收到 user_info后包装为Oauth2AutheticationToken放入了securityContext中。

【重点**】************************

1.OAuth2Authenticationtoken不同于access_token,access_token是client被user授权去访问资源服务器资源的令牌。只发生在Client去访问其他资源服务器时用。而OAuth2Authenticationtoken才是Client应用中已认证用户的Authentication。OAuth2Authenticationtoken包含了Client利用access_token到“资源服务器上取得的user”的数据--比如将userinfo中的id作为中的usernmae并赋予Role_user角色,Auth2AuthenticationProvider会默认给授权client的user一个角色Role_user.

2.Client的user用户认证是通过OAuth2AuthenticationFilter去完成的。标志是,当经过上述一系列操作拿到access_token并取得user_info后,相当于Fitler通过AuthenticationManager--》AuthenticationProvider--》UserDetailServcie获得userDetails。只有这样AuthenticationProvider将从资源服务器获得的user_info直接当做Client应用中的已经认证的用户。【并用user_info构造OAuth2Authenticationtoken里面有其他认证过滤器所具有的标志user身份标志的username:  而角色是该filter自动赋予的  Role_user】.

我们可以打印一下OAuth2Authenticationtoken

Granted Authorities: [[ROLE_USER, SCOPE_emails, SCOPE_projects, SCOPE_user_info]],

User Attributes: [{id=5244586, login=hoaringtiger, name=alexTan,

省略其他。。。。。。

; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@0: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: D48E621DC9CF771EDD4ED3B96D2A791D; Granted Authorities: ROLE_USER, SCOPE_emails, SCOPE_projects, SCOPE_user_inf

发现负责Client用户认证的过滤器OAuth2AuthenticationFilter通过将user重定向到Oauth2授权服务器去到进行user认证,client取得授权码,换取access_token再用access_token读取Oauth2资源服务器上的user_info.整个过程就是OAuth2AuthenticationFilter使用了不同于其他如UsernamePasswordAuthenticationFitler的一个AuthenticationProvider:OAuth2AuthenticationProvider。也就是AuthenticationProvider不再利用userDetailsService的LoadUserDetailsByUsername来获得userDetails。而是只要成功AuthenticationProvider获得了user_info就标志着user认证完成。那么标志user的id是什么呢?我们看我们Client中的配置:

.userNameAttributeName("id")  表示从资源服务器中取得的user_info中的属性名为id就当做Client中的userName也,也就是Client中user的标识!!!我们看下

ClientRegistration cr = ClientRegistration.withRegistrationId("gitee")

        .clientId("1b7fad4489aeb5411f52fdf20694a5d4dab952908d158eee8ead02b92690721d")

        .clientSecret("819c94bdbc1a6d2b54a832116019c6e5562e6d56c9e593d15d4dbaeec09ec2f8")

        .scope(new String[]{"user_info"})

        .authorizationUri("https://gitee.com/oauth/authorize")

        .tokenUri("https://gitee.com/oauth/token")

        .userInfoUri("https://gitee.com/api/v5/user")

        .userNameAttributeName("id")  【取得的user详情中的属性id的值作为Client的已认证的用户的用户名。】

而在OAuth2AuthenticationToken中,OAuth2AuthenticationProvider已经将取得的用户id放入了OAuth2AuthenticationToken。其中id就是已经在本Client通过认证的username。同时,设置为Authenticated

Granted Authorities: [[ROLE_USER, SCOPE_emails, SCOPE_projects, SCOPE_user_info]],

User Attributes: [{id=5244586, login=hoaringtiger, name=alexTan,

省略其他。。。。。。

Credentials: [PROTECTED];

Authenticated: true;

Details: org.springframework.security.web.authentication.WebAuthenticationDetails@0:

RemoteIpAddress: 0:0:0:0:0:0:0:1;

SessionId: D48E621DC9CF771EDD4ED3B96D2A791D;

Granted Authorities: ROLE_USER, SCOPE_emails, SCOPE_projects, SCOPE_user_inf

显然,user通过登录Oauth2授权服务器的授权client访问Oauth2资源服务器获取user_info后“间接地”通过了Client的认证。而通过security认证的标志就是认证过滤器比如OAuth2AuthenticationFilter将带有username和authorities以及状态为Authenticated: true; 的一个Authentication---OAuth2AuthenticationToken放入securityContext,然后通过securityContextHolder放到Request线程中。至于“之后的Client授权过滤器还是其他微服务的就要看本token中的权限了”。说白了就是通过Oauth2的机制获得了UserDetails包括用户名username。这是OAuth2AuthenticationFilter不同于其他认证过滤器如UsernamePasswordAuthenticationFitler的地方,也是让人迷惑的地方

【以上才完成了user的oauth2认证。然后security将user的原始请求http://localhost:8080/取出去向浏览器再次重定向】

15.再次通过securitycontextPersistenceFilter后将reqeust中的securityContext取出(含有OAuth2Authenticationtoken)并清空request中securityContext ,并以与当前front-channel(浏览器)保持的的sessionid保存session。发出从定向到浏览器. 并setcookie:jsessionid=***

16.浏览器根据重定向响应指令发出http://localhost:8080/请求,携带cookie:jessionid到Client应用被securitycontextPersistenceFilter拦截,根据sessionid再次取出了OAuth2Authenticationtoken。并将此含有Authentication的securityContext放入reqeust中。而这个OAuth2AuthenticationToken是isAuthenticated的。直接到授权过滤器FilterSecurityInterceptor后,你配置的任何Client上的资源都是http.authorizerequest().anyrequest().authenticated.符合条件,放行,直接到了DistpacherServlet--》

17Controller以OAuth2AuthenticationToken为参数,所以被DispatcherServlet通过securityContextHolder从请求中拿出来提供给controller。

@Controller

public class MainController {



    private Logger logger =

            Logger.getLogger(MainController.class.getName());



    @GetMapping("/")

    public String main(OAuth2AuthenticationToken token) {

        //logger.info(String.valueOf(token));

        System.out.println(String.valueOf(token.getPrincipal()));

        return "main.html";

    }

}

那么到达DispatcherServlet--》controller返回结果。

另外一个重点。Oauth认证服务器和资源服务器只相当于我们应用中的securityFilterChain的OauthFilter利用authenticationProvider拿到用户详情,设定Authentication的isAuthenticated为true 到SecurityContextHolder中。

到达DispatcherServlet--controller后就结束此请求,或者颁发JWT-token给浏览器客户端,或者则需要有set-cookie

还有:之所以把我们的应用称为Client,是相对于Oauth server和resource server而言我们的应用中集成了Oauth2 server的客户端

换句话说。Client针对前端浏览器就是普通的的web应用(),而针对资源服务器必须用access_token来访问。

代码请参考《spring security in action》manning公司

 的ssia-ch12-ex1。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值