分布式 Session 和 OAuth

不论一家企业做什么领域业务,登录基本都是绕不过去的功能——任何操作都必须在已经登录的前提下才能执行,我这里主要聚焦登录中分布式 Session 的设计,然后连带提一下其他方方面面。

分布式 Session

Session 在服务端保存了登录信息,在登录之后所有的用户身份校验都依赖于 Session,Session 的存储有多种不同方案,一般会有以下几种:

  1. Session sticky
    通过 Nginx 的 ip hash 实现,相同 IP 的请求被 hash 到同一台机器上。
    但是后台 Tomcat 宕机则这台机器上的 Session 全部丢失,不符合高可用的原则。
  2. Session Replication
    同样是通过 Nginx 的 ip hash 来负载均衡,但是在一台 Tomcat 上新建 Session 之后,需要复制到其他 Tomcat 上。
    因为 Tomcat 有对应插件可供使用,所以实现起来非常简单,但是 Session 的复制需要额外的网络(内网)开销,每台 Tomcat 都需要保存全局 Session 数据、导致内存占用严重。
  3. Cookie based
    思路:将数据存储到客户端 Cookie 里。
    缺陷:不安全。
  4. Session 集群存储 Redis
    思路:将 Session 存储到 Redis 集群。
    优势:Redis 提供了高效的集群管理能力。
    缺陷:增大了网络开销。

如何标识一次会话

Session 的出现和 HTTP 紧密相关,因为 HTTP 是无状态的,每次请求都是一次新的连接,HTTP 本身也并不会维护用户身份信息。

HTTP1.1 开始支持长连接。
HTTP Basic Auth、HTTP Digest Auth 是例外,但是这些标准并不常用。

OSI 7 层模型中还包括一个 Session 层用于维护用户访问会话,但是我们一般采用 TCP/IP 协议栈,会话都是在应用层维护的。

高可用 & 高性能

为了高性能,MySQL 等数据库必然不能选择,何况几乎每次请求都必须要查询一次 Session,如果使用 MySQL 存储 Session 分分钟就能爆炸。
最常见的方案是基于 Redis 实现的,通过 Sentinel 可以实现高可用,单机可以支持 10W+的 QPS,如果用户规模超过了这个数,也可以集群化来扩容。

OAuth2

OAuth2概述

OAuth2中定义了以下几种角色:

  • 资源所有者(Resource Owner):用户,即资源服务器上资源的所有者。
  • 客户端(Client):意图访问受限资源的第三方应用。在访问前,它必须先经过用户授权,并且获得的授权凭证将进一步由授权服务器进行验证;
  • 授权服务器(Authorization Server):授权服务器用来验证用户提供的信息是否正确,并返回一个令牌给第三方应用;
  • 资源服务器(Resource Server):资源服务器是提供给用户资源的服务器,例如用户的证件号码、照片等。

OAuth2模块交互

OAuth2的4种授权模式:

  • 授权码
    客户端从授权服务器获取授权码。
    优点:功能最完善、流程最严谨。
  • 简化模式
    直接在浏览器中向授权服务器申请令牌。
    优点:简单,适用于纯静态页面;
  • 密码模式
    用户直接把用户名密码告诉客户端,客户端使用这些信息向授权服务器申请令牌;
    优点:免去了授权步骤,但用户必须对客户端高度信任,一般发生在客户端和资源服务器属于同一家公司的情况。
  • 客户端模式
    客户端以自己的名义而不是用户的名义向服务提供者申请授权。

OAuth2中4种角色之间的交互(以授权码模式为例):
1、2:客户端向用户请求授权,用户同意后授权服务器将授权许可凭证返回给客户端;
3、4:客户端携带授权许可凭证去授权服务器申请令牌,授权服务器确认无误后发放令牌给客户端;
5、6:客户端携带令牌去资源服务器上访问资源,资源服务器确认令牌无误后返回资源。

SpringSecurity中的OAuth2实现

OAuth2是一个协议,SpringSecurity包含了其一种实现。
,使用SpringSecurity的过程中需要先配置授权服务器和资源服务器,之后客户端想要从资源服务器上获取资源前,需要先引导用户跳转到授权页触发授权,授权成功后客户端再拿着授权码调用资源服务器。

授权

入口为授权服务器开放的/oauth/token接口。
TODO

资源

授权刷新

仿 OAuth 令牌机制

之前做的一个项目中涉及到了一个类OAuth2的登录功能,简化后的时序图如下所示:
授权时序图
接下来我详细介绍一下当时对登录过程的实现。

登录及登录校验

下面描述一个简化版的登录功能实现,没有严格遵照标准,仅仅作为一个参考。
登录流程
登录:

  1. 登录时,由前端提供用户标识;
  2. 后端校验登录标识,比如账户密码是否匹配;
  3. 后端生成 Token 和 RefreshToken,它们的生成规则是一致的;
  4. 后端保存登录标识(上图中的 LoginContext)到 Session;
  5. 登录成功,后端重定向到目标页面,并通过 URL params 返回 Token、TokenExpire 和 RefreshToken,因为用 Header 和 Cookie 都会有跨域问题;

    最好按 Header 的命名规范——“大驼峰法”——来命名,我曾经按 “abc_def” 的格式传值,结果被 Nginx 过滤了,闹了个笑话

校验登录标识:

  1. 前端每次调用后端接口时都必须将 Token 放到 Header 而不是 Cookie,因为 RequestHeader 中的 Cookie 和 ResponseHeader 中的 Set-Cookie 只能由同域的服务器接收和返回,而大多数的 API 是跨域的。

    比如公司对外的域名是 www.abc.com,静态文件服务器的域名是 s.abc.com,sso 服务的域名是 a.abc.com,业务服务的域名是 b.abc.com,访问 www.abc.com 的时候会先从 s.abc.com 获取网页,然后操作登录时访问 a.abc.com,登录成功重定向到首页,并通过 URL 参数告知 Token 等数据,之后访问 b.abc.com 进行业务操作,可见,这个流程中充斥着跨域请求。

  2. 后端校验 loginContext,主要是校验登录是否过期。
  3. 补偿设置 Cookie。
    补偿操作看起来似乎有点奇怪,主要是由于 RefreshToken 的存在,客户端会在到事件后触发后端的刷新 Token,但是到时间后如果前端触发了多次刷新,那么这些刷新更新 Session 的顺序、返回的顺序都是不确定的,这意味着前端最终保留下来的 Token 和 RefreshToken 也都是不确定的。

    其实还有一个略暴力的方案,就是直接加分布式锁,但是会降低吞吐量,而且登录校验这么频繁的操作,加锁风险相当大。

刷新登录标识:
refresh 时都是通过 Cookie 返回,当然,服务端返回时的 token 存 cookie 或 local storage 都是可以的。

并发登录控制

不考虑 RefreshToken,一个账号同时允许登录一次

这种情况下,并发登录控制比较简单,因为加入 RefreshToken 相当于并发场景多了一倍。
思路比较简单,比较登录时间即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String token = request.getHeader("Authorization");
Jwt jwt = JwtUtil.decode(token);
if(null == jwt) {
writeJsonResponse(101, "需要登录");
return false;
}
SessionJwt sessionJwt = sessionStore.getJwt(jwt.getUserId);
if(null == sessionJwt) {
writeJsonResponse(101, "需要登录");
return false;
}
// 根据登录时间判断是否被踢
if(jwt.getLoginTime.compareTo(sessionJwt.getLoginTime()) < 0) {
writeJsonResponse(102, "您的账号已在其他设备登录");
}
sessionJwt.putJwt(jwt);
return true;

不考虑 RefreshToken,允许多人登录一个账号(同一账号最大会话数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String token = request.getHeader("Authorization");
Jwt jwt = JwtUtil.decode(token);
boolean locked = false;
try {
locked = sessionStore.lock(jwt.getAccount(), 2, TimeUnit.SECONDS);
// 如果队列里没有此token,且用户没有被踢出,放入队列
if(!sessionStore.containsToken(token)
&& sessionStore.isKickout(token) == false) {
sessionStore.push(token);
}
// 如果队列里的sessionId数超出最大会话数,开始踢人
while (sessionStore.logined(jwt.getAccount()) > maxSession) {
sessionStore.kickoutFirst(jwt.getAccount());
}
} finally {
if(locked) {
sessionStore.unlock(jwt.getUserAccount());
}
}
  • 底层实现灵活:很多针对 Token 的操作我都是直接用 SessionStore 接口表示了,其实底层实现多种多样;
  • 效率:登录队列可以使用 Redis list 来实现,因为一般情况下允许同时登录的用户数不会太高,contains 等 O(n)复杂度的算法也不会太慢。
  • 并发问题:显然上述代码中存在多个写库操作,解决办法是加分布式锁,当然如果是 Redis,通过 Lua 脚本实现原子操作也是可以的。

加入 RefreshToken,且一个账号只允许登录一次

下面为了方便,jwt 表示 token 序列化后的对象,sessionJwt 表示服务端保存的登录信息,但并不是标准的 JWT(Json Web Token)实现。当然将 jwt 保存到 session 里也不是乱写的,因为 JWT 由于安全性等原因,一般还是会将大部分信息保存到服务器 session 中,而客户端只能拿到其中 token、refreshToken 等和登录相关的内容。

  • 登录
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    校验身份信息,比如手机号+密码,那么手机号就相当于用户的账号了,下面用account来表示

    // 生成Tokenplainplainplainplainplainplainplainplainplain
    long loginTime = now()
    String loginId = genLoginId()
    String accessToken = encodeToken(account, loginId, loginTime)
    String refreshToken = encodeToken(account, loginId,
    loginTime + RandomUtils.nextLong(1, 100))
    // 保存到Session
    String sessionJwtJson = encodeJWT(accessToken, loginTime + tokenExpire * 1000, refreshToken)
    sessionStore.putJWT(account, sessionJwtJson)
    // 重定向时通过url参数将token信息返回
    response.sendRedirect(url + "?accessToken=" + accessToken + "&tokenExpire=" + tokenExpire + "&refreshToken=" + refreshToken)
  • loginId
    loginId 主要目的是唯一标识一次登录,一个账号可以登录多次,但是每次生成的 loginId 都是不同的。
    只有保证唯一性这一要求,直接使用 UUID 方法生成即可。
  • Token
    Token 包括 AccessToken 和 RefreshToken 两种,它们的生成方式都是相同的,只是为了区分它们,所以给 RefreshToken 加了个随机的偏移量。
    生成方式为使用下划线拼接然后加密:
    1
    2
    3
    4
    public String encodeToken(String userId, String loginId, long time) {
    String key = Constant.JOINER_UNDER.join(userId, loginId, String.valueOf(time));
    return XXTEAUtil.encrypt(key);
    }
  • 登录拦截
    1
    2
    3
    4
    5
    if(StringUtils.isNotBlank(refreshToken)) {
    doRefresh(refreshToken)
    return ;
    }
    doCheck(accessToken)
    检查登录,防止重复登录的关键是 loginId 的使用,因为如果后来有其他人登录,那么 session 中保存的 loginId 会被覆盖掉。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    void doCheck(String accessToken) {
    Jwt jwt = decodeJwt(accessToken)
    if(null == jwt) {
    跳转到登录页
    }
    String sessionJwtJson = sessionStore.getJWT(jwt.getAccount())
    if(StringUtils.isBlank(sessionJwtJson)) {
    跳转到登录页
    }
    SessionJwt sessionJwt = decodeSessionJWT(sessionJwtJson)
    long tokenExpire = sessionJwt.getExpireTime()
    if(now() > tokenExpire) {
    跳转到登录页,并提示“登录超时”
    }
    if(!StringUtils.equals(jwt.getLoginId(), sessionJwt.getLoginId()) {
    跳转到登录页,并且提示“您已被挤下去”
    }
    // 补偿
    CookieUtils.addCookie(response, "AccessToken", sessionJwt.getAccessToken())
    CookieUtils.addCookie(response, "RefreshToken", sessionJwt.getRefreshToken())
    }
    刷新
    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
    void resetLoginToken(Jwt jwt) {
    // 生成并重置cookie
    String account = jwt.getAccount()
    String loginId = jwt.getLoginId()
    String refreshTime = now()
    String accessToken = encodeToken(account, loginId, refreshTime)
    String refreshToken = encodeToken(account, loginId,
    refreshTime + RandomUtils.nextLong(1, 100))
    CookieUtils.addCookie(response, "AccessToken", accessToken)
    CookieUtils.addCookie(response, "TokenExpire", tokenExpire)
    CookieUtils.addCookie(response, "RefreshToken", refreshToken)
    // 重置session
    String sessionJwtJson = encodeSessionJwt(accessToken, refreshTime + tokenExpire * 1000, refreshToken)
    session.putJWT(jwt.getAccount(), sessionJwtJson)
    }
    void doRefresh(String refreshToken) {
    Jwt jwt = decodeJwt(refreshToken)
    if(null == jwt) {
    跳转到登录页
    }
    String sessionJwtJson = sessionStore.getJWT(jwt.getAccount())
    if(StringUtils.isBlank(sessionJwtJson)) {
    跳转到登录页
    }
    SessionJwt sessionJwt = decodeSessionJWT(sessionJwtJson)
    if(!StringUtils.equals(jwt.getLoginId(), sessionJwt.getLoginId()) {
    跳转到登录页
    }
    // 重置登录token
    resetLoginToken(jwt)
    }

加入 RefreshToken,且一个账号允许登录多次

许多软件其实会有多端登录的场景,比如微信可以在手机上登录也可以在电脑端登录。

  1. 手机端登录成功后生成 token,然后在电脑端登录校验通过后将之前已经生成的 token 返回,这种实现方式相当于一个账号可以登录无限多次;
  2. 手机端登录成功生成 token,保存到 scene=”MOBILE”域下,和电脑端登录的 scene=”PC”域隔离,通过场景相互隔离,至于具体如何实现就凭需求怎么来了,无法一一穷尽。
  3. 如果需要增加登录次数限制,可以将 loginId 添加到一个队列中,后登录的会压入队列并将队列末端的挤出。

重定向

登录操作可以通过提交表单来实现,后端完全可以sendRedirect重定向,但是以防万一,还是更推荐在 URL 后面挂参数、由前端跳转的形式来重定向。
登录检查重定向则必须通过 URL 后面挂参数的方式实现了,因为前端通过 Ajax 调接口,Ajax 只支持局部的刷新,无法重新加载整个页面,每次后端调用sendRedirect来重定向都会直接进入 Ajax 的结果处理函数。

Refresh 并发问题

AccessToken 过期后继续访问接口会被重定向到登录页面,难道每次过期后都需要用户重新登录吗?当然不是,其实在即将过期的时候,前端需要传一个 RefreshToken 来触发 Token 的刷新机制。比如如果 AccessToken 的过期时间是 5 分钟,那么 4 分钟后再请求接口时就会在 RequestHeader 中多带上一个 RefreshToken。
许多页面都是会同时触发多次请求的,而且这些请求都是通过 Ajax 发送,client 发出的顺序、server 接收的顺序、client 接收的顺序都是不确定的,对此有多种解决方案:

  • 一种最直观的方式是通过加锁实现幂等,只要距离服务端上次刷新的时间比较接近,就直接将 AccessToken 和 RefreshToken 返回,而不是重新刷新一遍;
  • 还有一种无锁的方案,每次 refresh 请求都会触发刷新并返回新的 Token,这时客户端最终保存的是哪一次返回的其实是不确定的,这时,到下一次刷新时间点之前的每次 check 请求都会重新返回服务端的 Token,其实是补偿了前端的这种并发乱序情况,其实就是上边doCheck()方法中最后的几行代码:
    1
    2
    3
    // 补偿
    CookieUtils.addCookie(response, "AccessToken", sessionJwt.getAccessToken())
    CookieUtils.addCookie(response, "RefreshToken", sessionJwt.getRefreshToken())

跨域

虽然跨域限制初衷是保护 Web 安全,但是奈何正常的 Web 请求几乎也全是跨域的,总不能要求所有请求都访问同一个域名、子域名吧。
现在最常见的跨域方案是 CORS,web 服务器如 Nginx、web 框架如 SpringMVC 等都已有现成的方案,在此就不赘述了。

Token 加密

Token 不经加密、明文传输可能会暴露生成规则,且生成 Token 的材料中偶尔会包含一些无法通过 URL、RequestHeader 携带的特殊字符,需要进行转义。
以 XXTEA 为例:

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
/**
* Encrypt data with key.
*/
public static byte[] encrypt(byte[] data, byte[] key) {
if (data.length == 0) {
return data;
}
return toByteArray(encrypt(toIntArray(data, true), toIntArray(key, false)), false);
}

/**
* Decrypt data with key.
*/
public static byte[] decrypt(byte[] data, byte[] key) {
if (data.length == 0) {
return data;
}
return toByteArray(decrypt(toIntArray(data, false), toIntArray(key, false)), true);
}

/**
* Encrypt data with key.
*/
public static int[] encrypt(int[] v, int[] k) {
int n = v.length - 1;

if (n < 1) {
return v;
}
if (k.length < 4) {
int[] key = new int[4];

System.arraycopy(k, 0, key, 0, k.length);
k = key;
}
int z = v[n], y = v[0], delta = 0x9E3779B9, sum = 0, e;
int p, q = 6 + 52 / (n + 1);

while (q-- > 0) {
sum = sum + delta;
e = sum >>> 2 & 3;
for (p = 0; p < n; p++) {
y = v[p + 1];
z = v[p] += (z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (k[p & 3 ^ e] ^ z);
}
y = v[0];
z = v[n] += (z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (k[p & 3 ^ e] ^ z);
}
return v;
}

/**
* Decrypt data with key.
*/
public static int[] decrypt(int[] v, int[] k) {
int n = v.length - 1;

if (n < 1) {
return v;
}
if (k.length < 4) {
int[] key = new int[4];

System.arraycopy(k, 0, key, 0, k.length);
k = key;
}
int z = v[n], y = v[0], delta = 0x9E3779B9, sum, e;
int p, q = 6 + 52 / (n + 1);

sum = q * delta;
while (sum != 0) {
e = sum >>> 2 & 3;
for (p = n; p > 0; p--) {
z = v[p - 1];
y = v[p] -= (z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (k[p & 3 ^ e] ^ z);
}
z = v[n];
y = v[0] -= (z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (k[p & 3 ^ e] ^ z);
sum = sum - delta;
}
return v;
}

/**
* Convert byte array to int array.
*/
private static int[] toIntArray(byte[] data, boolean includeLength) {
int n = (((data.length & 3) == 0) ? (data.length >>> 2) : ((data.length >>> 2) + 1));
int[] result;

if (includeLength) {
result = new int[n + 1];
result[n] = data.length;
} else {
result = new int[n];
}
n = data.length;
for (int i = 0; i < n; i++) {
result[i >>> 2] |= (0x000000ff & data[i]) << ((i & 3) << 3);
}
return result;
}

/**
* Convert int array to byte array.

*/
private static byte[] toByteArray(int[] data, boolean includeLength) {
int n = data.length << 2;

;
if (includeLength) {
int m = data[data.length - 1];

if (m > n) {
return null;
} else {
n = m;
}
}
byte[] result = new byte[n];

for (int i = 0; i < n; i++) {
result[i] = (byte) ((data[i >>> 2] >>> ((i & 3) << 3)) & 0xff);
}
return result;
}

/**
* 先XXXTEA加密,后Base64加密

*/
public static String encrypt(String str, String key) {
String enVid = "";
byte[] k = key.getBytes();
byte[] v = str.getBytes();
enVid = new String(Base64.encodeBase64(XXTEAUtil.encrypt(v, k)));
enVid = enVid.replace('+', '-');
enVid = enVid.replace('/', '_');
enVid = enVid.replace('=', '.');
return enVid;
}

/**
* 先Base64解密,后XXXTEA解密
*/
public static String decrypt(String str, String key) {
String deVid = "";
str = str.replace('-', '+');
str = str.replace('_', '/');
str = str.replace('.', '=');
byte[] k = key.getBytes();
byte[] v = Base64.decodeBase64(str);
deVid = new String(XXTEAUtil.decrypt(v, k));
return deVid;
}

public static String encrypt(String str) {
String enVid = "";
byte[] k = "I+Rj5j]#D*a-".getBytes();
byte[] v = str.getBytes();
enVid = new String(Base64.encodeBase64(encrypt(v, k)));
enVid = enVid.replace('+', '-');
enVid = enVid.replace('/', '_');
enVid = enVid.replace('=', '.');
return enVid;
}

public static String decrypt(String str) {
String deVid = "";
str = str.replace('-', '+');
str = str.replace('_', '/');
str = str.replace('.', '=');
byte[] k = "I+Rj5j]#D*a-".getBytes();
byte[] v = Base64.decodeBase64(str);
deVid = new String(decrypt(v, k));
return deVid;
}

利用 HTTPS 对请求加密

虽然前面描述的授权过期机制可以减小 Token 被盗用的风险,但是仍然有被攻击者拿到 Token 然后攻击的风险。一般解决方案是在发送请求和响应前,先对请求体和响应体进行加密,这样,对用户来说内容是不可见的,更别提随意调用这些接口了。对请求、响应加密还有个好处:能尽量避免一些“专业人士”用脚本刷这些接口。
举个例子,业务中使用到了三方的服务,需要与厂商进行对接,免不了需要公开接口进行交互,也就是说接口会暴露到公网,避免其他人恶意调用,最常用的方案就是对接口进行签名:

  1. 三方使用秘钥 key 对签名 sign 进行加密得到密文 cipherText,并将签名一并传过来;
  2. 我方使用事先约定好的秘钥对签名进行加密,和传过来的密文进行比对,若相等则验签成功。

一般这种加密是通过 HTTPS 协议实现的,HTTPS 可以看作 HTTP+TLS,TLS 的核心是对接口的签名。

参考

  1. 权限系统设计
  2. OAuth 2.0 的一个简单解释
  3. OAuth 2.0 的四种方式
  4. GitHub OAuth 第三方登录示例教程
  5. JSON Web Token 入门教程
  6. 傻傻分不清之 Cookie、Session、Token、JWT
  7. Spring Shiro
    Spring Boot 中集成 Shiro
  8. Spring Security
    Spring 高级篇—Spring Security 入门原理及实战
  9. 基于 Shiro 的统一用户中心

SpringSecurity

  1. SpringBoot - 使用Spring Security实现OAuth2授权认证教程(实现token认证)
  2. Spring Security 与 OAuth2(完整案例)