1- 授权以及流程
授权的作用 :在后端分配权限,让不同的用户拥有不同的权限,才能访问不同的资源
授权流程:
1- 在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。
2-在FilterSecurityInterceptor中,会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中需要把当前登录用户的权限信息也存入Authentication,然后设置我们的资源所需要的权限即可。
2- 入门学习DEMO
写死不从数据库查询,基于注解的方式来实现,便于学习和理解
2-1 限制资源访问的权限
首先开启相关的配置,在springSecurity中加注解,如下: @EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
2-2
然后就可以使用对应的注解:@PreAuthorizehasAnyAuthority 实际上就是方法调用,判断用户是否有权限
@RequestMapping("/hello")
@PreAuthorize("hasAnyAuthority('test')")
public String hello(){
return "hello";
}
2-3 在上文中我们留下TODO 需要增加权限的地方,补上写死的权限,如下:
@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);
}
}
2-4 登录实体类重写
新增权限集合,重写getAuthorities() 获取权限的方法,
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
//权限集合
private List<String> list;
不需要序列化存储到redis中
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if(null != authorities){
return this.authorities;
}
/* for (String en : list){
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(en);
authorities.add(simpleGrantedAuthority);
}*/
//权限集合 转化为 GrantedAuthority的对象进行返回
List<SimpleGrantedAuthority> collect = list.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return collect;
}
@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;
}
}
2-5 获取权限信息封装到Authentication中
@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 userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request,response);
}
}
测试
访问
http://localhost:8888/hello ,带上登录token
UsernamePasswordAuthenticationToken 用户信息中也带上了权限数据,如下图
3-从数据库中查询用户权限
权限模型讲解 : RBAC 权限模型 ,基于角色的权限模型,就是经典的权限五表
3-1 图片展示表之间的关联
用户表和权限表, 关联关系是一对多的关系
因为我先希望权限一次性分配给用户,所以引入了角色的概念。比如图书管理员具有所有权限,而借阅人只有查看图书的权限。如下图:角色表 role
一个角色对相应多个菜单权限,而一个菜单权限也会对应多个角色,所以是多对多的关系,需要引入中间表role_menu来表示角色表和权限表之间的关系
一个用户会对应多个角色,一个角色也可能对应多个用户。比如有些公司人员,即做开发也会做运维工作,多对多的关系形势,所以引入中间表 如,如下user_role表
3-2 下面是创建以上权限五表的SQL
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 '用户类型:1代表普通用户,0代表管理员',
`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=14787164048663 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`menu_name` varchar(50) NOT NULL COMMENT '菜单名称',
`path` varchar(200) DEFAULT '' 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 COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT '' COMMENT '备注',
`del_flag` int(11) DEFAULT '0' ,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2029 DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`name` varchar(128) DEFAULT NULL COMMENT '角色名称',
`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) NOT NULL COMMENT '角色状态(0正常 1停用)',
`del_flag` int(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色信息表';
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 COMMENT='角色和菜单关联表';
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 COMMENT='用户和角色关联表';
五张表创建完成后 随便写点关联数据,如下角色和菜单表的数据
3-3 根据userid 查询 权限
接口
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId(Long userId);
}
查询sql
<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>
3-3 代码实现
@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("用户名或密码错误");
}
//TODO 查询对应的权限信息
// List<String> list = new ArrayList<>(Arrays.asList("test","admin"));
List<String> list = menuMapper.selectPermsByUserId(user.getId());
//把数据封装成UserDetails返回
return new LoginUser(user, list);
}
}
注解放入权限
@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAnyAuthority('system/test/index')")
public String hello(){
return "hello";
}
}
4-登录失败或者授权失败 自定义异常处理
自定义失败处理
我们希望在认证失败或者是授权失败的情况下,也能和我们的接口一样返回相同结构的json,这样可以让前端对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
1- 如果是认证过程中出现的异常会被封装成AuthenticationException,然后调用AuthenticationEntryPoint对象的方法进行异常处理。
2- 如果是授权过程中出现的异常,会被封装成AccessDeniedException,然后调用AccessDeniedHandler对象的方法进行异常处理。
所以,如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler,然后配置给SpringSecurity即可。
/**
* <简述>登录失败异常处理
* <详细描述>
*
* @author syf
* @date 2023年08月31日 16:20
*/
@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);
}
}
/**
* <简述>授权失败异常处理
* <详细描述>
*
* @author syf
* @date 2023年08月31日 16:20
*/
@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);
}
}
配置类中将异常处理配置给SpringSecurity
/**
* <简述>
* <详细描述>
*
* @author syf
* @date 2023年08月28日 11:12
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;
//创建BCryptPasswordEncoder注入容器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//重写:暴露这个bean:在SecurityConfig中配置AuthenticationManager,方便后面注入并获取用户信息。
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//在SecurityConfig中配置,让SpringSecurity对这个接口放行
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//STATELESS表示不会通过session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口 允许匿名访问(未登录可以访问,anonymous表示可以匿名访问)
.antMatchers("/user/login").anonymous()
//除上面外的所有请求全部需要认证即可访问
.anyRequest().authenticated();
// 将jwt过滤器 放到负责处理用户登录信息的处理器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//将异常处理配置给security
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}
}
5-跨域出处理
背景:
前后端分离项目,前端和后端项目一般都不是同源的,所以会出现跨域问题。、
原因:
浏览器出于安全考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。
同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
代码:编写配置类,自定义跨域请求
@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);
}
}
开启跨域配置
由于我们的资源会受到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity允许跨域访问。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;
//创建BCryptPasswordEncoder注入容器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//重写:暴露这个bean:在SecurityConfig中配置AuthenticationManager,方便后面注入并获取用户信息。
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//在SecurityConfig中配置,让SpringSecurity对这个接口放行
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//STATELESS表示不会通过session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口 允许匿名访问(未登录可以访问,anonymous表示可以匿名访问)
.antMatchers("/user/login").anonymous()
//除上面外的所有请求全部需要认证即可访问
.anyRequest().authenticated();
// 将jwt过滤器 放到负责处理用户登录信息的处理器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//将异常处理配置给security
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
}
6-CSRF
CSRF是指跨站请求伪造 (Cross-site request forgery) ,是web常见的攻击之一。
详情可以在这篇文章了解 : https://blog.csdn.net/freeking101/article/details/86537087
SpringSecurity去防止CSRF攻击的方式就是通过csrf token,后端会生成一个srf token,前端发起请求的时候需要携带这个srf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。
7- 认证成功处理器
注意:在用到UsernamePasswordAuthenticationFilter才会配置认证处理器
通过实现 AuthenticationSuccessHandler
接口来实现认证登录成功处理器, 还有其他认证失败处理器以及注销处理器可自行学习。
* 默认登录成功后,跳转到之前请求的 url , 而现在希望登录成功后,实现其他的业务逻辑。比如累计积分、 * 通过Ajax 请求响应一个JSON数据,前端接收到响应的数据进行跳转。那可以使用自定义登录成功处理逻辑。
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 例如,重定向到指定页面
response.sendRedirect("/dashboard");
// 当认证成功后,响应 JSON 数据给前端
response.setContentType("application/json;charset=utf-8");
ResponseResult res = new ResponseResult(200, "认证成功");
String toJSONString = JSONObject.toJSONString(res);
response.getWriter().write(toJSONString);
}
}
在springSecurity中配置登录成功处理器
//在SecurityConfig中配置,让SpringSecurity对这个接口放行
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//STATELESS表示不会通过session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口 允许匿名访问(未登录可以访问,anonymous表示可以匿名访问)
.antMatchers("/user/login").anonymous()
//除上面外的所有请求全部需要认证即可访问
.anyRequest().authenticated();
// 将jwt过滤器 放到负责处理用户登录信息的处理器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置登录成功处理器
http.formLogin().successHandler(customAuthenticationSuccessHandlers);
//将异常处理配置给security
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
}