React, NodeJS, Express, Mongodb
在构建现代Web应用时,安全性和用户体验是我们不可或缺的考量。随着技术的发展,Token-based认证已成为保护用户会话的黄金标准。然而,随之而来的Token管理问题,尤其是如何有效处理Token过期问题,成为了开发者不得不面对的挑战。本篇博客将深入探讨如何在Web应用中实现自动刷新Token机制,旨在为读者提供一个既安全又不损害用户体验的解决方案。
引言与概述
在数字世界的每一个角落,安全性始终是我们永恒的追求。想象一下,每次网络请求都要求用户重新登录,这无疑会极大地影响用户体验,让人感到烦躁。为了解决这一问题,Token-based认证应运而生,它允许用户在一定时间内无需重复验证身份即可与服务器进行通信。然而,如何在保证安全的同时,处理Token过期并自动更新,却是一门值得深究的艺术。
本篇博客将从Token的基本概念讲起,详细解释访问Token和刷新Token的区别及其各自的角色 。我们不仅会探讨为什么需要刷新Token ,更重要的是,如何在用户几乎感觉不到的情况下,自动地更新Token,从而保持用户会话的持续性。通过对这一过程的深入讲解,我们希望能够帮助开发者构建出更安全、更易用的Web应用。
背景与核心概念
如果你已经了解了Token的概念,以及明白了为什么需要Refresh Token,那么可以跳过这一章,直接看下一章节。
什么是Token
在Web应用中,Token主要用于用户认证和授权,可以理解为是一段加密好的信息。当我们登录成功的之后,服务器会给我们一个Token,我们以后只需要拿着这个Token换我们想要的资源就好了,这个Token相当于用户的身份证了。至于服务器如何制造这个身份证(Token)的,以什么统一的标准,那就可以去了解了解Json Web Token(JWT)了,JWT就是这个所谓的标准。
为什么需要Refresh Token
为了保证安全,我们一般会给Token加上一个过期时间(15分钟左右),这样哪怕Token真的被泄露了,攻击者也只能在有限的时间内使用这个Token。
可是这样的话,另一个问题又来了,我总不能让用户老师每隔15分钟就需要重新登录一次吧,太麻烦了,用户要烦了(试想你每隔一周就需要亲自跑去公安局重新办个身份证?)。所以我们需要打造一个机制,让程序能够自动刷新我们的Token。
那我们可以尝试这么做:
我们可以把Token分为两种:访问令牌(Access Tokens
) 和 刷新令牌(Refresh Tokens
) 。访问令牌就是那个有效期比较短的,15分钟寿命的Token,可以用于访问受保护的资源;而刷新令牌我们可以给他一个更长的有效期,比如一周,用于在访问令牌过期后获取新的访问令牌,而无需用户重新登录。
这样一旦程序检测到访问令牌过期了,就可以让程序自己拿着刷新令牌去和服务器说,我还是我本人,你还是把新的访问令牌给我吧,然后就可以继续找服务器要资源了。
这有点像护照和签证的关系:登录时,服务器提供的短期签证(Access Token)允许我们自由访问资源。一旦签证过期,我们可以通过刷新令牌(Refresh Token),像自动续签一样,延长访问权限。
实现自动刷新Token
服务端:处理刷新令牌请求
创建Access Token
和Refresh Token
当用户成功登录后,服务端需要生成两种令牌:一个是用于访问保护资源的Access Token
,另一个是用于在Access Token过期后获取新的Access Token的Refresh Token
。这两种令牌通常使用JSON Web Tokens (JWT)技术来创建。
js
const jwt = require('jsonwebtoken');
// Access token expires in 15 minutes
const accessToken = jwt.sign({ userId: user._id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
// Refresh token expires in 7 days
const refreshToken = jwt.sign({ userId: user._id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
使用JWT创建了两个Token之后,我们要把他们给两个地方:
- 将
refreshToken
存到我们自己的数据库里,以用来后续的验证。只有客户端发来的refreshToken
和我们所储存的refreshToken
一致时,我们才会允许返回一个新的accessToken
。 - 将此时创建好的
accessToken
和refreshToken
返回给客户端,因为用户已经登录成功了,需要这两个token来后续和服务端沟通。
js
// Save refreshToken with user
// 这里我用的`mongodb`,你需要根据自己的配置更改语法
// 用一个array来储存refreshToken,因为可能会出现用户有多个登录端口的情况
user.refreshTokens.push({ token: refreshToken });
await user.save();
// Resonse with the new accessToken and refreshToken
res.status(200).send({ accessToken, refreshToken });
验证令牌
接收refreshToken
当客户端发起刷新访问令牌的请求时,它应该在请求体中包含刷新令牌。服务端首先需要从请求中提取这个令牌。
js
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: "Refresh token is required" });
}
验证accessToken
和refreshToken
的合法性
在后端,我们接收带有accessToken
的请求时,都需要在正式处理请求之前,检验其合法性,有没有被篡改,有没有过期。如果accessToken
过期了,我们也需要到时候把指定的错误代码传回客户端,这样客户端就知道要重新用refreshToken
自动刷新一下accessToken
了。这样的需求,在每个需要验证身份的请求前验证accessToken
,这像不像要让我写个中间件?
js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.ACCESS_TOKEN_SECRET;
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN"
if (token == null) {
return res.status(401).json({ message: "No token provided" });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
// Check for token expiration specific error
if (err.name === 'TokenExpiredError') {
// Return 409 as the status code when the token expired
// Client can use the same status code to check whether the token expired
return res.status(409).json({ message: "Token expired" });
} else {
return res.status(403).json({ message: "Token is not valid" });
}
}
// If the token is verified successfully, attach the user info to the request object
req.user = user;
next();
});
};
module.exports = authenticateToken;
于是我在之后请求的时候就可以直接用了
js
const authenticateToken = require('./middleware/authenticateToken');
// ...
app.get('/api/userInfo', authenticateToken, async (req, res) => {
// ...
})
当然,对于refreshToken
,在我们使用它来换取新的token时,我们也需要使用与创建刷新令牌时相同的密钥(process.env.REFRESH_TOKEN_SECRET
)提前验证一下它的合法性。不过要多的一步是,我们还需要检查刷新令牌是否和我们数据库里储存的是有匹配的。
js
app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: "Refresh Token is required" });
}
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
const user = await User.findById(payload.userId);
if (!user) {
return res.status(403).json({ message: "User not found" });
}
// Find the index of the token that matches the incoming refreshToken
const tokenIndex = user.refreshTokens.findIndex(token => {
return token.token === refreshToken
});
if (tokenIndex === -1) { // Token not found
return res.status(403).json({ message: "Refresh Token is not valid" });
}
// ...
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(403).json({ message: "Refresh Token has expired" });
} else {
console.error('Token Refresh Error:', error);
return res.status(403).json({ message: "Invalid Refresh Token" });
}
}
});
发放新的Access Token
js
// Issue a new access token
const accessToken = jwt.sign({ userId: user._id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
res.status(200).send({ accessToken }); // Response with the new accessToken
刷新令牌旋转:发放新的Refresh Token
为了增加安全性,一旦使用刷新令牌请求成功,应立即撤销该刷新令牌,并为用户生成一个新的刷新令牌。这种做法被称为刷新令牌的旋转,有助于防止刷新令牌被盗用。
js
// Issue a new refresh token
const newRefreshToken = jwt.sign({ userId: user._id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); // Expires in 7 days
// Replace the old refresh token with the new one at the same index
user.refreshTokens[tokenIndex] = { token: newRefreshToken };
await user.save(); // Save the updated user document
res.status(200).send({ accessToken, refreshToken }); // Response with the new accessToken and refreshToken
客户端:自动刷新令牌
请求拦截
为了让我们可以在请求的过程中,自动截取到409的响应状态码,从而对过期的token发起刷新的请求,我们可以对原生的fetch
方法做一个封装(authFetch
),这样我们之后就可以直接使用authFetch
为我们无感自动刷新accessToken
。当然如果你用axios的话,也可以使用axios.interceptors
去在axios请求中截取response
。
js
async function authFetch(url, options) {
let response = await fetch(url, options);
if (response.ok) {
return response;
} else if (response.status === 409) { // Now, the server told us the access token expired
// TODO: Create a refresh() function to get the new token
return refresh().then(newToken => {
const authOptions = {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`, // Update the authorization header
},
};
return fetch(url, authOptions); // Try to request again with the new token
}).catch(err => {
throw err; // Rethrow to be caught by the caller
})
} else {
throw new Error('Failed to fetch');
}
}
发送刷新令牌请求
这里,我们可以写一个refresh()
方法,来发送我们刷新令牌的请求。
js
const API_BASE_URL = 'http://localhost:8080';
async function refresh() {
// Assuming the refresh token is stored in localStorage
const refreshToken = localStorage.getItem('refreshToken');
try {
const response = await fetch(`${API_BASE_URL}/api/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Could not refresh token');
// Update localStorage with the new tokens
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
return data.accessToken;
} catch (error) {
console.error('Refresh Token Error:', error);
throw error;
}
}
这样,通过refresh
和authToken
,我们就已经基本实现了自动刷新token。比如我们需要带着token去获取用户信息时,我们可以直接使用我们写好的authFetch
。
js
const getUserInfo = async () => {
try {
const url = `${API_BASE_URL}/api/userInfo`;
const accessToken = getAuthToken('accessToken');
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
};
const response = await authFetch(url, options);
if (!response.ok) {
const error = new Error('Failed to fetch user info');
error.info = await response.json();
error.status = response.status;
throw error;
}
return response.json(); // Return the user info
} catch (error) {
console.error('Error fetching user info:', error);
throw error; // Rethrow the error so it can be handled by the caller
}
};
避免重复的刷新令牌请求
在实现自动刷新Token机制时,特别是在并发请求的场景下,避免重复发送刷新令牌的请求是非常重要的。这不仅可以减少服务器的负担,还能避免因重复刷新而导致的潜在安全问题。为实现这个需求,我们可以采用下方的策略:
在尝试刷新令牌时,使用一个锁(例如,一个布尔变量)来标记正在进行的刷新操作。如果有请求在刷新操作进行时到达,这些请求将会等待刷新操作完成后再继续,而不是自己也尝试去刷新令牌。
当刷新令牌的请求正在进行时,我们需要对后续的请求进行缓存,直到刷新令牌的操作完成。一旦新的访问令牌可用,就使用它来重新发起被缓存的请求。
说人话就是:
有人已经在请求刷新token了,那就别掺合,一边儿排队。等有了新的token,大家一起用。
js
let isRefreshing = false;
let requests = [];
async function authFetch(url, options) {
let response = await fetch(url, options);
if (response.ok) {
return response;
} else if (response.status === 409) {
if (!isRefreshing) {
isRefreshing = true;
return refresh().then(newToken => {
const authOptions = {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`, // Update the authorization header
},
};
return fetch(url, authOptions);
}).catch(err => {
// TODO: need a processQueue function to process the requests in list
processQueue(err, null); // Process the queue with error
throw err; // Rethrow to be caught by the caller
}).finally(() => {
isRefreshing = false; // Reset the refreshing flag
});
} else {
// If a refresh is already in progress, queue this request
return new Promise((resolve, reject) => {
requests.push({
resolve: () => resolve(authFetch(url, options)),
reject: () => reject,
});
});
}
} else {
throw new Error('Failed to fetch');
}
}
js
const processQueue = (error, token = null) => {
requests.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
requests = [];
}
至此,我们就可以通过上述方法来实现一个自动刷新token的web了。
结语
在本文中,我们深入探讨了自动刷新Token机制的实现原理及其在现代Web应用中的重要性。从服务端的Token生成与验证,到客户端的请求拦截和令牌刷新逻辑,我们逐步揭示了构建安全、高效且用户友好的认证系统所需的关键技术要素。
你会了啥?
- 理解Token的角色:我们学习了访问令牌(Access Token)和刷新令牌(Refresh Token)在用户认证流程中的作用,以及它们如何共同工作以维持用户会话的安全与持久性。
- 安全性与用户体验的平衡:通过自动刷新Token机制,我们探索了如何在增强安全措施(例如,限制访问令牌的有效期)和保持良好用户体验(避免频繁重新登录)之间找到平衡点。
- 技术实现的深入理解:通过对服务端验证刷新令牌、客户端实现请求拦截和处理令牌刷新的逐步分析,我们了解了在实际应用开发中如何具体实施自动刷新Token机制。