1. 登录流程全景概览:从点击登录到进入首页
大家好,我是老张,在后台系统开发这块摸爬滚打十来年了。今天咱们不聊那些虚头巴脑的概念,直接上手拆解一个非常经典且实用的后台框架——若依(RuoYi)的后端登录流程。很多朋友在用若依做二次开发时,可能只是照着文档配一配,登录功能就跑起来了,但里面到底是怎么一环扣一环工作的,心里可能没那么清楚。搞明白这个,不仅出了问题能快速定位,对你设计自己的权限系统也大有裨益。
简单来说,若依前后端分离版的登录,就是一个典型的“过五关斩六将”的过程。用户在前端登录页面点击“登录”按钮,这个动作会触发后端一连串精密配合的操作,最终目标是为前端生成一个合法的访问凭证(Token),并告诉前端这个用户能看到什么菜单、能操作哪些按钮。整个过程可以粗略分为四个核心阶段:验证码校验、用户身份认证、用户信息与权限拉取、动态路由菜单构建。听起来有点复杂?别急,咱们一步一步,结合代码,把它掰开揉碎了讲。我会尽量用大白话,把每个环节的“为什么”和“怎么做”都讲清楚,让你看完不仅能懂,还能自己动手调试和改造。
2. 第一关:验证码的生成与校验
用户打开登录页,第一眼看到的通常就是那个扭曲的字母数字组合——验证码。这可不是为了为难用户,而是安全防护的第一道简易防线,主要为了防止恶意程序用脚本暴力尝试密码。在若依框架里,这个环节的设计既简洁又典型。
2.1 验证码是如何“画”出来的
当浏览器加载登录页面时,前端会主动调用一个后端接口:/captchaImage。这个接口的活很简单:生成一张验证码图片,以及一个唯一标识(UUID),并把标识和正确的验证码答案临时存起来。
// 示例性的核心逻辑,非完全源码
@GetMapping("/captchaImage")
public AjaxResult getCaptchaImage() {
// 1. 生成一个随机的唯一标识,比如UUID
String uuid = IdUtils.fastUUID();
// 2. 生成4位或5位的随机验证码字符串
String verifyCode = CaptchaUtils.generateCode(4);
// 3. 根据验证码字符串,生成一张图片(比如用Kaptcha库)
BufferedImage image = CaptchaUtils.createImage(verifyCode);
// 4. 关键一步:把 uuid 和 verifyCode 的对应关系缓存起来,通常设置3-5分钟过期
redisCache.setCacheObject(CAPTCHA_CODE_KEY + uuid, verifyCode, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
// 5. 将图片转为Base64编码,和uuid一起返回给前端
String base64Image = ImageUtils.toBase64(image);
Map<String, Object> result = new HashMap<>();
result.put("uuid", uuid);
result.put("img", "data:image/gif;base64," + base64Image);
return AjaxResult.success(result);
}
前端拿到这个uuid和img(Base64格式的图片数据),就把图片显示给用户看,同时把uuid悄悄藏起来(通常放在表单的隐藏域或者请求头里),准备在登录时一起提交。这里用Redis做缓存是点睛之笔,保证了验证码数据的快速存取和自动过期,避免了 session 带来的集群部署问题。
2.2 登录时如何验证“码”对不对
用户输入用户名、密码和验证码后点击登录,请求到达后端的 /login 接口。在真正去核对用户名密码之前,系统会先校验验证码,这是登录流程的第一个校验点。
// 在登录服务 LoginService 中
public String login(String username, String password, String code, String uuid) {
// 0. 校验验证码
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
// 从Redis中取出之前存好的正确验证码
String captcha = redisCache.getCacheObject(verifyKey);
// 立即删除已使用的验证码,确保一次性使用
redisCache.deleteObject(verifyKey);
if (captcha == null) {
// 记录日志,抛出“验证码过期”的业务异常
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)) {
// 记录日志,抛出“验证码错误”的业务异常
throw new CaptchaException();
}
// 验证码通过,才继续后续的用户认证流程...
}
你看,流程非常清晰。先凭uuid去缓存里找答案,找不到就是过期了;找到了就比对用户输入的code,不区分大小写,不一致就报错。校验完不管成功与否,都会把缓存里的验证码删掉,这叫“一次性使用”,防止被人截获了重复使用。这一步如果没过,请求就直接被挡回去了,根本不会去查数据库,有效减轻了数据库的压力,也提升了安全性。
3. 第二关:用户身份认证与Token生成
验证码这关过了,接下来才是重头戏:确认“你是谁”。这个过程主要依托 Spring Security 这个安全框架,若依对它进行了深度定制。我刚开始用 Security 时也觉得绕,但理解其核心就一句话:它提供了一套插件化的认证授权机制,你需要告诉它“怎么获取用户信息”和“怎么判断权限”。
3.1 Spring Security 的认证入口
在 /login 接口的后续处理中,会调用 AuthenticationManager 的 authenticate 方法。这个方法可以理解成 Security 的认证总开关。它需要一个 UsernamePasswordAuthenticationToken 对象,里面装着用户提交的用户名和密码。
// 在登录服务中继续
// 1. 将用户名密码封装成 Security 认识的 Token
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 2. 调用 Security 的核心认证管理器进行认证
Authentication authentication = authenticationManager.authenticate(authenticationToken);
这个 authenticate 方法内部会做一系列事情,最终它会调用一个非常关键的接口:UserDetailsService。这个接口只有一个方法 loadUserByUsername(String username)。若依框架通过实现这个接口,告诉 Spring Security:“当需要认证用户时,请按我的方式来查用户信息”。
3.2 自定义用户信息加载:UserDetailsServiceImpl
这是整个认证环节最核心的部分,位于 ruoyi-framework 模块中。我们来看看若依是怎么重写这个方法的。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private ISysUserService userService;
@Autowired
private SysPermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 根据用户名查询用户实体
SysUser user = userService.selectUserByUserName(username);
if (user == null) {
// 记录日志
throw new UsernameNotFoundException("用户不存在");
}
// 2. 一系列用户状态检查(非常重要!)
if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
// 记录日志,抛出“账号已被删除”异常
throw new UserDeleteException();
}
if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
// 记录日志,抛出“账号已停用”异常
throw new UserDisableException();
}
// 3. 创建并返回自定义的 LoginUser 对象
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user) {
// 重点:查询用户的权限信息(菜单权限和角色)
Set<String> menuPermission = permissionService.getMenuPermission(user);
Set<String> rolePermission = permissionService.getRolePermission(user);
// 将所有权限集合合并
Set<String> permissions = new HashSet<>();
permissions.addAll(menuPermission);
permissions.addAll(rolePermission);
// 构建 LoginUser 对象,它实现了 UserDetails 接口
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissions);
}
}
这段代码有几个关键点。第一,它先根据用户名从数据库里把用户完整信息查出来。第二,它进行了一系列业务状态检查,比如用户是否被删除、是否被禁用。这些检查在实际项目中至关重要,能防止已离职或违规用户继续登录。第三,它没有直接返回查到的 SysUser,而是调用了 createLoginUser 方法,这个方法干了一件大事:查询当前用户的权限集合。
权限查询的逻辑是:先判断这个用户是不是超级管理员(比如 userId == 1)。如果是超管,通常就赋予所有权限,不需要再去数据库查关联表了。如果不是超管,那就得老老实实根据 userId,去查询 sys_user_role、sys_role_menu 这些关联表,找出他拥有的所有角色和菜单权限标识符(比如 system:user:query 这种字符串),最后封装成一个权限集合(Set<String>)。
这个权限集合,最后和用户基本信息一起,被包装进了 LoginUser 对象。LoginUser 实现了 Spring Security 的 UserDetails 接口,所以它可以被 AuthenticationManager 认作是合法的“用户详情”。Security 拿到这个对象后,会自动比对 LoginUser 里封装的密码(从数据库查出的加密后的密码)和用户登录时提交的密码(经过相同加密算法处理后)是否一致。如果一致,认证就通过了。
3.3 登录成功后的收尾工作与Token签发
认证通过后,控制权回到我们自己的登录服务 (LoginService) 中。这时我们已经有了一个认证成功的 Authentication 对象,里面就包含着刚刚那个装满信息的 LoginUser。
// 在登录服务中,认证通过后
// 3. 获取到认证信息中的 LoginUser
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 4. 记录登录日志(比如IP、时间、浏览器信息),更新用户最后登录时间
recordLoginInfo(loginUser.getUserId());
// 5. 生成此用户的唯一登录令牌(Token)
String token = tokenService.createToken(loginUser);
// 6. 返回Token给前端
return AjaxResult.success().put(Constants.TOKEN, token);
recordLoginInfo 方法会异步执行,更新用户表里的最后登录IP和时间,同时可能往日志表里插入一条登录记录,这样便于后续审计。重点在 createToken 方法,它负责生成我们常说的 JWT Token。
若依默认使用 JWT(JSON Web Token)作为令牌。生成Token的过程大致是:以 LoginUser 为核心信息,选择一个密钥(secret),设置一个过期时间(比如720分钟),然后用JWT库(如jjwt)生成一个加密的字符串。这个字符串通常分为三部分(Header.Payload.Signature),其中 Payload 部分就包含了用户ID、用户名等关键信息(注意,敏感信息如密码绝不放入)。
// TokenService 中的简化逻辑
public String createToken(LoginUser loginUser) {
String token = IdUtils.fastUUID(); // 生成一个Token的唯一标识
loginUser.setToken(token);
// 将 loginUser 对象以 token 为key,存入Redis,并设置过期时间
redisCache.setCacheObject(getTokenKey(token), loginUser, expireTime, TimeUnit.MINUTES);
// 返回给前端的是这个UUID token,而不是JWT字符串本身(具体实现可能不同,有的直接存JWT)
// 更常见的做法是:用JWT生成字符串,并把 loginUser 信息存入Redis,键为JWT本身或其中的jti
return token;
}
这里需要说明,若依的Token机制可能有不同版本。一种常见实现是:生成的Token就是一个JWT字符串,JWT的Payload里存有用户ID,然后将这个JWT本身作为Key,把完整的 LoginUser 对象存入Redis。另一种实现(如上面简化代码)是生成一个UUID作为Token键,将 LoginUser 存入Redis,然后将这个UUID返回给前端。前端后续请求时,在请求头(如 Authorization: Bearer <token>)中携带这个Token。后端拦截器会根据Token从Redis中取出 LoginUser,从而恢复用户的登录状态和权限信息。无论哪种,核心思想都是无状态令牌+中心化缓存,兼顾了安全性和灵活性。
4. 第三关:获取用户详情与权限列表
前端拿到Token,登录接口的请求就算成功了。但登录成功只是拿到了“入场券”,用户具体是谁、能干什么,前端还不知道。所以接下来,前端通常会紧接着调用两个接口:/getInfo 和 /getRouters。咱们先看 /getInfo。
4.1 解析Token与获取核心用户信息
前端在登录成功的回调里,或者在全局路由守卫中,会调用 /getInfo 接口。这个接口不需要传额外参数,因为用户的身份信息就藏在请求头携带的Token里。
@GetMapping("/getInfo")
public AjaxResult getInfo() {
// 1. 从Security上下文中获取当前登录的用户(LoginUser)
LoginUser loginUser = SecurityUtils.getLoginUser();
// 2. 根据用户ID查询角色列表
Set<String> roles = permissionService.getRolePermission(loginUser);
// 3. 根据用户ID查询权限标识符集合(菜单和按钮)
Set<String> permissions = permissionService.getMenuPermission(loginUser);
// 4. 组装数据返回
Map<String, Object> result = new HashMap<>();
result.put("user", loginUser.getUser()); // 用户基本信息
result.put("roles", roles); // 角色编码集合
result.put("permissions", permissions); // 权限字符串集合
return AjaxResult.success(result);
}
SecurityUtils.getLoginUser() 是一个工具方法,它背后是从 Spring Security 的 SecurityContextHolder 中获取当前线程绑定的认证信息,再从中提取出我们之前存入的 LoginUser 对象。这个过程是由一个叫 JwtAuthenticationTokenFilter 的过滤器完成的:它在所有接口请求之前,解析请求头中的Token,从Redis里找到对应的 LoginUser,然后把它设置到 Security 的上下文里,这样在整个请求生命周期中,任何地方都能方便地拿到当前用户信息。
注意,这里查询角色和权限,逻辑和登录时 createLoginUser 里类似,但可能更详细。比如角色查询,返回的可能是角色键名集合(如 ["admin", "common"]),而权限查询返回的是类似 ["system:user:add", "system:user:edit"] 这样的按钮级权限标识符。前端拿到这些数据后,通常会存入 Vuex 或 Pinia 这样的状态管理库,用于全局控制页面元素的显示隐藏(比如某个按钮用户没权限就不显示)。
4.2 动态路由菜单的构建原理
用户信息有了,但用户能访问哪些页面(菜单路由)还不知道。这就是 /getRouters 接口的使命。这个接口返回的数据,直接决定了前端侧边栏菜单的渲染。
@GetMapping("/getRouters")
public AjaxResult getRouters() {
// 1. 获取当前用户ID
Long userId = SecurityUtils.getUserId();
// 2. 根据用户ID,查询他有权限访问的菜单列表(已经按父子层级排好序的 SysMenu 列表)
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
// 3. 将后端菜单结构,构建成前端Vue Router能识别的路由结构
List<RouterVo> routers = menuService.buildMenus(menus);
return AjaxResult.success(routers);
}
第一步 selectMenuTreeByUserId 是关键。它执行的SQL会比较复杂,需要关联 sys_user_role、sys_role_menu、sys_menu 等多张表,过滤出状态正常、类型为目录或菜单、且对应用户有权限的记录,并且使用递归或CTE(公用表表达式)将结果集处理成树形结构。返回的 List<SysMenu> 里,每个菜单对象可能包含 children 属性,里面就是它的子菜单。
4.3 将数据库菜单转换为前端路由
拿到树形菜单列表后,需要转换成前端路由配置的格式,这是 buildMenus 方法的工作。这个方法理解起来有点绕,因为它需要同时考虑前端路由的多种配置场景。我结合代码和注释,给你梳理一下核心逻辑。
public List<RouterVo> buildMenus(List<SysMenu> menus) {
List<RouterVo> routers = new LinkedList<>();
for (SysMenu menu : menus) {
RouterVo router = new RouterVo();
// 设置路由基本属性:是否隐藏、路由名称、路径、对应组件
router.setHidden("1".equals(menu.getVisible())); // 是否隐藏菜单
router.setName(getRouteName(menu)); // 路由name,用于keep-alive等
router.setPath(getRouterPath(menu)); // 访问路径
router.setComponent(getComponent(menu)); // 组件路径,如 "system/user/index"
// 设置元信息,用于菜单标题、图标、是否缓存等
router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), menu.getIsCache(), menu.getPath()));
List<SysMenu> cMenus = menu.getChildren();
// 场景一:当前菜单是目录(TYPE_DIR)且有子菜单
if (!cMenus.isEmpty() && UserConstants.TYPE_DIR.equals(menu.getMenuType())) {
router.setAlwaysShow(true); // 始终显示根目录
router.setRedirect("noRedirect"); // 目录本身不重定向
// 递归处理子菜单
router.setChildren(buildMenus(cMenus));
}
// 场景二:当前菜单是内嵌框架且无父级(如“首页”)
else if (isMenuFrame(menu)) {
router.setMeta(null); // 根路由不显示meta
// 创建一个子路由,将实际组件放在子级
RouterVo children = new RouterVo();
children.setPath(menu.getPath());
children.setComponent(menu.getComponent());
children.setName(StringUtils.capitalize(menu.getPath()));
children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), menu.getIsCache(), menu.getPath()));
router.setChildren(Collections.singletonList(children));
}
// 场景三:当前菜单是内链(跳转外部链接)
else if (menu.getParentId().intValue() == 0 && isInnerLink(menu)) {
router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon()));
router.setPath("/inner"); // 统一的内链入口路径
// 创建子路由指向实际外部链接
RouterVo children = new RouterVo();
String routerPath = StringUtils.replaceEach(menu.getPath(), new String[]{"http://", "https://"}, new String[]{"", ""});
children.setPath(routerPath);
children.setComponent(UserConstants.INNER_LINK); // 组件指向一个固定的内链组件
children.setName(StringUtils.capitalize(routerPath));
children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), menu.getPath()));
router.setChildren(Collections.singletonList(children));
}
routers.add(router);
}
return routers;
}
这个方法处理了三种前端路由的常见结构。第一种是最普通的目录-菜单树。第二种是“首页”这种特殊页面,它作为顶级菜单,但实际内容是一个组件,这种结构需要包装一层。第三种是内链,点击菜单会跳转到站外链接,这需要映射到一个统一的内链处理组件。最终返回的 List<RouterVo>,其结构完全符合 Vue Router 动态添加路由(router.addRoute())所需的格式。前端拿到这个数据,遍历并动态添加到路由实例中,侧边栏菜单也就随之生成,实现了真正的权限动态路由。
5. 关键细节与实战踩坑点
走完了主流程,咱们再聊聊几个容易出问题或者值得深入理解的细节。这些是我在实际项目开发和团队带人过程中,大家最容易困惑和踩坑的地方。
5.1 权限标识符的设计与使用
若依框架中,权限标识符(Permission)通常对应菜单表(sys_menu)里的 perms 字段。这个字段的字符串怎么设计很有讲究。常见的格式是 模块:功能:操作,比如 system:user:add。在 LoginUser 对象里,这些字符串被存为一个 Set<String>。Spring Security 的权限判断,底层就是看当前用户的这个集合里,是否包含访问某个资源所需的权限字符串。
这个判断发生在哪里呢?通常是通过注解 @PreAuthorize("@ss.hasPermi('system:user:list')") 放在Controller的方法上。这个 @ss 指向一个叫 PermissionService 的Bean,它的 hasPermi 方法会从 Security 上下文取出当前用户的权限集合进行比对。这种设计非常灵活,你可以轻松实现按钮级别的权限控制。但要注意,权限标识符的设计一定要有层次、易管理,避免随意命名导致后期维护混乱。
5.2 Token过期与续期策略
Token是有生命周期的,默认可能是720分钟(12小时)。过期后用户需要重新登录。但在一些体验要求高的系统里,我们希望实现“无感刷新”。若依框架本身可能没有直接提供这个功能,但我们可以基于现有机制实现。一种常见思路是:在Token临近过期时(比如过期前30分钟),前端在请求拦截器中判断,如果某个请求返回了特定的状态码(如401或自定义的Token即将过期码),则自动调用一个刷新Token的接口。
这个刷新接口需要接收旧的Token,验证其有效性(即使快过期,但签名必须正确),然后生成一个新的Token返回给前端,前端替换掉旧的Token。同时,后端需要处理好旧Token的失效问题,避免新旧Token同时有效。更安全的做法是引入Refresh Token机制,但复杂度也会增加。在实现时,务必考虑并发请求下的Token刷新问题,避免短时间内多次刷新。
5.3 菜单与路由的缓存优化
每次登录都去数据库查菜单树并构建路由,如果菜单很多,会对数据库造成压力。一个很实用的优化是引入缓存。我们可以在 menuService.selectMenuTreeByUserId 方法上做文章。例如,以用户ID为Key,将查询到的菜单树结构存入Redis,设置一个较长的过期时间(比如6小时)。这样,同一用户在过期时间内的多次登录或刷新页面,都能直接从缓存获取菜单,性能提升非常明显。
但要注意缓存更新问题。当管理员在后台增删改菜单或调整权限后,需要清除或更新相关用户的菜单缓存。一个简单的办法是,在修改菜单或角色权限关联关系的Service方法中,清除所有相关的用户菜单缓存(可以使用通配符Key),或者更精细地只清除受影响的用户缓存。这需要根据业务量来权衡实现的复杂度。
5.4 前后端协作的注意事项
动态路由功能强大,但对前后端协作要求也高。首先,后端菜单表里的几个字段必须和前端的路由配置约定好,比如 component 字段对应的是前端 views 目录下的文件路径,这个路径必须真实存在,否则前端动态加载组件时会报错。其次,关于“是否缓存”(isCache)这个字段,需要前后端对 keep-alive 的理解一致,否则可能会遇到页面状态不保存或者保存错乱的问题。
在开发联调时,我建议先在后端把 /getRouters 接口的返回数据打印出来,对照着前端的路由配置规则,一条一条地检查 path、name、component、meta 等字段是否正确映射。特别是嵌套路由和重定向的配置,很容易出错。另外,前端在动态添加路由后,如果遇到页面刷新白屏,通常是新添加的路由没有正确应用到路由实例上,或者路由守卫的逻辑有问题,需要仔细检查Vue Router的 addRoute 调用时机和路由守卫的跳转逻辑。
214



被折叠的 条评论
为什么被折叠?



