
本文基于若依3.9.2、SpringBoot3版本。Spring Security 工具封装SecurityUtils 获取当前登录用户com.ruoyi.common.utils.SecurityUtils是若依对 Spring Security 的工具封装方法分两组一组从安全上下文里取当前登录用户一组做密码加密与权限、角色判断。这里只看第一组主要有三个方面取的值存在哪个对象上、它由谁在请求过程中写进去、取不到时为什么变成 401。取值只依赖一个静态持有者组内的方法层层调用最终收敛到这两行代码/** 获取Authentication */publicstaticAuthenticationgetAuthentication(){returnSecurityContextHolder.getContext().getAuthentication();}/** 获取用户 */publicstaticLoginUsergetLoginUser(){try{return(LoginUser)getAuthentication().getPrincipal();}catch(Exceptione){thrownewServiceException(获取用户信息异常,HttpStatus.UNAUTHORIZED);}}getAuthentication()没有 try-catch因为它不会失败上下文为空时SecurityContextHolder.getContext()会新建一个空上下文并写回ThreadLocal接着getAuthentication()返回null两次调用都不抛异常。getLoginUser()不同它在null上调用getPrincipal()空指针在这里抛出然后被 catch 接住。这两个方法穿过的对象构成一条链每一层各管一件事对象获取方式在若依中的作用SecurityContextHolder静态方法用ThreadLocal保存当前线程的SecurityContext线程之间互不影响SecurityContextgetContext()一次请求的安全上下文内部主要承载一个AuthenticationAuthenticationgetAuthentication()认证结果含主体、凭证、权限集合、已认证标记PrincipalgetPrincipal()认证主体。若依在这里放的是LoginUser最后一步的强转是SecurityUtils与若依自身的耦合点。getPrincipal()声明为ObjectSpring Security 不规定里面放什么类型由写入方决定。若依的写入方固定放LoginUser所以这里可以直接强转换成别的认证方式时这个强转会抛ClassCastException同样落进 catch 变成 401。上下文由 JWT 过滤器写入SecurityUtils只负责读写发生在 framework 模块的JwtAuthenticationTokenFilter。它继承OncePerRequestFilter核心只有几行protectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{LoginUserloginUsertokenService.getLoginUser(request);if(StringUtils.isNotNull(loginUser)StringUtils.isNull(SecurityUtils.getAuthentication())){tokenService.verifyToken(loginUser);UsernamePasswordAuthenticationTokenauthenticationTokennewUsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());authenticationToken.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);}chain.doFilter(request,response);}tokenService.getLoginUser(request)从请求头取出 JWT解出里面的 uuid再用login_tokens:拼上 uuid 去 Redis 查LoginUser。没带 token、token 解析失败、Redis 里查不到三种情况都返回null后面的if不成立上下文保持为空。第二个判空写成SecurityUtils.getAuthentication()而不是SecurityContextHolder.getContext().getAuthentication()两者效果一样工具类在这里只是一个更短的入口。这个判断防止同一次请求里重复设置认证信息。UsernamePasswordAuthenticationToken用的是三参数构造依次是主体、凭证、权限集合。凭证传null因为走 token 认证没有密码参与权限集合来自loginUser.getAuthorities()而LoginUser对这个方法的实现是直接返回nullOverridepublicCollection?extendsGrantedAuthoritygetAuthorities(){returnnull;}传null不会引发空指针。AbstractAuthenticationToken的构造对null做了兜底会替换成AuthorityUtils.NO_AUTHORITIES这个空集合。结果是令牌上的权限集合始终为空Spring Security 原生的hasAuthority一类判断在该令牌上恒为 false。若依不用这套权限模型判断依据是LoginUser.permissions这里的空集合不参与任何判断。三参数构造还有一个作用把令牌的authenticated标记置为true。标记为已认证的令牌不会再交给AuthenticationManager校验Spring Security 认为它的来源可信。setAuthentication之后令牌进入当前线程的SecurityContext。执行顺序上这个过滤器通过addFilterBefore排在UsernamePasswordAuthenticationFilter之前而 Controller 方法在DispatcherServlet里执行所以业务代码运行时上下文已经写好。反过来说请求没带有效 token、访问的又是匿名可达的接口/login、Anonymous标注的方法时过滤器不写入认证信息业务代码拿到的就是空上下文。请求结束时上下文被清空SecurityContext挂在ThreadLocal上而 Web 容器的线程是复用的。如果没人清理上一个请求的用户会残留到下一个请求里。清理由过滤链上的SecurityContextHolderFilter完成它把chain.doFilter包在 try/finally 中finally 里调SecurityContextHolder.clearContext()正常返回和抛异常两条路径都会执行。若依把 session 策略设为SessionCreationPolicy.STATELESS安全上下文不写入 HttpSession请求结束就丢弃。用户的身份本身存在 Redis下一个请求到来时由 JWT 过滤器重新查出来、重新写入。所以每次请求里SecurityUtils读到的LoginUser都是本次查出来的对象多个请求之间不共享同一个实例。这个清理只作用于处理请求的那个线程。Async方法、AsyncManager提交的任务跑在别的线程上ThreadLocal不跟着过去在异步任务里直接调SecurityUtils.getLoginUser()会拿到空上下文并抛 401。若依的处理方式是在请求线程里先把要用的值取出来作为参数传进异步任务LoginUserloginUserSecurityUtils.getLoginUser();SysOperLogoperLognewSysOperLog();if(loginUser!null){operLog.setOperName(loginUser.getUsername());}AsyncManager.me().execute(AsyncFactory.recordOper(operLog));operLog是普通对象异步线程只访问它不再访问安全上下文。这里的loginUser ! null实际不会成立getLoginUser()要么返回对象要么抛异常没有返回null的分支。判断留着不影响结果但也不起作用。派生方法把异常统一成 401getUserId()、getDeptId()、getUsername()三个方法同构都是先取LoginUser再取其中一个属性/** 用户ID */publicstaticLonggetUserId(){try{returngetLoginUser().getUserId();}catch(Exceptione){thrownewServiceException(获取用户ID异常,HttpStatus.UNAUTHORIZED);}}区别只在方法名、getter 与提示文字错误码统一是HttpStatus.UNAUTHORIZED值为 401。两层 try-catch 是叠加的。getUserId()捕获到的异常可能正是getLoginUser()抛出的ServiceException(获取用户信息异常)它被重新包装成ServiceException(获取用户ID异常)。新异常没有把原异常作为 cause 传进去也没有打印抛出去后原始异常就丢了只剩最外层的提示文字和新的堆栈。排查时看到「获取用户ID异常」光靠提示文字无法知道根因是上下文为空。401 的两条来源ServiceException是RuntimeException的子类带一个可选的code字段。它在业务代码里抛出后由RestControllerAdvice标注的GlobalExceptionHandler接住ExceptionHandler(ServiceException.class)publicAjaxResulthandleServiceException(ServiceExceptione,HttpServletRequestrequest){log.error(e.getMessage(),e);Integercodee.getCode();returnStringUtils.isNotNull(code)?AjaxResult.error(code,e.getMessage()):AjaxResult.error(e.getMessage());}code非空时走AjaxResult.error(code, msg)响应体是{code:401,msg:获取用户信息异常}。这个方法没有ResponseStatusHTTP 状态码仍是 200401 只存在于响应体的code字段里。前端在响应拦截器里读这个字段constcoderes.data.code||200if(code401){if(!isRelogin.show){isRelogin.showtrueMessageBox.confirm(登录状态已过期您可以继续留在该页面或者重新登录,系统提示,{confirmButtonText:重新登录,cancelButtonText:取消,type:warning}).then((){isRelogin.showfalsestore.dispatch(LogOut).then((){location.href/index})}).catch((){isRelogin.showfalse})}returnPromise.reject(无效的会话或者会话已过期请重新登录。)}isRelogin.show防止多个并发请求同时弹出多个确认框。确认后调用登出接口并跳回首页取消则停在当前页后续请求继续拿到 401。另一条 401 不经过SecurityUtils。请求没带 token 或 token 失效时anyRequest().authenticated()授权规则先拦下请求ExceptionTranslationFilter把认证异常交给AuthenticationEntryPointImplpublicvoidcommence(HttpServletRequestrequest,HttpServletResponseresponse,AuthenticationExceptione){intcodeHttpStatus.UNAUTHORIZED;StringmsgStringUtils.format(请求访问{}认证失败无法访问系统资源,request.getRequestURI());ServletUtils.renderString(response,JSON.toJSONString(AjaxResult.error(code,msg)));}这条路径上请求根本到不了 Controller。两条路径的code相同msg不同认证入口带的文案里有请求 URISecurityUtils抛出的是固定文字。前端对两者做同样处理。若依中的调用位置取值方法在若依里有三类调用点分别在 Controller、Service 与权限校验的 Bean 中。Controller 层通过BaseController转调publicLoginUsergetLoginUser(){returnSecurityUtils.getLoginUser();}publicLonggetUserId(){returngetLoginUser().getUserId();}publicStringgetUsername(){returngetLoginUser().getUsername();}子类继承BaseController后直接写getLoginUser()方法体里看不出背后是静态工具类实际是父类同名方法转调。SysProfileController的个人信息、修改密码、更换头像几个接口都用这种方式拿当前用户。Service 层用isAdmin()做分支publicvoidcheckRoleDataScope(Long...roleIds){if(!SecurityUtils.isAdmin()){for(LongroleId:roleIds){SysRolerolenewSysRole();role.setRoleId(roleId);ListSysRolerolesSpringUtils.getAopProxy(this).selectRoleList(role);if(StringUtils.isEmpty(roles)){thrownewServiceException(没有权限访问角色数据);}}}}超级管理员跳过数据权限校验普通用户逐条查。SysUserServiceImpl、SysDeptServiceImpl里的同类校验方法结构一致。isAdmin()内部调的是isAdmin(getUserId())userId等于 1 即视为管理员。权限校验走PermissionServiceBean 名ss。它的hasPermi、hasAnyPermi、hasRole、hasAnyRoles四个方法结构一致都是先取当前用户再比对集合取用户与判空的部分如下LoginUserloginUserSecurityUtils.getLoginUser();if(StringUtils.isNull(loginUser)||CollectionUtils.isEmpty(loginUser.getPermissions())){returnfalse;}if里的两个条件只有后一半会成立。getLoginUser()要么返回对象、要么抛异常StringUtils.isNull(loginUser)恒为false真正起作用的是权限集合是否为空。hasRole、hasAnyRoles判断的是loginUser.getUser().getRoles()是否为空写法相同同样带着这个不会成立的条件。PreAuthorize里的 SpEL 由 Spring Security 求值最终落到这个 Bean 上。思考静态方法还是注入 BeanSecurityUtils没有Component方法全为static好处是任何位置都能直接调Controller 基类、Service 实现、AOP 切面、静态工具类内部都不需要注入。若改成 BeanDictUtils这类静态工具类就只能像取RedisCache那样通过SpringUtils.getBean拿绕一层。代价是静态方法无法替换实现单元测试要么真实准备SecurityContextHolder手动setContext一个带LoginUser的Authentication要么放弃对这段逻辑的隔离测试。若依选了静态把测试成本放到使用方对框架自身是划算的方法体只有几行可测的价值本来就低真正需要隔离的是调用它的业务逻辑。取值失败为什么一律转 401上下文为空、主体为null、强转类型不对、属性为null这几种情况成因完全不同返回给上层的却是同一个 code。好处是调用方只面对一种语义「当前没有可用的登录用户」业务代码不必区分坏处是编程错误被伪装成登录过期例如写入方换成了别的UserDetails实现前端只会弹「登录状态已过期」日志里也只有一句「获取用户信息异常」。GlobalExceptionHandler用log.error(e.getMessage(), e)打出完整堆栈就是为这种情况留的定位手段。getLoginUser()不返回null的取舍方法要么给出用户要么抛异常调用方不必判空也不会出现拿到半可用结果继续往下走的情况。代价有两处。一是判空代码在框架自己的实现里反复出现LogAspect的if (loginUser ! null)、PermissionService四个判断方法里的StringUtils.isNull(loginUser)都不会成立读代码时容易误以为存在返回null的分支这些位置想表达的是「查不到用户就当作没有权限」false本来是个合理答案而getLoginUser()只给异常判空于是写不出来。二是真正需要「可能没有登录用户」的场景不好表达比如一个允许匿名访问、登录后要额外返回个人数据的接口只能 try-catch 兜住 401 再继续或者改用不带异常的getAuthentication()判断上下文是否为空。后者更清晰SecurityUtils也把getAuthentication()公开出来了。