基于SpringBoot + Redis實(shí)現(xiàn)網(wǎng)站七天免密登錄系統(tǒng)
引言
最近在優(yōu)化用戶登錄體驗(yàn)時(shí),發(fā)現(xiàn)一個(gè)有意思的現(xiàn)象:用戶平均每天打開APP 3-5次,但每次都要求輸入密碼,體驗(yàn)真的很差。于是我們引入了七天免密登錄功能,用戶勾選"七天內(nèi)自動(dòng)登錄"后,7天內(nèi)無(wú)需輸入密碼,大大提升了用戶體驗(yàn)。
很多同學(xué)可能覺得免密登錄很復(fù)雜,其實(shí)只要合理利用Redis的過期機(jī)制,實(shí)現(xiàn)起來(lái)并不難。今天就來(lái)聊聊如何用SpringBoot + Redis實(shí)現(xiàn)一個(gè)安全可靠的七天免密登錄系統(tǒng)。
為什么需要七天免密登錄?
用戶體驗(yàn)痛點(diǎn)
傳統(tǒng)的登錄方式存在這些問題:
頻繁登錄:
- 用戶每天多次打開APP都要輸入賬號(hào)密碼
- 忘記密碼導(dǎo)致登錄失敗
- 手機(jī)端輸入密碼體驗(yàn)差
記憶負(fù)擔(dān):
- 多個(gè)APP需要記住不同密碼
- 密碼復(fù)雜度要求高,難以記憶
- 密碼管理混亂
安全與便利的平衡:
- 短期免密登錄:兼顧安全與便利
- 長(zhǎng)期記住密碼:安全風(fēng)險(xiǎn)高
- 永久免密登錄:安全隱患大
核心架構(gòu)設(shè)計(jì)
我們的七天免密登錄架構(gòu):
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 用戶瀏覽器 │───?│ SpringBoot │───?│ Redis存儲(chǔ) │
│ (登錄頁(yè)面) │ │ (認(rèn)證服務(wù)) │ │ (Token管理) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ 登錄請(qǐng)求(用戶名密碼) │ │
│───────────────────────?│ │
│ │ 生成Token并存儲(chǔ) │
│ │──────────────────────?│
│ 返回Token │ │
│?───────────────────────│ │
│ │ │
│ 后續(xù)請(qǐng)求(Token) │ │
│───────────────────────?│ │
│ │ 驗(yàn)證Token │
│ │──────────────────────?│
│ │?──────────────────────│
│ │ │
核心設(shè)計(jì)要點(diǎn)
1. Token設(shè)計(jì)策略
// 免密登錄Token實(shí)體
@Data
public class AutoLoginToken {
private String token; // 唯一標(biāo)識(shí)
private Long userId; // 用戶ID
private String deviceId; // 設(shè)備ID
private String userAgent; // 用戶代理信息
private LocalDateTime createTime; // 創(chuàng)建時(shí)間
private LocalDateTime expireTime; // 過期時(shí)間
private String ip; // IP地址(可選)
}
// Token生成策略
@Component
public class TokenGenerator {
public String generateToken(Long userId, String deviceId) {
// 使用UUID + 用戶ID + 時(shí)間戳生成唯一Token
String tokenSource = userId + "_" + deviceId + "_" + System.currentTimeMillis();
return DigestUtils.md5DigestAsHex(tokenSource.getBytes());
}
}
2. Redis存儲(chǔ)結(jié)構(gòu)
// Redis存儲(chǔ)設(shè)計(jì)
@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;
// 存儲(chǔ)Token信息
public void storeToken(String token, AutoLoginToken autoLoginToken) {
String key = TOKEN_PREFIX + token;
String userKey = USER_TOKEN_PREFIX + autoLoginToken.getUserId();
// 存儲(chǔ)Token詳細(xì)信息
redisTemplate.opsForValue().set(key, autoLoginToken,
TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
// 建立用戶與Token的關(guān)聯(lián)(用于單點(diǎn)登錄控制)
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. 安全驗(yàn)證機(jī)制
// 安全驗(yàn)證策略
@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不存在或已過期
}
// 驗(yàn)證設(shè)備ID是否匹配
if (!Objects.equals(storedToken.getDeviceId(), deviceId)) {
return false; // 設(shè)備不匹配
}
// 驗(yàn)證User-Agent是否匹配(防止Token被盜用)
if (!Objects.equals(storedToken.getUserAgent(), userAgent)) {
return false; // User-Agent不匹配
}
// 可選:驗(yàn)證IP地址(嚴(yán)格模式)
if (storedToken.getIp() != null && !Objects.equals(storedToken.getIp(), ip)) {
return false; // IP地址不匹配
}
return true;
}
// 驗(yàn)證通過后,延長(zhǎng)Token有效期
public void refreshToken(String token) {
AutoLoginToken storedToken = redisTokenStore.getToken(token);
if (storedToken != null) {
// 重新設(shè)置過期時(shí)間為7天
redisTokenStore.storeToken(token, storedToken);
}
}
}
關(guān)鍵實(shí)現(xiàn)細(xì)節(jié)
1. 登錄控制器
@RestController
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
// 驗(yàn)證用戶名密碼
User user = userService.authenticate(request.getUsername(), request.getPassword());
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(LoginResponse.failed("用戶名或密碼錯(cuò)誤"));
}
// 創(chuàng)建登錄響應(yīng)
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) {
// 驗(yàn)證Token有效性
boolean isValid = tokenValidator.validateToken(
request.getToken(),
request.getDeviceId(),
request.getUserAgent(),
request.getIp()
);
if (!isValid) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(LoginResponse.failed("免密登錄Token無(wú)效"));
}
// 獲取用戶信息
AutoLoginToken tokenInfo = redisTokenStore.getToken(request.getToken());
User user = userService.getById(tokenInfo.getUserId());
// 延長(zhǎng)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. 攔截器實(shí)現(xiàn)
@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) {
// 設(shè)置當(dāng)前用戶上下文
AutoLoginToken tokenInfo = redisTokenStore.getToken(token);
CurrentUserContext.setUserId(tokenInfo.getUserId());
// 延長(zhǎng)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. 用戶上下文管理
// 當(dāng)前線程用戶上下文
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;
}
}
// 用戶信息服務(wù)
@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);
}
}
業(yè)務(wù)場(chǎng)景應(yīng)用
1. 登錄頁(yè)面實(shí)現(xiàn)
<!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">七天內(nèi)自動(dòng)登錄</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(), // 生成設(shè)備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. 自動(dòng)登錄檢查
// 頁(yè)面加載時(shí)檢查自動(dòng)登錄
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();
// 自動(dòng)登錄成功,跳轉(zhuǎn)到主頁(yè)
window.location.href = '/dashboard';
} else {
// Token無(wú)效,清除本地存儲(chǔ)
localStorage.removeItem('autologin_token');
}
}
});
3. 安全管理功能
// 安全管理服務(wù)
@Service
public class SecurityManagementService {
// 查看用戶的所有登錄設(shè)備
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<>();
}
// 強(qiáng)制注銷某個(gè)設(shè)備
public void forceLogoutDevice(Long userId, String token) {
redisTokenStore.removeToken(token, userId);
}
// 注銷所有設(shè)備
public void logoutAllDevices(Long userId) {
// 找到所有相關(guān)Token并刪除
String userTokenKey = "autologin:user:" + userId;
String token = (String) redisTemplate.opsForValue().get(userTokenKey);
if (token != null) {
redisTokenStore.removeToken(token, userId);
}
}
}
最佳實(shí)踐建議
1. 安全防護(hù)措施
@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; // 暫時(shí)禁止登錄
}
return true;
}
public void recordLoginAttempt(String username, boolean success) {
String key = "login_attempt:" + username;
if (success) {
redisTemplate.delete(key); // 登錄成功,清除嘗試記錄
} else {
// 登錄失敗,增加嘗試次數(shù)
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, 30, TimeUnit.MINUTES); // 30分鐘內(nèi)有效
}
}
// 設(shè)備指紋驗(yàn)證
public String generateDeviceFingerprint(String userAgent, String ip) {
String fingerprint = userAgent + ":" + ip;
return DigestUtils.md5DigestAsHex(fingerprint.getBytes());
}
}
2. 監(jiān)控告警
@Component
public class LoginMonitor {
@EventListener
public void handleLoginEvent(LoginEvent event) {
// 記錄登錄日志
log.info("用戶登錄: userId={}, device={}, ip={}, success={}",
event.getUserId(), event.getDeviceId(), event.getIp(), event.isSuccess());
// 監(jiān)控指標(biāo)上報(bào)
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();
}
// 異常登錄檢測(cè)
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. 性能優(yōu)化
@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緩存優(yōu)化
@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;
}
}
預(yù)期效果
通過七天免密登錄方案,我們可以實(shí)現(xiàn):
- 用戶體驗(yàn)提升:用戶7天內(nèi)無(wú)需重復(fù)登錄,操作更便捷
- 登錄轉(zhuǎn)化率提高:減少登錄步驟,降低流失率
- 安全性保障:多重驗(yàn)證機(jī)制,防止Token盜用
- 運(yùn)維友好:完善的監(jiān)控和管理功能
- 擴(kuò)展性強(qiáng):支持多種免密時(shí)長(zhǎng)配置
這套方案在保證安全的前提下,極大提升了用戶登錄體驗(yàn),是現(xiàn)代Web應(yīng)用的標(biāo)配功能。
以上就是基于SpringBoot + Redis實(shí)現(xiàn)網(wǎng)站七天免密登錄系統(tǒng)的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot Redis七天免密登錄的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
SpringBoot如何進(jìn)行對(duì)象復(fù)制的實(shí)踐
本文主要介紹了SpringBoot 如何進(jìn)行對(duì)象復(fù)制,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-09-09
SpringBoot中的錯(cuò)誤處理機(jī)制源碼解析
這篇文章主要介紹了SpringBoot中的錯(cuò)誤處理機(jī)制源碼解析,springboot根據(jù)訪問者的request中的Accept屬性來(lái)判斷要返回什么樣的數(shù)據(jù),SpringBoot存在一個(gè)錯(cuò)誤處理機(jī)制,會(huì)根據(jù)不同請(qǐng)求返回不同的結(jié)果,需要的朋友可以參考下2023-12-12
gateway和jwt網(wǎng)關(guān)認(rèn)證實(shí)現(xiàn)過程解析
這篇文章主要介紹了gateway和jwt網(wǎng)關(guān)認(rèn)證實(shí)現(xiàn)過程解析,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-11-11
Spring中ContextLoaderListener監(jiān)聽詳解
這篇文章主要介紹了Spring中ContextLoaderListener監(jiān)聽詳解,SpringMVC啟動(dòng)時(shí)會(huì)啟動(dòng)WebApplicationContext類型的容器,并且會(huì)調(diào)用之前分析的refresh方法,需要的朋友可以參考下2024-01-01
spring中定時(shí)任務(wù)taskScheduler的詳細(xì)介紹
這篇文章主要介紹了spring中定時(shí)任務(wù)taskScheduler的相關(guān)資料,文中通過示例代碼介紹的很詳細(xì),相信對(duì)大家具有一定的參考價(jià)值,有需要的朋友們下面來(lái)一起看看吧。2017-02-02

