Spring Boot Security5 动态用户角色资源的权限管理(6)

前言

上篇文章介绍了Spring Boot Security基于Redis的Spring Session管理

本篇文章,可以说比较核心、实用的功能,动态用户角色资源管理(RBAC),可能篇幅会比较长,废话不多说,马上进入正题

简单介绍

相信每个正规的系统,都会对系统安全访问权限有严格的控制。简单的一句话总结,就是对的人访问对的资源,这里可能会比较抽象,小编给大家举个例子就懂了:

现在假设有个系统,里面有菜单A、菜单B和菜单C
客户有这么个需求,就是对于管理员来说,可以访问所有资源菜单,对于普通用户来说,只能访问菜单A和菜单B,如图:

image.png

相信这个也是广大系统都有的最基础的需求,那么在系统中的表现,就是用户登录了系统后,如果是普通用户的话,前端只显示菜单A和菜单B,其他途径访问(直接输入URL)菜单C会被提示无权限,而管理员则显示所有菜单

那么怎么实现呢,小编这里就是基于RABC模型去实现的,简单来说就是:


image.png

举个例子:

  • 用户就是登陆系统的用户,像张三、李四、小王这样的具体登陆用户
  • 角色就是假如张三是教师、李四是学生,那么教师和学生角色,也可能可以分得更细,这个根据需求来定义
  • 资源就是访问系统的资源,如查询学生信息、编辑学生信息等等之类

用户和资源是没有直接关联的,用户是通过关联角色,角色再关联资源这种间接的方式去判断自己的资源权限。这样做的好处就是可以更简单直观的去管理用户资源间的关联,不需要说每创建一个用户,就去再重新分配资源这么繁琐,减少数据库冗余设计

数据库设计

数据库表的设计如图:


20191213203541355.png

这里有几点要说明下:

  • 一般 用户 与 角色 是一对一或者一对多的关系,我这里为了方便所以选择一对一的关系
  • 角色 与 资源 是多对多的关系,所以需要中间表 sys_role_resource 存储中间的联系

实体代码如下:

Role.java

package com.demo.ssdemo.sys.entity;

import com.alibaba.fastjson.annotation.JSONField;
import org.springframework.security.core.GrantedAuthority;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Set;

@Entity
@Table(name = "sys_role")
public class Role implements GrantedAuthority {

    //id
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    protected Integer id;

    //角色标识
    @Column
    private String roleKey;

    //角色名称
    @Column
    private String roleName;

    //角色拥有的资源
    @ManyToMany(targetEntity = Resource.class, fetch = FetchType.EAGER)
    @JoinTable(
            name = "sys_role_resource",
            joinColumns = {
                    @JoinColumn(name = "role_id", referencedColumnName = "id", nullable = false)
            },
            inverseJoinColumns = {
                    @JoinColumn(name = "resource_id", referencedColumnName = "id", nullable = false)
            })
    private Set<Resource> resources;

    @Override
    public String getAuthority() {
        return roleKey;
    }
    
    ...get、set方法...
    
}

这里要说明下,GrantedAuthority 接口中的getAuthorities()方法返回的当前用户对象拥有的权限,简单的说就是该用户的角色信息,所以这里我用角色标识roleKey表示

Resource.java

package com.demo.ssdemo.sys.entity;

import javax.persistence.*;
import java.io.Serializable;

@Entity
@Table(name = "sys_resource")
public class Resource  implements Serializable {

    //id
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    protected Integer id;

    //资源名称
    @Column(nullable = false)
    private String resourceName;
    
    //资源标识
    @Column(nullable = false)
    private String resourceKey;

    //资源url
    @Column(nullable = false)
    private String url;

    /**
     * 资源类型
     * 0:菜单
     * 1:按钮
     */
    @Column(nullable = false)
    private Integer type;

    ...get、set方法...
}

相信这些代码大家都看得明白,下面开始进入核心部分

实现

在这里,小编介绍下怎么在Spring Security中实现资源管理功能,也就是针对不同的用户角色,动态的判断是否能访问相应的资源菜单

先看看项目结构图:


20191213203541355.png

首先,我们需要在自定义登录认证那里,设置权限信息:

LoginValidateAuthenticationProvider.java

package com.demo.ssdemo.core;

import com.demo.ssdemo.sys.entity.User;
import com.demo.ssdemo.sys.service.UserService;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.HashSet;
import java.util.Set;

/**
 * @Description 自定义登陆验证
 **/
@Component
public class LoginValidateAuthenticationProvider implements AuthenticationProvider {

    @Resource
    private UserService userService;

    @Resource
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //获取输入phone
        String username = authentication.getName();
        String rawPassword = (String) authentication.getCredentials();

        //查询用户是否存在
        User user = (User) userService.loadUserByUsername(username);

        if (user.isEnabled()) {
            throw new DisabledException("该账户已被禁用,请联系管理员");
            
        } else if (user.isAccountNonLocked()) {
            throw new LockedException("该账号已被锁定");

        } else if (user.isAccountNonExpired()) {
            throw new AccountExpiredException("该账号已过期,请联系管理员");

        } else if (user.isCredentialsNonExpired()) {
            throw new CredentialsExpiredException("该账户的登录凭证已过期,请重新登录");
        }

        //验证密码
        if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
            throw new BadCredentialsException("输入密码错误!");
        }

        //设置角色权限信息
        Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
        grantedAuthorities.add(new SimpleGrantedAuthority(user.getRole().getRoleKey()));
        user.setAuthorities(grantedAuthorities);

        return new UsernamePasswordAuthenticationToken(user, rawPassword, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        //确保authentication能转成该类
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

这里要注意的是,我们把resource实体的resourceKey作为资源的权限标识,设置进grantedAuthorities集合里面,以便spring security根据注解@PreAuthorize自动权限判断

由于我们设计的用户与角色是一对一关联,所以我们这里GrantedAuthority集合就只有一条角色信息数据

然后就是自定义权限不足handler

PerAccessDeniedHandler.java

package com.demo.ssdemo.core.handler;

import com.alibaba.fastjson.JSONObject;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;

@Component
public class PerAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //登录成功返回
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("code", "503");
        paramMap.put("message", accessDeniedException.getMessage());
        //设置返回请求头
        response.setContentType("application/json;charset=utf-8");
        //写出流
        PrintWriter out = response.getWriter();
        out.write(JSONObject.toJSONString(paramMap));
        out.flush();
        out.close();
    }

}

最后我们看看Spring Security配置类的变化:

SecurityConfig.java

package com.demo.ssdemo.config;

import com.demo.ssdemo.core.LoginValidateAuthenticationProvider;
import com.demo.ssdemo.core.handler.LoginFailureHandler;
import com.demo.ssdemo.core.handler.LoginSuccessHandler;
import com.demo.ssdemo.core.handler.PerAccessDeniedHandler;
import com.demo.ssdemo.sys.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

import javax.annotation.Resource;
import javax.sql.DataSource;

/**
 * @Author OZY
 * @Date 2019/08/08 13:59
 * @Description
 * @Version V1.0
 **/

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled=true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 数据源
     */
    @Resource
    private DataSource dataSource;

    /**
     * 用户业务层
     */
    @Resource
    private UserService userService;

    /**
     * 自定义认证
     */
    @Resource
    private LoginValidateAuthenticationProvider loginValidateAuthenticationProvider;

    /**
     * 登录成功handler
     */
    @Resource
    private LoginSuccessHandler loginSuccessHandler;

    /**
     * 登录失败handler
     */
    @Resource
    private LoginFailureHandler loginFailureHandler;

    /**
     * 权限不足handler
     */
    @Resource
    private PerAccessDeniedHandler perAccessDeniedHandler;


    /**
     * 权限核心配置
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //基础设置
        http.httpBasic()//配置HTTP基本身份验证
            .and()
                .authorizeRequests()
                .anyRequest().authenticated()//所有请求都需要认证
            .and()
                .formLogin() //登录表单
                .loginPage("/login")//登录页面url
                .loginProcessingUrl("/login")//登录验证url
                .defaultSuccessUrl("/index")//成功登录跳转
                .successHandler(loginSuccessHandler)//成功登录处理器
                .failureHandler(loginFailureHandler)//失败登录处理器
                .permitAll()//登录成功后有权限访问所有页面
            .and()
                .exceptionHandling().accessDeniedHandler(perAccessDeniedHandler)//设置权限不足handler
            .and()
                .rememberMe()//记住我功能
                .userDetailsService(userService)//设置用户业务层
                .tokenRepository(persistentTokenRepository())//设置持久化token
                .tokenValiditySeconds(24 * 60 * 60); //记住登录1天(24小时 * 60分钟 * 60秒)

        //关闭csrf跨域攻击防御
        http.csrf().disable();

    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //这里要设置自定义认证
        auth.authenticationProvider(loginValidateAuthenticationProvider);
    }


    /**
     * BCrypt加密方式
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 记住我功能,持久化的token服务
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        //数据源设置
        tokenRepository.setDataSource(dataSource);
        //启动的时候创建表,这里只执行一次,第二次就注释掉,否则每次启动都重新创建表
        //tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

}

在Spring Security配置文件中,我们只需要设置PerAccessDeniedHandler 就可以了,还要记得在头部添加@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled=true)注解,以启动spring security注解生效

接下来就是前端页面和控制层:

package com.demo.ssdemo.sys.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/")
public class UserController {

    /**
     * 登录页面跳转
     * @return
     */
    @GetMapping("login")
    public String login() {
        return "login.html";
    }

    /**
     * index页跳转
     * @return
     */
    @GetMapping("index")
    public String index() {
        return "index.html";
    }

    /**
     * menu1
     * @return
     */
    @PreAuthorize("hasAuthority('menu1')")
    @GetMapping("menu1")
    @ResponseBody
    public String menu1() {
        return "menu1";
    }

    /**
     * menu2
     * @return
     */
    @PreAuthorize("hasAuthority('menu2')")
    @GetMapping("menu2")
    @ResponseBody
    public String menu2() {
        return "menu2";
    }

    /**
     * menu3
     * @return
     */
    @PreAuthorize("hasAuthority('menu3')")
    @GetMapping("menu3")
    @ResponseBody
    public String menu3() {
        return "menu3";
    }
    
}

这里要注意的是,每个需要权限判断的方法中,都需要增加@PreAuthorize("hasAuthority('key')")注解,否则权限判断不生效,key对应数据库资源表中的资源标识字段

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>index页</title>
</head>
<body>
index页<br/><br/>

<button id="menu1Btn" type="button" onclick="sendAjax('/menu1')">菜单1</button>
<button id="menu2Btn" type="button" onclick="sendAjax('/menu2')">菜单2</button>
<button id="menu3Btn" type="button" onclick="sendAjax('/menu3')">菜单3</button>

<script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>
<script type="text/javascript">

    function sendAjax(url) {

        $.ajax({
            type: "GET",
            url: url,
            dataType: "text",
            success: function (data) {
                console.log(data);
            }
        });
    }

</script>
</body>
</html>

这里简单的说说数据库的数据

用户表:admin、teacher1和student1
角色表:管理员、教师和学生
资源表:menu1、menu2、menu3

对应权限:
管理员:menu1、menu2、menu3
教师:menu1、menu2
学生:meun1

下面我们看看效果,登录页

image.png

index页:

image.png

这里我们先用admin管理员角色登录,然后点击所有菜单

image.png

可以看到数据正常,并且已经访问到了所有资源菜单

然后我们用 teacher1教师角色 登录,也是点击所有菜单

image.png

会发现,在点击第三个菜单的时候,会返回没有权限访问

我们再用 student1学生角色 登录,也是点击所有菜单

image.png

这里说明我们的动态权限资源管理都生效了

那么文章就介绍到这里,在这里留了个坑,一般系统是不会让用户去点击了菜单才发现没有权限访问,而是针对不同的用户,动态显示不同的菜单,这个内容小编下篇文章就会讲解

demo也已经放到github,获取方式在文章的Spring Boot2 + Spring Security5 系列搭建教程开头篇(1) 结尾处

如果小伙伴遇到什么问题,或者哪里不明白欢迎评论或私信,也可以在公众号里面私信问都可以,谢谢大家~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,904评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,581评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,527评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,463评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,546评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,572评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,582评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,330评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,776评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,087评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,257评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,923评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,571评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,192评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,436评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,145评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容