目录
设计篇
api的设计主要包括:
- 返回的数据结构
- 错误码的设计
返回的数据结构
api数据结构格式
必有以下三个字段,字段名可以自己定制,字段可以根据情况添加:
status 业务状态码
message 提示信息
data 数据层
通用化api数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<?php class LoginController { public function index(){ //逻辑处理 // ... ... $data = [ 'status'=>10001, 'message'=>'这是一个消息', 'data'=>[] ]; $http_code = 200; return json($data,$http_code); } public function home(){ //逻辑处理 // ... ... $data = [ 'status'=>10001, 'message'=>'这是一个消息', 'data'=>[] ]; $http_code = 200; return json($data,$http_code); } } |
像上面的例子,每个方法都要定义定义api数据结构,然后再以json响应客户端,这样冗余太大,还有,万一api数据结构的字段需要改变,也不好维护修改。
解决:
在tp提供的公共文件common.php里封装一个api的数据格式,控制器的各个方法只需要调用这个方法传数据即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<?php /** * $status 业务状态码 * $message 信息 * $data 数据 * $httpCode http状态码 */ function show($status,$message,$date=[],$httpCode=200){ $data = [ "status"=>$status, "message"=>$message, "data"=>$data ]; return json($data,$httpCode); } |
使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<?php class LoginController { public function index(){ //逻辑处理 // ... ... return show(1,'ok',input(post.),201); } public function home(){ //逻辑处理 // ... ... return show(2,'ok',input(post.),201); } } |
错误码的设计
熟悉http状态码,并合理使用。
设计错误码,参考文章:错误码设计以及 Django 的异常统一处理 和 错误码系统的设计
另外一种观点:问题:求返回码规范设计规范?
其它
这里有一篇比较好的博文,可以阅读一下:用产品思维设计API
安全篇
api的安全主要有以下的任务:
- 请求参数防篡改
- 重放攻击
- 幂等性
- 身份识别
请求参数防篡改
采用https协议可以将传输的明文进行加密,但是黑客仍然可以截获传输的数据包,进一步伪造请求进行重放攻击。如果黑客使用特殊手段让请求方设备使用了伪造的证书进行通信,那么https加密的内容也将会被解密。
在API接口中我们除了使用https协议进行通信外,还需要有自己的一套加解密机制,对请求参数进行保护,防止被篡改。
过程如下:
- 客户端使用约定好的秘钥对传输参数进行加密,得到签名值signature,并且将签名值也放入请求参数中,发送请求给服务端
- 服务端接收客户端的请求,然后使用约定好的秘钥对请求的参数(除了signature以外)再次进行签名,得到签名值autograph。
- 服务端对比signature和autograph的值,如果对比一致,认定为合法请求。如果对比不一致,说明参数被篡改,认定为非法请求。
因为黑客不知道签名的秘钥,所以即使截取到请求数据,对请求参数进行篡改,但是却无法对参数进行签名,无法得到修改后参数的签名值signature。
签名的秘钥我们可以使用很多方案,可以采用对称加密或者非对称加密。
总的来说,签名有三要素,分别是:加密钥匙,加密的算法和签名算法。
签名算法
签名算法,可以根据自身的情况编写一套签名的流程算法,签名的流程算法是公开的,核心是对请求的参数进行签名,方式参数被修改。
这里有阿里与腾讯的签名流程,可以作为参考,更多的签名流程可以自己到大公司的开发文档中寻找。
实现签名流程的时候,需要注意:URL编码注意事项
以下是腾讯签名客户端代码:
腾讯签名服务器端:
阿里签名客户端:
阿里签名服务端:
请求携带签名的方式
请求携带签名的方式,一般有两种方式:
一种是将签名以参数的形式拼接在url后,形如:/api/v1/userInfo?sign=xxxxx×tamp=xxxx;
第二种是将签名以header头信息形式传递。
对于这两种方式各有利弊:
url拼接优点就是可以简单的携带验证参数,携带的参数一目了然,缺点就是url字符串的长度是有限的,超出了就不能携带其他参数了,也不美观,不够简洁,即使没携带其他参数,也要拖着一串校验字符串;
header头优点是检验参数都在header头里,url就会显得简洁,不用每次url都带有一串验证参数。缺点是,要配置跨域。
一般来说,校验参数是认证信息,用户提交的数据是业务信息,校验参数一般是固定的几个字段,而客户的数据却是变化的,所以为了区分它们的性质与方便管理,校验参数放header头。这只是一般来说,具体看情况,也可以使用url传递校验参数。aws同时支持这两种方式。
其它问题
Q:如果签名的秘钥也存在于客户端,那么反编译客户端就可以获取到秘钥,这样不安全。
A:对于安全性要求严格的api来说,这确实是一个问题,以我有限的见识,可以通过以下方法解决:
(一)可以使用非对称加密,比如RSA加密,客户端使用服务器的公钥加密,服务器使用私钥解密;
(二)使用加固软件,比如:360加固保,腾讯御安全等,对app进行加固,提高app被反编译的难度;
重放攻击
API的重放机制
我们在设计接口的时候,最怕一个接口被用户截取用于重放攻击。重放攻击是什么呢?就是把你的请求原封不动地再发送一次,两次…n次,一般正常的请求都会通过验证进入到正常逻辑中,如果这个正常逻辑是插入数据库操作,那么一旦插入数据库的语句写的不好,就有可能出现多条重复的数据。一旦是比较慢的查询操作,就可能导致数据库堵住等情况。
这里就有一种防重放的机制来做请求验证。
timestamp+nonce
我们常用的防止重放的机制是使用timestamp和nonce来做的重放机制。
timestamp用来表示请求的当前时间戳,这个时间戳当然要和服务器时间戳进行校正过的。我们预期正常请求带的timestamp参数会是不同的(预期是正常的人每秒至多只会做一个操作)。每个请求带的时间戳不能和当前时间超过一定规定的时间。比如60s。这样,这个请求即使被截取了,你也只能在60s内进行重放攻击。过期失效。
但是这样也是不够的,还有给攻击者60s的时间。所以我们就需要使用一个nonce,随机数。
nonce是由客户端根据足够随机的情况生成的,比如 md5(timestamp+rand(0, 1000)); 它就有一个要求,正常情况下,在短时间内(比如60s)连续生成两个相同nonce的情况几乎为0。
服务端
服务端第一次在接收到这个nonce的时候做下面行为:
- 去redis中查找是否有key为nonce:{nonce}的string
- 如果没有,则创建这个key,把这个key失效的时间和验证timestamp失效的时间一致,比如是60s。
- 如果有,说明这个key在60s内已经被使用了,那么这个请求就可以判断为重放请求。
示例
那么比如,下面这个请求:
http://a.com?uid=123×tamp=1480556543&nonce=43f34f33&sign=80b886d71449cb33355d017893720666
这个请求中国的uid是我们真正需要传递的有意义的参数
timestamp,nonce,sign都是为了签名和防重放使用。
timestamp是发送接口的时间,nonce是随机串,sign是对uid,timestamp,nonce(对于一些rest风格的api,我建议也把url放入sign签名)。签名的方法可以是md5({秘要}key1=val1&key2=val2&key3=val3…)
服务端接到这个请求:
- 先验证sign签名是否合理,证明请求参数没有被中途篡改
- 再验证timestamp是否过期,证明请求是在最近60s被发出的
- 最后验证nonce是否已经有了,证明这个请求不是60s内的重放请求
幂等性
幂等的数学概念
幂等是源于一种数学概念。其主要有两个定义
如果在一元运算中,x 为某集合中的任意数,如果满足 f(x) = f(f(x)) ,那么该 f 运算具有幂等性,比如绝对值运算 abs(a) = abs(abs(a)) 就是幂等性函数。
如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性,比如求大值函数 max(x,x) = x 就是幂等性函数。
幂等性在开发中的概念
在数学中幂等的概念或许比较抽象,但是在开发中幂等性是极为重要的。简单来说,对于同一个系统,在同样条件下,一次请求和重复多次请求对资源的影响是一致的,就称该操作为幂等的。比如说如果有一个接口是幂等的,当传入相同条件时,其效果必须是相同的。
特别是对于现在分布式系统下的 RPC 或者 Restful 接口互相调用的情况下,很容易出现由于网络错误等等各种原因导致调用的时候出现异常而需要重试,这时候就必须保证接口的幂等性,否则重试的结果将与第一次调用的结果不同,如果有个接口的调用链 A->B->C->D->E,在 D->E 这一步发生异常重试后返回了错误的结果,A,B,C也会受到影响,这将会是灾难性的。
在生活中常见的一些要求幂等性的例子:
- 博客系统同一个用户对同一个文章点赞,即使这人单身30年手速疯狂按点赞,那么实际上也只能给这个文章 +1 赞
- 在微信支付的时候,一笔订单应当只能扣一次钱,那么无论是网络问题或者bug等而重新付款,都只应该扣一次钱
幂等性与并发安全
在查阅网络资料的时候,我看到许多文章把幂等性和并发安全的问题有些混淆了。幂等性是系统接口对外的一种承诺,而不是实现,承诺多次相同的操作的结果都会是一样的。而并发安全问题是当多个线程同时对同一个资源操作时,由于操作顺序等原因导致结果不正确。
这两个实际上是完全独立的两个问题,比如说同一笔订单即使你不停的提交支付,如果扣除了多次钱,就说明该操作不幂等。而有多笔订单同时进行支付,最后扣除金额不是这多笔金额的总和,那么说明该操作有并发安全问题。所以幂等性和并发安全是完全两个维度的问题,要分开讨论解决。
我在一些讨论幂等性的文章中看到中给出的解决方案为‘悲观锁’和‘乐观锁’,这两个方案可以很好的解决并发问题,但是却不应该是幂等性问题的解决方案,特别是悲观锁是用于防止多个线程同时修改一个资源的。倒是乐观锁的版本号机制可以勉强以 token
或者状态标识
作为版本号来实现幂等性(下文解释token
和状态标识
),勉强说的过去。
所以说幂等性与并发安全是不同的,在本文就只讨论幂等性的问题,对于并发安全问题不做讨论
Http 协议与幂等性
如果把操作按照功能分类,那就是增删改查四种,在 http 协议中则表现为 Get、Post、Put、Delete 四种。
查询操作 (Get)
Get 方法用于获取资源,不应当对系统资源进行改变,所以是幂等的。注意这里的幂等提现在对系统资源的改变,而不是返回数据的结果,即使返回结果不相同但是该操作本身没有副作用,所以幂等。
删除操作 (Delete)
Delete 方法用于删除资源,虽然改变了系统资源,但是第一次和第N次删除操作对系统的作用是相同的,所以是幂等的。比如要删除一个 id 为 1234 的资源,可能第一次调用时会删除,而后面所有调用的时候由于系统中已经没有这个 id 的资源了,但是第一次操作和后面的操作对系统的作用是相同的,所以这也是幂等的,调用者可以多次调用这个接口不必担心错误。
修改操作 (Put)
修改操作有可能是幂等的也可能不幂等。如果修改的资源为固定的,比如说把账户中金额改为 1000 元,无论调用几次都是幂等的。假如资源不固定,比如账户中金额减少50元,调用一次和调用多次的结果肯定不一样,这时候就不幂等了。在修改操作中想要幂等在下文中讨论。
新增操作 (Post)
Post 新增操作天生就不是一个幂等操作,其在 http 协议的定义如下:
The POST method is used to request that the origin server accept the entity enclosed in the request as a new subordinate of the resource identified by the Request-URI in the Request-Line.(https://www.w3.org/Protocols/…)
在其定义中表明了 Post 请求用于创建新的资源,这意味着每次调用都会在系统中产生新的资源,所以该操作注定不是幂等操作。这时候想要幂等就必须在业务中实现,方案在下文会讨论。
实现幂等性的方案
在上面提到的幂等性还是比较理论,下面结合一些常见的实际业务场景来讨论幂等性设计方案。
去重表
利用数据库的特性来实现幂等。通常是在表上构建一个唯一索引,那么只要某一个数据构建完毕,后面再次操作也无法成功写入。
常见的业务就是博客系统点赞功能,一个用户对一个博文点赞后,就把用户 id 与 博文 id 绑定,后续该用户点赞同一个博文就无法插入了。或是在金融系统中,给用户创建金融账户,一个用户肯定不能有多个账户,就在账户表中增加唯一索引来存储用户 id,这样即使重复操作用户也只能拥有一个账户。
状态标识
状态标识是很常见的幂等设计方式,主要思路就是通过状态标识的变更,保证业务中每个流程只会在对应的状态下执行,如果标识已经进入下一个状态,这时候来了上一个状态的操作就不允许变更状态,保证了业务的幂等性。
状态标识经常用在业务流程较长,修改数据较多的场景里。最经典的例子就是订单系统,假如一个订单要经历 创建订单 -> 订单支付取消 -> 账户计算 -> 通知商户 这四个步骤。那么就有可能一笔订单支付完成后去账户里扣除对应的余额,消耗对应的优惠卷。但是由于网络等原因返回了错误信息,这时候就会重试再次去进行账户计算步骤造成数据错误。
所以为了保证整个订单流程的幂等性,可以在订单信息中增加一个状态标识,一旦完成了一个步骤就修改对应的状态标识。比如订单支付成功后,就把订单标识为修改为支付成功,现在再次调用订单支付或者取消接口,会先判断订单状态标识,如果是已经支付过或者取消订单,就不会再次支付了。
Token 机制
Token 机制应该是适用范围最广泛的一种幂等设计方案了,具体实现方式也很多样化。但是核心思想就是每次操作都生成一个唯一 Token 凭证,服务器通过这个唯一凭证保证同样的操作不会被执行两次。这个 Token 除了字面形式上的唯一字符串,也可以是多个标志的组合(比如上面提到的状态标志),甚至可以是时间段标识等等。
举个例子,在论坛中发布一个新帖子,这是一个典型的 Post 新增操作,要怎样防止用户多次点击提交导致产生多个同样的帖子呢。可以让用户提交的时候带一个唯一 Token,服务器只要判断该 Token 存在了就不允许提交,便能保证幂等性。
上面这个例子比较容易理解,但是业务比较简单。由于 Token 机制适用较广,所以其设计中要注意的要求也会根据业务不同而不同。
Token 在何时生成,怎么生成?这是该机制的核心,就拿上面论坛系统来说,如果你在用户提交帖子的时候才生成 Token,那用户每次点提交都会生成新的 Token 然后都能提交成功,就不是幂等的了。必须在用户提交内容之前,比如进入编辑页面的时候生成 Token,用户在提交的时候内容带着 Token 一起提交,对于同一个页面无论用户提交多少次,就至多能成功一次。所以 Token 生成的时机必须保证能够使该操作具多次执行都是相同的效果才行。使用 Token 机制就要求开发者对业务流程有较好的理解。
结语
幂等性是开发当中很常见也很重要的一个需求。尤其是金融、支付等行业对其要求更加严格,既要有好的性能也要有严格的幂等性。除了对其概念的掌握,理解自身业务需求更是实现幂等功能的要点,必须处理好每一个结点细节,一旦某个地方没有设计完善,最后的结果可能仍旧达不到要求。
这里有阿里的如何保证幂等性的文章,可以当做参考。
身份识别
Session与Token
名词解释
- session 会话,维护用户状态。会话中关联了用户信息。
- token 令牌,用于签权。
很多人纠结于token是什么,session又是什么,但我认为,这就是两个单词,两个概念而已,就看你怎么去实现并使用了。
Session
session顾名思义就是会话,维护了用户的状态就是会话,这是为了解决http是stateless的问题发明的东西。
通常的用法是:
用户使用账号+密码/手机号+验证码登录
后台验证通过后会在Redis会话表里产生一条记录,这条记录就可以称为会话!记录里面有一个随机的唯一值和用户的信息,这个随机的唯一值会返回给客户端保存,以后的接口通过这个唯一值进行鉴权,这个唯一值可以称为sessionId。
后台接口带上sessionId,服务器拿到后去表里校验是否存在且有效,有效则鉴权通过,无效则报错
Token
Token翻译过来就是令牌,何为令牌,就是一个证明自己的证明。那拿刚才的sessionId来说,它又何常不是一个证明呢?它证明了我之前是登录过的,并且是有效的用户,所以我认为,刚才的sessionId也可以称为token,也有人认为那就是token。
看到这里估计又有人吐槽了,token的状态是保存在客户端的,session是服务端在管理状态。这么理解的人是认为,session管理需要在后台存表,管理这个状态,而token后台不需要保存,因为token里自带了信息,比如用户基本信息、过期时间等,后台每次收到直接解密校验即可,所以说token的状态是保存在客户端的。这么理解不是不可以,很多人也是这么用的,但有明确规定token就一定要带信息吗?别和我说JWT,JWT只是token的一种实现而已,不代表所有token都必须要有用户信息。我不反对有人这么理解,因为token和session本身就是概念,并不是规定,有不同的理解就可以有不同的实现。
token的多平台身份认证架构设计
1、概述
在存在账号体系的信息系统中,对身份的鉴定是非常重要的事情。
随着移动互联网时代到来,客户端的类型越来越多, 逐渐出现了一个服务器,N个客户端的格局。
不同的客户端产生了不同的用户使用场景,这些场景有不同的环境安全威胁,不同的会话生存周期,不同的用户权限控制体系,不同级别的接口调用方式。
综上所述,它们的身份认证方式也存在一定的区别。
2、使用场景
下面是一些在IT服务常见的一些使用场景:
- 用户在web浏览器端登录系统,使用系统服务;
- 用户在手机端(Android/iOS)登录系统,使用系统服务;
- 用户使用开放接口登录系统,调用系统服务;
- 用户在PC处理登录状态时通过手机扫码授权手机登录(使用得比较少);
- 用户在手机处理登录状态进通过手机扫码授权PC进行登录(比较常见)
通过对场景的细分,得到如下不同的认证token类别:
- 原始账号密码类别
- 用户名和密码API应用ID/KEY会话ID类别
- 浏览器端token移动端tokenAPI应用token接口调用类别
- 接口访问token身份授权类别
- PC和移动端相互授权的token
3、token的类别
不同场景的token进行如下几个维度的对比:
一、天然属性对比:
(一) 使用成本
本认证方式在使用的时候,造成的不便性。比如:
账号密码需要用户打开页面然后逐个键入;
二维码需要用户掏出手机进行扫码操作
(二) 变化成本
本认证方式,token发生变化时,用户需要做出的相应更改的成本。
用户名和密码发生变化时,用户需要额外记忆和重新键入新密码;
API应用ID/KEY发生变化时,第三方应用需要重新在代码中修改并部署;
授权二维码发生变化时,需要用户重新打开手机应用进行扫码;
(三) 环境风险
被偷窥的风险被抓包的风险被伪造的风险
二、可调控属性对比:
- 使用频率
- 在网路中传送的频率有效时间
- 此token从创建到终结的生存时间
最终的目标:安全和影响。
安全和隐私性主要体现在:
- token 不容易被窃取和盗用(通过对传送频率控制);
- token 即使被窃取,产生的影响也是可控的(通过对有效时间控制);
关于隐私及隐私破坏后的后果,有如下的基本结论:
曝光频率高的容易被截获生存周期长的在被截获后产生的影响更严重和深远
遵守如下原则:
- 变化成本高的token不要轻易变化;
- 不轻易变化的token要减少曝光频率(网络传输次数);
- 曝光频率高的token的生存周期要尽量短。
将各类token的固有特点及可控属性进行调控后,对每个指标进行量化评分(1~5分),我们可以得到如下的对比表:
备注:
user_name/passwd和app_id/app_key是等价的效果
4、token的层级关系
参考上一节的对比表,可以很容易对这些不同用途的token进行分层,主要可以分为4层:
- 密码层
最传统的用户和系统之间约定的数字身份认证方式 - 会话层
用户登录后的会话生命周期的会话认证 - 调用层
用户在会话期间对应用程序接口的调用认证 - 应用层
用户获取了接口访问调用权限后的一些场景或者身份认证应用
token的分层图如下:
在一个多客户端的信息系统里面,这些token的产生及应用的内在联系如下:
用户输入用户名和用户口令进行一次性认证,在不同的终端里面生成拥有不同生命周期的会话token,客户端会话token从服务端交换生命周期短,但曝光频繁的接口访问token,会话token可以生成和刷新延长access_token的生存时间,access_token可以生成生存周期最短的用于授权的二维码的token。
使用如上的架构有如下的好处:
良好的统一性。可以解决不同平台上认证token的生存周期的归一化问题良好的解耦性。核心接口调用服务器的认证 access_token 可以完成独立的实现和部署良好的层次性。不同平台的可以有完全不同的用户权限控制系统,这个控制可以在会话层中各平台解决掉
4.1、账号密码
广义的账号/密码有如下的呈现方式:
传统的注册用户名和密码应用程序的app_id/app_key
它们的特点如下:
- 会有特别的意义
比如:用户自己为了方便记忆,会设置有一定含义的账号和密码。 - 不常修改
账号密码对用户有特别含义,一般没有特殊情况不会愿意修改。 而app_id/app_key则会写在应用程序中,修改会意味着重新发布上线的成本。 - 一旦泄露影响深远
正因为不常修改,只要泄露了基本相当于用户的网络身份被泄露,而且只要没被察觉这种身份盗用就会一直存在。
所以在认证系统中应该尽量减少传输的机会,避免泄露。
4.2、客户端会话token
功能:
充当着session的角色,不同的客户端有不同的生命周期。
使用步骤:
用户使用账号密码,换取会话token
不同的平台的token有不同的特点。
- Web平台生存周期短主要原因:
环境安全性
由于web登录环境一般很可能是公共环境,被他人盗取的风险值较大输入便捷性
在PC上使用键盘输入会比较便捷 - 移动端生存周期长
主要原因:环境安全性
移动端平台是个人用户极其私密的平台,它人接触的机会不大输入便捷性
在移动端上使用手指在小屏幕上触摸输入体验差,输入成本高
4.3、access_token
功能:
服务端应用程序api接口访问和调用的凭证。
使用步骤:
使用具有较长生命周期的会话token来换取此接口访问token。
其曝光频率直接和接口调用频率有关,属于高频使用的凭证。 为了照顾到隐私性,尽量减少其生命周期,即使被截取了,也不至于产生严重的后果。
注意:在客户端token之下还加上一个access_token, 主要是为了让具有不同生命周期的客户端token最后在调用api的时候, 能够具有统一的认证方式。
4.4、pam_token
功能:
由已经登录和认证的PC端生成的二维码的原始串号(Pc Auth Mobile)。
主要步骤如下:
PC上用户已经完成认证,登录了系统PC端生成一组和此用户相关联的pam_tokenPC端将此pam_token的使用链接生成二维码移动端扫码后,请求服务器,并和用户信息关联移动端获取refresh_token(长时效的会话)根据 refresh_token 获取 access_token完成正常的接口调用工作
备注:
生存周期为2分钟,2分钟后过期删除没有被使用时,每1分钟变一次被使用后,立刻删除掉此种认证模式一般不会被使用到
4.5、map_token
功能:
由已经登录的移动app来扫码认证PC端系统,并完成PC端系统的登录(Mobile Auth Pc)。
主要步骤:
移动端完成用户身份的认证登录app未登录的PC生成匿名的map_token移动端扫码后在db中生成map_token和用户关联(完成签名)db同时针对此用户生成web_tokenPC端一直以map_token为参数查找此命名用户的web_tokenPC端根据web_token去获取access_token后续正常的调用接口调用工作
备注:
生存周期为2分钟,2分钟后过期删除没有被使用时,每1分钟变一次被使用后,立刻删除掉
5、小结与展望
本文所设计的基于token的身份认证系统,主要解决了如下的问题:
token的分类问题token的隐私性参数设置问题token的使用场景问题不同生命周期的token分层转化关系
本文中提到的设计方法,在应用层中可以适用于且不限于如下场景中:
用户登录有时效的优惠券发放有时效的邀请码发放有时效的二维码授权具有时效手机/邮件验证码多个不同平台调用同一套API接口多个平台使用同一个身份认证中心。
这里还有一篇博文:API接口TOKEN设计有一定借鉴作用。
token的解决方案–JWT
关于jwt的原理,可以看一下这篇博文:认识JWT。
API版本解决方案
http://tpswoole.net/api/v1/banner/1,以v1这个位置,作为访问不同版本api的控制;
如果新增的v2版本的api需要在v1的基础上进行大修,只要复制v1整个文件夹命名为v2即可,也不会影响旧版本v1api的使用。
APP本地时间和服务器时间一致性解决方案
客户端的时间是不可信的,服务器可以提供一个校时的接口,客户端的时间以请求服务器的校时接口为准。
异常管理与日志管理
异常,需要自定义异常的输出格式,对于api来说,异常呈现给客户端永远都是json格式的数据,不会是详细的debug信息;但是对于开发人员来说,则刚好相反,开发人员需要详细的debug信息。所以,异常要做客户端异常和系统服务异常两大部分进行设计。
同理,日志的记录也要做客户端日志和系统服务日志两大部分进行设计。对于客户端的错误日志,不需要记录(没有什么价值,还会占用大量磁盘空间),只需对系统错误日志记录即可。
thinkPHP 5.1 异常的处理:
当内部异常的时候,屏幕会输出tp的异常页面,并不是json数据。这就需要改造tp的异常页面渲染。自定义异常处理类,继承tp的异常处理类,重写render()方法。还有一点就是,在调试开发模式下,是需要异常页面的,可以根据配置文件的app_bug作为开关,判断当前的异常是以json格式输出,还是以原来的错误页面输出。
api的开发
前期准备
功能分析
拿到功能就写完了,最后发现不是需要的功能点。拿到需求要对这些功能或需求进行(产品工程师,开发工程师,设计工程师一起)讨论、评审,讨论完后看这个需求是否合理,然后做功能的拆解,最后才开发。
本案例,拆解成后台和APP两大部分,各个部分还可以继续拆解成更具体的功能点。
表的总体设计
根据拆分的功能画出数据表ER关系图,确定需要哪些表,表与表之间的关系是怎样的,暂时不需要设计到表的字段。
以下是一些需要注意的地方:
后台
tp的视图模板的搭建。此处省略,需要注意模板的继承与复用。
tp5配置数据库的连接配置
后台用户表设计
表的设计
注意:
表字段名起名的规范、数据类型的正确选择等等
索引的合理设计,提高查询效率
这些可以百度表设计的优化,此处省略。
验证码
查看thinkPHP源码,会发现验证码已经有一个路由,url直接访问即可调取。
tp提供的request可以直接获取IP地址:request()->ip()
model调用助手:model(model名)->select();
对模型操作要接异常,该接异常就接异常,做好代码的规范:
1 2 3 4 5 |
try{ model('user')->save($data,['id'=>1]); }catch(\Exception $e){ $this->error($e->getMessage()) } |
标识性的东西,比如状态码、固定的字符串,最好放到一个地方,集中管理,用的时候直接引用就好。
登录权限的控制
(一)后台的所有页面应该登录后才能访问,而不是直接输入某个页面地址就可以访问了,没有登录的,则跳转到登录页面。
(二)已登录的用户,当访问登录页面,应该直接转跳到后台首页。
解决:
新建一个公共控制器,这个公共控制器继承tp的controller,在里面编写登录权限校验以及动作的代码,如果想使用构造方法,可以重写controller的initialize方法。所有控制器都继承这个公共控制器,即可解决问题。
还有一个想法,就是通过tp的中间件或钩子进行登录权限的控制逻辑。
在后台不挂断地运行命令
使用方法:nohup和&后台运行,进程查看及终止
nohup:
用途:不挂断地运行命令。
&:
用途:在后台运行
示例:nohup php -S localhost:8181 route.php &
RSA加密与解密
rsa 加密与解密的博文:php RSA加密传输代码示例
php加密与解密方法以及PHP实现13位时间戳
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
<?php /**字符串日期转换为13位时间串,支持毫秒日期 * 例如:2019-04-26 10:01:30.123 * @param $timeStr 日期字符串 * @return float 13位时间戳 */ function str2JavaTime($timeStr=''){ //检查参数 符合 字符串 非空 条件 if (!is_string($timeStr) || empty($timeStr)){ return $timeStr; } //正则查找秒后面的.数字 $pattern = '/(?<=\.)[0-9]+$/'; $ms = '000'; if (preg_match($pattern,$timeStr,$match)){ $ms = $match[0]; //确保只取三位 $ms = substr($ms,0,3); $str_len = strlen($ms); //字符长度小于3,少多少补多少零 if ($str_len < 3){ $ms = $ms.str_repeat(0,(3-$str_len)); } } //返回13位时间戳 return floatval(strtotime($timeStr).$ms); } /** * 获取当前13位时间戳 * @return float 13位时间戳 */ function javaTime(){ return floatval(explode('.',strval(microtime(true)*1000))[0]); } function ArgumentCheck($data,...$args){ } /** AES 加密 * @param $data 待加密数据 * @param string $method 加密方法 * @param string $secret_key 加密秘钥 * @param string $options 加密选项 * @param int $iv 非NULL的初始化向量 * @return string */ function AES_encrypt($data, $method='', $secret_key='', $options='', $iv=0){ $method = empty($method)?config('openssl.aes.method'):$method; $secret_key = empty($secret_key)?config('openssl.aes.secret_key'):$secret_key; $options = empty($options)?config('openssl.aes.options'):$options; $iv = empty($iv)?config('openssl.aes.iv'):$iv; $result = ''; if (in_array($method, openssl_get_cipher_methods())) { $result = openssl_encrypt($data, $method, $secret_key, $options, hexToStr($iv)); } return $result; } /** AES 解密 * @param $data AES加密后的数据 * @param string $method 解密方法,应该与解密的一致 * @param string $secret_key 解密秘钥,应该与解密的一致 * @param string $options 解密选项,应该与解密的一致 * @param int $iv 非NULL的初始化向量 * @return string */ function AES_decrypt($data, $method='', $secret_key='', $options='', $iv=0){ $method = empty($method)?config('openssl.aes.method'):$method; $secret_key = empty($secret_key)?config('openssl.aes.secret_key'):$secret_key; $options = empty($options)?config('openssl.aes.options'):$options; $iv = empty($iv)?config('openssl.aes.iv'):$iv; $result = ''; if (in_array($method, openssl_get_cipher_methods())) { $result = openssl_decrypt($data, $method, $secret_key, $options, hexToStr($iv)); } return $result; } /** DES 加密 * @param $data 待加密的字数据 * @param string $method 加密的方法 * @param string $secret_key 加密的秘钥 * @param string $options 加密的选项 * @return string */ function Des_encrypt($data, $method='', $secret_key='', $options =''){ $secret_key = empty($secret_key)?config('openssl.des.secret_key'):$secret_key; $method = empty($method)?config('openssl.des.method'):$method; $options = empty($options)?config('openssl.des.options'):$options; return openssl_encrypt($data, $method, $secret_key, $options); } /** DES 解密 * @param $data DES加密后的数据 * @param string $method 解密方法,应该与解密的一致 * @param string $secret_key 解密秘钥,应该与解密的一致 * @param string $options 解密选项,应该与解密的一致 * @return string */ function Des_decrypt($data, $method='', $secret_key='', $options ='') { $secret_key = empty($secret_key)?config('openssl.des.secret_key'):$secret_key; $method = empty($method)?config('openssl.des.method'):$method; $options = empty($options)?config('openssl.des.options'):$options; return openssl_decrypt($data, $method, $secret_key, $options); } /** RSA 加密 * @param $data RSA待加密的数据 * @param bool $base64 是否使用base64处理,默认使用 * @return string * @throws Exception */ function RSA_encrypt($data,$base64=true){ //读取公钥文件 try{ $public_key = file_get_contents(dirname(__DIR__).config('openssl.rsa.file_path').config('openssl.rsa.filename_public_key').".pem"); }catch (\Exception $e){ exception('公钥找不到'); } $padding = empty(config('openssl.rsa.padding'))?OPENSSL_PKCS1_PADDING:config('openssl.rsa.padding'); $pu_key = openssl_pkey_get_public($public_key); //解决:密钥是1024bit长的(openssl genrsa -out rsa_private_key.pem 1024)那么明文长度最多只能就是128-11=117字节。 //如果超出,那么这些openssl加解密函数会返回false。 $result = ''; //把明文分段 $split = str_split($data, 100);// 1024bit && OPENSSL_PKCS1_PADDING 不大于117即可 //一段段的加密 foreach ($split as $part) { $isOkay = openssl_public_encrypt($part, $encrypted, $pu_key, $padding);//公钥加密 //遇到某一段加密失败,立即停止并返回false if(!$isOkay){ return false; } //每段加密结果都拼接起来,最终成为一个完整的加密 if ($base64){ //返回加密后的数据,rsa加密后密文是16进制的,并非都是可以打印的字符。通常的做法是密文出来以后做一个 base64 编码。 $result .= base64_encode($encrypted); }else{ $result .= $encrypted; } } return $result; } /** RSA 解密 * @param $data RSA已加密的数据 * @param bool $base64 是否使用base64处理,默认使用 * @return mixed * @throws Exception */ function RSA_decrypt($data,$base64=true){ //读取私钥文件 try{ $private_key = file_get_contents(dirname(__DIR__).config('openssl.rsa.file_path').config('openssl.rsa.filename_private_key').".pem"); }catch (\Exception $e){ exception('秘钥找不到'); } $padding = empty(config('openssl.rsa.padding'))?OPENSSL_PKCS1_PADDING:config('openssl.rsa.padding'); $pi_key = openssl_pkey_get_private($private_key); //解决:密钥是1024bit长的(openssl genrsa -out rsa_private_key.pem 1024)那么明文长度最多只能就是128-11=117字节。 //如果超出,那么这些openssl加解密函数会返回false。 $result = ''; //把密文分段 $split = str_split($data, 172);// 1024bit 固定172 //一段段的解密 foreach ($split as $part) { if ($base64){ // base64在这里使用,因为172字节是一组,是encode来的 $isOkay = openssl_private_decrypt(base64_decode($part), $decrypted, $pi_key, $padding); }else{ $isOkay = openssl_private_decrypt($part, $decrypted, $pi_key, $padding); } //遇到某一段解密失败,停止解密并返回false if(!$isOkay){ return false; } //把每段解密拼接起来,组成完整的明文 $result .= $decrypted; } //返回解密后的数据 return $result; } function test(){ return dirname(__DIR__); } /** 十六进制转换成字符串 * @param $hex 十六进制 * @return string */ function hexToStr($hex) { $string=''; for ($i=0; $i < strlen($hex)-1; $i+=2){ $string .= chr(hexdec($hex[$i].$hex[$i+1])); } return $string; } |
新建的加密解密配置文件:openssl.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<?php // +---------------------------------------------------------------------- // | openssl 加密设置 // +---------------------------------------------------------------------- return [ 'aes' => [ // 加解密方法,可通过openssl_get_cipher_methods()获得 'method' => 'AES-256-CBC', // 加解密的密钥 'secret_key' => 'benz', // 加解密的向量,有些方法需要设置比如CBC(32个字符) 'iv' => '00000000000000000000000000000000', // 是以下标记的按位或: OPENSSL_RAW_DATA 、 OPENSSL_ZERO_PADDING 'options' => 0 ], 'des' => [ 'method' => 'des-ecb', 'secret_key' => 'benz', 'options' => 'OPENSSL_RAW_DATA' ], 'rsa' => [ //首先要生成一对公钥私钥。前提是linux机器上安装了openssl命令。 //openssl genrsa -out rsa_private_key.pem 1024 //openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem //生成文件的路径,/:表示项目的根目录 'file_path' => '/', //私钥文件名 'filename_private_key' => 'rsa_private_key', //公钥文件名 'filename_public_key' => 'rsa_public_key', //RSA加密解密的填充方式,不同编程语言之间交互,需要注意这个。 // OPENSSL_PKCS1_PADDING, OPENSSL_SSLV23_PADDING, OPENSSL_PKCS1_OAEP_PADDING, OPENSSL_NO_PADDING 'padding' => OPENSSL_PKCS1_PADDING, ], ]; |
注意:在java中比较常用到String的getBytes()这个方法,但是在Windows和Linux的结果会不一样。原因是因为在Windows中文操作系统上getBytes()使用的默认编码是GBK,英文操作系统没有测试过,但是在Linux操作系统上getBytes()使用的默认编码是UTF-8,GBK一个汉字占2个字节,UTF-8是3个,所以结果就不一致了,可以通过指定编码方法解决。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
<?php ** * byte数组与字符串转化类 */ class Bytes { /**转换一个String字符串为byte数组 * windows与Linux获取的中文字节数组是有差异的. * php字符串默认是utf-8,对标java的String.getBytes("utf-8"); * @param string $str 需要转换的字符串 * @return array */ public static function getBytes($str='') { $len = strlen($str); $bytes = array(); for($i=0;$i<$len;$i++) { if(ord($str[$i]) >= 128){ $byte = ord($str[$i]) - 256; }else{ $byte = ord($str[$i]); } $bytes[] = $byte ; } return $bytes; } /** * 将字节数组转化为String类型的数据 * @param $bytes 字节数组 * @param $str 目标字符串 * @return 一个String类型的数据 */ public static function toStr($bytes) { $str = ''; foreach($bytes as $ch) { $str .= chr($ch); } return $str; } /** * 转换一个int为byte数组 * @param $byt 目标byte数组 * @param $val 需要转换的字符串 * @author Zikie */ public static function integerToBytes($val) { $byt = array(); $byt[0] = ($val & 0xff); $byt[1] = ($val >> 8 & 0xff); $byt[2] = ($val >> 16 & 0xff); $byt[3] = ($val >> 24 & 0xff); return $byt; } /** * 从字节数组中指定的位置读取一个Integer类型的数据 * @param $bytes 字节数组 * @param $position 指定的开始位置 * @return 一个Integer类型的数据 */ public static function bytesToInteger($bytes, $position) { $val = 0; $val = $bytes[$position + 3] & 0xff; $val <<= 8; $val |= $bytes[$position + 2] & 0xff; $val <<= 8; $val |= $bytes[$position + 1] & 0xff; $val <<= 8; $val |= $bytes[$position] & 0xff; return $val; } /** * 转换一个shor字符串为byte数组 * @param $byt 目标byte数组 * @param $val 需要转换的字符串 * @author Zikie */ public static function shortToBytes($val) { $byt = array(); $byt[0] = ($val & 0xff); $byt[1] = ($val >> 8 & 0xff); return $byt; } /** * 从字节数组中指定的位置读取一个Short类型的数据。 * @param $bytes 字节数组 * @param $position 指定的开始位置 * @return 一个Short类型的数据 */ public static function bytesToShort($bytes, $position) { $val = 0; $val = $bytes[$position + 1] & 0xFF; $val = $val << 8; $val |= $bytes[$position] & 0xFF; return $val; } } |