"七天免登录"的标准实现方案,从原理到代码全拆解,看完就能直接落地
背景:为什么需要"七天免登录"?
在日常使用各种 App 和网站时,"七天免登录"是一个非常常见的功能。用户只需要登录一次,在接下来的七天内无需再次输入账号密码,极大地提升了用户体验。
但是,这个看似简单的功能背后,却涉及到一系列复杂的技术问题:
- 如何在保证安全的前提下实现长期免登录?
- Token 过期了怎么办?
- 用户主动退出或修改密码后如何处理?
- 多设备登录如何管理?
- 如何防止 Token 被盗用?
本文将从原理到代码,全面拆解"七天免登录"的标准实现方案。
核心概念:双 Token 机制
"七天免登录"的核心在于双 Token 机制,即使用两种不同生命周期的 Token:
- Access Token(访问令牌):短期有效,用于接口访问认证
- Refresh Token(刷新令牌):长期有效(如7天),用于获取新的 Access Token
为什么需要双 Token?
如果只使用一个长期有效的 Token,存在以下风险:
- 安全风险:Token 一旦泄露,攻击者可以长期冒充用户
- 撤销困难:无法快速撤销已颁发的 Token
- 灵活性差:无法动态调整 Token 的有效期
双 Token 机制的优势:
- 安全性高:Access Token 短期有效,即使泄露影响有限
- 可撤销:可以通过控制 Refresh Token 来撤销用户登录
- 用户体验好:用户无感知地自动续期,保持登录状态
架构设计
系统架构
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 客户端 │ │ 服务端 │ │ Redis │
└───────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. 登录请求 │ │
│ ─────────────────> │ │
│ │ 2. 验证账号密码 │
│ │ │
│ │ 3. 生成双 Token │
│ │ ─────────────────> │
│ │ │
│ 4. 返回 Token │ │
│ <───────────────── │ │
│ │ │
│ 5. 请求接口(Access Token)│ │
│ ─────────────────> │ │
│ │ 6. 验证 Access Token│
│ │ │
│ 7. 返回数据 │ │
│ <───────────────── │ │
│ │ │
│ 8. Access Token 过期 │ │
│ │ │
│ 9. 刷新请求(Refresh Token)│ │
│ ─────────────────> │ │
│ │ 10. 验证 Refresh Token
│ │ │
│ │ 11. 生成新 Access Token
│ │ │
│ 12. 返回新 Token │ │
│ <───────────────── │ │
Token 存储结构
# Access Token
Key: access_token:{userId}:{token}
Value: {userId, username, deviceId, createTime, expireTime}
TTL: 2小时
# Refresh Token
Key: refresh_token:{userId}:{token}
Value: {userId, username, deviceId, createTime, expireTime}
TTL: 7天
# 用户登录状态
Key: user_login:{userId}
Value: {deviceId1: refreshToken1, deviceId2: refreshToken2, ...}
TTL: 7天
# Token 黑名单(用于撤销)
Key: token_blacklist:{token}
Value: 1
TTL: 根据 Token 剩余有效期
技术实现
1. Token 生成服务
@Service
@Slf4j
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
@Value("${token.access-expire:7200}") // 2小时
private Long accessTokenExpireTime;
@Value("${token.refresh-expire:604800}") // 7天
private Long refreshTokenExpireTime;
/**
* 生成双 Token
*/
public TokenPair generateTokenPair(Long userId, String username, String deviceId) {
String accessToken = generateAccessToken(userId, username, deviceId);
String refreshToken = generateRefreshToken(userId, username, deviceId);
// 存储用户登录状态
storeUserLoginStatus(userId, deviceId, refreshToken);
log.info("Generated token pair for user: {}, device: {}", username, deviceId);
return TokenPair.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.accessTokenExpireTime(accessTokenExpireTime)
.refreshTokenExpireTime(refreshTokenExpireTime)
.build();
}
private String generateAccessToken(Long userId, String username, String deviceId) {
String token = UUID.randomUUID().toString().replace("-", "");
TokenInfo tokenInfo = TokenInfo.builder()
.userId(userId)
.username(username)
.deviceId(deviceId)
.token(token)
.tokenType("ACCESS")
.createTime(System.currentTimeMillis())
.expireTime(System.currentTimeMillis() + accessTokenExpireTime * 1000)
.build();
String key = getAccessTokenKey(userId, token);
redisTemplate.opsForValue().set(key, JSON.toJSONString(tokenInfo),
accessTokenExpireTime, TimeUnit.SECONDS);
return token;
}
private String generateRefreshToken(Long userId, String username, String deviceId) {
String token = UUID.randomUUID().toString().replace("-", "");
TokenInfo tokenInfo = TokenInfo.builder()
.userId(userId)
.username(username)
.deviceId(deviceId)
.token(token)
.tokenType("REFRESH")
.createTime(System.currentTimeMillis())
.expireTime(System.currentTimeMillis() + refreshTokenExpireTime * 1000)
.build();
String key = getRefreshTokenKey(userId, token);
redisTemplate.opsForValue().set(key, JSON.toJSONString(tokenInfo),
refreshTokenExpireTime, TimeUnit.SECONDS);
return token;
}
private void storeUserLoginStatus(Long userId, String deviceId, String refreshToken) {
String key = getUserLoginKey(userId);
redisTemplate.opsForHash().put(key, deviceId, refreshToken);
redisTemplate.expire(key, refreshTokenExpireTime, TimeUnit.SECONDS);
}
/**
* 验证 Access Token
*/
public TokenInfo validateAccessToken(String token) {
// 检查黑名单
if (isTokenBlacklisted(token)) {
log.warn("Token is blacklisted: {}", token);
return null;
}
String key = getAccessTokenKeyByToken(token);
if (key == null) {
return null;
}
String tokenJson = redisTemplate.opsForValue().get(key);
if (!StringUtils.hasText(tokenJson)) {
return null;
}
TokenInfo tokenInfo = JSON.parseObject(tokenJson, TokenInfo.class);
// 检查是否过期
if (tokenInfo.getExpireTime() < System.currentTimeMillis()) {
return null;
}
return tokenInfo;
}
/**
* 验证 Refresh Token
*/
public TokenInfo validateRefreshToken(String token) {
// 检查黑名单
if (isTokenBlacklisted(token)) {
log.warn("Refresh token is blacklisted: {}", token);
return null;
}
String key = getRefreshTokenKeyByToken(token);
if (key == null) {
return null;
}
String tokenJson = redisTemplate.opsForValue().get(key);
if (!StringUtils.hasText(tokenJson)) {
return null;
}
TokenInfo tokenInfo = JSON.parseObject(tokenJson, TokenInfo.class);
// 检查是否过期
if (tokenInfo.getExpireTime() < System.currentTimeMillis()) {
return null;
}
return tokenInfo;
}
/**
* 刷新 Token
*/
public TokenPair refreshToken(String refreshToken) {
TokenInfo tokenInfo = validateRefreshToken(refreshToken);
if (tokenInfo == null) {
throw new AuthException("Refresh token is invalid or expired");
}
// 将旧的 Refresh Token 加入黑名单
addToBlacklist(refreshToken, tokenInfo.getExpireTime() - System.currentTimeMillis());
// 删除旧的 Access Token
deleteAccessToken(tokenInfo.getUserId(), tokenInfo.getToken());
// 生成新的双 Token
return generateTokenPair(tokenInfo.getUserId(), tokenInfo.getUsername(), tokenInfo.getDeviceId());
}
/**
* 用户登出
*/
public void logout(Long userId, String deviceId) {
// 获取用户的 Refresh Token
String key = getUserLoginKey(userId);
String refreshToken = (String) redisTemplate.opsForHash().get(key, deviceId);
if (refreshToken != null) {
// 将 Refresh Token 加入黑名单
TokenInfo tokenInfo = validateRefreshToken(refreshToken);
if (tokenInfo != null) {
addToBlacklist(refreshToken, tokenInfo.getExpireTime() - System.currentTimeMillis());
}
// 删除 Access Token
deleteAccessToken(userId, tokenInfo != null ? tokenInfo.getToken() : null);
// 删除用户登录状态
redisTemplate.opsForHash().delete(key, deviceId);
}
log.info("User logged out: {}, device: {}", userId, deviceId);
}
/**
* 用户修改密码后强制所有设备登出
*/
public void forceLogoutAllDevices(Long userId) {
String key = getUserLoginKey(userId);
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
for (Map.Entry<Object, Object> entry : entries.entrySet()) {
String deviceId = (String) entry.getKey();
String refreshToken = (String) entry.getValue();
// 将 Refresh Token 加入黑名单
TokenInfo tokenInfo = validateRefreshToken(refreshToken);
if (tokenInfo != null) {
addToBlacklist(refreshToken, tokenInfo.getExpireTime() - System.currentTimeMillis());
}
}
// 删除用户登录状态
redisTemplate.delete(key);
log.info("Force logout all devices for user: {}", userId);
}
private void addToBlacklist(String token, long ttlMillis) {
String key = "token_blacklist:" + token;
redisTemplate.opsForValue().set(key, "1", ttlMillis, TimeUnit.MILLISECONDS);
}
private boolean isTokenBlacklisted(String token) {
String key = "token_blacklist:" + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
private void deleteAccessToken(Long userId, String token) {
if (token != null) {
String key = getAccessTokenKey(userId, token);
redisTemplate.delete(key);
}
}
// 辅助方法...
private String getAccessTokenKey(Long userId, String token) {
return "access_token:" + userId + ":" + token;
}
private String getRefreshTokenKey(Long userId, String token) {
return "refresh_token:" + userId + ":" + token;
}
private String getUserLoginKey(Long userId) {
return "user_login:" + userId;
}
private String getAccessTokenKeyByToken(String token) {
Set<String> keys = redisTemplate.keys("access_token:*:" + token);
return CollectionUtils.isEmpty(keys) ? null : keys.iterator().next();
}
private String getRefreshTokenKeyByToken(String token) {
Set<String> keys = redisTemplate.keys("refresh_token:*:" + token);
return CollectionUtils.isEmpty(keys) ? null : keys.iterator().next();
}
}
2. 登录控制器
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
@Autowired
private AuthService authService;
@Autowired
private TokenService tokenService;
/**
* 用户登录
*/
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody @Valid LoginRequest request,
HttpServletRequest httpRequest) {
log.info("User login: {}", request.getUsername());
// 验证账号密码
User user = authService.validateCredentials(request.getUsername(), request.getPassword());
// 获取设备ID(可以是设备指纹、浏览器指纹等)
String deviceId = getDeviceId(httpRequest);
// 生成双 Token
TokenPair tokenPair = tokenService.generateTokenPair(user.getId(), user.getUsername(), deviceId);
LoginResponse response = LoginResponse.builder()
.userId(user.getId())
.username(user.getUsername())
.accessToken(tokenPair.getAccessToken())
.refreshToken(tokenPair.getRefreshToken())
.accessTokenExpireTime(tokenPair.getAccessTokenExpireTime())
.refreshTokenExpireTime(tokenPair.getRefreshTokenExpireTime())
.build();
return Result.success(response);
}
/**
* 刷新 Token
*/
@PostMapping("/refresh")
public Result<TokenPair> refreshToken(@RequestHeader("X-Refresh-Token") String refreshToken) {
log.info("Refreshing token");
TokenPair tokenPair = tokenService.refreshToken(refreshToken);
return Result.success(tokenPair);
}
/**
* 用户登出
*/
@PostMapping("/logout")
@RequireAuth
public Result<String> logout(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("currentUserId");
String deviceId = (String) request.getAttribute("deviceId");
tokenService.logout(userId, deviceId);
return Result.success("登出成功");
}
private String getDeviceId(HttpServletRequest request) {
// 可以从 Header 获取,或者根据 User-Agent、IP 等生成设备指纹
String deviceId = request.getHeader("X-Device-Id");
if (!StringUtils.hasText(deviceId)) {
// 生成设备指纹
deviceId = generateDeviceFingerprint(request);
}
return deviceId;
}
private String generateDeviceFingerprint(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
String ip = getClientIp(request);
return DigestUtils.md5DigestAsHex((userAgent + ip).getBytes());
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (!StringUtils.hasText(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (!StringUtils.hasText(ip)) {
ip = request.getRemoteAddr();
}
return ip.split(",")[0].trim();
}
}
3. Token 验证拦截器
@Component
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 检查是否需要认证
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequireAuth requireAuth = handlerMethod.getMethodAnnotation(RequireAuth.class);
if (requireAuth == null) {
requireAuth = handlerMethod.getBeanType().getAnnotation(RequireAuth.class);
}
if (requireAuth == null) {
return true;
}
// 获取 Access Token
String accessToken = extractAccessToken(request);
if (!StringUtils.hasText(accessToken)) {
writeErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "请先登录");
return false;
}
// 验证 Access Token
TokenInfo tokenInfo = tokenService.validateAccessToken(accessToken);
if (tokenInfo == null) {
writeErrorResponse(response, HttpStatus.UNAUTHORIZED.value(),
"Token 无效或已过期,请使用 Refresh Token 刷新");
return false;
}
// 将用户信息存入请求属性
request.setAttribute("currentUserId", tokenInfo.getUserId());
request.setAttribute("currentUsername", tokenInfo.getUsername());
request.setAttribute("deviceId", tokenInfo.getDeviceId());
return true;
}
private String extractAccessToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
return token.substring(7);
}
return null;
}
private void writeErrorResponse(HttpServletResponse response, int status, String message)
throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
Result<String> result = Result.error(status, message);
response.getWriter().write(JSON.toJSONString(result));
}
}
4. 客户端自动刷新 Token
// 前端 Axios 拦截器示例
import axios from 'axios';
const api = axios.create({
baseURL: '/api'
});
// 请求拦截器:添加 Access Token
api.interceptors.request.use(
config => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
error => Promise.reject(error)
);
// 响应拦截器:处理 Token 过期
let isRefreshing = false;
let refreshSubscribers = [];
function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback);
}
function onTokenRefreshed(newToken) {
refreshSubscribers.forEach(callback => callback(newToken));
refreshSubscribers = [];
}
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果是 401 错误且不是刷新 Token 的请求
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 等待 Token 刷新完成
return new Promise(resolve => {
subscribeTokenRefresh(newToken => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('/api/auth/refresh', null, {
headers: { 'X-Refresh-Token': refreshToken }
});
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
// 更新存储的 Token
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
// 通知等待的请求
onTokenRefreshed(accessToken);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转到登录页
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
安全增强措施
1. Token 绑定设备
将 Token 与设备绑定,防止 Token 被盗用后跨设备使用。
2. Token 黑名单
当用户登出或修改密码时,将 Token 加入黑名单,立即失效。
3. 单点登录控制
限制同一账号的登录设备数,超过限制时踢出最早登录的设备。
4. 异常检测
检测异常的登录行为,如异地登录、短时间内多次刷新 Token 等。
5. HTTPS 传输
所有 Token 传输必须使用 HTTPS,防止中间人攻击。
最佳实践
1. Token 存储
- Web 端:使用 localStorage 或 sessionStorage
- 移动端:使用 Keychain(iOS)或 Keystore(Android)
- 小程序:使用 wx.setStorage
2. 刷新策略
- Access Token 过期前 5 分钟自动刷新
- 避免并发刷新请求
- 刷新失败时引导用户重新登录
3. 多设备管理
- 提供设备管理页面,用户可以查看和注销已登录设备
- 新设备登录时发送通知提醒
4. 监控告警
- 监控 Token 刷新频率,发现异常及时处理
- 记录登录日志,便于审计和追踪
常见问题
1. Refresh Token 也过期了怎么办?
Refresh Token 过期后,用户必须重新输入账号密码登录。这是为了保证安全性。
2. 如何防止 Refresh Token 被盗用?
- 绑定设备信息
- 限制 Refresh Token 的使用次数
- 监控异常使用行为
3. 用户修改密码后如何处理?
立即将所有该用户的 Token 加入黑名单,强制所有设备重新登录。
4. 服务端重启后 Token 会丢失吗?
不会,因为 Token 存储在 Redis 中,只要 Redis 数据不丢失,Token 就不会丢失。
总结
"七天免登录"功能通过双 Token 机制,在保证安全性的前提下,实现了良好的用户体验。核心要点:
- 双 Token 机制:Access Token 短期有效,Refresh Token 长期有效
- 自动续期:客户端自动刷新 Token,用户无感知
- 安全措施:设备绑定、黑名单、单点登录控制
- 优雅降级:Token 过期后引导用户重新登录
通过本文的完整实现方案,你可以直接在自己的项目中落地"七天免登录"功能。
互动话题
- 你在实现免登录功能时遇到过哪些坑?
- 你认为双 Token 机制还有哪些可以优化的地方?
- 对于移动端和 Web 端的 Token 存储,你有什么经验分享?
欢迎在评论区交流讨论!
欢迎关注公众号:服务端技术精选,获取更多技术分享和经验。
标题:"七天免登录"的标准实现方案,从原理到代码全拆解,看完就能直接落地
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/03/17/1773583761454.html
公众号:服务端技术精选
- 背景:为什么需要"七天免登录"?
- 核心概念:双 Token 机制
- 为什么需要双 Token?
- 架构设计
- 系统架构
- Token 存储结构
- 技术实现
- 1. Token 生成服务
- 2. 登录控制器
- 3. Token 验证拦截器
- 4. 客户端自动刷新 Token
- 安全增强措施
- 1. Token 绑定设备
- 2. Token 黑名单
- 3. 单点登录控制
- 4. 异常检测
- 5. HTTPS 传输
- 最佳实践
- 1. Token 存储
- 2. 刷新策略
- 3. 多设备管理
- 4. 监控告警
- 常见问题
- 1. Refresh Token 也过期了怎么办?
- 2. 如何防止 Refresh Token 被盗用?
- 3. 用户修改密码后如何处理?
- 4. 服务端重启后 Token 会丢失吗?
- 总结
- 互动话题
评论
0 评论