Token与Oauth2.0
最近在做一个接第三方页面嵌入iframe的项目,用到了Token,这里记录一下相关知识。
简易版Token流程:
- 用户登录,提供用户名和密码等一系列登录信息给后端
- 后端经过数据库等校验确定该用户在平台注册过,于是返回前端结合了用户名和密码等信息,加密构成的Token
- 前端在后续的请求中在
Authorization请求头提供给后端这个Token,用于验证该用户信息以及维持登录态
但是以上引发了另一个问题:
Q: Token是在请求中等于是明文传输,也就是可以轻而易举得拦截,存在信息冒充和顶替的风险,应该怎处理这种安全泄露问题?
(1.1) 缩短Token的有效期,如果Token只有10分钟,那么等到Token被窃取并且使用的时候,Token可能就已经过期了
(1.2) 使用IP,使用上下文场景,异地登陆等场景辅助校验是否Token被窃取使用
Q: 如果Token只有十分钟,那么如果有个场景让用户填特别复杂的表单,等到用户终于完成前置冗余的操作,准备提交表单,突然弹出信息,需要重新登录,前置信息可能作废,怎么办
服务端在提供Token的时候会提供accessToken和RefreshToken,其中accessToken就是上文用于信息验证的Token,有效期是10分钟,refreshToken则会存储在cookies,有效期是七天,但是使用一次之后会自动过期:
- 后端提供refreshToken和accessToken给前端,前端拿到Token之后使用accessToken验证请求
- 如果十分钟之后accessToken过期,后端会返回一个401告知Token过期,然后这个时候前端会停止正在发送的请求和之前发送失败的请求,都放在一个请求队列中,维持pending态,并开启isRrefeshing锁
- 然后前端给后端发送refreshToken,去获取新的accessToken和refreshToken,前端拿到新的accessToken,再从pending队列中拿到之前的请求,再次请求,同时关闭isRrefreshing锁
- 如果refreshToken过期,那么才会跳转登录态(一般refreshToken过期了,accessToken一定会过期,因为只有accessToken过期,才会验证refreshToken是否过期)
刷新分为主动刷新和被动后端返回401刷新两种,主动刷新是登陆成功之后建立倒计时setTimeout,倒计时往往限制在accessToken过期前30s左右,自动取refreshToken进行刷新,避免后端返回刷新的时间卡顿感,但是还是优先以后端返回的结果为主,前端的倒计时为辅
通过refreshToken获取accessToken的请求必须携带{ withCredentials: true }这个参数!!! 核心作用是:控制浏览器在跨域请求时,是否携带「凭据信息」(Cookie、HTTP 认证信息、TLS 客户端证书等)到服务器。
- 默认值:
false→ 跨域请求时,浏览器不会携带任何 Cookie / 认证信息; - 设置为
true→ 跨域请求时,浏览器会携带当前域名下的 Cookie(包括 HttpOnly Cookie)到后端。
无 withCredentials: true 的问题:前端调用 http://localhost:8080/api/refresh-token 时,因为是跨域请求,浏览器会拒绝携带后端域名下的 refreshToken Cookie → 后端拿不到 refreshToken → 刷新 Token 失败
前后端跨域,需确保:
- Axios/Fetch 配置
withCredentials: true(允许携带 Cookie);- 后端配置 CORS 响应头:
Access-Control-Allow-Credentials: true,且Access-Control-Allow-Origin不能为*(需指定具体域名);- Cookie 的
SameSite属性配置为Lax(兼容跨域)或Strict(同域),避免浏览器拦截 Cookie。
// utils/request.js
// 刷新锁:防止并发请求重复刷新 Token
let isRefreshing = false;
// 存储刷新期间的待重试请求
let retryRequestQueue = [];
// 核心:刷新 accessToken 的方法
const refreshAccessToken = async () => {
try {
// 调用后端刷新 Token 接口(refreshToken 优先通过 HttpOnly Cookie 自动携带,无需手动传)
const res = await axios.post('/api/refresh-token', {}, { withCredentials: true });
// 返回新的 accessToken 和有效期(秒) accessToken一般直接走json返回
return {
token: res.data.accessToken,
expiresIn: res.data.expiresIn,
};
} catch (error) {
// 刷新失败:清空 Token,跳转登录页
localStorage.removeItem('accessToken'); // 若存在 localStorage 则清除
window.location.href = '/login'; // 跳转登录
throw error; // 抛出错误,终止后续逻辑
}
};
// 请求拦截器:添加 accessToken 到请求头
service.interceptors.request.use(
(config) => {
// 从 localStorage/内存/Pinia/Redux 获取 accessToken(根据你的存储方式调整)
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
// 标准化格式:Authorization + Bearer 前缀
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:处理 Token 过期、无感刷新
service.interceptors.response.use(
(response) => response, // 成功响应直接返回
async (error) => {
const originalRequest = error.config;
// 判断是否需要刷新 Token:
// - 接口返回 401(Token 过期)
// - 不是刷新 Token 接口本身(避免循环)
// - 未标记过重试(避免无限循环)
if (
error.response?.status === 401 &&
originalRequest.url !== '/api/refresh-token' &&
!originalRequest._retry
) {
originalRequest._retry = true; // 标记已重试
// 场景 1:正在刷新 Token,将请求加入队列等待
if (isRefreshing) {
return new Promise((resolve) => {
retryRequestQueue.push(() => {
resolve(service(originalRequest)); // 刷新完成后重试请求
});
});
}
// 场景 2:开始刷新 Token
isRefreshing = true;
try {
// 刷新 Token
const { token, expiresIn } = await refreshAccessToken();
// 刷新成功:更新 accessToken 存储
localStorage.setItem('accessToken', token);
// 可选:记录过期时间,用于主动刷新(提前 30 秒刷新)
localStorage.setItem('tokenExpires', Date.now() + expiresIn * 1000);
// 重试所有排队的请求
retryRequestQueue.forEach((callback) => callback());
retryRequestQueue = []; // 清空队列
// 重试当前过期的请求
return service(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError); // 刷新失败,抛出错误
} finally {
isRefreshing = false; // 释放刷新锁
}
}
// 非 401 错误,直接抛出
return Promise.reject(error);
}
);
// 主动刷新:提前 30 秒刷新 Token(可选,体验更优)
export const startAutoRefresh = () => {
const checkExpire = () => {
const expiresTime = localStorage.getItem('tokenExpires');
if (!expiresTime) return;
// 计算剩余时间(提前 30 秒刷新)
const remainTime = expiresTime - Date.now() - 30 * 1000;
if (remainTime > 0) {
setTimeout(async () => {
await refreshAccessToken(); // 主动刷新
startAutoRefresh(); // 刷新后重新启动定时器
}, remainTime);
}
};
checkExpire();
};
export default service;
Q:JWT是什么?
A:使用用户登录信息时间戳等签名的json字符串
Q:Oauth是什么意思?
A:是一种第三方授权框架。
Oauth授权过程分为以下几个步骤:
用户访问客户端,客户端向第三方跳转,用户在第三方网站拿到授权码,授权码存在前端
用户携带授权码访问自身的服务器,再从自身的服务器使用授权码+客户端ID+客户端专属认证密钥从第三方网站拿到AccessToken+refreshToken码存储在自身的服务器,再用这个Token码(HTTP)访问第三方网站服务器拿到资源,返回给自身的前端
这个方案更安全:因为TOKEN只存储在后端,前端即使截获授权码,没有客户端认证密钥也拿不到Token;而且授权码有效期极短,一般只有60秒,只做唯一的一次认证授权,授权后资源获取使用refreshTOKEN,refreshToken也过期后再次获取授权
Q: 怎么做到部署第三方网站代理到自己域名的网址上,然后嵌入自己的平台
A: 将第三方网站代理到自有域名并嵌入自有平台,核心是通过 Nginx 反向代理,将第三方网站的内容伪装成自有域名的资源:
- 用户访问
https://your-domain.com/third-party;- Nginx 接收到请求后,偷偷转发到第三方网站
https://third-party.com;- Nginx 获取第三方的响应内容,再返回给用户;
- 对用户和浏览器而言,所有资源都来自
your-domain.com,不存在跨域问题,可直接嵌入iframe。
单点登录
目的是:一次登录,同一家公司下的所有系统都实现自动登录,不用重复输账号密码
现在有三个网址,业务A a.com,业务B b.com,SSO网址 sso.com
(1)用户登录业务A网址,发现A本地没有登录态(Cookie里面没有Token),重定向到SSO网址,query里面携带来源的业务网址a.com
https://sso.com/login?redirect=https://a.com
(2)SSO中心也发现没有登录态(Cookie里面没有Token),用户输入账号信息,获取全局登录会话Cookie,比如sso-session-id=xxx放在Cookie里面,和业务登录的Token没有任何关系,同时SSO后端直接302重定向到业务A系统,重定向地址把Ticket拼在URL里面
Ticket有效期只有30s-3分钟,超时作废;会被浏览器加密;Ticket加密信息是由业务系统url和用户id一起生成的,只能在该用户和业务系统下使用,由此保证安全性。
302 Location: https://a.com/callback?ticket=J8S72KSL91JD
同时SSO后端会存储一份信息:
ticket: "TICKET-ABC123"
userId: 1001
service: "https://a.com"
used: false
expireAt: 2025-xx-xx 10:00:03
(3)业务后端从url里获取ticket,业务后端后台调用SSO校验接口,会传递上ticket和请求的业务网址:
POST https://sso.com/validateTicket
Body: { ticket: "xxx", service: "a.com" }
然后业务后端获取SSO后端提供的用户信息,业务后端再生成自己的Token,Token通常通过set-Cookie上传递过来;也有直接通过json传过来的,也就是说SSO只管登录,保持用户信息的统一,不负责鉴权
(4)业务系统B再想访问SSO.com的时候,发现SSO.com的Cookie里面已经有了sso-session-id=xxx,会自动在给SSO的后端请求里带上,于是会自动由业务系统B和用户信息一起加密生成一个Ticket,重定向到业务系统B,后续的过程同业务系统A
Set-Cookie
-
对于 refreshToken:必须用
Set-Cookie+HttpOnly+Secure+SameSite保护,禁止前端读取; -
对于 accessToken:可通过
Set-Cookie(非 HttpOnly)或 JSON 返回(存内存),但优先结合场景选择更安全的方式。accessToken 避免持久化存储。accessToken 短期有效(1 小时内),优先存储内存(Pinia/Vuex/Redux/ 全局变量),页面刷新后通过 refreshToken 重新获取,降低泄露风险;若需持久化(如兼容低版本浏览器),仅存入sessionStorage(关闭标签页即销毁),不要存入localStorage(长期留存)。上面accessToken代码是豆包生成的样板代码,所以使用了localstorage,正常不应该使用localstorage存储。
(1) HttpOnly:js脚本无法读取,比如document.cookie无法读取到cookie;也禁止修改该Cookie
(2) Secure: 仅允许在Https的协议下传输,Http请求下不会传输该信息
(3) SameSite:strict 禁止跨站请求携带 Cookie;none 跨站请求携带Cookie
# 传递 refreshToken(核心:HttpOnly + Secure + SameSite,前端无法读取)
Set-Cookie: refreshToken=xxx; Path=/; Domain=a.com; HttpOnly; Secure; SameSite=Strict; Max-Age=604800
# 传递 accessToken(可选:非 HttpOnly,前端可读取,或直接存内存)
Set-Cookie: accessToken=xxx; Path=/; Domain=a.com; Secure; SameSite=Strict; Max-Age=3600
核心原因: 防止XSS攻击,在返回的请求JSON里面,前端js脚本是可以完全爬取到请求的内容的,但是如果在Set-Cookie里面设置了HttpOnly + Secure + SameSite,前端的js脚本就没办法爬取了;除此之外还是自动携带该Token