基于Springboot3的权限系统设计2

上篇地址

基于Springboot3的权限系统设计上篇 - 掘金 (juejin.cn)

RBAC权限模型

RBAC权限模型是开发中常用的,相对简单的权限模型。

具体做法是将权限系统分成以下3张主表

  1. 用户表
  2. 角色表
  3. 权限表

其中用户表存储不同的用户信息,而角色表则设计不同的权限角色,他们之间是多对多的关系。而权限表则设置具体的权限,他们之间也是多对多关系。

然后鉴于上面3张表之间的关系,我们需要另外设置2张表以连接他们

  1. 用户角色表,负责存储用户id以及对应的角色id

  2. 角色权限表,负责存储角色id以及对应的权限id

建表SQL

用户表我们在上面已经建成,所以我们只需要考虑剩下2张表以及他们之间的连接表即可。

权限表

sql 复制代码
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='菜单表';

该表字段取自开源项目若依,其中设置了一些前端需要使用的路由转跳项以及其他字段,我们只需要关注我们今天会用到的perms字段即可,我们用这个字段设置具体的权限

角色表

sql 复制代码
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='角色表';

用户表

为了方便阅读,我再贴一下角色表的建表SQL

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 '用户类型(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='用户表';

用户角色关联表

sql 复制代码
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;

角色权限表

sql 复制代码
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;

添加数据

我们来设计一个健身房体系,首先对于角色,我们有

  1. 健身房老板
  2. 健身房教练
  3. 健身房会员

对于权限,我们简单设计以下几个

  1. 添加会员
  2. 添加器材
  3. 移动器材
  4. 使用器材

对于用户我们添加

  1. 范马某次郎
  2. 强人锁男van
  3. 鹿某
  4. 坤坤

密码我这里统一采用和账号一致,加密用测试跑一下

swift 复制代码
@Autowired
PasswordEncoder passwordEncoder;
@Test
void testMysql() throws Exception {
    String u1 = passwordEncoder.encode("范马某次郎");
    System.out.println("范马某次郎   "+u1+"\n~~~~~~~~~~~~~~~~~~~~~~~~");

    String u12 = passwordEncoder.encode("强人锁男van");
    System.out.println("强人锁男van   "+u12+"\n~~~~~~~~~~~~~~~~~~~~~~~~");

    String u13 = passwordEncoder.encode("鹿某");
    System.out.println("鹿某   "+u13+"\n~~~~~~~~~~~~~~~~~~~~~~~~");

    String u14 = passwordEncoder.encode("坤坤");
    System.out.println("坤坤   "+u14+"\n~~~~~~~~~~~~~~~~~~~~~~~~");
}

范马某次郎   $2a$10$bmyjx4s2sqHZk6EmcN.bLOjD.jOO6OqG5ndicW6mjhnCpLZdSV.A6
~~~~~~~~~~~~~~~~~~~~~~~~
强人锁男van   $2a$10$xzYz0sGvao52A7YlNxU1NeA6xLDCFPArUNzWjK.ZUCavHSA2km.si
~~~~~~~~~~~~~~~~~~~~~~~~
鹿某   $2a$10$SiM0qRlS4hhrIVGpPmy37.MoI4o746s.wxrRoeXybn1dqk5pvssvG
~~~~~~~~~~~~~~~~~~~~~~~~
坤坤   $2a$10$umh3UhWeS9j.zmDpZ27TE.o493.ZdoHNf9L.dXzEaAQdMfqnACBTK
~~~~~~~~~~~~~~~~~~~~~~~~

组合权限关系

我们为范马某次郎 添加健身房老板 角色。 也就是在user_role表中为id为3的用户添加角色id 1001

我们为健身房老板角色添加所有权限 ,也就是在role_menu表中为id为1001的角色添加所有权限


其实到这里,大家基本上也就搞清楚了权限系统,剩下的给会员以及教练添加权限的我就不再演示了,会员只有一个使用权限,教练比会员多一个移动器材权限

整合进Springboot

准备工作

我们先写3个Handler方法,对应不同的权限

kotlin 复制代码
@GetMapping("/add-user")
@PreAuthorize("hasAuthority('add_user')")
public MyResult addUser(){
    return MyResult.OK().message("添加会员成功");
}

@GetMapping("/move-gym-equipment")
@PreAuthorize("hasAuthority('move_gym_equipment')")
public MyResult moveGymEquipment(){
    return MyResult.OK().message("移动器材成功");
}

@GetMapping("/use-gym-equipment")
@PreAuthorize("hasAuthority('use_gym_equipment')")
public MyResult useGymEquipment(){
    return MyResult.OK().message("使用器材成功");
}

下一步我们生成去数据库查权限需要的Mapper跟对应的xml文件

less 复制代码
public interface permissionMapper {

    ArrayList<String> getPermissions(@Param("userId") Long userId);
}

我们这里直接用ArrayList接收就行了,传入一个id。

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.zhiqi.springsecurity.mapper.permissionMapper">

    <select id="getPermissions" resultType="java.lang.String">
     </select>
</mapper>

实现

我们可以看一下之前做账号密码确认的UserCheck类

java 复制代码
@Component
public class CheckUser implements UserDetailsService {

    @Autowired
    UserMapper userMapper;

    //TODO 我们不能把存redis的工作放在这里,
    // 因为该类的本职工作应该是--->认证
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.getUserByName(username);
        Assert.notNull(user,"用户不存在!");

        user.setPermission(List.of("haha"));
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        user.getPermission().forEach(str -> {
             grantedAuthorities.add(new SimpleGrantedAuthority(str));
        });
        return new UserPro(user,grantedAuthorities);
    }
}

在这一步我们是可以拿到这个User对象的,也就说明,这个UserId我们也取得到。那剩下的就是写个SQL去数据库拿权限存入权限List就OK了。

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.zhiqi.springsecurity.mapper.permissionMapper">

    <select id="getPermissions" resultType="java.lang.String">
        SELECT perms
        FROM `sys_user_role` user_role
                 LEFT JOIN `sys_role` role ON role.id=user_role.role_id
                 LEFT JOIN `sys_role_menu` role_menu ON user_role.role_id=role_menu.role_id
                 LEFT JOIN `sys_menu` menu ON menu_id=menu.id
        WHERE user_role.user_id=#{userId}
          AND role.status = 0
    </select>
</mapper>

写一个多表联查查出指定用户的权限信息,多了一个and条件, 需要讲一下:role.status = 0是要求角色没有被禁用。

最后修改一下我们的查库校验类

java 复制代码
@Component
public class CheckUser implements UserDetailsService {

    @Autowired
    UserMapper userMapper;
    
    @Autowired
    PermissionMapper permissionMapper;

    //TODO 我们不能把存redis的工作放在这里,
    // 因为该类的本职工作应该是--->认证
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.getUserByName(username);
        Assert.notNull(user,"用户不存在!");
        //TODO 去MySQL查用户权限
        ArrayList<String> permissions = permissionMapper.getPermissions(user.getId());
        Assert.notEmpty(permissions,"尚未添加用户权限,请联系管理员!");
        //TODO 封装进User对象
        user.setPermission(permissions);
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        //TODO 创建符合指定格式的List并封装进UserPro
        permissions.forEach(str -> {
             grantedAuthorities.add(new SimpleGrantedAuthority(str));
        });
        return new UserPro(user,grantedAuthorities);
    }
}

问题与展望

到上面为止,我们就基本完成了我们的权限校验系统。如果代码没有出错,正常的校验是没有问题的,但即便是完成了基本的权限校验,但其实我们还有着一系列的问题没有处理。

大家可以尝试一下发送一个错误的请求,比如向服务器发送一个错误的账号和密码 。此时你会发现,我们的请求像泥牛入海一样没有一点响应。而我们理想的情况应该是即便有错误,也应该返回相应的反馈,在前后端分离的场景下,这样毫无响应的情况显然是不符合我们需求的。

异常处理

前言

其实在SpringSecurity中,为我们准备了相应的接口,可以让我们使用我们自定义的异常处理。其中我们主要使用以下2个接口

  1. AuthenticationEntryPoint
  2. AccessDeniedHandler

根据名字,我们就可以知道, AuthenticationEntryPoint负责账号密码的认证工作,而AccessDeniedHandler则处理权限认证工作。

我们通过实现它们并放入IOC,最后配进filterChain的配置类让其生效。


AccessDeniedHandler的使用

java 复制代码
@Component
public class AccessExceptionHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        String fail = JSONObject.toJSON(MyResult.FAIL().message("权限不足!")).toString();
        WebUtils.renderString(response,fail);
    }
}

在上面的处理函数中,我们定义了一个自己的错误信息 ,并让它转换成JSON返回 ,其实在这里,我们已经绕过了Controller层的处理方法,转而自己定义返回值 。也就意味着这里不会经过Springboot为Controller中Handler处理方法准备的JSON转换器 ,我们也必须自己想办法将返回值转换成JSON,而上图中的JSONObject.toJSON方法来自于Alibaba提供的JSON转换依赖,(该转换器目前是世界上最快的JSON转换器)。

xml 复制代码
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.67_noneautotype2</version>
</dependency>

WebUtils.renderString(response,fail);是一个自定义的工具类,它为我们的响应定义一些必要的信息,接收一个字符串,然后将它定义为请求体并返回。

(特别注明:工具类代码来自于B站三更草堂

typescript 复制代码
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;
        }


}

配入我们自己的大配置类MySpringSecurityConfigfilterChain配置中(需要在类中@AutoWired一下,这应该不用我教吧

scss 复制代码
http.exceptionHandling()
        .accessDeniedHandler(accessExceptionHandler);

此时我们配好了我们的登录验证相关的异常处理,让我们来试一下。


AuthenticationEntryPoint的使用

java 复制代码
@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String fail = JSONObject.toJSON(MyResult.FAIL().message("同户名或密码错误,请重新登录")).toString();
        WebUtils.renderString(response,fail);
    }
}

这个同样需要配置一下,我直接贴出完整的filterChain配置

scss 复制代码
@Autowired
AccessDeniedHandler accessExceptionHandler;
@Autowired
AuthenticationExceptionHandler authenticationExceptionHandler;

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    // 基于 token,不需要 csrf
    http
            .csrf().disable()
            // 开启跨域以便前端调用接口
            .cors()
            .and()
            .authorizeRequests()
            // 指定某些接口不需要通过验证即可访问。登录接口肯定是不需要认证的
            .requestMatchers("/user/login")
            .permitAll()
            // 这里意思是其它所有接口需要认证才能访问
            .anyRequest().authenticated()
            .and()
            // 基于 token,不需要 session
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            // cors security 解决方案
            ;
    http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    //TODO 放入异常处理类
    http.exceptionHandling()
            .accessDeniedHandler(accessExceptionHandler)
            .authenticationEntryPoint(authenticationExceptionHandler);
    return http.build();
}

测试

让我们测试一下,我们先以错误的用户名个密码登录

json 复制代码
{
    "stateId": 201,
    "state": "FAIL",
    "message": "同户名或密码错误,请重新登录"
}

正常,我们测试访问没有权限的路径。

json 复制代码
{
    "stateId": 201,
    "state": "FAIL",
    "message": "权限不足!"
}

也成功抓到了异常。

目前来讲我们似乎一切正常,我们再尝试一下使用空的token访问需要权限的接口

json 复制代码
{
    "stateId": 201,
    "state": "FAIL",
    "message": "同户名或密码错误,请重新登录"
}

这显然是不符合要求的,我们希望如果token为空,那么它就直接无权访问任何信息了,而非账号密码错误!而且明明是对权限的校验,为什么会给到账号验证的返回值?

其原因在于下面这行代码

ini 复制代码
//从请求头里面取token 
String token = request.getHeader("token"); 
//TODO 没有token则放行,由登录验证调用UserDetailsService去拦截 
if (token==null){ 
    filterChain.doFilter(request,response); 
    return; 
}

我们对于空的token进行了直接放行处理,所以当进入到后面的用户账户验证环节时就会报错,进而使用了账号验证的返回值。如何来对空token进行处理呢?

首先既然它返回了账号验证的错误信息,也就说明它一定进入了我们写的账号验证的异常处理类。

我们的异常处理类名为AuthenticationException,其实在SpringSecurity中为我们专门针对账号密码错误情况准备了异常类型 ,而且是AuthenticationException的子类,我们只需要对账号密码错误做专门的处理,其他类型错误直接404即可,处理方式如下:

typescript 复制代码
@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String fail = null;
        if (authException instanceof BadCredentialsException || authException instanceof UsernameNotFoundException){
            fail = JSONObject.toJSON(MyResult.FAIL().message("同户名或密码错误,请重新登录")).toString();
        } else {
            fail = JSONObject.toJSON(MyResult.FAIL().stateId(404).message("未知异常")).toString();
        }

        WebUtils.renderString(response,fail);
    }
}

其中BadCredentialsException是密码错误,而UsernameNotFoundException则是账号错误。

好了,问题解决。

我们尝试一下随便输入一个错误的账号登录一下。此时我们会发现控制台报错了! 这是因为我们下面这句代码造成的,我们在查数据库时抛出了一个非空的断言异常

ini 复制代码
User user = userMapper.getUserByName(username); 
Assert.notNull(user,"用户不存在!");

其原因显而易见,异常处理只能处理AuthenticationException类型的异常,而我们这里的代码查不到账号,肯定符合它的子类UsernameNotFoundException的情况了,我们改一下代码:

csharp 复制代码
if (user == null){
    throw new UsernameNotFoundException("用户不存在");
}

而这行代码下面的权限LIst合集为空异常,我们其实改成什么异常都不太好,而且即便我们在这里不处理,到下面

rust 复制代码
permissions.forEach(str -> {
     grantedAuthorities.add(new SimpleGrantedAuthority(str));
});

依旧会控制台报错,最终其实依旧会返回响应404的自定义异常。其实对于权限集为空的情况,多数是因为我们管理者在添加新用户时出了问题。所以我们比较优雅的处理方式为将错误原因打印到日志中而且对于很多需要内部处理的情况,也往往会使用这种处理方式

引入日志系统处理特殊情况

引入log4j2

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    <version>3.1.4</version>
</dependency>

简单配置log4j,资源文件夹下新建log4j2.xml 大家根据自己情况配置,指定日志存放位置

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <loggers>
        <!--
            level指定日志级别,从低到高的优先级:
                TRACE < DEBUG < INFO < WARN < ERROR < FATAL
                trace:追踪,是最低的日志级别,相当于追踪程序的执行
                debug:调试,一般在开发中,都将其设置为最低的日志级别
                info:信息,输出重要的信息,使用较多
                warn:警告,输出警告的信息
                error:错误,输出错误信息
                fatal:严重错误
        -->
        <root level="INFO">
            <appender-ref ref="spring6log"/>
            <appender-ref ref="RollingFile"/>
            <appender-ref ref="log"/>
        </root>
    </loggers>

    <appenders>
        <!--输出日志信息到控制台-->
        <console name="spring6log" target="SYSTEM_OUT">
            <!--控制日志输出的格式-->
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss SSS} [%t] %-3level %logger{1024} - %msg%n"/>
        </console>

        <!--文件会打印出所有信息,这个log每次运行程序会自动清空,由append属性决定,适合临时测试用-->
        <File name="log" fileName="d:/spring6_log/test.log" append="false">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %class{36} %L %M - %msg%xEx%n"/>
        </File>

        <!-- 这个会打印出所有的信息,
            每次大小超过size,
            则这size大小的日志会自动存入按年份-月份建立的文件夹下面并进行压缩,
            作为存档-->
        <RollingFile name="RollingFile" fileName="d:/spring6_log/app.log"
                     filePattern="log/$${date:yyyy-MM}/app-%d{MM-dd-yyyy}-%i.log.gz">
            <PatternLayout pattern="%d{yyyy-MM-dd 'at' HH:mm:ss z} %-5level %class{36} %L %M - %msg%xEx%n"/>
            <SizeBasedTriggeringPolicy size="50MB"/>
            <!-- DefaultRolloverStrategy属性如不设置,
            则默认为最多同一文件夹下7个文件,这里设置了20 -->
            <DefaultRolloverStrategy max="20"/>
        </RollingFile>
    </appenders>
</configuration>

最终处理后的代码

less 复制代码
@Slf4j
@Component
public class CheckUser implements UserDetailsService {

    @Autowired
    UserMapper userMapper;

    @Autowired
    PermissionMapper permissionMapper;

    //TODO 我们不能把存redis的工作放在这里,
    // 因为该类的本职工作应该是--->认证
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.getUserByName(username);
        if (user == null){
            throw new UsernameNotFoundException("用户不存在");
        }
        //TODO 去MySQL查用户权限
        ArrayList<String> permissions = permissionMapper.getPermissions(user.getId());
        if (permissions.isEmpty()){
            log.error("尚未添加用户权限,请联系管理员!");
        }
        //TODO 封装进User对象
        user.setPermission(permissions);
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        //TODO 创建符合指定格式的List并封装进UserPro
        permissions.forEach(str -> {
             grantedAuthorities.add(new SimpleGrantedAuthority(str));
        });
        return new UserPro(user,grantedAuthorities);
    }
}

处理完登录情况的所有可能异常点处理,我们再回到权限验证的代码,既然,登录异常处理只能处理AuthenticationException类型的异常,那么自然AccessDeniedHandler也只能处理权限认证工作中的异常了,对于我们的断言异常,一定还是会报错到控制台。

看下面这段代码,

php 复制代码
//TODO 如果有token则jwt解码 
String subject = null;
try { 
    subject = JwtUtils.parseJWT(token).getSubject(); 
    } catch (Exception e) { 
    throw new RuntimeException("token非法!"); 
 }

先不论说是否会报到控制台,一个用户的的token不为空,即便他的token已经过期,但解码的步骤是不可能报错的,token不为空且无法解码的情况只有一种可能,用户伪造了token。较为优雅的处理方式如下:

php 复制代码
//TODO 如果有token则jwt解码
String subject = null;
try {
    subject = JwtUtils.parseJWT(token).getSubject();
} catch (Exception e) {
    //TODO 如果发现客户伪造token,则记录攻击者ip用于追踪。
    log.warn("token非法!"+request.getHeader("X-Real-IP"));
    throw new RuntimeException();
}

最终代码

scala 复制代码
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    MyRedisCache myRedisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("token");
        //TODO 没有token则放行,由登录验证调用UserDetailsService去拦截
        if (!StringUtils.hasLength(token)){
            filterChain.doFilter(request,response);
            return;
        }

        //TODO 如果有token则jwt解码
        String subject = null;
        try {
            subject = JwtUtils.parseJWT(token).getSubject();
        } catch (Exception e) {
            //TODO 如果发现客户伪造token,则记录攻击者ip用于追踪。
            log.warn("token非法!"+request.getHeader("X-Real-IP"));
        }

        UsernamePasswordAuthenticationToken authenticationToken =
                getUsernamePasswordAuthenticationToken(subject);
        //TODO 该方法才是最终存储权限信息的位置,
        // 放入SpringSecurity的验证对象
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //TODO 此时已经有相关信息,则会一路进行身份以及权限验证,最终通过
        filterChain.doFilter(request,response);
    }

    private UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(String subject) {
        UsernamePasswordAuthenticationToken authenticationToken = null;
        try {
            //TODO jwt 解码成功则去查redis,再次确认
            User user = myRedisCache.getCacheObject(subject);
            //TODO 拿到权限信息转换成指定合集
            List<GrantedAuthority> grantedAuthorities = user.getPermission()
                    .stream().map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
            //TODO 传入信息生成验证token
            authenticationToken = new UsernamePasswordAuthenticationToken(
                    user.getUserName(),user.getPassword(),grantedAuthorities);
        } catch (Exception e) {
            //TODO 发生任何问题,直接返回空,让SpringSecurity认证处理
            return null;
        }
        return authenticationToken;
    }
}

在上面的代码中,我们将获取redis中用户信息的步骤与创建鉴权对象的步骤抽取出来,在发生错误时返回null,我们也不再需要在伪造token时报运行时错误,更不会打印到控制台最终会以null值的形式放入

scss 复制代码
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

中,最终由AuthenticationException捕获。

既然我们已经完成了对异常情况的处理全流程,那么在LoginServiceImplgetUserDetail中的断言报错也就没有意义了,大家可以删掉这段无效的代码。


总结与展望

在上面的步骤中,我们已经完成了权限系统的异常处理问题。但我们的权限系统依旧不够完善。我列举以下几个问题

  1. 用户可以同时任意次数的登录自己的账号,即便同时登录一万次你也管不了他,这显然不行!

  2. 用户可以无限次数的去尝试登录,登录端口面临爆破压力,这显然也不行。

  3. 既然我们已经基本完成了我们的基本权限框架,那么可不可以实现扫码登录呢?

禁止用户多次登录保持在线

问题与思路

其实这个东西的实现思路看似很简单,好像我们只需要在登录根据提供的用户信息去MySQL查一下当前用户的信息是否存在即可,如果存在,则告知用户无法登陆。

但这面临以下3个问题

  1. 我们用户每次登录都会去查数据库,这无疑会增加MySQL压力

  2. 我们给用户的登录凭证ID是随机生成的,只有用户跟Redis知道,我们其实无法根据用户的账号密码得出用户的登录凭证是什么,也就无法操作Redis中的用户信息

  3. 随着所需功能的增多,如果我们的代码卸载登录与权限控制的逻辑中,代码必然会越来越冗余耦合度也会越来越高

思路

对于第一个问题,我们将用户的登录状态存放在Redis中,这样就不需要担心MySQL压力,另一方面,用户登录状态这种类型的信息,往往会频繁的变化,我们通过Redis为相关信息设置有效时间,更符合实际情况

第二个问题我们通过存储用户名以及token,在登陆成功时将用户名作为key与token一起存起来,通过判定key是否存在来表示用户的登录状态 。如果需要使用户下线,我们可以取出这个值,取得token,使用户下线

第三个问题我们通过Springboot的事件发布机制,如果账号信息验证通过,我们就将登录成功这个事件发布出去,通过事件监听器来处理Redis存取相关的工作 。如果后续有其他的逻辑需要绑定到登陆成功这件事上,我们定义监听器并设置Order即可

代码实现

一个小改进

我们之前在存储用户信息时,一直使用的是与MySQL表对应的字段映射类。但是这个类里面包含了大量的字段,而且绝大多数与登录或身份验证无关。我在这里重新设计了一个pojo,与原来的相比,减少了很多字段

typescript 复制代码
@Data
@Repository
public class LoginUser {
    private Long id;
    private String userName;
    private  String password;
    private Boolean checkedLogin;
    private String token;
    private List<String> permission;
    private String status;
}

上面大家会发现多了2个字段checkedLogintoken,这两个与User表无关,但下面会用到。

代码完成账号同时在线限制

kotlin 复制代码
@Override
public MyResult login(LoginUser loginUser) {
    //TODO 先去查用户是否强制登录
    MyResult result = checkLogin(loginUser);
    if (result!=null){
        return result;
    }
    
    
    
private MyResult checkLogin(LoginUser loginUser){
    if (loginUser.getCheckedLogin() == null){
        Boolean b = redisCache.redisTemplate.hasKey(loginUser.getUserName());
        if (b) {
            return MyResult.FAIL().message("您的账号正在它地登录。您确认要登录吗?");
        }
        loginUser.setCheckedLogin(false);
        return null;
    }
    loginUser.setCheckedLogin(true);
    return null;
}

上面的一段代码是在LoginService中的截取,我们的设想是,如果账号已经在别的地方登陆了,就会返回提示:是否要强制登录。如果用户选择强制登录,就让前端二次发送请求 ,并且为checkedLogin字段赋值为true。我们就执行登录操作。

那么强制登录与非强制登录有什么区别呢?强制登录需要我们去把之前在线的token删除掉,让另一边的用户下线。当然,这些都属于登录成功后的操作,而且不需要返回值,我们就发布事件并在监听器中完成它。

事件发布者

typescript 复制代码
@Component
public class EventPublish implements ApplicationEventPublisherAware {


    ApplicationEventPublisher eventPublisher;

    public void publish(ApplicationEvent event){
        eventPublisher.publishEvent(event);
    }

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.eventPublisher = applicationEventPublisher;
    }
}

事件对象

scala 复制代码
public class LoginSuccessEvent extends ApplicationEvent {
    public LoginSuccessEvent(Object source) {
        super(source);
    }
}

对事件感兴趣的人(监听者)

scss 复制代码
@EventListener
public void onLoginSuccess(LoginSuccessEvent loginSuccessEvent){
    LoginUser loginUser =
            (LoginUser) loginSuccessEvent.getSource();

    if (loginUser.getCheckedLogin()!= null && loginUser.getCheckedLogin()){
        //TODO 如果用户是强制登录的,我们就给他把旧的token删掉,把用户踢下线。
        UserNameAndToken oldTokenObj = myRedisCache.getCacheObject(loginUser.getUserName());
        myRedisCache.deleteObject(oldTokenObj.getToken());
        //TODO 还要把旧的用户状态下线掉
        myRedisCache.deleteObject(loginUser.getUserName());
    }

    //TODO 让新的用户状态生效
    myRedisCache.setCacheObject(loginUser.getToken(),loginUser,3, TimeUnit.HOURS);
    myRedisCache.setCacheObject(loginUser.getUserName(),
            new UserNameAndToken(loginUser.getUserName(), loginUser.getToken()),
            3,TimeUnit.HOURS);
}

这里的参数是我们在发布事件时放入的,是SpringSecurity校验完成后返回的loginUser对象。

ini 复制代码
myRedisCache.setCacheObject(loginUser.getToken(),loginUser,3, TimeUnit.HOURS);

大家会发现在这里我把登陆成功后的用于权限校验的那个以随机token为key的信息的存储过程也放在了这里,因为它本质上也是属于登陆成功后的操作,所以放在这里更为合理。

还有这个UserNameAndToken对象,也是为了缩小体积,内容就跟它的名字一样,只有用户名和token两个字段的pojo,大家可以自己写一下(其实用Map存也可以)。

loginService中的写法

ini 复制代码
//TODO 创建一个用来验证用户身份的token
String cliToken = "cliToken"+ UUID.randomUUID().toString().replace("-","");

//TODO 登陆成功 将登录事件发布出去,监听器完成Redis相关工作
mySqlLoginUser.setToken(cliToken);
eventPublish.publish(new LoginSuccessEvent(mySqlLoginUser));

这里就用到了这个token字段。

设置token失效后的返回值

试想一下,你作为一个用户,当你的账号在异地被登录,你会希望所有的页面全部404呢还是弹出提示:身份已过期呢

所以我们就要处理一下这个情况发生时对应的异常处理:

首先我们要知道这种情况会发生在哪一段逻辑中。

  1. 用户发送请求并携带token。
  2. token因为符合我们的解码规则,所以能够被正常解析
  3. token进行Redis查库,此时就会出现问题,当用户登录状态失效,此时,Redis中已经没有了对应的数据了。也就会在次数报错

但我们之前讲过,鉴权逻辑中的非权限不够的报错都会被认证阶段的异常处理截获并包装 。也就是说,任何错误都会变成相同的报错,而且不能够携带报错信息,也就是说,在异常处理的逻辑中,它并不能判断出是不是token失效

此处我们通过给请求对象添加参数的形式来通知异常处理类发生了什么事情

java 复制代码
private UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(String subject,HttpServletRequest request) {
    UsernamePasswordAuthenticationToken authenticationToken = null;
    try {
        //TODO jwt 解码成功则去查redis,再次确认
        LoginUser user = myRedisCache.getCacheObject(subject);
        //TODO 如果token过期,我们只能通过这种方式传递信息到异常处理
        if (user==null){
            request.setAttribute("tokenExpires","");
        }

上面是鉴权代码的一个子方法,我们现在添加上图中多出的代码。

java 复制代码
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    String fail = null;
    if (request.getAttribute("tokenExpires") != null) {
        fail = JSONObject.toJSON(MyResult.FAIL().stateId(405).message("身份已过期,请重新登录!")).toString();
    }

这样在AuthenticationExceptionHandler中,我们就知道发生了什么事了。

账号退出逻辑

完成了上面那个步骤,我顺便把这个账号退出的功能也做了,同样用了事件监听机制

less 复制代码
@PostMapping("/logout")
public MyResult logout(@RequestHeader(value = "token") String token){
    eventPublish.publish(new LogoutEvent(token));
    return MyResult.OK().message("已执行退出逻辑");
}
scala 复制代码
public class LogoutEvent extends ApplicationEvent {
    public LogoutEvent(Object source) {
        super(source);
    }
}

下面的逻辑我就没有写很严密,退出逻辑的重要性其实不如登录场景。一般不会出问题,出问题会打印日志。

scss 复制代码
@EventListener
public void LogoutEventListener(LogoutEvent logoutEvent) {
    String jwt = (String) logoutEvent.getSource();
    try {
        String subject = JwtUtils.parseJWT(jwt).getSubject();
        LoginUser loginUser = (LoginUser) myRedisCache.redisTemplate.opsForValue().getAndDelete(subject);
        myRedisCache.deleteObject(loginUser.getUserName());
    } catch (Exception e) {
        log.warn("退出阶段出现问题,请排查!");
    }

}

限制密码错误尝试次数

思路

对于这个问题,我们的思路是通过Redis进行错误计数,如果超过5次,就禁用掉用户账号,让用户30分钟后再登录。这里需要关照的点是

  1. 禁用账号的操作,如果对MySQL中的用户字段修改而达到禁用的目的,那么30分钟后还用进行解封,频繁操作MySQL并不好,而且用户表注定是大表。

  2. 错误次数不能无限时间的可累计。如果用户很久之前的错误次数也被累计,今天不小心错了一次,直接给封30分钟,这显然不行

  3. 如果错误此时达到5次,就不光是用户再次输错密码时会提示禁用,而是即便用户输对了密码,账号依然是禁用状态,不能因为用户撞对了密码就突破禁用让其登录

代码实现

typescript 复制代码
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    String fail = null;
    if (request.getAttribute("tokenExpires") != null) {
        fail = JSONObject.toJSON(MyResult.FAIL().stateId(405).message("身份已过期,请重新登录!")).toString();
    } else if (authException instanceof BadCredentialsException) {
        //TODO  密码错误的处理逻辑
        String userName =  (String) request.getAttribute("userName");
        fail = BadCredentialsHandler(userName);
    }
    else if (authException instanceof UsernameNotFoundException) {
        fail = JSONObject.toJSON(MyResult.FAIL().stateId(406).message("账号不存在,请确认后再尝试登录")).toString();
    } else {
        fail = JSONObject.toJSON(MyResult.FAIL().stateId(404).message("未知异常")).toString();
    }

    WebUtils.renderString(response, fail);
}

这里我把密码错误的处理逻辑封装了出去,而且对于不同情况做了不同的处理。大家可能觉得这个地方if-else多,其实这一段代码归根结底也就这么点逻辑了,也不可能再加别的逻辑了,如果没有后续再添加if-else,其实还好。

scss 复制代码
private String BadCredentialsHandler(String userName) {
    Integer badCount = myRedisCache.getCacheObject(BAD_PASSWORD + userName);
    if (badCount==null){
        myRedisCache.setCacheObject(BAD_PASSWORD + userName,1,30, TimeUnit.MINUTES);
        return JSONObject.toJSON(MyResult.FAIL().message("密码错误,您还有4次机会")).toString();
    }
    if (badCount == -1){
        return JSONObject.toJSON(MyResult.FAIL().stateId(444).message("密码错误次数过多,目前账号已禁用,请30分钟后再尝试登录!")).toString();
    }
    if ( badCount <= 3){
        myRedisCache.setCacheObject(BAD_PASSWORD+userName,++badCount,30,TimeUnit.MINUTES);
        return JSONObject.toJSON(MyResult.FAIL().message("密码错误,您还有"+(5-badCount)+"次机会")).toString();
    }else {
        myRedisCache.setCacheObject(BAD_PASSWORD+userName,-1,30,TimeUnit.MINUTES);
        return JSONObject.toJSON(MyResult.FAIL().message("错误次数过多,请30分钟后再尝试登录!")).toString();
    }

}

对了,这里还定义了一个常量(这些代码的常量我都没有做过控制,还是优秀代码看的少,自己动手写得少),习惯养成果然也是需要时间的。

arduino 复制代码
public static String BAD_PASSWORD = "BAD_PASSWORD";

在上面的代码中,我为每一次的错误都添加了计时器,防止用户错误次数的无限期累计。用-1代表账号禁用。这样就避免了短期禁用时频繁操作MySQL。登录时查出账号错误次数为-1直接告知账号禁用即可


登录层面的禁用拦截

这里直接贴出LoginService的全部代码吧,大家可以翻一翻

scss 复制代码
@Service
public class LoginServiceImpl implements LoginService{

    @Autowired
    AuthenticationManager authenticationManager;

    @Autowired
    MyRedisCache redisCache;

    @Autowired
    EventPublish eventPublish;


    @Override
    public MyResult login(LoginUser loginUser) {
        //TODO 先去查用户是否强制登录
        MyResult result = checkLogin(loginUser);
        if (result!=null){
            return result;
        }
        //TODO 把用户名传递给校验异常处理
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();
        request.setAttribute("userName",loginUser.getUserName());

        //TODO 先去查账号密码对不对
        UserPro userDetail = getUserDetail(loginUser);
        LoginUser mySqlLoginUser = userDetail.getUser();

        //TODO 判断用户账号是否禁用
        MyResult disableResult = isDisable(mySqlLoginUser);
        if (disableResult != null){
            return disableResult;
        }

        //TODO 创建一个用来验证用户身份的token
        String cliToken = "cliToken"+ UUID.randomUUID().toString().replace("-","");

        //TODO 登陆成功 将登录事件发布出去,监听器完成Redis相关工作
        mySqlLoginUser.setToken(cliToken);
        eventPublish.publish(new LoginSuccessEvent(mySqlLoginUser));

        //TODO 以前端token为id生成jwt并返回给前端
        String jwt = JwtUtils.createJWT(cliToken);
        HashMap<String, Object> map = new HashMap<>();
        map.put("token",jwt);
        return MyResult.OK()
                .message("登陆成功!").
                data(map);
    }

    /**
     * 查看用户是否被禁用,如果mysql层面被长期禁用,则直接返回禁用情况所需的返回值
     * 若MySQL层面未被禁用,此时触发查找Redis缓存层面是否被短期禁用,如被短期禁用,依旧返回被禁用
     * 若2者都未禁用,则返回空值继续执行登录流程。
     * @param mySqlLoginUser
     * @return
     */
    private MyResult isDisable(LoginUser mySqlLoginUser) {
        Integer userStatus = null;
        String badUserName = AuthenticationExceptionHandler.BAD_PASSWORD+mySqlLoginUser.getUserName();
        if (!(Objects.equals(mySqlLoginUser.getStatus(), "0"))||
                ((userStatus=redisCache.getCacheObject(badUserName))!=null
                && userStatus==-1)){
            return MyResult.FAIL().stateId(444).message("您的账号目前处于禁用状态!");
        }
        return null;
    }


    /**
     * 该方法验证传入的用户名以及密码是否正确
     * 如果正确则返回SpringSecurity的内部增强版用户对象
     * @param user
     * @return SpringSecurity的内部增强版用户对象
     */
    private UserPro getUserDetail(LoginUser user) {
        //TODO 根据用户名及密码创建一个符合SpringSecurity的验证对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
        //TODO 使用我们的认证管理者来进行后台验证,
        // 其实还是调用我们自定义的UserDetailService进行验证,认证失败这个返回值就会直接变成空的
        Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        //TODO 验证通过抓出加强版的User对象,返回
        return (UserPro)authenticate.getPrincipal();
    }

    private MyResult checkLogin(LoginUser loginUser){
        if (loginUser.getCheckedLogin() == null){
            Boolean b = redisCache.redisTemplate.hasKey(loginUser.getUserName());
            if (b) {
                return MyResult.FAIL().message("您的账号正在它地登录。您确认要登录吗?");
            }
            loginUser.setCheckedLogin(false);
            return null;
        }
        loginUser.setCheckedLogin(true);
        return null;
    }
}

其实这里对MYSQL数据库层面的账号层面长期封禁也做了拦截,但是这个封号逻辑我没有写,但拦截处理已经写过了。


遗憾与展望

其实代码写到这里,大家应该已经发现了一些问题

  1. 没有作为常量存在的错误返回值代码,会让不同的错误显得非常乱。如果与前端小伙伴一起开发,肯定要被打。我写的这个Result实在难堪大用。

  2. 代码文件过多,其实可以缩减。

  3. 常量定义其实可以多一点。

  4. 部分地方可以进行方法抽取。

对于本篇章的后续我也有着许多展望与期待。其实还可以添加的功能还有很多,比较常见的比如

  1. 验证码(我想摆脱SpringSecurity实现验证码功能)

  2. 扫码登录,完成多端登录。(其实有了我们安全的token身份令牌机制,扫码登录的实现就会变得可行。)

  3. 手机号注册登录(连上发短信的接口,这个也不难实现)

小总结

无论怎么说,我们还是完成了一些真实可用的功能 ,实现了较为安全严密的权限管理系统

还是上篇结尾的那些话,摆脱无聊的CRUD玩具项目,去探寻技术栈的横向拓展,能用会用,还要多看别人的优秀代码。归根结底,技术只是实现想法的工具,思想大于coding。扣字不易,觉得有用可以关注我:一个努力搞钱攒老婆本的程序员。

相关推荐
David爱编程几秒前
Java 守护线程 vs 用户线程:一文彻底讲透区别与应用
java·后端
小奏技术18 分钟前
国内APP的隐私进步,从一个“营销授权”弹窗说起
后端·产品
小研说技术36 分钟前
Spring AI存储向量数据
后端
苏三的开发日记36 分钟前
jenkins部署ruoyi后台记录(jenkins与ruoyi后台处于同一台服务器)
后端
苏三的开发日记38 分钟前
jenkins部署ruoyi后台记录(jenkins与ruoyi后台不在同一服务器)
后端
陈三一42 分钟前
MyBatis OGNL 表达式避坑指南
后端·mybatis
whitepure43 分钟前
万字详解JVM
java·jvm·后端
我崽不熬夜1 小时前
Java的条件语句与循环语句:如何高效编写你的程序逻辑?
java·后端·java ee
我崽不熬夜1 小时前
Java中的String、StringBuilder、StringBuffer:究竟该选哪个?
java·后端·java ee
我崽不熬夜2 小时前
Java中的基本数据类型和包装类:你了解它们的区别吗?
java·后端·java ee