单点登录(SSO)常见的设计方案与原理(附代码示例)

后端 潘老师 5天前 14 ℃ (0) 扫码查看

单点登录(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的认证流程如下:

  1. 用户向leetcode请求资源。
  2. 用户收到授权许可提示。
  3. 用户拿着授权许可向认证服务器(这里是微信)申请。
  4. 认证服务器给leetcode颁发token。
  5. leetcode拿着Access token访问资源。
  6. 验证Access token的有效性,若有效,返回受保护的资源给用户。

本质上,身份认证源于对请求方的不信任,而OAuth2就是为了解决这个问题。不过,像微信这样的认证服务器不可能为所有第三方站点直接提供token,所以第三方站点需要向微信申请第三方应用。一般来说,微信、微博、Apple、github等都有各自的OAuth使用说明。

OAuth2有多种授权方式,由于篇幅限制,这里暂不展开,以后有机会再详细介绍。

五、总结

不同规模的系统适合不同的单点登录模式。对于小规模系统,Session + Cookie模式基本就能满足需求;大规模系统则更适合Token或双Token模式;如果涉及第三方登录,OAuth2协议是个不错的选择。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/back/17594.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】