登录
问题
解释一下这三行代码
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success"))); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); recordLoginInfo(loginUser.getUserId());为什么要记录两遍日志?
以及authentication.getPrincipal()这个代码的作用是?
解释
三者区别(一句话)
行 写到哪 目的 第 1 行
sys_logininfor(异步)登录审计流水
第 2 行
内存对象
拿到当前登录用户
第 3 行
sys_user更新该用户最近登录 IP/时间
之后才会执行
tokenService.createToken(loginUser)生成 JWT 返回给前端。
token是uuid?
问题
我发现若依的token是随机uuid?
/** * 创建令牌 * * @param loginUser 用户信息 * @return 令牌 */ public String createToken(LoginUser loginUser) { String token = IdUtils.fastUUID(); loginUser.setToken(token); setUserAgent(loginUser); refreshToken(loginUser); Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); claims.put(Constants.JWT_USERNAME, loginUser.getUsername()); return createToken(claims); }解释
不完全是“随便发个 UUID 当令牌”。我先把createToken/ JWT / Redis 这几段串起来看。
对了一半:IdUtils.fastUUID()生成的是 Redis 会话键,不是最终返回给前端的那个 token。
两层“token”
| 名字 | 实际是什么 | 作用 |
|---|---|---|
| 随机 UUID | 作为 Redis key 的一部分,关联整份 |
| JWT 字符串 | 发给前端,Header 里带的那个 |
流程是:
1. 生成 uuid
2. Redis: login_tokens:{uuid} = LoginUser(权限、用户信息、过期时间等)
3. 把 uuid 放进 JWT 的 claims(login_user_key)
4. 用 secret 做 HS512 签名,得到真正的 JWT 返回前端
对应代码:
public String createToken(LoginUser loginUser) { String token = IdUtils.fastUUID(); // ① Redis 会话标识 loginUser.setToken(token); setUserAgent(loginUser); refreshToken(loginUser); // ② 写入 Redis Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); // ③ uuid 塞进 JWT claims.put(Constants.JWT_USERNAME, loginUser.getUsername()); return createToken(claims); // ④ 返回签名后的 JWT }请求进来时反过来:
解析 JWT → 取出里面的 uuid → 用 uuid 去 Redis 取 LoginUser为什么这样设计
- JWT 本身很“瘦”,不塞完整用户/权限(改权限可直接改 Redis,不用等 JWT 过期)。
- UUID 只是会话 ID,随意生成没问题,安全靠:
- JWT 有
secret签名,伪造不了; - Redis 里有会话数据,删了就登出(强制下线也靠这个)。
- JWT 有
所以:UUID 是会话钥匙;前端拿到的是包着这把钥匙的 JWT。
若依token校验拦截器?
问题
那当前项目是不是 都会有一个拦截器,如果没有token校验通过,就会直接阻拦?
解释
是的,思路对,但精确说:不是 MVC 拦截器,而是 Spring Security + JWT 过滤器。
两层配合
1.JwtAuthenticationTokenFilter(过滤器)
每个请求都会进这个 Filter:从 Header 取 JWT → 解析出 uuid → 去 Redis 取LoginUser→ 放进SecurityContext。
注意:它本身不会直接 return 拦截,没 token / token 无效时只是不往 SecurityContext 里塞用户,然后继续往下走。
2.SecurityConfig(真正决定拦不拦)
requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll() .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated();- 白名单(登录、注册、验证码、静态资源等):
permitAll,不需要登录。 - 其余全部:
authenticated(),SecurityContext 里没有认证用户就拦截。
拦截后由AuthenticationEntryPointImpl返回 401,提示认证失败。
请求流程(简化)
请求进来
→ JwtFilter:有有效 token?有则写入 SecurityContext
→ Security 规则:
白名单 → 放行
其他 → 已认证?放行 : 401 拦住
补充
业务接口上还有@PreAuthorize等做权限校验(能登录 ≠ 有某个菜单/按钮权限)。那是第二道门,第一道是上面的登录态认证。
A01-菜单表的perms字段?
问题
我不太懂sys_menu表中的perms这个字段是干啥的。(都长这个样子)【system:role:list】
解释
perms是权限标识(permission string),用来做按钮/接口级鉴权,不是用来画菜单的。
它存什么
一段字符串,约定俗成写成:模块:功能:操作
例如:
system:user:list— 用户列表system:user:add— 用户新增system:user:edit— 用户修改
目录(M)一般为空;菜单(C)常带*:list;按钮(F)才是各类操作权限的主力。
它怎么用
登录后系统会按「用户 → 角色 → 菜单」查出该用户拥有的所有perms,放进权限集合。之后两处比对这个字符串:
- 后端接口:
@PreAuthorize("@ss.hasPermi('system:user:add')")
没有对应perms就调不通接口。 - 前端按钮:
v-hasPermi="['system:user:add']"
没有对应perms就隐藏「新增」等按钮。
两边写的字符串,必须和sys_menu.perms里配置的一致。
和菜单的关系(容易混)
| 概念 | 作用 |
|---|---|
| 能不能看到、打开哪个页面 |
| 能不能点某个按钮、调某个接口 |
所以:能进「用户管理」页,不等于能新增用户;新增还得角色勾了带system:user:add的那条按钮菜单。
一句话:perms= 权限开关的名字;角色勾了哪些菜单,用户就拿到哪些名字,前后端拿这些名字做鉴权。
A02-那为什么目录是空的?
问题
如果sys_menu表中的menu_type如果是M(即代表目录的时候),perms这个字段是空的?
解释
因为目录本身不对应任何可执行操作,只负责把菜单组织成树。
在若依里:
- 目录
M:只是分组(如「系统管理」),没有页面、没有接口,所以不需要perms,留空即可。 - 菜单
C:对应一个页面,常配xxx:list,用来控制「能不能进这个功能/查列表」。 - 按钮
F:对应具体操作(增删改导出等),perms才是真正被前后端拿去鉴权的。
权限实际是挂在菜单和按钮上的。角色勾选时,勾了某个页面下的按钮,就拿到那些perms;父级目录空着不影响——它既不参与@PreAuthorize,也不参与v-hasPermi。
简单说:目录只负责“归类显示”,不负责“能不能操作”,所以perms为空是正常设计。
以下是 目录和菜单的一一对应
树形结构代码-解析
代码
01-selectMenuTreeByUserId
@Override public List<SysMenu> selectMenuTreeByUserId(Long userId) { List<SysMenu> menus = null; if (SecurityUtils.isAdmin(userId)) { menus = menuMapper.selectMenuTreeAll(); } else { menus = menuMapper.selectMenuTreeByUserId(userId); } return getChildPerms(menus, MENU_ROOT_ID); }02-getChildPerms
/** * 根据父节点的ID获取所有子节点 * * @param list 分类表 * @param parentId 传入的父节点ID * @return String */ public List<SysMenu> getChildPerms(List<SysMenu> list, long parentId) { List<SysMenu> returnList = new ArrayList<SysMenu>(); for (Iterator<SysMenu> iterator = list.iterator(); iterator.hasNext();) { SysMenu t = (SysMenu) iterator.next(); // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点 if (t.getParentId() == parentId) { recursionFn(list, t); returnList.add(t); } } return returnList; }03-recursionFn
/** * 递归列表 * 作用是:得到该父节点的直系子节点列表 * @param list 分类表 * @param t 子节点 */ private void recursionFn(List<SysMenu> list, SysMenu t) { // 得到该父节点的直系子节点列表 List<SysMenu> childList = getChildList(list, t); t.setChildren(childList); // 判断孩子有没有孩子 for (SysMenu tChild : childList) { if (hasChild(list, tChild)) { recursionFn(list, tChild); } } }问题
你能否举个具体的实际例子,然后我看一下排序的整个过程
解释
好,用一组具体菜单数据把整条链路走一遍。这里的「排序过程」指的是递归建树时一步步怎么挂节点(getChildList本身不按orderNum排序,顺序取决于查库时列表的先后)。
1. 初始扁平列表list
假设从数据库查出来是这样(已按order_num排好):
| 序号 | menuId | menuName | parentId |
|---|---|---|---|
0 | 1 | 系统管理 | 0 |
1 | 100 | 用户管理 | 1 |
2 | 101 | 角色管理 | 1 |
3 | 1001 | 用户查询 | 100 |
4 | 1002 | 用户新增 | 100 |
5 | 2 | 系统监控 | 0 |
6 | 200 | 在线用户 | 2 |
调用:getChildPerms(list, 0)—— 找根节点(parentId == 0)。
2.getChildPerms:先找顶级节点
遍历list,parentId == 0的有两个:
系统管理(1)→ 调用recursionFn(list, 系统管理),再加入结果系统监控(2)→ 调用recursionFn(list, 系统监控),再加入结果
3. 对「系统管理」做recursionFn(逐步展开)
第 1 层:t = 系统管理(1)
getChildList → parentId == 1 的节点
→ [用户管理(100), 角色管理(101)]
setChildren → 系统管理.children = [用户管理, 角色管理]
接着遍历这两个子节点:
第 2 层-A:tChild = 用户管理(100)
hasChild→ 有(1001、1002)→ 再递归:
getChildList → parentId == 100
→ [用户查询(1001), 用户新增(1002)]
setChildren → 用户管理.children = [用户查询, 用户新增]
再看叶子:
用户查询:hasChild= false → 停用户新增:hasChild= false → 停
第 2 层-B:tChild = 角色管理(101)
hasChild→ 没有子节点 → 不递归,children保持空/未再挂
此时「系统管理」这棵子树已经成型:
系统管理(1)
├── 用户管理(100)
│ ├── 用户查询(1001)
│ └── 用户新增(1002)
└── 角色管理(101)
4. 对「系统监控」做recursionFn
getChildList → parentId == 2
→ [在线用户(200)]
setChildren → 系统监控.children = [在线用户]
在线用户:hasChild = false → 停
系统监控(2)
└── 在线用户(200)
5. 最终返回结果
[
系统管理(1)
├── 用户管理(100)
│ ├── 用户查询(1001)
│ └── 用户新增(1002)
└── 角色管理(101),
系统监控(2)
└── 在线用户(200)
]
6. 调用顺序小结(像栈一样)
getChildPerms(parentId=0)
│
├─ recursionFn(系统管理)
│ ├─ setChildren([用户管理, 角色管理])
│ ├─ recursionFn(用户管理)
│ │ ├─ setChildren([用户查询, 用户新增])
│ │ ├─ 用户查询 无子 → 停
│ │ └─ 用户新增 无子 → 停
│ └─ 角色管理 无子 → 停
│
└─ recursionFn(系统监控)
├─ setChildren([在线用户])
└─ 在线用户 无子 → 停
核心就三步循环:找儿子 → 挂到 children → 儿子还有孙子就继续递归。
如果你想看的是orderNum怎么影响最终菜单显示顺序,可以说一下,我再按 SQL/order_num那条线补一版。
全局安全规则SecurityConfig
代码
/** * spring security配置 * * @author ruoyi */ @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true) @Configuration public class SecurityConfig { /** * 认证失败处理类 */ @Autowired private AuthenticationEntryPointImpl unauthorizedHandler; /** * 退出处理类 */ @Autowired private LogoutSuccessHandlerImpl logoutSuccessHandler; /** * token认证过滤器 */ @Autowired private JwtAuthenticationTokenFilter authenticationTokenFilter; /** * 跨域过滤器 */ @Autowired private CorsFilter corsFilter; /** * 允许匿名访问的地址 */ @Autowired private PermitAllUrlProperties permitAllUrl; /** * 身份验证实现 */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } /** * anyRequest | 匹配所有请求路径 * access | SpringEl表达式结果为true时可以访问 * anonymous | 匿名可以访问 * denyAll | 用户不能访问 * fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录) * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 * hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问 * hasAuthority | 如果有参数,参数表示权限,则其权限可以访问 * hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 * hasRole | 如果有参数,参数表示角色,则其角色可以访问 * permitAll | 用户可以任意访问 * rememberMe | 允许通过remember-me登录的用户访问 * authenticated | 用户登录后可访问 */ @Bean protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity // CSRF禁用,因为不使用session .csrf(csrf -> csrf.disable()) // 禁用HTTP响应标头 .headers((headersCustomizer) -> { headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin()); }) // 认证失败处理类 .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) // 基于token,所以不需要session .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 注解标记允许匿名访问的url .authorizeHttpRequests((requests) -> { permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll()); // 对于登录login 注册register 验证码captchaImage 允许匿名访问 requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() // 静态资源,可匿名访问 .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll() .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); }) // 添加Logout filter .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler)) // 添加JWT filter .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) // 添加CORS filter .addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class) .addFilterBefore(corsFilter, LogoutFilter.class) .build(); } /** * 强散列哈希加密实现 */ @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } }问题
讲些一下这个SecurityConfig类需要学习的东西呗。跟接口没关系的吧
解释
对,它本身不是业务接口类(不是 Controller),而是 Spring Security 的全局安全规则配置。
但它会拦所有 HTTP 请求(含/login、业务 API),所以学若依权限体系时绕不开。
这类要学什么
1. 它在整条链路里的位置
请求进来后大致是:
CORS→JWT Filter→Security 鉴权规则→ Controller
SecurityConfig负责定规则 + 挂过滤器,不写具体业务逻辑。
2. 四个核心 Bean / 组件
| 组件 | 作用 |
|---|---|
| 主配置:CSRF、Session、谁能访问、挂哪些 Filter |
| 登录时做用户名密码认证(给登录服务用) |
| 密码加密/校验 |
注入的 Handler/Filter | 失败处理、退出、JWT 解析、匿名 URL |
3. 为什么这样配(若依常用套路)
- 关 CSRF:前后端分离 + JWT,不用 Cookie Session,CSRF 意义不大
STATELESS:不建 Session,认证信息靠 TokenpermitAllvsauthenticated:白名单匿名;其余必须登录@EnableMethodSecurity:Controller 上可用@PreAuthorize做按钮/接口级权限
4. 过滤器顺序(很重要)
CorsFilter → JwtAuthenticationTokenFilter → UsernamePasswordAuthenticationFilter
先跨域,再解析 Token 塞进 SecurityContext,后面才能判断“已登录”。
5. 和接口的真正关系
- 写业务接口:去
*Controller - 决定“这个 URL 要不要登录 / 匿名”:看这里的
authorizeHttpRequests,以及@Anonymous扫出来的permitAllUrl - 决定“登录后有没有某个权限”:方法上的
@PreAuthorize("hasPermi('xxx')"),由@EnableMethodSecurity打开
所以:不是接口代码,但是接口的门禁配置。
建议学习顺序
- 本类:
filterChain每一段在干什么 JwtAuthenticationTokenFilter:Token 怎么解析、怎么放进上下文SysLoginService+AuthenticationManager:登录怎么拿到 Token@PreAuthorize/ 权限注解:接口级授权
如果你愿意,我可以按这个顺序把JwtAuthenticationTokenFilter也拆开讲一遍。