前言
公众号 《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 引入
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
,在后续的AuthenticationProvider
的supports
方法中将匹配到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
,在providerManager
的authenticate
方法实现中,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,还记得上面
UsernamePasswordAuthenticationFilter
的authentication
的实现UsernamePasswordAuthenticationToken
吗,这里将默认匹配到DaoAuthenticationProvider
,DaoAuthenticationProvider
本身并没有实现supports
方法,真正的实现是AbstractUserDetailsAuthenticationProvider
,而AbstractUserDetailsAuthenticationProvider
的实现只有DaoAuthenticationProvider
,所以默认就匹配了DaoAuthenticationProvider
- authenticate 真正的认证方法
默认AuthenticationProvider
的核心实现AbstractUserDetailsAuthenticationProvider
实现了大部分的通用关键逻辑方法authenticate
和supports
方法, 并且提供了扩展抽象方法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
的实现中,出现了一个新的服务UserDetailsService
,UserDetailsService
是一个获取用户信息的核心服务接口,只有一个方法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,权限不足,大功告成!
网友评论