前言
最近在做一个管理系统,神奇的是最后发现登录模块竟然没有人安排人去做(不得不吐槽一下公司的开发流程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写的,因此就不拿出来献丑了。