面试官:聊聊单点登录(SSO)

前言

一个规模大点的公司大概率会有很多子系统,每个子系统都是属于公司的,没必要为每个子系统做一个登录系统,因为用户是相通的,把每个子系统的登录部分抽离出来形成一个认证中心,这就是单点登录(Single Sign On)

实现单点登录的模式比较多, 并没有固定的模式,不过有标准模式(CAS, OAuth2),非标准模式,可能每个公司实现方案都不相同。但从技术上来看,大体上可以分为两种,一个 session + cookie, 一个 token

单点登录的常见模式

session + cookie

用户将账号密码信息发给认证中心,认证中心有个 session 表格,里面是键值对,key 是生成的全局唯一 id,value 就是用户的身份信息,一旦用户登录成功,表格里面就会记录一条信息。

只要认证中心的 session 表有这个用户的信息,那么就表明该用户是登录成功的状态,反之,session 表没这个信息,用户就会登录失效,有可能是过期了,session 表有可能是存在 数据库的,也有可能是存在 redis(内存) 中。

利用 cookie 把 sid 带给用户,浏览器就会保存这个 sid,后面浏览器访问子系统时就会把 sid 带过去

子系统并没有这个 session 表去判定是否有效,于是子系统会将接收到的 sid 发给认证中心,认证中心去查,查到后会告诉子系统该用户完成登录了,具有权限,把身份信息给到子系统

这种模式下有个好处就是认证中心的控制力很强,只要 session 表删除了用户信息,用户就会立马下线,再配合黑名单,用户就登不上系统了

这种模式下只要用户体量很大,认证中心的压力就会非常大,不同子系统不断的给认证中心发 sid 让他判断,并且表也会非常庞大,还要做 session 集群,并且认证中心不能挂,你需要给他做一个 session 容灾;再者,某个子系统的用户体量很大导致该系统要扩容,这样一来,这个子系统给认证中心发 sid 的频率也在变大,随之认证中心也要扩容;这里面所有的缺陷最终都是指向烧钱,为了降低成本,token 模式随之诞生

token

这个模式下的认证中心压力很小

token 模式下用户向认证中心发送登录信息后,认证中心此时并没有向 session 表去记录任何东西,认证中心会生成一个不能篡改的字符串 token 给用户,这其实就是 jwt ,此前文章有详细讲过juejin.cn/post/734643…

用户接收到 token 后会将token 存入,可以存 cookie 也可以存入 localStorage 都可以,后面的事情就无关认证中心了。于是用户访问某个子系统时带上 token,子系统是可以自己认证的,具体认证方式比如子系统和认证中心去交换一个密钥,子系统拿到一个密钥之后可以自行认证用户的 token 是否为认证中心颁发的,一旦认证成功就会把受保护的资源发给用户

由此可见,token 模式下认证中心压力就很小了,因为子系统几乎没有向认证中心发送任何的请求,成本随之降低,具体某个子系统用户体量大而去扩容也不会影响到认证中心

缺点也显而易见,认证中心失去了对用户的绝对控制,假设某个用户违规操作,现在希望让这个用户下线,就需要认证中心向每个子系统去发送信息,让用户下线,一两个子系统倒还好,一旦子系统过多就很麻烦

为了解决这个问题双 token 随之诞生。

<顺便吆喝一句,民族企业核心部门年底前的一波岗,base武汉、深圳、东莞、西安、上海、北京、苏州等地

前、后端or测试>>>直通机会 ,学历要求放宽(本科包含双非学院),机考分数设低(150分也可)至年末。>

来源:牛客网token + refreshToken

这个模式下有两个 token,一个原 token 一个刷新 token

用户在登录完成后,认证中心会发送两个 token,一个 token 是所有子系统都能识别的,另一个刷新 token 只有认证中心自己认识,原 token 的刷新时间非常短,可能 20min 刷新一次,刷新 token 的过期时间会比较长,比如一周一个月。

假设原 token 没有失效,那么流程就和单token 模式一样的。假设失效了就会如图这样

用户第一次登录会收到认证中心的两个 token,假设用户过了一段时间去登录子系统,原 token 过期了,子系统告诉这个 token 失效,此时用户会将 刷新 token 发给认证中心去验证,认证中心会返回一个新的 token 给到用户,用户再去访问子系统就可以正常访问了

这个模式的意义相较于单 token 模式多了层对用户的控制,比如某个用户违规操作希望让其下线,虽然不能让该用户立即下线,但是原 token 一旦过期,用户拿着 refreshToken 向认证中心索要 token 时,认证中心不管就行,其余子系统是无感的

token 的无感刷新

token 的无感刷新其实主要工作在于后端

看个情形:一般 token 过期时间很短,假设 token 过期时间为 10min,用户登录 10min 后 token 失效就会把你送回登录界面重新登录,此时查看 request 你会发现其实是携带了 token 的,只不过你的 token 失效了,因此 401 了。每次 10min 后用户都要重新登录下,这样用户体验很糟糕。

如何解决这个问题呢,我们可以加一个刷新 token,也就是 refreshtoken,这个 token 的过期时间一般会设置较长比如一周、两周、一个月,这个 token 的作用就是去给你替换新的 原token。

所谓 token 无感刷新就是让你 原 token 过期时,前端默默帮你把 refreshtoken 替换成了新 token,用户不再需要重新登陆去拿到新的原 token

所以前端想要实现无感刷新的基本思路就是当原 token 过期时,用refreshtoken替换原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)
}


这是那期文章 axios 的封装,这是没有 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 等工具去登录,这样,对于你不信任的网站登录时你不需要提供账号密码,这样的方式就可以避免泄露自己的账号密码等信息

Oauth2 的认证流程如下

假设用户现在通过微信去登录 leetcode,用户只要同意授权,那么认证服务器就会给第三方网站 leetode 颁发 token,同意之后,leetcode 就可以拿到你的头像等等信息

其实所谓的身份认证,其本质都是基于对请求方的不信任产生的,因此 oauth2 就是来解决这个问题的

还有个问题就是像是微信这样扮演认证服务器的角色不可能给所有的第三方站点都提供这个 token,因此第三方站点需要向微信申请第三方应用,一般微信,微博、Apple,github 都会有自己的 oauth 使用说明

OAuth2 的有几种授权方式,这部分内容以后有空再填

最后

一般来讲,小规模系统 Session + Cookie 就够用了,大规模系统就适合 Token 或者 双Token,若是需要第三方登录就用 OAuth2。

——转载自作者:Dolphin_海豚

全部评论
以为是你自己那么用心画图
点赞 回复 分享
发布于 01-15 08:53 广东
机考155非目标院校24届还有机会吗
点赞 回复 分享
发布于 01-27 15:39 湖南

相关推荐

01-15 11:05
门头沟学院 Java
华为海思 通软开发 总包大概在30左右
点赞 评论 收藏
分享
评论
5
20
分享

创作者周榜

更多
牛客网
牛客企业服务