Spring Security基于DB的权限认证

532 阅读8分钟

前言

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

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

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

基于DB的权限表设计

RBAC介绍

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

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

image-20210407221950592

用户表

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 引入

mybatis.plus/guide/insta…

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();
    }

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

image-20210407223930453

Spring Security认证扩展点实现

SpringSecurityFilterChain

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

image-20210407231320494

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方法描述中可知,尝试去通过认证,返回一个填充了用户信息和认证信息的结果数据,

image-20210407233436589

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 真正的认证方法

image-20210408220901450

默认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);
		}
	}
}

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

image-20210408231549405

实现

实现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

image-20210409213443208

管理员角色

image-20210409213514328

用户admin

image-20210409213538810

admin账户关联管理员角色

image-20210409213601222

管理员角色关联add权限

image-20210409213642298

启动应用

image-20210409213725321

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

image-20210409214149518

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

image-20210409214214753