学习视频:https://www.bilibili.com/video/BV1mm4y1X7Hc?p=1
🔶 系列笔记
(1)【SpringCloud】Spring Security简介、快速入门、原理流程
(2)【SpringCloud】Spring Security实现登录认证的思路与具体实现过程
(3)【SpringCloud】Spring Security授权实现流程、自定义失败处理方法
(4)【SpringCloud】Spring Security解决跨域问题、自定义校验方法
文章目录
一、权限系统的作用
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
二、授权基本流程
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。
然后设置我们的资源所需要的权限即可。
三、授权实现
3.1 限制访问资源所需权限
SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。
但是要使用它我们需要先开启相关配置。在SecurityConfig上面加上该注解。
@EnableGlobalMethodSecurity(prePostEnabled = true)
然后就可以使用对应的注解。@PreAuthorize
@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello(){
return "hello";
}
}
3.2 封装权限信息
修改LoginUser,添加permissions权限,修改getAuthorities()方法用于返回权限。
domian.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;
}
/**
* Redis默认时不会把它进行序列,但是这样会出问题
* 其实,我们不需要把这个成员变量序列号存储到Redis当中,我们只需要存储permissions即可。
* 我们可以将permissions转换为authorities。
* 通过 @JSONField(serialize = false) 注解,可以不让它序列号。
*/
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (authorities != null) {
return authorities;
}
// 把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
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;
}
}
在登录的时候为用户添加权限, 正常情况下权限要从数据库中获取。为方便测试,直接为用户添加test、admin权限。
service.impl.UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(queryWrapper);
// 如果没有查询到用户则抛出异常,在过滤链中有异常捕获,这里抛出的异常会被捕获
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// TODO 查询对应的权限信息
List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
// 把数据封装成UserDetails返回
return new LoginUser(user, list);
}
}
校验的时候,也要获取权限。修改filter.JwtAuthenticationTokenFilter
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
// 放行,后面还有其他过滤器
filterChain.doFilter(request, response);
// 所有过滤器执行完毕后,响应回来还会走到这里
return;
}
// 解析token
String id;
try {
Claims claims = JwtUtil.parseJWT(token);
id = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
// 从redis中获取用户信息
String redisKey = "login:" + id;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
// 后面将需要一个Authentication的对象,在这里通过实现类UsernamePasswordAuthenticationToken构造这个对象
// 选择3个参数的构造器,principal:账号,credentials:密码,authorities:权限
// 为什么要选择这个构造器呢?因为这个构造器中有,super.setAuthenticated(true); 标识用户为已认证。
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
// 存入SecurityContextHolder
// 存入需要一个Authentication的对象,在登录的时候也用到过类似的方法。
// TODO 获取权限信息封装到Authentication
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}
要修改了该行代码,在获取UsernamePasswordAuthenticationToken 的时候,添加了权限。
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
3.3 从数据库中查询权限信息
3.3.1 RBAC权限模型
RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
用户表:存储用户信息
权限表:各种权限信息
角色表:规定了每一种角色具有哪些权限。
用户角色关联表:规定了用户所对应的角色。
角色权限关联表:规定了该角色具有哪些权限。
3.3.2 准备工作
数据库创建,sys_user表已经创建过了,这里可以不用去重新创建了。
USE `security_test`;
/*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_menu` */
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_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;
插入一些数据
insert into sys_role (id, name, role_key) value (1, 'CEO', 'ceo');
insert into sys_role (id, name, role_key) value (2, 'Coder', 'coder');
insert into sys_menu (id,menu_name,path,component,perms) value (1,'部门管理', 'dept', 'system/dept/index', 'system:dept:list');
insert into sys_menu (id,menu_name,path,component,perms) value (2,'测试', 'dept', 'system/test/index', 'system:test:list');
insert into sys_role_menu (role_id, menu_id) value (1, 1);
insert into sys_role_menu (role_id, menu_id) value (1, 2);
insert into sys_user_role (user_id, role_id) value (1, 1);
测试sql语句:根据userId查询perms对应的role和menu,并且查询出来的role和menu都要为正常状态的。
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 = 1
and r.status = 0
and m.status = 0
对应的实体类:
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 菜单表(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.3.3 代码实现
我们只需要根据用户id去查询到其所对应的权限信息即可。
所以我们可以先定义个mapper,其中提供一个方法可以根据userid查询权限信息。
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
/**
* 根据用户id获取权限
* @param userId 用户id
* @return
*/
List<String> selectPermsByUserId(Long userId);
}
<?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.zqc.springsecuritydemo.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String" parameterType="java.lang.Long">
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 = #{id}
and r.status = 0
and m.status = 0
</select>
</mapper>
在application.yml中配置mapperXML文件的位置
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
然后我们可以在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中即可。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(queryWrapper);
// 如果没有查询到用户则抛出异常,在过滤链中有异常捕获,这里抛出的异常会被捕获
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// 查询对应的权限信息
List<String> list = menuMapper.selectPermsByUserId(user.getId());
// 把数据封装成UserDetails返回
return new LoginUser(user, list);
}
}
3.3.4 自定义失败处理
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成AuthenticationException
然后调用AuthenticationEntryPoint
对象的方法去进行异常处理。
如果是无该权限导致的异常会被封装成AccessDeniedException
然后调用AccessDeniedHandler
对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint
和AccessDeniedHandler
然后配置给SpringSecurity即可。
3.3.4.1 认证失败处理
状态码 401
handler.AuthenticationEntryPointImpl
@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);
}
}
3.3.4.2 无权限失败处理
状态码 403
handler.AccessDeniedHandlerImpl
@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);
}
}
3.3.4.3 修改配置类
注入上面两个异常处理类,并在configure方法内添加该配置。
config.SecurityConfig
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 创建 BCryptPasswordEncoder 注入容器
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* 认证失败处理
*/
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
/**
* 无权限失败处理
*/
@Autowired
private AccessDeniedHandler accessDeniedHandler;
/**
* 配置
* @param http
* @throws Exception
*/
@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();
// 在过滤器UsernamePasswordAuthenticationFilter之前,添加我们自定义的过滤器JwtAuthenticationTokenFilter
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 配置异常处理器
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
}
/**
* 用于用户认证
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
3.3.4.4 测试
错误账号登录,登录失败,也就是认证失败,返回401状态码,提示登录失败。
正确账号登录,正常返回响应体。
带着正确的token,访问有权限的hello接口,正常访问,输出响应体hello。
带着正确的token,访问无权限的hello2接口,对于hello2接口,该用户无权限访问,所以返回403错误信息。
带着错误的token访问hello或hello2接口,返回401状态码,用户认证失败。