● RBAC

RBAC(Role-Based Access Control)基于角色的访问控制
比如普通用户和管理员。当普通用户进入页面和管理员进入页面看到的是不同的菜单页面,页面菜单是随着自己的角色(用户、管理员)的改变而动态分配的,这就是RBAC模型。

如果一个页面是写死的,用户写死用户的页面,管理员写死管理员的页面,那么当一个新角色加入后,比如是数据库管理员、系统管理员,又要为他们写不同的页面,这样的工作量非常大,也不利于动态维护,所以RBAC是为了解决这一问题而诞生。

它分为用户、角色、菜单三部分组成,一个用户可以分配多个角色(买家、卖家)而一个角色又可以对应多个用户,同理角色和菜单,一个角色可以有多个菜单(买家、店铺、卖家)而页面又可以对应多个角色,这样就形成了用户-多对多-角色、角色-多对多-菜单。所以在数据库中,三张多对多表维护需要创建五张表。
在这里插入图片描述

  • 用户 (User):系统的使用者。
  • 角色 (Role):权限的集合载体(如:管理员、普通用户、财务专员)。
  • 菜单/权限 (Permission/Menu):受控资源(如:页面菜单、按钮、API 接口)。
  • 用户-角色关联 (User-Role):多对多关系。一个用户可以有多个角色(例如某人既是“部门经理”也是“考勤管理员”)。
  • 角色-菜单关联 (Role-Menu):多对多关系。一个角色包含多个菜单权限,一个菜单也可以被多个角色拥有。

数据库表设计(来自若依框架):

-- ----------------------------
-- 2、用户信息表
-- ----------------------------
drop table if exists sys_user;
create table sys_user (
  user_id           bigint(20)      not null auto_increment    comment '用户ID',
  dept_id           bigint(20)      default null               comment '部门ID',
  user_name         varchar(30)     not null                   comment '用户账号',
  nick_name         varchar(30)     not null                   comment '用户昵称',
  user_type         varchar(2)      default '00'               comment '用户类型(00系统用户)',
  email             varchar(50)     default ''                 comment '用户邮箱',
  phonenumber       varchar(11)     default ''                 comment '手机号码',
  sex               char(1)         default '0'                comment '用户性别(0男 1女 2未知)',
  avatar            varchar(100)    default ''                 comment '头像地址',
  password          varchar(100)    default ''                 comment '密码',
  status            char(1)         default '0'                comment '账号状态(0正常 1停用)',
  del_flag          char(1)         default '0'                comment '删除标志(0代表存在 2代表删除)',
  login_ip          varchar(128)    default ''                 comment '最后登录IP',
  login_date        datetime                                   comment '最后登录时间',
  pwd_update_date   datetime                                   comment '密码最后更新时间',
  create_by         varchar(64)     default ''                 comment '创建者',
  create_time       datetime                                   comment '创建时间',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新时间',
  remark            varchar(500)    default null               comment '备注',
  primary key (user_id)
) engine=innodb auto_increment=100 comment = '用户信息表';

-- ----------------------------
-- 4、角色信息表
-- ----------------------------
drop table if exists sys_role;
create table sys_role (
  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 '显示顺序',
  data_scope           char(1)         default '1'                comment '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)',
  menu_check_strictly  tinyint(1)      default 1                  comment '菜单树选择项是否关联显示',
  dept_check_strictly  tinyint(1)      default 1                  comment '部门树选择项是否关联显示',
  status               char(1)         not null                   comment '角色状态(0正常 1停用)',
  del_flag             char(1)         default '0'                comment '删除标志(0代表存在 2代表删除)',
  create_by            varchar(64)     default ''                 comment '创建者',
  create_time          datetime                                   comment '创建时间',
  update_by            varchar(64)     default ''                 comment '更新者',
  update_time          datetime                                   comment '更新时间',
  remark               varchar(500)    default null               comment '备注',
  primary key (role_id)
) engine=innodb auto_increment=100 comment = '角色信息表';


-- ----------------------------
-- 5、菜单权限表
-- ----------------------------
drop table if exists sys_menu;
create table sys_menu (
  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 '组件路径',
  query             varchar(255)    default null               comment '路由参数',
  route_name        varchar(50)     default ''                 comment '路由名称',
  is_frame          int(1)          default 1                  comment '是否为外链(0是 1否)',
  is_cache          int(1)          default 0                  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         varchar(64)     default ''                 comment '创建者',
  create_time       datetime                                   comment '创建时间',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新时间',
  remark            varchar(500)    default ''                 comment '备注',
  primary key (menu_id)
) engine=innodb auto_increment=2000 comment = '菜单权限表';

-- ----------------------------
-- 6、用户和角色关联表  用户N-1角色
-- ----------------------------
drop table if exists sys_user_role;
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 comment = '用户和角色关联表';

-- ----------------------------
-- 7、角色和菜单关联表  角色1-N菜单
-- ----------------------------
drop table if exists sys_role_menu;
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 comment = '角色和菜单关联表';

○ 组建菜单树状结构

如果是RBAC模型那么前端肯定需要动态路由,就需要根据用户权限来动态搭建一个路由列表,这时候就需要后端对菜单进行父子拼装组装成树状结构(如果一个树状菜单用户拥有一个分支下面的菜单权限,那么这个父菜单权限也要有,通常是父子联动,所以使用递归组装树状结构通常从parentId为0也就是顶级菜单开始入手,return当菜单底下没有菜单直接return一个空集合即可)

{
	menu.id: 0,
	parentId: 0,
	...
	children:{
		menu.id: 1,
		parentId: 0,
		children: {
			menu.id:2
			parentId: 1
		}
	}
}

菜单业务根据用户ID组装对应权限的树状菜单
查找对应菜单思路:userId->roleId->menuId
通过userId找对应的roleId,再通过这些roleId查出对应的menuId,就获取到了该角色对应的menu数据平铺大列表,在对这些平铺数据进行树状组装而获得树状数据。

构建树状结构思路:
在这里插入图片描述

若依框架源码:

/**
     * 获取路由信息
     * 
     * @return 路由信息
     */
    @GetMapping("getRouters")
    public AjaxResult getRouters()
    {
        Long userId = SecurityUtils.getUserId();
        //这一步获取平铺集合 并把集合转为树状结构 为入口
        List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId); 
        //封装成前端能看懂的路由数据,充当翻译官
        return AjaxResult.success(menuService.buildMenus(menus));
    }
/**
     * 根据用户ID查询菜单
     * 
     * @param userId 用户名称
     * @return 菜单列表
     */
    @Override
    public List<SysMenu> selectMenuTreeByUserId(Long userId)
    {
        List<SysMenu> menus = null;
        if (SecurityUtils.isAdmin(userId))
        {
            menus = menuMapper.selectMenuTreeAll();
        }
        else
        {
            menus = menuMapper.selectMenuTreeByUserId(userId);
        }

				//上面都是获取平铺menu集合 这一步开始封装树状结构
        return getChildPerms(menus, MENU_ROOT_ID);
    }
 /**
     * 根据父节点的ID获取所有子节点
     * 
     * @param list 分类表
     * @param parentId 传入的父节点ID
     * @return String
     */
    public List<SysMenu> getChildPerms(List<SysMenu> list, long parentId){\
    		//设置返回结果,只需要返回顶级节点,因为下面会递归封装子节点到顶级节点的children内
        List<SysMenu> returnList = new ArrayList<SysMenu>();
        for (Iterator<SysMenu> iterator = list.iterator(); iterator.hasNext();)
        {
            SysMenu t = (SysMenu) iterator.next();
            // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点
            if (t.getParentId() == parentId)
            {
                recursionFn(list, t); //递归组装子节点 参数:平铺集合 顶级节点
                returnList.add(t); //封装顶级节点
            }
        }
        return returnList;
    }

递归开始组装子节点

/**
     * 递归列表
     * 
     * @param list 分类表
     * @param t 子节点
     */
    private void recursionFn(List<SysMenu> list, SysMenu t)
    {
        // 得到子节点列表
        List<SysMenu> childList = getChildList(list, t);
        t.setChildren(childList);
        for (SysMenu tChild : childList)
        {
            if (hasChild(list, tChild)) //判断该节点有没有子节点 如果有继续递归组装
            {
                recursionFn(list, tChild);
            }
        }
    }

获取子节点列表

/**
     * 得到子节点列表
     */
    private List<SysMenu> getChildList(List<SysMenu> list, SysMenu t)
    {
        List<SysMenu> tlist = new ArrayList<SysMenu>();
        Iterator<SysMenu> it = list.iterator();
        while (it.hasNext())
        {
            SysMenu n = (SysMenu) it.next();
            if (n.getParentId().longValue() == t.getMenuId().longValue())
            {
                tlist.add(n);
            }
        }
        return tlist;
    }

递归思路:
先是平铺集合与所有根节点调用方法封装子节点,每一个根节点都遍历一次,先从第一个根节点开始,根据根节点的menuId查询平铺集合的每一个menu的getParentId是否对应menuId,如果对应这就是根节点的子节点就把他封装进集合赋给根节点的children,然后获取到了根节点的children集合,就把children集合作为递归的平铺集合,而children的每一个元素就是二级节点,遍历每一个二级节点,根据menuId封装parentId对应的集合,然后一层层循环,一个根节点往下不断展开,然后回溯,再下一个根节点在展开,在回溯,直到封装完毕。

时间复杂度:
在顶层,根节点假设有五个,那么只需要循环五次平铺集合,也就是O(n)
到第二层,平铺集合就会缩减,而子节点个数是平铺集合的n分之一倍,所以时间复杂度也是O(n)
第三层以此类推O(n)
根据层级关系,菜单关系不会太深入完全跟不上平铺集合的n,也是平铺集合的n分之一倍,所以是常数c*O(n)=O(n)时间复杂度
在这里插入图片描述
在这里插入图片描述

● Spring Security

通过RBAC模型会出现一个问题,那就是即使用户看不到对应的菜单目录但也能发送请求和访问该目录,如果弄一个权限以页面为单位的拦截器,那就是以整个页面为一个维度,被称为粗颗粒权限管理。而spring security可以实现细颗粒度的权限管控,即每一个按钮都设置权限,实现细颗粒权限管控。

spring security能干什么
如果把后端服务器当做一个私密的办公楼,那么spring security就是一个全能的安保系统
它的核心功能:验身份、管权限、保安全,他代替了拦截器。

如果一个用户要操作一个菜单或按钮,肯定要发送请求给后端,那么可以使用Filter过滤器、Interceptor拦截器、AOP技术可以把控用户访问权限,但这样太麻烦,开发只需注重业务逻辑的编写,所以spring security大大简化了这一过程。

○ 验身份:

验身份是指用户在登录时调用一系列的filter链来验证用户的用户名密码校验是否与数据库一致,虽然我们也可以自己写一堆if和sql来验证,但是这样工程量太大,俗话说就是太麻烦了,security提供了框架来实现轻量化编程,虽然我们可以自己写,但是没必要,用他的更好。

流程前置思想:
通常在前端vue发送/login登录请求时security会默认使用自己的filter也就是UserNamePasswordAuthenticationFilter来拦截这个login请求进行身份校验,但是这个过滤器太老套了,他接受的是前端的application/x-www-form-urlencoded的表单形式数据,而现在大部分都是用json格式他无法处理json格式,虽然我们可以自己重构这个过滤器但是有点麻烦不如直接用自己的controller的登录接口来实现拦截。

配置流程:
创建security配置类

在这个类内声明AuthenticationManager(包括验证用户名密码的service、对密码校验加密的Encoder比如MD5、强散列哈希加密实现,同时也做密码校验、用户锁定校验等)

还有filterChain(过滤链)这个过滤链声明过滤规则比如

  • 开启跨域请求,关闭跨站请求伪造检验(因为用的jwt不需要cookie或session)直接关了即可省性能
  • 关闭session策略省内存
  • 设置门禁规则,比如拦截器过滤某些请求打开某些请求(login、register)
  • 设置无状态登录处理
  • 可以加入自定义逻辑校验比如自己的jwt校验

登录流程:

  • 前端vue发送请求/login
  • [最先执行的过滤器](登录状态\未登录状态)【他的作用就是后续请求不包含登录请求,虽然登录请求也会执行这个但是毫无作用,后续请求进来之后如果用户是已登录状态那么解析jwt获取redis信息后将用户信息存到上下文保证接口后续业务逻辑调用用得到这个上下文】执行JwtAuthenticationTokenFilter的过滤器,如果用户携带token的jwt会解析jwt的UUID获取后在redis内查询用户指纹并把用户指纹设置到全局上下文对象(本地线程SecurityContextHolder)内,如果用户没有携带jwt或jwt失效了那就直接放行交给后面的过滤链处理这个异常或登录。
  • 接着执行Security的拦截器,拦截除配置类中声明不需要拦截的以外的任何请求,如果其他请求的上下文对象SecurityContextHolder的authentication没有东西则表明他没有进行登录,那么直接抛全局异常,这个异常会被security捕获然后执行配置类中的exceptionHandling参数设置的自定义的异常处理器返回“401不能访问系统资源,无权限”
  • (登录请求)到自己的controller登录接口,在接口内用用户名密码生成一个UserNamePasswordAuthenticationToken(用户标志)
  • 把他传给AuthenticationManager调用它的authenticate(用户标志)方法,他底层会去遍历一个(ProviderManager继承了AuthenticationManager)的子类,从这些provider中寻找我们在配置类设置的校验用户名密码的service也就是后面使用到的UserDetailsService实现类
  • 这个实现类只接收用户名,是我们自己实现的,他可以通过去我们的数据库查找对应的用户数据,这个业务逻辑是我们自己写的,他的返回值是一个UserDetails实现类,我们自己创建实现类即可,但是这个接口有很多麻烦的方法,所以在创建自己实现类的时候可以直接继承User类,这个User类已经实现了UserDetails,其实小型业务逻辑用这个User比较好,这个返回值交给AuthenticationManager他会在底层用在配置类声明的密码加密方式进行密码对比,成功后返回一个authencation对象,失败则报错
  • 这个authentication对象会回到自己写的登录接口,可以通过他去获取自己写的UserDetails的实现类、还可以拿到权限等信息。
  • 如果直接绕过登录去访问其他接口会被security拦截并直接拒绝访问,过滤链内配置了.anyRequest().authenticated()

在这里插入图片描述

○ 管权限:

管权限也就是用户授权,根据用户的所属角色查询用户可以访问某个菜单或按钮的集合,找到对应角色所能操作的权限,授予用户权限,因为不同用户对应不同角色,不同角色对应不同权限集合,实现动态权限管理,细粒度权限管理

○ 保安全:

○ 登录校验使用

加入依赖:

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

△ 实现security配置类

package com.note.config;

import com.note.handler.AuthenticationEntryPointImpl;
import com.note.handler.JwtAuthenticationTokenFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {

    /**
     * 自定义用户信息获取实现类
     */
    private final UserDetailsService userDetailsService;

    /**
     * jwt校验过滤器
     */
    private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    /**
     * 单点登录
     */
    private final AuthenticationEntryPointImpl authenticationEntryPoint;

    /**
     * 身份验证实现
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService); //数据库获取用户信息业务逻辑 自己实现
        daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); //密码校验规则
        return new ProviderManager(daoAuthenticationProvider);
    }

    /**
     * 配置过滤链
     *
     * @param http security过滤规则
     * @return http
     */
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                // CSRF禁用,因为不使用session
                .csrf(AbstractHttpConfigurer::disable)
                // 明确告诉 Spring Security:不要创建 Session,不要存 Cookie
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 3. 【路径鉴权规则】(核心)
                // 4. 【异常处理】
                // 如果没带 Token 访问,返回自定义的 JSON (401),而不是默认的报错页
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(authenticationEntryPoint))
                .authorizeHttpRequests(auth -> {
                    // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                    auth.antMatchers("/login", "/register", "/captchaImage").permitAll()
                            // 静态资源,可匿名访问
                            .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
                            .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
                            // 除上面外的所有请求全部需要鉴权认证
                            .anyRequest().authenticated();
                })
                // 5. 【插入过滤器】(关键)
                // 把我们的 JWT 过滤器插到 UsernamePasswordAuthenticationFilter 之前
                // 确保请求进来先查 Token
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

△ [最先执行的过滤] 在以上配置中的JwtAuthenticationTokenFilter实现

这个JwtAuthenticationTokenFilter在任何请求(包括login)都会拦截,相当于一个拦截器,这个拦截器的作用就是:用户已经登录后将用户指纹设置到全局上下文的本地线程内,但是登录注册请求进来之后不会有任何作为,直接放行了。

package com.note.handler;

import com.note.common.utils.JwtUtil;
import com.note.domain.model.LoginBodyAuthentication;
import com.note.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    private final JwtProperties jwtProperties;

    private final RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //从请求头获取jwt令牌
        String authorization = request.getHeader("Authorization");

        //如果令牌存在且以Bearer开头则开始令牌验证与取信息
        if (authorization != null && authorization.startsWith("Bearer ")) {
            //剔除jwt前缀
            String jwt = JwtUtil.extractTokenFromHeader(authorization, "Bearer");

            //开始对jwt进行防伪验证与过期验证
            if (JwtUtil.validateToken(jwtProperties.getSecretKey(), jwt)) {
                //开始获取UUID
                Claims claims = JwtUtil.getClaimsFromToken(jwtProperties.getSecretKey(), jwt);

                //拿到用户唯一标识UUID
                String uuid = (String) claims.get("UUID");

                //从redis获取用户指纹
                LoginBodyAuthentication userDetails = (LoginBodyAuthentication) redisTemplate.opsForValue().get(uuid);

                if (userDetails != null) {
                    //拼装authenticationToken放到上下文对象内
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                            userDetails, // 1. Principal: 主角(用户对象)
                            null, // 2. Credentials: 凭证(密码/Token)。登录后通常设为 null,为了安全
                            userDetails.getAuthorities() // 3. Authorities: 权限列表(很重要!后续鉴权全靠它)
                    );

                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

△ [security拦截器处理类] AuthenticationEntryPointImpl

这个类的作用就是拦截login、register等白名单请求之外的任何请求,如果后续请求进来,发现他们的本地线程SecurityContextHolder没有任何东西存在说明用户未登录,这个上下文对象是在前面的Filter设置的,没有的话进行处理,相当于一个警卫了,但是如果在登录时调用authenticate发送loadUserByUserName返回user给AuthenticationManager发现用户已经被锁定或过期也会走到这个处理类。

package com.note.handler;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 设置响应头
        response.setStatus(200); // 建议返回 200,在 body 里标识 code=401,或者直接返回 401 状态码
        response.setContentType("application/json;charset=utf-8");

        // 返回 JSON
        String json = "{\"code\": 401, \"msg\": \"认证失败,无法访问系统资源\"}";
        response.getWriter().print(json);
    }
}

△ UserDetails实现类

这个类的作用就是当UserNamePasswordAuthenticationToken发给AuthenticationManager后,调用loadUserByUserName()从数据获取用户数据而返回给AuthenticationManager的用户对象,必须实现UserDetails接口,他也可以充当用户指纹。

package com.note.domain.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.note.domain.entity.SysUser;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@EqualsAndHashCode(callSuper = true)
@Data
public class LoginBodyAuthentication extends SysUser implements UserDetails {

    private static final long serialVersionUID = 1L;

    public LoginBodyAuthentication() {
    }

    /**
     * 用户权限不需要存入redis直接忽视 不然会报错
     */
    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return new ArrayList<GrantedAuthority>();
    }

    @Override
    public String getPassword() {
        return super.getPassword();
    }

    /**
     * 因为父类SysUser有一个getUserName和这个getUsername都会把用户名存到redis会冲突
     * 所以只留一个即可
     */
    @JsonIgnore
    @Override
    public String getUsername() {
        return super.getUserName();
    }

    /**
     * 以下四个方法必须加上@JsonIgnore不然序列化redis会出错
     */
    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }
}

△ UserDetailsService实现类

这个类的作用就是在UserNamePasswordAuthenticationToken发给AuthenticationManager后,遍历provider而选择一个合适的provider来调用loadUserByUserName(),这个合适的provider就是实现了UserDetailsService的实现类,他来处理从数据库获取用户信息的业务,并把这个用户信息返回给AuthenticationManager处理密码校验

package com.note.service.impl;

import com.note.domain.entity.SysUser;
import com.note.domain.model.LoginBodyAuthentication;
import com.note.service.ISysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final ISysUserService userService;

    /**
     * 执行用户信息获取
     *
     * @param username 用户名
     * @return 结果
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = userService.selectUserByUserName(username);
        if (sysUser == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        LoginBodyAuthentication loginBody = new LoginBodyAuthentication();
        BeanUtils.copyProperties(sysUser, loginBody);

        return loginBody;
    }
}

△ login接口

package com.note.user.controller;

import com.note.common.domain.AjaxResult;
import com.note.common.utils.JwtUtil;
import com.note.domain.entity.SysMenu;
import com.note.domain.model.LoginBodyAuthentication;
import com.note.domain.vo.LoginBodyDTO;
import com.note.properties.JwtProperties;
import com.note.service.ISysMenuService;
import com.note.service.ISysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class LoginController {

    private final ISysUserService sysUserService;

    private final JwtProperties jwtProperties;

    private final ISysMenuService sysMenuService;

    private final RedisTemplate<String, Object> redisTemplate;

    private final AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBodyDTO loginBodyVO) {
        //执行用户信息校验功能
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                loginBodyVO.getUsername(), loginBodyVO.getPassword()
        );

        //执行验证操作 他会自动调用userDetailsServiceImpl.loadUserByUserName(username)
        Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);

        //获取登录后的用户
        LoginBodyAuthentication sysUser = (LoginBodyAuthentication) authenticate.getPrincipal();

        //查询用户的平铺menu列表集合 多对多联表查询
        List<SysMenu> sysMenus = sysUserService.selectMenusByUserId(sysUser.getUserId());

        //封装列表集合成树状结构
        List<SysMenu> treeNodes = sysMenuService.buildTree(sysMenus);

        //生成jwt
        HashMap<String, Object> claims = new HashMap<>();
        String uuid = UUID.randomUUID().toString();
        claims.put("UUID", uuid);
        String jwtToken = JwtUtil.createJwtToken(
                jwtProperties.getSecretKey(),
                jwtProperties.getTtl(),
                claims
        );

        //将用户指纹存入redis 设置过期时间 方便扩展双过期验证与用户过期登录
        redisTemplate.opsForValue().set(uuid, sysUser, jwtProperties.getTtl() / 4, TimeUnit.MILLISECONDS);

        //返回前端树状结构与jwt
        HashMap<String, Object> view = new HashMap<>();
        view.put("token", jwtToken);
        view.put("routers", treeNodes);
        return AjaxResult.success(view);
    }

}

○ 权限授权使用

权限校验就是比登录校验多了一个MethodSecurityInterceptor的AOP权限注解校验而已,这个作用就是当用户登录成功走过security自带拦截器之后,他从上下文中拿到用户权限列表和角色列表然后和目标接口需要的权限角色进行对比,发现列表存不存在该权限、角色,决定放不放行,他在security自带拦截器之后,在调用接口方法之前进行

△ 修改UserDetails实现类让他可以存权限和角色

现在逻辑是在UserDetailsService实现类中查出用户menu菜单,从这个菜单中获取所有的权限拼装成String集合放到UserDetails实现类中,在类中将权限放进getAuthorities内即可

package com.note.domain.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.note.domain.entity.SysUser;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@EqualsAndHashCode(callSuper = true)
@Data
public class LoginBodyAuthentication extends SysUser implements UserDetails {

    private static final long serialVersionUID = 1L;

    /**
     * 设置权限字符串集合 比如admin:user:list等
     */
    private List<String> permissions;

   ......

    /**
     * 设置角色字符串集合 比如admin、common等
     */
    private List<String> roleList;

    public LoginBodyAuthentication() {
    }

    /**
     * 用户权限不需要存入redis直接忽视 不然会报错
     * 往authorities内存储所有权限字符串、角色字符串
     */
    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    	if (roleList == null || roleList.isEmpty() || permissions == null || permissions.isEmpty()) {
            return new ArrayList<>();
        }
        List<SimpleGrantedAuthority> collect = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        collect.addAll(roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
        return collect;
    }

  ......
}

△ 修改UserDetailsService实现类让他查出权限列表

因为UserDetailsService只在用户登录时查询一次数据库,所以第一次查完之后以后就不查数据库权限列表,因为如果用户每次发送请求都要查一个那么数据库压力巨大,登录查完一次后,存到redis,以后权限校验直接在redis内获取权限列表即可,效率极高。

package com.note.service.impl;

import com.note.domain.entity.SysMenu;
import com.note.domain.entity.SysRole;
import com.note.domain.entity.SysUser;
import com.note.domain.model.LoginBodyAuthentication;
import com.note.service.ISysRoleService;
import com.note.service.ISysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final ISysUserService userService;

    private final ISysUserService sysUserService;

    private final ISysRoleService sysRoleService;

    /**
     * 执行用户信息获取
     *
     * @param username 用户名
     * @return 结果
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = userService.selectUserByUserName(username);
        if (sysUser == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        //查询用户的平铺menu列表集合 多对多联表查询
        List<SysMenu> sysMenus = sysUserService.selectMenusByUserId(sysUser.getUserId());

        List<SysRole> sysRoles = sysRoleService.selectRolesByUserId(sysUser.getUserId());

        //获取权限字符集合
        List<String> authorities = sysMenus.stream().map(SysMenu::getPerms).filter(StringUtils::hasText).collect(Collectors.toList());

        //获取角色字符集合 因为判断的时候security会自动在角色前加一个前缀ROLE_所以我们也要加上ROLE_保持一致
        List<String> roles = sysRoles.stream().map(SysRole::getRoleKey).filter(StringUtils::hasText).collect(Collectors.toList());
        List<String> prefixRoles = roles.stream().map(role -> "ROLE_" + role).collect(Collectors.toList());

        LoginBodyAuthentication loginBody = new LoginBodyAuthentication();
        BeanUtils.copyProperties(sysUser, loginBody);

        //设置权限 这样返回的authentication就有getAuthorities了
        loginBody.setPermissions(authorities);

        //设置角色 可以在@PreAuthorize(hasRole)判断有没有角色
        loginBody.setRoleList(prefixRoles);

        return loginBody;
    }
}

△ 开始方法级权限校验

在security配置类中开启权限校验注解

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) //开启方法级权限校验
public class SpringSecurityConfig {
		......
}

使用@PreAuthorize注解标注方法需要什么权限来校验
这个接口就是判断用户必须有system:user:list并且有admin角色才可以访问

@GetMapping
    @PreAuthorize("hasAuthority('system:user:list') or hasAnyRole('admin')")
    public AjaxResult getResource() {
        System.out.println("获取内部资源接口");
        return AjaxResult.success("获取资源成功");
    }

若依思想:但是security这样做就很大的局限性,比如管理员访问接口时,还要进行权限校验,本来管理员就是可以默认访问所有接口的,这样会多此一举,比如还要做一些复杂校验,比如只有在这个部门且入职满3年的用户,或者是该部门的经理,才能查看工资,他的注解就会很长

@PreAuthorize("hasRole('manager') or (authentication.principal.deptId == #deptId and ...)")

所以若依就把security校验解释权交给自己
它不是单纯为了跳过权限,而是为了接管鉴权逻辑的解释权。
原生:Spring 说什么就是什么(必须精确匹配,必须加前缀)。
@ss:我说是就是(我可以让 admin 跳过,我可以自定义比对规则,我可以写复杂逻辑)。

创建ss(就是注册一个Bean):
该方法从上下文获取authentication,这个上下文对象在请求首次到达后端是,被TokenFilter过滤已经拿到设置到上下文了,所以这里可以直接拿到

package com.note.service.impl;

import com.note.domain.model.LoginBodyAuthentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

@Service("ss")
public class AuthoritiesService {

    /**
     * 判断接口是否有该权限,如果是管理员直接放行
     *
     * @param authority 权限
     * @return 结果
     */
    public boolean hasPermission(String authority) {
        //从security的上下文中获取用户指纹
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        //获取用户指纹
        LoginBodyAuthentication sysUser = (LoginBodyAuthentication) authentication.getPrincipal();

        //管理员直接放行
        if (sysUser.getUserId().equals(1L)) {
            return true;
        }

        //开始验证权限
        for (String auth : sysUser.getPermissions()) {
            if (auth.equals(authority)) {
                return true;
            }
        }

        return false;
    }

    /**
     * 验证用户角色 同理
     *
     * @param authority 角色
     * @return 结果
     */
    public boolean hasRole(String authority) {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        LoginBodyAuthentication sysUser = (LoginBodyAuthentication) authentication.getPrincipal();

        if (sysUser.getUserId().equals(1L)) {
            return true;
        }

        for (String auth : sysUser.getRoleList()) {
            if (auth.equals(authority)) {
                return true;
            }
        }

        return false;
    }
}

△ 后续请求接口

在目标方法设置权限校验:

@GetMapping
@PreAuthorize("@ss.hasPermission('system:user:resource')")
public AjaxResult getResource() {
    System.out.println("获取内部资源接口");
    return AjaxResult.success("获取资源成功");
}

Login接口存入的redis内部
在这里插入图片描述

△ 权限校验流程图

在这里插入图片描述

● 笔记项目[Spring Security]

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐