SpringSecurity+JWT 其二: 登录授权

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();
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值