他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)

一、前言

最近在学习spring security,自己也了些小的demo。也看了几个优秀的后台管理的开源项目。今天聊一下若依系统的权限管理的详细流程。

二、权限管理模型

若依使用的也是当前最流行的RBAC模型。如果不了解RBAC的小伙伴可以去网上查一下,其实很好理解。若依这里大致可以认为是实现了RBAC0。简单来说,就是用户不直接拥有权限,而是添加角色作为中转,将权限赋予角色。然后再将角色赋予用户。权限可以是菜单权限或者是按钮权限等。

三、主要技术栈

1、后端

Springboot,SpringSecurity,JWT,Redis,Mybatis

2、前端

vue,vuex,router

四、数据库设计

与权限相关的表主要有三张,sys_usersys_role,sys_menu。次要的还有两张关联表sys_user_role,sys_role_menu。下面依次看一下主要的表。

1、User表

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第1张图片
上面就是user表的基本结构,保存了一些用户的基本资料,以及用户状态标志位。没什么特别要说的地方。

2、Role表

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第2张图片
上面是role表的基本结构,定义了角色的名称以及角色的标识字符串。还包括了其他模块的一些数据,比如数据权限的标识,这里不做讨论。

3、Menu表

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第3张图片
这张表要特别说一下。
首先是动态菜单的实现。表里包括了前端生成动态路由router的数据。
其次就是perms字段。这个字段将权限管理的粒度细化到了按钮,也就是你可能可以进入某个页面。但是无法使用这个页面里的所有功能。菜单部分的权限是在渲染页面时就确定了,如果你没有某个菜单或目录的所有权限,那你的页面则不会出现这些目录。

五、基本流程

我们观察一下点击登录之后,前端一共发送了三个请求。
在这里插入图片描述
依次看一下这些请求都做了什么。

1、login

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第4张图片

前后端分离的系统交互一般都是无状态登录,这里使用的是jwt实现。登录后续的所有请求都会借助token进行权限验证。

2、getInfo

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第5张图片
登录成功后,需要获取一些公用状态,比如用户名称,用户头像信息等。这些状态都被保存在vuex中管理。其实这里不是很严谨,这里忽略了路由守卫的部分,但是感知不强,会在下面详细说一下。

3、getRouter

他山之石——RuoYi后台系统权限管理解析(RuoYi版本:v3.2.0)_第6张图片
到这里就进行到首页渲染的最后一步,获取路由信息。
其实图中的过程不是很严谨,但是这样稍微更好理解一些。
这一步的工作主要由前端来完成,登录完成后,会跳转至首页。在首页渲染之前,路由守卫会做一些操作,这一部分我在另一篇文章里有详细描述。戳这里在这些操作里就包括上面的接口请求,以及这里的路由信息的请求。在获取到路由信息后,将信息转化为router对象,再动态挂载路由。然后在左边栏的页面部分,遍历router对象生成边栏。

六、具体实现(部分)

这里会贴一些我认为比较重要的代码进行说明,更多具体的限于篇幅也不搞太多。

1、SpringSecurity

这里就直接看配置类了

@Override
    protected void configure(HttpSecurity httpSecurity) throws Exception
    {
        httpSecurity
                // CSRF禁用,因为不使用session
                .csrf().disable()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                // 对于登录login 验证码captchaImage 允许匿名访问
                .antMatchers("/login", "/captchaImage").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                .antMatchers("/profile/**").anonymous()
                .antMatchers("/common/download**").anonymous()
                .antMatchers("/common/download/resource**").anonymous()
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .antMatchers("/webjars/**").anonymous()
                .antMatchers("/*/api-docs").anonymous()
                .antMatchers("/druid/**").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

作者已经给配置类做了一些注解,这里比较重要的是添加了jwt过滤器,而且是所有的请求都会被这个过滤器拦截,包括"/login", "/captchaImage"。除此之外,配置类里并没有声明登录接口。那么肯定在某个地方加入SpringSecurity的过滤链。
其实从Controller层顺藤摸瓜,很快就能看到这个方法。这个方法验证了用户是否合法,并且将用户信息保存进了Redis

public String login(String username, String password, String code, String uuid)
    {
        // 通过UUID,还原登录前的秘钥
        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
        // 通过秘钥查询Redis中存储的验证信息
        String captcha = redisCache.getCacheObject(verifyKey);
        // 删除验证信息
        redisCache.deleteObject(verifyKey);
        if (captcha == null)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
            throw new CaptchaExpireException();
        }
        if (!code.equalsIgnoreCase(captcha))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
            throw new CaptchaException();
        }
        // 用户验证
        Authentication authentication = null;
        try
        {
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager
                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
        }
        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 CustomException(e.getMessage());
            }
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        // 生成token
        return tokenService.createToken(loginUser);
    }

就是在下面这个位置调用了authenticationManager,将用户名密码加入了整个验证链。而且作者也在此做了注释该方法会去调用UserDetailsServiceImpl.loadUserByUsername,也就是我们自定义的用户验证规则。

        // 用户验证
        Authentication authentication = null;
        try
        {
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager
                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
        }

在这里会从数据库验证用户是否合法。到这基本上就算完成了完整的验证流程。

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user))
        {
            log.info("登录用户:{} 不存在.", username);
            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
        }
        else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("登录用户:{} 已被删除.", username);
            throw new BaseException("对不起,您的账号:" + username + " 已被删除");
        }
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("登录用户:{} 已被停用.", username);
            throw new BaseException("对不起,您的账号:" + username + " 已停用");
        }

        return createLoginUser(user);
    }

2、JWT

在配置类中只定义了一个jwt过滤器。将这个过滤器添加到了UsernamePasswordAuthenticationFilter过滤器之前。这部分我感觉没有特别难理解的部分了,主要就是一些业务逻辑。

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }

七、总结

以上都是个人对项目的理解,如有错误还请指正。如果有其他问题,请在评论区提出讨论。如果这篇文章帮到了你,请点个赞鼓励一下我这个菜鸟。

你可能感兴趣的:(SpringSecurity,ruoyi)