用户登录与登出业务深度解析
视频
引子:登录与注册的本质区别
在分析登录逻辑之前,我们需要先理解一个关键问题:登录和注册面临的挑战是完全不同的。
Step 1:业务理解
登录业务的核心诉求
分库分表带来的挑战
这里有一个很重要的技术背景需要理解:
Step 2:源码解析
整体架构一览
相关源码
- 网关核心执行逻辑 (Gateway Core)
这个方法通常在网关的全局过滤器(GlobalFilter)中调用。
/**
* 网关核心执行逻辑
* 作用:拦截请求,进行签名验证、解密、Token解析,并将解析出的 userId 透传给下游。
*/
private Map<String,String> doExecute(String originalBody, ServerWebExchange exchange){
log.info("current thread verify: {}", Thread.currentThread().getName());
ServerHttpRequest request = exchange.getRequest();
// 1. 准备请求体数据
String requestBody = originalBody;
Map<String, String> bodyContent = new HashMap<>(32);
// 将 JSON 字符串转为 Map,方便后续提取 code、body 等字段
if (StringUtil.isNotEmpty(originalBody)) {
bodyContent = JSON.parseObject(originalBody, Map.class);
}
String code = null; // 渠道编码 (如: IOS, ANDROID, H5)
String token; // 用户 Token
String userId = null; // 解析出的用户 ID
String url = request.getPath().value();
// 检查是否开启“免签模式” (通常用于内部调试或特定后门,生产环境通常关闭)
String noVerify = request.getHeaders().getFirst(NO_VERIFY);
boolean allowNormalAccess = gatewayProperty.isAllowNormalAccess();
// 安全拦截:如果配置不允许普通访问,且请求试图绕过验证,则直接抛异常
if ((!allowNormalAccess) && (VERIFY_VALUE.equals(noVerify))) {
throw new DaMaiFrameException(BaseCode.ONLY_SIGNATURE_ACCESS_IS_ALLOWED);
}
// 2. 核心校验流程
// checkParameter: 基础参数非空检查
// skipCheckParameter: 判断当前 URL 是否在“白名单”中(如支付宝回调接口不需要验签)
if (checkParameter(originalBody, noVerify) && !skipCheckParameter(url)) {
String encrypt = request.getHeaders().getFirst(ENCRYPT);
// 从请求体获取渠道 Code (Client ID)
code = bodyContent.get(CODE);
// 从 Header 获取 Token
token = request.getHeaders().getFirst(TOKEN);
// 【关键】根据 Code 获取该渠道的密钥配置 (公钥、私钥、Token密钥)
GetChannelDataVo channelDataVo = channelDataService.getChannelDataByCode(code);
// 3. 业务数据解密 (如果启用了 V2 加密)
// 这里的 BUSINESS_BODY 是真正传给业务服务的参数,网关层负责解密
if (StringUtil.isNotEmpty(encrypt) && V2.equals(encrypt)) {
// 使用渠道的 SecretKey 进行 AES/RSA 解密
String decrypt = RsaTool.decrypt(bodyContent.get(BUSINESS_BODY), channelDataVo.getDataSecretKey());
// 将解密后的明文放回 Map,准备传给下游
bodyContent.put(BUSINESS_BODY, decrypt);
}
// 4. RSA 签名验证 (防篡改)
// 使用渠道的公钥 (SignPublicKey) 验证请求参数签名
boolean checkFlag = RsaSignTool.verifyRsaSign256(bodyContent, channelDataVo.getSignPublicKey());
if (!checkFlag) {
throw new DaMaiFrameException(BaseCode.RSA_SIGN_ERROR);
}
// 5. Token 校验策略
// 判断当前 URL 是否需要强制登录 (Protected Resources)
// 注意:skipCheckToken 返回 false 代表“不能跳过” -> “必须检查”
boolean skipCheckTokenResult = skipCheckToken(url);
// 如果是受保护接口,但没有 Token -> 抛异常
if (!skipCheckTokenResult && StringUtil.isEmpty(token)) {
ArgumentError argumentError = new ArgumentError();
argumentError.setArgumentName(token);
argumentError.setMessage("token参数为空");
List<ArgumentError> argumentErrorList = new ArrayList<>();
argumentErrorList.add(argumentError);
throw new ArgumentException(BaseCode.ARGUMENT_EMPTY.getCode(), argumentErrorList);
}
// 如果是受保护接口 -> 强制解析 Token 获取 UserId
if (!skipCheckTokenResult) {
// 调用 TokenService 进行 JWT 解析 + Redis 校验
UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret());
userId = userVo.getId();
}
// 6. 柔性 Token 解析 (Soft Check)
// 场景:节目详情页 (Program Detail)。
// 这是一个公开接口(skipCheckTokenResult=true),但如果用户带了 Token,
// 我们希望解析出 userId,以便查询“用户是否收藏了该节目”等个性化信息。
if (StringUtil.isEmpty(userId) && checkNeedUserId(url) && StringUtil.isNotEmpty(token)) {
// 这里通常建议加 try-catch,因为非强制接口 Token 失效不应阻断请求,降级为游客即可
UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret());
userId = userVo.getId();
}
// 更新 requestBody 为真正的业务参数 (可能经过了解密)
requestBody = bodyContent.get(BUSINESS_BODY);
}
// 7. API 限流 (Rate Limiting)
// 防止接口被刷
apiRestrictService.apiRestrict(userId, url, request);
// 8. 构造结果 Map
// 这个 Map 会被后续的 Filter 使用,通常会将 userId 放入 Request Header 传给下游微服务
Map<String,String> map = new HashMap<>(4);
map.put(REQUEST_BODY, requestBody);
if (StringUtil.isNotEmpty(code)) {
map.put(CODE, code);
}
if (StringUtil.isNotEmpty(userId)) {
map.put(USER_ID, userId);
}
return map;
}
/**
* 判断是否跳过 Token 检查
* 逻辑:如果 URL 在配置的 checkTokenPaths (受保护路径) 中,则返回 false (不跳过 -> 检查)。
* 否则返回 true (跳过 -> 游客可访问)。
*/
public boolean skipCheckToken(String url){
for (String checkTokenPath : gatewayProperty.getCheckTokenPaths()) {
PathMatcher matcher = new AntPathMatcher();
// 如果当前 URL 匹配到了配置中的受保护路径
if (matcher.match(checkTokenPath, url)) {
return false; // 匹配到了,说明是受保护资源,不能跳过检查
}
}
return true; // 没匹配到,说明是公开资源,可以跳过检查
}
- Token 解析业务 (Token Service)
TokenService 实现了双重校验机制:不仅校验 JWT 签名,还校验 Redis 会话状态。
@Component
public class TokenService {
@Autowired
private RedisCache redisCache;
// 纯 JWT 解析:从 Token 字符串中提取 userId
public String parseToken(String token, String tokenSecret){
String userStr = TokenUtil.parseToken(token, tokenSecret);
if (StringUtil.isNotEmpty(userStr)) {
return JSONObject.parseObject(userStr).getString("userId");
}
return null;
}
/**
* 获取用户信息 (强校验)
* 1. 解析 JWT 获取 userId。
* 2. 查询 Redis 确认会话有效。
*/
public UserVo getUser(String token, String code, String tokenSecret){
UserVo userVo = null;
// 1. 第一步:解析 Token
String userId = parseToken(token, tokenSecret);
// 2. 第二步:Redis 二次校验 (Server-side Session Check)
// 为什么有了 JWT 还要查 Redis?
// 因为 JWT 一旦签发无法撤销。如果用户登出、改密码或被封禁,必须通过 Redis 删除 Key 来强制 Token 失效。
// Key 结构:USER_LOGIN:CHANNEL_CODE:USER_ID
if (StringUtil.isNotEmpty(userId)) {
userVo = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId), UserVo.class);
}
// 3. 如果 Redis 中没有用户信息,抛出“用户未登录”异常
return Optional.ofNullable(userVo).orElseThrow(() -> new DaMaiFrameException(BaseCode.LOGIN_USER_NOT_EXIST));
}
}
- 配置类 (Gateway Property)
定义了哪些接口需要保护,哪些接口可以公开访问。
@Data
@Component
public class GatewayProperty {
// 需要 API 限流的路径
@Value("${api.limit.paths:#{null}}")
private String[] apiRestrictPaths;
/**
* 【核心配置】受保护路径列表 (Protected Paths)
* 这里列出的所有接口,都必须携带有效的 Token 才能访问。
* 例如:下单、添加购票人、修改用户信息等。
*/
@Value("${skip.check.token.paths:/**/program/order/create/v1, ..., /**/ticket/user/add, ...}")
private String[] checkTokenPaths;
@Value("${skip.check.parmeter.paths:/**/alipay/notify}")
private String[] checkSkipParmeterPaths;
@Value("${allow.normal.access:true}")
private boolean allowNormalAccess;
// 需要尝试提取 userId 的路径(公开但可个性化),如节目详情页
@Value("${userId.paths:/**/program/detail,/**/program/detail/v1,/**/program/detail/v2}")
private String[] userIdPaths;
}
- 控制层 (Controller)
这部分比较简单,主要是接收 HTTP 请求并转发给 Service 层。
// Controller 层注解,标识这是一个 RESTful 接口控制器
@RestController
@RequestMapping("/user/login") // 假设的路径,根据上下文推断
public class UserLoginController {
@Autowired
private UserService userService;
// Swagger/OpenAPI 注解,用于生成接口文档
@Operation(summary = "登录")
// 接收 POST 请求
@PostMapping(value = "/login")
// @Valid 开启参数校验,@RequestBody 接收 JSON 请求体
public ApiResponse<UserLoginVo> login(@Valid @RequestBody UserLoginDto userLoginDto) {
// 调用 Service 层的 login 方法,并将结果封装为统一响应对象返回
return ApiResponse.ok(userService.login(userLoginDto));
}
}
5.业务层 (Service) - 核心逻辑
/**
* 登录业务逻辑核心方法
* @param userLoginDto 登录入参(包含手机号/邮箱、密码、渠道Code等)
* @return 用户信息(包含Token和UserId)
* */
public UserLoginVo login(UserLoginDto userLoginDto) {
UserLoginVo userLoginVo = new UserLoginVo();
// 获取渠道Code (用于区分不同端,如App、小程序,可能对应不同的Token密钥)
String code = userLoginDto.getCode();
String mobile = userLoginDto.getMobile();
String email = userLoginDto.getEmail();
String password = userLoginDto.getPassword();
// 1. 基础校验:手机号和邮箱不能同时为空,至少需要一种登录方式
if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) {
throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST);
}
Long userId;
// 2. 分支逻辑:判断是手机号登录还是邮箱登录
if (StringUtil.isNotEmpty(mobile)) {
// ============ 手机号登录流程 ============
// A. 防暴力破解检查:从 Redis 获取该手机号的登录失败次数
String errorCountStr =
redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR, mobile), String.class);
// 如果错误次数超过阈值(ERROR_COUNT_THRESHOLD),直接抛出异常,阻断登录
if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) {
throw new DaMaiFrameException(BaseCode.MOBILE_ERROR_COUNT_TOO_MANY);
}
// B. 查映射表:UserMobile
// 解释:在大用户量场景下,User表通常按 userId 分库分表。
// 为了支持手机号登录,需要一张 UserMobile 表作为索引,将 mobile 映射到 userId。
LambdaQueryWrapper<UserMobile> queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
.eq(UserMobile::getMobile, mobile);
UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);
// C. 账号不存在处理
if (Objects.isNull(userMobile)) {
// 记录错误次数到 Redis,防刷
redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1);
// 设置由错误计数的过期时间(例如1分钟内限制登录)
redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1,TimeUnit.MINUTES);
throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY);
}
// 获取真正的 userId
userId = userMobile.getUserId();
} else {
// ============ 邮箱登录流程 (逻辑同手机号) ============
// A. 防暴力破解检查
String errorCountStr =
redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR, email), String.class);
if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) {
throw new DaMaiFrameException(BaseCode.EMAIL_ERROR_COUNT_TOO_MANY);
}
// B. 查映射表:UserEmail
LambdaQueryWrapper<UserEmail> queryWrapper = Wrappers.lambdaQuery(UserEmail.class)
.eq(UserEmail::getEmail, email);
UserEmail userEmail = userEmailMapper.selectOne(queryWrapper);
// C. 账号不存在处理
if (Objects.isNull(userEmail)) {
redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1);
redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1,TimeUnit.MINUTES);
throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST);
}
userId = userEmail.getUserId();
}
// 3. 验证密码
// 使用获取到的 userId 和前端传来的 password 查询主表 User
LambdaQueryWrapper<User> queryUserWrapper = Wrappers.lambdaQuery(User.class)
.eq(User::getId, userId)
.eq(User::getPassword, password); // 注意:实际生产中密码应加密比对
User user = userMapper.selectOne(queryUserWrapper);
// 密码错误
if (Objects.isNull(user)) {
// 这里其实也可以增加密码错误的计数逻辑,代码中省略了
throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR);
}
// 4. 保存登录态到服务端 (Redis)
// 这是一个关键点:虽然使用了JWT,但这里依然把用户信息存入了Redis。
// 目的:为了实现 Token 的可撤销(退出登录、强制下线)以及存储一些不方便放在 Token 里的敏感信息。
redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()), user,
tokenExpireTime, TimeUnit.MINUTES);
// 5. 生成 JWT Token 并返回
userLoginVo.setUserId(userId);
// getChannelDataByCode(code).getTokenSecret():根据不同的渠道获取对应的加密密钥,增加安全性
userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret()));
return userLoginVo;
}
// 辅助方法:构建 Token
public String createToken(Long userId, String tokenSecret){
Map<String,Object> map = new HashMap<>(4);
map.put("userId", userId);
// 使用 uidGenerator 生成唯一 jti,将 userId 放入 payload,设置过期时间
return TokenUtil.createToken(String.valueOf(uidGenerator.getUid()), JSON.toJSONString(map), tokenExpireTime * 60 * 1000, tokenSecret);
}
- 工具类 (TokenUtil)
基于 JJWT 库封装的 JWT 生成和解析工具。
@Slf4j
public class TokenUtil {
// 签名算法:HMAC using SHA-256
private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
/**
* 创建 JWT Token
* @param id Token 的唯一标识 (jti)
* @param info Token 的主体内容 (Subject),通常存放用户基础信息的 JSON 串
* @param ttlMillis 过期时间 (毫秒)
* @param tokenSecret 签名密钥
* @return 加密后的 Token 字符串
*/
public static String createToken(String id, String info, long ttlMillis, String tokenSecret) {
long nowMillis = System.currentTimeMillis();
JwtBuilder builder = Jwts.builder()
.setId(id) // 设置 jti (JWT ID)
.setIssuedAt(new Date(nowMillis)) // 设置签发时间 (iat)
.setSubject(info) // 设置主体数据 (sub),这里存的是 userId 的 Map JSON
.signWith(SIGNATURE_ALGORITHM, tokenSecret); // 设置签名算法和密钥
if (ttlMillis >= 0) {
// 设置过期时间 (exp)
builder.setExpiration(new Date(nowMillis + ttlMillis));
}
// 压缩并生成最终字符串
return builder.compact();
}
/**
* 解析 JWT Token
* @param token 前端传来的 Token 字符串
* @param tokenSecret 解析需要的密钥
* @return Token 中的 Subject 内容 (即用户信息的 JSON)
*/
public static String parseToken(String token, String tokenSecret) {
try {
return Jwts.parser()
.setSigningKey(tokenSecret) // 设置解析密钥
.parseClaimsJws(token) // 解析 Token
.getBody()
.getSubject(); // 获取 Subject 部分
} catch (ExpiredJwtException jwtException) {
// 捕获 Token 过期异常,打印日志并抛出业务异常
log.error("parseToken error", jwtException);
throw new DaMaiFrameException(BaseCode.TOKEN_EXPIRE);
}
}
// Main 方法用于本地测试生成和解析
public static void main(String[] args) {
String tokenSecret = "CSYZWECHAT"; // 测试密钥
JSONObject jsonObject = new JSONObject();
jsonObject.put("001key", "001value");
// 生成 Token
String token1 = TokenUtil.createToken("1", jsonObject.toJSONString(), 10000, tokenSecret);
System.out.println("token:" + token1);
// 解析 Token
String subject = TokenUtil.parseToken(token1, tokenSecret);
System.out.println("解析token后的值:" + subject);
}
}
设计亮点深度解读
亮点一:分库分表下的索引表设计
这是整个登录系统最精妙的设计之一。理解为什么需要这样设计:
代码体现:
// 手机号登录:先查索引表
LambdaQueryWrapper<UserMobile> queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
.eq(UserMobile::getMobile, mobile);
UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);
// 获取真正的 userId
userId = userMobile.getUserId();
// 再用 userId 查主表(命中分片键)
LambdaQueryWrapper<User> queryUserWrapper = Wrappers.lambdaQuery(User.class)
.eq(User::getId, userId)
.eq(User::getPassword, password);
User user = userMapper.selectOne(queryUserWrapper);
亮点二:JWT + Redis 双重会话管理
这个设计解决了纯 JWT 和纯 Session 各自的痛点:
代码体现:
// 登录时:同时生成 JWT 和存储 Redis
// 1. 存入 Redis
redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()),
user, tokenExpireTime, TimeUnit.MINUTES);
// 2. 生成 JWT
userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret()));
// 网关验证时:两者都要检查
public UserVo getUser(String token, String code, String tokenSecret) {
// 1. 解析 JWT 获取 userId
String userId = parseToken(token, tokenSecret);
// 2. 检查 Redis 是否存在登录态
if (StringUtil.isNotEmpty(userId)) {
userVo = redisCache.get(RedisKeyBuild.createRedisKey(
RedisKeyManage.USER_LOGIN, code, userId), UserVo.class);
}
// 3. Redis 不存在则拒绝
return Optional.ofNullable(userVo)
.orElseThrow(() -> new DaMaiFrameException(BaseCode.LOGIN_USER_NOT_EXIST));
}
亮点三:多端隔离与动态密钥
代码体现:
// 根据渠道 code 获取对应的密钥
private GetChannelDataVo getChannelDataByCode(String code) {
// 先从 Redis 查
GetChannelDataVo channelDataVo = getChannelDataByRedis(code);
if (Objects.isNull(channelDataVo)) {
// 调用 base-data 服务查询
channelDataVo = getChannelDataByClient(code);
// 放到 Redis 中缓存
setChannelDataRedis(code, channelDataVo);
}
return channelDataVo;
}
// 使用渠道密钥生成 Token
userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret()));
亮点四:网关层统一验证
代码体现:
// 网关验证核心逻辑
private Map<String,String> doExecute(String originalBody, ServerWebExchange exchange) {
// ...
// 判断是否需要验证 Token
boolean skipCheckTokenResult = skipCheckToken(url);
if (!skipCheckTokenResult && StringUtil.isEmpty(token)) {
// 需要 Token 但没传,报错
throw new ArgumentException(BaseCode.ARGUMENT_EMPTY.getCode(), argumentErrorList);
}
if (!skipCheckTokenResult) {
// 验证 Token 并获取用户信息
UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret());
userId = userVo.getId();
}
// 将 userId 传递给下游
map.put(USER_ID, userId);
return map;
}
// 路径匹配逻辑
public boolean skipCheckToken(String url) {
for (String skipCheckTokenPath : gatewayProperty.getCheckTokenPaths()) {
PathMatcher matcher = new AntPathMatcher();
if (matcher.match(skipCheckTokenPath, url)) {
return false; // 匹配到需要验证的路径
}
}
return true; // 不在列表中,跳过验证
}
亮点五:防暴力破解机制
完整登录流程梳理
后续请求的验证流程
可取之处总结
| 设计点 | 解决的问题 | 实现方式 |
|---|---|---|
| 索引表设计 | 分库分表下的非分片键查询 | UserMobile/UserEmail 表映射到 userId |
| JWT + Redis | JWT 无法主动失效的问题 | Redis 存储登录态,双重验证 |
| 多端隔离 | 不同端的安全策略独立 | 渠道 code 区分,独立密钥 |
| 网关统一验证 | 验证逻辑集中,下游信任 | 网关层解析 Token,传递 userId |
| 防暴力破解 | 账号撞库、密码爆破 | Redis 错误计数 + 锁定机制 |
| 路径白名单 | 灵活控制哪些接口需要登录 | 配置化的路径匹配 |
待优化点
1.密码错误不计数
- 现状:只有"账号不存在"才增加错误计数
- 风险:密码暴力破解无限制
- 建议:密码错误也要计数
2.登录成功不清零
- 现状:登录成功后错误计数仍残留影响:用户可能被误伤
- 建议:登录成功后清零错误计数
3.登录态无续期
- 现状:Redis 过期时间固定,不会续期
- 影响:活跃用户也会被迫重新登录
- 建议:活跃时刷新过期时间
💡 总结:这套登录系统的设计是比较成熟的,尤其是索引表解决分库分表查询问题、JWT+Redis 双重会话管理、网关层统一验证这几个设计亮点值得学习。不足之处主要在于防暴力破解机制不够完善(密码错误不计数),以及缺少登录态续期机制。整体来说,架构设计是合理的,细节可以继续优化。
Step 3:方案评估与改进
改进方案设计
Step 4:改进后的代码实现
改进后的登录逻辑
/**
* 用户登录(改进版)
* 包含:参数校验、防暴力破解、分表查询、密码验证、登录态管理
*/
public UserLoginVo login(UserLoginDto userLoginDto) {
UserLoginVo userLoginVo = new UserLoginVo();
String code = userLoginDto.getCode(); // 渠道编码 (App, H5等)
String mobile = userLoginDto.getMobile();
String email = userLoginDto.getEmail();
String password = userLoginDto.getPassword();
// ========== 1. 基础参数校验 ==========
if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) {
throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST);
}
// 确定本次登录使用的标识(Key)和对应的 Redis 错误计数类型
// 策略模式思想:统一处理手机号和邮箱的差异
String loginKey = StringUtil.isNotEmpty(mobile) ? mobile : email;
RedisKeyManage errorKeyType = StringUtil.isNotEmpty(mobile)
? RedisKeyManage.LOGIN_USER_MOBILE_ERROR
: RedisKeyManage.LOGIN_USER_EMAIL_ERROR;
// 构造 Redis Key,例如: "login:error:mobile:13800138000"
String errorCountKey = RedisKeyBuild.createRedisKey(errorKeyType, loginKey);
// ========== 2. 防暴力破解检查 (Pre-check) ==========
// 在查库之前先查 Redis,如果已经被锁,直接抛异常,保护数据库
String errorCountStr = redisCache.get(errorCountKey, String.class);
int currentErrorCount = StringUtil.isNotEmpty(errorCountStr) ? Integer.parseInt(errorCountStr) : 0;
// 阈值检查:例如允许错误 5 次
if (currentErrorCount >= ERROR_COUNT_THRESHOLD) {
// 抛出 "错误次数过多,请稍后再试"
throw new DaMaiFrameException(BaseCode.LOGIN_ERROR_COUNT_TOO_MANY);
}
// ========== 3. 查询用户 ID (分表查询) ==========
Long userId;
try {
// 调用封装好的私有方法,根据 mobile 或 email 查 UserMobile/UserEmail 表
userId = getUserIdByLoginKey(mobile, email);
} catch (DaMaiFrameException e) {
// 【关键安全改进】:账号不存在时,也要增加错误计数!
// 原因:防止黑客通过“账号不存在”和“密码错误”的响应时间或提示差异,来通过枚举探测哪些手机号已注册。
incrementErrorCount(errorCountKey);
throw e;
}
// ========== 4. 验证密码 (核心认证) ==========
// 使用查到的 userId 和前端传来的 password 查询主表
LambdaQueryWrapper<User> queryUserWrapper = Wrappers.lambdaQuery(User.class)
.eq(User::getId, userId)
.eq(User::getPassword, password); // 生产环境建议使用 BCrypt 等加密比对,不要明文查询
User user = userMapper.selectOne(queryUserWrapper);
if (Objects.isNull(user)) {
// 【关键安全改进】:密码错误也要增加错误计数!
// 原始代码这里漏掉了计数,会导致攻击者可以无限次尝试密码而不被锁定。
incrementErrorCount(errorCountKey);
throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR);
}
// ========== 5. 登录成功处理 ==========
// 【体验优化】:登录成功,立即清零错误计数
// 否则用户输错4次后登录成功,下次再输错1次就会被锁,体验不好。
redisCache.del(errorCountKey);
// 6. 保存服务端登录态 (Session/Token State)
// Key: user:login:CHANNEL_CODE:USER_ID
// 作用:用于强制下线、判断多端登录等
redisCache.set(
RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()),
user,
tokenExpireTime, // 例如 30 分钟
TimeUnit.MINUTES
);
// 7. 生成并返回 Token
userLoginVo.setUserId(userId);
// 使用对应渠道的密钥生成 JWT
userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret()));
return userLoginVo;
}
辅助私有方法 (Private Helpers)
提取公共逻辑,让主流程代码干净清爽。
/**
* 辅助方法:增加错误计数并设置锁定时间
* 策略:Redis Incr 原子操作
* 过期时间:ERROR_LOCK_MINUTES (例如 10分钟),意味着限制 10 分钟内不能登录
*/
private void incrementErrorCount(String errorCountKey) {
// 计数 + 1
redisCache.incrBy(errorCountKey, 1);
// 每次错误都重置过期时间,或者只在第一次设置(取决于业务策略)
// 这里采用:每次错误都刷新锁定窗口
redisCache.expire(errorCountKey, ERROR_LOCK_MINUTES, TimeUnit.MINUTES);
}
/**
* 辅助方法:根据登录账号获取 userId
* 封装了分表查询的逻辑
*/
private Long getUserIdByLoginKey(String mobile, String email) {
if (StringUtil.isNotEmpty(mobile)) {
// 查手机号映射表
LambdaQueryWrapper<UserMobile> queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
.eq(UserMobile::getMobile, mobile);
UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);
if (Objects.isNull(userMobile)) {
throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY);
}
return userMobile.getUserId();
} else {
// 查邮箱映射表
LambdaQueryWrapper<UserEmail> queryWrapper = Wrappers.lambdaQuery(UserEmail.class)
.eq(UserEmail::getEmail, email);
UserEmail userEmail = userEmailMapper.selectOne(queryWrapper);
if (Objects.isNull(userEmail)) {
throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST);
}
return userEmail.getUserId();
}
}
用户缓存续期方案 (Refresh Token Strategy)
这个方法通常不在登录接口调用,而是在网关过滤器 (Gateway Filter) 或 拦截器 (Interceptor) 中调用。
原理: 类似于 Session 的滑动过期机制。只要用户在操作(请求接口),就自动延长 Token/Redis 的有效期,避免用户在使用过程中突然被踢出。
/**
* 刷新用户登录态
* 场景:每次鉴权通过后调用
* 策略:惰性续期 (Lazy Refresh) —— 只有当剩余时间不足时才续期,减少 Redis 写操作压力
*/
public void refreshLoginState(String code, Long userId) {
// 构造 Redis Key
String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId);
// 1. 检查 Key 是否存在
if (redisCache.hasKey(loginKey)) {
// 2. 获取剩余存活时间 (TTL)
Long ttl = redisCache.getExpire(loginKey, TimeUnit.MINUTES);
// 3. 续期判定逻辑
// 假设过期时间是 30 分钟。
// 如果 ttl < 15 分钟 (tokenExpireTime / 2),说明用户活跃,给它续满到 30 分钟。
// 如果 ttl > 15 分钟,说明刚登录不久或刚续过,这次不操作 Redis,节省性能。
if (ttl != null && ttl < tokenExpireTime / 2) {
redisCache.expire(loginKey, tokenExpireTime, TimeUnit.MINUTES);
}
}
}
总结优化点
- 安全性提升:
- 防爆破闭环:账号不存在、密码错误都会触发计数,且计数器 Key 统一,攻击者无法区分是账号不对还是密码不对。
- 性能/体验平衡:
- 滑动续期:
refreshLoginState中的ttl < threshold判断非常精髓,避免了每次 HTTP 请求都去写 Redis(写操作比读操作重),只有在必要时才写。
- 滑动续期:
- 代码质量:
- SRP (单一职责):主方法只关注流程控制,查库细节和计数细节下沉到私有方法。
- 可读性:消除了原始代码中大量的
if-else嵌套。
Step 5:登出逻辑分析
当前登出实现
public Boolean logout(UserLogoutDto userLogoutDto) {
String userStr = TokenUtil.parseToken(userLogoutDto.getToken(),getChannelDataByCode(userLogoutDto.getCode())
.getTokenSecret());
if (StringUtil.isEmpty(userStr)) {
throw new DaMaiFrameException(BaseCode.USER_EMPTY);
}
String userId = JSONObject.parseObject(userStr).getString("userId");
redisCache.del(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,userLogoutDto.getCode(),userId));
return true;
}
登出逻辑分析
登出优化建议
/**
* 登出(改进版)
*/
public void logout(UserLogoutDto userLogoutDto) {
String code = userLogoutDto.getCode();
Long userId = userLogoutDto.getId();
// 直接删除登录态,不需要查数据库
// 因为即使用户不存在,删除一个不存在的 key 也不会报错
String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId);
redisCache.del(loginKey);
// 可选:记录登出日志
log.info("用户登出: userId={}, code={}", userId, code);
}
/**
* 踢人下线(管理功能)
*/
public void kickOut(Long userId, String code) {
String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId);
redisCache.del(loginKey);
log.info("用户被踢下线: userId={}, code={}", userId, code);
}
/**
* 全渠道登出(所有设备下线)
*/
public void logoutAllChannels(Long userId) {
// 假设有多个渠道:PC、App、小程序等
List<String> channels = Arrays.asList("0001", "0002", "0003");
for (String code : channels) {
String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId);
redisCache.del(loginKey);
}
log.info("用户全渠道登出: userId={}", userId);
}
完整的登录登出流程图
核心总结
登录 vs 注册 的防护策略对比
| 维度 | 注册 | 登录 |
|---|---|---|
| 面向对象 | 未知用户、新账号 | 已知用户、已注册账号 |
| 核心风险 | 缓存穿透、批量注册 | 暴力破解、字典攻击 |
| 防护重点 | 防"未知 key"穿透 | 防"已知 key"暴力破解 |
| 技术手段 | 布隆过滤器 + 验证码 + 限流 | 错误次数限制 + 账号锁定 |
| 计数维度 | 全局请求数 | 单账号错误次数 |
当前实现的问题与改进
| 问题 | 影响 | 改进方案 |
|---|---|---|
| 密码错误不计数 | 暴力破解无限制 | 密码错误也要增加错误计数 |
| 登录成功不清零 | 用户可能被误伤 | 登录成功清零错误计数 |
| 缓存不续期 | 活跃用户需重登 | 添加缓存续期机制 |
JWT + Redis 的设计意图
| 纯 JWT | JWT + Redis |
|---|---|
| 无状态,无法主动失效 | 服务端可控 |
| 无法踢人下线 | 支持踢人下线 |
| 无法单设备登录 | 支持单设备登录 |
| Token 泄露风险高 | 可主动使 Token 失效 |
💡 设计思想:登录和注册虽然都是用户入口功能,但面临的安全挑战完全不同。注册要防"外来者"(未知的恶意请求),登录要防"内鬼"(针对已知账号的攻击)。理解这个本质区别,才能设计出正确的防护策略。
企业级项目导航:⬅️ 07-敏感数据保护:展示脱敏与加密存储深度解析 | 08-用户登录与登出业务深度解析 | ➡️ 09-高并发业务设计思维框架
💬 评论