Header 头
header 头推荐加上字段:
Authorization
用来存放 access_token, 通过该 token 对用户进行认证可以理解其作用等同于 cookie 中的 session_id, 有该令牌就默认用户已经认证登陆过
Signture 客户端签名
用来判断该请求是否是客户端 APP 发起的请求, 现在最常见的两种方法 一种 是通过 ASE 加密解密来实现一种是通过比对信息摘要的方式 最简单的生成方式是给出一个随机值和时间戳, 还有和服务端约定好的盐值按照一定的顺序进行 MD5 加密
signture = md5(nonce + timastamp + salt)
服务端收到该签名的时候, 按照约定的顺序进行 MD5 加密比对签名, 如果服务端生成的签名与客户端签名不一致, 则可认为不是客户端发起的请求, 此时不响应该请求当然使用这种方法要同时提交 timestamp 和 随机字符串 nonce
version 客户端版本
用来存放客户端版本号, 主要是小版本, 大版本的话一般服务端会开新接口但是比较好的做法, 是客户端每一次发布都要有一个版本号就算是此次发布, 服务端没做任何更改, 只要客户端新发布也应该给一个新的版本号然后将这个版本号写入 header 头中这样的好处是如果请求出现错误, 我们能够定位到是哪个版本的 APP 出现了该错误, 可以更容易的定位复现错误更详细地还可以把设备的操作系统和型号也在 header 头提交
timestamp 请求时间戳
客户端发送请求时的时间戳, 可用于请求过期判断
客户端签名细节
防止重放攻击
虽然 signture 的存在, 使我们可以判断该请求是否是客户端生成, 从而只响应客户端的请求但是这样安全性还是不够
一个典型的攻击手法时, 通过抓包客户端生成的签名不断地进行接口请求最常见的就是利用该签名不停地请求短信验证码接口, 直到服务器的验证码余额耗光
防止重放攻击的最有效方式就是保持 signture 的唯一性客户端必须生成唯一的 signture, 同时服务端也要保证对于一个 signture 只响应一次请求
保证客户端签名的唯一性
客户端保证每次生成一个唯一地 signture 的最简单方式是在生成签名的方法中加入时间戳
服务端保证对每一个 signture 签名, 只使用一次的实现方法是: 对每次请求做判断, 如果该请求携带的签名没有使用过, 则响应该请求, 并把该签名记录在服务端并标记为已使用如果查到该请求携带的签名已经被标记为已使用, 则不响应该请求
服务端对 signture 做记录, 一般通过 3 种方式:
写进文件
写进 MySql 数据库
写进 Redis
写进文件的弊端, 在于无法供分布式系统使用 MySql 的弊端在于请求数多时增加了数据库的压力所以最好的方式还是写进 Redis 里
增加签名超时机制
我们保证了客户端签名唯一性的方法是将每次请求的 signtue 记录在服务端但是随着请求的增加, 记录的 signture 也越来越多每次请求都要逐一比对以前所有请求记录下的 signture, 这样显然是不合理
所以我们正确的方式, 不应该是单纯把 signture 写进文件, mysql, 或 redis 而应该是做一个文件缓存, 或是写数据库临时表, 或是 redis 缓存
但是如果我们只比对缓存期内的 signture, 攻击者还是可以通过使用已到期被缓存清除的 signture 来进行重放攻击于是我们引入了一个超时机制, 如果该请求携带的 timestamp 比当前的服务端时间相隔已经 大于 singture 缓存时间, 则不响应这个请求这样就保证了, 攻击者无法使用被缓存清除的 signture 进行重放攻击
当然超时时间也不能设置太小, 因为客户端请求到达服务端需要一定的时间所以超时时间的设置应满足
(服务端当前时间 - 客户端提交的 timestamp) < 超时时间 < signture 缓存到期时间
引入超时机制的另一个好处是防止了一部分的中间人攻击因为劫持增加了请求的时间, 因为超时机制的存在, 可能使被劫持的请求失效
防止超时时间下溢攻击
引入了超时机制以后, 可能我们一般都会这样写
if( (服务端当前时间 - 客户端提交的 timestamp) < 超时时间 ){
超时了;
}
但是如果攻击者 更改了客户端时间, 使客户端提交的 timestamp 是一个比服务端时间还超前几天或是几年的时间戳, 并生成了一个对应的 signture 这个时候, 当该请求响应后攻击者等待一段时间, 等缓存中这个 signture 失效了, 攻击者就可以拿着这个 signture 和 timestamp 进行重放攻击了, 因为这个 timestamp 超前了服务端时间几天或几年, 所以
服务端 - 客户端提交的 timestamp = 负数 < 超时时间
所以, 通过负数小于超时时间, 绕过了超时机制, 使 signture 又可以重新使用当然这种情况下的重放攻击已经很弱了, 因为 signture 使用过一次就会被缓存, 所以通过下溢重新使用 signture 也要等到上一次 signture 的缓存失效了则两次攻击之间, 便必须隔一段缓存有效期
所以, 更合理的话, 除了比对请求时间是否小于超时时间还应该判断:
服务端时间 - 客户端提交的 timestamp > 0
保证客户端服务端的时间一致性
引入超时机制的前提是客户端和服务端的时间误差在可接受的范围
试想一下, 如果客户端请求需要 0.5s 到达服务端, 所以服务端的超时时间设置了 1s 但是客户端的时间比服务端慢了 2s 这个时候当客户端的 timestamp 提交到服务端时 本来应该是
服务端当前时间 - 客户端提交的 timestamp = 0.5 < 1 // 不超时
结果因为客户端时间比服务端时间慢了 2s, 使 timestamp 到达服务端时, 变成了
服务端当前时间 - 客户端提交的 timestamp = 2.5s > 1 // 超时
所以客户端服务端时间不一致的会造成客户端所有请求都因为超时而无法响应
那么如何保证服务端和客户端的时间一致性呢? 一个最常用的解决方式就是:
服务端给出一个接口返回当前时间戳,
客户端请求该接口获取时间戳, 加上该请求的响应时间与当前时间戳相减得出时间差
而客户端提交的 timestamp 就是当前时间戳加上服务端与客户端的时间差
客户端签名生成方式
加密 还是 信息摘要 ?
信息摘要
信息摘要的好处在于服务端处理更简单, 只需要生成对应的签名进行比对即可
加密
加密的方式生成签名常见的可以采用 ASE 加密, 借鉴微信支付宝 sdk 签名的生成方式, 把 header 头的重要的参数, 都参与 signture 的生成这样的好处在于更加安全, 如果传输中 header 头的参数被劫持更改, 会造成服务端验签失败, 则请求自然就不响应了
Timestamp
timestamp 是要提交 10 位还是 13 位的时间戳呢?
这个问题最常见于用 PHP 写的 APP 后端因为 PHP 中使用的时间戳是 10 位的, time() 也是只返回一个 10 位的时间戳
但是, 我还是认为应该使用一个 13 位的时间戳至少你生成 signture 的时候应使用 13 位的时间戳, 因为这样实时性更强, 防止客户端太多的时候, 不同的客户端同时生成签名时, 出现 signture 相同的情况
所以, 反正客户端使用的是 13 位的时间戳, 如果提交一个 10 位的时间戳, 它也要进行截取不如直接提交过来怎么使用, 服务端自己决定的
- <?php
- // 13 位时间戳转 10 位 进行比对
- time() - ceil($timestamp/1000);
- // 如果是生成 13 位时间戳进行比对
- list($micro,$time) = expload(' ',microtime());
- ceil(($time + $micro)*1000) - $timestamp;
- // 更新 其实 microtime() 是可以直接返回一个 float 数据, 只需要传一个常数 true
- ceil(microtime(true)*1000) - $timestamp
请求错误日志
APP 开发的其中一个难点就是错误定位难, 复现难所以写日志就非常重要了 当前错误日志所需记录的信息应包含至少以下几类信息:
发生错误的接口地址和时间
该请求的 header 头中的 access_token, 其实更合理地应该通过 access_token 获取用户 id 并记录, 因为 access_token 是有可能更改的
该请求的客户端版本号, 更详细的话, 还有操作系统和设备型号这就是为什么前面提倡将这几个参数写在 header 头里每次请求都提交的原因客户端版本号可以用来判断哪个版本请求会出现错误, 然后再决定如何更改操作系统和设备型号主要用于给前端兼容性错误排查
该请求产生错误信息
该请求的 http 状态码
业务层如果有错误状态码也需要记录
请求返回格式
APP 开发现在比较流行的还是返回 json 格式而不是 xml 格式
返回 json 格式的数据一般是这样的:
- {
- "status" :200,
- "message":"ok",
- "data" :{},
- }
status 返回请求状态码, 一般复用 http 状态码
message 返回请求消息, 如果有错误这里写错误信息
data 是返回的数据
但是我还是比较喜欢以下这种返回方式
- {
- "code":0
- "data":{}
- }
- // 为什么请求成功 要使用 0 作为 code 状态码呢, 0 的第一感觉不是 false 吗?
嗯, 错误情况千千万, 而成功只有一种情况正数负数千千万, 而 0 也只有一个
- {
- "code":1001
- "message":"某个控制器请求出错"
- }
为什么呢?
因为不想用 http status 来传达 API 请求状态, http status 传达的是通讯层的状态 API 是为了满足业务, 返回的数据应包含业务层的状态码业务层不和通讯层耦合, 不拿 http status 取巧
当然对于这点, 喜欢使用 http status 的同学也有不同的看法, 这就看个人的喜好了
我觉得使用 code 的好处在于:
我们可以自定义更多的状态码和错误信息一般我会做一个接口错误地图类, 然后根据 code 的值获取对应的 message
更好地对 code 进行分类定义, 比如 1000 开头的表示 a 控制器各个接口的产生的各种错误 2000 开头的表示 b 控制器各个接口产生的各种错误 -1 表示 错误地图类中 未定义的错误
业务层的状态码不和通讯层状态码耦合, 更详细地展示业务层错误信息
避免客户端出现某个接口返回未考虑进去的非 200 ok 的 http 状态码, 而造成客户端卡死的情况我喜欢在后端对 http 响应的状态码进行判断, 如果该请求的响应码不是 200 就把查看错误地图类转化为对应的 code 状态码和错误 message, 写入日志, 并把 http 状态码改回 200 这样保证每次 http 请求基本都会返回 200, 可预知的错误都转化为返回的 json 数据中的 code 状态码
Authorization
App 后端开发不能使用 session?
虽然 app 通过接口请求的方式与后端交互, 没有 cookie, 但是依然可以使用 sessionsession 的实现不依赖于 cookie, 如果你把 cookie 中的 session_id 但是打开 session 的令牌那么 header 头中的 Authorization 字段提交的 access_token 同样可以看成令牌实现同样的作用
是否允许账户同时在两个以上的设备登陆
因为我们通过 Authorization 来获取认证, 所以:
如果你允许同时登陆多台设备, 你只需要登陆后复用 user 表中的 access_token
如果那你不允许同时登陆多台设备, 则可以选择登陆时刷新 access_token, 这样就使得其他在线的设备请求头中的 Authorization 字段提交的 access_token 与 user 表中的不匹配, 自然就被挤下线了
access_token 的安全性问题
我们通过 access_token 来获取用户, 也就意味着 access_token 如果被劫持就等同于用户的账户被盗
你想想同样作为获取服务端 session 的令牌, 使用 cookie 时, 为了安全我们一般会做哪些呢?
cookie 在生成时就会被指定一个 Expire 值, 这就是 cookie 的生存周期, 在这个周期内 cookie 有效, 超出周期 cookie 就会被清除
对 cookie 进行加密, 嵌入时间戳保证每次加密后的密文不同
不允许跨域使用
所以, 虽然 signture 的唯一性已经为我们证明了是 APP 发起的合法请求, 但是严格来说我们也不能单单对 access_token 进行明文传输 我们可以考虑在 Authorization 字段不是简单地传输 access_token 的值, 可以传一个 access_token 和时间戳的加密字符串, 在服务端再进行解密, 并先判断是否超时如果要安全性高些, 还可以参考 signture 做唯一性处理
版本升级
建议建一个版本升级表用来存放版本升级信息并且要有是否强制更新字段
我们 header 头提交 version 参数, 写日志为的都是不想失去对客户端的控制, 能更好的定位错误但是 app 与传统的 web 开发的一个区别, 就是 web 开发页面做了修改, 所有的用户都能看到修改, 但是 APP 的话, 只要用户没有更新, 已修复的 bug, 对用户而言 依旧存在
版本升级表设计
字段名 | 类型 | 备注 |
---|---|---|
id | int | 主键 id |
app_type | varchar | 客户端版本类型 ios or android |
version | int | 开发版本号 |
version_code | varchar | 客户端版本号(1.0.2) |
upagrade_desc | varchar | 更新提示语 |
apk_url | varchar | 更新包链接 |
is_force | tinyint | 是否强制更新 |
created_at | int | 创建时间 |
status | tinyint | 是否已发布 |
有了版本升级表以后, 我们就能更方便直观地管理查看我们发布的版本
而且我们可以在打开 APP 时请求接口, 查询版本设计表获得最新的版本与 header 头提交的 version 字段作对比, 判断是否需要更新, 弹出更新窗口
对于需要强制更新的版本, 弹窗应设置为不允许用户点击取消, 一定要更新才能使用该 APP 这样我们就可以把一些重大更新或者修复一些重要 bug 的版本设为强制更新, 不更新就不让继续使用
用户分析
为了更好地进行用户分析, 我们还可以建一个 APP 登陆记录表 打开 APP 时就通过 header 把用户信息记录起来, 用来做用户分析用户日活量, 月活量
客户端一打开就将数据发给该接口就行, 不管请求是否成功, 客户端都不需要关心
app_active_log 表
字段名 | 类型 | 备注 |
---|---|---|
id | int | 主键 id |
app_type | varchar | 客户端版本类型 ios or android |
version | int | 开发版本号 |
version_code | varchar | 客户端版本号(1.0.2) |
model | varchar | 设备型号 小米 苹果 |
uid | int | 用户 id |
created_at | int | 创建时间 |
这个表的另一个功能还可以统计某个版本的用户量或是 Android 还是 IOS 用户多, 方便我们更新版本时选择先开发 IOS 版或是安卓版, 或者出现 bug 决定哪个版本先修复
客户端异常监控, 分析
常见的 APP 端异常:
crash 使用 APP 过程中突然出现闪退
卡顿 出现画面卡顿
Exception 程序被 catch 起来的 Exception
ANR 出现提示无响应弹框 (Android)
我们在服务端写日志, 在 header 头提交设备信息这些都是为了更好地定位客户端的错误但是我们的日志只能记录接口调用异常对于客户端的异常却无能为力
为此, 我们应该和客户端配合把客户端产生异常定期上报到服务端方便客户端工程师定位复现并修复客户端异常
我们可以建一个 app_crap 表来统计收集 crash 卡顿 Exception ANR 的次数和影响用户量 用户数
字段名 | 类型 | 备注 |
---|---|---|
id | int | 主键 id |
app_type | varchar | 客户端版本类型 ios or android |
version | int | 开发版本号 |
version_code | varchar | 客户端版本号(1.0.2) |
model | varchar | 设备型号 小米 苹果 |
type | tinyint | 端异常类型 卡顿 闪退 |
description | varchar | 描述 |
created_at | int | 创建时间 |
当然, 客户端记录这些数据比较麻烦. 一个更好的解决方案是在客户端中集成第三方服务提供的 SDK, 将这些数据提交到第三方平台, 客户端工程师可以登录第三方平台查看客户端异常统计
常用的第三方平台 :
听云
OneAPM
消息推送
原生方式
客户端轮询 不推荐
服务端主动推客户端 实现难度大
第三方推送服务
极光推送 推荐使用 restful api 接口 比其他 SDK 用起来更方便
百度云推送
信鸽
APP 后端开发工具推荐
postman
接口调试神器
ngrok
内网映射工具 app 开发一个麻烦的地方在于无法本地调试, 因为客户端需要请求有域名或公网 ip 的服务端代码, 虽然公司有测试服务器, 但是有些时候测试服上有很多人同时使用, 我 git 提交了修改后的服务端代码不能马上 reset hard 生效或者测试服不在我开发的分支 ngrok 的好处是内网映射, 给你的电脑绑定一个域名而客户端测试时填写这个域名能访问到你电脑的服务端代码, 实时调试更方便
guzzle
php 一个 http 请求包, 通过 composer 安装快速使用, 可用来写接口的测试代码, 模拟发起 http 请求, 比起 postman 的优点在于, 通过代码实现, 自定义更方便
以上就是我做 APP 后端开发的一些总结, 由于是第一次开发 APP 后端, 水平有限, 还请大家多多指教
来源: https://juejin.im/post/5a8eaee96fb9a0634417f4bf