当前位置: 首页 > news >正文

RuoYi-Cloud项目登录流程+密码验证

RuoYi-Cloud项目登录流程+密码验证(8.5)

一、登录流程

1、当用户点击登录按钮时,会触发handleLogin 方法。先进行表单验证,若验证通过则开启防抖机制(设置 loading = true),使按钮进入加载状态,从而防止用户因网络卡顿而频繁点击发送异步请求。

​ 若用户勾选“记住我“,则前端会先对密码进行加密计算,并将用户名、加密后的密码密文及记住状态存入 Cookie 中,设置 30 天的过期时间;若未勾选,则主动清除 Cookie 中残留的旧凭证信息。

​ 随后发起Login登录请求,将表单数据异步发送给后端服务器进行认证。当后端返回响应时,若登录成功,前端会将页面路由跳转至目标地址(优先跳转重定向地址,否则跳转首页);若登录失败,系统会捕获异常,解除按钮的加载状态以恢复用户点击能力,并在启用验证码的情况下自动刷新验证码,引导用户重新输入。

  <el-button:loading="loading"size="medium"type="primary"style="width:100%;"@click.native.prevent="handleLogin"><span v-if="!loading">登 录</span><span v-else>登 录 中...</span></el-button>
 handleLogin() {this.$refs.loginForm.validate(valid => {if (valid) {this.loading = trueif (this.loginForm.rememberMe) {Cookies.set("username", this.loginForm.username, { expires: 30 })Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 })Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 })} else {Cookies.remove("username")Cookies.remove("password")Cookies.remove('rememberMe')}this.$store.dispatch("Login", this.loginForm).then(() => {this.$router.push({ path: this.redirect || "/" }).catch(()=>{})}).catch(() => {this.loading = falseif (this.captchaEnabled) {this.getCode()}})}})}

2、登录后,查看请求urlhttp://localhost/dev-api/auth/login

​ 前端发起的 http://localhost/dev-api/auth/login 请求首先通过 dev-api 代理统一进入网关,网关随即读取 Nacos 配置中心的路由规则,通过 Path=/auth/** 匹配到该请求,并根据 uri: lb://ruoyi-auth 从 Nacos 服务列表中寻址到运行在 9200 端口的 ruoyi-auth 认证服务实例;接着,网关执行 StripPrefix=1 过滤器剥离路径前缀,将请求精准转发至 RuoYiAuthApplication 项目中 Controller 的 login 方法,从而完成整个微服务架构下的统一入口路由与业务处理。

# 认证中心
- id: ruoyi-authuri: lb://ruoyi-authpredicates:- Path=/auth/**filters:# 验证码处理- CacheRequestBody- ValidateCodeFilter- StripPrefix=1

3、在请求负载中携带了code(验证码)、password、username、和uuid(前端生成的唯一标识)。现在密码是明文的,没有加密。

前端:在响应数据中,access_token 是后端签发的 JWT 身份凭证,用于后续请求的身份鉴权;expires_in 为 720,表示该令牌的有效时长为 720 分钟(12小时)。

后端:代码逻辑分为两部分,首先根据用户类型(普通用户或管理员)调用对应的服务校验用户名与密码,验证通过后由 Token 服务基于用户信息生成 JWT 令牌并返回。

{"code": 200,"msg": null,"data": {"access_token": "eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6Im...","expires_in": 720}
}
 @PostMapping("login")public R<?> login(@RequestBody LoginBody form){// 用户登录LoginUser userInfo = null;if (form.getType().equals(TokenConstants.ADMIN)) {userInfo = sysLoginService.login(form.getUsername(), form.getPassword());} else {userInfo = pcLoginService.login(form.getUsername(), form.getPassword());}// 获取登录tokenreturn R.ok(tokenService.createToken(userInfo));}

(1)sysLoginService.login方法

①前置参数校验

​ 检查用户名、密码是否为空、在不在指定的范围内。如果不符合要求,则记录失败日志并抛出异常。

②IP黑名单校验

​ 从 Redis 缓存中获取系统配置的 IP 黑名单,判断当前用户的访问 IP 是否在其中。如果在黑名单内,则记录失败日志并拦截登录。

③远程查询用户信息

​ 由于认证中心(ruoyi-auth)本身不直接操作业务数据库,这里通过 Feign 进行远程调用(remoteUserService.getUserInfo),从其他微服务(如系统服务)安全地获取用户的完整信息。

详解:

// 查询用户信息
R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER);

​ 点击 getUserInfo 方法时,通过 @FeignClient 注解中的 value = ServiceNameConstants.SYSTEM_SERVICE,可知请求被路由至 ruoyi-system 服务,具体对应 travel-modules-system 模块中 SysUserControllerinfo 方法。该方法通过 MyBatis 查询数据库,若未查找到用户,则直接返回“用户名或密码错误”。

​ 查询成功后,系统会进一步查出该用户的权限集合和角色集合,并将这些权限信息与底层用户数据一起,复制并封装到 LoginUser 对象(即登录进来的对象)中。

拓展:为什么不用sysUser,而去再写一个LoginUser?

​ 这是为了在微服务多端架构下实现统一鉴权模型。后端服务底层存储了两类用户:SysUser(对应后台管理端用户)和 InternetUser(对应互联网前端用户)。虽然它们的底层存储不同,但在登录成功后,都会被统一封装为 LoginUser 对象。这样认证中心只需处理统一的 LoginUser 即可生成 JWT 令牌,无需关心底层的具体用户类型。

/*** 获取当前用户信息*/@InnerAuth@GetMapping("/info/{username}")public R<LoginUser> info(@PathVariable("username") String username){SysUser sysUser = userService.selectUserByUserName(username);if (StringUtils.isNull(sysUser)){return R.fail("用户名或密码错误");}// 角色集合Set<String> roles = permissionService.getRolePermission(sysUser);// 权限集合Set<String> permissions = permissionService.getMenuPermission(sysUser);LoginUser sysUserVo = new LoginUser();sysUserVo.setSysUser(sysUser);sysUserVo.setRoles(roles);sysUserVo.setPermissions(permissions);return R.ok(sysUserVo);}

④账号状态校验

​ 获取到用户信息后,取出底层的系统用户对象(SysUser),校验其账号状态。如果账号已被删除或处于停用状态,则记录失败日志并抛出相应的异常。

⑤ 密码校验与记录成功日志

​ 如果账号状态正常,则调用 passwordService.validate 验证用户输入的密码是否正确。密码验证通过后,记录一条“登录成功”的日志。

详解:
​ 首先从 Redis 获取当前用户的密码错误次数(默认为 0)。若达到系统设定的最大重试阈值(如 5 次),则记录失败日志并抛出异常,锁定账号(如 5 分钟);若未达到,则继续执行密码匹配。

​ 调用密码匹配方法进行校验。若匹配失败,将 Redis 中的错误次数加 1 并重新写回缓存(设置过期时间),记录日志后抛出异常;若匹配成功,则立即清除 Redis 中的失败记录缓存,放行登录。

public void validate(SysUser user, String password){String username = user.getUserName();Integer retryCount = redisService.getCacheObject(getCacheKey(username));if (retryCount == null){retryCount = 0;}if (retryCount >= Integer.valueOf(maxRetryCount).intValue()){String errMsg = String.format("密码输入错误%s次,帐户锁定%s分钟", maxRetryCount, lockTime);recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL,errMsg);throw new ServiceException(errMsg);}if (!matches(user, password)){retryCount = retryCount + 1;recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, String.format("密码输入错误%s次", retryCount));redisService.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);throw new ServiceException("用户不存在/密码错误");}else{clearLoginRecordCache(username);}}

​ 进入matches(user, password)方法后,系统会调用 Spring Security 提供的安全工具类进行密码比对。该方法内部会使用注入的密码编码器(passwordEncoder)对象,并在执行匹配前进行非空校验和格式校验(判断数据库中的密码是否符合 BCrypt 格式)。校验通过后,最终调用 BCrypt 算法的底层校验方法,将用户输入的原始密码转换为字节数组后,与数据库中的加密密码进行比对。

public boolean matches(CharSequence rawPassword, String encodedPassword) {if (rawPassword == null) {throw new IllegalArgumentException("rawPassword cannot be null");} else if (encodedPassword != null && encodedPassword.length() != 0) {if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {this.logger.warn("Encoded password does not look like BCrypt");return false;} else {return BCrypt.checkpw(rawPassword.toString(), encodedPassword);}} else {this.logger.warn("Empty encoded password");return false;}}

​ 在 BCrypt 算法中,盐(Salt)是保障密码安全的核心。在用户注册时,系统会随机生成一段盐值与明文密码混合并进行加密,然后将包含该盐值的加密结果存入数据库。在登录校验时,BCrypt 算法会自动从数据库中存储的加密密码里提取出当初生成的盐值,用它来加密用户本次输入的明文密码,再进行比对。这种机制意味着盐值在第一次加密后就与密文融为一体,无需在服务器端单独存储盐值,从而极大地提升了密码存储的安全性。

image-20260805210259713

⑥返回登录用户对象

​ 最后,将包含用户基础信息及权限数据的 LoginUser 对象返回给上层调用者,以便后续生成 JWT 令牌。

/*** 登录*/public LoginUser login(String username, String password){// 用户名或密码为空 错误if (StringUtils.isAnyBlank(username, password)){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "用户/密码必须填写");throw new ServiceException("用户/密码必须填写");}// 密码如果不在指定范围内 错误if (password.length() < UserConstants.PASSWORD_MIN_LENGTH|| password.length() > UserConstants.PASSWORD_MAX_LENGTH){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "用户密码不在指定范围");throw new ServiceException("用户密码不在指定范围");}// 用户名不在指定范围内 错误if (username.length() < UserConstants.USERNAME_MIN_LENGTH|| username.length() > UserConstants.USERNAME_MAX_LENGTH){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "用户名不在指定范围");throw new ServiceException("用户名不在指定范围");}// IP黑名单校验String blackStr = Convert.toStr(redisService.getCacheObject(CacheConstants.SYS_LOGIN_BLACKIPLIST));if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr())){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "很遗憾,访问IP已被列入系统黑名单");throw new ServiceException("很遗憾,访问IP已被列入系统黑名单");}// 查询用户信息R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER);if (StringUtils.isNull(userResult) || StringUtils.isNull(userResult.getData())){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "登录用户不存在");throw new ServiceException("登录用户:" + username + " 不存在");}if (R.FAIL == userResult.getCode()){throw new ServiceException(userResult.getMsg());}LoginUser userInfo = userResult.getData();SysUser user = userResult.getData().getSysUser();if (UserStatus.DELETED.getCode().equals(user.getDelFlag())){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "对不起,您的账号已被删除");throw new ServiceException("对不起,您的账号:" + username + " 已被删除");}if (UserStatus.DISABLE.getCode().equals(user.getStatus())){recordLogService.recordLogininfor(username, Constants.LOGIN_FAIL, "用户已停用,请联系管理员");throw new ServiceException("对不起,您的账号:" + username + " 已停用");}passwordService.validate(user, password);recordLogService.recordLogininfor(username, Constants.LOGIN_SUCCESS, "登录成功");recordLoginInfo(user.getUserId());return userInfo;}

(2)R.ok(tokenService.createToken(userInfo))方法

 @PostMapping("login")public R<?> login(@RequestBody LoginBody form){// 用户登录LoginUser userInfo = null;if (form.getType().equals(TokenConstants.ADMIN)) {userInfo = sysLoginService.login(form.getUsername(), form.getPassword());} else {userInfo = pcLoginService.login(form.getUsername(), form.getPassword());}// 获取登录tokenreturn R.ok(tokenService.createToken(userInfo));}

① 创建令牌(Token)
登录成功后,系统会调用 tokenService.createToken 方法,利用当前登录的 LoginUser 对象生成 JWT 令牌。

详解:

1. 生成唯一标识与组装登录信息
首先,随机生成一个 UUID 作为本次登录的唯一 Token 标识。接着,从 LoginUser 对象中提取 userIduserName,连同用户的 IP 地址等信息,一并设置到 LoginUser 对象中,并调用 refreshToken 方法将当前完整的登录信息存入 Redis 缓存中。

2. 构建 JWT 载荷(Claims)
系统构建一个 Map 集合,将上一步生成的 UUID Token、用户 ID(userId)和用户名(userName)作为核心信息放入其中,作为 JWT 的载荷(Payload)数据。

3. 生成 JWT 并返回前端
调用工具类,将包含上述信息的 Map 集合生成最终的 JWT 字符串。最后,将该 JWT 字符串(access_token)以及过期时间(expires_in)封装到响应 Map 中,返回给前端。

面试核心考点:JWT 与 Redis 混合架构设计

​ 在标准的 JWT 理论中,JWT 本身是无状态的,通常不需要依赖 Redis。但本项目采用了"JWT + Redis"的混合架构,面试时可围绕"提效""过期管控"两个核心维度进行表述:

1. Redis 的作用:负责"提效"(快速读取用户信息)
如果只用 JWT,里面只能存 userIdusername 等少量信息,后续请求需要拿着 userId 再去数据库查用户的完整信息(权限、角色等),高并发下频繁查库会很慢。因此,项目在登录时将完整的 LoginUser 对象(包含权限、角色、IP 等)存入 Redis。后续请求只需通过 JWT 中的 UUID 去 Redis 一查,就能瞬间拿到用户的全部信息,完全不用查数据库,极大提升了性能。

2. Redis 的作用:负责"过期管控"(强制失效)
JWT 存在"一旦签发,在过期前无法主动使其失效"的致命缺陷。因此,项目将登录会话状态存入 Redis,JWT 仅作为传输凭证,真正的会话有效性由 Redis 控制。当用户主动登出、被踢下线或修改密码时,只需删除 Redis 中该 UUID 对应的记录,即可使 JWT 立即失效。

3. 架构总结
Redis 负责"提效"与"强制过期",JWT 负责"防篡改"与"身份传递"。两者结合,既通过 Redis 缓存避免了频繁查库的性能问题,又弥补了 JWT 无法主动撤销的安全漏洞,实现了性能与安全性的最佳平衡。

/*** 创建令牌*/public Map<String, Object> createToken(LoginUser loginUser){String token = IdUtils.fastUUID();Long userId = loginUser.getUserid();String userName = loginUser.getUsername();loginUser.setToken(token);loginUser.setUserid(userId);loginUser.setUsername(userName);loginUser.setIpaddr(IpUtils.getIpAddr());refreshToken(loginUser);// Jwt存储信息Map<String, Object> claimsMap = new HashMap<String, Object>();claimsMap.put(SecurityConstants.USER_KEY, token);claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId);claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName);// 接口返回信息Map<String, Object> rspMap = new HashMap<String, Object>();rspMap.put("access_token", JwtUtils.createToken(claimsMap));rspMap.put("expires_in", expireTime);return rspMap;}

二、项目总结

1、单点登录

​ 在微服务架构下,假设有用户、商品和订单等多个服务都需要登录才能使用,为了避免每个服务都重复写一遍登录逻辑,系统会抽取一个统一的认证中心,这就叫做单点登录(SSO),实现“一次登录,处处可用”。同时,因为这个认证中心(如 ruoyi-auth)需要暴露在外面供所有登录请求访问,为了保证安全,就不能让它直接连接数据库,而是需要在它与数据库之间架一个专门的业务服务,通过 Feign 远程调用该服务去访问数据库,从而保障底层数据的安全。

2、MD5 密码加密与匹配原理

​ 在注册时,用户名和手机号直接存入数据库,而密码会先经过 MD5 加密后再存储登录时,系统将用户输入的密码进行同样的 MD5 加密,然后与数据库中的密文进行比对。由于 MD5 是单向哈希算法,无法逆向解密,因此安全性较高。然而,MD5 存在被“暴力破解”的风险:攻击者可以提前将常见的密码组合通过 MD5 算法加密,构建一个以“MD5结果”为 Key、“原始密码”为 Value 的庞大字典(即彩虹表),通过反查字典来破解密码。

​ 为了提升安全性,通常会引入“加盐(Salt)”机制。这个“盐”是随机生成的字符串,在加密前与用户的密码进行拼接(拼接在前后或使用特定算法)后再进行 MD5 加密。最关键的是,这个盐值必须存储在服务器的数据库中(通常与用户的哈希密码存放在同一条记录里)。在登录匹配时,系统会从数据库中提取出该用户的盐值,与用户输入的密码拼接后再进行 MD5 加密,最后与数据库中的密文比对。因为每个用户的盐值都是随机且唯一的,即使密码相同,最终生成的密文也完全不同,从而有效防止了彩虹表攻击。

3、BCrypt 密码加密与匹配原理

​ 由于 MD5 存在安全缺陷且与 Spring Security 框架不匹配,现代系统通常采用 BCrypt 算法。在注册时,BCrypt 会自动生成一段随机盐值,将其与明文密码混合后进行复杂的哈希运算,并将包含盐值和哈希结果的完整字符串存入数据库。在登录匹配时,系统会将数据库中存储的完整加密密码直接作为参数传入,BCrypt 算法在内部运算时,会自动解析并复用其中包含的原始盐值,用它来加密用户本次输入的明文密码,再将生成的新哈希值与数据库中的哈希结果进行比对。BCrypt 的核心优势在于每次加密的盐值都是随机的,即使两个用户使用相同的密码,生成的密文也完全不同,从根本上免疫了彩虹表攻击;同时,这种“盐密一体”的机制使得盐值无需在服务器端单独存储,极大地提升了密码存储的安全性。

4、JWT 的核心本质与演进逻辑

首先需要明确一个核心概念:JWT 本身并不是加密算法,它的核心作用是“防篡改”与“身份自包含”。

在早期的单体服务架构中,身份认证通常依赖 Session。客户端发送账号密码,服务器验证通过后,将登录状态存入 Session。当客户端再次请求时,服务器只需检查 Session 中是否存在当前用户即可。然而,随着业务增长,单台服务器无法支撑高并发,系统演变为多台服务器集群。此时便暴露出了Session 共享问题:用户在 A 服务器登录后,其 Session 仅存在于 A 服务器,当请求被分发到 B 服务器时,B 服务器无法识别该用户。

为了解决这一问题,业界首先采用了共享 Session(Redis)方案。即不再将 Session 存在本地,而是统一存入 Redis 中。无论用户请求落到 A 还是 B 服务器,都去 Redis 中读取状态。这种方案虽然解决了问题,但引入了新的痛点:它强依赖于 Redis 这一中心化存储组件,一旦 Redis 发生故障或面临极高的并发瓶颈,整个认证系统就会受到牵连。

为了彻底摆脱对中心化存储的依赖,JWT(JSON Web Token)应运而生。JWT 的本质是将状态从服务端转移到了客户端。当用户登录成功后,服务器会将登录信息(如用户 ID、角色等)打包生成一个带有数字签名的字符串(即 JWT),并返回给客户端。在后续的请求中,客户端只需携带这个字符串,服务器通过本地验证签名即可确认身份的真实性与完整性。

总结来说,JWT 的诞生从根本上解决了分布式架构下的 Session 共享难题。它通过“自包含”的设计,让服务器无需再维护任何会话状态,从而实现了真正的无状态扩展,完美解决了多服务器集群下的身份认证问题。

http://www.jsqmd.com/news/1335918/

相关文章:

  • AI Agent 面试题 508:如何实现Agent的约束动态更新和重规划?
  • 战略地图:从目标到执行的核心工具
  • MySQL Binlog日志保留策略与清理方法详解
  • 如何用开源工具构建个人音乐库:MusicDownload终极指南
  • Unity Wii Remote API开发指南:低成本体感交互实现与实战
  • 从H.264/H.265码流手动解析SPS:获取视频宽高与帧率的底层原理与实践
  • 从特斯拉塔到无线充电:电磁谐振原理与现代工程实践
  • DLSS Swapper完全指南:智能革命重新定义游戏性能优化
  • 技术资源聚合站部署与自动化集成实战指南
  • 信号链 | 高精度ADC产品LKAD7606LQ,国产替代AD7606
  • 智慧化项目实战:从物联网、数字孪生到AI的落地路径与避坑指南
  • STM32 USB虚拟串口开发实战:从CubeMX配置到稳定通信全解析
  • AI Agent 面试题 507:Agent的推理链中的知识检索和注入机制
  • 大语言模型集成实战:从API调用到本地部署的完整指南
  • 江苏建设人才网站深度解析:如何借助权威平台实现职业生涯的华丽转身与项目精准对接
  • 终极指南:3步让你的Mac原生支持所有视频格式预览
  • 2026年罗定吊车出租怎么收费?本地起重设备租赁价格参考 - 优企甄选
  • python 基于Django的多商户外卖订购与配送平台
  • 从带宽到体验:现代ISP网络架构、运维实战与未来趋势深度解析
  • ChatGPT 出来的内容如何去除 #** 符号 结合 AI 导出鸭的应用分析
  • 如何在低显存设备上运行FLUX.1-dev:3个简单步骤实现AI图像生成
  • React异步副作用处理与onCleanup最佳实践
  • 终极黑苹果配置自动化工具:OpCore-Simplify让你15分钟完成复杂配置
  • 2026哈尔滨正规驾校教练联系方式实用推荐指南 - 谁都没有我好看
  • 高效文件命名与整理:提升数字资产管理效率的核心技能
  • 多类型评选不踩坑!免费线上投票平台推荐,支持图文视频投票 - 投票评选制作软件系统
  • AI Agent 面试题 499:Agent的任务分解中的上下文依赖处理
  • 电网智能化转型:从传统刚性系统到柔性智慧网络的演进
  • 终极iOS应用包管理利器:IPATool命令行工具完全指南
  • Deform:如何在Unity中实现实时网格变形的终极指南