Watch Star Fork

习习风 / httpsignJavaApache-2.0

加入码云
与超过 300 万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
RESTful API 签名认证
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README.md 23.84 KB

简介

欢迎使用 HTTP Sign.
本项目将解决HTTP通信中的如下问题:

  • 防止重放攻击
  • 防止中途篡改数据
  • 保证请求服务幂等

从而,尽可能地让HTTP通信接近安全.

使用

<dependency>
    <groupId>org.fastquery</groupId>
    <artifactId>httpsign</artifactId>
    <version>1.0.4</version>
</dependency>

准备一个JAX-RS Resource Classes

@javax.ws.rs.Path("helloworld")
public class HelloWorldResource {

  @org.fastquery.httpsign.Authorization // 作用在方法上,那么该方法将进行签名认证
  @javax.ws.rs.GET
  @javax.ws.rs.Produces("text/plain")
  public String getHello() {
      return "hi";
  }
  
}

编写作用于服务端的ContainerRequestFilter

@org.fastquery.httpsign.Authorization
public class AuthorizationContainerRequestFilter extends 
		org.fastquery.httpsign.AuthAbstractContainerRequestFilter {
	@Override
	public String getAccessKeySecret(String accessKeyId) {
		// 根据 accessKeyId 找出 accessKeySecret
	}
}

编写作用于客户端的ClientRequestFilter

public class AuthorizationClientRequestFilter extends 
		org.fastquery.httpsign.AuthAbstractClientRequestFilter {
	@Override
	public String getAccessKeySecret(String accessKeyId) {
		// 根据 accessKeyId 找出 accessKeySecret
	}
}

在Jersey环境里使用

服务端:

@ApplicationPath("userResorce")
public class Application extends ResourceConfig {
	public Application() throws IOException {
		register(HelloWorldResource.class);
		register(AuthorizationContainerRequestFilter.class);
	}
}

JAX-RS客户端:

javax.ws.rs.client.Client client = javax.ws.rs.client.ClientBuilder.newClient();
client.register(AuthorizationClientRequestFilter.class);
javax.ws.rs.client.WebTarget target = client.target("http://localhost:8080").path("userResorce/helloworld");
// ... ...

在CXF+Spring环境里使用

服务端:

<jaxrs:server address="http://localhost...">
	<jaxrs:serviceBeans>
		<bean class="your package.HelloWorldResource" /> 
	</jaxrs:serviceBeans>
	<jaxrs:providers>
		<bean class="org.fastquery.httpsign.sample.AuthorizationContainerRequestFilter" />
	</jaxrs:providers>
</jaxrs:server>

客户端:

<jaxrs:client address="<your request address>" serviceClass="<your request service>">
	<jaxrs:providers>
		<bean class="org.fastquery.httpsign.sample.AuthorizationClientRequestFilter" />
	</jaxrs:providers>
</jaxrs:client>

HTTP Sign 的设计

字面约定

字面格式 含义
< > 变量
[ ] 可选项
{ } 必选项
| 互斥关系
标点符号 本文一律采用英文标点符号

请求参数名,命名规则

  1. 首字母小写,如果参数名由多个单词组成,相连单词的首字母要大写(例: userInfo)
  2. 英文缩写词一律小写(例:vcd)
  3. 只能由 [A~Z]、[a~z]、[0~9] 以及字符"-"、"_"、"." 组成参数名
  4. 不能以数字开头
  5. 不允许出现中文及拼音命名

术语表

术语 全称 中文 说明
RS RESTful Web Services WEB REST服务 REST 架构风格的Web服务
SecurityGroup Security Group 安全组 安全组制定安全策略
GMT Greenwich Mean Time 格林尼治标准时间 指位于英国伦敦郊区的皇家格林尼治天文台的标准时间
URIPath Uniform Resource Identifier Path 统一资源标识符的路径 用于标识某一互联网资源路径
RFC Request For Comments 一系列以编号排定的文件 几乎所有的互联网标准都收录在RFC文件之中

相关名词解释

  1. 字典升序排列
    如同在字典中排列单词一样排序,按照字母表递增顺序排列,参与比较的两个单词,若它们的第一个字母相同,就比较第二个字母,依此类推.
    例如: "scheme , java , basic , sql , php" 做字典升序排列后的结果是 "basic , java , php , scheme , sql".

  2. 幂等性
    接口在设计上可以被完全相同的URL重复调用多次,而最终得到的结果是一致的.

使用限制

请求端的当前时间与服务器的当前时间之差的绝对值不能大于10分钟,否则拒绝处理. 也就是说,请求端的时间不能比服务器时间快10分钟或慢10分钟,否则,服务器不受理.

请求结构

  1. 服务地址
    接口按照功能划分成了不同的功能模块,每个模块使用不同的域名或上下文访问,具体域名或上下文请参考各个接口的文档.

  2. 通信协议
    所有接口均采用HTTPS通信.

  3. 请求方法
    支持 [GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS].

  4. 字符编码
    在无特别说明情况下,均使用UTF-8编码.

  5. API请求结构

    名称 描述 备注
    API入口 API调用的RS服务的入口 https://<domain>/path/hi
    公共header 每个接口都包含的通用请求头 详见 公共参数
    公共参数 每个接口都包含的通用参数 详见 公共参数

公共参数

公共请求头(Common Request Headers)

名称 是否必选 描述
Authorization 用于验证请求合法性的认证信息
Accept 默认:"application/json",表示发送端(客户端)希望从服务端接受到的数据类型
Content-Length RFC2616中定义的HTTP请求内容长度(一般的http客户端工具都会自动带上这个请求头)
Date HTTP 1.1协议中规定的GMT时间,例如:Wed, 28 Mar 2018 09:09:19 GMT
Host 访问Host值(一般的http客户端工具都会自动带上这个请求头)

公共请求参数(Common Http Request Parameters)

名称 是否必选 类型 描述
nonce String 随机数,长度范围[8,36]
accessKeyId String accessKeyId(长度范围[8,36])和accessKeySecret(长度范围[6,36])从云端申请,accessKeyId 用来标识身份的,一个 accessKeyId 对应唯一的 accessKeySecret , 而 accessKeySecret 会用来生成签名 Signature
signatureMethod String 签名算法,目前支持HMACSHA256和HMACSHA1.默认采用:HMACSHA1验证签名
token String 临时证书所用的Token,需要结合临时密钥一起使用

服务端将从 QueryString 获得这些参数.

签名机制

用户在HTTP请求中增加Authorization的Header来包含签名(Signature)信息,表明这个消息已被签名,认证是否通过,服务端说了算.
Authorization的值如何得到,其计算规则如下:

Signature = base64(SignatureMethod(AccessKeySecret,
            HttpMethod + "\n"
            + Content-MD5 + "\n" //注意: 如果Content-MD5为""或null,后面就不能 + "\n" 了(去掉该行)
            + Accept + "\n" 
            + Date + "\n" 
            + BuildCustomHeaders + "\n" //注意: 如果BuildCustomHeaders为""或null,后面就不能 + "\n" 了(去掉该行)
            + URIPath + "\n"
            + BuildRequestParameters))
            
Authorization = "Basic " + Signature  
  • 1.SignatureMethod
    可选算法,HMACSHA256和HMACSHA1.

  • 2.AccessKeySecret
    服务端颁发给用户的密钥,不能泄露,只允许用户知道.

  • 3.HttpMethod
    请求方法,可选值[GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS].

  • 4.Content-MD5
    表示请求主体(Request Body)数据的MD5值,对消息内容(不包括头部)计算MD5值获得128bit(比特位)数字,对该数字进行Base64编码而得到,如果没有Body该值为""(空字符串).
    注意: Content-MD5如果为""(空字符串),末尾的"\n"必须去掉.
    假设,body内容为"好好学习,天天向上",计算其Content-MD5,以Java代码作为示例:

     // 待计算的内容
     String content = "好好学习,天天向上";
     byte[] input = content.getBytes(java.nio.charset.Charset.forName("utf-8"));
     
     // 1. 先计算出MD5加密的字节数组(16个字节)
     java.security.MessageDigest messageDigest = java.security.MessageDigest.getInstance("MD5");
     messageDigest.update(input);
     byte[] md5Bytes =messageDigest.digest();
     
     // 2. 再对这个字节数组进行Base64编码(而不是对长度为32的MD5字符串进行编码)。
     // Java 8+ 中自带的Base64工具(java.util.Base64)
     String str = java.util.Base64.getEncoder().encodeToString(md5Bytes);
     
     // 正确的值应该是 "BheE8OSZqgEXBcg6TjcrfQ=="
     
     // 断言
     assertThat(str, equalTo("BheE8OSZqgEXBcg6TjcrfQ=="));
    

    假设,给body的是一个文件,计算其Content-MD5:

     MessageDigest messageDigest = MessageDigest.getInstance("MD5");
     try (InputStream data = new URL("https://gitee.com/uploads/36/788636_xixifeng.com.png").openStream()) {
     	final byte[] buffer = new byte[1024];
     	int read = data.read(buffer, 0, 1024);
     
     	while (read > -1) {
     		messageDigest.update(buffer, 0, read);
     		read = data.read(buffer, 0, 1024);
     	}
     } catch (IOException e) {
     	throw e;
     }
     
     byte[] md5Bytes = messageDigest.digest();
     String str = java.util.Base64.getEncoder().encodeToString(md5Bytes);
     
     // 正确的值应该是 "5ErvegqUtShUeMfmowveow=="
     // 断言
     assertThat(str, equalTo("5ErvegqUtShUeMfmowveow=="));
    
  • 5.Accept
    可选值: application/json 或 application/xml.

  • 6.Date
    表示此次请求的当前时间,必须为GMT时间,如"Wed, 28 Mar 2018 09:09:19 GMT".
    以Java代码作为示例,怎么获得GMT时间:

     // RFC 822 时间格式
     String f = "EEE, dd MMM yyyy HH:mm:ss 'GMT'";
     java.text.SimpleDateFormat rfc822DateFormat = new java.text.SimpleDateFormat(f, java.util.Locale.US);
     rfc822DateFormat.setTimeZone(new java.util.SimpleTimeZone(0, "GMT"));
     
     // 将Date格式化成GMT时间格式的字符串
     java.util.Date date = new java.util.Date();
     String gmtStr = rfc822DateFormat.format(date);
     
     // 将GMT时间格式的字符串解析成Date对象
     java.util.Date d = rfc822DateFormat.parse(gmtStr);
    

    推荐使用 JAVA 8+ 的时间格式转换:

     // RFC 822 时间格式
     String f = "EEE, dd MMM yyyy HH:mm:ss 'GMT'";
     
     java.util.Locale l = java.util.Locale.US;
     java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern(f, l);
     java.time.LocalDateTime localDateTime = java.time.LocalDateTime.now(java.time.ZoneId.of("GMT"));
     // 将LocalDateTime格式化成GMT时间格式的字符串
     String gmt = localDateTime.format(formatter);
     
     // 将GMT时间格式的字符串解析成LocalDateTime对象
     LocalDateTime ldt = LocalDateTime.parse(gmt,formatter);
    
  • 7.BuildCustomHeaders
    所有以X-Custom-做为前缀的HTTP Header被称为自定义请求头.
    BuildCustomHeaders构建规则如下:
    7.1 将所有以X-Custom-为前缀的HTTP请求头的名字转换成小写,例如将"X-Custom-Meta-Author: FastQuery"转换成"x-custom-meta-author: FastQuery".
    7.2 将上一步得到的所有HTTP请求头做字典升序排列.
    7.3 请求头名称与内容之间用":"号隔开,并且需要清空分割符":"左右的空白.例如需要将"x-custom-meta-author : FastQuery"处理成"x-custom-meta-author:FastQuery".
    7.4 每个完整的请求头(头名称:内容),它们之间用"\n"进行分隔,最后拼接成BuildCustomHeaders.
    7.5 BuildCustomHeaders 允许为""(空字符串).
    举例: 若有一个请求头"X-CUSTOM-META-A:xx",那么,BuildCustomHeaders为"x-custom-meta-a:xx".
    若有2个请求头"X-CUSTOM-META-A:xx","X-CUSTOM-META-B:yy",那么,BuildCustomHeaders为"x-custom-meta-a:xx\nx-custom-meta-b:yy".

  • 8.URIPath
    URL端口与QueryString之间的地址,不含"?",在此称之为URIPath.举例:
    若有请求URL "https://<domain><默认80可以省略>/path/hi?action=myInfo",那么URIPath为"/path/hi".
    若有请求URL "https://<domain>:8080/path/hi?action=myInfo",那么URIPath为"/path/hi".
    若有请求URL "https://<domain>:8080/path/hi",那么URIPath为"/path/hi".
    若有请求URL "https://<domain>:8080/",那么URIPath为"/".
    若有请求URL "https://<domain>:8080?action=myInfo",那么URIPath为"".
    以Java代码为示例,获取URIPath:

     public class AuthorizationClientRequestFilter implements javax.ws.rs.client.ClientRequestFilter {
     	@Override
     	public void filter(javax.ws.rs.client.ClientRequestContext requestContext) {
     		java.net.URI uri = requestContext.getUri();
     		String uriPath = uri.getPath();
     		LOG.debug("uriPath:{}",uriPath);
     	}
     }
    
  • 9.BuildRequestParameters,构建规则如下:

    • 9.1. 对参数排序
      对所有请求参数按参数名做字典升序排列.
      实际上就是按照ASCII码从小至大排序,举例:

      字母 ASCII码对应的10进制
      A 65
      N 78
      R 82
      S 83
      T 84
      i 105
      l 108
      o 111

    则,做字典升序排列后的顺序是:A N R S T i l o

    • 9.2. 对参数编码
      对做字典升序排列之后的请求参数的值进行URL编码(参数名称严格按照上文提及到的命名规范,因此不用编码,因为它的组成字符都是RFC3986中明确说明的不用编码的字符).遵循RFC3986规定,编码规则如下:

      • 9.2.1. 参数值用UTF-8字符集;

      • 9.2.2. 对于字符 A~Z、a~z、0~9 以及字符"-"、"_"、"."、"~"不编码;

      • 9.2.3. 对其它字节做RFC3986中规定的百分号编码(Percent-encoding),即一个"%"后面跟着两个表示该字节值的十六进制字母,字母一律采用大写形式.其格式:%XY,其中 XY 是字符对应 ASCII 码的 16 进制表示.
        比如:
        英文的空格" ",采用UTF-8字符集,对应的字节是:0X22, 因此其URL编码为%22;
        英文字符的"*",采用UTF-8字符集,对应的字节是:0X2A, 因此其URL编码为%2A.

      • 9.2.4. 对于扩展的 UTF-8 字符,编码成 %AB%CD 的格式;
        最初十进制[0,127],共128个代码是ASCII. 然而,大于127以上ASCII后面跟着第二个字节.这两个字节一起定义一个字符.
        举例:

        字符 采用UTF-8字符集对应的字节
        α 0XCEB1
        β 0XCEB2
        γ 0XCEB3

        那么,将可以算出URL

        字符 URL代码
        α %CE%B1
        β %CE%B2
        γ %CE%B3
      • 9.2.5. 使用编码工具应该注意的事项
        该编码方式和一般采用的 application/x-www-form-urlencoded MIME 格式编码算法相似,但又有所不同.
        比如 Java 标准库中的 java.net.URLEncoder 实现了application/x-www-form-urlencoded MIME 格式编码, 就拿它来做比喻.
        URLEncoder.encode("~", "utf-8") 输出的结果是 %7E, RFC3986规定中不对~进行编码.
        URLEncoder.encode("*", "utf-8") 输出的结果是 *, RFC3986规定,没有说不对*这个符号进行编码.
        URLEncoder.encode(" ", "utf-8") 输出的结果是 +, RFC3986规定,编码结果采用%XY格式(XY: 16进制字面).
        目前发现这些差异性
        因此,使用JAVA的URLEncoder进行URL编码,不能满足我们所约定的编码规范,需要对它的处理结果稍作该进.
        将URLEncoder.encode处理的结果的+ 替换成%20,* 替换为 %2A %7E 替换回~.

         private static String specialUrlEncode(String value) throws UnsupportedEncodingException {
         	return URLEncoder.encode(<待编码字符串>, "utf-8").replace("+", "%20").replace("*", "%2A")
         	.replace("%7E", "~");
         }
        
    • 9.3. 拼接参数
      按字典升序排列后,参数值经过上个步骤编好码后, 参数名和参数值用=连接,参数与参数之间用&连接. 截至这里,BuildRequestParameters构建完成.

    • 9.4 举例:
      假设有6个参数:

       {
           "nonce" : "1aabcde-5268-3326-c845-56kljgwexe",
           "action" : "myInfo",
           "offset" : 1,
           "secretKeyId" : "BKJGW40598092JXMWNRF",
           "limit" : 15
       }
      

      步骤1: 对参数做字典升序排列

       {
           "action" : "myInfo",
           "limit" : 15,
           "nonce" : "1aabcde-5268-3326-c845-56kljgwexe",
           "offset" : 1,
           "secretKeyId" : "BKJGW40598092JXMWNRF"
       }
      

      步骤2: 遵循RFC3986对请求参数的值进行URL编码

      步骤3: 拼接参数
      action=myInfo&limit=15&nonce=1aabcde-5268-3326-c845-56kljgwexe&offset=1&secretKeyId=BKJGW40598092JXMWNRF 这就是BuildRequestParameters.

,根据如下假设,计算出Authorization.
设, AccessKeySecret 为: "KYA8A4-74E17B58B093";
设, 签名算法为:"HMACSHA1";
设, URIPath为:"/httpsign/userResorce/greet"
设,请求方法(Request Method)为: POST;
设,请求头为:

请求头名称
Authorization 待计算
Accept "application/json"
Date "Wed, 11 Apr 2018 06:03:43 GMT"
X-Custom-Meta-Author "FastQuery.HttpSign"
X-Custom-Meta-Description "HTTP authentication techniques."
X-Custom-Meta-Range "52363"

设,请求参数(Request Parameters)为:

参数名称
accessKeyId "AP084671DF-5F8C-41D2"
typeId 7
nonce "e6e03b6f-7de2-4d02-8e04-3ccbad143389"

设,请求Body为:"蚓无爪牙之利,筋骨之强,上食埃土,下饮黄泉,用心一也".

:
此解,意在阐述计算Authorization的过程,为了便于读者阅读,故,代码紧凑一看到底.

// 密钥
String accessKeySecret = "KYA8A4-74E17B58B093";

String uriPath = "/httpsign/userResorce/greet";
String httpMethod = "POST";
String accept = "application/json";
String date = "Wed, 11 Apr 2018 06:03:43 GMT";

// 构建请求头
java.util.TreeMap<String, String> headerTreeMap = new java.util.TreeMap<>();
headerTreeMap.put("X-Custom-Content-Range", "52363");
headerTreeMap.put("X-Custom-Meta-Author", "FastQuery.HttpSign");
headerTreeMap.put("X-Custom-Meta-Description", "HTTP authentication techniques.");
StringBuilder headersBuilder = new StringBuilder();
headerTreeMap.forEach((k, v) -> headersBuilder.append(k.toLowerCase()).append(':').append(v).append('\n'));
String headersStr = headersBuilder.toString();

// 构建请求参数
java.util.TreeMap<String, String> queryStringTreeMap = new java.util.TreeMap<>();
queryStringTreeMap.put("accessKeyId", "AP084671DF-5F8C-41D2");
queryStringTreeMap.put("typeId", "7");
queryStringTreeMap.put("nonce", "e6e03b6f-7de2-4d02-8e04-3ccbad143389");
StringBuilder requestParametersBuilder = new StringBuilder();
queryStringTreeMap.forEach((k, v) -> {
	try {
		requestParametersBuilder.append('&').append(k).append('=')
				.append(java.net.URLEncoder.encode(v, "utf-8").replace("+", "%20")
				.replace("*", "%2A").replace("%7E", "~"));
	} catch (java.io.UnsupportedEncodingException e) {
		throw new RuntimeException("URL编码出错", e);
	}
});
String requestParameters = requestParametersBuilder.substring(1);

// 计算Content-MD5的值
String requestBody = "蚓无爪牙之利,筋骨之强,上食埃土,下饮黄泉,用心一也";
byte[] input = requestBody.getBytes(java.nio.charset.Charset.forName("utf-8"));
java.security.MessageDigest messageDigest = java.security.MessageDigest.getInstance("MD5");
messageDigest.update(input);
byte[] md5Bytes = messageDigest.digest();
String contentMD5 = java.util.Base64.getEncoder().encodeToString(md5Bytes);

// 构建 stringToSign
StringBuilder sb = new StringBuilder();
sb.append(httpMethod).append('\n');
sb.append(contentMD5).append('\n');
sb.append(accept).append('\n');
sb.append(date).append('\n');
sb.append(headersStr);
sb.append(uriPath).append('\n');
sb.append(requestParameters);
String stringToSign = sb.toString();

// 计算出signature
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HMACSHA1");
mac.init(new javax.crypto.spec.SecretKeySpec(accessKeySecret.getBytes(
		java.nio.charset.Charset.forName("utf-8")), "HMACSHA1"));
byte[] signData = mac.doFinal(stringToSign.getBytes(java.nio.charset.Charset.forName("utf-8")));
String signature = java.util.Base64.getEncoder().encodeToString(signData);

// 得出authorization
String authorization = "Basic " + signature;
// 断言:authorization等于"Basic 3qo3tKAYM16Pr88Lpr5WPj2VJco="
org.junit.Assert.assertThat(authorization, 
		org.hamcrest.Matchers.equalTo("Basic 3qo3tKAYM16Pr88Lpr5WPj2VJco="));

截至这里, 解毕.

返回结果

正确返回结果

若 API 调用成功,错误码code为0,并且会返回结果数据.
示例如下:

{
	code:0,
	data:<结果数据>
}

错误返回结果

若 API 调用失败,错误码code不为 0,message字段会显示详细错误信息(成功返回没有该字段).
示例如下:

{
    "code": 40001,
    "message": "传递的请求头Authorization不符合规范."
}

标准公共错误码

根据RFC 2616定义,将如下状态码定义(Status Code Definitions)作为公共错误码:

错误码 描述
400 Bad Request
401 Unauthorized
402 Payment Required
403 Forbidden
404 Not Found
405 Method Not Allowed
406 Not Acceptable
407 Proxy Authentication Required
408 Request Timeout
409 Conflict
410 Gone
411 Length Required
412 Precondition Failed
413 Request Entity Too Large
414 Request-URI Too Long
415 Unsupported Media Type
416 Requested Range Not Satisfiable
417 Expectation Failed
428 Precondition Required
429 Too Many Requests
431 Request Header Fields Too Large
500 Internal Server Error
501 Not Implemented
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
505 HTTP Version Not Supported
511 Network Authentication Required

自定义公共错误码

自定义错误码由5位数字组成(除0表示成功外),前3位数表示对应的HTTP状态码(HTTP Status Code).目前自定义的错误前缀如下:

  • 400XX 请求错误
  • 403XX 被禁止
  • 404XX 找不到
  • 500XX 内部错误
  • 503XX 服务不可用
错误码 描述
40000 没有传递请求头Authorization.
40001 传递的请求头Authorization不符合规范.
40002 传递的请求头Accept不符合要求,要么是"application/json" 要么是 "application/xml".
40003 请求头Date必须传递,并且必须是HTTP 1.1协议中规定的GMT时间.
40004 请求端的时间不能比服务器时间快10分钟或慢10分钟.
40008 名称为nonce的请求参数没有传递.
40009 nonce的长度不能超过36且不能小与8.
40010 名称为accessKeyId的请求参数没有传递.
40011 根据accessKeyId没有找到对应的accessKeySecret.
40012 签名算法要么传递HMACSHA1或HMACSHA256,要不传递(默认:HMACSHA1).
40013 传递的token错误.
40014 token认证失败.
40015 有请求body,而没有传递请求头Content-MD5.
40016 计算请求body的MD5出错.
40017 计算Authorization出错.
40018 传过来的Authorization是错的.
40300 在10分钟内不能传递相同的随机码.
50300 服务不可用.

版权归习习风所有,请认准开源地址:
https://gitee.com/xixifeng.com/httpsign
https://github.com/xixifeng/httpsign
以获得最近更新.

推荐开源项目

反馈

https://gitee.com/xixifeng.com/httpsign/issues
秉承自由、开放、分享的精神,本项目每次升级之后,代码和文档手册都会在第一时间完全开源,以供大家查阅、批评、指正.笔者技术水平有限,bug或不周之处在所难免,所以,遇到有问题或更好的建议时,还请大家通过issue来向我们反馈.

捐助

Httpsign 采用 Apache 许可的开源项目, 使用完全自由, 免费. 如果 httpsign 对你有帮助, 可以用捐助来表示谢意.

项目点评 ( 4 )

你可以在登录后,发表评论

搜索帮助