权限管理模块——表结构设计

权限管理模块——表结构设计

敲得码黛 599 2020-07-30

前言

最近在做一个管理系统,神奇的是最后发现登录模块竟然没有人安排人去做(不得不吐槽一下公司的开发流程emm),好在这个管理系统是内部使用的,目前基本没什么用户。于是产品小姐姐就把这块需求直接划分给了我。

技术选型

第二天一大早我就开始考虑这件事情:脑海中的第一方案就是使用shiro来做权限认证这个需求,因为之前也接触过shiro,但是由于不是自己开发的且开发完成后几乎没有过这方面的需求,因此也导致自己对shiro始终处于一知半解的程度。正好借此机会来巩固一下自己的技术。

至于其他方面的原因肯定也是有的:shiro框架使用较为广泛、也相对成熟,除认证授权外还提供会话管理等强大功能。

需求分析

由于时间紧迫、产品小姐姐对这块的要求也没那么严苛、跟我说只需要可以通过用户名+密码实现登录功能就完事了(其实是让我CV)。但是作为一条有梦想、有追求的程序员,怎会甘心如此?至少也要考虑到扩展性、可读性、可维护性吧。这样也不至于让后面接手的兄弟骂自己不是。于是我按照经典的五张表接口重新设计了这个需求。时间有限,先完成用户认证、授权(动态菜单)这两个功能。

表结构设计

趁着年轻还有头发、我赶紧设计出了如下的几张表。

用户表

CREATE TABLE `user` (
  `id` char(32) NOT NULL COMMENT '用户id',
  `account` varchar(20) NOT NULL COMMENT '用户账号',
  `password` varchar(32) NOT NULL COMMENT '用户密码',
  `mobile` varchar(11) DEFAULT NULL COMMENT '用户手机号',
  `email` varchar(30) DEFAULT NULL COMMENT '邮箱',
  `status` varchar(10) NOT NULL COMMENT '用户状态:A正常状态',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '编辑时间',
  PRIMARY KEY (`id`),
  KEY `uni_account` (`account`) COMMENT '账号唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

用户表用于存放用户账号信息,其中用32位uuid作为唯一主键标识、用户账号及用户密码可以用于验证用户是否合法、用户邮箱、手机号两个字段可以用于用户丢失密码后的找回功能(暂时不做、方便后期扩展)、用户状态可以用于用户区分及逻辑删除等功能。创建时间、编辑时间仅做记录

角色表

CREATE TABLE `user_role` (
  `id` char(32) NOT NULL,
  `name` varchar(20) NOT NULL COMMENT '角色名称',
  `descript` varchar(50) DEFAULT NULL COMMENT '角色描述',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NOT NULL COMMENT '编辑时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';

角色表的可以看做是一组资源权限的集合。用户通过角色来关联一组具体的权限,这样就可以通过为用户添加角色的方式,来给用户进行批量授权。当然也可以用于区分用户身份

权限表

CREATE TABLE `user_permission` (
  `id` char(32) NOT NULL,
  `resource_url` varchar(50) NOT NULL COMMENT '资源路径(一般用于页面跳转)',
  `resource_name` varchar(50) NOT NULL COMMENT '资源名称',
  `resource_type` varchar(30) NOT NULL COMMENT '资源类型(目录:MENU,功能按钮:FUNCTION,页面跳转:FORWARD)',
  `resource_mark` varchar(20) NOT NULL COMMENT '权限标识',
  `resource_seq` int(30) DEFAULT NULL COMMENT '显示顺序',
  `resource_icon` varchar(50) DEFAULT NULL COMMENT '资源图标',
  `parent_resource` char(32) DEFAULT NULL COMMENT '父资源id(同表关联,可为空)',
  `descript` varchar(50) DEFAULT NULL COMMENT '描述信息',
  `create_time` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '编辑时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='资源权限表(目录及功能菜单)';

资源权限表是作为用户权限最小粒度的划分,这里我通过其表现方式不同将其划分为了3大类,分别是目录(MENU)、功能按钮(FUNCTION)、页面跳转(FORWARD)。目录也可以称之为菜单,决定了用户登录后可以访问到哪些菜单,这些菜单(MENU)通过parent_resource字段形成树状结构。而树状菜单的叶子节点则是页面跳转(FORWARD)类型,当用户点击具体的FORWARD时,可以通过对应的资源路径(resource_url)跳转到相应页面。至于FUNCTION权限类型,则是用于控制页面上具体的某个功能按钮,是针对FORWARD更细程度的划分。

资源名称(resource_name)作为MENU、FORWARD显示的名称、FUNCTION可按需使用。权限标识(resource_mark)用于存放shiro的权限标识符、Shiro通过此字段验证用户是否具有该权限【如果不使用shiro的授权模块则可以省略此字段】,显示顺序(resource_seq)是用于处理同级目录下的排列顺序问题,数字小的排前面。资源图标、描述信息作为保留字段、暂无实际意义

用户角色中间表

CREATE TABLE `user_role_relation` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` char(32) DEFAULT NULL COMMENT '用户id',
  `role_id` char(32) DEFAULT NULL COMMENT '权限id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '编辑时间',
  PRIMARY KEY (`id`),
  KEY `index_user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8 COMMENT='用户角色关联表';

用户表<>角色表:关联表、多对多关系、用户通过此表绑定指定的角色

角色权限中间表

CREATE TABLE `user_role_permission_relation` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `permission_id` char(32) NOT NULL COMMENT '权限id',
  `role_id` char(32) NOT NULL COMMENT '角色id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '编辑时间',
  PRIMARY KEY (`id`),
  KEY `index_role_id` (`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8 COMMENT='角色权限关联表';

角色表<>权限表:关联表、多对多关系、角色通过此表绑定指定权限

代码实现

环境准备

  • IntelliJ IDEA

  • Maven

  • Java8

  • SpringBoot+Mybatis+Mysql

shiro依赖

<!-- shiro -->
<dependency>
   <groupId>org.apache.shiro</groupId>
   <artifactId>shiro-spring</artifactId>
   <version>1.3.2</version>
</dependency>

shiro相关配置

ShiroConfig
/**
 * Shiro配置
 *
 * @author hcq
 * @date 2020/6/23 9:51
 */
@Configuration
public class ShiroConfig {

    /**
     * ShiroFilter:拦截未经认证的用户请求、使其跳转到登录页面
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 设置安全管理器,通过此管理器进行认证及授权
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 未认证通过的用户访问敏感资源时的跳转路径
        shiroFilterFactoryBean.setLoginUrl("/login");
        // 用户访问未经授权的资源时的跳转路径
        shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");
        // 敏感资源设置(anon表示允许匿名访问,authc代表需要进行认证)
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/login", "anon");
        filterChainDefinitionMap.put("/logout", "anon");
        filterChainDefinitionMap.put("/verifiy/getImg", "anon");
        filterChainDefinitionMap.put("/verificationCode", "anon");
        filterChainDefinitionMap.put("/resources/**", "anon");
        filterChainDefinitionMap.put("/images/**", "anon");

        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * 安全管理器:
     * 目前仅用于管理Realm及管理凭证匹配器
     * Shiro提供了多种凭证匹配器用于进行凭证的匹配
     * HashedCredentialsMatcher匹配器会对明文密码进行散列后再匹配
     */
    @Bean
    public SecurityManager securityManager(ShiroRealm realm, HashedCredentialsMatcher credentialsMatcher) {
        DefaultWebSecurityManager defaultSecurityManager = new DefaultWebSecurityManager();
        realm.setCredentialsMatcher(credentialsMatcher);
        defaultSecurityManager.setRealm(realm);
        return defaultSecurityManager;
    }


    /**
     * [凭证/认证信息/密码]匹配器(对明文密码进行散列)
     */
    @Bean
    public HashedCredentialsMatcher credentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName(ShiroUtil.SUMMARY);
        hashedCredentialsMatcher.setHashIterations(ShiroUtil.COUNT);
        return hashedCredentialsMatcher;
    }

}
ShiroRealm
/**
 * @author hcq
 * @date 2020/6/23 9:59
 */
@Component
@AllArgsConstructor
public class ShiroRealm extends AuthorizingRealm {

    private final UserMapper userMapper;
    private final UserRoleMapper userRoleMapper;
    private final UserPermissionMapper permissionMapper;

    /**
     * 授权
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        // 获取认证通过的用户信息
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        // 绑定用户角色及资源权限
        List<UserRole> userRoles = userRoleMapper.selectListByAccount(user.getAccount());
        if(BlankUtils.isNotBlank(userRoles)){
            userRoles.forEach( role ->{
                info.addRole(role.getName());
                List<UserPermission> permission = permissionMapper.selectListByRole(role.getId());
                if(BlankUtils.isNotBlank(permission)){
                    permission.forEach(per -> info.addStringPermission(per.getResourceMark()));
                }
            });
        }
        return info;
    }

    /**
     * 认证
     *
     * @param authToken 认证信息
     * @throws AuthenticationException 认证失败
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authToken) throws AuthenticationException {
        // 通过用户输入的账号查询用户信息
        String account = (String) authToken.getPrincipal();
        Wrapper<User> wrapper = new QueryWrapper<User>().lambda()
                .eq(User::getAccount,account)
                .ne(User::getStatus, UserStatusEnum.DELETE.getStatus());
        User user=userMapper.selectOne(wrapper);
        if(BlankUtils.isBlank(user)){
            throw new UnknownAccountException();
        }
        String salt=user.getId();
        String password=user.getPassword();
       
        // user:用户信息  password:用户真实密码  salt:盐值  realm名称
        // 凭证匹配器根据password、salt,匹配用户输入的密码,
        // 匹配失败将抛出对应异常,匹配成功后可以通过Subject取出user信息
        return new SimpleAuthenticationInfo(user, password, ByteSource.Util.bytes(salt), getName());
    }

}
ShiroUtil
/**
 * Shrio
 * @author hcq
 * @date 2020/6/23 10:58
 */
public class ShiroUtil {

    /**
     * 摘要方式
     */
    public static final String SUMMARY = "MD5";
    /**
     * 摘要次数
     */
    public static final Integer COUNT = 10;

    /**
     * 密码摘要
     *
     * @param password 明文密码
     * @param salt     盐值
     */
    public static String passwordMd5(String password, String salt) {
        SimpleHash result = new SimpleHash(SUMMARY, password, salt, COUNT);
        return result.toString();
    }

    public static String passwordMd5(String password) {
        return passwordMd5(password,"");
    }

}

用户认证

Controller
/**
 * @author zhangliuming
 * <p>
 * 登录控制层
 * </p>
 * @date 2020/6/8
 */
@Controller
@Slf4j
@AllArgsConstructor
public class LoginController {

    private final UserRoleMapper userRoleMapper;
    private final UserPermissionMapper permissionMapper;

    /**
     * 首页
     *
     * @return
     * @author zhangliuming
     * @date 2020/6/8
     */
    @RequestMapping(value = {"/index"})
    public String index(Model model) {
        Subject subject = SecurityUtils.getSubject();
        User user = (User) subject.getPrincipal();
        if(user == null || !subject.isAuthenticated()){
            return "login";
        }
        // 查询用户权限树
        List<UserRole> roles = userRoleMapper.selectListByAccount(user.getAccount());
		List<UserPermission> permissions = new ArrayList<>(32);
        roles.forEach(role -> permissions.addAll(permissionMapper.selectListByRole(role.getId())));
		Set<TreePermission> tree=getTreePermission(permissions);
		model.addAttribute("menuTree",tree);
        return "index";
    }


    /**
     * 登录
     *
     * @param name
     * @param pwd
     * @return
     * @author zhangliuming
     * @date 2020/6/8
     */
    @RequestMapping(value = {"/login"})
    @ResponseBody
    public Out verificationCode(String name, String pwd) {
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(name, pwd);
        try {
            subject.login(token);
            if (subject.isAuthenticated()) {
                //认证通过
                return Output.ok();
            } else {
                //认证失败
                return Output.fail();
            }
        } catch (UnknownAccountException uae) {
            return Output.fail("未知账户");
        } catch (IncorrectCredentialsException ice) {
            return Output.fail("密码不正确");
        } catch (LockedAccountException lae) {
            return Output.fail("账户已锁定");
        } catch (ExcessiveAttemptsException eae) {
            return Output.fail("用户名或密码错误次数过多");
        } catch (AuthenticationException ae) {
            return Output.fail("用户名或密码不正确!");
        } catch (Exception e) {
            log.error("登录服务异常:{}", e.getMessage());
            return Output.fail();
        }
    }

    /**
     * 构建树形菜单
     *
     * @param permissions 权限集合
     * @return 用户树形菜单
     */
    private Set<TreePermission> getTreePermission(List<UserPermission> permissions) {
        // 顶层菜单
        Set<TreePermission> upMenu = new TreeSet<>();
        permissions.forEach(permission -> {
            // 判断是否是顶级菜单
            if (permission.isUpMenu()) {
                upMenu.add(new TreePermission(permission));
            }
        });
        // 组装子级菜单
        upMenu.forEach(root -> permissions.forEach(root::add));
        return upMenu;
    }
}

TreePermission

封装树形权限菜单

/**
 * 树形菜单
 *
 * @author hcq
 * @date 2020/6/24 10:17
 */
@Data
public class TreePermission implements Comparable<TreePermission>{

    /**
     * 当前菜单
     */
    private UserPermission current;

    /**
     * 子菜单
     */
    private Set<TreePermission> children = new TreeSet<>();

    public TreePermission(UserPermission root) {
        current = root;
    }

    /**
     * 添加权限节点
     * @param node 权限
     */
    public boolean add(UserPermission node) {
        if(BlankUtils.isBlank(current)){
            return false;
        }else if (current.getId().equals(node.getParentResource())) {
            // node节点属于当前层级的子节点
            children.add(new TreePermission(node));
            return true;
        }else if(BlankUtils.isBlank(children)){
            return false;
        }else{
            // node节点不属于当前层级的子节点,遍历下一层级节点
            for(TreePermission child : children){
                if(child.add(node)){
                    return true;
                }
            }
        }
        return false;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        TreePermission that = (TreePermission) o;
        return Objects.equals(current.getId(), that.current.getId());
    }

    @Override
    public int hashCode() {
        return Objects.hash(current.getId());
    }

    /**
     * 实现compareTo
     * 用于SortSet根据ResourceSeq进行排序及通过id进行去重
     * @param  o 需要对比的元素    this:需要添加的元素
     * @return 0:代表两个元素节点相等,新元素节点将会添加失败
     *         1:代表新元素节点大于当前元素,新元素节点将会被添加到当前元素后面
     *        -1:代表新元素节点小于当前元素,新元素节点将会被添加到当前元素前面
     */
    @Override
    public int compareTo(TreePermission o) {
        if(this.current.getId().equals(o.getCurrent().getId())){
            return 0;
        }
        if(this.current.getResourceSeq() < o.getCurrent().getResourceSeq()){
            return -1;
        }
       return 1;
    }

}

TreePermission是对资源权限封装的一个略为简单的树状结构,重写equals、hashcode是为了用于Set集合可以根据id进行自动去重。实现Comparable接口是为了可以通过resourceSeq字段进行自动排序。

总结

这篇文章仅仅只涉及到Shiro的认证及加密模块,Shiro的自定义注解权限校验以及Session管理都尚未处理(没有业务场景是最蓝瘦的事情啊)。

ps:前端代码是用jsp写的,因此就不拿出来献丑了。


# shiro # 权限管理 # 用户角色