"七天免登录"的标准实现方案,从原理到代码全拆解,看完就能直接落地

背景:为什么需要"七天免登录"?

在日常使用各种 App 和网站时,"七天免登录"是一个非常常见的功能。用户只需要登录一次,在接下来的七天内无需再次输入账号密码,极大地提升了用户体验。

但是,这个看似简单的功能背后,却涉及到一系列复杂的技术问题:

  • 如何在保证安全的前提下实现长期免登录?
  • Token 过期了怎么办?
  • 用户主动退出或修改密码后如何处理?
  • 多设备登录如何管理?
  • 如何防止 Token 被盗用?

本文将从原理到代码,全面拆解"七天免登录"的标准实现方案。

核心概念:双 Token 机制

"七天免登录"的核心在于双 Token 机制,即使用两种不同生命周期的 Token:

  1. Access Token(访问令牌):短期有效,用于接口访问认证
  2. 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 机制,在保证安全性的前提下,实现了良好的用户体验。核心要点:

  1. 双 Token 机制:Access Token 短期有效,Refresh Token 长期有效
  2. 自动续期:客户端自动刷新 Token,用户无感知
  3. 安全措施:设备绑定、黑名单、单点登录控制
  4. 优雅降级:Token 过期后引导用户重新登录

通过本文的完整实现方案,你可以直接在自己的项目中落地"七天免登录"功能。

互动话题

  1. 你在实现免登录功能时遇到过哪些坑?
  2. 你认为双 Token 机制还有哪些可以优化的地方?
  3. 对于移动端和 Web 端的 Token 存储,你有什么经验分享?

欢迎在评论区交流讨论!


欢迎关注公众号:服务端技术精选,获取更多技术分享和经验。


标题:"七天免登录"的标准实现方案,从原理到代码全拆解,看完就能直接落地
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/03/17/1773583761454.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消