从零开始手写一个http-basic认证服务器

简单了解HTTP

HTTP是英文HyperText Transfer Protocol首字母缩写,是目前WWW万维网通讯标准协议,在属于OSI第七层(应用层)协议,要实现HTTP协议通常需要基于TCP-Socket套接字作为传输层的支撑。

什么是http-basic

http-basic是http协议的认证模块,http-basic也是http协议下的协议。下面先来看一下http-basic协议流程图:

流程图说明:

第一步:当客户端通过浏览器访问实现了http-basic的后端服务器时,后端服务器首先会校验是否携带Authorization请求头,如果没有则返回Code=401:

http.setCode(401);
http.setStatus("Unauthorized");
// 其中Basic表示认证类型为Basic,Realm用来描述后端应用名称,例如OA,CRM,可自定义
// 除了Basic认证类型外,后面还会将另一种类型:Digest
http.setHeader("WWW-Authenticate", "Basic Reaml=\"HTTP Demo Server\"");

下面是谷歌浏览器默认抓包截图:

此时用户就会收到浏览器弹出的登陆输入框:

第二步:当用户输入用户名和密码,点击登陆按钮时,浏览器会将表单的用户名和密码按规则:BASE64(用户名:密码)编码后放到请求头为HTTP-Request-Header=Authorization: Basic cm9vdDoxMjM0NTY= 方式提交给服务器进行认证,具体截图如下:

第三步:后端服务器接收到请求后,仍然会校验是否携带Authorization请求头,如果携带,则通过BASE64解码出来得到用户名:密码字符串,然后通过":"进行分隔得到用户名和密码进行验证,如果验证失败,继续第一步的内容,如果验证成功,后端服务可能会进一步验证URL资源权限等,如果验证资源权限不通过,此时参考第一步的方式返回(注意此时返回Code=403):

http.setCode(401);
http.setStatus("Forbidden");
http.setHeader("WWW-Authenticate", "Basic Reaml=\"HTTP Demo Server\"");

如果验证通过,则返回Code=200即可:

http.setCode(200);
http.setStatus("OK");

http-basic缺陷

http-basic(当响应头WWW-Authenticate:Basic Reaml=xxx)存在以下缺陷:

1、用户名和密码明文(仅仅只是Base64编码而已)传输,需要配合HTTPS来保证信息传输的安全。

2、就算HTTPS将密码加密传输,也仍然存在重放攻击风险。

3、代理和中间节点的防护措施弱,很容易通过伪装服务器来骗过认证,诱导用户输入用户名和密码。

下面将介绍http-basic升级版更为安全的摘要认证http-digest。

了解http-digest流程

http-digest流程比http-basic流程复杂很多,流程图如下:

下面对http-digest涉及的字段作用进行说明:

  • WWW-Authentication:用来定义使用何种方式(Basic、Digest、Bearer等)去进行认证以获取受保护的资源。

  • realm:表示Web服务器中受保护文档的安全域(比如OA、CRM系统域等),用来指示需要哪个域的认证。

  • qop:保护质量,包含auth(默认的)和auth-int(增加了报文完整性检测)两种策略,可以为空,但不推荐为空值。

  • nonce:服务端向客户端发送质询时附带的一个随机数,这个数会经常发生变化。客户端计算密码摘要时将其附加上去,使得多次生成同一用户的密码摘要各不相同,用来防止重放攻击。

  • nc:nonce计数器,是一个16进制的数值,表示同一nonce下客户端发送出请求的数量。例如,在响应的第一个请求中,客户端将发送“nc=00000001”。这个指示值的目的是让服务器保持这个计数器的一个副本,以便检测重复的请求。

  • cnonce:客户端随机数,这是一个不透明的字符串值,由客户端提供,并且客户端和服务器都会使用,以避免用明文文本。这使得双方都可以查验对方的身份,并对消息的完整性提供一些保护。

  • response:这是由用户代理软件计算出的一个字符串,以证明用户知道口令。

  • Authorization-Info:用于返回一些与授权会话相关的附加信息。

  • nextnonce:下一个服务端随机数,使客户端可以预先发送正确的摘要

  • rspauth:响应摘要,用于客户端对服务端进行认证。

  • stale:当密码摘要使用的随机数过期时,服务器可以返回一个附带有新随机数的401响应,并指定stale=true,表示服务器在告知客户端用新的随机数来重试,而不再要求用户重新输入用户名和密码了。

http-digest流程说明

第一步:当客户端通过浏览器访问实现了http-digest的后端服务器时,后端服务器首先会校验是否携带Authorization请求头,如果没有则返回Code=401:

http.setCode(401);
http.setStatus("Unauthorized");
// 其中Basic表示认证类型为Basic,Realm用来描述后端应用名称,例如OA,CRM,可自定义
// 除了Basic认证类型外,后面还会将另一种类型:Digest
http.setHeader("WWW-Authenticate", "Basic Reaml=\"HTTP Demo Server\"");

并且返回(注意和http-basic的区别):

WWW-Authenticate: Digest nonce=MTYyMTA4OTE3MDgyODo6ODQzMjU0NjMwNTE0MTYzNzEy,realm=LazyAgentConsole,qop=auth,algorithm=MD5

下面是谷歌浏览器默认抓包截图:

此时用户就会收到浏览器弹出的登陆输入框:

第二步:当用户输入用户名和密码,点击登陆按钮时,浏览器会将表单的用户名和密码进行加密后放到请求头,浏览器抓包截图如下:

Authorization: Digest username="root", realm="LazyAgentConsole", nonce="MTYyMTA4OTMyNjUyMzo6ODQzMjU1MjgzNTQ2MzI0OTky", uri="/", algorithm=MD5, response="7497e20c827e0de40ac28379ab52afe6", qop=auth, nc=00000002, cnonce="5ec74e43305c2a56"

第三步:后端服务器接收到请求后,仍然会校验是否携带Authorization请求头,如果携带,则通过算法解密(后面介绍)出来得到用户名和密码信息进行验证,如果验证失败,继续第一步的内容,如果验证成功,后端服务可能会进一步验证URL资源权限等,如果验证资源权限不通过,此时参考第一步的方式返回(注意此时返回Code=403):

http.setCode(401);
http.setStatus("Forbidden");
http.setHeader("WWW-Authenticate", "Basic Reaml=\"HTTP Demo Server\"");

如果验证通过,则返回Code=200即可:

http.setCode(200);
http.setStatus("OK");

http-digest解密算法

传给后台请求头Authorization中response值就是客户端通过计算得到的密码摘要,后端解密response时根据不同的保护质量qos解密方式也有所不同,当qos策略为auth,计算方式如下:

// 使用默认的MD5加密算法
MD5(MD5(A1):<nonce>:<nc>:<cnonce>:<qop>:MD5(A2))

算法

A1

MD5(默认)

<username>:<realm>:<password>

MD5-sess

MD5(<username>:<realm>:<password>):<nonce>:<cnonce>

qop

A2

auth(默认)

<request-method>:<uri>

auth-int

<request-method>:<uri>:MD5(<request-entity-body>)

到这里,理论性的内容就讲解完毕,接下来进入http-digest协议代码实战环节。

项目结构

pom.xml源码:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>


    <groupId>groupId</groupId>
    <artifactId>lazy-httpbasic</artifactId>
    <version>1.0-SNAPSHOT</version>


    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>


    <build>
        <finalName>lazy-httpbasic</finalName>
        <plugins>
            <plugin>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.2.0</version>
                <configuration>
                    <includes>
                        <include>**/*.*</include>
                    </includes>
                    <archive>
                        <manifest>
                            <!--配置jar包内创建MANIFEST。MF文件 -->
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>classes/</classpathPrefix>
                            <!--指定jar包启动类 -->
                            <mainClass>com.lazy.httpbasic.Boostrap</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Config.java源码:

package com.lazy.httpbasic.conf;


public class Config {


    /**
     * 控制台会话超时时间,单位毫秒, 默认10分钟
     */
    public static final long CONSOLE_HTTP_SESSION_TIME = 600000;
    /**
     * 心跳控制台监听默认端口
     */
    public static final int HEARTBEAT_CONSOLE_PORT = 9550;
    /**
     * 控制台HTTP BASIC 用户名
     */
    public static final String CONSOLE_HTTP_BASIC_USERNAME = "root";
    /**
     * 控制台HTTP BASIC 密码
     */
    public static final String CONSOLE_HTTP_BASIC_PASSWORD = "123456";
    /**
     * realm
     */
    public static final String REALM = "HttpBasicServer";


}


HttpRequest.java类源码:

package com.lazy.httpbasic.bean;


import java.util.HashMap;
import java.util.Map;


public class HttpRequest {
    /**
     * 请求方法 GET/POST/PUT/DELETE/OPTION...
     */
    private String method;
    /**
     * 请求的uri
     */
    private String uri;
    /**
     * http版本
     */
    private String version;
    /**
     * 请求头
     */
    private Map<String, String> headers = new HashMap<>();
    /**
     * 请求参数
     */
    private Map<String, Object> parameters = new HashMap<>();
    /**
     * 请求参数相关
     */
    private String message;


    public String getHeader(String name) {
        return this.headers.get(name);
    }


    public void setHeader(String name, String val) {
        this.headers.put(name, val);
    }


    public void setParameters(Map<String, Object> parameters) {
        this.parameters = parameters;
    }


// 省略getter setter...
}


HttpResponse.java类源码:

package com.lazy.httpbasic.bean;


import java.util.Map;


public class HttpResponse {


    private String version;
    private int code;
    private String status;
    private Map<String, String> headers;
    private String message;
 // 省略getter setter... 
}


HttpRequestParser.java源码:

package com.lazy.httpbasic.bean;


import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;


public class HttpRequestParser {




    /**
     * 根据标准的http协议,解析请求行
     * 请求行,包含三个基本要素:请求方法 + URI + http版本,用空格进行分割,所以解析代码如下
     *
     * @param reader
     * @param request
     */
    private static void decodeRequestLine(BufferedReader reader, HttpRequest request) throws IOException {
        String[] strs = reader.readLine().split(" ");
        assert strs.length == 3;
        request.setMethod(strs[0]);
        request.setUri(strs[1]);
        request.setVersion(strs[2]);


        //解析参数
        String[] params = null;
        String[] uriAndParam = strs[1].split("\\?");
        if (uriAndParam.length == 2) {
            String param = uriAndParam[1];
            params = param.split("&");
            for (int i = 0; i < params.length; i++) {
                String[] p = params[i].split("=");
                if (p.length == 2) {
                    request.getParameters().put(p[0], p[1]);
                }
            }
            request.setUri(uriAndParam[0]);
        }
    }


    /**
     * 根据标准http协议,解析请求头
     * 请求头的解析,从第二行,到第一个空白行之间的所有数据,都是请求头;请求头的格式也比较清晰, 形如 key:value
     *
     * @param reader
     * @param request
     * @throws IOException
     */
    private static void decodeRequestHeader(BufferedReader reader, HttpRequest request) throws IOException {
        Map<String, String> headers = new HashMap<>(16);
        String line = reader.readLine();
        String[] kv;
        while (!"".equals(line)) {
            kv = line.split(":");
            assert kv.length == 2;
            headers.put(kv[0].trim(), kv[1].replaceAll("\"", "").trim());
            line = reader.readLine();
        }
        request.setHeaders(headers);
    }


    /**
     * 根据标注http协议,解析正文
     *
     * @param reader
     * @param request
     * @throws IOException
     */
    private static void decodeRequestMessage(BufferedReader reader, HttpRequest request) throws IOException {
        int contentLen = Integer.parseInt(request.getHeaders().getOrDefault("Content-Length", "0"));
        if (contentLen == 0) {
            // 表示没有message,直接返回
            // 如get/options请求就没有message
            return;
        }
        char[] message = new char[contentLen];
        reader.read(message);
        request.setMessage(new String(message));
    }


    /**
     * http的请求可以分为三部分
     * <p>
     * 第一行为请求行: 即 方法 + URI + 版本
     * 第二部分到一个空行为止,表示请求头
     * 空行
     * 第三部分为接下来所有的,表示发送的内容,message-body;其长度由请求头中的 Content-Length 决定
     * <p>
     * 几个实例如下
     *
     * @param reqStream
     * @return
     */
    public static HttpRequest parse2request(InputStream reqStream) throws IOException {
        BufferedReader httpReader = new BufferedReader(new InputStreamReader(reqStream, StandardCharsets.UTF_8));
        HttpRequest httpRequest = new HttpRequest();
        decodeRequestLine(httpReader, httpRequest);
        decodeRequestHeader(httpReader, httpRequest);
        decodeRequestMessage(httpReader, httpRequest);
        return httpRequest;
    }


}


HttpResponseParser.java源码:

package com.lazy.httpbasic.bean;


import java.util.HashMap;
import java.util.Map;


public class HttpResponseParser {


    public static String buildResponse(HttpRequest request, String response) {
        HttpResponse httpResponse = ofResponse(request, response);
        StringBuilder builder = new StringBuilder();
        buildResponseLine(httpResponse, builder);
        buildResponseHeaders(httpResponse, builder);
        buildResponseMessage(httpResponse, builder);
        return builder.toString();
    }


    public static String ofResponseStr(HttpResponse httpResponse) {
        StringBuilder builder = new StringBuilder();
        buildResponseLine(httpResponse, builder);
        buildResponseHeaders(httpResponse, builder);
        buildResponseMessage(httpResponse, builder);
        return builder.toString();
    }


    public static HttpResponse ofResponse(HttpRequest request, String response) {
        if (response == null) {
            response = "";
        }
        HttpResponse httpResponse = new HttpResponse();
        httpResponse.setCode(200);
        httpResponse.setStatus("ok");
        httpResponse.setVersion(request.getVersion());
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "text/html");
        headers.put("Cache-Control", "no-store");
        headers.put("Content-Length", String.valueOf(response.getBytes().length));
        httpResponse.setHeaders(headers);
        httpResponse.setMessage(response);
        return httpResponse;
    }


    private static void buildResponseLine(HttpResponse response, StringBuilder stringBuilder) {
        stringBuilder.append(response.getVersion()).append(" ").append(response.getCode()).append(" ")
                .append(response.getStatus()).append("\n");
    }


    private static void buildResponseHeaders(HttpResponse response, StringBuilder stringBuilder) {
        for (Map.Entry<String, String> entry : response.getHeaders().entrySet()) {
            stringBuilder.append(entry.getKey()).append(":").append(entry.getValue()).append("\n");
        }
        stringBuilder.append("\n");
    }


    private static void buildResponseMessage(HttpResponse response, StringBuilder stringBuilder) {
        stringBuilder.append(response.getMessage());
    }


}


HttpDigest.java源码:

package com.lazy.httpbasic.bean;




import com.lazy.httpbasic.conf.Config;
import com.lazy.httpbasic.util.MD5Util;
import com.lazy.httpbasic.util.StringUtil;


import java.util.Locale;


public class HttpDigest {


    private String username;
    private String nonce;
    private String realm;
    private String qop;
    private String nc;
    private String cnonce;
    private String response;
    private String uri;
    private String stale;
    private String rspauth;
    private String algorithm;
    private String method;
    private String nextnonce;


    public String getNextnonce() {
        return nextnonce;
    }


    public void setNextnonce(String nextnonce) {
        this.nextnonce = nextnonce;
    }


    public String md5() {
        String a1 = this.getUsername() + ":" + this.getRealm() + ":" + Config.CONSOLE_HTTP_BASIC_PASSWORD;
        String ha1 = MD5Util.encodeByMD5(a1);


        String a2 = this.getMethod() + ":" + this.getUri();
        String ha2 = MD5Util.encodeByMD5(a2);
        //服务器计算出的摘要
        String responseBefore = ha1 + ":" + this.getNonce() + ":" + this.getNc()
                + ":" + this.getCnonce() + ":" + this.getQop() + ":" + ha2;
        String responseMD5 = MD5Util.encodeByMD5(responseBefore);
        return responseMD5;
    }


    public void parse(String str) {
        String[] pair = str.split(",");
        for (String s : pair) {
            String[] kv = s.split("=");
            if (kv.length != 2) {
                continue;
            }
            String k = kv[0].toLowerCase(Locale.ENGLISH).trim();
            String v = kv[1];
            if ("username".equals(k)) {
                this.username = v;
            }
            if ("nonce".equals(k)) {
                this.nonce = v;
            }
            if ("realm".equals(k)) {
                this.realm = v;
            }
            if ("qop".equals(k)) {
                this.qop = v;
            }
            if ("nc".equals(k)) {
                this.nc = v;
            }
            if ("cnonce".equals(k)) {
                this.cnonce = v;
            }
            if ("response".equals(k)) {
                this.response = v;
            }
            if ("uri".equals(k)) {
                this.uri = v;
            }
            if ("stale".equals(k)) {
                this.stale = v;
            }
            if ("rspauth".equals(k)) {
                this.rspauth = v;
            }
            if ("algorithm".equals(k)) {
                this.algorithm = v;
            }
            if ("nextnonce".equals(k)) {
                this.nextnonce = v;
            }
        }
    }


    public String ofString() {
        StringBuilder s = new StringBuilder("Digest ");
        if (StringUtil.isNotBlank(username)) {
            s.append("username=").append(username).append(",");
        }
        if (StringUtil.isNotBlank(nonce)) {
            s.append("nonce=").append(nonce).append(",");
        }
        if (StringUtil.isNotBlank(realm)) {
            s.append("realm=").append(realm).append(",");
        }
        if (StringUtil.isNotBlank(qop)) {
            s.append("qop=").append(qop).append(",");
        }
        if (StringUtil.isNotBlank(nc)) {
            s.append("nc=").append(nc).append(",");
        }
        if (StringUtil.isNotBlank(cnonce)) {
            s.append("cnonce=").append(cnonce).append(",");
        }
        if (StringUtil.isNotBlank(response)) {
            s.append("response=").append(response).append(",");
        }
        if (StringUtil.isNotBlank(uri)) {
            s.append("uri=").append(uri).append(",");
        }
        if (StringUtil.isNotBlank(stale)) {
            s.append("stale=").append(stale).append(",");
        }
        if (StringUtil.isNotBlank(rspauth)) {
            s.append("rspauth=").append(rspauth).append(",");
        }
        if (StringUtil.isNotBlank(algorithm)) {
            s.append("algorithm=").append(algorithm).append(",");
        }
        if (StringUtil.isNotBlank(nextnonce)) {
            s.append("nextnonce=").append(nextnonce).append(",");
        }
        s.deleteCharAt(s.length() - 1);
        return s.toString();
    }
}


HttpBasicUtil.java源码:

package com.lazy.httpbasic.util;




import com.lazy.httpbasic.bean.HttpDigest;
import com.lazy.httpbasic.bean.HttpRequest;
import com.lazy.httpbasic.conf.Config;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;


import java.io.UnsupportedEncodingException;




public class HttpBasicUtil {


    /**
     * Authorization: Digest username=“xxxxx”,realm=“myTomcat”,qop=“auth”,nonce=“xxxxx”,uri=“xxxx”,cnonce=“xxxxxx”,nc=00000001,response=“xxxxxxxxx”,opaque=“xxxxxxxxx” 。其中username是用户名;cnonce是客户端生成的随机字符串;nc是运行认证的次数;response就是最终计算得到的摘要。
     *
     * @param request
     * @return
     */
    public static HttpDigest ofDigestRequest(HttpRequest request) {
        String authorization = request.getHeader("Authorization");
        if ((authorization != null) && (authorization.length() > 7)) {
            authorization = authorization.substring(7);
            HttpDigest digest = new HttpDigest();
            digest.parse(authorization);
            digest.setMethod(request.getMethod());
            return digest;
        }
        return null;
    }


    public static String base64DecodeNonce() {
        try {
            return base64Encode((System.currentTimeMillis() + "::" + SnowflakeIdUtil.getInstance().nextId()).getBytes("utf-8"));
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return null;
    }




    public static String base64DecodeNonce(String nonceBase64) {
        return base64Decode(nonceBase64);
    }


    public static HttpDigest ofDigestResponse(HttpRequest request) {
        HttpDigest digestResponse = new HttpDigest();
        digestResponse.setNonce(base64DecodeNonce());
        digestResponse.setQop("auth");
        digestResponse.setAlgorithm("MD5");
        digestResponse.setRealm(Config.REALM);
        return digestResponse;
    }


    /**
     * 编码
     *
     * @param bstr
     * @return String
     */
    @SuppressWarnings("restriction")
    public static String base64Encode(byte[] bstr) {
        String strEncode = new BASE64Encoder().encode(bstr);
        return strEncode;
    }


    /**
     * 解码
     *
     * @param str
     * @return
     */
    public static String base64Decode(String str) {
        if (StringUtil.isBlank(str)) {
            return null;
        }
        String s = null;
        try {
            BASE64Decoder decoder = new BASE64Decoder();
            byte[] b = decoder.decodeBuffer(str);
            s = new String(b, "utf-8");
        } catch (Exception ignored) {


        }
        return s;
    }


}


Boostrap.java源码:

package com.lazy.httpbasic;


import com.lazy.httpbasic.conf.Config;
import com.lazy.httpbasic.task.WorkTask;


import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.*;


public class Boostrap {


    private static final int nThreads = Runtime.getRuntime().availableProcessors();


    /**
     * 主线程池
     */
    private static final ExecutorService bootstrapExecutor = Executors.newFixedThreadPool(2);
    /**
     * 任务线程池
     */
    private static final ExecutorService taskExecutor = new ThreadPoolExecutor(
            nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<>(100),
            new ThreadPoolExecutor.DiscardPolicy());


    /**
     * 启动方法
     *
     * @param args
     */
    public static void main(String[] args) {


        //启动socket线程
        bootstrapExecutor.submit(new Startup());
    }




    /**
     * 启动线程
     */
    static class Startup implements Runnable {


        @Override
        public void run() {


            ServerSocket serverSocket = null;
            Socket socket = null;
            try {
                serverSocket = new ServerSocket(Config.HEARTBEAT_CONSOLE_PORT);
                System.out.println("The Http-Basic Server is start in port:" + Config.HEARTBEAT_CONSOLE_PORT);
                while (true) {
                    socket = serverSocket.accept();
                    //接受客户端请求后提交给任务线程池去执行
                    taskExecutor.submit(new WorkTask(socket));
                }
            } catch (Exception ignored) {
                if (socket != null) {
                    try {
                        socket.close();
                    } catch (IOException e) {
                        //ignored
                    }
                }
                if (serverSocket != null) {
                    try {
                        serverSocket.close();
                    } catch (IOException e) {
                        //ignored
                    }
                }
            }
        }
    }
}


启动方式

1、直接IDE运行Boostrap的main方法。

2、maven打包后通过java -jar lazy-httpbasic.jar 启动。

启动后测试

启动后控制台输入如下:

浏览器访问: http://localhost:9550端口任何地址即可,截图如下(输入错误用户名或密码时仍然继续弹出登陆框):

输入正确用户名和密码时,登陆成功,截图如下:

完整源码获取可关注未新龚总浩【Java软件编程之家】后台回复:http-basic关键字即

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值