分布式 Session 和 OAuth
不论一家企业做什么领域业务,登录基本都是绕不过去的功能——任何操作都必须在已经登录的前提下才能执行,我这里主要聚焦登录中分布式 Session 的设计,然后连带提一下其他方方面面。
分布式 Session
Session 在服务端保存了登录信息,在登录之后所有的用户身份校验都依赖于 Session,Session 的存储有多种不同方案,一般会有以下几种:
- Session sticky
通过 Nginx 的 ip hash 实现,相同 IP 的请求被 hash 到同一台机器上。
但是后台 Tomcat 宕机则这台机器上的 Session 全部丢失,不符合高可用的原则。 - Session Replication
同样是通过 Nginx 的 ip hash 来负载均衡,但是在一台 Tomcat 上新建 Session 之后,需要复制到其他 Tomcat 上。
因为 Tomcat 有对应插件可供使用,所以实现起来非常简单,但是 Session 的复制需要额外的网络(内网)开销,每台 Tomcat 都需要保存全局 Session 数据、导致内存占用严重。 - Cookie based
思路:将数据存储到客户端 Cookie 里。
缺陷:不安全。 - 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的4种授权模式:
- 授权码
客户端从授权服务器获取授权码。
优点:功能最完善、流程最严谨。 - 简化模式
直接在浏览器中向授权服务器申请令牌。
优点:简单,适用于纯静态页面; - 密码模式
用户直接把用户名密码告诉客户端,客户端使用这些信息向授权服务器申请令牌;
优点:免去了授权步骤,但用户必须对客户端高度信任,一般发生在客户端和资源服务器属于同一家公司的情况。 - 客户端模式
客户端以自己的名义而不是用户的名义向服务提供者申请授权。
OAuth2中4种角色之间的交互(以授权码模式为例):
1、2:客户端向用户请求授权,用户同意后授权服务器将授权许可凭证返回给客户端;
3、4:客户端携带授权许可凭证去授权服务器申请令牌,授权服务器确认无误后发放令牌给客户端;
5、6:客户端携带令牌去资源服务器上访问资源,资源服务器确认令牌无误后返回资源。
SpringSecurity中的OAuth2实现
OAuth2是一个协议,SpringSecurity包含了其一种实现。
,使用SpringSecurity的过程中需要先配置授权服务器和资源服务器,之后客户端想要从资源服务器上获取资源前,需要先引导用户跳转到授权页触发授权,授权成功后客户端再拿着授权码调用资源服务器。
授权
入口为授权服务器开放的/oauth/token
接口。
TODO
资源
授权刷新
仿 OAuth 令牌机制
之前做的一个项目中涉及到了一个类OAuth2的登录功能,简化后的时序图如下所示:
接下来我详细介绍一下当时对登录过程的实现。
登录及登录校验
下面描述一个简化版的登录功能实现,没有严格遵照标准,仅仅作为一个参考。
登录:
- 登录时,由前端提供用户标识;
- 后端校验登录标识,比如账户密码是否匹配;
- 后端生成 Token 和 RefreshToken,它们的生成规则是一致的;
- 后端保存登录标识(上图中的 LoginContext)到 Session;
- 登录成功,后端重定向到目标页面,并通过 URL params 返回 Token、TokenExpire 和 RefreshToken,因为用 Header 和 Cookie 都会有跨域问题;
最好按 Header 的命名规范——“大驼峰法”——来命名,我曾经按 “abc_def” 的格式传值,结果被 Nginx 过滤了,闹了个笑话
校验登录标识:
- 前端每次调用后端接口时都必须将 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 进行业务操作,可见,这个流程中充斥着跨域请求。
- 后端校验 loginContext,主要是校验登录是否过期。
- 补偿设置 Cookie。
补偿操作看起来似乎有点奇怪,主要是由于 RefreshToken 的存在,客户端会在到事件后触发后端的刷新 Token,但是到时间后如果前端触发了多次刷新,那么这些刷新更新 Session 的顺序、返回的顺序都是不确定的,这意味着前端最终保留下来的 Token 和 RefreshToken 也都是不确定的。其实还有一个略暴力的方案,就是直接加分布式锁,但是会降低吞吐量,而且登录校验这么频繁的操作,加锁风险相当大。
刷新登录标识:
refresh 时都是通过 Cookie 返回,当然,服务端返回时的 token 存 cookie 或 local storage 都是可以的。
并发登录控制
不考虑 RefreshToken,一个账号同时允许登录一次
这种情况下,并发登录控制比较简单,因为加入 RefreshToken 相当于并发场景多了一倍。
思路比较简单,比较登录时间即可。
1 | String token = request.getHeader("Authorization"); |
不考虑 RefreshToken,允许多人登录一个账号(同一账号最大会话数)
1 | String token = request.getHeader("Authorization"); |
- 底层实现灵活:很多针对 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
4public String encodeToken(String userId, String loginId, long time) {
String key = Constant.JOINER_UNDER.join(userId, loginId, String.valueOf(time));
return XXTEAUtil.encrypt(key);
} - 登录拦截 检查登录,防止重复登录的关键是 loginId 的使用,因为如果后来有其他人登录,那么 session 中保存的 loginId 会被覆盖掉。
1
2
3
4
5if(StringUtils.isNotBlank(refreshToken)) {
doRefresh(refreshToken)
return ;
}
doCheck(accessToken)刷新1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21void 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
31void 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,且一个账号允许登录多次
许多软件其实会有多端登录的场景,比如微信可以在手机上登录也可以在电脑端登录。
- 手机端登录成功后生成 token,然后在电脑端登录校验通过后将之前已经生成的 token 返回,这种实现方式相当于一个账号可以登录无限多次;
- 手机端登录成功生成 token,保存到 scene=”MOBILE”域下,和电脑端登录的 scene=”PC”域隔离,通过场景相互隔离,至于具体如何实现就凭需求怎么来了,无法一一穷尽。
- 如果需要增加登录次数限制,可以将 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 | /** |
利用 HTTPS 对请求加密
虽然前面描述的授权过期机制可以减小 Token 被盗用的风险,但是仍然有被攻击者拿到 Token 然后攻击的风险。一般解决方案是在发送请求和响应前,先对请求体和响应体进行加密,这样,对用户来说内容是不可见的,更别提随意调用这些接口了。对请求、响应加密还有个好处:能尽量避免一些“专业人士”用脚本刷这些接口。
举个例子,业务中使用到了三方的服务,需要与厂商进行对接,免不了需要公开接口进行交互,也就是说接口会暴露到公网,避免其他人恶意调用,最常用的方案就是对接口进行签名:
- 三方使用秘钥 key 对签名 sign 进行加密得到密文 cipherText,并将签名一并传过来;
- 我方使用事先约定好的秘钥对签名进行加密,和传过来的密文进行比对,若相等则验签成功。
一般这种加密是通过 HTTPS 协议实现的,HTTPS 可以看作 HTTP+TLS,TLS 的核心是对接口的签名。
参考
- 权限系统设计
- OAuth 2.0 的一个简单解释
- OAuth 2.0 的四种方式
- GitHub OAuth 第三方登录示例教程
- JSON Web Token 入门教程
- 傻傻分不清之 Cookie、Session、Token、JWT
- Spring Shiro
Spring Boot 中集成 Shiro - Spring Security
Spring 高级篇—Spring Security 入门原理及实战 - 基于 Shiro 的统一用户中心