系列文章

ruoyi若依框架学习笔记-01

ruoyi若依框架分页实现分析

ruoyi若依框架SpringSecurity实现分析

文章目录

  • 系列文章
  • 前言
  • 具体分析
    • 一、项目中的SpringSecurity版本
    • 二、登录认证流程分析
    • 三、权限鉴定
    • 四、退出登录
    • 五、SpringSecurity配置类
  • 总结

前言

在ruoyi-vue若依框架中使用到了SpringSecurity作为认证授权的技术栈。今天,来分析一下若依中是如何实现认证授权的。


具体分析

一、项目中的SpringSecurity版本


可见,当前springsecurity的版本还是相对比较旧的security5。所以我们在自己的项目中可以对此进行重构,升级成security6的版本。但目前只是先分析一下它的实现原理。

二、登录认证流程分析

首先看一下登录控制器
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java

@PostMapping("/login")public AjaxResult login(@RequestBody LoginBody loginBody){AjaxResult ajax = AjaxResult.success();// 生成令牌String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),loginBody.getUuid());ajax.put(Constants.TOKEN, token);return ajax;}

内容比较简单,我们去看一下login方法

public String login(String username, String password, String code, String uuid){// 验证码校验validateCaptcha(username, code, uuid);// 登录前置校验loginPreCheck(username, password);// 用户验证Authentication authentication = null;try{//UsernamePasswordAuthenticationToken是Authenticatiion的实现类UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); //这里设置成上下文的意图是在身份验证过程中, //其他组件或方法可以获取到该对象,以便进行相关的操作, //比如记录登录日志、获取用户信息等。AuthenticationContextHolder.setContext(authenticationToken);// 该方法会去调用UserDetailsServiceImpl.loadUserByUsernameauthentication = authenticationManager.authenticate(authenticationToken);}catch (Exception e){if (e instanceof BadCredentialsException){//这里去使用日志记录相关错误AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));throw new UserPasswordNotMatchException();}else{AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));throw new ServiceException(e.getMessage());}}finally{//最后要在对应操作完成之后,清理ContextAuthenticationContextHolder.clearContext();}AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));//这里就是拿到自定义封装的用户信息LoginUser loginUser = (LoginUser) authentication.getPrincipal();//将登录信息存入到数据库recordLoginInfo(loginUser.getUserId());// 生成tokenreturn tokenService.createToken(loginUser);}

然后我们去看一下loadUserByUsername方法
com.ruoyi.framework.web.service.UserDetailsServiceImpl

@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{//从数据库中查询相关用户数据SysUser user = userService.selectUserByUserName(username);if (StringUtils.isNull(user)){log.info("登录用户:{} 不存在.", username);throw new ServiceException(MessageUtils.message("user.not.exists"));}else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())){log.info("登录用户:{} 已被删除.", username);throw new ServiceException(MessageUtils.message("user.password.delete"));}else if (UserStatus.DISABLE.getCode().equals(user.getStatus())){log.info("登录用户:{} 已被停用.", username);throw new ServiceException(MessageUtils.message("user.blocked"));}//密码校验,这里主要是看用户在短时间内输错了多少次密码,防止过分重复登录passwordService.validate(user);//将用户信息封装成我们自定义的LoginUser对象,他是UserDetails的实现类return createLoginUser(user);}public UserDetails createLoginUser(SysUser user){return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));}

由于框架本身的UserDetails对象无法满足我们的需求,所以需要自定义一个实现类。
这里是UserDetails的实现类com.ruoyi.common.core.domain.model.LoginUser#serialVersionUID

public class LoginUser implements UserDetails{private static final long serialVersionUID = 1L;/** * 用户ID */private Long userId;/** * 部门ID */private Long deptId;/** * 用户唯一标识 */private String token;/** * 登录时间 */private Long loginTime;/** * 过期时间 */private Long expireTime;/** * 登录IP地址 */private String ipaddr;/** * 登录地点 */private String loginLocation;/** * 浏览器类型 */private String browser;/** * 操作系统 */private String os;/** * 权限列表 */private Set<String> permissions;/** * 用户信息 */private SysUser user;public LoginUser(){}public LoginUser(SysUser user, Set<String> permissions){this.user = user;this.permissions = permissions;}public LoginUser(Long userId, Long deptId, SysUser user, Set<String> permissions){this.userId = userId;this.deptId = deptId;this.user = user;this.permissions = permissions;}public Long getUserId(){return userId;}public void setUserId(Long userId){this.userId = userId;}public Long getDeptId(){return deptId;}public void setDeptId(Long deptId){this.deptId = deptId;}public String getToken(){return token;}public void setToken(String token){this.token = token;}@JSONField(serialize = false)@Overridepublic String getPassword(){return user.getPassword();}@Overridepublic String getUsername(){return user.getUserName();}/** * 账户是否未过期,过期无法验证 */@JSONField(serialize = false)@Overridepublic boolean isAccountNonExpired(){return true;}/** * 指定用户是否解锁,锁定的用户无法进行身份验证 ** @return */@JSONField(serialize = false)@Overridepublic boolean isAccountNonLocked(){return true;}/** * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证 ** @return */@JSONField(serialize = false)@Overridepublic boolean isCredentialsNonExpired(){return true;}/** * 是否可用 ,禁用的用户不能身份验证 ** @return */@JSONField(serialize = false)@Overridepublic boolean isEnabled(){return true;}public Long getLoginTime(){return loginTime;}public void setLoginTime(Long loginTime){this.loginTime = loginTime;}public String getIpaddr(){return ipaddr;}public void setIpaddr(String ipaddr){this.ipaddr = ipaddr;}public String getLoginLocation(){return loginLocation;}public void setLoginLocation(String loginLocation){this.loginLocation = loginLocation;}public String getBrowser(){return browser;}public void setBrowser(String browser){this.browser = browser;}public String getOs(){return os;}public void setOs(String os){this.os = os;}public Long getExpireTime(){return expireTime;}public void setExpireTime(Long expireTime){this.expireTime = expireTime;}public Set<String> getPermissions(){return permissions;}public void setPermissions(Set<String> permissions){this.permissions = permissions;}public SysUser getUser(){return user;}public void setUser(SysUser user){this.user = user;}@Overridepublic Collection<" />extends GrantedAuthority> getAuthorities(){return null;}}

其实我觉得最后应该也要重写getAuthorities()方法内部,如下

if (ObjectUtils.isEmpty(authorities)) {authorities = new ArrayList<>();permissions.forEach(permission -> authorities.add(()->permission));}return authorities;

还望大佬发表看法。
在ruoyi-vue中有对认证失败的处理类
com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl
在其中直接将错误信息渲染返回给前端

@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)throws IOException{int code = HttpStatus.UNAUTHORIZED;String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));}

当用户需要访问其他接口的时候,我们需要验证请求头中是否携带合法的token,并将用户信息存入Authentication中。
com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter中有token过滤器,验证token有效性。

@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter{@Autowiredprivate TokenService tokenService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException{//从token中获取用户信息,封装成LoginUser对象LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){//验证token是否合法tokenService.verifyToken(loginUser);//封装成authentication对象UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());//设置详细信息可以获取到请求的详细信息,例如请求的IP地址、请求的会话ID等。//设置详细信息可以帮助记录日志、进行安全审计和监控等操作authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);}chain.doFilter(request, response);}}

三、权限鉴定

我们先看一下controller层方法的上面的注解

“@ss.hasPermi(‘system:menu:list’)”的意思是调用ss这个容器下的hasPermi方法
可以看到,ruoyi是自定义的权限校验方法。那我们来看一下这个容器
com.ruoyi.framework.web.service.PermissionService

@Service("ss")public class PermissionService{/** * 验证用户是否具备某权限 ** @param permission 权限字符串 * @return 用户是否具备某权限 */public boolean hasPermi(String permission){if (StringUtils.isEmpty(permission)){return false;}LoginUser loginUser = SecurityUtils.getLoginUser();if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){return false;}PermissionContextHolder.setContext(permission);return hasPermissions(loginUser.getPermissions(), permission);}/** * 验证用户是否不具备某权限,与 hasPermi逻辑相反 * * @param permission 权限字符串 * @return 用户是否不具备某权限 */public boolean lacksPermi(String permission){return hasPermi(permission) != true;}/** * 验证用户是否具有以下任意一个权限 * * @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表 * @return 用户是否具有以下任意一个权限 */public boolean hasAnyPermi(String permissions){if (StringUtils.isEmpty(permissions)){return false;}LoginUser loginUser = SecurityUtils.getLoginUser();if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){return false;}PermissionContextHolder.setContext(permissions);Set<String> authorities = loginUser.getPermissions();for (String permission : permissions.split(Constants.PERMISSION_DELIMETER)){if (permission != null && hasPermissions(authorities, permission)){return true;}}return false;}/** * 判断用户是否拥有某个角色 ** @param role 角色字符串 * @return 用户是否具备某角色 */public boolean hasRole(String role){if (StringUtils.isEmpty(role)){return false;}LoginUser loginUser = SecurityUtils.getLoginUser();if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())){return false;}for (SysRole sysRole : loginUser.getUser().getRoles()){String roleKey = sysRole.getRoleKey();if (Constants.SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role))){return true;}}return false;}/** * 验证用户是否不具备某角色,与 isRole逻辑相反。 * * @param role 角色名称 * @return 用户是否不具备某角色 */public boolean lacksRole(String role){return hasRole(role) != true;}/** * 验证用户是否具有以下任意一个角色 * * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表 * @return 用户是否具有以下任意一个角色 */public boolean hasAnyRoles(String roles){if (StringUtils.isEmpty(roles)){return false;}LoginUser loginUser = SecurityUtils.getLoginUser();if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())){return false;}for (String role : roles.split(Constants.ROLE_DELIMETER)){if (hasRole(role)){return true;}}return false;}/** * 判断是否包含权限 ** @param permissions 权限列表 * @param permission 权限字符串 * @return 用户是否具备某权限 */private boolean hasPermissions(Set<String> permissions, String permission){return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));}}

别忘了在配置类中加上注解 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
这里就不具体一个一个分析了,因为比较简单,都是很容易看懂的。

四、退出登录

若依中还自定义了退出登录处理类com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl

@Configurationpublic class LogoutSuccessHandlerImpl implements LogoutSuccessHandler{@Autowiredprivate TokenService tokenService;/** * 退出处理 ** @return */@Overridepublic void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)throws IOException, ServletException{LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser)){String userName = loginUser.getUsername();// 删除用户缓存记录tokenService.delLoginUser(loginUser.getToken());// 记录用户退出日志AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, MessageUtils.message("user.logout.success")));}//返回信息交给前端渲染ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success(MessageUtils.message("user.logout.success"))));}}

五、SpringSecurity配置类

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter{/** * 自定义用户认证逻辑 */@Autowiredprivate UserDetailsService userDetailsService;/** * 认证失败处理类 */@Autowiredprivate AuthenticationEntryPointImpl unauthorizedHandler;/** * 退出处理类 */@Autowiredprivate LogoutSuccessHandlerImpl logoutSuccessHandler;/** * token认证过滤器 */@Autowiredprivate JwtAuthenticationTokenFilter authenticationTokenFilter;/** * 跨域过滤器 */@Autowiredprivate CorsFilter corsFilter;/** * 允许匿名访问的地址 */ //这里我先不分析,以后补上@Autowiredprivate PermitAllUrlProperties permitAllUrl;/** * 解决 无法直接注入 AuthenticationManager * * @return * @throws Exception */@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception{return super.authenticationManagerBean();}/** * anyRequest| 匹配所有请求路径 * access| SpringEl表达式结果为true时可以访问 * anonymous | 匿名可以访问 * denyAll | 用户不能访问 * fullyAuthenticated| 用户完全认证可以访问(非remember-me下自动登录) * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 * hasAnyRole| 如果有参数,参数表示角色,则其中任何一个角色可以访问 * hasAuthority| 如果有参数,参数表示权限,则其权限可以访问 * hasIpAddress| 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 * hasRole | 如果有参数,参数表示角色,则其角色可以访问 * permitAll | 用户可以任意访问 * rememberMe| 允许通过remember-me登录的用户访问 * authenticated | 用户登录后可访问 */@Overrideprotected void configure(HttpSecurity httpSecurity) throws Exception{// 注解标记允许匿名访问的urlExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());//我主要看这部分httpSecurity// CSRF禁用,因为不使用session.csrf().disable()// 禁用HTTP响应标头.headers().cacheControl().disable().and()// 认证失败处理类.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()// 基于token,所以不需要session.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()// 过滤请求.authorizeRequests()// 对于登录login 注册register 验证码captchaImage 允许匿名访问.antMatchers("/login", "/register", "/captchaImage").permitAll()// 静态资源,可匿名访问.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()// 除上面外的所有请求全部需要鉴权认证.anyRequest().authenticated().and().headers().frameOptions().disable();// 添加Logout filterhttpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);// 添加JWT filterhttpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 添加CORS filterhttpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);}/** * 强散列哈希加密实现 */@Beanpublic BCryptPasswordEncoder bCryptPasswordEncoder(){return new BCryptPasswordEncoder();}/** * 身份认证接口 */@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception{auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());}}

总结

其实ruoyi-vue中对SpringSecurity的使用非常时候我们用来复习SpringSecurity,以及学习它的编码风格,绝对收益不浅。