程序员求职经验分享与学习资料整理平台

网站首页 > 文章精选 正文

Spring Security 从入门到实战(前后端分离)

balukai 2024-12-30 01:58:29 文章精选 15 ℃

一、Spring Security 原理

Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它为基于 Java EE 的企业软件应用程序提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理 用户认证(Authentication)和 用户授权(Authorization)。它提供了强大的企业安全服务,如:认证授权机制、Web 资源访问控制、业务方法调用访问控制、领域对象访问控制 Access Control List(ACL)、单点登录(SSO)等等。

它的核心是一组过滤器链,不同的功能经由不同的过滤器。使用 Spring Security 可以帮助我们来简化认证和授权的过程。常用的权限框架除了 Spring Security,还有 Apache 的 Shiro 框架。

Spring-Security 认证流程

用户登录验证和授权请求的数据需要经过层层拦截器从而实现权限控制,整个 Web 的端配置为 DelegatingFilterProxy,但它并不实现真正的过滤,而是所有过滤器链的代理类,真正执行拦截处理的是由 Spring 容器管理的 Filter Bean 组成的 filterChain。

认证与授权

  • 认证(Authentication):确定一个用户的身份的过程。
  • 授权(Authorization): 判断一个用户是否有访问某个安全对象的权限。

下面讨论一下 Spring Security 中最基本的认证与授权,首先明确一下在认证与授权中关键的是 UsernamePasswordAuthenticationFilter,该过滤器用于拦截我们表单提交的请求(默认为/login),进行用户的认证过程。

授权登录的请求,UsernamePasswordAuthenticationFilter 将拦截请求进行认证,如图所示。

AuthenticationProvider 中维护了 UserDetailsService,我们使用内存中的用户,默认的实现是 InMemoryUserDetailsManager。UserDetailsService 用来查询用户的详细信息,该详细信息就是 UserDetails。UserDetails 的默认实现是 User。查询出来 UserDetails 后再对用户输入的密码进行校验。校验成功则将 UserDetails 中的信息填充进 Authentication 中返回。校验失败则提醒用户密码错误。

通常情况下 Security 结合 JWT 实现单点登录,单点登录就是在一个多应用系统中,只要在其中一个系统上登录之后,不需要在其它系统上登录也可以访问其内容。

举个例子,像电商平台那么复杂的系统肯定不会是单体结构,必然是微服务架构,比如订单功能是一个系统,交易是一个系统,如果用户在下订单的时候登录了,付钱再登录一次,这样的话用户体验会很差。实现的流程就是在下单的时候系统发现用户没登录,登录完了之后系统返回给前端一个 Token,就类似于身份证的东西;然后用户去付钱的时候就把 Token 再传到交易系统中,然后交易系统验证一下 Token 就知道是谁了,就不需要再让用户登录一次,那么这里面提到的 token 其实就是 JWT(JSON Web Token),JWT 实现单点登录的流程如图。

JWT 实际上是一种声明规范,它主要帮助通信双方之间提供更加简洁和安全的信息。一个 JWT 实际上就是一个字符串,它由三部分组成,头部、载荷与签名。JWT 详细构成信息如图所示。

二、什么是 RBAC 模型,Spring Security 与 RBAC 模型的关系是怎样的?

RBAC(Role-Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联。

简单地说,一个用户可以拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户—角色—权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间通过配置产生关联,一般者是多对多的关系。这个模型几乎与所有的项目都有密不可分的关系。

权限管理的三要素分别是账号、角色和权限。

下图是 RBAC 的基本功能模块,包括了用户管理、角色管理、权限管理:

账号

每个后台系统的使用者,都有自己的账号。账号就是使用者进入系统后台的钥匙,它的权限对应着使用者在系统中操作范围,和可查看的范围。

角色

角色是搭建在账号与权限之间的一道桥梁。系统中会有各种各样的权限,如果每建一个账号都要配置一遍权限,这样工作效率将大大的降低。因此,角色作为使用者人群的集合,把需要的权限提前收归于其中,然后再根据账号的具体需求来配置角色。通常会根据不同的部门、岗位、工作内容等,对角色进行设置。例如图书馆系统,有普通用户、图书管理员、系统管理员三个角色,普通用户可以浏览借阅图书,图书管理员可以新增图书、修改图书简介、图片等信息,系统管理员可以管理所有的图书管理员等。一个人可以是只有读者的权限,如果他是图书管理员,那么他可以有读者、图书管理员两个权限,也就是说一个人可以有多个角色。

角色这一概念的引入,极大地增加了权限管理系统配置的灵活性和便捷性。创建账号时,可以将不同的角色配置在同一个账号上,也可以给不同的账号配置相同的角色。创建角色时,可以根据角色的差异赋予其不同的权限。

权限

权限可分为三类:数据权限、操作权限和页面权限。

  • 数据权限:控制账号可看到的数据范围。
  • 页面权限:控制账号可以看到的页面,通常系统都会有这一层权限控制。页面权限往往体现在菜单权限上,某个人可以看到的菜单列表。
  • 操作权限:操作权限是 API 级别的权限控制。控制账号在页面上可以操作的按钮,通常指的是页面中的新增、删除、编辑、查询功能。没有操作权限,就只能看到页面中的数据,但是不能对数据进行操作。操作权限是比页面权限更精细一层的权限控制。

三、满足 API 权限级别的数据库表详细设计

安全认证和权限管理的实现是整个系统项目的核心内容之一。正如第一节阐述的内容,Security 实际上是一个过滤链,只有通过层层认证才能登录系统,权限的数据库表设计 ER 关系如图所示。(仅展示关键字段)

以下是满足 API 权限级别的数据库表设计:涉及到四张表:用户表(sys_user)、角色表(sys_role)、菜单表(sys_menu)、API 表(sys_api)。

数据库表详细设计:

-- ----------------------------
-- Table structure for sys_api
-- ----------------------------
DROP TABLE IF EXISTS `sys_api`;
CREATE TABLE `sys_api`  (
  `id` bigint(45) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `api_name` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'API 名称',
  `api_url` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'API 请求地址',
  `api_method` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'API 请求方式:GET、POST、PUT、DELETE',
  `parent_id` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '父 ID',
  `api_sort` int(11) NOT NULL COMMENT '排序',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
  `available` int(11) NULL DEFAULT 1 COMMENT '0:不可用,1:可用',
  `description` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 36 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '后台接口表' ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单/按钮 ID',
  `parent_id` bigint(20) NULL DEFAULT NULL COMMENT '上级菜单 ID',
  `menu_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '菜单/按钮名称',
  `router_url` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '前端菜单路由 URL',
  `perms` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '权限标识',
  `icon` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '图标',
  `type` char(2) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '类型 0 菜单 1 按钮',
  `order_num` bigint(20) NULL DEFAULT NULL COMMENT '排序',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
  `available` int(11) NULL DEFAULT 1 COMMENT '0:不可用,1:可用',
  `open` int(1) NULL DEFAULT 1 COMMENT '0:不展开,1:展开',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 330 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '菜单表' ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色 ID',
  `role_code` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色编码',
  `role_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色名称',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
  `enabled` int(1) NULL DEFAULT 1 COMMENT '是否可用,0:不可用,1:可用',
  `description` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色描述',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色表' ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'admin', '超级管理员', '2021-03-07 21:25:18', '2021-03-07 21:25:20', 1, NULL);
INSERT INTO `sys_role` VALUES (2, 'teacher', '老师', '2021-03-07 21:25:49', '2021-03-07 21:25:51', 1, NULL);
INSERT INTO `sys_role` VALUES (3, 'student', '学生', '2021-03-07 21:26:42', '2021-03-07 21:26:45', 1, NULL);

-- ----------------------------
-- Table structure for sys_role_api
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_api`;
CREATE TABLE `sys_role_api`  (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `role_id` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色 ID',
  `api_id` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'API 管理表 ID',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 46 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色接口表' ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu`  (
  `id` bigint(10) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `role_id` bigint(20) NOT NULL COMMENT '角色 ID',
  `menu_id` bigint(20) NOT NULL COMMENT '菜单/按钮 ID',
  `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 27 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色菜单关联表' ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户 ID',
  `username` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
  `password` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
  `nickname` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '昵称',
  `email` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '邮箱',
  `avatar` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '头像',
  `phone_number` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '联系电话',
  `enabled` int(1) NOT NULL COMMENT '账户是否禁用 默认为 1(可用)',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
  `sex` int(1) NULL DEFAULT NULL COMMENT '性别 1 男 0 女 2 保密',
  `type` int(11) NOT NULL DEFAULT 1 COMMENT '0:超级管理员,1:系统用户',
  `birth` date NULL DEFAULT NULL COMMENT '生日',
  `last_login_time` datetime NULL DEFAULT NULL COMMENT '最后一次登录时间',
  `token` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'token 令牌',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role`  (
  `id` bigint(10) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `role_id` bigint(10) NULL DEFAULT NULL COMMENT '角色 ID',
  `user_id` bigint(10) NULL DEFAULT NULL COMMENT '用户 ID',
  `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '系统管理 - 用户角色关联表 ' ROW_FORMAT = DYNAMIC;

四、将 Spring Security 整合到 Spring Boot 中

首先我们小牛刀试一下,先创建一个 Spring Boot 项目,实际上在 Maven 中引入 Security 的依赖包,启动项目就会生成一个随机密码,访问 localhost:8080/login 就弹出了 Spring Security 的默认登录页面:

<!--权限 SpringSecurity-->
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
 </dependency>

输入账号密码(默认账号为 user)即可实现登录,然后可以正常访问到项目的 API:

还可以在配置文件中自定义登录的账号和密码:

spring:
    security:
      user:
        name: admin
        password: 123456

现在绝大多数项目都会使用前后端分离的方式,将 Security 整合到 Spring Boot 中,并且我们在做登录验证的时候,不会使用 Security 自带的登录界面,毕竟不美观,那么使用自定义的登录界面,并使用 JSON 数据传递到后端,是实际项目的经典场景。

五、采用前后端分离的方式的登录校验&权限认证

如下图是整个项目的结构:

1. 引入依赖

在项目中,我们使用 MybatisPlus 作为持久层框架,需要用到 MyBatis-Plus 的代码自动生成器,所以这里导入一些需要的依赖。

<!--mysql 连接 java 的依赖-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.13</version>
</dependency>

<!--swagger 的依赖-->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.7.0</version>
</dependency>

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.7.0</version>
</dependency>

<!--MyBatis-Plus 的依赖-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>

<!--代码自动生成器-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.4.1</version>
</dependency>

<!--添加 模板引擎 依赖,MyBatis-Plus 支持 Velocity(默认)-->
<dependency>
    <groupId>org.apache.velocity</groupId>
    <artifactId>velocity-engine-core</artifactId>
    <version>2.2</version>
</dependency>

<!--hutool 工具类-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.8</version>
</dependency>

2. 代码生成器

代码生成器,我们一般放在 test 中,具体代码含义已经以注释的形式写在代码里。

重点:

  1. 数据源配置上,要修改为自己的数据库连接。
  2. 代码倒数第三行:忽略表中生成的实体类的前缀,通常在创建表的时候,我们会用各种前缀代表不同类型的表数据,那么如果在自动生成实体类等文件的时候,不希望带前缀可以在这里配置。
package com.house.system.generator;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.config.DataSourceConfig;
import com.baomidou.mybatisplus.generator.config.GlobalConfig;
import com.baomidou.mybatisplus.generator.config.PackageConfig;
import com.baomidou.mybatisplus.generator.config.StrategyConfig;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;

import java.util.Scanner;

/**
 * 代码生成器
 */
public class CodeGenerator {
    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotBlank(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }

    public static void main(String[] args) {
        // 创建代码生成器对象
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        gc.setOutputDir(scanner("请输入你的项目路径") + "/src/main/java");
        gc.setAuthor("chendandan");
        //生成之后是否打开资源管理器
        gc.setOpen(false);
        //重新生成时是否覆盖文件
        gc.setFileOverride(false);
        //%s 为占位符
        //mp 生成 service 层代码,默认接口名称第一个字母是有 I
        gc.setServiceName("%sService");
        //设置主键生成策略 自动增长
        gc.setIdType(IdType.AUTO);
        //设置 Data 的类型 默认使用 java.util.date 代替
        gc.setDateType(DateType.ONLY_DATE);
        //开启实体属性 Swagger2 注解
        gc.setSwagger2(true);
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://192.168.184.131:3306/house_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8");
        dsc.setDriverName("com.mysql.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        //使用 Mysql 数据库
        dsc.setDbType(DbType.MYSQL);
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName(scanner("请输入模块名"));
        pc.setParent("com.house.system");
        pc.setController("controller");
        pc.setService("service");
        pc.setServiceImpl("service.impl");
        pc.setMapper("mapper");
        pc.setEntity("entity");
        pc.setXml("mapper");
        mpg.setPackageInfo(pc);

        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        //表名分割 设置哪些表需要自动生成
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        // 实体类 驼峰命名
        strategy.setNaming(NamingStrategy.underline_to_camel);
        //列名 驼峰命名
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        //使用简化 getter setter
        strategy.setEntityLombokModel(true);
        //设置 controller 的 api 风格 使用 restController 风格
        strategy.setRestControllerStyle(true);
        //驼峰转连字符
        strategy.setControllerMappingHyphenStyle(true);
        //忽略表中生成实体类的前缀
        strategy.setTablePrefix("tb_","auth_","sys_");
        mpg.setStrategy(strategy);
        mpg.execute();
    }

}

使用示例:(这是拿我当时写的项目笔记中的截图)

3. application.yml 文件配置

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.184.131:3306/security_test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8
    username: root
    password: 123456

  jackson:
    date-format: yyyy-MM-dd
    time-zone: GMT%2B8

mybatis-plus:
  mapper-locations: classpath*:/mapper/*Mapper.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  type-aliases-package: com.xiaoge.bootsecurity.entity

4. 配置 Spring Security

首先我们需要自定义 UserDetailsService,将用户信息和权限注入进来。

我们需要重写 loadUserByUsername 方法,参数是用户输入的用户名。返回值是 UserDetails,这是一个接口,一般使用它的子类 org.springframework.security.core.userdetails.User,它有三个参数,分别是用户名、密码和权限集。

实际情况下,大多将 DAO 中的 User 类继承 org.springframework.security.core.userdetails.User 返回。

添加类 UserDetailsServiceImpl 看实现的代码,实际上就一个服务类,通过用户名,查询用户的信息,以及权限集合。

@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private SysUserMapper sysUserMapper;

    @Resource
    private SysMenuMapper sysMenuMapper;

    /**
     * 通过名称加载用户信息
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(username==null||"".equals(username)){
            throw new RuntimeException("用户名不能为空");
        }
        //根据用户名查询用户信息
        LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysUser::getUsername,username);
        SysUser user = sysUserMapper.selectOne(wrapper);
        if(user==null){
            throw new UsernameNotFoundException(String.format("%s 用户不存在",username));
        }
        List<GrantedAuthority> authorities = new ArrayList<>();
        //查询权限集合
        List<SysMenu> menuList = sysMenuMapper.getMenuListByUserName(username);
        List<String> permsList = menuList.parallelStream()
                .filter(Objects::nonNull)
                .map(menu -> {
                    return menu.getPerms();
                })
                .collect(Collectors.toList());

        permsList.stream().filter(Objects::nonNull)
                .filter(s->!s.isEmpty())
                .collect(Collectors.toList()).forEach(perms->{
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(perms);
            authorities.add(authority);
        });
        return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(),authorities);
    }
}

5. 创建类 WebSecurityConfig

它继承了 WebSecurityConfigurerAdapter,这是 Security 的核心配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
    @Resource
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
    @Resource
    private MyLogoutHandler myLogoutHandler;
    @Resource
    private MyLogoutSuccessHandler myLogoutSuccessHandler;
    @Resource
    private MyAuthenticationEntryPoint myAuthenticationEntryPoint;
    @Resource
    private MyAccessDeniedHandler myAccessDeniedHandler;
    @Resource
    private MyExpiredSessionStrategy myExpiredSessionStrategy;
    //jwt 拦截器
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //获取用户账号密码及权限信息
        auth.userDetailsService(userDetailsService)
                // 设置默认的加密方式(强 hash 方式加密)
                .passwordEncoder(passwordEncoder);
    }
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(HttpMethod.GET,
                "/favicon.ico",
                "/**/*.png",
                "/**/*.ttf",
                "/*.html",
                "/**/*.css",
                "/**/*.js");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //第 1 步:解决跨域问题。cors 预检请求放行,让 Spring security 放行所有 preflight request(cors 预检请求)
        http.authorizeRequests().requestMatchers(CorsUtils::isPreFlightRequest).permitAll();

        //第 2 步:让 Security 永远不会创建 HttpSession,它不会使用 HttpSession 来获取 SecurityContext
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().headers().cacheControl();

        //第 3 步:请求权限配置
        http.authorizeRequests()
                //.antMatchers("/**").permitAll()
                .antMatchers(HttpMethod.GET,"/swagger-ui.html").permitAll()
                .antMatchers(HttpMethod.GET,"/doc.html").permitAll()
                .antMatchers(HttpMethod.GET,"/webjars/**").permitAll()
                .antMatchers(HttpMethod.GET,"/v2/**").permitAll()
                .antMatchers(HttpMethod.GET,"/swagger-resources/**").permitAll()
                .antMatchers(HttpMethod.GET,"/users/getUserInfo").permitAll()
                .antMatchers(HttpMethod.GET,"/users/getUserById").permitAll()
                .antMatchers(HttpMethod.GET,"/users/getUserList").permitAll()
                .anyRequest().access("@dynamicPermission.hasPermission(request,authentication)");

        //第 4 步:拦截 token,并检测。在 UsernamePasswordAuthenticationFilter 之前添加 JwtAuthenticationTokenFilter
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //第 5 步:处理异常情况:认证失败和权限不足
        http.exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint).accessDeniedHandler(myAccessDeniedHandler);

        //第 6 步:登录,因为使用前端发送 JSON 方式进行登录,所以登录模式不设置也是可以的。
        http.formLogin().loginPage("/login").successHandler(myAuthenticationSuccessHandler).failureHandler(myAuthenticationFailureHandler);

        //第 7 步:设置会话管理:同一账号同时登录最大用户数,会话失效(账号被挤下线)处理逻辑
        http.sessionManagement()
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false)
                .expiredSessionStrategy(myExpiredSessionStrategy);

        //第 8 步:退出
        http.logout().addLogoutHandler(myLogoutHandler).logoutSuccessHandler(myLogoutSuccessHandler).deleteCookies("JSESSIONID");
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

先看这一部分代码,是不是很熟悉?userDetailsService是我们在前面创建的服务类,AuthenticationManagerBuilder是身份验证管理器 Builder,用于获取用户账号密码以及权限信息,并且可加密。

 @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //获取用户账号密码及权限信息
        auth.userDetailsService(userDetailsService)
                // 设置默认的加密方式(强 hash 方式加密)
                .passwordEncoder(passwordEncoder);
    }

下面这个方法,是忽略在请求中的静态文件,例如 ico、png、html、css 文件等等。

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(HttpMethod.GET,
                "/favicon.ico",
                "/**/*.png",
                "/**/*.ttf",
                "/*.html",
                "/**/*.css",
                "/**/*.js");
    }

接下来这个方法中的配置可以说就是 Security 的核心内容了:

   @Override
    protected void configure(HttpSecurity http) throws Exception {
      //核心配置
    }

权限配置中可以配置哪些请求可以忽略掉权限过滤,重点在最后一行的允许请求:

.anyRequest().access("@dynamicPermission.hasPermission(request,authentication)");

需要创建一个 DynamicPermission 类,用于处理当前用户的权限,校验当前请求该用户是否有权限,首先会根据用户信息查询出该用户拥有的 API 权限列表,然后将请求 url 与列表的每一条信息进行比对,比对成功返回 true。

 //第 3 步:请求权限配置
http.authorizeRequests()
     .antMatchers(HttpMethod.GET,"/swagger-ui.html").permitAll()
     .antMatchers(HttpMethod.GET,"/doc.html").permitAll()
     .antMatchers(HttpMethod.GET,"/webjars/**").permitAll()
     .antMatchers(HttpMethod.GET,"/v2/**").permitAll()
     .antMatchers(HttpMethod.GET,"/swagger-resources/**").permitAll()
     .antMatchers(HttpMethod.GET,"/users/getUserInfo").permitAll()
     .antMatchers(HttpMethod.GET,"/users/getUserById").permitAll()
     .antMatchers(HttpMethod.GET,"/users/getUserList").permitAll()
     .anyRequest().access("@dynamicPermission.hasPermission(request,authentication)");
/**
 * 权限校验
 */
@Component("dynamicPermission")
public class DynamicPermission {
    @Resource
    private SysApiMapper sysApiMapper;
    public boolean hasPermission(HttpServletRequest request,
                                 Authentication authentication){
        Object principal = authentication.getPrincipal();
        if(principal instanceof UserDetails){
            //得到当前的账号
            UserDetails userDetails = (UserDetails)principal;
            //获取当前登录账号的用户名称
            String username = userDetails.getUsername();
            //通过用户名称获取 API 列表
            List<SysApi> apiList = sysApiMapper.getApiListByUserName(username);
            /*
                这个类由 spring 提供
                背景:在做 uri 匹配规则发现这个类,根据源码对该类进行分析,它主要用来做类 URLs 字符串匹配;
                可以做 URLs 匹配,规则如下
                    ?匹配一个字符
                    *匹配 0 个或多个字符
                    **匹配 0 个或多个目录
                用例如下
                    /trip/api*//*x    匹配 /trip/api/x,/trip/api/ax,/trip/api/abx ;但不匹配 /trip/abc/x;
                    /trip/a/a?x    匹配 /trip/a/abx;但不匹配 /trip/a/ax,/trip/a/abcx
                    /**//*api/alie    匹配 /trip/api/alie,/trip/dax/api/alie;但不匹配 /trip/a/api
                    *//**//**.htmlm   匹配所有以.htmlm 结尾的路径
             */
            AntPathMatcher antPathMatcher = new AntPathMatcher();
            //当前访问路径  localhost:8080/system/role/listPage
            String uri = request.getRequestURI();
            //提交类型
            String method = request.getMethod();
            //anyMatch:判断的条件里,任意一个元素成功,返回 true
            boolean flag = apiList.stream().anyMatch(api->{
                //判断访问的 uri 是否满足数据库中存放的 url(通过正则表达式进行判断)
                //   /role/authority/?   /role/authority/12
                boolean match = antPathMatcher.match(api.getApiUrl(), uri);
                //判断请求方式是否和数据库中匹配(数据库存储:GET,POST,PUT,DELETE)
                boolean equals = api.getApiMethod().equals(method);
                //两个都为真则满足条件
                return match && equals;
            });
            boolean flag = true;
            if(flag){
                return flag;
            }else{
                throw  new MyAccessDeniedException("您没有访问该 API 的权限!");
            }
        }else{
            throw  new MyAccessDeniedException("不是 UserDetails 类型!");
        }
    }
}

接下来是核心配置文件中提到的第四步:就是第一节提到的 JWT。

我们需要创建一个 JwtAuthenticationTokenFilter 类,实际上就是在请求中携带一个信息密钥 token,这个 token 具有时效性,过期后需要重新登录,也就是重新获取 token。

 //第 4 步:拦截 token,并检测。在 UsernamePasswordAuthenticationFilter 之前添加 JwtAuthenticationTokenFilter
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
@Slf4j
@Component("jwtAuthenticationTokenFilter")
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private JwtTokenUtil jwtTokenUtil;
    @Resource
    RedisTemplate<String,String> redisTemplate;
    private String header = "Authorization";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        //判断 token 是否合法,如果 token 是合法的,那就意味着已经进行过登录了
        //从请求头中获取 token
        String headerToken = request.getHeader(header);
        log.info("headerToken = " + headerToken);
        log.info("request getMethod = " + request.getMethod());
        //判断 token 是否为空
        if (!StringUtils.isEmpty(headerToken)) {
            //postMan 测试时,自动加入的前缀要去掉。
            String token = headerToken.replace("Bearer", "").trim();
            log.info("token = " + token);
            //判断令牌是否过期,默认是一周
            //比较好的解决方案是:
            //登录成功获得 token 后,将 token 存储到数据库(redis)
            //将数据库版本的 token 设置过期时间为 15~30 分钟
            //如果数据库中的 token 版本过期,重新刷新获取新的 token
            //注意:刷新获得新 token 是在 token 过期时间内有效。
            //如果 token 本身的过期(1 周),强制登录,生成新 token。
            //假设 token 是无效的
            boolean check = false;
            try {
                //验证 token 是否过期
                check = this.jwtTokenUtil.isTokenExpired(token);
            } catch (Exception e) {
                //过期则抛出异常
                new Throwable("令牌已过期,请重新登录。"+e.getMessage());
            }
            //如果没有过期,则要验证 token 的合法性了
            if(!check){
                //通过令牌获取用户名称
                String username = jwtTokenUtil.getUsernameFromToken(token);
                log.info("username = " + username);
                //拿到用户名之后,重新通过 userService 的 loadUserByUsername 方法重新得到一个用户
                //放到 securityContext 的上下文中就行了
                //判断用户不为空,且 SecurityContextHolder 授权信息还是空的
                if(username != null && SecurityContextHolder.getContext().getAuthentication() == null){
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    //验证令牌有效性
                    boolean validata = false;
                    try {
                        validata = jwtTokenUtil.validateToken(token,userDetails);
                    } catch (Exception e) {
                        new Throwable("验证 token 无效:"+e.getMessage());
                    }
                    if (validata) {
                        // 将用户信息存入 authentication,方便后续校验
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(
                                        userDetails,
                                        null,
                                        userDetails.getAuthorities()
                                );
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        // 将 authentication 存入 ThreadLocal,方便后续获取用户信息
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                        //将用户信息存储 redis
                       // redisTemplate.opsForValue().set("username",username);
                        //redisTemplate.expire("username",1, TimeUnit.HOURS);//设置一个小时过期
                    }
                }
            }
        }
        chain.doFilter(request, response);
    }
}

配置文件中的第 5 步是:处理异常情况,常见的异常有认证失败和权限不足,当我们请求失败的时候 Security 会自动为我们跳转到 /error url,实际上,在前后端分离的情况下,我们希望给前端一个 JSON,让前端自定义异常情况。以上需要实现AuthenticationEntryPoint 和 AccessDeniedHandler接口。

//第 5 步:处理异常情况:认证失败和权限不足
http.exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint).accessDeniedHandler(myAccessDeniedHandler);

代码中提到的 Result 以及 ResultCode 是我自定义的统一返回类以及 code 枚举信息,WriteJSON 是封装返回值并输出 JSON 格式。

/**
 *  匿名用户访问无权限资源时的异常
 */
@Component("myAuthenticationEntryPoint")
public class MyAuthenticationEntryPoint extends JSONAuthentication implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        Result result = Result.error(ResultCode.USER_NOT_LOGIN);
        this.WriteJSON(request,response,result);
    }
}
@Component("myAccessDeniedHandler")
public class MyAccessDeniedHandler extends JSONAuthentication implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        System.out.println(e.getMessage());
        Result result = Result.error(ResultCode.NO_PERMISSION).message(e.getMessage());

        this.WriteJSON(request,response,result);
    }
}
/**
 * 封装输出 JSON 格式的类
 */
public abstract class JSONAuthentication {
    /**
     * 输出 JSON
     */
    protected void WriteJSON(HttpServletRequest request,
                             HttpServletResponse response,
                             Object data) throws IOException, ServletException {
        //这里很重要,否则页面获取不到正常的 JSON 数据集
        response.setContentType("application/json;charset=UTF-8");
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Method", "POST,GET");
        //输出 JSON
        PrintWriter out = response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(data));
        out.flush();
        out.close();
    }
}

最后一个比较重要的步骤是设置会话管理,我们知道 QQ 最多支持一个手机和电脑登录,如果你的朋友用你的 QQ 在手机上登录了,你这边就会被迫下线,那么实际上 Security 就可以帮我们实现这点。

  • maximumSessions 是最大登录数,一般设置为 1。
  • maxSessionsPreventsLogin 如果设置为 true,那么此时一个浏览器登录成功后,就保留登录,别的用户想登录的话是不能将其踢下线的,那么一般情况下,我们都会设置为 false。
  • expiredSessionStrategy 当账户被踢下线的时候处理返回给前端自定义的返回参数,就像前面提到的无权限访问返回给前端一条 JSON 数据一样。
 //第 7 步:设置会话管理:同一账号同时登录最大用户数,会话失效(账号被挤下线)处理逻辑
        http.sessionManagement()
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false)
                .expiredSessionStrategy(myExpiredSessionStrategy);
/**
 * 当账户被踢下线的时候如何处理
 */
@Component("myExpiredSessionStrategy")
public class MyExpiredSessionStrategy extends JSONAuthentication implements SessionInformationExpiredStrategy {
    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
        Result result = Result.error(ResultCode.USER_ACCOUNT_USE_BY_OTHERS);
        this.WriteJSON(event.getRequest(),event.getResponse(),result);
    }
}

六、效果展示

项目用到的技术:

1. 为用户分配角色:

2. 为角色分配菜单权限:

3. 对权限菜单树的管理:

4. 涉及到 API 维度的权限控制,在设计数据库表的时候,涵盖进去了,Security 权限认证的时候也有详细的逻辑代码,但是由于在系统设计的时候,考虑太多因素比较复杂,因此后面将 API 权限级别的内容注释了,感兴趣的读者可以阅读项目的源码。

除了项目相关的权限,项目中也有接入 echarts 图表、数据字典的示例、高德地图的示例、OSS 文件管理器的接入等等,可供读者参考和交流。

非常感谢,大家对松鼠的信任,破费订阅松鼠的文章,本篇文章实际上并没有达到预期,一是时间仓促、二是篇幅过于长,要介绍的东西太宽泛。因此有任何疑问,欢迎大家留言,松鼠看到了以后都会一一解答。希望我的文章能给你带来一点帮助!

Tags:

最近发表
标签列表