## 权限控制
本系统权限控制采用`RBAC`思想。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限,每一个角色拥有若干个菜单,这样,就构造成“用户-角色-权限”、“角色-菜单” 的授权模型。在这种模型中,用户与角色、角色与权限、角色与菜单之间构成了多对多的关系,如下图
![](https://img.kancloud.cn/06/9e/069e5a051796a7fb062a0221453e0302_1900x1158.png)
#### 后端权限控制
本系统安全框架使用的是`Spring Security + Jwt Token`, 访问后端接口需在请求头中携带`token`进行访问,请求头格式如下:
~~~
# Authorization: Bearer 登录时返回的token
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTU1ODk2NzY0OSwiaWF0IjoxNTU4OTQ2MDQ5fQ.jsJvqHa1tKbJazG0p9kq5J2tT7zAk5B6N_CspdOAQLWgEICStkMmvLE-qapFTtWnnDUPAjqmsmtPFSWYaH5LtA
~~~
也可以过滤一些接口如:`Druid`监控,`swagger`文档等。
配置文件位于:`skadmin-admin-service -> config -> SecurityConfig`
~~~
// 关键代码,部分略
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 禁用 CSRF
.csrf().disable()
// 授权异常
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 不创建会话
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers("/druid/**").permitAll()
// swagger 文档
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
// 所有请求都需要认证
.anyRequest().authenticated();
httpSecurity
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
~~~
`permitAll()`方法指所有登录和未登录人员都可以访问,这个会经过`security filter`
`anonymous()`所有人都能访问,但是这个不会经过`security filter`
#### 系统数据交互
用户登录 -> 后端验证登录返回`token`\-> 前端带上`token`请求后端数据 -> 后端返回数据, 数据交互流程如下:
![](https://img.kancloud.cn/bc/8e/bc8e2410a9b6f2540193d42a79d08a31_831x540.png)
#### 接口权限控制
`Spring Security`提供了`Spring EL`表达式,允许我们在定义接口访问的方法上面添加注解,来控制访问权限,相关`EL`总结如下:
| 表达式 | 描述 |
| --- | --- |
| hasRole(\[role\]) | 当前用户是否拥有指定角色。 |
| hasAnyRole(\[role1,role2\]) | 多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true。 |
| hasAuthority(\[auth\]) | 等同于hasRole |
| hasAnyAuthority(\[auth1,auth2\]) | 等同于hasAnyRole |
| Principle | 代表当前用户的principle对象 |
| authentication | 直接从SecurityContext获取的当前Authentication对象 |
| permitAll | 总是返回true,表示允许所有的 |
| denyAll | 总是返回false,表示拒绝所有的 |
| isAnonymous() | 当前用户是否是一个匿名用户 |
| isRememberMe() | 表示当前用户是否是通过Remember-Me自动登录的 |
| isAuthenticated() | 表示当前用户是否已经登录认证成功了。 |
| isFullyAuthenticated() | 如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true。 |
下面的接口表示用户拥有`ADMIN`、`MENU_ALL`、`MENU_EDIT`三个权限中的任意一个就能能访问`update`方法,如果方法不加`@preAuthorize`注解,意味着所有用户都带上有效的`token`后能访问`update`方法
~~~
@Log(description = "修改菜单")
@PutMapping(value = "/menu")
@PreAuthorize("hasAnyRole('ADMIN','MENU_ALL','MENU_EDIT')")
public ResponseEntity update(@Validated @RequestBody Menu resources){
// 略
}
~~~
#### 通用查询
本系统对Jpa的查询进行了封装,现可以通过注解方式实现简单的查询与复杂查询,简单查询:`等于、大于等于、小于等于、模糊查询、包含(IN)查询等`,复杂查询:`左连接、右连接`,如需使用复杂查询,可以查看源码中的`JobQueryCriteria`,下面介绍简单查询的使用方法
##### 使用方式
1、首先编写查询类,如日志查询:
~~~
/**
* 日志查询类
* @author Sinkiang
* @date 2019-6-8 09:23:07
*/
@Data
public class LogQuery {
@Query(type = Query.Type.LIKE)
private String username;
@Query
private String logType;
@Query(type = Query.Type.LIKE)
private String description;
}
~~~
2、Controller 中使用
~~~
public ResponseEntity<Object> getLog(LogQuery query, Pageable pageable){
return new ResponseEntity<>(logService.queryAll(query, pageable), HttpStatus.OK);
}
~~~
3、Service 中查询
~~~
@Override
public Page<Log> queryAll(LogQuery query, Pageable pageable){
Page<Log> page = logRepository.findAll(((root, query, cb) -> .getPredicate(root, query, cb)), pageable);
return page;
}
~~~
这样做的好处是,如果需要添加一个字段查询,只需要在查询类中添加就可以了,可以节省大量时间
#### 系统缓存
本系统缓存使用的是`redis`,默认使用`Spring`的注解对系统缓存进行操作,并且提供了可视化的`redis`缓存操作
#### 配置缓存
`redis`配置文件位于`skadmin-common - > redis`,部分配置文件如下:
~~~
public class RedisConfig extends CachingConfigurerSupport {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.jedis.pool.max-idle}")
private int maxIdle;
@Value("${spring.redis.jedis.pool.max-wait}")
private long maxWaitMillis;
@Value("${spring.redis.password}")
private String password;
/**
* 配置 redis 连接池
* @return
*/
@Bean
public JedisPool redisPoolFactory(){
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
if (StrUtil.isNotBlank(password)) {
return new JedisPool(jedisPoolConfig, host, port, timeout, password);
} else {
return new JedisPool(jedisPoolConfig, host, port,timeout);
}
}
/**
* 设置 redis 数据默认过期时间
* 设置@cacheable 序列化方式
* @return
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(){
FastJsonRedisSerializer<Object> fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(fastJsonRedisSerializer)).entryTtl(Duration.ofHours(2));
return configuration;
}
@Bean(name = "redisTemplate")
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
//序列化
FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
// value值的序列化采用fastJsonRedisSerializer
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);
// 全局开启AutoType,不建议使用
// ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
// 建议使用这种方式,小范围指定白名单
ParserConfig.getGlobalInstance().addAccept("com.dxj.admin.service.dto");
// key的序列化采用StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setConnectionFactory(redisConnectionFactory);
return template;
}
/**
* 自定义缓存key生成策略
* 使用方法 @Cacheable(keyGenerator="keyGenerator")
* @return
*/
@Bean
@Override
public KeyGenerator keyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getName());
sb.append(method.getName());
for (Object obj : params) {
sb.append(obj.toString());
}
log.info(sb.toString());
return sb.toString();
};
}
}
~~~
#### 缓存注解
* @CacheConfig:主要用于配置该类中会用到的一些共用的缓存配置
* @Cacheable:主要方法的返回值将被加入缓存。在查询时,会先从缓存中获取,若不存在才再发起对数据库的访问
* @CachePut:主要用于数据新增和修改操作
* @CacheEvict:配置于函数上,通常用在删除方法上,用来从缓存中移除相应数据
使用如下:
~~~
@CacheConfig(cacheNames = "qiNiu")
public interface QiNiuService {
/**
* 查配置
* @return
*/
@Cacheable(key = "'1'")
QiniuConfig find();
/**
* 修改配置
* @param qiniuConfig
* @return
*/
@CachePut(key = "'1'")
QiniuConfig update(QiniuConfig qiniuConfig);
/**
* 查询文件,使用自定义key
* @param id
* @return
*/
@Cacheable(keyGenerator = "keyGenerator")
QiniuContent findByContentId(Long id);
/**
* 删除文件
* @param content
* @param config
* @return
*/
@CacheEvict(allEntries = true)
void delete(QiniuContent content, QiniuConfig config);
}
~~~
#### 可视化redis操作
![](https://img.kancloud.cn/2f/f7/2ff796f38c0dcdb4342fe9fd4341ffaa_2198x1232.png)
#### 异常处理
我们开发项目的时,数据在请求过程中发生错误是非常常见的事情。如:权限不足、数据唯一异常、数据不能为空异常、义务异常等。这些异常如果不经过处理会对前端开发人员和使用者造成不便,因此我们就需要统一处理他们。
源码位于:`skadmin-common - > exception`
#### 定义实体异常
~~~
@Data
class ApiError {
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;
private String message;
private ApiError() {
timestamp = LocalDateTime.now();
}
public ApiError(Integer status,String message) {
this();
this.status = status;
this.message = message;
}
}
~~~
#### 封装异常处理
#### 1、通用异常
封装了`BadRequestException`,用于处理通用的异常
~~~
@Getter
public class BadRequestException extends RuntimeException{
private Integer status = BAD_REQUEST.value();
public BadRequestException(String msg){
super(msg);
}
public BadRequestException(HttpStatus status,String msg){
super(msg);
this.status = status.value();
}
}
~~~
#### 2、实体相关异常
(1) 实体不存在:`EntityNotFoundException`
(2) 实体已存在:`EntityExistException`
使用场景,删除用户的时候是根据ID删除的,可判断ID是否存在,抛出异常;新增用户的时候用户名是唯一的,可判断用户是否存在,抛出异常
#### 全局异常拦截
使用全局异常处理器`@RestControllerAdvice`处理请求发送的异常,部分代码如下:
~~~
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理所有不可知的异常
* @param e
* @return
*/
@ExceptionHandler(Throwable.class)
public ResponseEntity handleException(Throwable e){
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
ApiError apiError = new ApiError(BAD_REQUEST.value(),e.getMessage());
return buildResponseEntity(apiError);
}
/**
* 处理自定义异常
* @param e
* @return
*/
@ExceptionHandler(value = BadRequestException.class)
public ResponseEntity<ApiError> badRequestException(BadRequestException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
ApiError apiError = new ApiError(e.getStatus(),e.getMessage());
return buildResponseEntity(apiError);
}
/**
* 处理 EntityExist
* @param e
* @return
*/
@ExceptionHandler(value = EntityExistException.class)
public ResponseEntity<ApiError> entityExistException(EntityExistException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
ApiError apiError = new ApiError(BAD_REQUEST.value(),e.getMessage());
return buildResponseEntity(apiError);
}
/**
* 处理 EntityNotFound
* @param e
* @return
*/
@ExceptionHandler(value = EntityNotFoundException.class)
public ResponseEntity<ApiError> entityNotFoundException(EntityNotFoundException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
ApiError apiError = new ApiError(NOT_FOUND.value(),e.getMessage());
return buildResponseEntity(apiError);
}
/**
* 统一返回
* @param apiError
* @return
*/
private ResponseEntity<ApiError> buildResponseEntity(ApiError apiError) {
return new ResponseEntity(apiError, HttpStatus.valueOf(apiError.getStatus()));
}
}
~~~
#### 具体使用
~~~
throw new BadRequestException("发生了异常");
~~~
#### 系统日志
本系统使用`AOP`记录用户操作日志,只需要在`controller`的方法上使用`@Log("")`注解,就可以将用户操作记录到数据库,源码可查看`eladmin-logging`
模块具体使用如下:
~~~
@Log("新增用户")
@PostMapping(value = "/users")
@PreAuthorize("hasAnyRole('ADMIN','USER_ALL','USER_CREATE')")
public ResponseEntity create(@Validated @RequestBody User resources){
checkLevel(resources);
return new ResponseEntity(userService.create(resources),HttpStatus.CREATED);
}
~~~
页面上可以看到`操作日志`和`异常日志`
##### 操作日志
![](https://img.kancloud.cn/7c/b6/7cb61bf789f5b3a26796134569ea974a_2192x220.png)
##### 异常日志
![](https://img.kancloud.cn/82/6d/826d482365979d4d24e8b3929882a305_2198x206.png)
#### 数据权限
本系统是基于部门做的一个简单数据权限控制,也就是通过用户角色中的数据权限控制用户能看哪些数据。目前系统在`用户管理`、`部门管理`、`岗位管理`中加入了数据权限供大家测试
##### 角色数据权限
系统提供了三种数据权限控制
* 全部数据权限 无数据权限限制
* 本级数据权限 限制只能看到本部门数据
* 自定义数据权限 可根据实际需要选择部门控制数据权限
![](https://img.kancloud.cn/5a/4b/5a4bb1258fb78415002cb30814d3d1e4_1530x1002.png)
##### 修改后端代码
这里用岗位管理来举例,控制用户能看到哪些岗位数据,首先岗位的实体中需要关联部门,这里用的是一对一关联
~~~
@OneToOne
@JoinColumn(name = "dept_id")
private Dept dept;
~~~
**(1)在控制器中注入**
~~~
@Autowired
private DataScope dataScope;
~~~
**(2)在查询的方法中加入如下代码获取数据权限**
~~~
@Log("查询岗位")
@GetMapping(value = "/job")
@PreAuthorize("hasAnyRole('ADMIN','USERJOB_ALL','USERJOB_SELECT','USER_ALL','USER_SELECT')")
public ResponseEntity getJobs(@RequestParam(required = false) String name,
@RequestParam(required = false) Long deptId,
@RequestParam(required = false) Boolean enabled,
Pageable pageable){
// 数据权限
Set<Long> deptIds = dataScope.getDeptIds();
return new ResponseEntity(jobQueryService.queryAll(name, enabled , deptIds, deptId, pageable),HttpStatus.OK);
}
~~~
**(3)修改QueryService**
~~~
@Override
public Predicate toPredicate(Root<Job> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder cb) {
List<Predicate> list = new ArrayList<Predicate>();
// 数据权限
Join<Dept, Job> join = root.join("dept", JoinType.LEFT);
if (!CollectionUtils.isEmpty(deptIds)) {
list.add(join.get("id").in(deptIds));
}
Predicate[] p = new Predicate[list.size()];
return cb.and(list.toArray(p));
}
~~~
#### 定时任务
对于简单的定时任务用`Spring`的`@Scheduled`注解即可,如需要动态管理定时任务就需要使用到`Quartz`。本系统的动态定时任务源码位于`skdamin-quartz`,使用流程如下
##### 编写任务处理类
~~~
@Slf4j
@Component
public class TestTask {
public void run(){ log.info("执行成功"); }
public void run1(String str){ log.info("执行成功,参数为: {}" + str); }
}
~~~
##### 创建定时任务
打开定时任务页面,点击新增按钮创建定时任务,部分参数解释如下:
* Bean名称:Spring Bean名称,如: testTask
* 方法名称:对应后台任务方法名称 方法参数:对应后台任务方法名称值,没有可不填
* cron表达式:可查询官方cron表达式介绍
* 状态:是否启动定时任务
##### 常用cron表达式
~~~
0 0 10,14,16 * * ? 每天上午10点,下午2点,4点
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
0 0 12 ? * WED 表示每个星期三中午12点
"0 0 12 * * ?" 每天中午12点触发
"0 15 10 ? * *" 每天上午10:15触发
"0 15 10 * * ?" 每天上午10:15触发
"0 15 10 * * ? *" 每天上午10:15触发
"0 15 10 * * ? 2005" 2005年的每天上午10:15触发
"0 * 14 * * ?" 在每天下午2点到下午2:59期间的每1分钟触发
"0 0/5 14 * * ?" 在每天下午2点到下午2:55期间的每5分钟触发
"0 0/5 14,18 * * ?" 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
"0 0-5 14 * * ?" 在每天下午2点到下午2:05期间的每1分钟触发
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44触发
"0 15 10 ? * MON-FRI" 周一至周五的上午10:15触发
"0 15 10 15 * ?" 每月15日上午10:15触发
"0 15 10 L * ?" 每月最后一日的上午10:15触发
"0 15 10 ? * 6L" 每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6#3" 每月的第三个星期五上午10:15触发
~~~
#### 代码生成
本系统提供高灵活度的代码生成功能,只需要在数据库中设计好表结构,就能一键生成前后端代码,是不是很nice,使用流程如下
##### 设计表结构
1. 配置主键(字符串或者整形皆可,整形可不设置自增)
2. 可以设计字段是否为空(会根据这个进行表单验证)
3. 设计注释,`前端会根据注释生成表格标题`
我们数据库中表都能在这看到,需根据自己的需求进行`生成器配置`
![](https://img.kancloud.cn/02/2c/022ccebfc014f5c1ad0478385a3c654b_2162x976.png)
##### 生成器配置
1. 模块名称:这个顾名思义就是模块的名称
2. 至于包下:这个的意思是`生成的代码放到哪个包里面`
3. 前端路径:前端代码生成的路径
4. API路径:这个默认至于`src/api`目录下
5. 是否覆盖:危险操作,需谨慎
##### 代码生成
我们配置好生成器后就能进行代码生成啦,具体操作如下:
1. 点击生成代码按钮
2. 可以临时修改字段标题
3. 配置查询方式,可选:精确或者模糊
4. 列表显示:前端页面是否显示该字段
5. 点击生成按钮
![](https://img.kancloud.cn/fb/07/fb07739e3d82a3fb64065fa6f18693c2_1880x882.png)
##### 额外工作
代码生成可以节省你`80%`左右的开发任务,部分是需要自己需求进行修改的,如:
1. 添加菜单:虽然代码给你生成了,但是菜单还是需要自己手动添加的
2. 权限验证:权限默认生成了,但是没有添加进数据库,需要自行添加
#### 系统工具
为了让大家快速的熟悉该项目,这里列举出项目中使用到的工具类
* SkAdminConstant:系统常用常量定义
* AesEncryptUtils:加密工具
* FileUtils:文件工具类
* PageUtils:分页工具类
* RequestHolder:随时获取 HttpServletRequest
* SecurityUtils:获取当前用户
* SpringContextHolder:随时获取bean
* StringUtils:字符串工具类
* ThrowableUtils:异常工具,获取堆栈信息
* ValidationUtils:验证工具
##### 目录如下
![](https://img.kancloud.cn/95/85/95853c750975cd42d2f98f128c17f0db_908x960.png)