1.2 自定义application.yml配置jwt属性org.springframework.boot spring-boot-starter-securityio.jsonwebtoken jjwt0.9.0
# jwt令牌 jwt: # JWT存储的请求头 # 正常前端请求 tokenHeader:Authorization 是key tokenHead:Bearer是它的 value 加上空格然后jwt令牌,组成一个请求 tokenHeader: Authorization # JWT 加解密使用的密钥 secret: yeb-secret # JWT的超期限时间(60*60*24) expiration: 604800 # JWT 负载中拿到开头 tokenHead: Bearer1.3 编写jwt工具类
import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; @Component @SuppressWarnings("all") public class JwtTokenUtils { //用户名的key private static final String CLAIM_KEY_USERNAME="sub"; //jwt创建时间 private static final String CLAIM_KEY_CREATED="created"; @Value("${jwt.secret}")//jwt 密钥 private String secret; @Value("${jwt.expiration}")//失效时间 private Long expiration; //用户信息通过Security中的 UserDetails 拿取 public String generateToken(UserDetails userDetails){ Map1.4 登录流程 1.5 security登录流程jwtToken = new HashMap<>(); jwtToken.put(CLAIM_KEY_USERNAME,userDetails.getUsername()); jwtToken.put(CLAIM_KEY_CREATED,new Date()); //根据荷载生成jwt return generateToken(jwtToken); } public String getUserNameFromToken(String token){ String username; try { Claims claims = getClaimsFromToken(token); //通过荷载 claims 就可以拿到用户名 username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } public boolean validateToken(String token, UserDetails userDetails) { String username = getUserNameFromToken(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } public boolean canRefresh(String token){ return !isTokenExpired(token); } public String refreshToken(String token){ Claims claims = getClaimsFromToken(token); //将创建时间改成当前时间,就相当于去刷新了 claims.put(CLAIM_KEY_CREATED,new Date()); return generateToken(claims); } private boolean isTokenExpired(String token) { Date expireDate = getExpiredDateFromToken(token); //判断token时间是否是当前时间的前面 .before return expireDate.before(new Date()); } private Date getExpiredDateFromToken(String token) { //从token里面获取荷载 //因为token的过期时间有对应的数据,设置过的,荷载里面就有设置过的数据 Claims claims = getClaimsFromToken(token); return claims.getExpiration(); } private Claims getClaimsFromToken(String token) { //拿到荷载 Claims claims = null ; try { claims = Jwts.parser() //签名 .setSigningKey(secret) //密钥 .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { e.printStackTrace(); } return claims; } private String generateToken(Map claims){ return Jwts.builder() .setClaims(claims) //失效时间 .setExpiration(generateExpirationDate()) //签名 .signWith(SignatureAlgorithm.HS512, secret) .compact(); } private Date generateExpirationDate() { return new Date(System.currentTimeMillis() + expiration * 1000); } }
UsernamePasswordAuthenticationFilter:是我们最常用的用户名和密码认证方式的主要处理类,构造了一个UsernamePasswordAuthenticationToken对象实现类,将用请求信息封authentication
Authentication接口: 封装了用户相关信息
AuthenticationManage:定义了认证Authentication的方法,是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名+密码登录,同时允许用户使用邮箱+密码,手机号码+密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说AuthenticationManager一般不直接认证,AuthenticationManager接口的常用实现类ProviderManager 内部会维护一个List列表,存放多种认证方式,实际上这是委托者模式的应用(Delegate)。也就是说,核心的认证入口始终只有一个:AuthenticationManager
AuthenticationManager,ProviderManager ,AuthenticationProvider…
用户名+密码(UsernamePasswordAuthenticationToken),邮箱+密码,手机号码+密码登录则对应了三个AuthenticationProvider
DaoAuthenticationProvider:用于解析并认证 UsernamePasswordAuthenticationToken 的这样一个认证服务提供者,对应以上的几种登录方式。
UserDetailsService接口:Spring Security 会将前端填写的username 传给 UserDetailService.loadByUserName方法。我们只需要从数据库中根据用户名查找到用户信息然后封装为UserDetails的实现类返回给SpringSecurity 即可,自己不需要进行密码的比对工作,密码比对交由SpringSecurity处理。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
继承UserDetails
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("f_user") @ApiModel(value="FUser对象") public class FUser implements Serializable, UserDetails{ private static final long serialVersionUID = 1L; @TableId(type = IdType.ASSIGN_UUID) @ApiModelProperty(value = "用户唯一标识") private String id; @ApiModelProperty(value = "用户名") private String username; @ApiModelProperty(value = "手机号") private String phone; @ApiModelProperty(value = "密码") private String password; //自定义解析器 @JsonDeserialize(using = CustomAuthorityDeserializer.class) @Override public Collection extends GrantedAuthority> getAuthorities() { Listauthorities = roles .stream() //将获得的权限名字通过 SimpleGrantedAuthority 转换成授权的 url .map(role -> new SimpleGrantedAuthority(role.getName())) .collect(Collectors.toList()); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @TableField(exist = false) private List roles; }
UserController
@Api("用户接口") @RestController @RequestMapping("/user") public class FUserController { @Autowired private AccountUtil accountUtil; @Autowired private IFUserService userService; @ApiOperation("是否注册") @RequestMapping(value ="/exist",method = RequestMethod.POST) public RespBean existUser(@RequestBody FUser fUser){ String desEncrypt = accountUtil.desEncrypt(fUser.getPhone()); FUser user = userService.getOne(new QueryWrapper().eq("phone", desEncrypt)); if(ObjectUtil.isEmpty(user)){ return RespBean.success("可以注册,手机号还未注册"); } return RespBean.error("手机号已注册"); } @ApiOperation("注册") @RequestMapping(value ="/register",method = RequestMethod.PUT) public RespBean registerUser(@RequestBody FUser user){ user.setPhone(accountUtil.desEncrypt(user.getPhone())); boolean save = userService.save(user); if(save){ return RespBean.success("注册成功!"); } return RespBean.error("注册失败"); } @ApiOperation("登录之后返回token") @RequestMapping(value ="/login",method = RequestMethod.POST) public RespBean loginUser(@RequestBody LoginUser loginUser, HttpServletRequest request){ return userService.loginUser(loginUser.getPhone(),loginUser.getPassword(),request); } @ApiOperation(value = "获取当前登录用户的信息") @GetMapping("/info") public FUser getUserInfo(Principal principal){ if (principal == null){ return null; } String username = principal.getName(); FUser user = userService.getOneUser(username); user.setPassword(null); user.setRoles(userService.getRoles(user.getId())); return user; } }
服务实现类,这里我采用手机号登录,可以根据手机号查出这个对象不为空的话再去获得其
username再放给loadbyusername
@Service public class FUserServiceImpl extends ServiceImplimplements IFUserService { @Autowired private FRoleMapper roleMapper; @Autowired private JwtTokenUtils jwtTokenUtils; @Autowired private IFUserService userService; @Autowired private UserDetailsService userDetailsService; //密码加密 @Autowired private PasswordEncoder bCryptPasswordEncoder; //将配置文件中存的值取过来 @Value("${jwt.tokenHead}") private String tokenHead; @Override public RespBean loginUser(String phone, String password, HttpServletRequest request) { //查询数据库中是否存在用户并判断当前登录用户密码与数据库密码是否匹配 FUser user = userService.getUser(phone); if(ObjectUtil.isNull(user) || !bCryptPasswordEncoder.matches(password,user.getPassword())){ return RespBean.error("用户名或密码不正确,请重新输入!"); } UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername()); //更新security上下文登录用户对象,null的位置本来该放密码,但是一般不放 //userDetails.getAuthorities()是权限列表 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); // 放在security全局里面 SecurityContextHolder.getContext().setAuthentication(authentication); // 到这里说明没问题 就让他拿到令牌 String token = jwtTokenUtils.generateToken(userDetails); Map tokenMap = new HashMap<>(); tokenMap.put("token", token); tokenMap.put("tokenHead", tokenHead);//返回头部信息 // 登录成功之后返回一个token给前端 return RespBean.success("登录成功", tokenMap);//tokenMap中有 username,new Date(),tokenHead } @Override public List getRoles(String id) { return roleMapper.getRoles(id); } @Override public FUser getUser(String phone) { return userService.getOne(new QueryWrapper ().eq("phone",phone)); } @Override public FUser getOneUser(String username) { return userService.getOne(new QueryWrapper ().eq("username",username)); } }
jwt拦截器
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead; @Autowired private JwtTokenUtils jwtTokenUtils; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { //通过 request 获取请求头 String authHeader = httpServletRequest.getHeader(tokenHeader); //验证头部,不存在,或者不是以tokenHead:Bearer开头的 if (authHeader != null && authHeader.startsWith(tokenHead)){ //存在,就做一个字符串的截取,其实就是获取了登录的token String authToken = authHeader.substring(tokenHead.length()); //jwt根据token获取用户名 //token存在用户名但是未登录 String userName = jwtTokenUtils.getUserNameFromToken(authToken); if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null){ //登录 UserDetails userDetails = userDetailsService.loadUserByUsername(userName); //判断token是否有效,如果有效把他重新放到用户对象里面 if (jwtTokenUtils.validateToken(authToken,userDetails)){ UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } } //放行 filterChain.doFilter(httpServletRequest,httpServletResponse); } }
securityconfig需要继承WebSecurityConfigureAdapter
//WebSecurityConfigurerAdapter 类是个适配器, 在配置的时候,需要我们自己写个配置类去继承他,然后编写自己所特殊需要的配置 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private IAdminService adminService; @Autowired private RestAuthorizationEntryPoint restAuthorizationEntryPoint; @Autowired private RestfulAccessDeniedHandler restfulAccessDeniedHandler; @Autowired private CustomFilter customFilter; @Autowired private CustomUrlDecisionManager customUrlDecisionManager; @Override//身份验证管理生成器 protected void configure(AuthenticationManagerBuilder auth) throws Exception { //重写这个方法是因为,让登录的时候请求走自己重写的登录方法 UserDetailsService userDetailsService() //userDetailsService() 获取了用户名 //asswordEncoder(passwordEncoder())密码匹配是通过BCryptPasswordEncoder加密来完成的 auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder()); // auth.userDetailsService(userDetailsService()).passwordEncoder(NoOpPasswordEncoder.getInstance()); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers( //放行的资源路径 "/login", "/logout", "/css @Component public class CustomFilter implements FilterInvocationSecurityMetadataSource { @Autowired private IFMenuService menuService; AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public CollectiongetAttributes(Object object) throws IllegalArgumentException { //获取请求的url String requestUrl = ((FilterInvocation) object).getRequestUrl(); //根据角色去查询所有的菜单 List menus = menuService.getMenuRoleList(); //同时查询所有菜单的权限 List menusAll = menuService.list(); for (FMenu fMenu : menusAll) { if(fMenu.getUrl().equals(requestUrl)){ for (FMenu menu : menus){ //判断请求 url 与菜单角色是否匹配,如果这个url在菜单中存在但是没有匹配上就直接抛异常 if (antPathMatcher.match(menu.getUrl(),requestUrl)){ String[] strings = menu.getRoles().stream().map(FRole::getName).toArray(String[]::new); return SecurityConfig.createList(strings); } } throw new AccessDeniedException("权限不足,请联系管理员!"); } } //没匹配的 url 默认登录就可以访问 return SecurityConfig.createList("ROLE_LOGIN"); } @Override public Collection getAllConfigAttributes() { return null; } @Override public boolean supports(Class> aClass) { return false; } }
根据角色去匹配对应的url是否有权限
@Slf4j @Component public class CustomUrlDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, CollectionconfigAttributes) throws AccessDeniedException, InsufficientAuthenticationException { for (ConfigAttribute configAttribute : configAttributes) { //当前 url 所需要的角色 String needRole = configAttribute.getAttribute(); if ("ROLE_LOGIN".equals(needRole)){ if (authentication instanceof AnonymousAuthenticationToken){ throw new AccessDeniedException("尚未登录,请登录!"); }else { throw new AccessDeniedException("权限不足,请联系管理员!"); } } //判断用户角色是否为url所需要角色 Collection extends GrantedAuthority> authorities = authentication.getAuthorities(); for (GrantedAuthority authority : authorities) { log.info("当前authority:"+authority.getAuthority()); if (authority.getAuthority().equals(needRole)){ return; } } } throw new AccessDeniedException("权限不足,请联系管理员!"); } @Override public boolean supports(ConfigAttribute configAttribute) { return false; } @Override public boolean supports(Class> aClass) { return false; } }
自定义序列化
public class CustomAuthorityDeserializer extends JsonDeserializer { @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { ObjectMapper mapper = (ObjectMapper) p.getCodec(); JsonNode jsonNode = mapper.readTree(p); ListgrantedAuthorities = new LinkedList<>(); Iterator elements = jsonNode.elements(); while (elements.hasNext()){ JsonNode next = elements.next(); //找到authority字段 JsonNode authority = next.get("authority"); //序列号,让json可以解析 grantedAuthorities.add(new SimpleGrantedAuthority(authority.asText())); } return grantedAuthorities; } }
Security自定义返回结果
@Component public class RestAuthorizationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setCharacterEncoding("UTF-8"); //设置数据格式为json格式 response.setContentType("application/json"); //拿到输出流 PrintWriter out = response.getWriter(); //未登录或失效 RespBean bean = RespBean.error("未登录或用户信息过期,请重新登录!"); bean.setCode(401); out.write(new ObjectMapper().writeValueAsString(bean)); out.flush(); out.close(); } }
@Component public class RestfulAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); PrintWriter out = response.getWriter(); RespBean error = RespBean.error("权限不足,请联系管理员!"); error.setCode(403); out.write(new ObjectMapper().writeValueAsString(error)); out.flush(); out.close(); } }
根据用户id查找角色,便于后面拿角色与根据url请求的角色做对比
id, name select r.id,r.name from f_user_role ur,f_role r where ur.uid = #{id} and ur.rid = r.id
@Repository public interface FRoleMapper extends BaseMapper{ List getRoles(@Param("id")String id); }
public interface IFUserService extends IService{ RespBean loginUser(String phone, String password, HttpServletRequest request); FUser getUser(String phone); FUser getOneUser(String username); List getRoles(String id); }
根据url查询所需角色
@Repository public interface FMenuMapper extends BaseMapper{ List getMenus(); }
public interface IFMenuService extends IService{ List getMenuRoleList(); }
@Service public class FMenuServiceImpl extends ServiceImplimplements IFMenuService { @Autowired private FMenuMapper menuMapper; @Override public List getMenuRoleList() { return menuMapper.getMenus(); } }
Swagger配置,添加authrozation字段用来存放token
@Configuration @EnableSwagger2 public class SwaggerConfig { @Bean//规定扫描哪些包下面生成swagger2文档 public Docket createRestApi(){ return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) //选择扫面哪个包 .select() .apis(RequestHandlerSelectors.basePackage("wqm.store.api.controller")) //所有的路径都可以 .paths(PathSelectors.any()) .build() //给swagger2令牌,不然测试接口太繁琐,需要登录会被拦截 .securityContexts(securityContexts())//全局 .securitySchemes(securitySchemes());//安全计划 } private ApiInfo apiInfo(){ return new ApiInfoBuilder() //标题 .title("接口文档") //描述 .description("") .contact(new Contact("魏青冕","http://localhost:8080/doc.html","3139596057@qq.com")) .version("1.0") .build(); } private List securitySchemes(){ //设置请求头信息 List result = new ArrayList<>(); //令牌 ApiKey apikey = new ApiKey("authorization","authorization","Header"); result.add(apikey); return result; } private ListsecurityContexts(){ //设置需要登录认证的路径 List result = new ArrayList<>(); result.add(getContextBypath("/test3/.*")); return result; } private SecurityContext getContextBypath(String pathRegex) { return SecurityContext.builder() .securityReferences(defaultAuth()) .forPaths(PathSelectors.regex(pathRegex)) .build(); } private List defaultAuth() { List result = new ArrayList<>(); AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything"); AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; authorizationScopes[0] = authorizationScope; result.add(new SecurityReference("Authorization",authorizationScopes)); return result; } }
测试如下
3.RBAC模型设计
SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for f_menu -- ---------------------------- DROP TABLE IF EXISTS `f_menu`; CREATE TABLE `f_menu` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `url` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of f_menu -- ---------------------------- INSERT INTO `f_menu` VALUES ('67796ddf8305e03625ebdba3195ec312', '/test1'); INSERT INTO `f_menu` VALUES ('b443e9ad4898f4e5897274371562df76', '/test2'); -- ---------------------------- -- Table structure for f_menu_role -- ---------------------------- DROP TABLE IF EXISTS `f_menu_role`; CREATE TABLE `f_menu_role` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `rid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `mid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of f_menu_role -- ---------------------------- INSERT INTO `f_menu_role` VALUES ('db268eac1fc15596e175b46683066cb8', 'ab363ec9a5766ee42b15fc554d2e7c21', '67796ddf8305e03625ebdba3195ec312'); -- ---------------------------- -- Table structure for f_role -- ---------------------------- DROP TABLE IF EXISTS `f_role`; CREATE TABLE `f_role` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `name` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of f_role -- ---------------------------- INSERT INTO `f_role` VALUES ('ab363ec9a5766ee42b15fc554d2e7c21', 'ROLE_stu'); -- ---------------------------- -- Table structure for f_user -- ---------------------------- DROP TABLE IF EXISTS `f_user`; CREATE TABLE `f_user` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户唯一标识', `username` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户名', `phone` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '手机号', `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密码', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of f_user -- ---------------------------- INSERT INTO `f_user` VALUES ('1ef48d092db370165b8e4cdfac9a0187', 'cjj', '13456789457', '$2a$10$ogvUqZZAxrBwrmVI/e7.SuFYyx8my8d.9zJ6bs9lPKWvbD9eefyCe'); INSERT INTO `f_user` VALUES ('2ff299d16d007aa37007394bae9bbcac', '会飞的鱼', '15295675946', '$2a$10$ogvUqZZAxrBwrmVI/e7.SuFYyx8my8d.9zJ6bs9lPKWvbD9eefyCe'); -- ---------------------------- -- Table structure for f_user_role -- ---------------------------- DROP TABLE IF EXISTS `f_user_role`; CREATE TABLE `f_user_role` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `uid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户主键', `rid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色主键', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of f_user_role -- ---------------------------- INSERT INTO `f_user_role` VALUES ('695208cf31b61627c00bd987cfdd22e7', '2ff299d16d007aa37007394bae9bbcac', 'ab363ec9a5766ee42b15fc554d2e7c21'); SET FOREIGN_KEY_CHECKS = 1;
定义参考:RBAC权限模型[完整] - 简书 (jianshu.com)