基于SpringBoot + Redis实现网站七天免密登录设计思路及实战

引言

最近在优化用户登录体验时,发现一个有意思的现象:用户平均每天打开APP 3-5次,但每次都要求输入密码,体验真的很差。于是我们引入了七天免密登录功能,用户勾选"七天内自动登录"后,7天内无需输入密码,大大提升了用户体验。

很多同学可能觉得免密登录很复杂,其实只要合理利用Redis的过期机制,实现起来并不难。今天就来聊聊如何用SpringBoot + Redis实现一个安全可靠的七天免密登录系统。

为什么需要七天免密登录?

用户体验痛点

传统的登录方式存在这些问题:

频繁登录

  • 用户每天多次打开APP都要输入账号密码
  • 忘记密码导致登录失败
  • 手机端输入密码体验差

记忆负担

  • 多个APP需要记住不同密码
  • 密码复杂度要求高,难以记忆
  • 密码管理混乱

安全与便利的平衡

  • 短期免密登录:兼顾安全与便利
  • 长期记住密码:安全风险高
  • 永久免密登录:安全隐患大

核心架构设计

我们的七天免密登录架构:

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   用户浏览器    │───▶│   SpringBoot    │───▶│   Redis存储     │
│   (登录页面)    │    │   (认证服务)    │    │  (Token管理)   │
└─────────────────┘    └──────────────────┘    └─────────────────┘
        │                        │                       │
        │ 登录请求(用户名密码)     │                       │
        │───────────────────────▶│                       │
        │                        │ 生成Token并存储       │
        │                        │──────────────────────▶│
        │  返回Token            │                       │
        │◀───────────────────────│                       │
        │                        │                       │
        │ 后续请求(Token)        │                       │
        │───────────────────────▶│                       │
        │                        │ 验证Token            │
        │                        │──────────────────────▶│
        │                        │◀──────────────────────│
        │                        │                       │

核心设计要点

1. Token设计策略

// 免密登录Token实体
@Data
public class AutoLoginToken {
    private String token;           // 唯一标识
    private Long userId;           // 用户ID
    private String deviceId;       // 设备ID
    private String userAgent;      // 用户代理信息
    private LocalDateTime createTime; // 创建时间
    private LocalDateTime expireTime; // 过期时间
    private String ip;             // IP地址(可选)
}

// Token生成策略
@Component
public class TokenGenerator {
    
    public String generateToken(Long userId, String deviceId) {
        // 使用UUID + 用户ID + 时间戳生成唯一Token
        String tokenSource = userId + "_" + deviceId + "_" + System.currentTimeMillis();
        return DigestUtils.md5DigestAsHex(tokenSource.getBytes());
    }
}

2. Redis存储结构

// Redis存储设计
@Component
public class RedisTokenStore {
    
    private static final String TOKEN_PREFIX = "autologin:token:";
    private static final String USER_TOKEN_PREFIX = "autologin:user:";
    private static final int TOKEN_EXPIRE_DAYS = 7;
    
    // 存储Token信息
    public void storeToken(String token, AutoLoginToken autoLoginToken) {
        String key = TOKEN_PREFIX + token;
        String userKey = USER_TOKEN_PREFIX + autoLoginToken.getUserId();
        
        // 存储Token详细信息
        redisTemplate.opsForValue().set(key, autoLoginToken, 
            TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
        
        // 建立用户与Token的关联(用于单点登录控制)
        redisTemplate.opsForValue().set(userKey, token, 
            TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
    }
    
    // 获取Token信息
    public AutoLoginToken getToken(String token) {
        String key = TOKEN_PREFIX + token;
        return (AutoLoginToken) redisTemplate.opsForValue().get(key);
    }
    
    // 删除Token
    public void removeToken(String token, Long userId) {
        String key = TOKEN_PREFIX + token;
        String userKey = USER_TOKEN_PREFIX + userId;
        
        redisTemplate.delete(key);
        redisTemplate.delete(userKey);
    }
}

3. 安全验证机制

// 安全验证策略
@Component
public class TokenValidator {
    
    public boolean validateToken(String token, String deviceId, String userAgent, String ip) {
        AutoLoginToken storedToken = redisTokenStore.getToken(token);
        
        if (storedToken == null) {
            return false;  // Token不存在或已过期
        }
        
        // 验证设备ID是否匹配
        if (!Objects.equals(storedToken.getDeviceId(), deviceId)) {
            return false;  // 设备不匹配
        }
        
        // 验证User-Agent是否匹配(防止Token被盗用)
        if (!Objects.equals(storedToken.getUserAgent(), userAgent)) {
            return false;  // User-Agent不匹配
        }
        
        // 可选:验证IP地址(严格模式)
        if (storedToken.getIp() != null && !Objects.equals(storedToken.getIp(), ip)) {
            return false;  // IP地址不匹配
        }
        
        return true;
    }
    
    // 验证通过后,延长Token有效期
    public void refreshToken(String token) {
        AutoLoginToken storedToken = redisTokenStore.getToken(token);
        if (storedToken != null) {
            // 重新设置过期时间为7天
            redisTokenStore.storeToken(token, storedToken);
        }
    }
}

关键实现细节

1. 登录控制器

@RestController
@RequestMapping("/auth")
public class AuthController {
    
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        // 验证用户名密码
        User user = userService.authenticate(request.getUsername(), request.getPassword());
        
        if (user == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(LoginResponse.failed("用户名或密码错误"));
        }
        
        // 创建登录响应
        LoginResponse response = new LoginResponse();
        response.setUserId(user.getId());
        response.setUsername(user.getUsername());
        
        // 如果用户选择了七天免密登录
        if (request.isRememberMe()) {
            String token = tokenGenerator.generateToken(user.getId(), request.getDeviceId());
            
            AutoLoginToken autoLoginToken = new AutoLoginToken();
            autoLoginToken.setToken(token);
            autoLoginToken.setUserId(user.getId());
            autoLoginToken.setDeviceId(request.getDeviceId());
            autoLoginToken.setUserAgent(request.getUserAgent());
            autoLoginToken.setCreateTime(LocalDateTime.now());
            autoLoginToken.setExpireTime(LocalDateTime.now().plusDays(7));
            autoLoginToken.setIp(request.getIp());
            
            redisTokenStore.storeToken(token, autoLoginToken);
            
            response.setToken(token);
            response.setRememberMe(true);
        }
        
        return ResponseEntity.ok(response);
    }
    
    @PostMapping("/auto-login")
    public ResponseEntity<LoginResponse> autoLogin(@RequestBody AutoLoginRequest request) {
        // 验证Token有效性
        boolean isValid = tokenValidator.validateToken(
            request.getToken(), 
            request.getDeviceId(), 
            request.getUserAgent(), 
            request.getIp()
        );
        
        if (!isValid) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(LoginResponse.failed("免密登录Token无效"));
        }
        
        // 获取用户信息
        AutoLoginToken tokenInfo = redisTokenStore.getToken(request.getToken());
        User user = userService.getById(tokenInfo.getUserId());
        
        // 延长Token有效期
        tokenValidator.refreshToken(request.getToken());
        
        // 返回登录成功信息
        LoginResponse response = new LoginResponse();
        response.setUserId(user.getId());
        response.setUsername(user.getUsername());
        response.setToken(request.getToken());
        response.setRememberMe(true);
        
        return ResponseEntity.ok(response);
    }
    
    @PostMapping("/logout")
    public ResponseEntity<String> logout(@RequestBody LogoutRequest request) {
        // 删除Token
        if (request.getToken() != null) {
            AutoLoginToken tokenInfo = redisTokenStore.getToken(request.getToken());
            if (tokenInfo != null) {
                redisTokenStore.removeToken(request.getToken(), tokenInfo.getUserId());
            }
        }
        
        return ResponseEntity.ok("退出成功");
    }
}

2. 拦截器实现

@Component
public class AutoLoginInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("Authorization");
        
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            
            boolean isValid = tokenValidator.validateToken(
                token,
                request.getHeader("Device-ID"), 
                request.getHeader("User-Agent"),
                getClientIpAddress(request)
            );
            
            if (isValid) {
                // 设置当前用户上下文
                AutoLoginToken tokenInfo = redisTokenStore.getToken(token);
                CurrentUserContext.setUserId(tokenInfo.getUserId());
                
                // 延长Token有效期
                tokenValidator.refreshToken(token);
            }
        }
        
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 清理用户上下文
        CurrentUserContext.clear();
    }
    
    private String getClientIpAddress(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

3. 用户上下文管理

// 当前线程用户上下文
public class CurrentUserContext {
    private static final ThreadLocal<Long> USER_CONTEXT = new ThreadLocal<>();
    
    public static void setUserId(Long userId) {
        USER_CONTEXT.set(userId);
    }
    
    public static Long getUserId() {
        return USER_CONTEXT.get();
    }
    
    public static void clear() {
        USER_CONTEXT.remove();
    }
    
    public static boolean isAuthenticated() {
        return USER_CONTEXT.get() != null;
    }
}

// 用户信息服务
@Service
public class CurrentUserService {
    
    public User getCurrentUser() {
        Long userId = CurrentUserContext.getUserId();
        if (userId != null) {
            return userService.getById(userId);
        }
        return null;
    }
    
    public boolean isCurrentUser(Long userId) {
        return Objects.equals(CurrentUserContext.getUserId(), userId);
    }
}

业务场景应用

1. 登录页面实现

<!DOCTYPE html>
<html>
<head>
    <title>用户登录</title>
</head>
<body>
    <form id="loginForm">
        <div>
            <label>用户名:</label>
            <input type="text" id="username" required>
        </div>
        <div>
            <label>密码:</label>
            <input type="password" id="password" required>
        </div>
        <div>
            <input type="checkbox" id="rememberMe"> 
            <label for="rememberMe">七天内自动登录</label>
        </div>
        <button type="submit">登录</button>
    </form>

    <script>
        document.getElementById('loginForm').addEventListener('submit', async function(e) {
            e.preventDefault();
            
            const formData = {
                username: document.getElementById('username').value,
                password: document.getElementById('password').value,
                rememberMe: document.getElementById('rememberMe').checked,
                deviceId: getDeviceId(), // 生成设备ID
                userAgent: navigator.userAgent,
                ip: await getClientIP() // 获取客户端IP
            };
            
            const response = await fetch('/auth/login', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify(formData)
            });
            
            if (response.ok) {
                const data = await response.json();
                if (data.token) {
                    // 保存Token到localStorage(免密登录用)
                    localStorage.setItem('autologin_token', data.token);
                }
                window.location.href = '/dashboard';
            } else {
                alert('登录失败');
            }
        });
    </script>
</body>
</html>

2. 自动登录检查

// 页面加载时检查自动登录
window.addEventListener('load', async function() {
    const token = localStorage.getItem('autologin_token');
    if (token) {
        const response = await fetch('/auth/auto-login', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                token: token,
                deviceId: getDeviceId(),
                userAgent: navigator.userAgent,
                ip: await getClientIP()
            })
        });
        
        if (response.ok) {
            const data = await response.json();
            // 自动登录成功,跳转到主页
            window.location.href = '/dashboard';
        } else {
            // Token无效,清除本地存储
            localStorage.removeItem('autologin_token');
        }
    }
});

3. 安全管理功能

// 安全管理服务
@Service
public class SecurityManagementService {
    
    // 查看用户的所有登录设备
    public List<LoginDeviceInfo> getUserLoginDevices(Long userId) {
        // 从Redis获取用户的所有Token信息
        String userTokenKey = "autologin:user:" + userId;
        String token = (String) redisTemplate.opsForValue().get(userTokenKey);
        
        if (token != null) {
            AutoLoginToken autoLoginToken = redisTokenStore.getToken(token);
            if (autoLoginToken != null) {
                LoginDeviceInfo deviceInfo = new LoginDeviceInfo();
                deviceInfo.setToken(token);
                deviceInfo.setDeviceId(autoLoginToken.getDeviceId());
                deviceInfo.setLastLoginTime(autoLoginToken.getCreateTime());
                deviceInfo.setExpireTime(autoLoginToken.getExpireTime());
                return Arrays.asList(deviceInfo);
            }
        }
        
        return new ArrayList<>();
    }
    
    // 强制注销某个设备
    public void forceLogoutDevice(Long userId, String token) {
        redisTokenStore.removeToken(token, userId);
    }
    
    // 注销所有设备
    public void logoutAllDevices(Long userId) {
        // 找到所有相关Token并删除
        String userTokenKey = "autologin:user:" + userId;
        String token = (String) redisTemplate.opsForValue().get(userTokenKey);
        
        if (token != null) {
            redisTokenStore.removeToken(token, userId);
        }
    }
}

最佳实践建议

1. 安全防护措施

@Component
public class SecurityEnhancer {
    
    // 防止暴力破解
    private final Map<String, Integer> loginAttemptCount = new ConcurrentHashMap<>();
    private final Map<String, LocalDateTime> lastAttemptTime = new ConcurrentHashMap<>();
    
    public boolean checkLoginAttempt(String username) {
        String key = "login_attempt:" + username;
        Integer count = (Integer) redisTemplate.opsForValue().get(key);
        
        if (count != null && count >= 5) {  // 5次登录失败
            return false;  // 暂时禁止登录
        }
        
        return true;
    }
    
    public void recordLoginAttempt(String username, boolean success) {
        String key = "login_attempt:" + username;
        
        if (success) {
            redisTemplate.delete(key);  // 登录成功,清除尝试记录
        } else {
            // 登录失败,增加尝试次数
            redisTemplate.opsForValue().increment(key);
            redisTemplate.expire(key, 30, TimeUnit.MINUTES);  // 30分钟内有效
        }
    }
    
    // 设备指纹验证
    public String generateDeviceFingerprint(String userAgent, String ip) {
        String fingerprint = userAgent + ":" + ip;
        return DigestUtils.md5DigestAsHex(fingerprint.getBytes());
    }
}

2. 监控告警

@Component
public class LoginMonitor {
    
    @EventListener
    public void handleLoginEvent(LoginEvent event) {
        // 记录登录日志
        log.info("用户登录: userId={}, device={}, ip={}, success={}", 
            event.getUserId(), event.getDeviceId(), event.getIp(), event.isSuccess());
        
        // 监控指标上报
        if (event.isSuccess()) {
            MeterRegistry registry = Metrics.globalRegistry;
            registry.counter("login.success").increment();
            registry.timer("login.duration").record(event.getDuration(), TimeUnit.MILLISECONDS);
        } else {
            Metrics.globalRegistry.counter("login.failure").increment();
        }
        
        // 异常登录检测
        if (isSuspiciousLogin(event)) {
            alertService.sendAlert("异常登录", 
                String.format("用户 %s 在 %s 从 %s 登录", 
                    event.getUserId(), event.getDeviceId(), event.getIp()));
        }
    }
    
    private boolean isSuspiciousLogin(LoginEvent event) {
        // 检查异地登录、频繁登录等异常行为
        return checkGeographicAnomaly(event) || checkFrequencyAnomaly(event);
    }
}

3. 性能优化

@Configuration
public class RedisConfig {
    
    // Redis连接池配置
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofSeconds(5))
            .option(SocketOptions::builder)
                .connectTimeout(Duration.ofSeconds(3))
            .build();
        
        RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration();
        serverConfig.setHostName("localhost");
        serverConfig.setPort(6379);
        
        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }
    
    // Token缓存优化
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // 使用Jackson序列化
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(LazyLoadingAwareObjectMapper.getDefaultFactory());
        serializer.setObjectMapper(objectMapper);
        
        template.setValueSerializer(serializer);
        template.setKeySerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        
        return template;
    }
}

预期效果

通过七天免密登录方案,我们可以实现:

  • 用户体验提升:用户7天内无需重复登录,操作更便捷
  • 登录转化率提高:减少登录步骤,降低流失率
  • 安全性保障:多重验证机制,防止Token盗用
  • 运维友好:完善的监控和管理功能
  • 扩展性强:支持多种免密时长配置

这套方案在保证安全的前提下,极大提升了用户登录体验,是现代Web应用的标配功能。


欢迎关注公众号"服务端技术精选",获取更多技术干货!
欢迎大家加群交流


标题:基于SpringBoot + Redis实现网站七天免密登录设计思路及实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/02/12/1770702415931.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消