跳到主要内容

2 篇博文 含有标签「sso」

查看所有标签

· 阅读需 7 分钟

JSON Web Token,既是一种数据标准,也是一种登录认证方案

[HEADER].[PAYLOAD].[SIGNATURE]

Header 是一个 JSON 对象,描述 JWT 的元数据,例如加密算法或类型等

Payload 是一个 JSON 对象,实际需要传递的数据

Signature 是签名,使用密钥对前两部分使用加密算法进行加密后的字符串

定义

JWT 全称 JSON Web Token,定义了一种在网络上安全传输以 JSON 格式包含的声明信息(数据)的标准,也指代了一种登录认证方案

由来

传统的 cookie-session 登录模式流程如下

1、客户端向服务器发送用户名和密码

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等

3、服务器向客户端返回一个 session_id,写入客户端的 Cookie

4、客户端随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份

这种模式具有一些明显的弊端

  1. 扩展性不高:在分布式系统中,如果用户的请求被路由到不同的服务器,需要共享 Session,处理这个问题会相对麻烦。这是因为,会话信息通常保存在服务器的内存中,当服务需要扩展时,也需要同步这些会话信息。
  2. 存储压力:如果网站有大量的并发访问,每个用户登录都需要服务器存储用户的会话信息,这无疑会增加服务器的存储压力。
  3. 无法携带数据:客户端的 Cookie 只是一个会话 ID,要获取用户的登录信息,还需要在服务器端进行查询。
  4. CSRF攻击:与跨站点脚本 (XSS) 不同,跨站点脚本 (XSS) 利用用户对特定站点的信任,而 CSRF 利用站点在用户浏览器中的信任。不过现在可以通过 sameSite|secure|httpOnly 等属性解决一定的 cookie 安全性问题
  5. 存在被篡改和伪造的隐患:如果某个人能够拦截这个会话ID,那么他就可以通过伪造这个会话ID来假冒用户身份,这就是所谓的“会话劫持”。此外,如果服务器端的会话存储不当,比如存储在可被外部访问或者能够被SQL注入的数据库,那么这些会话数据就有可能被篡改。

为了提高安全性减轻服务端压力实现单点登录等,JWT 应运而生

组成

JWT 由三部分组成,中间由 . 连接

[HEADER].[PAYLOAD].[SIGNATURE]

Header 是一个 JSON 对象,描述 JWT 的元数据

{
"alg": "HS256", // 加密算法
"typ": "JWT" // token 类型
}

将上面的 JSON 对象使用 Base64URL 算法转成字符串 btoa 解码 atob

注意这里是 Base64URL 而不是 Base64

Base64编码使用的字符包括A-Z,a-z,0-9,+和/,并且在需要的时候使用=作为填充。然而,这些字符在URL中有特殊的含义,可能会被认为是分隔符,也可能在传输过程中被改变。

Base64URL编码是为了解决这个问题而产生的。它将Base64编码中的+和/分别替换为-和_。这样,就可以在URL和文件名中安全地使用Base64URL编码的字符串,不需要进行额外的URL转义处理。

Payload

Payload 也是一个 JSON 对象,用来存放实际需要传递的数据

{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iss": "签发人",
"exp": "过期时间",
"nbf": "生效时间",
"iat": "签发时间",
"jti": "编号"
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),产生前两部分的签名,再经过 Base64URL 编码后作为 JWT 的第三部分

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

流程

登录成功后,服务端使用密钥生成 jwt 返回给客户端,客户端保存在本地。再次发起请求时,服务端将 jwt 放到 Authorization 请求头中,服务端拿到 jwt 字符串,使用密钥解密,验证用户是否有效,解密成功且用户有效,则校验通过。

优缺点

  • 优点:解决了上述传统登录模式的问题,提升了数据传输的安全性、减轻服务端压力(自包含用户信息,无状态,无需保持会话状态)
  • 缺点:由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑(黑名单)。

· 阅读需 9 分钟

session-cookie模式:认证中心既要登录又要鉴权,虽然实现对用户的绝对掌控,但压力过大

token 模式:认证中心只需登录,签发 token,鉴权就交给子服务,减轻压力。但是认证中心失去了对用户的绝对控制

既希望对用户的绝对控制,又不想给认证中心太大压力,推出了双 token 模式。第一个 token 过期时间很短,第二个 refreshToken 过期时间较长,第一个过期了就去认证中心用 refreshToken 换新的。 让用户每隔一小段时间来一次认证中心,从而保持认证中心对用户绝对的控制

需求

某公司有很多子产品线,为了提升用户体验,希望用户只要在其中一个系统登录,再访问另一个网站就会自动登录

一种实现方案基于 session-cookie,将用户管理的业务单独抽离出来,成为独立的用户认证中心,用来鉴权与管理用户信息。

1. 用户向认证中心发起登录请求,登录成功后,认证中心会生成 session_id,结合用户信息,以 key value 的形式储存到服务器的数据库/内存中
2. 将 session_id 通过 Set-cookie 方法发送给客户端
3. 当用户再次向子系统发起请求时,浏览器会自动带上这个 id 作为 cookie 请求头,供服务端验证
4. 此时子系统会访问认证中心的服务器,认证中心来验证用户的登录状态
5. 如果服务端的 session 数据中有这条数据,说明用户的登录验证是有效的;反之说明用户没有登录或登录过期

image-20240228134957767

这种方式的优势在于

  • 架构清晰

  • 认证中心对于用户的管理具有很强的控制力

缺点也显而易见——烧钱

  • 对于用户量较大的应用,同时在线人数很多,无论是认证中心的请求响应业务还是数据存储的压力都非常大,所以对服务器的配置要求也非常高;

  • 而且如果认证中心服务挂了,所有系统都瘫痪了,所以还需要做容灾;

  • 还有就是只要某个子系统有扩容需求时,认证中心的服务为了满足这个子系统的需求也要跟着扩容

Token

为了降低认证中心的压力,降低成本,提出了 Token 模式

即认证中心服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

1. 用户向认证中心发起登录请求,登录成功后,认证中心会加密生成一个不会被篡改的字符串(Token),发送给客户端
2. 客户端本地保存 localStorage/cookie,接下来认证中心就什么都不管了
3. 当用户再次向子系统请求受限的资源时,将这个 token 也带过去,由子系统自行验证即可(与认证中心共享加密密钥)
4. 子系统无需频繁向认证中心验证用户登录状态,

image-20240228135309184

优点在于显著降低了成本低了,也解除了与子系统的依赖

缺点在于认证中心失去了对用户绝对的控制

双 token

为了弥补这个缺陷,在 token 方案的基础上,提出了双 token 的模式,

即在普通 token 的基础上,增加一个 refreshToken

1. 用户向认证中心发起登录请求,登录成功后,认证中心会加密生成两个 Token,发送给客户端
2. 一个是可以供子系统自行认证的 token,另一个是只有认证中心才能解密的 token
3. 但是第一个 token 过期时间通常设置的短一些(10 min),第二个 refresh Token 的过期时间要久一些(一个月)
4. 当第一个 token 过期时,用户就会使用 refreshToken 请求认证中心,认证中心校验成功后,颁发新的 token

image-20240228135720305

这样的目的就是让用户每隔一小段时间来一次认证中心,从而保持认证中心对用户绝对的控制

如何实现 Token 的无感刷新?

Token 无感刷新就是上面说的双 token 的具体实现方案,只有在单点登录的场景下才可以讨论

主体逻辑并不难,难在处理刷新时机与冗余刷新等一些细节问题

利用响应拦截器来完成主体逻辑

如果响应中含有 token 或者 refreshToken,则存储到本地

如果响应表示无权限

request.js

import axios from 'axios'
import { setToken, setRefreshToken, getToken } from './token'
import { refreshToken, isRefreshRequest } from './refreshToken'

const ins = axios.create({
baseURL: 'xxxx',
headers: {
Authorization: `Bearer ${getToken()}`
}
})

ins.interceptors.response.use(async (res) => {
// 如果有短 token,就保存/更新 ls
if (res.headers.authorization) {
const token = res.headers.authorization.replace('Bearer ', '')
setToken(token)
// 修改默认请求头为最新的 token
ins.defaults.headers.Authorization = `Bearer ${token}`
}

// 如果有 refreshtoken,就保存/更新 ls
if (res.headers.refreshtoken) {
const refreshToken = res.headers.refreshtoken.replace('Bearer ', '')
setRefreshToken(refreshToken)
}

// 如果响应无权限且不是刷新 token 的请求,就调用更新 token 的接口,并重新发起请求
if (res.data.code === 401 && !isRefreshRequest(res.config)) {
// 刷新 token
const isSuccess = await refreshToken()
if (isSuccess) {
// 如果成功换到了新 token
// 使用之前请求的配置(修改权限请求头)重新发起请求
res.config.headers.Authorization = `Bearer ${getToken()}`
const resp = await ins.request(res.config)
return resp
} else {
console.log('请重新登录')
}

}

return res.data
})

export default ins

refreshToken.js

import request from './request'
import { getRefreshToken } from './token'

// 缓存刷新 token 请求的 promise
let promise

export async function refreshToken() {
if (promise){
return promise
}

promise = new Promise((resolve) => {
const res = await request.get('/refresh-token', {
headers: {
Authorization: `Bearer ${getRefreshToken()}`,
},
// 标记是否是刷新 token 的请求
__isRefreshToken: true
})
resolve(res.code === 0)
})

promise.finally(() => promise = null)

return promise
}

export function isRefreshRequest(config) {
return !!config.__isRefreshToken
}

为了解决刷新 token 期间产生的新请求,仍然会重复请求刷新 token 的问题

我们可以将 refreshToken 的结果缓存为一个变量 promise,如果这个 promise 没结束,说明还没完成刷新 token 的请求

这样一来,在刷新 token 期间产生的请求,都等待(await)的是同一个刷新 token 的 promise,解决了冗余请求刷新 token 的问题