Access Token vs Refresh Token

设计需求

  1. 统计在线列表,可以随时的踢下线,让客户端缓存的token失效
  2. 30天内无需重新登录,默认可无限期延续

Token生成方案

JWT(JSON WEB TOKEN)的构成

第一部分我们称它为头部(header),
第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),
第三部分是签名(signature确保数据的完整性).
JWT token = header.payload.signature
由于JWT是无状态的,也未能实现我们上述的需求,所以不得不放弃了JWT的方案,但是其有关token的加密算法还是值得参考的。

access_token生成方案

1
2
3
4
5
6
7
8
payload = urlencode(base64({uid,ts}))
salt=TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ //盐值
signature = HMACSHA256(payload,salt);
access_token = payload.signature

redis_key = access_token:uid:{access_token}//uid 是从token中payload解析出来的
redis_value = userinfo
redis_ttl = 2hr

refresh_token生成方案

1
2
3
4
5
6
7
8
payload = urlencode(base64({uid,ts}))
salt=TJVA95Or11.0cBab30RMHrHDcEfxjoYZgeFONFh7HgQ //盐值
signature = HMACSHA256(payload,salt);
refresh_token = payload.signature;

redis_key = refresh_token:uid:{refresh_token}//uid 是从token中payload解析出来的
redis_value = access_token
redis_ttl = 30days

算法实现:

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
import org.apache.commons.codec.binary.Base64;
import com.fanfq.util.commons.encrypt.HMACSHA256;

System.out.println("\n#####token生成算法");

Long uid = 10010l;

Map<String,Object> map = new HashMap<String,Object>();
map.put("uid", uid);
map.put("ts", System.currentTimeMillis());

String str = JSONObject.toJSONString(map);

String base64 = Base64.encodeBase64String(str.getBytes());
String payload = null;
try {
payload = URLEncoder.encode(base64,"UTF-8");
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
String salt = "TJVA95Or11.0cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
String signature = HMACSHA256.encode(payload,salt);

String token = payload+"."+signature;
System.out.println("payload:"+payload);
System.out.println("signature:"+signature);
System.out.println("token:"+token);
System.out.println("redis key: token:"+uid+":"+token);

System.out.println("\n#####token校验算法");

System.out.println("1.签名校验");
System.out.println("token:"+token);
String payload_ = token.split("\\.")[0];
String signature_ = token.split("\\.")[1];
System.out.println("payload密文:"+payload_);
System.out.println("signature签名:"+signature_);
boolean check = HMACSHA256.encode(payload_,salt).equals(signature_);
System.out.println("签名校验:"+check);

System.out.println("\n2.时间戳校验");
String urldecode = null;
try {
urldecode = URLDecoder.decode(payload_, "UTF-8");
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
String jsondecode = new String(Base64.decodeBase64(urldecode));
System.out.println("payload解密:"+jsondecode);

JSONObject jsobj = JSONObject.parseObject(jsondecode);
Long ts = jsobj.getLong("ts");
System.out.println(System.currentTimeMillis() - ts +" ms");
if(System.currentTimeMillis() - ts > 1000*60*60*2) {
System.out.println("超过2小时");
}else {
System.out.println("有效期内,开始进行redis查询");
}

token生成算法

1
2
3
4
5
明文:{"uid":10010,"ts":1540912751948}
payload:eyJ1aWQiOjEwMDEwLCJ0cyI6MTU0MDkxMjc1MTk0OH0%3D
signature:0ba40cff2bda8fab9ae61893b43c198e3022a1e8f058ac8054417241d3129d1f
token:eyJ1aWQiOjEwMDEwLCJ0cyI6MTU0MDkxMjc1MTk0OH0%3D.0ba40cff2bda8fab9ae61893b43c198e3022a1e8f058ac8054417241d3129d1f
redis key: token:10010:eyJ1aWQiOjEwMDEwLCJ0cyI6MTU0MDkxMjc1MTk0OH0%3D.0ba40cff2bda8fab9ae61893b43c198e3022a1e8f058ac8054417241d3129d1f

token校验算法

  1. 签名校验

    1
    2
    3
    4
    token:eyJ1aWQiOjEwMDEwLCJ0cyI6MTU0MDkxMjc1MTk0OH0%3D.0ba40cff2bda8fab9ae61893b43c198e3022a1e8f058ac8054417241d3129d1f
    payload密文:eyJ1aWQiOjEwMDEwLCJ0cyI6MTU0MDkxMjc1MTk0OH0%3D
    signature签名:0ba40cff2bda8fab9ae61893b43c198e3022a1e8f058ac8054417241d3129d1f
    签名校验:true
  2. 时间戳校验

    1
    2
    3
    payload解密:{"uid":10010,"ts":1540912751948}
    690 ms
    有效期内,开始进行redis查询

注意:

这个也是我经过思考后最终确认下来的token生成算法,也许你也发现既然redis持久化了就直接查库即可,无需搞的这么复杂的算法。实际上我是这样考虑的,因为大量的api请求都会带token参数导致每次请求都会查询库,固然redis的强大的性能足以支持,但还是为了效率过滤一些没有必要的io资源。

  1. 得到请求的时候首先验证signature是否匹配,防止篡改
  2. 再从payload中获取ts以判断时间戳是否失效,access_token 2hr, refresh_token 30days.
  3. 前两者校验完成,则请求一次redis io做最终的判断。当然排除临时被提下线的情况下,大部分情况下这里都是通过的。
  4. redis key 的设计,其中的uid设计主要是为了后期统计使用,如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    keys (refresh_token:uid:*)获取当前终端连接数,

    Set<String> setz = jedis.keys("access_token:*");
    System.out.println("access_token:* key 的数目:"+setz.size());

    获取当前在线用户数(根据uid排重)

    Set<String> setzz = new HashSet<String>();
    for(String str:setz) {
    setzz.add(str.split(":")[1]);
    }
    System.out.println("key uid 去重:"+setzz.size());
  5. redis ttl 就不用不多说了,access_token 2hr, refresh_token 30days.
  6. redis value:whatever anythings

Redis 有状态缓存

access_token

key ttl value
access_token:10024:ak_1 2hr username:zhangsan,uid:10024
access_token:10025:ak_2 2hr username:lisi,uid:10025

refresh_token

key ttl value
refresh_token:10024:rk_1 30days access_token:10024:ak_1
refresh_token:10025:rk_2 30days access_token:10025:ak_2

拓扑结构

用户授权获取token

请求方法:

https://apihost/oauth2/access_token?username=zhangsan&password=123456

正确的返回:

1
2
3
4
5
6
7
8
9
10
{
"code":200,
"msg":"ok",
"data":{
"access_token":"ak_1",
"uid":10024,
"refresh_token":"rk_1",
"expires_in":7200
}
}
参数 说明
access_token 接口调用凭证
refresh_token 用户刷新access_token
uid 授权用户唯一标识
expires_in access_token接口调用凭证超时时间,单位(秒)

错误返回样例:

{"code":400,"msg":"bad request"}

刷新access_token有效期

access_token是调用授权关系接口的调用凭证,由于access_token有效期(目前为2个小时)较短,当access_token超时后,可以使用refresh_token进行刷新,用refresh_token仅能使用一次,使用一次后,将被废弃。也就是说refresh_token 更新 access_token 的时候 refresh_token 也会跟着被更新

  1. 通过用refresh_token机制可以确保活跃用户长期不用登录授权。
  2. refresh_token拥有较长的有效期(30天),当refresh_token失效的后,需要用户重新授权。也就说第一次登录与第二次登录时长间隔30天以上则需要用户重新授权登录。

请求方法:

https://apihost/oauth2/refresh_token?uid=UID&refresh_token=REFRESH_TOKEN

正确的返回:

1
2
3
4
5
6
7
8
9
10
{
"code":200,
"msg":"ok",
"data":{
"access_token":"ak_1",
"uid":10024,
"refresh_token":"rk_1",
"expires_in":7200
}
}
参数 说明
access_token 接口调用凭证
refresh_token 用户刷新access_token
uid 授权用户唯一标识
expires_in access_token接口调用凭证超时时间,单位(秒)

错误返回样例:

{"code":400,"msg":"invalid refresh_token"}

通过access_token调用接口

获取access_token后,进行接口调用,有以下前提:
access_token有效且未超时;如果access_token失效,则通过refresh_token重新同步。若refresh_token也失效了则需要用户重新授权登录。

Eg.通过UID获取用户基本信息

请求方法

https://apihost/user?access_token=ak_1&uid=10024

正确的返回

1
2
3
4
5
6
7
8
9
{
"code":200,
"msg":"ok",
"data":{
"username":"zhangsan",
"age":28,
"address":"xx路xx号"
}
}

错误返回样例:

{"code":400,"msg":"invalid access_token"}

注意事项RISK CONTROL

  1. access_token 为用户授权客户端发起接口调用的凭证(相当于用户登录态),存储在客户端,可能出现恶意获取access_token 后导致的用户数据泄漏、用户微信相关接口功能被恶意发起等行为;
  2. refresh_token 为用户授权客户端应用的长效凭证,仅用于刷新access_token,但泄漏后相当于access_token 泄漏,风险同上。
  3. 单点/多点登录。介于我们当前的设计不考虑单点登录的问题,也就是说同一个账号在不同的客户端都可以同时登录,并且生成不同的token以供使用每个客户端程序维护自己的一套token即可。倘若要实现单点登录则在用户授权的时候做排重判断,如果已在其他重点登录则清空该重点的token,并且为当前链接生成新token对即可。具体情况shi
  4. 用户信息跟新,针对我们当前的授权方式一旦用户修改的密码,则要清空该用户所有终端的token,让其重新授权。这也是为什么要设计这套有状态的token机制主要原因
Fred范方青 wechat
项目合作请联系我私人微信: fredtv23
0%