单点登录

目录

一、HTTP的无状态性

二、Cookie技术的引入

三、基于表单的认证

四、Session存储位置以及集群情况下的问题

五、小结

六、JWT

七、JWT结构说明

八、JWT交互流程

九、JWT在微服务中的应用

9.1 RSA

9.2 没有RSA加密的鉴权流程

9.3 结合RSA的鉴权流程

十、生成Token

十一、解析Token获取用户信息


一、HTTP的无状态性

HTTP 是无状态协议,它不对之前发送过的请求和响应的状态进行管理。也就是说,无法根据之前的状态进行本次的请求处理。假设要求登录认证的 Web 页面本身无法进行状态的管理(不记录已登录的状态),那么每次跳转新页面不是要再次登录,就是要在每次请求报文中附加参数来管理登录状态。不可否认,无状态协议当然也有它的优点。由于不必保存状态,自然可减少服务器的 CPU 及内存资源的消耗。从另一侧面来说,也正是因为 HTTP 协议本身是非常简单的,所以才会被应用在各种场景里。

二、Cookie技术的引入

如果让服务器管理全部客户端状态则会成为负担,保留无状态协议这个特征的同时又要解决类似的矛盾问题,于是引入了 Cookie 技术。Cookie 技术通过在请求和响应报文中写入Cookie信息来控制客户端的状态。Cookie会根据从服务器端发送的响应报文内的一个叫做Set-Cookie 的首部字段信息,通知客户端保存 Cookie。当下次客户端再往该服务器发送请求时,客户端会自动在请求报文中加入Cookie 值后发送出去。

1、没有 Cookie 信息状态下的请求

2、第 2 次以后(存有 Cookie 信息状态) 的请求

3、详细介绍Cookie 传输过程

服务器端发现客户端发送过来的 Cookie 后, 会去检查究竟是从哪一个客户端发来的连接请求, 然后对比服务器上的记录, 最后得到之前的状态信息。

三、基于表单的认证

目前用户的认证多半是基于表单的认证,基于表单的认证一般会使用 Cookie 来管理Session(Session会话,Session代表着服务器和客户端一次会话的过程,直到Session失效(服务端关闭)或者客户端关闭时结束)。基于表单认证本身是通过服务器端的 Web应用,将客户端发送过来的用户ID和密码与之前登录过的信息做匹配来进行认证的。

但鉴于 HTTP 是无状态协议, 之前已认证成功的用户状态无法通过协议层面保存下来。 即无法实现状态管理, 因此即使当该用户下一次继续访问,也无法区分他与其他的用户。于是我们会使用Cookie 来管理 Session,以弥补 HTTP 协议中不存在的状态管理功能。

简单的来说就是,用户在登录的时候,会在Web服务器中开辟一段内存空间Session用于保存用户的认证信息和其他信息,用户登录成功之后会通过Set-Cookie的首部字段信息,通知客户端保存Cookie,而这Cookie保存的就是服务器端Session的ID,下次请求的时候客户端会带上该Cookie向服务器端发送请求,服务器端进行校验,通过校验Session ID来识别用户和其认证状态。

四、Session存储位置以及集群情况下的问题

Session 是存储在Web服务器(例如:Tomcat)中的,并针对每个客户端(客户),通过SessionID来区别不同用户的。Session是以Cookie技术或URL重写实现,默认以Cookie技术实现,服务端会给这次会话创造一个JSESSIONID的Cookie值。

但是一个显著的问题就是,在集群模式下如果通过Nginx负载均衡的时候,如果有一个用户登录的时候请求被分配到服务器A上,登录成功后设置的Session就会存放在服务器A上了,但是在服务器B上却没有该用户的Session数据,当用户再次发起一个请求的时候,此时请求如果被分配到服务器B上,则就不会查询到该用户的登录状态,就会出现登录失败的情况!

一种可以想到的方式就是将多个Web服务器上存储的Session统一存储到某一存储介质中,保证进集群中的每一台机器都可以看到所有相同Session数据,这里的同步体现在所有的Session存储在同一的存储介质里边。

幸运的是我们常用的Tomcat容器已经为我们提供了一个接口,可以让我们实现将Session存储到除当前服务器之外的其他存储介质上,例如Redis等。

五、小结

Session和Cookie的目的相同,都是为了克服HTTP协议无状态的缺陷,但完成的方法不同。Session通过Cookie,在客户端保存SessionID,而将用户的其他会话消息保存在服务端的Session对象中,与此相对的,Cookie需要将所有信息都保存在客户端。因此Cookie存在着一定的安全隐患,例如本地Cookie中保存的用户名密码被破译,或Cookie被其他网站收集,例如:

  1. appA主动设置域B cookie,让域B cookie获取;

  2. XSS,在appA上通过JavaScript获取document.cookie,并传递给自己的appB。

上述过程我们简单的描述了Session的演进过程还有使用同步的方式解决Session在集群的时候出现的问题,但是我们意识到了使用Spring Session的方式来实现Session的同步是一件相对比较麻烦的事情,我们虽然使用Redis来进行同步,但是Redis并不是100%可靠的,我们需要对Redis搭建集群、进行主从同步复制、进行持久化等,显然这是一件很复杂的事情,因此有没有一种小而轻便的方式来实现我们的这种认证需求!那就是JWT了!

除了上述我们遇到的问题之外,在目前前后端分离的大环境下经常会遇到需要根据用户来分配权限和显示相对应信息的问题,虽然传统的Cookie和Session机制可以解决这个问题,但就通用性而言,JWT(JSON Web Token)相对来说更好。

六、JWT

Json web token (JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该标准被设计为紧凑且安全的,一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息。当然该标准也可直接被用于认证,也可被加密。

JWT的几个特点:

1、由于它们的尺寸较小,JWT可以通过URL,POST参数或HTTP头部发送。 另外,尺寸越小意味着传输速度越快。

2、有效载荷包含有关用户的所有必需信息,避免了多次查询数据库的需要。

JWT的使用场景:

1、验证

这是使用JWT最常见的情况。 一旦用户登录,每个后续请求将包括JWT。它将允许用户访问该令牌允许的路由,服务和资源。 单点登录是当今广泛使用JWT的一项功能,因为它的开销很小,而且能够轻松地跨不同域使用。

2、信息交换

JWT是在各方之间安全传输信息的好方法, 因为JWT可以被签名(例如使用公钥/私钥对进行签名)。所以你可以确定发件人是他们说的那个人。 此外,由于使用头部(header)和有效载荷(payload)计算签名,因此您还可以验证内容是否未被篡改。

七、JWT结构说明

JWT包含三个由点(.)分隔的部分:

1. Header:头部

通常头部有两部分信息:

我们会对头部进行base64加密(可解密),得到第一部分数据

  • 声明类型,这里是JWT

  • 加密算法,自定义(如HMAC SHA256或RSA)

2. Payload:载荷

令牌的第二部分是包含声明的有效载荷。 声明是关于实体(通常是用户)和附加元数据的声明。 有三种类型的声明:

  1. 标准中注册的声明;

  2. 公开声明;

  3. 私人声明;

(1)标准中注册的声明:这是一组预先定义的声明,这些声明不是强制性的,但建议提供一套有用的,可互操作的声明。 如下:

iss: jwt签发者

sub: jwt所面向的用户

aud: 接收jwt的一方

exp: jwt的过期时间,这个过期时间必须要大于签发时间

nbf: 定义在什么时间之前,该jwt都是不可用的.

iat: jwt的签发时间

jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

注意:声明名称只有三个字符长,因为JWT是紧凑的。

(2)公开声明:这些可以由使用JWT的人员随意定义。 但为避免冲突,应在IANA JSON Web令牌注册表中定义它们,或者将其定义为包含防冲突命名空间的URI。

(3)私人声明:这是为了共享使用它们的当事方之间共享信息而创建的声明,既不是登记声明,也不是公开声明。

这部分也会采用base64加密,得到第二部分数据,一般包含下面信息:

  • 用户身份信息(注意,这里因为采用base64加密,可解密,因此不要存放敏感信息)

  • 注册声明:如token的签发时间,过期时间,签发人等

3. Signature:签名

是整个数据的认证信息,一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性

八、JWT交互流程

  • 1、用户登录

  • 2、服务的认证,通过后根据secret生成token

  • 3、将生成的token返回给浏览器

  • 4、用户每次请求携带token

  • 5、服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息

  • 6、处理请求,返回响应结果

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

九、JWT在微服务中的应用

微服务架构中一般通过网关转发用户的请求到相应的微服务中,那么鉴权中心一般都放在网关中。9.1 

9.1 RSA

非对称加密

  • 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端

    • 私钥加密,持有公钥才可以解密

    • 公钥加密,持有私钥才可解密

  • 优点:安全,难以破解

  • 缺点:算法比较耗时

第一种用法:公钥加密,私钥解密。---用于加解密
第二种用法:私钥签名,公钥验签。---用于签名

9.2 没有RSA加密的鉴权流程

  • 1、用户请求登录

  • 2、Zuul将请求转发到授权中心,请求授权

  • 3、授权中心校验完成,颁发JWT凭证

  • 4、客户端请求其它功能,携带JWT

  • 5、Zuul将jwt交给授权中心校验,通过后放行

  • 6、用户请求到达微服务

  • 7、微服务将jwt交给鉴权中心,鉴权同时解析用户信息

  • 8、鉴权中心返回用户数据给微服务

  • 9、微服务处理请求,返回响应

存在的问题:

每次鉴权都需要访问鉴权中心,系统间的网络请求频率过高,效率略差,鉴权中心的压力较大。

9.3 结合RSA的鉴权流程

  • 首先利用RSA生成公钥和私钥。私钥保存在授权中心,公钥保存在Zuul和各个微服务

  • 用户请求登录

  • 授权中心校验,通过后用私钥对JWT进行签名加密

  • 返回jwt给用户

  • 用户携带JWT访问

  • Zuul直接通过公钥解密JWT,进行验证,验证通过则放行

  • 请求到达微服务,微服务直接用公钥解析JWT,获取用户信息,无需访问授权中心

十、生成Token

代码:

    /**
     * 私钥加密token
     *
     * @param userInfo      载荷中的数据
     * @param privateKey    私钥
     * @param expireMinutes 过期时间,单位秒
     * @return
     * @throws Exception
     */
    public static String generateToken(UserInfo userInfo, PrivateKey privateKey, int expireMinutes) throws Exception {
        return Jwts.builder()
                .claim(JwtConstans.JWT_KEY_ID, userInfo.getId())
                .claim(JwtConstans.JWT_KEY_USER_NAME, userInfo.getUsername())
                .setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();
    }

具体看一下JwtBuilder中的方法:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package io.jsonwebtoken;

import java.security.Key;
import java.util.Date;
import java.util.Map;

public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
    //设置头部
    JwtBuilder setHeader(Header var1);

    JwtBuilder setHeader(Map<String, Object> var1);

    //设置头部参数
    JwtBuilder setHeaderParams(Map<String, Object> var1);

    JwtBuilder setHeaderParam(String var1, Object var2);

    //设置载荷
    JwtBuilder setPayload(String var1);

    //设置声明
    JwtBuilder setClaims(Claims var1);

    //设置自定义声明
    JwtBuilder setClaims(Map<String, Object> var1);

    //添加自定义声明
    JwtBuilder addClaims(Map<String, Object> var1);
    //注意:claims不能和payload同时设置。

    //设置jwt签发者
    JwtBuilder setIssuer(String var1);

    //设置jwt所面向的用户
    JwtBuilder setSubject(String var1);

    //设置接收jwt的一方
    JwtBuilder setAudience(String var1);

    //设置jwt过期时间,而且这个时间必须大于签发时间
    JwtBuilder setExpiration(Date var1);

    //定义在某一时间前该jwt不可用
    JwtBuilder setNotBefore(Date var1);

    //设置jwt的签发时间
    JwtBuilder setIssuedAt(Date var1);

    //设置jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
    JwtBuilder setId(String var1);

    JwtBuilder claim(String var1, Object var2);

    JwtBuilder signWith(SignatureAlgorithm var1, byte[] var2);

    JwtBuilder signWith(SignatureAlgorithm var1, String var2);

    JwtBuilder signWith(SignatureAlgorithm var1, Key var2);

    JwtBuilder compressWith(CompressionCodec var1);

    String compact();
}

compact方法:

将header和payload进行Base64编码,然后使用header、payload和secret生成签名,最后进行拼接返回。

public String compact() {
        if (this.payload == null && Collections.isEmpty(this.claims)) {
            throw new IllegalStateException("Either 'payload' or 'claims' must be specified.");
        } else if (this.payload != null && !Collections.isEmpty(this.claims)) {
            throw new IllegalStateException("Both 'payload' and 'claims' cannot both be specified. Choose either one.");
        } else if (this.key != null && this.keyBytes != null) {
            throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either one.");
        } else {
            Header header = this.ensureHeader();
            Key key = this.key;
            if (key == null && !Objects.isEmpty(this.keyBytes)) {
                key = new SecretKeySpec(this.keyBytes, this.algorithm.getJcaName());
            }

            Object jwsHeader;
            if (header instanceof JwsHeader) {
                jwsHeader = (JwsHeader)header;
            } else {
                jwsHeader = new DefaultJwsHeader(header);
            }

            if (key != null) {
                ((JwsHeader)jwsHeader).setAlgorithm(this.algorithm.getValue());
            } else {
                ((JwsHeader)jwsHeader).setAlgorithm(SignatureAlgorithm.NONE.getValue());
            }

            if (this.compressionCodec != null) {
                ((JwsHeader)jwsHeader).setCompressionAlgorithm(this.compressionCodec.getAlgorithmName());
            }

            String base64UrlEncodedHeader = this.base64UrlEncode(jwsHeader, "Unable to serialize header to json.");
            String base64UrlEncodedBody;
            if (this.compressionCodec != null) {
                byte[] bytes;
                try {
                    bytes = this.payload != null ? this.payload.getBytes(Strings.UTF_8) : this.toJson(this.claims);
                } catch (JsonProcessingException var9) {
                    throw new IllegalArgumentException("Unable to serialize claims object to json.");
                }

                base64UrlEncodedBody = TextCodec.BASE64URL.encode(this.compressionCodec.compress(bytes));
            } else {
                base64UrlEncodedBody = this.payload != null ? TextCodec.BASE64URL.encode(this.payload) : this.base64UrlEncode(this.claims, "Unable to serialize claims object to json.");
            }

            String jwt = base64UrlEncodedHeader + '.' + base64UrlEncodedBody;
            if (key != null) {
                JwtSigner signer = this.createSigner(this.algorithm, (Key)key);
                String base64UrlSignature = signer.sign(jwt);
                jwt = jwt + '.' + base64UrlSignature;
            } else {
                jwt = jwt + '.';
            }

            return jwt;
        }
    }

十一、解析Token获取用户信息

代码:

    /**
     * 公钥解析token
     *
     * @param token     用户请求中的token
     * @param publicKey 公钥字节数组
     * @return
     * @throws Exception
     */
    private static Jws<Claims> parserToken(String token, byte[] publicKey) throws Exception {
        return Jwts.parser().setSigningKey(RsaUtils.getPublicKey(publicKey))
                .parseClaimsJws(token);
    }

    /**
     * 获取token中的用户信息
     *
     * @param token     用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     * @throws Exception
     */
    public static UserInfo getInfoFromToken(String token, PublicKey publicKey) throws Exception {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();
        return new UserInfo(
                ObjectUtils.toLong(body.get(JwtConstans.JWT_KEY_ID)),
                ObjectUtils.toString(body.get(JwtConstans.JWT_KEY_USER_NAME))
        );
    }

通过公钥来认证,确保token是鉴权中心颁发的。

parseClaimsJws方法调用parse来进行token解析

具体的parse方法有兴趣的话可以了解一下:

    public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException, SignatureException {
        Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
        String base64UrlEncodedHeader = null;
        String base64UrlEncodedPayload = null;
        String base64UrlEncodedDigest = null;
        int delimiterCount = 0;
        StringBuilder sb = new StringBuilder(128);
        char[] arr$ = jwt.toCharArray();
        int len$ = arr$.length;

        for(int i$ = 0; i$ < len$; ++i$) {
            char c = arr$[i$];
            if (c == '.') {
                CharSequence tokenSeq = Strings.clean(sb);
                String token = tokenSeq != null ? tokenSeq.toString() : null;
                if (delimiterCount == 0) {
                    base64UrlEncodedHeader = token;
                } else if (delimiterCount == 1) {
                    base64UrlEncodedPayload = token;
                }

                ++delimiterCount;
                sb.setLength(0);
            } else {
                sb.append(c);
            }
        }

        if (delimiterCount != 2) {
            String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
            throw new MalformedJwtException(msg);
        } else {
            if (sb.length() > 0) {
                base64UrlEncodedDigest = sb.toString();
            }

            if (base64UrlEncodedPayload == null) {
                throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
            } else {
                Header header = null;
                CompressionCodec compressionCodec = null;
                String payload;
                if (base64UrlEncodedHeader != null) {
                    payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
                    Map<String, Object> m = this.readValue(payload);
                    if (base64UrlEncodedDigest != null) {
                        header = new DefaultJwsHeader(m);
                    } else {
                        header = new DefaultHeader(m);
                    }

                    compressionCodec = this.compressionCodecResolver.resolveCompressionCodec((Header)header);
                }

                if (compressionCodec != null) {
                    byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
                    payload = new String(decompressed, Strings.UTF_8);
                } else {
                    payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
                }

                Claims claims = null;
                if (payload.charAt(0) == '{' && payload.charAt(payload.length() - 1) == '}') {
                    Map<String, Object> claimsMap = this.readValue(payload);
                    claims = new DefaultClaims(claimsMap);
                }

                if (base64UrlEncodedDigest != null) {
                    JwsHeader jwsHeader = (JwsHeader)header;
                    SignatureAlgorithm algorithm = null;
                    String object;
                    if (header != null) {
                        object = jwsHeader.getAlgorithm();
                        if (Strings.hasText(object)) {
                            algorithm = SignatureAlgorithm.forName(object);
                        }
                    }

                    if (algorithm == null || algorithm == SignatureAlgorithm.NONE) {
                        object = "JWT string has a digest/signature, but the header does not reference a valid signature algorithm.";
                        throw new MalformedJwtException(object);
                    }

                    if (this.key != null && this.keyBytes != null) {
                        throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
                    }

                    if ((this.key != null || this.keyBytes != null) && this.signingKeyResolver != null) {
                        object = this.key != null ? "a key object" : "key bytes";
                        throw new IllegalStateException("A signing key resolver and " + object + " cannot both be specified. Choose either.");
                    }

                    Key key = this.key;
                    if (key == null) {
                        byte[] keyBytes = this.keyBytes;
                        if (Objects.isEmpty(keyBytes) && this.signingKeyResolver != null) {
                            if (claims != null) {
                                key = this.signingKeyResolver.resolveSigningKey(jwsHeader, claims);
                            } else {
                                key = this.signingKeyResolver.resolveSigningKey(jwsHeader, payload);
                            }
                        }

                        if (!Objects.isEmpty(keyBytes)) {
                            Assert.isTrue(algorithm.isHmac(), "Key bytes can only be specified for HMAC signatures. Please specify a PublicKey or PrivateKey instance.");
                            key = new SecretKeySpec(keyBytes, algorithm.getJcaName());
                        }
                    }

                    Assert.notNull(key, "A signing key must be specified if the specified JWT is digitally signed.");
                    String jwtWithoutSignature = base64UrlEncodedHeader + '.' + base64UrlEncodedPayload;

                    JwtSignatureValidator validator;
                    try {
                        validator = this.createSignatureValidator(algorithm, (Key)key);
                    } catch (IllegalArgumentException var26) {
                        String algName = algorithm.getValue();
                        String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " + "algorithm, but the specified signing key of type " + key.getClass().getName() + " may not be used to validate " + algName + " signatures.  Because the specified " + "signing key reflects a specific and expected algorithm, and the JWT does not reflect " + "this algorithm, it is likely that the JWT was not expected and therefore should not be " + "trusted.  Another possibility is that the parser was configured with the incorrect " + "signing key, but this cannot be assumed for security reasons.";
                        throw new UnsupportedJwtException(msg, var26);
                    }

                    if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
                        String msg = "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.";
                        throw new SignatureException(msg);
                    }
                }

                boolean allowSkew = this.allowedClockSkewMillis > 0L;
                if (claims != null) {
                    Date now = this.clock.now();
                    long nowTime = now.getTime();
                    Date exp = claims.getExpiration();
                    String nbfVal;
                    SimpleDateFormat sdf;
                    if (exp != null) {
                        long maxTime = nowTime - this.allowedClockSkewMillis;
                        Date max = allowSkew ? new Date(maxTime) : now;
                        if (max.after(exp)) {
                            sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
                            String expVal = sdf.format(exp);
                            nbfVal = sdf.format(now);
                            long differenceMillis = maxTime - exp.getTime();
                            String msg = "JWT expired at " + expVal + ". Current time: " + nbfVal + ", a difference of " + differenceMillis + " milliseconds.  Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
                            throw new ExpiredJwtException((Header)header, claims, msg);
                        }
                    }

                    Date nbf = claims.getNotBefore();
                    if (nbf != null) {
                        long minTime = nowTime + this.allowedClockSkewMillis;
                        Date min = allowSkew ? new Date(minTime) : now;
                        if (min.before(nbf)) {
                            sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
                            nbfVal = sdf.format(nbf);
                            String nowVal = sdf.format(now);
                            long differenceMillis = nbf.getTime() - minTime;
                            String msg = "JWT must not be accepted before " + nbfVal + ". Current time: " + nowVal + ", a difference of " + differenceMillis + " milliseconds.  Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
                            throw new PrematureJwtException((Header)header, claims, msg);
                        }
                    }

                    this.validateExpectedClaims((Header)header, claims);
                }

                Object body = claims != null ? claims : payload;
                if (base64UrlEncodedDigest != null) {
                    return new DefaultJws((JwsHeader)header, body, base64UrlEncodedDigest);
                } else {
                    return new DefaultJwt((Header)header, body);
                }
            }
        }
    }

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值