章
目
录
单点登录(SSO)是个热门面试题,下面来深入聊聊单点登录。一文带你吃透单点登录的常见设计方式和原理,还会附上相关的代码案例帮助大家进一步深入理解。
一、单点登录是什么?
一般来说,规模稍大的公司往往有多个子系统。由于这些子系统都属于同一公司,用户群体是相通的,没必要为每个子系统单独搭建登录系统。于是,把各个子系统的登录功能抽取出来,形成一个统一的认证中心,这就是单点登录(Single Sign On,简称SSO)。
实现单点登录的方式多种多样,没有固定的模式。不过,总体可分为标准模式(如CAS、OAuth2)和非标准模式,不同公司的具体实现方案可能差异很大。从技术角度来看,常见的实现方式主要有两种:session + cookie模式和token模式。
二、单点登录的常见模式
session + cookie模式
用户把账号密码信息提交给认证中心。认证中心有一张session表格,以键值对的形式存储数据。其中,键是生成的全局唯一id,值则是用户的身份信息。用户登录成功后,表格中就会记录相应信息。
只要认证中心的session表中有该用户的信息,就表示用户处于登录状态;反之,如果session表中没有相关信息,用户登录就会失效,可能是因为登录信息过期了。session表既可以存储在数据库中,也能存在redis(内存)里。
认证中心利用cookie将生成的sid(session id)发送给用户,浏览器会保存这个sid。后续,浏览器访问子系统时,会自动携带sid。但子系统自身没有session表来判断sid是否有效,所以子系统会把接收到的sid发送给认证中心。认证中心查询后,若确认该用户已登录,就会将身份信息传递给子系统,并告知子系统该用户有权限访问。
这种模式的优点是认证中心的管控能力强。一旦在session表中删除用户信息,用户会立即下线。再结合黑名单机制,能有效阻止违规用户登录。然而,当用户数量庞大时,问题就来了。不同子系统频繁向认证中心发送sid进行验证,会使认证中心压力剧增,session表也会变得非常庞大。为了保证服务稳定,还得搭建session集群,并且要为认证中心做好容灾备份。此外,如果某个子系统用户量很大,需要扩容,其向认证中心发送sid的频率也会增加,认证中心也得跟着扩容,这一系列操作都意味着更高的成本投入。
token模式
在token模式下,用户向认证中心发送登录信息后,认证中心不会像session + cookie模式那样往session表中记录信息,而是生成一个不可篡改的字符串token(通常是jwt,关于jwt在之前的文章中有详细讲解,可参考:JWT是什么?有什么作用?一文帮你了解JWT!),并将其发送给用户。
用户收到token后,可以将其存储在cookie或localStorage中。之后,用户访问子系统时带上token,子系统就能自行完成认证。比如,子系统和认证中心预先交换一个密钥,子系统凭借这个密钥就能验证用户的token是否由认证中心颁发。若验证成功,子系统就会向用户提供受保护的资源。
由此可见,token模式大大减轻了认证中心的压力,子系统几乎不会向认证中心发送请求,成本也随之降低。即便某个子系统因用户量增加而扩容,也不会对认证中心造成影响。不过,这种模式也存在明显的缺点,即认证中心对用户的控制能力减弱。假设用户有违规操作,想让其下线,认证中心就需要向每个子系统发送通知,子系统数量较多时,这一过程会很繁琐。
token + refreshToken模式
为了解决token模式下认证中心对用户控制不足的问题,出现了token + refreshToken模式。在这种模式下,用户登录后,认证中心会同时发送两个token,一个是普通token,所有子系统都能识别;另一个是刷新token,只有认证中心能识别。普通token的刷新时间较短,可能20分钟就需要刷新一次,而刷新token的过期时间则较长,比如一周或一个月。
如果普通token未失效,访问流程和单token模式一样。当普通token失效时,情况就有所不同。例如,用户登录一段时间后访问子系统,此时普通token已过期,子系统会提示token失效。用户将刷新token发送给认证中心进行验证,认证中心验证通过后,会返回一个新的普通token给用户,用户就能用新token正常访问子系统了。
相较于单token模式,这种模式增加了对用户的控制。对于违规用户,虽然不能让其立即下线,但当普通token过期,用户用刷新token向认证中心索要新token时,认证中心可以选择不处理,而其他子系统对此并不知情。
三、token的无感刷新
token的无感刷新主要依靠后端实现。通常,token的过期时间较短,假设为10分钟。用户登录10分钟后,token失效,此时用户会被强制返回登录界面重新登录。查看request会发现,其实请求中是携带了token的,只是因为token失效,才返回401错误。频繁让用户重新登录,体验感会很差。
为了解决这个问题,可以引入刷新token(refreshtoken)。refreshtoken的过期时间一般设置得较长,如一周、两周或一个月。它的作用就是在原token过期时,用来替换新的原token。
所谓token无感刷新,就是当原token过期时,前端自动用refreshtoken替换成新token,用户无需重新登录获取新的原token。前端实现无感刷新的基本思路是,当原token过期时,调用refreshtoken函数进行替换。这需要封装axios,并利用拦截器interceptors来实现。
下面是在之前token文章例子基础上实现无感刷新的代码:
import axios from 'axios'
import router from '../router'
axios.defaults.baseURL = "http://localhost:3000"
// 请求拦截
axios.interceptors.request.use(config => {
let token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = token
}
return config // 把请求拦截下来,并往请求头中加入token,然后return
})
// 做一个响应拦截,比如登录失败需要提示登录失败 发请求和接受都需要经过axios的手
axios.interceptors.response.use(
(res) => {
if (res.data.code && res.data.code!== 0) { // 逻辑性错误,比如密码敲错了,并不是程序性错误
return Promise.reject(res.data.error) // 这么做的意义是,让axios好去调试,可以捕获错误
}
if (res.data.status >= 400 && res.data.status < 500) { // 程序性错误
// 状态码在[400, 500) 就认为用户没有权限,就强行把你重定向到登录页面
router.push('/login')
return Promise.reject(res.data)
}
return res // 响应的内容没有问题
}
)
export function post(url, body) {
return axios.post(url, body)
}
上述代码是未添加refreshtoken的情况,现在添加refreshtoken功能,即在401错误时刷新token并重新请求:
// 刷新 token
const refreashToken = () => {
await request.get('/refresh_token', {
headers: {
Authorization: `${localStorage.getItem(REFRESH_TOKEN_KEY)}`
}
})
}
// 做一个响应拦截,比如登录失败需要提示登录失败 发请求和接受都需要经过axios的手
axios.interceptors.response.use(
(res) => {
if (res.data.code && res.data.code!== 0) { // 逻辑性错误,比如密码敲错了,并不是程序性错误
return Promise.reject(res.data.error) // 这么做的意义是,让axios好去调试,可以捕获错误
}
if (res.data.status >= 400 && res.data.status < 500) { // 程序性错误
// 状态码在[400, 500) 就认为用户没有权限,就强行把你重定向到登录页面
router.push('/login')
return Promise.reject(res.data)
if (res.data.status === 401) {
// 刷新 token
await refreshToken()
// 重新请求
const resp = await axios.request(res.config)
return resp
}
}
return res // 响应的内容没有问题
}
)
export function post(url, body) {
return axios.post(url, body)
}
不过,这样写会出现死循环问题,因为res.config里面的token还是失效的token,所以还需要修改:
// 刷新 token
await refreshToken()
// 重新请求
res.config.headers.Authorization = localStorage.getItem('token')
const resp = await axios.request(res.config)
return resp
当前写法仍存在一个问题,当刷新token也过期时,依旧会陷入死循环。可以在refreshtoken函数中添加一个条件参数isRefreshToken,在判断401错误时加上这个条件,并且只有刷新成功才重新请求,最终代码如下:
import axios from 'axios'
import router from '../router'
axios.defaults.baseURL = "http://localhost:3000"
// 请求拦截
axios.interceptors.request.use(config => {
let token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = token
}
return config // 把请求拦截下来,并往请求头中加入token,然后return
})
// 刷新 token
const refreashToken = () => {
const resp = await request.get('/refresh_token', {
headers: {
Authorization: `${localStorage.getItem(REFRESH_TOKEN_KEY)}`
},
_isRefreshToken: true,
})
return resp.code === 200
}
const isRefreshRequest = (config) => {
return!!config._isRefreshToken // 隐式转换为布尔
}
// 做一个响应拦截,比如登录失败需要提示登录失败 发请求和接受都需要经过axios的手
axios.interceptors.response.use(
(res) => {
if (res.data.code && res.data.code!== 0) { // 逻辑性错误,比如密码敲错了,并不是程序性错误
return Promise.reject(res.data.error) // 这么做的意义是,让axios好去调试,可以捕获错误
}
if (res.data.status >= 400 && res.data.status < 500) { // 程序性错误
// 状态码在[400, 500) 就认为用户没有权限,就强行把你重定向到登录页面
router.push('/login')
return Promise.reject(res.data)
if (res.data.status === 401 &&!isRefreshToken(res.config)) {
// 刷新 token
const isSuccess = await refreshToken()
if (isSuccess) {
// 重新请求
res.config.headers.Authorization = localStorage.getItem('token')
const resp = await axios.request(res.config)
return resp
}
}
}
return res // 响应的内容没有问题
}
)
export function post(url, body) {
return axios.post(url, body)
}
当网速较慢时,refreshToken请求耗时,可能会有多个请求同时触发refreshToken,产生多个promise,造成冗余。此时,可以对refreshToken进行优化:
let promise
const refreashToken = () => {
if (promise) return promise
promise = new Promise(async (resolve) => {
const resp = await request.get('/refresh_token', {
headers: {
Authorization: `${localStorage.getItem(REFRESH_TOKEN_KEY)}`
},
_isRefreshToken: true,
})
resolve(resp.code === 200)
})
promise.finally(() => {
promise = null
})
return promise
}
四、OAuth2协议
OAuth 1.0版本现在基本不再使用,这里就不详细介绍了。OAuth2协议在日常生活中很常见,比如你登录第三方网站时,发现可以用微信、apple、google、github等账号登录。这种方式下,你无需向不信任的网站提供自己的账号密码,从而避免了账号密码泄露的风险。
以用户通过微信登录leetcode为例,OAuth2的认证流程如下:
- 用户向leetcode请求资源。
- 用户收到授权许可提示。
- 用户拿着授权许可向认证服务器(这里是微信)申请。
- 认证服务器给leetcode颁发token。
- leetcode拿着Access token访问资源。
- 验证Access token的有效性,若有效,返回受保护的资源给用户。
本质上,身份认证源于对请求方的不信任,而OAuth2就是为了解决这个问题。不过,像微信这样的认证服务器不可能为所有第三方站点直接提供token,所以第三方站点需要向微信申请第三方应用。一般来说,微信、微博、Apple、github等都有各自的OAuth使用说明。
OAuth2有多种授权方式,由于篇幅限制,这里暂不展开,以后有机会再详细介绍。
五、总结
不同规模的系统适合不同的单点登录模式。对于小规模系统,Session + Cookie模式基本就能满足需求;大规模系统则更适合Token或双Token模式;如果涉及第三方登录,OAuth2协议是个不错的选择。