Spring Security 的前后端分离项目的权限方案,从0到0.8

目录

一,简介

二,SpringBoot项目中集成SpringSecurity

三,自定义扩展和修改以满足定制需求

1,思路分析

(1)前后端分离项目中一般的认证流程

(2)前后端分离项目中一般的鉴权流程

2,Spring Security的原理

(1)Spring Security默认认证过程和问题分析

(2)Spring Security默认鉴权流程和问题分析

3,修改默认流程满足实际项目的认证授权

(一)实现思路分析

(二) 代码实现

Ⅰ、创建用户表

Ⅱ,自定义登陆接口

Ⅲ,配置放行自定义的登陆接口 ,和其他配置

 Ⅳ,自定义实现UserDetailsService接口,重写loadUserByUsername方法

Ⅴ,自定义认证过滤器,重写doFilter()方法,处理携带我们的token的请求

四,权限校验的方法(同时测试前面的授权程序)

1,注解校验法

Ⅰ,使用方法

Ⅱ,执行原理

Ⅲ,常用注解

2,配置校验法

Ⅰ,使用方法

 Ⅱ,鉴权方法

3,自定义权限校验法

五,自定义失败处理

1,思路分析

2,实现认证失败处理接口

3,实现授权失败处理接口

4,将实现的类在configure中配置

六,其他配置

1,退出登陆接口 

2,跨域

3,CSRF


首先,先解释一下文章的标题,这里为什么说”前后端分离项目的权限方案“?因为使用Spring Security作为项目的认证授权方案时,对于单体应用、前后端分离项目和微服务项目三种不同性质的项目,其具体的Spring Security认证授权方案实现方式有较大的差别。

如今的项目基本都采用前后端分离的方式开发,这里我就优先学习了一下前后端分离项目使用Spring Security作为权限方案的实现。微服务的项目也是非常的流行,其使用Spring Security的权限方案在后期再说。

前后端分离

  • 前后端分离不仅只是一种开发模式,也是一种架构模式,前后端分离已成为互联网项目开发的业界标准使用方式;
  • 前端HTML页面通过AJAX调用后端的RESTFUL API接口并使用JSON数据进行交互;
  • 前端只需要关注页面的样式与动态数据的解析及渲染,而后端专注于具体业务逻辑。

详细内容请参考这篇我觉得很好的文章(上图也来自于下文):前后端分离架构:Web 实现前后端分离,前后端解耦 - SegmentFault 思否​​​​​​

一,简介

Spring Security是Spring技术栈中的安全管理框架

Spring Security是一个专注于为Java应用程序提供身份验证授权的框架。

Spring Security是一个强大的、高度可定制的身份验证访问控制框架。它是保护基于spring的应用程序的事实标准。

Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。

与所有Spring项目一样,Spring Security的真正强大之处在于它可以很容易地扩展以满足定制需求。

安全管理的两大核心区域:认证和授权

  • 认证:一般要求用户提供的用户名和密码,验证某个用户是否是系统的合法用户,是否能访问系统,并确认具体是哪个用户;
  • 授权:经过认证后,验证当前用户是否有访问某个页面或执行某个操作的权限;
  • 认证和授权也是Spring Security的重要核心功能。(从后面可以看出,整个基于Spring Security的权限方案就是在这两条线完成的。

其他安全管理框架

  • Shiro,Apache旗下的轻量级权限控制框架;
  • 功能没有Spring Security强大,但在SSM中整合比较简单;

二,SpringBoot项目中集成SpringSecurity

自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了自动化配置方案,可以使用更少的配置来使用 Spring Security,于是现在也更流行和推荐使用Spring Boot+Spring Security的组合。

这里,我们在SpringBoot项目中集成SpringSecurity安全管理框架。

①在pom文件中引入Spring Security的依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

即可在Springboot中使用SpringSecurity框架。因为 pring Boot 对于 Spring Security 提供了自动化配置方案,尽管这时候的Spring Security并不完全是我们项目中想用的样子,但我们只需要自定义扩展一些东西以满足定制需求。

②访问接口测试

 这时候我们在浏览器中访问接口时,就会自动跳转到Spring Security的默认登录页面,要求输入用户名密码。

Spring Security默认的用户名为:uer,默认的密码在控制台中打印:

 输入用户名密码后就得到请求的返回信息:

 问题

从这里可以看出,这有两个问题:

  • 默认的登录页面,实际项目中我们要用自己的登录页面,而不是Spring Security的;
  • 默认的用户名密码,实际项目中我们应该去和数据库中的用户名密码进行比对,而不是Spring Security默认的用户名和后台生成的密码。

所以我们后面的扩展和修改也是应该基于这两点进行,实际也是这么做的!

三,自定义扩展和修改以满足定制需求

1,思路分析

①通过前面的分析,我们发现Spring Boot集成了默认的Spring Security之后虽然已经具备了认证授权的功能,但存在两个在实际项目中不适用的问题:

  • 默认的登录页面
  • 默认的用户名密码

②同时,前面我们也说一般web应用的安全管理都是通过两条线完成的,Spring Security也是:

  • 认证
  • 授权

③此外,我们结合前后端分离项目中常用的认证授权流程

(1)前后端分离项目中一般的认证流程

再次强调:认证,一般要求用户提供的用户名和密码,验证某个用户是否是系统的合法用户,是否能访问系统,并确认具体是哪个用户;

(2)前后端分离项目中一般的鉴权流程

再次强调:授权,经过认证后,验证当前用户是否有访问某个页面或执行某个操作的权限;

所以,这里我们通过两条线——认证、授权为行动路线,以要修改的两个问题——默认的登录页面、默认的用户名密码为行动目标,以目前前后端分离项目中常用的认证授权流程为行动方案,来扩展和修改Spring Security以满足实际项目中的需求。

此时,我们已经有了基本的路线、目标、方案,在真正扩展和修改Spring Security满足实际项目中的需求之前,我们需要先弄清楚Spring Security本来的运行流程,也就是我们在Spring Boot引入Spring Security什么都不动之前的执行流程。

2,Spring Security的原理

SpringSecurity采用的是责任链设计模式, 本质是一个过滤器链。因为它们都被Spring IOC容器管理着,我们可通过查看容器中的内容的方式看一看这些这个过滤器链。

 责任链模式

  • 责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。
  • 在这种模式中,通常每个接收者都包含对另一个接收者的引用,而连接起来形成一条链。
  • 请求在这个链上传递,直到链上的某一个对象决定处理此请求。
  • 如果一个对象不能处理该请求,那么它会把相同的请求传给下一个接收者,依此类推。

由图可以看出,这个过滤器链是由16个过滤器组成的。

根据前面的思路分析,我们要修改默认的登录页面、默认的用户名密码等,这里我们就只介绍会牵涉到的过滤器,其中扩展或修改需要用到的核心过滤器加粗处理:

CsrfFilter:用于处理跨站请求伪造。

LogoutFilter:用于处理退出登录。

UsernamePasswordAuthenticationFilter :处理填写了用户名密码的登陆请求——从表单中获取用户名密码,并进行身份验证。

DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个默认的登录表单页面。

ExceptionTranslationFilter :异常过滤器,捕获过滤器链抛出的异常并进行处理,主要处理AccessDeniedException 和AuthenticationException 异常。

FilterSecurityInterceptor:根据资源权限配置来判断当前请求是否有权限访问对应的资源,如果访问受限会抛出相关异常,并由ExceptionTranslationFilter 过滤器进行捕获和处理。是过滤器链的最后一个过滤器,是过滤器链的出口。

(1)Spring Security默认认证过程和问题分析

认证流程是在 UsernamePasswordAuthenticationFilter 过滤器中处理的,具体流程如下:

  相关接口

  • Authentication接口:实现类用于存储用户认证信息;
  • AuthenticationManager接口:认证相关的核心接口,也是认证的入口,定义了认证Authentication()方法;
  • UserDetailsService接口:加载用户特定数据的核心接口。
  • UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

问题分析:

从上面的Spring Security默认的认证流程图,并结合思路分析,可以发现,为了存在两个在实际项目中不适用的问题(默认的登陆页面、默认的用户名密码)这里需要修改Spring Secutiry默认认证流程的三个地方:

(一)不能在使用UsernamePasswordAuthenticationFilter重写的attemptAuthentication()方法,但需要调用认证的入口AuthenticationManager接口的Authentication()方法。

在实际开发中,可能有多种不同的认证方式,例如:用户名+ 密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是AuthenticationManager。

(二)不能在使用InMemoryUserDetailsManager的loadUserByUsername()方法从内存中查找用户和权限信息。而是要修改成从数据库中。但最后要返回UserDetails对象给上一级。

(三)不能在使用UsernamePasswordAuthenticationFilter过滤器从表单中获取用户名和密码进行校验了。同样需要将用户信息存入SecurityContext中。

tips:从三行红字可以看出,在修改过滤器链中的过滤器时,方法之间的接口(方法调用、返回值类型)没有变(而且一定不能变),只是重新实现了方法的逻辑来实现我们的需要。

(2)Spring Security默认鉴权流程和问题分析

从前面Spring Security的原理的分析中可知,Spring Security在过滤器链中是通过FilterSecurityInterceptor过滤器进行权限授权的;

在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息;

当然SecurityContextHolder中的Authentication权限信息是在前面的过滤器和配置中添加进去的;

根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器处理。

问题分析:

从上面对Spring Security默认鉴权流程的分析可知,流程好像没啥问题,我们只需要把在FilterSecurityInterceptor过滤器鉴权之前将当前登录用户的权限信息存入SecurityContextHolder中的Authentication中。

3,修改默认流程满足实际项目的认证授权

(一)实现思路分析

在对Spring Security默认认证过程的分析中,已经得出了修改Spring Secutiry默认认证流程的两个地方,再结合前后端分离项目中一般的认证流程,(整个思路分析是来源于三者的结合)总结实现思路如下:

①自定义登陆接口

        调用AuthenticationManager接口的Authentication()方法进行认证,如果认证通过就生成jwt

        把用户信息存入redis(减小数据库压力,没有这一步会使每次请求校验用户信息时都访问一次数据库)

        把jwt返回给前端保存(之后前端的每次请求都在请求头中携带token)

②配置放行自定义的登陆接口

③自定义实现UserDetailsService接口,重写loadUserByUsername方法

        从数据库中根据用户名查询用户信息

        将用户信息封装成UserDetails类型返回

④自定义认证过滤器,重写doFilter()方法,处理携带我们的token的请求(而不是默认登陆页面的用户名密码)

       登陆后,当请求来时,获取token,并解析获取用户id

        从redis中获取UserDetails类型的用户信息

        将用户信息封装成Authentication对象

        将Authentication对象存入SecurityContextHolder

于是,整个流程就变成了,调用我们自定义的登陆接口输入用户名密码,传入AuthenticationManager接口的Authentication()方法便进入了Spring Security的过滤器链,其中某个过程使用了我们自定义的自定义实现UserDetailsService接口从数据库中读入了用户信息,当其他请求携带者token到来时,使用我们自定的认证过滤器进行校验。

(二) 代码实现

整个代码实现和思路分析一一对应!

Ⅰ、创建用户表

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',,
  `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=14787164048664 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

 创建RBAC权限模型数据表

CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `role_name` varchar(30) NOT NULL COMMENT '角色名称',
  `role_key` varchar(100) NOT NULL COMMENT '角色权限字符串',
  `role_sort` int(4) NOT NULL COMMENT '显示顺序',
  `status` char(1) NOT NULL COMMENT '角色状态(0正常 1停用)',
  `del_flag` char(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=13 DEFAULT CHARSET=utf8 COMMENT='角色信息表';

CREATE TABLE `sys_user_role` (
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `role_id` bigint(20) NOT NULL COMMENT '角色ID',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户和角色关联表';

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
  `menu_name` varchar(50) NOT NULL COMMENT '菜单名称',
  `parent_id` bigint(20) DEFAULT '0' COMMENT '父菜单ID',
  `order_num` int(4) DEFAULT '0' COMMENT '显示顺序',
  `path` varchar(200) DEFAULT '' COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `is_frame` int(1) DEFAULT '1' COMMENT '是否为外链(0是 1否)',
  `menu_type` char(1) DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)',
  `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` char(1) DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2029 DEFAULT CHARSET=utf8 COMMENT='菜单权限表';

CREATE TABLE `sys_role_menu` (
  `role_id` bigint(20) NOT NULL COMMENT '角色ID',
  `menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色和菜单关联表';

 可参考(上图也来自于下文):(5条消息) RBAC权限模型_richest_qi的博客-CSDN博客_rbac权限模型

Ⅱ,自定义登陆接口

  • controller:
    @RestController
    public class LoginController {
    
        @Autowired
        LoginService loginService;
        /**
         * 登陆接口
         * @return token,返回给前端的jwt作为token
         */
        @PostMapping("login")
        public ResponseResult login(@RequestBody User user){
            if(Objects.isNull(user)){
                return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_ERROR);
            }
            if(!StringUtils.hasText(user.getUserName())){
                return ResponseResult.errorResult(AppHttpCodeEnum.USERNAME_IS_NULL);
            }
            return loginService.login(user);
        }
    }
  • service
    @Service
    public class LoginServiceImpl implements LoginService {
    
        //①
        @Autowired
        AuthenticationManager authenticationManager;
    
        @Autowired
        RedisCache redisCache;
    
        @Override
        public ResponseResult login(User user) {
    
            //②调用AuthenticationManager接口的Authentication()方法进行认证
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
            Authentication authenticate = authenticationManager.authenticate(token);
    
            //③如果认证不通过
            if(Objects.isNull(authenticate)){
                return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_ERROR);
            }
            //④
            LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
            //如果认证通过就生成jwt
            String jwt = JwtUtil.createJWT(loginUser.getUser().getId().toString());
    
            //⑤把用户信息存入redis(减小数据库压力,没有这一步会使每次请求校验用户信息时都访问一次数据库)
            redisCache.setCacheObject(SystemConstants.LOGIN_KEY+loginUser.getUser().getId(),loginUser);
    
            //把jwt返回给前端保存(之后前端的每次请求都在请求头中携带token)
            Map<String,String> map = new HashMap<>();
            map.put("token",jwt);
            return ResponseResult.okResult(map);
        }
    }

代码分析:

①因为要通过AuthenticationManager的authenticate()方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器,这里就直接自动装配。

②处:调用认证的入口AuthenticationManager接口的authentication()方法,校验传入的用户名密码信息是否与在系统中注册的相同。(从pring Security默认认证过程可知,这个方法调用了UserDetailsService的loadUserByUsername()方法,当然后面我们要重写这个方法去加载数据库中的用户名密码

③处:从authentication()的逻辑可以看出,用户名密码校验失败就会返回空指针,这里判断返回值是否为null,判断校验的状态。

④处:getPrincipal()返回被验证的主体或验证后被验证的主体。这返回的是验证通过的用户信息。

权限管理中的相关概念

  • 主体,principal:使用系统的用户。谁使用系统谁就是主体。
  • 认证,authentication:权限管理系统确认一个主体的身份,允许主体进入系统,证明“主体“是谁(是否属于系统、和其他个人信息——权限等)。
  • 授权,authorization:将使用系统的”权力“授权给主体,给用户分配它具有的权限。

⑤处:验证通过后把以用户id为key把用户信息存入redis, 说明该用户已经登陆,而且后期需要使用用户信息时直接从redis读取而不是数据库。 

总之,在这一段程序中,认证了传入的用户名密码信息生成jwt作为token返回给前端

Ⅲ,配置放行自定义的登陆接口 ,和其他配置

  • SecurityConfig
    @Configuration
    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("/login").anonymous()
                    // ②后台系统的其他接口都需要认证才能访问
                    .anyRequest().authenticated();
    
        }
    
        //③
        @Bean
        @Override
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        //④
        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    }

代码分析:

①②处: 允许登陆接口匿名访问,因为访问登录接口的时候还没有token,同时其他接口懂需要认证才能访问。

③处:因为要通过AuthenticationManager的authenticate()方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器;从源码注释可以看出这是暴露AuthenticationManager对象的一种方式。

④处: 替换PasswordEncoder加密方式,使用SpringSecurity提供的BCryptPasswordEncoder加密方式,只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。

从Spring Security默认认证过程可以看出,Spring Security会使用PasswordEncoder加密方式——Ⅳ通过PasswordEncoder中的matches()方法比对UserDetails对象和Authentication对象中的密码是否相同。

实际项目中我们不会把密码明文存储在数据库中。

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。 

 Ⅳ,自定义实现UserDetailsService接口,重写loadUserByUsername方法

  • UserDetailsService接口的实现类
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        @Autowired
        LoginMapper loginMapper;
    
        //自定义实现UserDetailsService接口,重写loadUserByUsername方法
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            //从数据库中根据用户名查询用户信息
            User user = loginMapper.selectUserByUserName(username);
            if(Objects.isNull(user)){
                throw new UsernameNotFoundException("用户名不存在");
            }
              //从数据库中查询给用户的权限信息
            List<String> permissions= loginMapper.selectPermissionByUserId(user.getId());
            //将用户信息和权限封装成UserDetails类型返回
            return new LoginUser(user,permissions);
        }
    }

前面的AuthenticationManager的authenticate()方法进行用户认证时,会查询在系统中注册过的用户信息来与请求传入的用户名密码比对,和权限信息用于授权,在查询系统中注册的用户信息时会通过调用UserDetailsService接口的loadUserByUsername()方法,这里我们重写这个方法使其从我们的数据库查询这个用户信息和权限信息,我们重写的方法会在AuthenticationManager的authenticate()方法中调用。

其中loginMapper中的selectUserByUserName()和selectPermissionByUserId()方法分别从数据库中查询用户信息和权限信息。

  • UserDetails的实现类
    
    @Data
    @AllArgsConstructor
    public class LoginUser implements UserDetails {
    
        private User user;
    
        //存储权限信息
        private List<String> permissions;
    
        //存储SpringSecurity所需要的权限信息的集合
        private List<GrantedAuthority> authorities;
    
        public LoginUser(User user, List<String> permissions) {
            this.user=user;
            this.permissions=permissions;
        }
    
        /**
         * Returns the authorities granted to the user. Cannot return <code>null</code>.
         *
         * @return the authorities, sorted by natural key (never <code>null</code>)
         */
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            //如果权限集合已经存在直接返回
            if(!Objects.isNull(authorities)){
                return authorities;
            }
            //把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
            authorities = permissions.stream().filter(StringUtils::hasText).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    
            return authorities;
        }
    
        /**
         * Returns the password used to authenticate the user.
         *
         * @return the password
         */
        @Override
        public String getPassword() {
            return user.getPassword();
        }
    
        /**
         * Returns the username used to authenticate the user. Cannot return
         * <code>null</code>.
         *
         * @return the username (never <code>null</code>)
         */
        @Override
        public String getUsername() {
            return user.getUserName();
        }
    
        /**
         * Indicates whether the user's account has expired. An expired account cannot be
         * authenticated.
         *
         * @return <code>true</code> if the user's account is valid (ie non-expired),
         * <code>false</code> if no longer valid (ie expired)
         */
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        /**
         * Indicates whether the user is locked or unlocked. A locked user cannot be
         * authenticated.
         *
         * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
         */
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        /**
         * Indicates whether the user's credentials (password) has expired. Expired
         * credentials prevent authentication.
         *
         * @return <code>true</code> if the user's credentials are valid (ie non-expired),
         * <code>false</code> if no longer valid (ie expired)
         */
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        /**
         * Indicates whether the user is enabled or disabled. A disabled user cannot be
         * authenticated.
         *
         * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
         */
        @Override
        public boolean isEnabled() {
            return true;
        }
    }

从Spring Security默认认证过程可以看出,因为UserDetailsService接口的实现类的loadUserByUsername()方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,并把用户信息封装在其中。 

之后的过滤器会通过调用UserDetails的getUsername()、getPassword()、getAuthorities()方法获取用户的用户名、密码、权限信息进行验证,这里我们需要重写这些方法返回我们从数据库查到的信息;

同理,之后的过滤器会通过调用UserDetails的is方法判断用户的状态,同样需要重写这些方法,返回我们从数据库查到的信息(这里省略了,全返回true了)。

至此,从数据库查询的用户信息和权限信息已经封装到UserDetails类中了,对于权限的授权,后续的FilterSecurityInterceptor过滤器直接使用该权限信息进行授权了。

Ⅴ,自定义认证过滤器,重写doFilter()方法,处理携带我们的token的请求

  • 自定义认证过滤器
    //自定义认证过滤器,重写doFilter()方法,处理携带我们的token的请求(而不是默认登陆页面的用户名密码)
    @Component
    public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    
        @Autowired
        RedisCache redisCache;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            //①登陆后,当请求来时,获取token,并解析获取用户id
            String token = request.getHeader("token");
            //②如果前端请求没有携带token直接放行(调用下一个过滤器)
            if(!StringUtils.hasText(token)){
                filterChain.doFilter(request,response);
                return;
            }
            String userId = null;
            try {
                //③
                Claims claims = JwtUtil.parseJWT(token);
                userId = claims.getSubject();
            } catch (Exception e) {
                throw new RuntimeException("请重新登陆!");
            }
    
            //④从redis中获取UserDetails类型的用户信息
            LoginUser loginUser = redisCache.getCacheObject(SystemConstants.LOGIN_KEY + userId);
            //⑤如果redis中没有登陆信息
            if(Objects.isNull(loginUser)){
                throw new RuntimeException("请登录");
            }
    
            //⑥将用户信息封装成Authentication对象
            UsernamePasswordAuthenticationToken authenticated =new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            //将Authentication对象存入SecurityContextHolder
            SecurityContextHolder.getContext().setAuthentication(authenticated);
    
            //⑦进入下一个过滤器
            filterChain.doFilter(request,response);
        }
    }

代码分析:

①处:需要认证的请求都需要在请求头中携带token,在代码中获取请求中的token,并用此token判断是否登陆;

②处:如果没有获取到token,说明请求不用认证,我们处理token的过滤器直接放行该请求;

③处:这里将从jwt中获取用户id,用以判断是谁在发起请求,前面在通过用户名密码登陆认证成功后,将用户id和用户信息存入了redis,并将用户id做成jwt作为token返回给前端了;如果从jwt获取用户id失败,说明这个token的格式有问题,不符合jwt格式;

④处:通过用户id从redis获取用户信息;

⑤处:如果没有获取到用户信息,说明没有登陆,因为前面登陆认证的时候已经存过用户信息到redis了,如果没获取到要么就是token是假的要么就是token过期了;

⑥处: 过滤器认证成功之后,要在认证成功的处理方法中将已认证的用户信息和权限信息对象 Authentication 封装进SecurityContext,并存入 SecurityContextHolder,因为后续的过滤器都将需要从SecurityContextHolder中的SecurityContext中寻找Authentication用户信息;

⑦处:这个过滤器执行完以后,调用过滤器链的doFilter()方法,开始进入下个过滤器执行。

  • SecurityConfig
    @Configuration
    public class SecurityConfig  extends WebSecurityConfigurerAdapter {
    
        @Autowired
        JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    //关闭csrf
                    .csrf().disable()
                    //不通过Session获取SecurityContext
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeRequests()
                    // 对于登录接口 允许匿名访问
                    .antMatchers("/login").anonymous()
                    // 后台系统的其他接口都需要认证才能访问
                    .anyRequest().authenticated();
    
            //①
            //把我们自定义的JwtAuthenticationTokenFilter过滤器加到SpringSecurity的过滤器链中
            //我们自定义的过滤器在UsernamePasswordAuthenticationFilter前执行,
            //我们的过滤器直接调用了AutheticationManager的方法,而UsernamePasswordAuthenticationFilter没有被调用执行
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    
        }
    
        @Bean
        @Override
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    }

代码分析:

①处: 

我们自定义了过滤器,但是要想让Spring Security用上我们的过滤器,就需要让我们的过滤器加入到Spring Security的过滤器链上;

http.addFilterBefore()方法是把一个过滤器加入到另一个过滤器前;

这里,把我们自定义的JwtAuthenticationTokenFilter过滤器加到SpringSecurity的过滤器链中的UsernamePasswordAuthenticationFilter前;

我们自定义的过滤器在UsernamePasswordAuthenticationFilter前执行,而我们的过滤器直接调用了AutheticationManager的方法,所以UsernamePasswordAuthenticationFilter没有被调用执行。

进而实现了使用自定义的过滤器替代了UsernamePasswordAuthenticationFilter。

至此,已经实现了登陆校验前端传入的用户名密码并与已在系统注册(存在数据库中)的用户信息是否匹配。

此时,在看Spring Security过滤器链:

四,权限校验的方法(同时测试前面的授权程序)

1,注解校验法

Ⅰ,使用方法

①使用注解先要开启注解功能

@EnableGlobalMethodSecurity(prePostEnabled = true)

② 在控制器方法上添加注解

Ⅱ,执行原理

@PreAuthorize注解在进入方法前进行权限验证;

@PreAuthorize 调用传入的方法hasAuthority()方法;

传入的hasAuthority(“test“)执行到了SecurityExpressionRoot的hasAuthority()方法;

SecurityExpressionRoot的hasAuthority()方法的内部调用Authentication对象的getAuthorities()方法获取从数据库读入的用户的权限列表,并将传入的”test"权限与用户权限列表比较,如果“test"用户权限列表则表示有权限访问。

可传入注解的方法:

  • hasAuthority:如果当前用户的权限列表中有该方法要求的权限时(该方法只能指定一种权限),允许当前用户访问接口。
  • hasAnyAuthority:如果当前用户的权限列表中有该方法要求的任意一种权限时(该方法能指定多种权限,传入权限集合),允许当前用户访问接口。
  • hasRole:如果当前用户的角色有该方法要求的角色时,允许当前用户访问接口。(该方法内部会把我们传入到该方法的参数拼接上ROLE_后再去比较,所以这种情况下要用用户对应的权限也要有ROLE_这个前缀才可以)
  • hasAnyRole:如果当前用户的角色有该方法要求的任意一种角色时,允许当前用户访问接口。(该方法内部会把我们传入到该方法的参数拼接上ROLE_后再去比较,所以这种情况下要用用户对应的权限也要有ROLE_这个前缀才可以)

Ⅲ,常用注解

@Secured:判断是否具有角色

//开启注解功能
@EnableGlobalMethodSecurity(securedEnabled=true)

//在接口方法上添加注解,可传入多个
@Secured({"admin", "user"}) 

@PostAuthorize:在方法执行后再进行权限验证,适合验证带有返回值的权限

//开启注解
@EnableGlobalMethodSecurity(prePostEnabled = true)

//在接口方法上添加注解
@PostAuthorize("hasAnyAuthority('menu:system')") 

其中,该注解可传入的方法同 @PreAuthorize。

@PostFilter:权限验证之后对数据进行过滤

@PreFilter:进入控制器之前对数据进行过滤

2,配置校验法

在配置类中使用使用配置的方式对资源进行权限控制。

Ⅰ,使用方法

 只需要在配置类中使用这行代码就行了,指定鉴权方法(hasAuthority())并传入需要指定的权限。

 Ⅱ,鉴权方法

  • hasAuthority:如果当前用户的权限列表中有该方法要求的权限时(该方法只能指定一种权限),允许当前用户访问接口。
  • hasAnyAuthority:如果当前用户的权限列表中有该方法要求的任意一种权限时(该方法能指定多种权限,传入权限集合),允许当前用户访问接口。
  • hasRole:如果当前用户的角色有该方法要求的角色时,允许当前用户访问接口。(该方法内部会把我们传入到该方法的参数拼接上ROLE_后再去比较,所以这种情况下要用用户对应的权限也要有ROLE_这个前缀才可以)
  • hasAnyRole:如果当前用户的角色有该方法要求的任意一种角色时,允许当前用户访问接口。(该方法内部会把我们传入到该方法的参数拼接上ROLE_后再去比较,所以这种情况下要用用户对应的权限也要有ROLE_这个前缀才可以)

3,自定义权限校验法

Ⅰ,使用方法

①自定义权限校验方法

@Component("ex")
public class MyExpressionRoot {

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

② 调用自定义的hasAuthority()方法

  在SPEL表达式中使用 @ex相当于获取容器中bean的名字为ex的对象。并通过.调用这个对象的方法。

五,自定义失败处理

从测试的过程中来看,现在的认证失败或授权失败时,接口的返回值还是Spring Security默认的返回信息,在实际项目中,往往我们希望指定失败时候的返回信息为我们想要的格式。

1,思路分析

前面我们提到过,在Spring Security中,去过认证或者授权失败会抛出异常信息,ExceptionTranslationFilter过滤器会捕获这些异常,并判断根据异常类型进行显影处理;

如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理;

如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理;

所以,思路分析:

我们实现AuthenticationEntryPoint接口AccessDeniedHandler接口的方法,自定义处理异常;

将实现的类,配置给Spring Security。

不知道你有没有发现:

前面我们说:与所有Spring项目一样,Spring Security的真正强大之处在于它可以很容易地扩展以满足定制需求。不知道你有没有发现,我们对Spring Security默认修改的过程中,一直在做两步:1,重写代码;2,配置

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(), "认证失败请重新登录");
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}

3,实现授权失败处理接口

@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(), "权限不足");
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}

4,将实现的类在configure中配置

六,其他配置

1,退出登陆接口 

现在的Spring Security过滤器链中,仍然是默认的用于处理退出登录的过滤器LogoutFilter,所以在推出时,扔使用的是默认的退出接口和逻辑。

我们需要重新写退出接口,并把默认的LogoutFilter过滤器从过滤器链中排除。 

  • controller
        @PostMapping("logout")
        public ResponseResult logout(){
            return loginService.logout();
        }
  •  service
        @Override
        public ResponseResult logout() {
            //从上下文中获取用户id
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            LoginUser loginUser = (LoginUser)authentication.getPrincipal();
            Long userId = loginUser.getUser().getId();
            //删除redis存入的登陆信息
            redisCache.deleteObject(SystemConstants.LOGIN_KEY+userId);
            return ResponseResult.okResult(200,"注销成功");
        }

 这里,直接从token中获取用户id(所以退出登陆接口必须配置为需要认证才可访问),并删除redis中的用户信息,因为前面设计的逻辑是如果redis没有用户信息就表示没有登陆或登陆过期。

  •  configure中
    http.logout().disable();

 关闭spirng security默认的注销功能,让自定义的logout接口生效,因为二者接口路径设置的相同。

此时,调用logout接口就会删除redis已经缓存的用户信息。

2,跨域

浏览器出于安全的考虑,使用 XMLHttpRequest对象发起的 HTTP请求必须遵守同源策略,否则就是跨域的HTTP请求;

同源策略(Same origin policy)是一种约定,它是浏览器最核心也最基本的安全功能;

同源是指: 协议、域名、端口都相同,就是同源, 否则就是跨域。

前后端分离项目,前端项目和后端项目一般都不是同源的,所以会存在跨域请求的问题;

我们所有的接口都会通过SpringSecurity,所以需要SpringSecurity允许跨域请求;

这里要允许SpringBoot和SpringSecurity的跨域请求,使前端能够访问后端的接口。

  • 配置Spingboot允许跨域请求
    @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);
        }
    }
  •  在configure中配置开启SpringSecurity的跨域访问
    http.cors();

 这时候SpringSecurity过滤器链中会增加一个CorsFilter过滤器来解决跨域问题

3,CSRF

跨站请求伪造,Cross-site request forgery是一种冒充用户操作在当前已登录的 Web 应用程序上执行一些操作的攻击方法。CSRF 利用的是网站对用户网页浏览器的信任。

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个用户自己曾经认证过的网站(认证之后认证信息保存在Cookie中)并运行一些操作(如转账)。由于浏览器曾经认证过(Cookie中又认证信息,浏览器发起请求时会自动携带),所以被访问的网站会认为是真正的用户操作而去运行。

这利用了web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。

Spring Security 4.0 开始,默认情况下会启用CSRF 保护,以防止CSRF 攻击应用程序,Spring Security CSRF 会针对 PATCHPOSTPUT DELETE 方法进行防护。

SpringSecurity通过后端生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器(CsrfFilter过滤器)进行校验,如果没有携带或者是伪造的就不允许访问。

我们发现,SpringSecurity防止跨站请求攻击的方式就是通过token实现,而在我们的Spring Security的实现方案中,使用的就是token,前端请求的时候必须携带token,所以现在的方案中天然不怕SCRF攻击。

而,为了提高系统的效率,我们更建议关闭Spring Security的CSRF保护功能,只需要在configure配置中:

http.csrf().disable();

即可关闭Spring Security的CSRF功能,这时,Spring Security的过滤器链中也就没有了CsrfFilter过滤器。

至此,SpringSecurity的最小可用的自定义满足需求就完成了,当然,气有浩然,学无止境!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值