Spring Security基于DB的权限认证

前言

公众号 《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 驱动依赖


    com.baomidou
    mybatis-plus-boot-starter
    3.4.2



            mysql
            mysql-connector-java
            5.1.38
        

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)才可以正常执行


    com.baomidou
    mybatis-plus-generator
    3.4.2




  org.freemarker
  freemarker
  2.3.28
  compile

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


    /**
     * 

* 读取控制台内容 *

*/ 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 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 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 resourceList = iUserService.getResourcesByUserName(username);
        List 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 implements IUserService {
@Override
    public List getResourcesByUserName(String userName) {
        //查询用户基础信息
        User user = getAdminByUserName(userName);
        if(Objects.isNull(user)){
            return new ArrayList<>();
        }
        //查询用户关联角色
        List tAdminRoleList = iUserRoleService.getRolesByUserId(user.getId());
        List 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的权限认证)