一、授权的概述
承接上一篇博客SpringSecurity认证流程详解和代码实现
1.1 授权的作用
不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
实际开发中,不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。
因为如果有人知道了对应功能的接口地址就可以不通过前端,直接去请求就可以实现相关功能操作。
因此还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行
相应的操作。
1.2 授权基本流程
在SpringSecurity
中,会使用默认的拦截器FilterSecurityInterceptor
来进行权限校验。
在FilterSecurityInterceptor
中会从SecurityContextHolder
获取其中的Authentication
,然
后获取其中的权限信息,最后判断当前用户是否拥有访问当前资源所需的权限。
因此在项目中只需要把当前登录用户的权限信息也存入Authentication。
然后设置我们的资源所需要的权限即可。
二、授权代码基础实现
这里是基于SpringBoot
来进行实现的。
2.1 限制访问资源所需权限
SpringSecurity
为我们提供了基于注解的权限控制方案,这也是项目中主要采用的方式。
可以使用注解去指定访问对应的资源所需的权限。但是要使用它我们需要先开启相关配置。
开启配置需要再在SpringSecurity
的配置类中增加如下注解:
@EnableGlobalMethodSecurity(prePostEnabled = true)
由于跨了一篇文章,因此配置类的代码再贴一遍,有不清楚的地方看上一篇。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 这里新增这个注解
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
// 这个自定义的过滤器
@Autowired
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
然后就可以使用对应的注解来设置接口的访问权限:@PreAuthorize
@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello(){
return "hello";
}
}
这么写好后,接口还不能访问,需要在loadUserByUsername方法中把权限信息封装到LoginUser的authorities字段中
2.2 封装权限信息
上一篇在写UserDetailsServiceImpl
的时候提到,在查询出用户后还要获取对应的权限信息,封装到UserDetails
中返回。
这里先直接把权限信息写死封装到UserDetails
中进行测试,实际开发权限信息存储在数据库,后面再演示。
上一篇博客定义了UserDetails
的实现类LoginUser
,想要让其能封装权限信息就要对其进行修改。
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
//存储权限信息
private List<String> permissions;
public LoginUser(User user,List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
//存储SpringSecurity所需要的权限信息的集合
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if(authorities!=null){
return authorities;
}
//把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
authorities = permissions.stream().
map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
LoginUser
修改完后我们就可以在UserDetailsServiceImpl
中去把权限信息封装到LoginUser
中了。
暂时写死权限进行测试,后面我们再从数据库中查询权限信息。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
List<String> list = new ArrayList<>(Arrays.asList("test"));
return new LoginUser(user, list);
}
}
这里完成后,发现hello接口已经可以访问了
三、从数据库获取用户权限信息
上面的代码中,权限信息是被我们写死的。
但是实际开发中,权限信息是保存在数据库当中的,上面只是为了理解和测试。
平时进行用户授权,更多的是使用RBAC
权限模型来进行开发。
3.1 RBAC权限模型
标准RBAC
权限模型一般就会涉及到这五张表:
-
sys_user
:系统用户表,当前系统的使用者,是发起各种操作的主体。 -
sys_menu
:系统权限表(菜单表)- 包括页面的操作权限,页面查看权限。定义了用户拥有的角色可以访问的系统资源
-
sys_role
:用户角色表,主要是为了建立用户和权限之间的关系- 每个角色可以关联多个权限,每个用户可以关联多个角色,彼此都是多对多的关系。
-
sys_user_role
:用户角色关联表 -
sys_role_menu
:角色权限关联表
这张图是网图,所以字段和博客中的代码有些不同,主要是为了理解各个表之间的关系。
3.2 建表语句
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
`status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(20) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
/*Table structure for table `sys_role` */
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(128) DEFAULT NULL,
`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
`del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
`create_by` bigint(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(200) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
/*Table structure for table `sys_role_menu` */
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*Table structure for table `sys_user` */
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
/*Table structure for table `sys_user_role` */
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.3 实体类
由于这里是演示授权,因此只需要用到菜单(权限)实体类。
/**
* 菜单表(Menu)实体类
*/
@TableName(value="sys_menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {
private static final long serialVersionUID = -54979041104113736L;
@TableId
private Long id;
/**
* 菜单名
*/
private String menuName;
/**
* 路由地址
*/
private String path;
/**
* 组件路径
*/
private String component;
/**
* 菜单状态(0显示 1隐藏)
*/
private String visible;
/**
* 菜单状态(0正常 1停用)
*/
private String status;
/**
* 权限标识
*/
private String perms;
/**
* 菜单图标
*/
private String icon;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
/**
* 是否删除(0未删除 1已删除)
*/
private Integer delFlag;
/**
* 备注
*/
private String remark;
}
3.4 代码实现
核心就是根据用户id去查询到其所对应的权限信息。
1)mapper接口
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId(Long id);
}
2)xml文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.security.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT
DISTINCT m.`perms`
FROM
sys_user_role ur
LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = #{userid}
AND r.`status` = 0
AND m.`status` = 0
</select>
</mapper>
在application.yml
文件中配置mapper.xml
文件的扫描位置。
spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
3)在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
// 调用接口获取权限信息
List<String> permissionKeyList = menuMapper.selectPermsByUserId(user.getId());
return new LoginUser(user,permissionKeyList);
}
}
四、其它权限校验方法
4.1 hasAnyAuthority、hasRole、hasAnyRole使用小结
前面部分都是再接口上使用@PreAuthorize
注解,然后在在其中使用的是hasAuthority
方法进行校验。
SpringSecurity
还为我们提供了其它方法例如:hasAnyAuthority
,hasRole
,hasAnyRole
等。
这里我们先不急着去介绍这些方法,先理解hasAuthority
的原理,然后再去学习其他方法就更容易理解。
hasAuthority
方法实际是执行到了SecurityExpressionRoot
类的hasAuthority
方法。
它内部其实是调用authentication
的getAuthorities
方法获取用户的权限列表。
然后判断我们存入的方法参数数据在权限列表中。
hasAnyAuthority
方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
public String hello(){
return "hello";
}
-
hasRole
要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上ROLE_
后再去比较。所以这种情况下要用用户对应的权限也要有
ROLE_
这个前缀才可以。
@PreAuthorize("hasRole('system:dept:list')")
public String hello(){
return "hello";
}
-
hasAnyRole
有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasAnyRole('admin','system:dept:list')")
public String hello(){
return "hello";
}
4.2 自定义权限校验方法
除了使用自带的权限校验方法,也可以定义自己的权限校验方法,在@PreAuthorize
注解中使用自定义的方法。
@Component("my")
public class SGExpressionRoot {
public boolean hasAuthority(String authority){
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
//判断用户权限集合中是否存在authority
return permissions.contains(authority);
}
}
在SPEL
表达式中使用 @my
相当于获取容器中bean
的名字为ex
的对象。然后再调用这个对象的hasAuthority
方法。
@RequestMapping("/hello")
@PreAuthorize("@my.hasAuthority('system:dept:list')")
public String hello(){
return "hello";
}
4.3 基于配置的权限控制
我们也可以在配置类中使用使用配置的方式对资源进行权限控制,但是这种方式用的并不多。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 这里新增这个注解
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
.antMatchers("/testCors").hasAuthority("system:dept:list222")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
}
五、自定义失败处理
如果希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json
,这样可以让前端能对
响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity
的异常处理机制。
在SpringSecurity
中,在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter
捕获到。
在ExceptionTranslationFilter
中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成AuthenticationException
然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成AccessDeniedException
然后调用AccessDeniedHandler对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint
和AccessDeniedHandler
然后配置给SpringSecurity
。
5.1 自定义授权失败处理实现类
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
String json = JSON.toJSONString(result);
WebUtils.renderString(response,json);
}
}
5.2 自定义认证失败处理实现类
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
String json = JSON.toJSONString(result);
WebUtils.renderString(response,json);
}
}
上面的代码都借助了如下工具类:
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class WebUtils
{
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try
{
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
}
catch (IOException e)
{
e.printStackTrace();
}
return null;
}
}
六、拓展
6.1 CSRF
CSRF
是指跨站请求伪造(Cross-site request forgery
),是web
常见的攻击之一。
SpringSecurity
防止CSRF
攻击的方式就是通过csrf_token
。
后端会生成一个csrf_token
,前端发起请求的时候需要携带这个csrf_token
,后端会有过滤器进行校验,如果
没有携带或者是伪造的就不允许访问。
我们可以发现CSRF
攻击依靠的是cookie
中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实
是token
,而token
并不是存储中cookie
中,并且需要前端代码去把token
设置到请求头中才可以,所以
CSRF
攻击也就不用担心了。
6.2 跨域
浏览器出于安全的考虑,使用XMLHttpRequest
对象发起 HTTP
请求时必须遵守同源策略,否则就是跨域的HTTP
请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
所以我们就要处理一下,让前端能进行跨域请求。
1)先对SpringBoot配置,允许跨域请求
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
2)开启SpringSecurity的跨域访问
由于我们的资源都会收到SpringSecurity
的保护,所以想要跨域访问还要让SpringSecurity
允许跨域访问。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//允许跨域
http.cors();
}
}