美文网首页
Spring Security基于DB的权限认证

Spring Security基于DB的权限认证

作者: JAVA编程手记 | 来源:发表于2021-04-10 20:49 被阅读0次

    前言

    公众号 《java编程手记》记录JAVA学习日常,分享学习路上点点滴滴,从入门到放弃,欢迎关注

    前面我们已经将一个简单的Spring Security Demo项目跑起来了,但是使用的是Spring Security自带默认的user用户名以及默认自动生成的密码,本文主要在原有的基础上加入更加适合生产环境使用的基于DB的权限认证,整体实现主要分为两个部分

    • 基于DB的权限表设计
    • Spring Security认证扩展点实现

    基于DB的权限表设计

    RBAC介绍

    RBAC是基于角色的访问控制Role-Based Access Control ),在RBAC的设置中,用户和角色进行绑定,角色和权限进行绑定,一个用户可以有多个角色,一个角色也可以有多个权限,用户和权限点之间通过角色进行链接,

    如下就是经典的表结构设计,用户表,角色表,权限表,用户角色表,角色权限表

    用户表

    CREATE TABLE `user` (
                `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
                `username` varchar(10) NOT NULL DEFAULT '' COMMENT '用户名',
                `password` varchar(255) NOT NULL DEFAULT '' COMMENT '密码',
                `name` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名',
                `email` varchar(36) NOT NULL COMMENT '邮箱',
                `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
                `sex` tinyint(2) NOT NULL DEFAULT '0' COMMENT '性别',
                `age` tinyint(2) DEFAULT '0' COMMENT '年龄',
                `user_type` tinyint(2) NOT NULL DEFAULT '1' COMMENT '用户类别[0:管理员,1:普通员工]',
                `locked` tinyint(2) DEFAULT '0' COMMENT '是否锁定[0:正常,1:锁定]',
                `status` tinyint(3) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
                `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
                `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
                PRIMARY KEY (`id`),
                UNIQUE KEY `IDX_username` (`username`)
    ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
    

    角色表

    
    CREATE TABLE `role` (
            `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
            `name` varchar(64) NOT NULL COMMENT '角色名',
            `description` varchar(255) DEFAULT NULL COMMENT '简介',
            `icon_cls` varchar(32) DEFAULT NULL COMMENT '角色图标',
            `seq` tinyint(2) NOT NULL DEFAULT '0' COMMENT '排序号',
            `status` tinyint(2) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
            `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
            `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
            PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 COMMENT='角色';
    
    
    

    用户角色表

    CREATE TABLE `user_role` (
                `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
                `user_id` int(11) NOT NULL COMMENT '用户id',
                `role_id` int(11) NOT NULL COMMENT '角色id',
                PRIMARY KEY (`id`),
                KEY `idx_user_role_ids` (`user_id`,`role_id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=79 DEFAULT CHARSET=utf8 COMMENT='用户角色';
    

    权限表

    CREATE TABLE `resource` (
                `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
                `name` varchar(64) NOT NULL COMMENT '资源名称',
                `permissions` varchar(32) DEFAULT NULL COMMENT '资源的权限',
                `url` varchar(100) DEFAULT NULL COMMENT '资源路径',
                `open_mode` varchar(32) DEFAULT NULL COMMENT '打开方式 ajax,iframe',
                `description` varchar(255) DEFAULT NULL COMMENT '资源介绍',
                `icon_cls` varchar(32) DEFAULT NULL COMMENT '资源图标',
                `pid` int(11) DEFAULT NULL COMMENT '父级资源id',
                `seq` tinyint(2) NOT NULL DEFAULT '0' COMMENT '排序',
                `status` tinyint(2) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
                `opened` tinyint(1) NOT NULL DEFAULT '0' COMMENT '打开状态',
                `resource_type` tinyint(2) NOT NULL DEFAULT '0' COMMENT '资源类别',
                `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
                `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
                PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=239 DEFAULT CHARSET=utf8 COMMENT='资源';
    

    角色权限表

    
    CREATE TABLE `role_resource` (
            `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
            `role_id` int(11) NOT NULL COMMENT '角色id',
            `resource_id` int(11) NOT NULL COMMENT '资源id',
            PRIMARY KEY (`id`),
            KEY `idx_role_resource_ids` (`role_id`,`resource_id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=683 DEFAULT CHARSET=utf8 COMMENT='角色资源';
    
    

    将上述SQL导入到DB中即可

    Mybatis-Plus 引入

    https://mybatis.plus/guide/install.html

    MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

    愿景

    我们的愿景是成为 MyBatis 最好的搭档,就像 魂斗罗 中的 1P、2P,基友搭配,效率翻倍。

    添加mybatis-plus SpringBoot && Mysql 驱动依赖

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.2</version>
    </dependency>
    
    <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>5.1.38</version>
            </dependency>
    

    application.yml配置

    这里填写自身的DB信息即可

    # DataSource Config
    spring:
      datasource:
        driver-class-name: com.mysql.jdbc.Driver
        url: jdbc:mysql://localhost:3306/security?useUnicode=true&useSSL=false&characterEncoding=utf8
        username: root
        password: 123456
    

    代码自动生成

    添加mybatis-plus-generator依赖,用以自动生成代码

    这里发现一个小坑,mybatis-plus-generator自带的freemarker包有问题,需要引入一个新的版本(2.3.28)才可以正常执行

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>3.4.2</version>
    </dependency>
    
    
    <dependency>
      <groupId>org.freemarker</groupId>
      <artifactId>freemarker</artifactId>
      <version>2.3.28</version>
      <scope>compile</scope>
    </dependency>
    

    使用Mybatis-Plus提供的Demo,我们自动生成表的Controller,Service,DAO,Mapper文件

    
        /**
         * <p>
         * 读取控制台内容
         * </p>
         */
        public static String scanner(String tip) {
            Scanner scanner = new Scanner(System.in);
            StringBuilder help = new StringBuilder();
            help.append("请输入" + tip + ":");
            System.out.println(help.toString());
            if (scanner.hasNext()) {
                String ipt = scanner.next();
                if (StringUtils.isNotBlank(ipt)) {
                    return ipt;
                }
            }
            throw new MybatisPlusException("请输入正确的" + tip + "!");
        }
    
        public static void main(String[] args) {
            // 代码生成器
            AutoGenerator mpg = new AutoGenerator();
    
            // 全局配置
            GlobalConfig gc = new GlobalConfig();
            String projectPath = System.getProperty("user.dir");
            gc.setOutputDir(projectPath + "/src/main/java");
            gc.setAuthor("uiaoo");
            gc.setOpen(false);
            // gc.setSwagger2(true); 实体属性 Swagger2 注解
            mpg.setGlobalConfig(gc);
    
            // 数据源配置
            DataSourceConfig dsc = new DataSourceConfig();
            dsc.setUrl("jdbc:mysql://localhost:3306/security?useUnicode=true&useSSL=false&characterEncoding=utf8");
            // dsc.setSchemaName("public");
            dsc.setDriverName("com.mysql.jdbc.Driver");
            dsc.setUsername("root");
            dsc.setPassword("123456");
            mpg.setDataSource(dsc);
    
            // 包配置
            PackageConfig pc = new PackageConfig();
            pc.setModuleName(scanner("模块名"));
            pc.setParent("com.uiaoo.spring.security");
            mpg.setPackageInfo(pc);
    
            // 自定义配置
            InjectionConfig cfg = new InjectionConfig() {
                @Override
                public void initMap() {
                    // to do nothing
                }
            };
    
            // 如果模板引擎是 freemarker
            String templatePath = "/templates/mapper.xml.ftl";
            // 自定义输出配置
            List<FileOutConfig> focList = new ArrayList<>();
            // 自定义配置会被优先输出
            focList.add(new FileOutConfig(templatePath) {
                @Override
                public String outputFile(TableInfo tableInfo) {
                    // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                    return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
                            + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
                }
            });
    
            cfg.setFileOutConfigList(focList);
            mpg.setCfg(cfg);
    
            // 配置模板
            TemplateConfig templateConfig = new TemplateConfig();
    
            templateConfig.setXml(null);
            mpg.setTemplate(templateConfig);
    
            // 策略配置
            StrategyConfig strategy = new StrategyConfig();
            strategy.setNaming(NamingStrategy.underline_to_camel);
            strategy.setColumnNaming(NamingStrategy.underline_to_camel);
            strategy.setEntityLombokModel(true);
            strategy.setRestControllerStyle(true);
            strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
            strategy.setControllerMappingHyphenStyle(true);
            strategy.setTablePrefix(pc.getModuleName() + "_");
            mpg.setStrategy(strategy);
            mpg.setTemplateEngine(new FreemarkerTemplateEngine());
            mpg.execute();
        }
    

    自动生成后的目录大致如下,包含了大部分常规的代码文件

    Spring Security认证扩展点实现

    SpringSecurityFilterChain

    Spring Security 在web场景的应用核心实现为Bean name为SpringSecurityFilterChain的这个Bean,Class为org.springframework.security.web.FilterChainProxy,SpringSecurityFilterChain中内部维护了一个FilterChain,默认FilterChain中会维护如下Filter

    UsernamePasswordAuthenticationFilter

    后续我们会意义讲解每个Filter的实现作用,这里我们重点了解下SpringSecurityFilterChain这个Filter实现,看名字就可以大致猜出来是跟登录的账户密码相关联的filter,UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter在执行doFilter方法后会进入到attemptAuthentication这个方法中,即尝试认证,这里需要注意的一个点是,Authentication使用的实现类是UsernamePasswordAuthenticationToken,在后续的AuthenticationProvidersupports方法中将匹配到DaoAuthenticationProvider的实现

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
            if (this.postOnly && !request.getMethod().equals("POST")) {
                throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
            } else {
                String username = this.obtainUsername(request);
                username = username != null ? username : "";
                username = username.trim();
                String password = this.obtainPassword(request);
                password = password != null ? password : "";
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                this.setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            }
        }
    

    AuthenticationManager

    方法最后是this.getAuthenticationManager().authenticate(authRequest),即AuthenticationManager#authenticate方法,AuthenticationManager类抽象了认证的模型,从authenticate方法描述中可知,尝试去通过认证,返回一个填充了用户信息和认证信息的结果数据,

    ProviderManager

    Spring Security默认提供了AuthenticationManager的实现类ProviderManager,在providerManagerauthenticate方法实现中,providerManager设想认证方式可能会有多种,例如常规的账户密码认证,三方授权认证等等,主要是遍历所有的AuthenticationProvider的实现,通过provider.supports方法识别当前传入的authentication对象实现是否是当前provider所支持的,如果不支持则跳过,直到找到一个匹配的,则执行provider.authenticate方法

    Class<? extends Authentication> toTest = authentication.getClass();
    //拿到所有的AuthenticationProvider实现,循环遍历,如果supports,进行认证,否则下一个Provider
    for (AuthenticationProvider provider : getProviders()) {
                if (!provider.supports(toTest)) {
                    continue;
                }
                ......
                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                        ....
                    }
                }
                catch (){
            ....
          }
            }
    

    AuthenticationProvider

    AuthenticationProvider方法中定义了authenticate方法supports方法

    • supports 当前authentication是否适配当前Provider,还记得上面UsernamePasswordAuthenticationFilterauthentication的实现UsernamePasswordAuthenticationToken吗,这里将默认匹配到DaoAuthenticationProviderDaoAuthenticationProvider本身并没有实现supports方法,真正的实现是AbstractUserDetailsAuthenticationProvider,而AbstractUserDetailsAuthenticationProvider的实现只有DaoAuthenticationProvider,所以默认就匹配了DaoAuthenticationProvider
    • authenticate 真正的认证方法

    默认AuthenticationProvider的核心实现AbstractUserDetailsAuthenticationProvider实现了大部分的通用关键逻辑方法authenticatesupports方法, 并且提供了扩展抽象方法retrieveUser ,当从缓存(默认缓存实现也是空的NullUserCache)中取不到用户信息时,将调用retrieveUser方法查询用户信息,DaoAuthenticationProvider实现了retrieveUser方法,

    public abstract class AbstractUserDetailsAuthenticationProvider
            implements AuthenticationProvider, InitializingBean, MessageSourceAware {
    @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            ...
            String username = determineUsername(authentication);
            boolean cacheWasUsed = true;
        //从缓存中获取用户信息
            UserDetails user = this.userCache.getUserFromCache(username);
            if (user == null) {
                cacheWasUsed = false;
                try {
            // 查询用户信息
                    user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
                }
                catch (UsernameNotFoundException ex) {
                    this.logger.debug("Failed to find user '" + username + "'");
                    if (!this.hideUserNotFoundExceptions) {
                        throw ex;
                    }
                    throw new BadCredentialsException(this.messages
                            .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
                }
                ...
            }
     }
      
      //authentication的实现UsernamePasswordAuthenticationToken
      @Override
      public boolean supports(Class<?> authentication) {
        return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
      }
    }
    

    DaoAuthenticationProvider的实现中,出现了一个新的服务UserDetailsServiceUserDetailsService是一个获取用户信息的核心服务接口,只有一个方法loadUserByUsername,通过userName查询,返回封装后的用户信息UserDetails对象,分析到这里终于可以告一段落,虽然Spring Security也提供了默认的实现比如JdbcUserDetailsManager,但是整体还是不够灵活,我们可以从这里入手实现自己的UserDetailsService

    public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    @Override
        protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
                throws AuthenticationException {
            prepareTimingAttackProtection();
            try {
          //调用UserDetailsService.loadUserByUsername获取用户信息
                UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
                if (loadedUser == null) {
                    throw new InternalAuthenticationServiceException(
                            "UserDetailsService returned null, which is an interface contract violation");
                }
                return loadedUser;
            }
            catch (UsernameNotFoundException ex) {
                mitigateAgainstTimingAttack(authentication);
                throw ex;
            }
            catch (InternalAuthenticationServiceException ex) {
                throw ex;
            }
            catch (Exception ex) {
                throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
            }
        }
    }
    

    说的有点多,画个图好理解下

    实现

    实现AuthenticationProvider

    这里我们直接继承实现DaoAuthenticationProvider类,什么也不做,直接使用DaoAuthenticationProvider原有的authenticate方法实现

    public class MyAuthenticationProvider extends DaoAuthenticationProvider {
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            return super.authenticate(authentication);
        }
    }
    

    实现UserDetailsService

    @Slf4j
    @Component
    public class MyUserDetailsService implements UserDetailsService {
    
        @Autowired
        private IUserService iUserService;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            //判断用户是否存在
            User userInfo = iUserService.getAdminByUserName(username);
            if(Objects.isNull(userInfo)){
                throw new UsernameNotFoundException("用户不存在");
            }
                    //根据用户名查询权限信息
            List<Resource> resourceList = iUserService.getResourcesByUserName(username);
            List<SimpleGrantedAuthority> authList = resourceList.stream().filter(v-> !StringUtils.isEmpty(v.getPermissions())).map(v -> new SimpleGrantedAuthority(v.getPermissions())).collect(Collectors.toList());
                    // {noop} 不使用密码加密
            User user = new User(username,"{noop}"+userInfo.getPassword(),authList);
            log.info("user info : {}",user);
            return user;
        }
    }
    
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
        public List<Resource> getResourcesByUserName(String userName) {
            //查询用户基础信息
            User user = getAdminByUserName(userName);
            if(Objects.isNull(user)){
                return new ArrayList<>();
            }
            //查询用户关联角色
            List<UserRole> tAdminRoleList = iUserRoleService.getRolesByUserId(user.getId());
            List<Integer> roleIds = new ArrayList<>();
            tAdminRoleList.forEach(tAdminRole -> {
                roleIds.add(tAdminRole.getRoleId());
            });
            //根据角色id查询关联权限信息
            return iRoleResourceService.getResource(roleIds);
        }
    }
    

    实现WebSecurityConfigurerAdapter配置项

    • EnableWebSecurity 启动SpringSecurity在web场景的自动装配
    • MapperScan({"com.smallcannon.spring.security.system.mapper"}) mybatis自动扫描mapper包
    • 定义/add路径访问需要add权限,/del需要 del权限
    @EnableWebSecurity
    @MapperScan({"com.smallcannon.spring.security.system.mapper"})
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Resource
        MyUserDetailsService myUserDetailsService;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.formLogin().and().authorizeRequests().antMatchers("/add").hasAuthority("add").and().authorizeRequests().antMatchers("/del").hasAuthority("del");
        }
    
    
            //设置自定义实现的AuthenticationProvider
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(authenticationProvider());
        }
    
        //设置自定义Provider,并将UserDetailService实现放进来
        @Bean
        public AuthenticationProvider authenticationProvider(){
            MyAuthenticationProvider provider = new MyAuthenticationProvider();
            provider.setUserDetailsService(myUserDetailsService);
            return provider;
        }
    
    }
    

    启动类,同事新增两个请求地址 /add /del

    @SpringBootApplication
    @RestController
    public class StudySecurityApplication {
    
       public static void main(String[] args) {
          SpringApplication.run(StudySecurityApplication.class, args);
       }
    
       @GetMapping("/add")
       public Object add(){
          return "add";
       }
    
       @GetMapping("/del")
       public Object del(){
          return "del";
       }
    }
    

    在库中新增一个管理员角色,并且关联admin账户,新增一个创建权限add,并且将管理员角色关联到权限add,这样在访问我们的/add页面时就会返回正常的页面,返回del页面时就会返回无权限

    权限add

    管理员角色

    用户admin

    admin账户关联管理员角色

    管理员角色关联add权限

    启动应用

    登录之后,访问/add 页面,成功返回add

    访问/del 页面则显示403forbidden,权限不足,大功告成!

    相关文章

      网友评论

          本文标题:Spring Security基于DB的权限认证

          本文链接:https://www.haomeiwen.com/subject/vxonkltx.html