27-AuthService:登录的完整链路
密码登录不只是"比对密码"------查用户、禁用检查、双源失败计数、锁定判断、SM3+salt哈希、会话数限制、踢最早会话、签发双令牌、记登录日志。245行AuthService把这些串成一条链。这篇逐段拆loginByPassword和它的七个辅助方法。
文章目录
源码:
browise-platform/src/main/java/com/browise/platform/auth/AuthService.java(245行)
一、loginByPassword主链
java
public Result loginByPassword(String account, String password, String ip) {
// ①查用户
SysUser user = userMapper.selectByAccount(account);
if (user == null) return Result.error("账号不存在");
// ②禁用检查
if ("0".equals(user.getEnabled())) return Result.error("账号已禁用");
// ③双源失败计数
int dbFails = countRecentFailures(user.getPsnId()); // 库里最近N分钟
int totalFails = Math.max(dbFails,
loginFailCount.getOrDefault(failKey, 0)); // 内存累计
if (totalFails >= authProperties.getMaxLoginAttempts()) {
return Result.error("账号已锁定,请X分钟后重试");
}
// ④SM3+salt哈希比对
String hashedInput = hashPassword(password, user.getSalt());
if (!hashedInput.equalsIgnoreCase(user.getPsnPwd())) {
loginFailCount.put(failKey, totalFails + 1); // 内存+1
recordLoginAttempt(user, ip, false); // 库里记失败
return Result.error("密码错误");
}
// ⑤成功------清计数+记日志+发令牌
loginFailCount.remove(failKey);
recordLoginAttempt(user, ip, true);
return buildLoginResult(user, ip);
}
二、双源失败计数:内存+数据库
java
private final Map<String, Integer> loginFailCount = new ConcurrentHashMap<>(); // 内存
private int countRecentFailures(String psnId) { // 数据库
long since = System.currentTimeMillis() - failLockMinutes * 60000L;
"SELECT COUNT(*) FROM SYS_LOGIN_LOG
WHERE PSN_ID=? AND ONLINE_FLAG='0' AND LOGIN_TIME>?" // 失败记录+时间窗口内
}
为什么要两个源------
| 源 | 计数快 | 重启不丢 | 跨实例 |
|---|---|---|---|
| 内存Map | ✓微秒 | ✗清零 | ✗各自计数 |
| SYS_LOGIN_LOG查询 | 慢一次SQL | ✓ | ✓ |
Math.max(dbFails, 内存值) ------取大者。攻击者碰上重启(内存清零),库里的失败记录还在锁;多实例部署时各自内存不共享,但都查同一个库。双源互为补丁,成本是每次登录多一次COUNT查询------登录是低频操作,这点开销无所谓。
锁定时间窗failLockMinutes------只在窗口内的失败计数,超窗自动解锁(不用显式解锁操作,时间到了count自然归零)。
三、SM3+salt哈希
java
public String hashPassword(String plainPassword, String salt) {
if (salt != null && !salt.isEmpty()) {
return sm3Hash.hashHex(plainPassword + salt); // 拼盐后SM3
}
return sm3Hash.hashHex(plainPassword); // 无盐兼容
}
public String generateSalt() {
byte[] bytes = new byte[16]; // 16字节随机盐
RANDOM.nextBytes(bytes); // SecureRandom------密码学安全
// 转hex存库
}
SM3是国密摘要算法 (对标SHA-256)------政务合规要求。拼接顺序固定password+salt------验证侧必须一致(hashPassword同时服务注册/重置/验证三个场景,拼法写死在一处)。
无盐分支是兼容旧数据 ------系统迭代前期的用户密码没盐,迁移过来直接比对无盐哈希。新密码(resetPwd/新增用户)全部generateSalt+带盐哈希。渐进迁移不强制全员改密。
equalsIgnoreCase------hex大小写都认(SM3实现可能输出大写hex,库里存小写)。
四、validatePasswordPolicy:五项复杂度
java
if (password.length() < p.getMinLength()) return "密码长度不能少于X位";
if (p.isRequireUpper() && !password.matches(".*[A-Z].*")) return "密码必须包含大写字母";
if (p.isRequireLower() && !password.matches(".*[a-z].*")) return "密码必须包含小写字母";
if (p.isRequireDigit() && !password.matches(".*\\d.*")) return "密码必须包含数字";
if (p.isRequireSpecial() && !password.matches(".*[!@#$%...].*")) return "密码必须包含特殊字符";
五项全部可配置开关 (AuthProperties.PasswordPolicy,browise.auth.password-policy.*前缀)------政务等保要求与用户体验之间按项目调。返回错误消息字符串(null=通过)而不是boolean------调用方直接把消息显示给用户,不用再查是哪项失败。
五、enforceSessionLimit:会话数与踢最早
java
private void enforceSessionLimit(String psnId, int maxSessions, boolean kickOldest) {
// 在线数 = SYS_LOGIN_LOG里ONLINE_FLAG='1'且LOGOUT_TIME IS NULL的记录数
if (在线数 >= maxSessions) {
if (kickOldest) {
// 踢最早登录的会话------按LOGIN_TIME ASC取第一条置离线
"UPDATE SYS_LOGIN_LOG SET ONLINE_FLAG='0', LOGOUT_TIME=?
WHERE LOG_ID = (SELECT LOG_ID FROM
(SELECT LOG_ID FROM SYS_LOGIN_LOG
WHERE PSN_ID=? AND ONLINE_FLAG='1' AND LOGOUT_TIME IS NULL
ORDER BY LOGIN_TIME ASC) WHERE ROWNUM=1)"
} else {
throw new RuntimeException("登录设备已达上限" + maxSessions);
}
}
}
会话的载体是SYS_LOGIN_LOG表 ------ONLINE_FLAG='1'+LOGOUT_TIME空=一个活会话。不是内存SessionRegistry------重启不丢会话记录(虽然token本身是无状态的,会话数限制需要状态,落库最稳)。
踢最早的ROWNUM子查询 ------Oracle取"最早一条"的三层写法(内层排序+中层取ROWNUM=1+外层UPDATE)。注意这是Oracle方言------MySQL部署时这条SQL要适配(ROW_NUMBER() OVER)。会话踢除只改数据库标记------被踢设备的token依然有效(JWT无状态),要等它下次请求时黑名单校验/过期才真失效。完整踢除=置离线+iat时间点拉黑,当前实现靠token自然过期兜底(access最多2小时)。
kickOldest=false时抛异常------"拒绝第N个设备登录"的严格模式。两种模式配置切换(session-policy.kick-oldest)。
六、refreshToken:三查+旋转
java
public Result refreshToken(String oldRefreshToken) {
// ①格式与类型
if (!jwtUtil.validate(oldRefreshToken) || !jwtUtil.isRefreshToken(oldRefreshToken))
return Result.error("refresh token无效或已过期");
// ②用户实时状态(refresh 7天里用户可能被禁用)
SysUser user = userMapper.selectById(psnId);
if (user == null || "0".equals(user.getEnabled())) return Result.error(...);
// ③旧refresh进黑名单------一次性
jwtBlacklist.blockRefreshToken(oldRefreshToken);
// ④签发全新的access+refresh对
return 新双令牌;
}
③是旋转(rotation) ------每个refresh只用一次,用完拉黑,发新的。防的是refresh被盗后的无限续期 :攻击者偷到refresh①,用户本人也在用------先到者刷新成功,后到者拿着已拉黑的refresh被拒------被盗这件事在下次合法使用时自动暴露(用户刷新失败=有异常)。
第25篇说"当前实现refresh用满7天"------看了AuthService的完整实现要修正:refreshToken端点确实是旋转模式(blockRefreshToken+双新令牌)。JwtUtil.refreshAccessToken(不拉黑的简版)是留给内部信任场景的工具方法。
七、四种登录方式
java
loginByPassword(account, password, ip) // 密码------完整链路
loginByPhone(phone, smsCode, ip) // 手机号------短信码(码校验在Controller层)
loginBySso(account, ssoToken, ip) // SSO------对接统一门户
loginByCa(certificateId, ip) // CA证书------预留
后三种跳过密码与失败计数 ------认证凭据在外部系统(短信网关/门户/CA),这里只做"凭据对应用户存在且启用"。四种方式汇入同一个buildLoginResult------会话限制、双令牌签发、登录日志对四种登录一视同仁。
✅ 亮点:loginByPassword主链的五步、双源失败计数互为补丁的设计、SM3+salt与无盐渐进迁移、会话数以SYS_LOGIN_LOG为载体重启不丢、refreshToken旋转模式防被盗无限续期、四种登录方式汇入同一签发出口。适合做认证模块的人。扩展方向:第25/26篇令牌与过滤器、第28篇JwtBlacklist、第34篇安全评审。