27-AuthService:登录的完整链路
密码登录不只是"比对密码"——查用户、禁用检查、双源失败计数、锁定判断、SM3+salt哈希、会话数限制、踢最早会话、签发双令牌、记登录日志。245行AuthService把这些串成一条链。这篇逐段拆loginByPassword和它的七个辅助方法。
文章目录
- 27-AuthService:登录的完整链路
- 一、loginByPassword主链
- 二、双源失败计数:内存+数据库
- 三、SM3+salt哈希
- 四、validatePasswordPolicy:五项复杂度
- 五、enforceSessionLimit:会话数与踢最早
- 六、refreshToken:三查+旋转
- 七、四种登录方式
源码:
browise-platform/src/main/java/com/browise/platform/auth/AuthService.java(245行)
一、loginByPassword主链
publicResultloginByPassword(Stringaccount,Stringpassword,Stringip){// ①查用户SysUseruser=userMapper.selectByAccount(account);if(user==null)returnResult.error("账号不存在");// ②禁用检查if("0".equals(user.getEnabled()))returnResult.error("账号已禁用");// ③双源失败计数intdbFails=countRecentFailures(user.getPsnId());// 库里最近N分钟inttotalFails=Math.max(dbFails,loginFailCount.getOrDefault(failKey,0));// 内存累计if(totalFails>=authProperties.getMaxLoginAttempts()){returnResult.error("账号已锁定,请X分钟后重试");}// ④SM3+salt哈希比对StringhashedInput=hashPassword(password,user.getSalt());if(!hashedInput.equalsIgnoreCase(user.getPsnPwd())){loginFailCount.put(failKey,totalFails+1);// 内存+1recordLoginAttempt(user,ip,false);// 库里记失败returnResult.error("密码错误");}// ⑤成功——清计数+记日志+发令牌loginFailCount.remove(failKey);recordLoginAttempt(user,ip,true);returnbuildLoginResult(user,ip);}二、双源失败计数:内存+数据库
privatefinalMap<String,Integer>loginFailCount=newConcurrentHashMap<>();// 内存privateintcountRecentFailures(StringpsnId){// 数据库longsince=System.currentTimeMillis()-failLockMinutes*60000L;"SELECTCOUNT(*)FROMSYS_LOGIN_LOGWHEREPSN_ID=?ANDONLINE_FLAG='0'ANDLOGIN_TIME>?"// 失败记录+时间窗口内}为什么要两个源——
| 源 | 计数快 | 重启不丢 | 跨实例 |
|---|---|---|---|
| 内存Map | ✓微秒 | ✗清零 | ✗各自计数 |
| SYS_LOGIN_LOG查询 | 慢一次SQL | ✓ | ✓ |
Math.max(dbFails, 内存值)——取大者。攻击者碰上重启(内存清零),库里的失败记录还在锁;多实例部署时各自内存不共享,但都查同一个库。双源互为补丁,成本是每次登录多一次COUNT查询——登录是低频操作,这点开销无所谓。
锁定时间窗failLockMinutes——只在窗口内的失败计数,超窗自动解锁(不用显式解锁操作,时间到了count自然归零)。
三、SM3+salt哈希
publicStringhashPassword(StringplainPassword,Stringsalt){if(salt!=null&&!salt.isEmpty()){returnsm3Hash.hashHex(plainPassword+salt);// 拼盐后SM3}returnsm3Hash.hashHex(plainPassword);// 无盐兼容}publicStringgenerateSalt(){byte[]bytes=newbyte[16];// 16字节随机盐RANDOM.nextBytes(bytes);// SecureRandom——密码学安全// 转hex存库}SM3是国密摘要算法(对标SHA-256)——政务合规要求。拼接顺序固定password+salt——验证侧必须一致(hashPassword同时服务注册/重置/验证三个场景,拼法写死在一处)。
无盐分支是兼容旧数据——系统迭代前期的用户密码没盐,迁移过来直接比对无盐哈希。新密码(resetPwd/新增用户)全部generateSalt+带盐哈希。渐进迁移不强制全员改密。
equalsIgnoreCase——hex大小写都认(SM3实现可能输出大写hex,库里存小写)。
四、validatePasswordPolicy:五项复杂度
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:会话数与踢最早
privatevoidenforceSessionLimit(StringpsnId,intmaxSessions,booleankickOldest){// 在线数 = SYS_LOGIN_LOG里ONLINE_FLAG='1'且LOGOUT_TIME IS NULL的记录数if(在线数>=maxSessions){if(kickOldest){// 踢最早登录的会话——按LOGIN_TIME ASC取第一条置离线"UPDATESYS_LOGIN_LOGSETONLINE_FLAG='0',LOGOUT_TIME=?WHERELOG_ID=(SELECTLOG_IDFROM(SELECTLOG_IDFROMSYS_LOGIN_LOGWHEREPSN_ID=?ANDONLINE_FLAG='1'ANDLOGOUT_TIMEISNULLORDERBYLOGIN_TIMEASC)WHEREROWNUM=1)"}else{thrownewRuntimeException("登录设备已达上限"+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:三查+旋转
publicResultrefreshToken(StringoldRefreshToken){// ①格式与类型if(!jwtUtil.validate(oldRefreshToken)||!jwtUtil.isRefreshToken(oldRefreshToken))returnResult.error("refresh token无效或已过期");// ②用户实时状态(refresh 7天里用户可能被禁用)SysUseruser=userMapper.selectById(psnId);if(user==null||"0".equals(user.getEnabled()))returnResult.error(...);// ③旧refresh进黑名单——一次性jwtBlacklist.blockRefreshToken(oldRefreshToken);// ④签发全新的access+refresh对return新双令牌;}③是旋转(rotation)——每个refresh只用一次,用完拉黑,发新的。防的是refresh被盗后的无限续期:攻击者偷到refresh①,用户本人也在用——先到者刷新成功,后到者拿着已拉黑的refresh被拒——被盗这件事在下次合法使用时自动暴露(用户刷新失败=有异常)。
第25篇说"当前实现refresh用满7天"——看了AuthService的完整实现要修正:refreshToken端点确实是旋转模式(blockRefreshToken+双新令牌)。JwtUtil.refreshAccessToken(不拉黑的简版)是留给内部信任场景的工具方法。
七、四种登录方式
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篇安全评审。