可能是全网介绍 OAuth2 最详细的一篇文章(下)

想让 OAuth2 和 JWT 在一起愉快玩耍?

前面的文章松哥侧重于和大家理清楚 OAuth2 的登录流程,对于一些登录细节则没有去深究,接下来松哥会和大家把这些案例一一进行晚上。

今天松哥主要和大家分享如何把 OAuth2 和 JWT 套在一起玩!

传统的通过 session 来记录用户认证信息的方式我们可以理解为这是一种有状态登录,而 JWT 则代表了一种无状态登录。「无状态登录天然的具备单点登录能力,所以这个技术组合小伙伴们还是很有必要认真学习下。」 可能有小伙伴对这个概念还不太熟悉,我这里就先来科普一下有状态登录和无状态登录。

无状态登录
什么是有状态

有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 Session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步有浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:

  1. 服务端保存大量数据,增加服务端压力
  2. 服务端保存用户状态,不支持集群化部署
什么是无状态

微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:

  1. 服务端不保存任何客户端请求者信息
  2. 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

那么这种无状态性有哪些好处呢?

  1. 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
  2. 服务端的集群和状态对客户端透明
  3. 服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)
  4. 减小服务端存储压力
如何实现无状态

无状态登录的流程:

  1. 首先客户端发送账户名/密码到服务端进行认证
  2. 认证通过后,服务端将用户信息加密并且编码成一个 token,返回给客户端
  3. 以后客户端每次发送请求,都需要携带认证的 token
  4. 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息
JWT
简介

JWT,全称是 Json Web Token , 是一种 JSON 风格的轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权:

在这里插入图片描述
JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的 Java 实现是 GitHub 上的开源项目 jjwt,地址如下:https://github.com/jwtk/jjwt

JWT 数据格式

JWT 包含三部分数据:

一,Header:头部,通常头部有两部分信息:

  1. 声明类型,这里是 JWT
  2. 加密算法,自定义

我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据。

二,Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了 7 个示例信息:

  1. iss (issuer):表示签发人
  2. exp (expiration time):表示token过期时间
  3. sub (subject):主题
  4. aud (audience):受众
  5. nbf (Not Before):生效时间
  6. iat (Issued At):签发时间
  7. jti (JWT ID):编号

这部分也会采用 Base64Url 编码,得到第二部分数据。

三,Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥 secret(密钥保存在服务端,不能泄露给客户端),通过 Header 中配置的加密算法生成。用于验证整个数据完整和可靠性。

生成的数据格式如下图:

在这里插入图片描述
注意,这里的数据通过 . 隔开成了三部分,分别对应前面提到的三部分,另外,这里数据是不换行的,图片换行只是为了展示方便而已。

JWT 交互流程

流程图:

在这里插入图片描述
步骤翻译:

  1. 应用程序或客户端向授权服务器请求授权
  2. 获取到授权后,授权服务器会向应用程序返回访问令牌
  3. 应用程序使用访问令牌来访问受保护资源(如API)

因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就符合了 RESTful 的无状态规范。

JWT 存在的问题

说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:

  1. 续签问题,这是被很多人诟病的问题之一,传统的 cookie+session 的方案天然的支持续签,但是 jwt 由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入 redis,虽然可以解决问题,但是 jwt 也变得不伦不类了
  2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端 secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,不过毕竟没有传统的注销方便
  3. 密码重置,密码重置后,原本的 token 依然可以访问系统,这时候也需要强制修改 secret
  4. 基于第 2 点和第 3 点,一般建议不同用户取不同 secret
OAuth2 中的问题

在前面的文章中,授权服务器派发了 access_token 之后,客户端拿着 access_token 去请求资源服务器,资源服务器要去校验 access_token 的真伪,所以我们在资源服务器上配置了 RemoteTokenServices,让资源服务器做远程校验:

@Bean
RemoteTokenServices tokenServices() {
    RemoteTokenServices services = new RemoteTokenServices();
    services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
    services.setClientId("javaboy");
    services.setClientSecret("123");
    return services;
}

在高并发环境下这样的校验方式显然是有问题的,如果结合 JWT,用户的所有信息都保存在 JWT 中,这样就可以有效的解决上面的问题。

改造方案
授权服务器改造

首先我们来看对授权服务器的改造,我们来修改 AccessTokenConfig 类,如下:

@Configuration
public class AccessTokenConfig {
    private String SIGNING_KEY = "javaboy";

    @Bean
    TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}

这里的改造主要是两方面:

  1. TokenStore 我们使用 JwtTokenStore 这个实例。之前我们将 access_token 无论是存储在内存中,还是存储在 Redis 中,都是要存下来的,客户端将 access_token 发来之后,我们还要校验看对不对。但是如果使用了 JWT,access_token 实际上就不用存储了(无状态登录,服务端不需要保存信息),因为用户的所有信息都在 jwt 里边,所以这里配置的 JwtTokenStore 本质上并不是做存储
  2. 另外我们还提供了一个 JwtAccessTokenConverter,这个 JwtAccessTokenConverter 可以实现将用户信息和 JWT 进行转换(将用户信息转为 jwt 字符串,或者从 jwt 字符串提取出用户信息)
  3. 另外,在 JWT 字符串生成的时候,我们需要一个签名,这个签名需要自己保存好

这里 JWT 默认生成的用户信息主要是用户角色、用户名等,如果我们希望在生成的 JWT 上面添加额外的信息,可以按照如下方式添加:

@Component
public class CustomAdditionalInformation implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> info = accessToken.getAdditionalInformation();
        info.put("author", "江南一点雨");
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}

自定义类 CustomAdditionalInformation 实现 TokenEnhancer 接口,并实现接口中的 enhance 方法。enhance 方法中的 OAuth2AccessToken 参数就是已经生成的 access_token 信息,我们可以从 OAuth2AccessToken 中取出已经生成的额外信息,然后在此基础上追加自己的信息。

「需要提醒一句,其实我们配置的 JwtAccessTokenConverter 也是 TokenEnhancer 的一个实例」

配置完成之后,我们还需要在 AuthorizationServer 中修改 AuthorizationServerTokenServices 实例,如下:

@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
CustomAdditionalInformation customAdditionalInformation;
@Bean
AuthorizationServerTokenServices tokenServices() {
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService());
    services.setSupportRefreshToken(true);
    services.setTokenStore(tokenStore);
    TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
    tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter, customAdditionalInformation));
    services.setTokenEnhancer(tokenEnhancerChain);
    return services;
}

这里主要是是在 DefaultTokenServices 中配置 TokenEnhancer,将之前的 JwtAccessTokenConverter 和 CustomAdditionalInformation 两个实例注入进来即可。

如此之后,我们的 auth-server 就算是配置成功了。

资源服务器改造

接下来我们还需要对资源服务器进行改造,也就是 user-server,我们将 auth-server 中的 AccessTokenConfig 类拷贝到 user-server 中,然后在资源服务器配置中不再配置远程校验地址,而是配置一个 TokenStore 即可:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("res1").tokenStore(tokenStore);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated();
    }
}

这里配置好之后,会自动调用 JwtAccessTokenConverter 将 jwt 解析出来,jwt 里边就包含了用户的基本信息,所以就不用远程校验 access_token 了。

测试

OK,上面配置完成后,我们就可以启动 auth-server、user-server 进行测试,这里为了测试方便,我配置了 password 模式来测试。

首先我们请求 auth-server 获取 token,如下:

在这里插入图片描述

可以看到,jwt 的字符串还是挺长的,另外返回的数据中也有我们自定义的信息。根据本文第一小节的介绍,小伙伴们可以使用一些在线的 Base64 工具自行解码 jwt 字符串的前两部分,当然也可以通过 check_token 接口来解析:

在这里插入图片描述
解析后就可以看到 jwt 中保存的用户详细信息了。

拿到 access_token 之后,我们就可以去访问 user-server 中的资源了,访问方式跟之前的一样,请求头中传入 access_token 即可:

在这里插入图片描述
如此之后,我们就成功的将 OAuth2 和 Jwt 结合起来了。

原理

那么普通的 access_token 到底是怎么变为 jwt 的?jwt 和认证信息又是如何自动转换的?松哥也来和大家扯一扯。

首先我们知道,access_token 的生成,默认是在 DefaultTokenServices#createAccessToken 方法中的,我们来看下 createAccessToken 方法:

private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
 DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
 int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
 if (validitySeconds > 0) {
  token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
 }
 token.setRefreshToken(refreshToken);
 token.setScope(authentication.getOAuth2Request().getScope());
 return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}

从这段源码中我们可以看到:

  1. 默认生成的 access_token 其实就是一个 UUID 字符串
  2. getAccessTokenValiditySeconds 方法用来获取 access_token 的有效期,点进去这个方法,我们发现这个数字是从数据库中查询出来的,其实就是我们配置的 access_token 的有效期,我们配置的有效期单位是秒
  3. 如果设置的 access_token 有效期大于 0,则调用 setExpiration 方法设置过期时间,过期时间就是在当前时间基础上加上用户设置的过期时间,注意乘以 1000 将时间单位转为毫秒
  4. 接下来设置刷新 token 和授权范围 scope(刷新 token 的生成过程在 createRefreshToken 方法中,其实和 access_token 的生成过程类似)
  5. 最后面 return 比较关键,这里会判断有没有 accessTokenEnhancer,如果 accessTokenEnhancer 不为 null,则在 accessTokenEnhancer 中再处理一遍才返回,accessTokenEnhancer 中再处理一遍就比较关键了,就是 access_token 转为 jwt 字符串的过程

这里的 accessTokenEnhancer 实际上是一个 TokenEnhancerChain,这个链中有一个 delegates 变量保存了我们定义的两个 TokenEnhancer(auth-server 中定义的 JwtAccessTokenConverter 和 CustomAdditionalInformation),也就是说,我们的 access_token 信息将在这两个类中进行二次处理。「处理的顺序是按照集合中保存的顺序,就是先在 JwtAccessTokenConverter 中处理,后在 CustomAdditionalInformation 中处理,顺序不能乱,也意味着我们在 auth-server 中定义的时候,JwtAccessTokenConverter 和 CustomAdditionalInformation 的顺序不能写错。」

无论是 JwtAccessTokenConverter 还是 CustomAdditionalInformation,它里边核心的方法都是 enhance,我们先来看 JwtAccessTokenConverter#enhance:

public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
 DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
 Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation());
 String tokenId = result.getValue();
 if (!info.containsKey(TOKEN_ID)) {
  info.put(TOKEN_ID, tokenId);
 }
 else {
  tokenId = (String) info.get(TOKEN_ID);
 }
 result.setAdditionalInformation(info);
 result.setValue(encode(result, authentication));
 OAuth2RefreshToken refreshToken = result.getRefreshToken();
 if (refreshToken != null) {
  DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
  encodedRefreshToken.setValue(refreshToken.getValue());
  // Refresh tokens do not expire unless explicitly of the right type
  encodedRefreshToken.setExpiration(null);
  try {
   Map<String, Object> claims = objectMapper
     .parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
   if (claims.containsKey(TOKEN_ID)) {
    encodedRefreshToken.setValue(claims.get(TOKEN_ID).toString());
   }
  }
  catch (IllegalArgumentException e) {
  }
  Map<String, Object> refreshTokenInfo = new LinkedHashMap<String, Object>(
    accessToken.getAdditionalInformation());
  refreshTokenInfo.put(TOKEN_ID, encodedRefreshToken.getValue());
  refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId);
  encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
  DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(
    encode(encodedRefreshToken, authentication));
  if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
   Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration();
   encodedRefreshToken.setExpiration(expiration);
   token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration);
  }
  result.setRefreshToken(token);
 }
 return result;
}

这段代码虽然比较长,但是却很好理解:

  1. 首先构造一个 DefaultOAuth2AccessToken 对象
  2. 将 accessToken 中的附加信息拿出来(此时默认没有附加信息)
  3. 获取旧的 access_token(就是上一步 UUID 字符串),将之作为附加信息存入到 info 中(上面测试中,返回的 jwt 中有一个 jti,其实就是这里存入进来的)
  4. 将附加信息存入 result 中
  5. 对 result 进行编码,将编码结果作为新的 access_token,这个编码的过程就是 jwt 字符串生成的过程
  6. 接下来是处理刷新 token,刷新 token 如果是 jwt 字符串,则需要有一个解码操作,否则不需要,刷新 token 如果是 ExpiringOAuth2RefreshToken 的实例,表示刷新 token 已经过期,则重新生成一个,这里的逻辑比较简单,我就不啰嗦了

最后我们再来看看这里多次出现的 encode 方法,就是 jwt 字符串编码的过程:

protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
 String content;
 try {
  content = objectMapper.formatMap(tokenConverter.convertAccessToken(accessToken, authentication));
 }
 catch (Exception e) {
  throw new IllegalStateException("Cannot convert access token to JSON", e);
 }
 String token = JwtHelper.encode(content, signer).getEncoded();
 return token;
}

我们可以看到,这里首先是把用户信息和 access_token 生成一个 JSON 字符串,然后调用 JwtHelper.encode 方法进行 jwt 编码。

微服务架构中的安全管理思路

今天就不和大家聊代码了,我想结合自己目前的工作,和大家说一说 Spring Cloud 基础架构的安全管理问题,因为我最近一直在做这方面的工作,有一些心得,发出来和小伙伴们一起探讨。

这其实是一个挺复杂的问题,本文我尽量从一个容易理解的方面来和大家介绍,我们先把这个思想体系搭建起来,后面的文章,松哥会抽空给大家上代码。

微服务架构

在微服务中,我们一般都会有一个网关,网关背后有很多个微服务,所有的请求都是首先到达网关,再由网关转发到不同的服务上去。另外我们可能会搭建一个统一认证中心,我画一个已经过简化的架构图大家来看下:

在这里插入图片描述
可以看到,在这个微服务架构中,我们的鉴权流程是这样:

  1. 客户端携带用户名密码发送登录请求到网关
  2. 网关收到请求之后,将请求路由到统一认证中心
  3. 统一认证中心确认用户的身份没有问题之后,将返回一个 access_token 给网关
  4. 网关将 access_token 转发到客户端
  5. 客户端将获取到的 access_token 放在请求头中去请求真正的微服务,当然这个操作依然会被网关拦下
  6. 网关将客户端的请求路由到微服务上,接下来微服务需要根据 access_token 鉴定用户身份
  7. 微服务可以调用统一认证中心去检验用户身份,如果我们采用了 JWT 的话,这一步实际上可以省略
  8. 微服务确认了用户身份和权限之后,就可以根据实际情况返回数据给用户了

这是我们一个大致的认证流程。

流程清楚了之后,代码写起来就非常容易了。有小伙伴会说,既然流程都清楚了,那我是不是可以自定义认证的相关逻辑了?

这个想法没错,但是我并不建议。当大家看到这张简化版的架构图,应该很容易就想到 OAuth2 了,很明显,将 OAuth2 放在这里最恰当不过。使用 OAuth2 好处是它是一个经过市场验证的安全标准,使用 OAuth2 的话,你就不用担心可能存在的风险漏洞,如果是自己设计的话,要考虑的问题就比较多。

但是 OAuth2 中存在的一些角色问题在这里是如何划分呢?

首先大家明白,OAuth2 中的授权服务器在校验的时候,实际上是有两个方面的校验工作,一方面是校验客户端信息,另一方面是校验用户信息,微服务 A 和 微服务 B 都在处理业务上的事情,实际上没有必要和客户端关联起来,所以我们可以在网关上先初步校验客户端信息,然后在微服务上再去校验用户身份信息。

具体来说是这样:

在上面的架构图中,网关还有另外一个身份就是资源服务器,当请求到达网关之后,如果是去往统一认证中心的请求,则直接转发即可;如果请求是去往普通微服务的请求,网关可以先做初步校验,就是校验客户端身份,如果没有问题,则将请求路由到不同的微服务上,各个微服务再根据自身的业务和权限情况,进行响应。

为什么不把所有权限校验都在网关做了呢?

对于一个超大型的微服务项目而言,涉及到的子系统可能非常多,权限控制也是非常复杂,网关不可能了解所有业务系统的逻辑,如果把所有的鉴权操作都放在网关上做,很明显会加大网关的复杂度,让网关变得非常臃肿。另一方面,不同的微服务可能是由不同的团队开发的,如果把每个微服务的鉴权系统放在网关上做,又会增加开发的难度,所以,我们可以先在网关对用户身份做初步校验,没问题的话,再把请求路由到不同的微服务,做具体的校验。

在这个过程中,我们可以使用普通的 access_token,就是那种一个 UUID 字符串的,如果使用了这种格式的 access_token,我们可以通过调用授权服务器来确定用户身份,也就是上图中的第七步不可以省略,这对于分布式系统来说显然不是最佳方案。结合 JWT 就可以很好的解决这个问题,JWT 中保存了用户的所有信息,微服务拿到 JWT 字符串之后,就可以很好的解析出用户的信息了。

为什么不建议 Cookie

微服务架构是一种分布式系统,在分布式系统中,我们经常需要将用户的信息从一个微服务传递到另外一个微服务中去,传统的 SecurityContext 这种基于 ThreadLocal 基于内存的方式显然就不太合适,因为这种方式无法灵活的在分布式系统之间传递用户信息,也无法很好的支持单点登录。

另一方面,前端应用程序多样化,Android、iOS、各种平台的小程序、H5 页面等等,并非所有的前端应用都会对 Cookie 有友好的支持,后端使用 access_token 也可以避免前端将来面临的这些问题。

内部调用鉴权

微服务内部调用的鉴权也需要考虑。当然,如果系统对于安全性的要求不高的话,这一步其实可以省略。

如果不能省略,我也来说说思路。

现在的微服务之间调用,例如 A 调用 B,如果是基于 Spring Cloud 架构的话,可能以 Open Feign 调用为主,这种情况下,我们可以自定义一个请求拦截器,当请求要发出的时候,自动拦截请求,然后自动向请求头中添加认证信息。

然后可以定义一个公共的注解,这个注解专门用来做校验工作,该注解可以从从请求头中提取出 A 传递来的信息进行校验。

在 B 中使用这个公共的注解即可。

当然 B 中也可以不使用注解,而是通过路径来校验,但是在这个场景下,注解反而灵活一些。

还要不要 Spring Security

有小伙伴会问,在微服务上拿到 JWT 字符串之后,是不是可以自己解析?这样就不需要 Spring Security 了?

虽然自己解析并不存在技术上的难点,但是我还是不建议自己解析,建议继续在 Spring Security 的基础上完成剩余操作。

我们拿到 JWT 之后,通过 Spring Cloud Security 来自动解析 JWT 字符串,获取用户信息,然后自动将用户信息注入 SecurityContext 中,相当于自动完成一次登录操作,然后继续后面的操作,这样自己要省事很多,而且 Spring Security 中的各种路径拦截规则我们都还可以继续使用。

Spring Boot 集成 OAuth2

单点登录是我们在分布式系统中很常见的一个需求。

分布式系统由多个不同的子系统组成,而我们在使用系统的时候,只需要登录一次即可,这样其他系统都认为用户已经登录了,不用再去登录。前面和小伙伴们分享了 OAuth2+JWT 的登录方式,这种无状态登录实际上天然的满足单点登录的需求。

当然大家也都知道,无状态登录也是有弊端的。

所以今天松哥想和大家说一说 Spring Boot+OAuth2 做单点登录,利用 @EnableOAuth2Sso 注解快速实现单点登录功能。

项目创建

前面的案例中,松哥一直都把授权服务器和资源服务器分开创建,今天这个案例,为了省事,我就把授权服务器和资源服务器搭建在一起(不过相信大家看了前面的文章,应该也能自己把这两个服务器拆分开)。

所以,今天我们一共需要三个服务:

项目端口描述
auth-server1111授权服务器+资源服务器
client11112子系统1
client21113子系统2

auth-server 用来扮演授权服务器+资源服务器的角色,client1 和 client2 则分别扮演子系统的角色,将来等 client1 登录成功之后,我们也就能访问 client2 了,这样就能看出来单点登录的效果。

我们创建一个名为 oauth2-sso 的 Maven 项目作为父工程即可。

统一认证中心

接下来我们来搭建统一认证中心。

首先我们创建一个名为 auth-server 的 module,创建时添加如下依赖:

在这里插入图片描述
项目创建成功之后,这个模块由于要扮演授权服务器+资源服务器的角色,所以我们先在这个项目的启动类上添加 @EnableResourceServer 注解,表示这是一个资源服务器:

@SpringBootApplication
@EnableResourceServer
public class AuthServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthServerApplication.class, args);
    }

}

接下来我们进行授权服务器的配置,由于资源服务器和授权服务器合并在一起,因此授权服务器的配置要省事很多:

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("javaboy")
                .secret(passwordEncoder.encode("123"))
                .autoApprove(true)
                .redirectUris("http://localhost:1112/login", "http://localhost:1113/login")
                .scopes("user")
                .accessTokenValiditySeconds(7200)
                .authorizedGrantTypes("authorization_code");

    }
}

这里我们只需要简单配置一下客户端的信息即可,这里的配置很简单。当然这里为了简便,客户端的信息配置是基于内存的,如果大家想将客户端信息存入数据库中,也是可以的。

接下来我们再来配置 Spring Security:

@Configuration
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login.html", "/css/**", "/js/**", "/images/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
                .antMatchers("/login")
                .antMatchers("/oauth/authorize")
                .and()
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .permitAll()
                .and()
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("sang")
                .password(passwordEncoder().encode("123"))
                .roles("admin");
    }
}

我这里来大致捋一下:

  1. 首先提供一个 BCryptPasswordEncoder 的实例,用来做密码加解密用
  2. 由于我自定义了登录页面,所以在 WebSecurity 中对这些静态资源方形
  3. HttpSecurity 中,我们对认证相关的端点放行,同时配置一下登录页面和登录接口
  4. AuthenticationManagerBuilder 中提供一个基于内存的用户
  5. 另外还有一个比较关键的地方,因为资源服务器和授权服务器在一起,所以我们需要一个 @Order 注解来提升 Spring Security 配置的优先级

SecurityConfig 和 AuthServerConfig 都是授权服务器需要提供的东西(如果小伙伴们想将授权服务器和资源服务器拆分,请留意这句话),接下来,我们还需要提供一个暴露用户信息的接口(如果将授权服务器和资源服务器分开,这个接口将由资源服务器提供):

@RestController
public class UserController {
    @GetMapping("/user")
    public Principal getCurrentUser(Principal principal) {
        return principal;
    }
}

最后,我们在 application.properties 中配置一下项目端口:

server.port=1111

另外,松哥自己提前准备了一个登录页面,如下:

在这里插入图片描述
将登录页面相关的 html、css、js 等拷贝到 resources/static 目录下:

在这里插入图片描述
这个页面很简单,就是一个登录表单而已,我把核心部分列出来:

<form action="/login" method="post">
    <div class="input">
        <label for="name">用户名</label>
        <input type="text" name="username" id="name">
        <span class="spin"></span>
    </div>
    <div class="input">
        <label for="pass">密码</label>
        <input type="password" name="password" id="pass">
        <span class="spin"></span>
    </div>
    <div class="button login">
        <button type="submit">
            <span>登录</span>
            <i class="fa fa-check"></i>
        </button>
    </div>
</form>

注意一下 action 提交地址不要写错即可。

如此之后,我们的统一认证登录平台就算是 OK 了。

客户端创建

接下来我们来创建一个客户端项目,创建一个名为 client1 的 Spring Boot 项目,添加如下依赖:

在这里插入图片描述
项目创建成功之后,我们来配置一下 Spring Security:

@Configuration
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated().and().csrf().disable();
    }
}

这段配置很简单,就是说我们 client1 中所有的接口都需要认证之后才能访问,另外添加一个 @EnableOAuth2Sso 注解来开启单点登录功能。

接下来我们在 client1 中再来提供一个测试接口:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication.getName() + Arrays.toString(authentication.getAuthorities().toArray());
    }
}

这个测试接口返回当前登录用户的姓名和角色信息。

接下来我们需要在 client1 的 application.properties 中配置 oauth2 的相关信息:

security.oauth2.client.client-secret=123
security.oauth2.client.client-id=javaboy
security.oauth2.client.user-authorization-uri=http://localhost:1111/oauth/authorize
security.oauth2.client.access-token-uri=http://localhost:1111/oauth/token
security.oauth2.resource.user-info-uri=http://localhost:1111/user

server.port=1112

server.servlet.session.cookie.name=s1

这里的配置也比较熟悉,我们来看一下:

  1. client-secret 是客户端密码
  2. client-id 是客户端 id
  3. user-authorization-uri 是用户授权的端点
  4. access-token-uri 是获取令牌的端点
  5. user-info-uri 是获取用户信息的接口(从资源服务器上获取)
  6. 最后再配置一下端口,然后给 cookie 取一个名字

如此之后,我们的 client1 就算是配置完成了。

按照相同的方式,我们再来配置一个 client2,client2 和 client1 一模一样,就是 cookie 的名字不同(随意取,不相同即可)。

测试

接下来,我们分别启动 auth-server、client1 和 client2,首先我们尝试去方式 client1 中的 hello 接口,这个时候会自动跳转到统一认证中心:
在这里插入图片描述
然后输入用户名密码进行登录。

登录成功之后,会自动跳转回 client1 的 hello 接口,如下:

在这里插入图片描述
此时我们再去访问 client2 ,发现也不用登录了,直接就可以访问:

在这里插入图片描述
OK,如此之后,我们的单点登录就成功了。

流程解析

最后,我再来和小伙伴们把上面代码的一个执行流程捋一捋:

一,首先我们去访问 client1 的 /hello 接口,但是这个接口是需要登录才能访问的,因此我们的请求被拦截下来,拦截下来之后,系统会给我们重定向到 client1 的 /login 接口,这是让我们去登录。

在这里插入图片描述
二,当我们去访问 client1 的登录接口时,由于我们配置了 @EnableOAuth2Sso 注解,这个操作会再次被拦截下来,单点登录拦截器会根据我们在 application.properties 中的配置,自动发起请求去获取授权码:
在这里插入图片描述
三,在第二步发送的请求是请求 auth-server 服务上的东西,这次请求当然也避免不了要先登录,所以再次重定向到 auth-server 的登录页面,也就是大家看到的统一认证中心。

四,在统一认真中心我们完成登录功能,登录完成之后,会继续执行第二步的请求,这个时候就可以成功获取到授权码了。
在这里插入图片描述
五,获取到授权码之后,这个时候会重定向到我们 client1 的 login 页面,但是实际上我们的 client1 其实是没有登录页面的,所以这个操作依然会被拦截,此时拦截到的地址包含有授权码,拿着授权码,在 OAuth2ClientAuthenticationProcessingFilter 类中向 auth-server 发起请求,就能拿到 access_token 了。

六,在第五步拿到 access_token 之后,接下来在向我们配置的 user-info-uri 地址发送请求,获取登录用户信息,拿到用户信息之后,在 client1 上自己再走一遍 Spring Security 登录流程,这就 OK 了。

让自己的网站接入 GitHub 第三方登录功能

OAuth2 和小伙伴们已经聊了很多了,咱们来一个实际点的案例练练手。

这里我才用 GitHub 来做第三方登录。

为什么是 GitHub 呢?有两方面考虑:

  1. 程序员基本上都有 GitHub 账号,测试方便
  2. 国内的无论 QQ、微信、微博等,都需要注册审核,很费时间
  3. 流程都是一样的,会做 GitHub 第三方登录,就会做 QQ 第三方登录
准备工作

首先登录自己的 GitHub 账户,右上角点击个人面板,选择 Settings:

在这里插入图片描述
然后点击左边菜单底部的 Developer settings:

在这里插入图片描述
然后选择 OAuth Apps,点击 Register a new application 按钮:

在这里插入图片描述
接下来注册你的应用信息

在这里插入图片描述
从上往下,依次是你的项目名称、首页地址、项目描述、授权成功回调地址。信息填完之后,点击下方的 Register application 按钮完成注册。

注册完成之后,我们就可以获取到一个 Client ID 和一个 Client Secret,这就是我们登录时客户端的凭据。

在这里插入图片描述
这两个信息保存好,不要外泄。

写到这里,松哥不由得感叹一句,还是国外的应用香啊,用 GitHub 做第三方登录,一分钟不到,准备工作就完成了,用 QQ 做,我的资料已经提交两天了,目前的状态还是审核中。。。不过我也理解腾讯所面临的监管问题。

创建应用

接下来我们来简单创建一个自己的网站,我们依然使用 Spring Boot 项目,添加如下依赖:

在这里插入图片描述
然后我们在 resources/templates 目录下创建一个简单的 index.html:

<body>
<p>hello 欢迎来到江南一点雨的小站!</p>
<div>
    社交账号登录:<a href="https://github.com/login/oauth/authorize?client_id=xxxxxx&state=javaboy">
        <svg t="1587352912571" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
             p-id="2196" width="32" height="32">
            <path d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667"
                  fill="#2c2c2c" p-id="2197"></path>
        </svg>
    </a>
</div>
<div>国际站:<a href="http://www.javaboy.org">javaboy.org</a></div>
<div>国内站:<a href="http://www.itboyhub.com">itboyhub.com</a></div>
</body>

这就是一个简单的 HTML 页面,页面上有一个 GitHub 的图标,点击这个图标,就可以去 GitHub 上完成授权操作,注意授权的链接如下:

https://github.com/login/oauth/authorize

这个授权需要携带一个 client_id 参数,这个 client_id 就是准备工作中获取到的 client_id,另外一个 state 则是用来防止跨站脚本攻击的,state 参数的值可以自己随意填写。

我们来看下页面的效果图:

在这里插入图片描述
当我们点击 GitHub 图标,完成授权操作之后,会自动跳转到我们在准备工作填的回调地址中,并且携带一个 code 参数,拿着这个 code 参数我们就可以去获取 access_token 了,有了 access_token 我们就可以获取到用户信息了。

因此,我们再来添加一个 /authorization_code 接口,如下:

@GetMapping("/authorization_code")
public String authorization_code(String code) {
    Map<String, String> map = new HashMap<>();
    map.put("client_id", "xxx");
    map.put("client_secret", "xxxx");
    map.put("state", "javaboy");
    map.put("code", code);
    map.put("redirect_uri", "http://localhost:8080/authorization_code");
    Map<String,String> resp = restTemplate.postForObject("https://github.com/login/oauth/access_token", map, Map.class);
    System.out.println(resp);
    HttpHeaders httpheaders = new HttpHeaders();
    httpheaders.add("Authorization", "token " + resp.get("access_token"));
    HttpEntity<?> httpEntity = new HttpEntity<>(httpheaders);
    ResponseEntity<Map> exchange = restTemplate.exchange("https://api.github.com/user", HttpMethod.GET, httpEntity, Map.class);
    System.out.println("exchange.getBody() = " + exchange.getBody());
    return "forward:/index.html";
}

在 authorization_code 接口中,我们首先向 http://localhost:8080/authorization_code 发起请求去获取 access_token,注意我们需要的参数,client_id 和 client_secret 使我们第一步准备工作申请到的。获取到 access_token 的数据格式如下:

{access_token=9f33730ef09b74943a1cf7d8f9c0e567b9286829, token_type=bearer, scope=}

可以看到,有 access_token,有 token_type,也有 scope。

接下来,拿着 access_token ,将 access_token 放在请求头里边,我们就可以去调用 https://api.github.com/user 接口获取用户信息了,获取到的用户信息格式如下:

{
    "login":"lenve",
    "id":35444368,
    "node_id":"MDQ6VXNlcjM1NDQ0MzY4",
    "avatar_url":"https://avatars3.githubusercontent.com/u/35444368?v=4",
    "gravatar_id":"",
    "url":"https://api.github.com/users/lenve",
    "html_url":"https://github.com/lenve",
    "followers_url":"https://api.github.com/users/lenve/followers",
    "following_url":"https://api.github.com/users/lenve/following{/other_user}",
    "gists_url":"https://api.github.com/users/lenve/gists{/gist_id}",
    "starred_url":"https://api.github.com/users/lenve/starred{/owner}{/repo}",
    "subscriptions_url":"https://api.github.com/users/lenve/subscriptions",
    "organizations_url":"https://api.github.com/users/lenve/orgs",
    "repos_url":"https://api.github.com/users/lenve/repos",
    "events_url":"https://api.github.com/users/lenve/events{/privacy}",
    "received_events_url":"https://api.github.com/users/lenve/received_events",
    "type":"User",
    "site_admin":false,
    "name":null,
    "company":null,
    "blog":"",
    "location":null,
    "email":null,
    "hireable":null,
    "bio":null,
    "public_repos":2,
    "public_gists":0,
    "followers":1,
    "following":1,
    "created_at":"2018-01-15T06:05:04Z",
    "updated_at":"2020-04-20T03:00:49Z"
}

好了,用户的 GitHub 信息现在已经获取到了,接下来该存数据库就存数据库,该接入自己的登录流程就接入。

如此之后,我们就完成了第三方登录功能,其实还是非常 Easy 的,只要大家把这个流程走通,以后想接入微信登录、QQ 登录,都将是非常容易的事情。

Spring Boot+OAuth2,如何自定义返回的 Token 信息?

在本系列前面的文章中,正常情况下,OAuth2 返回的 access_token 信息一共包含五项:

分别是:

  1. access_token
  2. token_type
  3. refresh_token
  4. expires_in
  5. scope

具体如下:

{
    "access_token": "b9c9e345-90c9-49f5-80ab-6ce5ed5a07c9",
    "token_type": "bearer",
    "refresh_token": "9f843e0e-1778-495d-859a-52a1a806c150",
    "expires_in": 7199,
    "scope": "seller-auth"
}

但是在实际操作中,我们往往需要在这个基础上,定制自己的返回信息,这就需要我们对这个东西进行自定义。本文松哥就来和大家聊一聊这里要如何自定义。

access_token 从哪里来

首先我们要搞清楚,access_token 从哪里来。

在前面的文章中,我们在生成 access_token 的时候,都配置了一个类,叫做 AuthorizationServerTokenServices,如下:

@Bean
AuthorizationServerTokenServices tokenServices() {
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService());
    services.setSupportRefreshToken(true);
    services.setTokenStore(tokenStore);
    TokenEnhancerChain chain = new TokenEnhancerChain();
    chain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter,externalAccessTokenInfo));
    services.setTokenEnhancer(chain);
    return services;
}

在这个配置中,我们提供了一个 DefaultTokenServices 实例,这个实例就是默认生成 access_token 的工具,我们进入到 DefaultTokenServices#createAccessToken 方法中,一路追踪,可以看到如下代码:

private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
 DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
 int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
 if (validitySeconds > 0) {
  token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
 }
 token.setRefreshToken(refreshToken);
 token.setScope(authentication.getOAuth2Request().getScope());
 return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}

从这段代码中,我们可以看到,用来保存 access_token 的实例,其实就是 DefaultOAuth2AccessToken,我们再来看看 DefaultOAuth2AccessToken 的定义:

public class DefaultOAuth2AccessToken implements Serializable, OAuth2AccessToken {
 private String value;
 private Date expiration;
 private String tokenType = BEARER_TYPE.toLowerCase();
 private OAuth2RefreshToken refreshToken;
 private Set<String> scope;
 private Map<String, Object> additionalInformation = Collections.emptyMap();
    //省略其他
}

从这段属性的声明中,我们就可以看出来,为什么默认返回的数据只有五项。

大家同时也发现,DefaultOAuth2AccessToken 中其实是提供了一个 additionalInformation 属性用来存储额外信息的,但是,我们在 DefaultTokenServices 类中并没有办法去自定义 DefaultOAuth2AccessToken 中的属性,也就是说,默认情况下,我们没有办法自己去给 additionalInformation 中添加值。

虽然默认情况下,无法添加,但是只要大家看了上面这段源码,就会明白,如果我们想要自定义返回的 access_token 信息,就要想办法自已去定义 DefaultOAuth2AccessToken 信息。

思路有了,接下来看操作。

两种定制方案

大家知道,我们在 OAuth2 中返回的令牌信息分为两大类:不透明令牌和透明令牌。

不透明令牌就是一种无可读性的令牌,一般来说就是一段普通的 UUID 字符串。不透明令牌的最大问题在于会降低系统性能和可用性,并且增加延迟(因为必须远程校验令牌)。

透明令牌的典型代表就是 JWT 了,用户信息都保存在 JWT 字符串中。

在实际开发中,大部分情况下,我们的 OAuth2 都是搭配 JWT 一起来使用的,所以,这里我就主要讲一下在生成的 JWT 中如何定制返回信息。

如果我们使用了 OAuth2+JWT 的方案,那正常情况下,我们还需要配置一个 JwtAccessTokenConverter 的实例,JWT 字符串将由 JwtAccessTokenConverter 实例负责生成。

JwtAccessTokenConverter 实例生成 JWT 的方法是在上文列出来的 DefaultTokenServices#createAccessToken 方法之后执行,该方法最后有一句:

accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;

这就是说,如果提供了 accessTokenEnhancer ,就进入到 accessTokenEnhancer 的 enhance 方法中对 access_token 做二次处理,accessTokenEnhancer 则就是我们的 JwtAccessTokenConverter 实例。

从这里大家看到,想要自定义 Token 信息,我们有两个时机,第一个时机就是在 DefaultTokenServices#createAccessToken 方法中修改,但是工作量较大,不推荐;第二个时机是在进入到 JwtAccessTokenConverter#enhance 方法之后修改,这是目前比较可行的方法。

如果采用第二种方案,就需要我们自定义一个类继承自 JwtAccessTokenConverter,如下:

public class MyJwt extends JwtAccessTokenConverter {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> additionalInformation = new LinkedHashMap<>();
        Map<String, Object> info = new LinkedHashMap<>();
        info.put("author", "江南一点雨");
        info.put("email", "wangsong0210@gmail.com");
        info.put("site", "www.javaboy.org");
        info.put("weixin", "a_java_boy2");
        info.put("WeChat Official Accounts", "江南一点雨");
        info.put("GitHub", "https://github.com/lenve");
        info.put("user", SecurityContextHolder.getContext().getAuthentication().getPrincipal());
        additionalInformation.put("info", info);
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInformation);
        return super.enhance(accessToken, authentication);
    }
}

在这里,我们自定义 MyJwt 继承自 JwtAccessTokenConverter 并重写 enhance 方法:

  1. 首先我们构造自己的附加信息,如果如需要当前登录用户信息,可以从 SecurityContextHolder 中获取
  2. 将附加信息放到 OAuth2AccessToken 的 additionalInformation 属性中去

这样相当于我们就修改了默认生成的 DefaultOAuth2AccessToken 了,然后再把修改后的 DefaultOAuth2AccessToken 实例调用 super.enhance 方法去生成 jwt 字符串,这样生成的 jwt 字符串就有我们的自定义信息了。

最后,在 TokenConfig 中配置 MyJwt 的实例,如下:

@Configuration
public class TokenConfig {
    @Bean
    TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new MyJwt();
        converter.setSigningKey("www.javaboy.org");
        return converter;
    }
}

配置完成后,其他地方的代码不变,我们启动项目来生成登录的 access_token 信息。

测试

接下来,我们启动项目进行测试:

在这里插入图片描述
可以看到,此时生成的 jwt 字符串就比较长了,我们将 access_token 拿到 /oauth/check_token 去校验一下就知道生成的具体信息了,如下:

在这里插入图片描述
可以看到,我们已经成功的将自定义信息存入 jwt 字符串中了。

当然,还有一种情况就是你可能只是想在调用 /oauth/token 接口的时候添加一些额外信息,并不想将额外信息添加到 jwt 中,就是下面这种效果:

在这里插入图片描述

扩展

好了,前面虽然跟大家分享的是 OAuth2+JWT 如何生成自定义的 access_token 信息,但是相信大家看完之后,应该也会针对不透明令牌生成自定义信息。

我这里也和大家分享一下思路:

上面代码的核心思路,就是在从 DefaultTokenServices#createAccessToken 方法到 JwtAccessTokenConverter#enhance 方法的过程中,给 DefaultOAuth2AccessToken 对象的 additionalInformation 属性添加了附加信息。

而 JwtAccessTokenConverter 是 TokenEnhancer 的实例,所以如果我们想要定制不透明令牌的信息,只需要自己定义类实现 TokenEnhancer 接口,并且在 enhance 方法中添加附加信息即可。这个思路给大家,小伙伴们可以自行尝试一下。

参考:Spring Boot+OAuth2,如何自定义返回的 Token 信息?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值