Spring Security 工具封装:SecurityUtils 获取当前登录用户

本文基于若依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()一次请求的安全上下文,内部主要承载一个Authentication
AuthenticationgetAuthentication()认证结果,含主体、凭证、权限集合、已认证标记
PrincipalgetPrincipal()认证主体。若依在这里放的是LoginUser

最后一步的强转是SecurityUtils与若依自身的耦合点。getPrincipal()声明为Object,Spring Security 不规定里面放什么类型,由写入方决定。若依的写入方固定放LoginUser,所以这里可以直接强转;换成别的认证方式时,这个强转会抛ClassCastException,同样落进 catch 变成 401。

上下文由 JWT 过滤器写入

SecurityUtils只负责读,写发生在 framework 模块的JwtAuthenticationTokenFilter。它继承OncePerRequestFilter,核心只有几行:

protectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{LoginUserloginUser=tokenService.getLoginUser(request);if(StringUtils.isNotNull(loginUser)&&StringUtils.isNull(SecurityUtils.getAuthentication())){tokenService.verifyToken(loginUser);UsernamePasswordAuthenticationTokenauthenticationToken=newUsernamePasswordAuthenticationToken(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对这个方法的实现是直接返回null:

@OverridepublicCollection<?extendsGrantedAuthority>getAuthorities(){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。若依的处理方式是在请求线程里先把要用的值取出来,作为参数传进异步任务:

LoginUserloginUser=SecurityUtils.getLoginUser();SysOperLogoperLog=newSysOperLog();if(loginUser!=null){operLog.setOperName(loginUser.getUsername());}AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

operLog是普通对象,异步线程只访问它,不再访问安全上下文。这里的loginUser != null实际不会成立,getLoginUser()要么返回对象,要么抛异常,没有返回null的分支。判断留着不影响结果,但也不起作用。

派生方法把异常统一成 401

getUserId()、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);Integercode=e.getCode();returnStringUtils.isNotNull(code)?AjaxResult.error(code,e.getMessage()):AjaxResult.error(e.getMessage());}

code非空时走AjaxResult.error(code, msg),响应体是{"code":401,"msg":"获取用户信息异常"}。这个方法没有@ResponseStatus,HTTP 状态码仍是 200,401 只存在于响应体的code字段里。

前端在响应拦截器里读这个字段:

constcode=res.data.code||200if(code===401){if(!isRelogin.show){isRelogin.show=trueMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录','系统提示',{confirmButtonText:'重新登录',cancelButtonText:'取消',type:'warning'}).then(()=>{isRelogin.show=falsestore.dispatch('LogOut').then(()=>{location.href='/index'})}).catch(()=>{isRelogin.show=false})}returnPromise.reject('无效的会话,或者会话已过期,请重新登录。')}

isRelogin.show防止多个并发请求同时弹出多个确认框。确认后调用登出接口并跳回首页;取消则停在当前页,后续请求继续拿到 401。

另一条 401 不经过SecurityUtils。请求没带 token 或 token 失效时,anyRequest().authenticated()授权规则先拦下请求,ExceptionTranslationFilter把认证异常交给AuthenticationEntryPointImpl:

publicvoidcommence(HttpServletRequestrequest,HttpServletResponseresponse,AuthenticationExceptione){intcode=HttpStatus.UNAUTHORIZED;Stringmsg=StringUtils.format("请求访问:{},认证失败,无法访问系统资源",request.getRequestURI());ServletUtils.renderString(response,JSON.toJSONString(AjaxResult.error(code,msg)));}

这条路径上请求根本到不了 Controller。两条路径的code相同,msg不同:认证入口带的文案里有请求 URI,SecurityUtils抛出的是固定文字。前端对两者做同样处理。

若依中的调用位置

取值方法在若依里有三类调用点,分别在 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){SysRolerole=newSysRole();role.setRoleId(roleId);List<SysRole>roles=SpringUtils.getAopProxy(this).selectRoleList(role);if(StringUtils.isEmpty(roles)){thrownewServiceException("没有权限访问角色数据!");}}}}

超级管理员跳过数据权限校验,普通用户逐条查。SysUserServiceImpl、SysDeptServiceImpl里的同类校验方法结构一致。isAdmin()内部调的是isAdmin(getUserId()),userId等于 1 即视为管理员。

权限校验走PermissionService,Bean 名ss。它的hasPermi、hasAnyPermi、hasRole、hasAnyRoles四个方法结构一致,都是先取当前用户,再比对集合,取用户与判空的部分如下:

LoginUserloginUser=SecurityUtils.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 上。

思考

静态方法还是注入 Bean:SecurityUtils没有@Component,方法全为static,好处是任何位置都能直接调,Controller 基类、Service 实现、AOP 切面、静态工具类内部都不需要注入。若改成 Bean,DictUtils这类静态工具类就只能像取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()公开出来了。