面对前端鉴权登录,你需要知道的一切。从 Cookie 到 JWT、从 Session 到 SSO
在 Web 应用中,用户身份认证和授权是非常重要的一部分。为了确保应用程序的安全性和可靠性,前端鉴权登录技术成为了一个不可或缺的话题。同时在面试当中前端鉴权登录也会经常被提到相关的问题:
- 你的登录是如何实现的?
- 登录状态是如何维持的?
- 如何实现退出登录?
- ...
这篇文章将通过介绍 Cookie
、Session
、JWT
、SSO
、oAuth
等技术,来帮助你理解前端鉴权登录的实现原理、它们的优缺点以及它们的适用场景,以便于你在实战中能够更好的选择合适的技术来实现前端鉴权登录,同时能在面试中娓娓道来。
首先,我们需要知道为什么会有这些技术的出现,它们是为了解决什么问题的呢?
前言
写完文章回来补个前言
文章的内容很多,从 HTTP 开始到 SSO 单点登录都有涉及,介绍了每种方式的登录流程,都会存在哪些问题。
如果是初学者,可以一点点看下去,如果你只想看某部分,可以直接跳转到对应的章节
如果有什么疑问,或者文章有什么疏漏,错误的地方,欢迎在评论区指出
HTTP 是无状态的
我们都知道 HTTP 是无状态的,在每次服务端接收到客户端的请求时,都会是个全新的请求,服务器并不知道这个请求是谁发起的,也不知道这个请求是不是第一次发起的,也不知道这个请求是不是最后一次发起的。这就意味着,每次请求都是独立的,服务器不会记录任何请求的信息,也不会记录任何客户端的信息。
举个具体的场景来说:
我们在访问一些电商网站时,我们会挑选商品到购物车,然后结账。如果我们在结账时,服务器不知道我们是谁,那么我们就需要在结账时,再次输入我们的用户名和密码,这样服务器才能知道我们是谁,才能完成结账的操作。
这样的操作显然是不合理的,我们在结账时,服务器应该知道我们是谁,而不是再次输入用户名和密码。
这种不合理的方式的出现也源于 HTTP 是无状态的,服务器无法记住用户的信息。
那么,如果 HTTP 有了状态,那么服务端就可以记住用户信息,为什么不将 HTTP 设计成有状态的呢?
为什么 HTTP 不设计成有状态的呢,而是无状态的呢?
在设计之处的考虑,一方面是为了提高协议的可扩展性和灵活性,确保它可以适用于各种不同的应用场景。另一方面是为了减轻服务器的负担,使其更加轻量级。因此选择了无状态的设计。
如果 HTTP 直接是有状态的,那么服务端需要跟踪每个客户端的状态信息,这样会使得服务器的负担变大,而且也会使得服务器的可扩展性变差。
而如果是无状态的,服务器只专注于处理一个单独的请求,不需要跟踪状态信息。同时这也促使了客户端和服务器解耦,每个请求都是独立的,不依赖服务器,更加灵活和动态。
那么,在 HTTP 无状态的情况下,我们如何实现状态的维持呢?
认证标记
接着上面电商的例子来说,我们可以使用认证标记来维持状态
我们在登录这个电商网站的时候,给你发个通行证,这样你可以拿着通行证,在这个网站里到处逛。
就像在公司里,你会拿着公司的门禁卡, 门禁卡上有你的信息,这样你就可以在公司里到处走动。
你在这个网站里逛了一圈,然后你想结账,你就拿着你的通行证去结账,这样在你结账的时候,就可以出示你的通行证,这样就可以知道你是谁了,就可以完成结账的操作。
因此,诞生了这一系列的设计,从网景公司发明的 Cookie,到后来 Session
、LocalStorage
、IndexDB
等技术,都为客户端处理状态信息提供了很多的解决方案。
根基 Cookie
Cookie 是一种存储方式,它是为了解决 HTTP 无状态导致无法跟踪用户信息而出现的。
它的出现,使得我们可以在客户端存储一些信息,然后在后续的请求中,自动带上这些信息,这样就可以实现状态的维持。
相比于 LocalStorage
、SessionStorage
等方式,Cookie 借助浏览器的能力,可以实现跨域存储,同时可以做到前端无感知,HTTP 请求自动带上 Cookie。
Cookie 实现状态维持的流程是怎么样的呢?
- 首先客户端向服务端发送请求时,服务端在 HTTP 响应头中添加一个
Set-Cookie
字段,这个字段的值就是 Cookie 的值。包含了唯一的会话标识,客户端浏览器会把它存到本地。 - 当客户端再次向服务端发送请求时,都会自动带上这个 Cookie,服务端就可以通过这个 Cookie 来识别客户端的身份。
服务端返回的 set-cookie 字段
Cookie 还有一些常用的配置项,比如 domain
、path
、expires
、httpOnly
等,这些配置项可以用来控制 Cookie 的作用域,以及 Cookie 的有效期。
domain
:指定 Cookie 的作用域,如果不指定,默认是当前域名。如果指定了,那么 Cookie 只能在指定的域名下使用。expires
:指定 Cookie 的过期时间,如果不指定,默认是会话结束时过期。httpOnly
: 指定该 Cookie 是否只能通过 HTTP 协议来访问,如果设置了这个属性,那么通过 JS 脚本是无法访问这个 Cookie 的。- ...
Cookie 的 Secure 和 HttpOnly 标记
这里需要特别说到 httpOnly
和 secure
属性,因为它是 Cookie 的非常重要的属性。
安全的 Cookie 需要经过 HTTPS 协议通过加密的方式发送到服务器。即使是安全的,也不应该将敏感信息存储在cookie 中,因为它们本质上是不安全的,并且此标志不能提供真正的保护。
对于前端而言,我们可以自己创建 Cookie,也可以修改 Cookie
document.cookie = 'xxxx';// 伪代码
当然这些操作只对 httpOnly
为 false 的 Cookie 有效,如果是 httpOnly
为 true 的 Cookie,那么就无法通过 JS 来操作了。
因此,我们可以通过设置 httpOnly
为 true 来防止 XSS 攻击。
此外 Secure 也需要特别提一下,它是用来指定 Cookie 是否只能通过 HTTPS 协议来传输,如果设置了这个属性,那么通过 HTTP 协议是无法传输这个 Cookie 的。
它为网站提供了一定的安全性!
但不管如何,Cookie 还是通过明文传输的,还是会有一定的安全隐患。
Cookie 的安全问题
主要有两个方面的问题:
- Cookie 劫持
- XSS 攻击
Cookie 劫持
通过中间人攻击,拦截用户的 Cookie,然后再次发送给服务端,这样就可以伪装成用户,从而获取用户的信息。
我们可以通过
- 升级为 HTTPS 协议加密传输数据,可以有效的防止中间人攻击和黑客窃听。
- 同时可以限制 Cookie 的过期时间,缩短 Cookie 有效时间,来减少 Cookie 劫持的风险。
- 还可以设置
HttpOnly
和 Secure 属性,限制 Cookie 只能在 HTTPS 协议下传输,避免被 XSS 攻击窃取。
XSS 攻击
在前面也有提到,客户端可以直接操作 Cookie,那么就会有被 XSS 攻击的风险,攻击者将恶意脚本注入到网页中,从而获取 Cookie 信息。
我们可以通过以下这些方式来预防 XSS 攻击:
- 对用户输入数据进行过滤和验证,确保所有输入的数据都符合预期。
- 使用 CSP 来限制脚本执行的域,防止XSS攻击。
- 设置
httpOnly
为 true
Cookie 登录验证流程
在我们首次登录这个网站时,服务端会在 HTTP 响应头中添加 Set-Cookie
字段,携带 cookie 的值,cookie 的值是在服务器生成的,主要是 cookie 关联的域名、过期时间、安全连接、用户数据等内容。
在下次请求时,浏览器会自动带上这个 Cookie,服务端就可以通过这个 Cookie 来识别客户端的身份,进行鉴权。
Cookie 作为维持 HTTP 请求状态的根基,大多数前端鉴权问题都是依靠 Cookie 解决的,比如下面提到的 Session 方式
服务端 Session
Session 也是 Web 应用程序中常用的会话跟踪机制。它是一种在服务器端存储用户状态信息的机制,通常用于存储用户的身份认证信息、会话标识符等敏感数据。
它与 Cookie 不同,Session 会将用户的数据存储在服务端,并且会更具加密算法确保它的安全性。
Session 登录流程
Session 的实现方式是当客户端第一次向服务器发送请求时,服务器会为该客户端创建一个唯一的 SessionID,并在自己的内存中存储 Session 数据,Session ID 则通过响应头部中的 Set-Cookie
字段返回给客户端浏览器。客户端再次向服务器发送请求时,会携带该 Session ID,服务端根据 Session ID 获取对应的 Session 数据以判断用户的会话状态。
上图是在掘金上看到的,很清晰
- 首先客户端登录网站,发送账号密码给服务端。服务端校验密码是否成功
- 生成一个 SessionId,把登录状态存到服务端的 session 中
- 通过
Set-Cookie
把 SessionId 写入到 Cookie 中,返回给客户端 - 此后浏览器再请求,都会自动带上 cookie
- 服务端会根据 cookie 中的 SessionId 找到对应的 session,从而判断用户是否登录
- 成功后,返回数据给客户端
可以把 session 理解为一个 Map,是键值对的形式,key 是 sessionId,value 的内容可以是用户信息,登录状态都可以
那么 Session 如何实现退出登录呢?
Cookie 那块没有讲退出登录的部分,这两个是一样的。
首先,退出登录无非就是将登录状态置为未登录,那么我们就需要清除掉 Session 中的登录状态,同时也需要清除掉 Cookie 中的 SessionId。
那么如何清除 Cookie 中的 SessionId 呢?
我们可以通过将 Cookie 的过期时间设置为一个过去的时间来实现。
采用 cookie + 服务端 session 会存在哪些安全问题呢?
首先,cookie 本身是不安全的,因为它是明文传输的,所以很容易被窃取。因此攻击者可以通过获取用户的 SessionId 来伪造用户的身份,进行恶意操作。
可以通过以下的方式预防
- 使用 HTTPS 协议加密传输数据,可以有效防止中间人攻击和黑客窃听。
- 生成随机且复杂的 Session ID,并设置合理的过期时间,避免被猜测或者重放攻击。
- 不要在 URL 中使用 Session ID,避免会话固定攻击。
- 定期更换 Session ID,以保证会话安全性。
还有一种安全问题是攻击者使用已知的 Session ID 信息,直接访问用户的会话,从而获取用户的敏感信息。
可以通过以下的方式预防
- 在会话开始时生成新的 Session ID,避免使用容易被猜测的 Session ID,例如递增的数字序列等。
- 使用
HttpOnly
和Secure
属性限制 Cookie 只能在 HTTPS 协议下使用,防止被 XSS 攻击窃取。
前面我们说了很多关于服务端 Session 的东西,它需要结合 Cookie 一起使用,那么如果客户端不支持 cookie 呢?这时候怎么办?
URL 重写 Session
URL 重写 Session 是一种在 URL 中传递 Session ID 的方式,不需要在客户端和服务端之间保存 Session 数据,而是通过在 URL 中添加 Session ID 来实现会话跟踪。
例如,在会话 ID为 ljc 的应用程序中,可以使用以下URL:
http://linjunc.com/index.html?SESSIONID=ljc
然后服务端通过检查 URL 的参数来识别会话,和存在 Cookie 的方式一样,对于前端而言就是 SessionId 存在哪里的问题。
但是这个方案也只是一个无奈的选择,因为它不安全,因为 URL 是明文传输的,所以很容易被窃取,攻击者可以通过获取用户的 SessionId 来伪造用户的身份,进行恶意操作。同时在部署和维护上也会带来一些麻烦。
服务端 Session 的方式相较于 Cookie 而言,安全性更高一些,但是会有两个问题:
- 首先服务端需要存储 Session,这样就会占用一定的内存,而且 Session 也是有过期时间的,所以需要定期清理过期的 Session,这样就会带来一些性能问题。
- 不同的服务器,无法共享 Session,通常需要借助 Redis (内存型数据库)解决
JWT Token
前面我们说了 Cookie 和 Session 两种方案,存在着这些问题
- 服务端 Session 需要在服务端维护,需要找地方保存它,又要考虑分布式的问题,甚至要单独为了它启用一套 Redis 集群。
- 都基于浏览器 Cookie 实现,用户禁用 Cookie 后,系统就无法正常使用了。
我们来回想一下,前面的方案是如何出现的,为了解决什么问题而出现的呢?
首先为了让服务端知道用户的登录状态,我们在客户端存储了 Cookie,然后服务端判断 Cookie 内容,来判断用户是否登录。但是这样需要将用户数据存放用 Cookie 中,这样就会存在安全问题,所以我们就想到了服务端 Session,它不需要将用户数据存放在 Cookie 中,而是存放在服务端,这样就可以避免了一定的安全问题。
现在我们又觉得 Cookie 存 sessionId 的方式不太好,同时服务端需要维护 Session,也有很大的开销。
那么有什么办法可以解决这些问题呢?
我们想想生活的场景,我们去景区的时候,带上学生证可以打 5 折,景区为了确定我们的身份,就会看我们学生证的有效期,还有照片对不对的上,这样就确定了我们的身份了。
那么在登录的这个场景上,我们是不是可以约定一个证件,它包含了一些用户的信息,服务端在看到这个证件的时候,就可以确定用户的身份了。
JWT 就是这样一种方案,它是一种基于 Token 的身份认证机制,它的特点是无状态,也就是说,它不需要在服务端保存会话信息,也不需要在客户端保存会话状态。
这样我们在客户端上存放的就不是 sessionId 或者是用户数据了,而是 token 字符串。
从广义上来说,JWT 就是一个标准,它定义了一种在客户端和服务端之间传递的数据格式,这个数据格式可以包含用户的信息,也可以包含用户的权限信息。
从狭义上来说,JWT 就是一个令牌,也就是我们在传递的 token 字符串。
我们先来介绍一下 JWT 的会话流程
JWT 会话流程
下面我们来看一下 JWT 的会话流程,token 是如何在客户端和服务端之间传递的。
- 首先用户通过账号和密码向服务器请求登录。
- 服务端校验用户身份后,生成一个 Token,然后将 Token 返回给客户端。
- 客户端需要在本地保存这个 Token,以便后续的请求携带 Token。
- 客户端可以在本地存储或
sessionStorage
中保存 Token,也可以在 Cookie 中保存 Token。 - 客户端在后续请求中,都需要将 Token 发送给服务器验证。一般将 Token 放在 HTTP 请求头的
Authorization
字段中,发送给服务器。Bearer [Token]
- 服务端在接收到请求后,会从
Authorization
字段中获取 Token,并验证 token 是否有效,签名是否正确,是否过期等。通过后,再进行响应。
JWT 的生成规则
知道了 JWT 的会话流程,我们来看一下 JWT 的生成规则。它是如何保证安全性的呢?
JWT 由三部分构成:header(头部)、payload(载荷)和 signature(签名)。每个部分用 .
做分隔
它们各个部分都是如何生成的呢?
Header
首先是 JWT 的头部 header,它是一个 JSON 对象,包含两个属性:alg
和 typ
alg
属性指定了用于生成签名的算法,例如 HMAC SHA256 或 RSA。typ
属性用于表示令牌类型,通常为 “JWT”。
例如
{
"alg": "HS256",
"typ": "JWT"
}
然后将这个 JSON 对象进行 Base64 编码,得到的字符串就是 JWT 的头部 header。
payload
JWT 载荷是包含实际信息的部分,通常是一个 JSON 对象
payload 可以包含任意数量的声明,声明是一个键值对,用于描述有效载荷中所包含的信息。
声明分为三类:注册声明、公共声明和私有声明。
注册声明是指预先定义的声明,它们不是强制的,但是建议使用,以避免冲突。
iss (issuer)
:签发人exp (expiration time)
:过期时间sub (subject)
:主题aud (audience)
:受众nbf (Not Before)
:生效时间iat (Issued At)
:签发时间jti (JWT ID)
:编号
公共的声明,可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
私有声明:用于在双方之间传递的自定义声明,不建议在公共场合使用。
{
"sub": "0771",
"name": "Linjunc",
"iat": 151223,
"userId": "sfhsjkfhskj"
}
然后同样的会将 payload JSON 对象进行 Base64 编码,得到的字符串就是 JWT 的载荷 payload。
需要特别注意的是:!!!
payload 部分默认是不进行加密的,因此不要将隐私信息直接存放再 payload 中!!!!!它仅仅只是 JSON 转成了 base64 的形式。
JWT 的 token 相当于是明文存储的,是可以被解密的,所以不要将敏感信息放在 payload 中。
signature
最后是 JWT 的签名 signature 部分,它由三部分结合算法生成:
- base64 后的 header
- base64 后的 payload
- secret 私钥
首先创建签名我们需要一个密钥,这个密钥只有服务器才能知道,不能泄露给用户,然后需要用在 JWT header 中,指定使用的签名算法,例如 HMAC SHA256 或者 RSA,进行签名。
以下是使用 HMAC SHA256 算法生成 JWT 签名的公式:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
签名用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证 JWT 的发送者的真实身份
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点".
分隔,就可以返回给用户。
我们用前面的 payload 生成一个 token 看看
得到一个 token 字符串
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNzcxIiwibmFtZSI6Ikxpbmp1bmMiLCJpYXQiOjE1MTIyMywidXNlcklkIjoic2Zoc2prZmhza2oifQ.xKsH1IZj5UizJ0a_zYplb8neUOoFrUW3ZLpgu4rbTqc
在客户端登录时,会存储服务端发回来的 token,然后在每次请求时,都会在请求头中带上这个 token,服务端会验证这个 token 是否有效,如果有效,就会返回数据。
这样解决了使用服务端 session 方案需要在服务端维持状态的问题,我们利用 token 的 payload,来存放用于校验用户信息的数据,相当于是在客户端维持了状态。
同时我们可以在 token 的 payload 中设置过期时间的字段,用来维持短暂的登录状态,总之,我们将信息可以存储在 payload 当中。
现在我们来想想,之前我们在 Cookie 和服务端 session 上遇到的安全问题,JWT 有很好的解决吗?
- 当用户退出登录了,前端删除了 token,但是由于服务端只有解密 token 的能力。这意味着,如果我们的 token 泄露了,这个 token 仍然是可以使用的,那么我们应该怎么处理呢?
- 另外,我们将 token 存在客户端中,会不会有 token 被篡改的问题呢?或者说捏造 token 的问题呢?
下面我们一一来看看 JWT 是如何解决这些安全问题的。
JWT 是如何防止被篡改?
在前面 JWT token 的生成规则中,我们知道,JWT 的签名部分会使用到 header 和 payload,以及一个 secret 私钥,这个私钥只有服务端才知道,所以我们可以利用这个特性,来防止 token 被篡改。
就算客户端拿到了 token,也无法伪造一个新的 token,因为它不知道 secret 私钥是什么,也就没有办法生成一个新的签名。
在整个 JWT 认证流程中,服务端在收到客户端请求头中携带的 token 时,会通过解析 token,然后使用相同的算法,将 header 和 payload 以及 secret 私钥进行签名,然后和 token 中的签名进行对比,如果一致,说明 token 没有被篡改,如果不一致,说明 token 被篡改了。
这样就确保了 JWT 被篡改后,服务端会拒绝这个 token 的场景。
而如果服务端的 密钥 secret 被泄露了,那就没救了,攻击人可能直接修改 header、payload 然后用密钥重新生成一个新的签名
因此,我们需要保证密钥 secret 的安全性,他是 JWT 安全的核心所在。
如何实现退出登录?
前面我们说了,JWT 的机制,使得 token 天然的安全性会更高一些,可以有效的避免被篡改的问题。
但是我们知道 token 是无状态的,它的声明被存储在客户端,而不是服务端内存中。这意味着,如果我们想要退出登录,客户端删除了本地的 token,token 仍然生效,服务端并不知道这个 token 不能继续使用了。
那么我们应该怎么做呢?
这里想到了 3 种方法
- 可以在 payload 种设置过期时间
- 服务端白名单机制
- 服务端黑名单机制
下面我们一一来看看这三种方法
在 payload 种设置过期时间
我们可以在 payload 中设置一个过期时间的字段,比如叫 exp,然后在 token 生成的时候,设置一个过期时间,比如 1 小时,那么这个 token 就会在 1 小时后过期。
这样,当客户端请求时,服务端会解析 token,然后判断 token 是否过期,如果过期,就拒绝这个 token,如果没有过期,就继续处理。
这样我们在退出登录后,token 会在过期时间后失效,服务端就会拒绝这个 token。
但是这种方法有一个问题,就是如果用户的 token 被泄露了,那么攻击者就可以在 token 过期前,不断的使用这个 token,这样就会造成安全问题。
因此这只是一个临时的解决方案,我们需要更好的解决方案。
服务端白名单机制
白名单和黑名单的机制都是需要在服务端维护一个数据,来标记哪些 token 是失效的,或者哪些 token 是有效的。这种做法其实有点违背 JWT 的无状态的特性,但是这是一种比较常见的做法。
白名单机制就是维护一个有效的 token 列表,当用户登录时,服务端会把生成的 token 存在服务端的内存中。
在用户注销时,服务端会把这个 token 从内存中删除,这样,当用户再次请求时,服务端就会拒绝这个 token。
这种方式和 cookie、session 的机制很像,但同样会带来内存上的开销。
服务端黑名单机制
黑名单机制和白名单机制相反,它维护一个无效的 token 列表,在用户注销登录后,会把用户 token 存到 token 列表中,当这个 token 再次被用来请求时,就会命中黑名单,而被拒绝请求
黑名单机制不需要将所有登录的 JWT 都缓存,只在退出时缓存,有效的缓解了服务器的压力。
黑名单机制非常巧妙的解决了内存消耗的问题,在实际场景中,token 过期的数量远远大于注销登录的数量,所以黑名单机制的内存消耗要远远小于白名单机制。
无论是白名单还是黑名单,都需要为 token 设置过期时间,避免 token 在内存中一直存在,造成内存泄露。
当然这些方法都存在各自的缺点,但是 JWT 的机制就是这样,在内存和安全之间做出了一个权衡。
如何实现无感刷新?
前面我们为了实现退出登录,为 token 设置了过期的时间,这样当 token 过期后,服务端就会拒绝这个 token。这样客户端就会频繁的需要重新登录。
那么我们需要如何实现无感刷新呢?在用户 token 过期前,自动刷新 token,让用户无感知的刷新 token。
临近过期时间
最容易想到的就是在每次请求的时候,判断 token 的过期时间,如果临近过期时间,我们就主动刷新 token,然后把新的 token 返回给客户端。
客户端每次请求都需要都需要检查新旧 token,更新本地 token,这样会造成客户端的负担,不太友好。
refreshToken 刷新机制
我们可以引入 refreshToken
的概念,用来做刷新 token 的凭证,当 token 过期时,我们就用 refreshToken
来获取一个新的 token。
在用户登录的时候,服务端返回两个 token
- 一个是
accessToken
,用来做用户的鉴权,过期时间比较短,比如 1 小时 - 一个是
refreshToken
,用来刷新accessToken
,过期时间比较长,比如 7 天
客户端把这两个 token 都存在本地,每次访问都将 accessToken
传给服务端,服务端校验 accessToken
有效后,响应。
如果服务端校验 accessToken
过期后,那么就需要将 refreshToken
传给服务端,如果 refreshToken
是有效的,那么服务端就会返回一个新的 accessToken
,客户端就可以使用新的 accessToken
继续访问。
客户端需要更新本地的 accessToken
,这样就实现了无感刷新。
这里一共会发送 2 次的 HTTP 请求,第一次过期被拒绝,第二次刷新 token。
需要注意的是
需要注意的是,为了保证安全性,刷新令牌通常会更长,并且只能用来获取新的访问令牌。而且,在设计刷新令牌时要考虑到其失效时间、有效范围等方面的控制,以减少安全风险。
这里可能会有疑惑,两个 token 都需要存在客户端,这样岂不是很不安全吗?
确实如此,但是由于 JWT 的出现就是为了解决服务端不想存储 session 的问题,所以这个问题没有很好的解决方案,我们只能让 refreshToken
的校验更加安全一些。
比如:可以通过绑定客户端 client_id
和 client_secret
来保证 refreshToken
的安全性,这样就可以保证 refreshToken
只能在特定的客户端使用。
总结
这样我们就完成了 JWT 相关的内容,这部分我们介绍了 JWT 的验证流程,JWT token 的生成规则,退出登录的实现,以及无感刷新的实现。
我们需要明确一点就是,JWT 是无状态的,服务端不需要存储任何信息,所以 JWT 的安全性取决于 token 的安全性。
OAuth 2.0
前面我们聊了很多种方式,其中也提到了 refreshToken 的方式,这里我们就来聊聊 OAuth 2.0。
它是一种常见的认证和授权协议。我们用微信公众号网页授权为例子,来看看它的认证流程。
网页授权流程
我们可以在微信开放平台上看到网页授权流程。
主要有四步
- 引导用户进入授权页面同意授权,获取code
- 通过 code 换取网页授权 access_token(与基础支持中的access_token不同)
- 如果需要,开发者可以刷新网页授权 access_token,避免过期
- 通过网页授权 access_token 和 openid 获取用户基本信息(支持UnionID机制)
具体的流程和实现是这样的:
- 首先客户端向授权平台发送认证请求,请求参数为重定向 URI 以及公众号的唯一标识 appid 等信息,重定向 URI 是一个URL,用于接收授权服务器的响应,并包含授权码。
- 授权服务器验证资源所有者的身份,并要求资源所有者批准客户端的请求。
- 在完成授权后,会跳转到 重定向 uri 并附上授权码 code,即
redirect_uri/?code=CODE&state=STATE
- 接下来我们通过这个授权码 code,向服务器请求 accessToken
- 客户端存储这个 token 来访问资源
在微信的开放能力中,都需要有 accessToken 来访问开放能力接口
在实际场景中,服务端一般不会直接返回用于网页授权的 access_token 给客户端,而是会返回一个 token,这是另一个 jwt 生成的 token,而 access_token 会由服务端来管理,这样就可以保证 access_token 的安全性。
我们在授权登录后,服务端会生成一个 token 返回给客户端,并将 token 和 access_token 存在服务端 session 中,我们通过 token 来请求能力接口时,会先验证 token 是否生效,再通过 session 中的 access_token 来请求能力接口。
refreshToken 刷新机制
在 JWT 中我们也有讲到 refreshToken 的刷新机制,这里是一样的,只不过这个 refreshToken 是由授权服务器生成的。
相当于现在服务端会存三个 token:jwtToken、accessToken、refreshToken。
用户通过 jwtToken 来访问,由后端来验证 jwtToken 的有效性,再来做向能力接口请求或者是刷新 accessToken 的操作
SSO 单点登录
前面我们采用了各种方式实现了身份认证,但是现在有这样一个问题,如果我们有多个系统,每个系统都需要登录,这样的体验就非常不好,那么我们就需要采用单点登录的方式来实现。
所谓单点登录,就是用户在一个系统登录后,其他系统可以直接访问,不需要再次登录。
下面我们来看看 SSO 的登录流程是怎么样的?
SSO 登录流程
我们以一个全新的用户为例,登录 a.com 和 b.com
这两个子系统时,SSO 的登录流程是怎么样的? 首先我们先了解几个名词,不然看着会很懵
ticket
:ticket 是 SSO 系统发给子系统的凭证,用来向子系统证明用户身份,获取子系统 tokentoken
:子系统的访问凭证,用来访问子系统的资源- 局部会话:局部会话是指用户在子系统中的会话,也就是用户在子系统中的登录状态
- 全局会话:全局会话是指用户在 SSO 系统中的会话,也就是用户在 SSO 系统中的登录状态
SSO 首次登录
- 用户访问
a.com
,a.com
发现没有登录,没有ticket
,跳转到 SSO 系统的登录页面,进行身份认证 - 在登录完成后,SSO 系统会生成一个
ticket
,然后重定向到a.com
,并且带上ticket
,有点类似网页授权带的 code a.com
拿到ticket
后,向a.com
服务端发送请求,a,com 服务端会向 SSO 系统发送请求,验证ticket
的有效性ticket
有效,会生成一个 token,然后返回给客户端,客户端拿到 token 后,存储起来,用于后续直接登录
SSO 已登录,访问 b.com
在上面我们完成了 A 系统的登录认证,并完成了 SSO 登录,现在要使用 B 系统了,看看会发生什么
- 用户进入
b.com
,没有ticket
,没有 token,B 系统重定向到 SSO - SSO 登陆过,不需要再次登录,再跳回 B 系统,携带
ticket
- B 系统被再次访问,携带了
ticket
,会向 SSO 验证ticket
是否有效 - B 系统放行,生成 token,发布 B 系统登录凭证
A,B 系统均登录
- 用户进入系统,判断 token 是否失效即可
在上面的流程中,我们介绍了 SSO 登录的大体过程,虽然流程没什么问题,但是我们从安全性的角度来想想,我们在传输 ticket
的时候,通过重写 URL 的方式,这样直接将 ticket
暴漏在了 URL 上,存在比较大的安全风险。
可以采用 JWT 方式提高 ticket 的安全性。
SSO 存在的问题
如何防止 ticket 被篡改?
ticket 里边是有用户凭证的,黑客如果篡改了 ticket 里边的用户凭证,比如改成黑客自己的,那到子系统 A 登录的时候,登录的就是黑客的身份了。
那么我们可以参照 JWT 的方式,采用私钥加密,通过 payload 传输数据。
只要加密数字签名的私钥不泄露,就无法捏造有效的数字签名。
如何防止 ticket 被盗用?
前面也说到 ticket
拼接在地址栏,安全风险很大,如果黑客拿到我们的 ticket
,岂不是能直接去子系统 A 登录了?
我们可以在颁布 ticket
的时候,获取用户的 ip 作为 payload 的一部分,采用 JWT 机制,来生成 ticket
,在验证 ticket
有效性的时候,再次获取用户的 ip,并解析 payload 中的 ip,对比是否一致
当然,这种方式有一定的缺点,黑客可以通过伪造 ip 的方式来绕过这个检验。
个人感觉可以采用携带 code 的方式,再向 SSO 请求 ticket,通过 HTTP 请求的方式,而不是重定向的方式。这样拿到 ticket 之后,就可以验证信息了。
ticket
如何设置只允许使用一次?
ticket
被多次使用,会有很大的风险。
可以在颁布 ticket
的时候,将 ticket
存储在 Redis 中,并设置一定的过期时间。
如果子系统验证 ticket
有效的时候,我们就会删除这个 ticket
。
如果 Redis 不存在这个 ticket
,有可能是过期了,也可能是被用过了,都是失效了,需要重新生成。
如何防止重放攻击?
重放攻击就是:黑客拦截了我们的请求,获得我们发给服务端的一个合法请求,然后重复的发送该合法请求,若该请求耗时过长,极端调用可能会搞崩我们的系统。
那么解决方法很简单,让合法的请求,只能被执行一次,保证每次请求的唯一性。
时间戳和随机数
在每个请求中添加一个时间戳或随机数,并将其与服务器上已接收到的值进行比较,如果时间戳或随机数相同,则认为是重复的请求。
限制请求频率
限制来自同一 IP 或用户的请求频率,防止恶意攻击者重复发送相同的请求。
如何选择合适的前端鉴权方式?
看到这里,我们已经介绍了 Cookie、Session、JWT、oAuth、SSO各种登录认证的方式,那么我们在实际场景中,该怎么去选择呢?需要考虑什么因素呢?
安全性
不同的鉴权方式的安全性不同,Cookie 和 Session 等传统鉴权方式存在被劫持、伪造等风险;而 JWT 等基于 Token 的鉴权方式则能有效避免这些风险。
如果基于安全性考虑,可以选择 JWT 这种验证方式,它提供了一种简单而安全的方法来传输用户凭据并防止 CSRF 和 XSS 等攻击。
扩展性
如果您需要一个可扩展的身份验证方案,建议使用 OAuth 2.0。它是一个开放标准,可以与多个应用程序和服务集成,并具有灵活的授权机制。
多平台
如果是大型企业,或者学校,这种应用体系复杂的场景,可以考虑 SSO。为用户提供无缝的登录体验,在各个应用程序之间自由切换。
...
总之,选择合适的鉴权方式需要考虑多个因素,包括安全性、扩展性、用户体验、跨平台支持和访问控制等。
总结
文章的最后,总结一下,这篇文章从 HTTP 是无状态的讲起,到 Cookie、Session、到 JWT 的演变,它们都在往更好的方向发展,不断的解决遗留下来的问题。
本文的要点如下:
- HTTP 是无状态的,因此需要客户端存储标记
- Cookie 方案将用户信息存储在客户端,存在被篡改、被盗用的风险
- 服务端 session 方案,依赖于 Cookie 实现,将用户信息存储在服务端,在客户端存放 sessionID,占用服务端资源
- JWT 利用 token 机制,增强了安全性
- SSO 单点登录,解决了多个系统登录的问题