VBlog2

前言

承接我的上一篇博客VBlog。在这里感谢 Evan-Nightly的教程
https://learner.blog.csdn.net/article/details/88925013
https://github.com/Antabot/White-Jotter

RBAC

参考 https://shuwoom.com
RBAC模型 Role-Based Access Control: 基于角色的访问控制模型。RBAC
认为授权过程可以抽象概括为:Who可以对What进行How的操作,并对这个逻
辑表达式判断是否为true的求解过程

RBAC的组成

三部分:用户、角色和权限。通过定义角色的权限,并对用户授予某个角色来
控制用户的权限,实现了用户和权限的逻辑分离,极大方便权限的管理。以
下是一些常见名词

  • User 每个用户都有一个唯一的UID识别,并授予不同的角色
  • Role 不同的角色具有不同的权限
  • Permission 访问权限
  • 用户-角色映射 用户和角色之间的映射关系
  • 角色-权限映射 角色和权限之间的映射


管理员和普通用户被授予不同的权限,普通用户只能去修改和查看个人信息,
而不能创建用户和冻结用户,而管理员被授予所有权限,所以可以做任何操

安全原则

RBAC支持三个安全原则:最小权限原则、责任分离原则和数据抽象原则

  • 最小权限原则 RBAC可以将角色配置成其完成任务所需的最小权限集合
  • 责任分离原则 可以通过调用相互独立互斥的角色共同完成敏感任务,例
    如要求一个记账员和财务管理员共同参与同一过账操作
  • 数据抽象原则 可以通过权限的抽象来体现,例如财务操作用借款、存款
    等抽象权限,而不是使用典型的读、写、执行权限

RBAC的功能模块

RBAC的执行流程

使用Shiro实现用户信息加密与登录认证

这一部分主要是两方面的内容

  • 用户信息加密思路
  • 使用Shiro完成加密与认证功能

用户信息加密

我们之前的用户信息都是明文存储在数据库中的,这样有三大弊端

  1. 不安全 很多应用脱库后用户密码全网流传
  2. 用户也不希望我们知道他们的密码,我上不了你的号,但是我知道你的号
    在干什么
  3. 如果用户在各个应用使用相同的密码,一个地方密码被盗则一连串被盗

所以我们要对用户信息进行加密,主要是用于验证的敏感信息,比如密码,
而且这种加密最好是不可逆的,明文密码只有用户知道

加盐加密

关于Hash算法可以参考我之前的博客。加盐是提高Hash算法安全性的一个常用
手段,本质是在密码后面加一段随机的字符串,然后再hash

  1. 用户注册时,输入用户名密码,向后台发送请求
  2. 后台将密码加上随机生成的盐并hash,再将hash后的值存入数据库中,盐
    也作为单独的字段存起来
  3. 用户登录时输入用户名密码,向后台发送请求,每个用户都有一个自己的盐
  4. 后台根据用户名查询出盐,和密码组合并hash,将得到的值和数据库中存
    储的密码比对,若一致则通过验证

加盐为什么能够提高安全性?一个hash值可以对应无数个输入,如果不加盐找
到一个和明文密码hash结果相同的输入相对容易,但是在有盐的情况下如果不
知道盐想要找到明文密码就很难了。如果有人能够拿到以hash值存储的密码,
也是有可能拿到盐,但是不同的用户盐不同,即时很多用户使用相同的密码,
存储在数据库的hash值也不同,在加盐的基础上,我们还可以设置hash的迭
代次数,进一步加大破解难度

核心代码

首先在数据库的user表中添加salt字段,并相应在user类中添加salt属性与
get set方法。现在先在result类中创建ResultFactory类,具体代码可以
看Evan的源码,然后开发register方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@PostMapping("api/register")
public Result register(@RequestBody User requestUser)
{
String username=requestUser.getUsername();
String password=requestUser.getPassword();
username = HtmlUtils.htmlEscape(username);
requestUser.setUsername(username);
//这里判断是否已经注册过这个用户名
boolean exist=userservice.isExist(username);
if(exist)
{
String message="用户名已被使用";
//400
return ResultFactory.buildFailResult(message);
}
//生成盐,默认长度是16位。生成随机的byte数组,又转换为base64编码并返回
String salt = new SecureRandomNumberGenerator().nextBytes().toString();
//设置hash算法迭代次数
int times=2;
//得到hash后的密码
String encodePassword=new SimpleHash("md5",password,salt,times).
toString();
//存储用户信息,包括salt和hash后的密码
requestUser.setSalt(salt);
requestUser.setPassword(encodePassword);
//存储用户信息
userservice.add(requestUser);
//将数据返回
return ResultFactory.buildSuccessResult(requestUser);
}

为了实现注册,前端再写一个注册页面,可以和登录页面保持风格统一,创建
Register.vue,代码放在ElementUi中的Register

使用Shiro认证登录

之前的登录方式就是简单粗暴地使用明文对比的方式进行验证,使用Shiro后
一切都会改变。接下来就应该修改登录操作,注册操作会增加用户,登录操作
时验证用户

Shiro的核心概念

关于Shiro有三个基本的核心概念:Subject、SecurityManager和Realms

  • Subject 现在在于软件交互的东西,这个东西可能是你我他,负责存储与修改
    当前用户的信息和状态,使用Shiro实现我们设计的各种功能,实际就是在调用
    Subject的API
  • SecurityManager 管理所有的Subject,只需要配置一次即可
  • Realm 是Shiro和安全数据之间的桥梁,也就是说Realm负责从数据源中获取
    数据并加工后传给SecurityManager

Shiro还有四大功能:Authentication(认证)、Authorization(授权)、
Session Management(会话管理)、Cryptography(加密)

Shiro配置与登录验证

首先添加maven依赖,可以参考我的SpringBoot博客,配置的顺序如下

  • 创建Realm并重写获取认证与授权信息的方法
  • 创建配置类,包括创建并配置 SecurityManager 等

新建realm包,创建WJRealm类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class WJRealm extends AuthorizingRealm {
@Autowired
private Userservice userservice;

// 简单重写获取授权信息方法
@Override
protected AuthorizationInfo doGetAuthorizationInfo(
PrincipalCollection principalCollection) {
SimpleAuthorizationInfo s = new SimpleAuthorizationInfo();
return s;
}

// 获取认证信息,即根据token中的用户名从数据库中获取密码、盐等并返回
@Override
protected AuthenticationInfo doGetAuthenticationInfo(
AuthenticationToken token) throws AuthenticationException {
//principal就是主体的标识属性,比如用户名邮箱等
String userName = token.getPrincipal().toString();
//在数据库中查询是否存在该用户
User user = userservice.getByName(userName);
//如果存在该用户则查询出正确的密码
String passwordInDB = user.getPassword();
//获取该用户相应的盐
String salt = user.getSalt();
//接下来Shiro内部帮我们验证密码是否正确
SimpleAuthenticationInfo authenticationInfo =
new SimpleAuthenticationInfo(userName, passwordInDB,
ByteSource.Util.bytes(salt), getName());
return authenticationInfo;
}
}

SimpleAuthenticationInfo中的salt非得存储成byte[],不过这里的byte[]
并不是我们当初随机生成的那个,而是随机生成的byte[]按base64
编码成String又按UTF-8编码成的byte[]

接下来分析一下登录验证的过程,首先编写一个shiro的配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Configuration
public class ShiroConfiguration {
@Bean
public static LifecycleBeanPostProcessor getLifecycleBeanProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean =
new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager =
new DefaultWebSecurityManager();
securityManager.setRealm(getWJRealm());
return securityManager;
}
@Bean
public WJRealm getWJRealm() {
WJRealm wjRealm = new WJRealm();
wjRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return wjRealm;
}
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher =
new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");
hashedCredentialsMatcher.setHashIterations(2);
return hashedCredentialsMatcher;
}
@Bean
public
AuthorizationAttributeSourceAdvisor
authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor
authorizationAttributeSourceAdvisor =
new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}

修改使用Shiro验证登录的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestController
public class Logincontroller {
@Autowired
Userservice userservice;
//处理post请求
@PostMapping(value="api/hellologin")
public Result login(@RequestBody User requestUser)
{
String username = requestUser.getUsername();
//获取当前用户
Subject subject= SecurityUtils.getSubject();
//封装用户的登录数据
UsernamePasswordToken usernamePasswordToken=new UsernamePasswordToken
(username,requestUser.getPassword());
try{
//执行登录的方法,Shiro内部帮我们进行验证操作
subject.login(usernamePasswordToken);
return ResultFactory.buildSuccessResult(username);
} catch(AuthenticationException e)
{
String message="账户密码错误";
return ResultFactory.buildFailResult(message);
}
}
}

最终Shiro通过Realm重写的doGetAuthenticationInfo方法获取验证信息,
再根据配置类里定义的CredentialsMatcher(HashedCredentialsMatcher)
执行如下方法

1
2
3
4
5
6
7
8
9
public boolean doCredentialsMatch(AuthenticationToken 
token, AuthenticationInfo info) {
//获取传送的密码加盐算取hash值
Object tokenHashedCredentials =
this.hashProvidedCredentials(token,info);
//获取数据库中hash后的密码
Object accountCredentials = this.getCredentials(info);
return this.equals(tokenHashedCredentials, accountCredentials);
}

再分析equals方法发现最终调用的是java.security.MessageDigest
包中的isEqual()方法,也就是比较两个hash值是否相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static boolean isEqual(byte[] digesta, byte[] digestb) {
if (digesta == digestb) return true;
if (digesta == null || digestb == null) {
return false;
}
if (digesta.length != digestb.length) {
return false;
}
int result = 0;
// time-constant comparison
for (int i = 0; i < digesta.length; i++) {
result |= digesta[i] ^ digestb[i];
}
return result == 0;
}

下一步要解决的问题如下

  • 每次重启浏览器都要重新登录
  • 没有登出功能
  • 拦截交由前端判断,判断的依据是localStorage中是否存在用户信息,这
    种信息完全可以伪造,比如在控制台输入window.localStorage.setItem
    (‘user’, JSON.stringify({“name”:”哈哈哈”})); 就可以绕过登录

用户认证方案与完善的访问拦截

这个部分的内容如下

  • 登出功能开发
  • 用户认证机制详解
  • 通过前后端的配合实现完善的访问拦截
  • 进一步分析Shiro的工作机制

登出功能开发

按照过去的登录验证方法,服务器并不会记录登录成功的状态,用户完全可以
不用登录自行构造请求访问后端的各种资源,前后端分离项目必须将前端拦截
和后端拦截结合起来才能实现真正意义上的访问控制。引入shiro安全框架后
拥有对登录状态进行管理的能力,这时才能实现真正的登录登出

  1. 后端 之前已经实现登入,登出代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    @GetMapping("api/logout")
    public Result logout() {
    Subject subject = SecurityUtils.getSubject();
    //Shiro帮助实现登出
    subject.logout();
    String message = "成功登出";
    return ResultFactory.buildSuccessResult(message);
    }

    这部分核心是subject.logout(),默认Subject接口是由DelegatingSubject
    类实现的,该方法会清除session、principals,并把authenticated设置为false

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public void logout() {
    try {
    this.clearRunAsIdentitiesInternal();
    this.securityManager.logout(this);
    } finally {
    this.session = null;
    this.principals = null;
    this.authenticated = false;
    }
    }

    由于登出功能不需要被拦截,所以还需要修改配置类MyWebConfigurer的
    addInterceptors()方法,后端拦截器的代码参考Evan

    1
    2
    3
    4
    5
    6
    7
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(getLoginIntercepter())
    .addPathPatterns("/**")
    .excludePathPatterns("/index.html")
    .excludePathPatterns("/api/login")
    .excludePathPatterns("/api/logout");
    }
  2. 前端 前端要做的事有两件,一是显示,二是逻辑。在顶部的导航栏增加
    一个登出按钮
    在NavMenu中修改,写在el-menu标签即可

    1
    2
    3
    <i class="el-icon-switch-button" @click="logout" 
    style="float:right;font-size: 40px;color: #222;padding: 10px">
    </i>

    调整样式

    1
    2
    3
    4
    .el-icon-switch-button {
    cursor: pointer;
    outline:0;
    }

    在methods中编写logout方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    logout () {
    var _this = this
    this.$axios.get('/logout').then(resp => {
    if (resp.data.code === 200) {
    // 前后端状态保持一致
    _this.$store.commit('logout')
    _this.$router.replace('/login')
    }
    })
    }

    在store中定义logout方法

    1
    2
    3
    4
    logout (state) {
    state.user = []
    window.localStorage.removeItem('user')
    }

    登出功能已经开发完毕

完善的访问拦截

目前登录登出的状态没问题,但是依然可以通过在控制台输入类似

1
window.localStorage.setItem('user', JSON.stringify({"name":"哈哈哈"}));

的命令来绕过前端的前置导航守卫,所以要想真正实现登录拦截,必须在后端也判断
用户是否登录以及是谁登录,而这就需要前端向后端发送用户信息

认证方案

先说最简单的认证方案,即前端再每次请求时都加上用户名和密码,交由后端验证,
这种方式有两个弊端

  • 一是要每次请求都要查询数据库,导致服务器压力过大,因为之前使用localStorage
    已经不管用了,所以必须查询数据库中用户密码是否对应
  • 二是安全性不能确保,如果信息被截取,攻击者就可以一直利用用户名密码登录

为了在某种程度上解决上述两个问题,有两种改进方案 session和token

session

许多语言在网络编程模块中都有会话机制,即session。利用session可以管理用户
状态,比如控制会话存在的时间,在会话中保存属性等。作用方式如下

  • 服务器接收第一个请求,生成session对象,通过响应头告诉客户端在cookie
    中放入sessionId
  • 客户端之后发送请求时,会带上sessionId的cookie
  • 服务器通过sessionId获取session,进而获得当前用户状态(是否登录)等信息

也就是说客户端只需要在登录的时候发送一次用户名密码,此后只需要在发送请求
时带上sessionId,服务器就可以验证用户是否登录。session存储在闪存中,在
用户量较少时访问效率较高,但一个服务器存储了上百万session就很难顶。同时
由于同一用户的多次请求需要访问到同一服务器,不能做简单集群,需要通过一
些策略来扩展

token

虽然session能够比较全面地管理用户状态,但这种方式毕竟占用了较多服务器资
源,所以有人想出一种无须在服务器端保存用户状态(简称无状态)的方案,即
使用token来做验证,常见的误区如下

  • 生成方面,使用随机算法生成的字符串、设备mac地址甚至sessionId作为
    token,虽然字面上都可以成为令牌,但是毫无意义
  • 验证方面,把token存储在session或数据库中,比对前端传来的token与存储
    的token是否一致

简单来说,一个真正的token本身是携带了一些信息的,比如用户id、过期时间等
, 这些信息通过签名算法防止伪造,也可以使用加密算法进一步提高安全性,但
一般没有人会在token存密码,作用流程与session相似,注意token无需服务器
存储

  1. 用户使用用户密码登录,服务器通过验证,根据用户名(或者用户id)通过
    预先设置的算法生成token,其中也可以封装其他信息,并将token返回给客户
  2. 客户端接收到token,并在之后发送请求时带上它
  3. 服务器对token进行解密验证

客户端存储方案

无论是明文用户密码,还是sessionId和token,都可以用三种方式存储,即cookie
localStorage和sessionStorage。当cookie和local/sessionStorage分工又不,
cookie可以作为传递的参数,并可通过后端进行控制,local/sessionStorage则
主要用于客户端中保存数据,其传输需要借助cookie或其他方式完成

通常来说在可以使用cookie的场景下作为验证用途进行传输的用户名密码、sessionId
和token直接放在cookie中即可,而后端传来的其它信息可以根据需要放在local/
session Storage中,作为全局变量之类进行处理

后端登录拦截

shiro的安全管理实际上是基于会话实现的。之前分析了subject.login()的底层
实现,该过程还会产生session,并自动把sessionId设置到cookie。之前说过靠
前端拦截很容易被绕过,想要实现靠谱的拦截必须由后端验证用户登录状态。这个
思路就是前端带上sesssionId发送请求交由后端验证,但是前后端分离的情况下
需要额外的配置解决跨域问题。默认的情况下跨域的cookie是被禁止的,后端不
能设置,前端也不能发送,所以两边都要设置

  1. 首先编写拦截器LoginInterceptor,主要修改preHandle方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Override
    public boolean preHandle(HttpServletRequest
    httpServletRequest, HttpServletResponse httpServletResponse,
    Object o) throws Exception {
    /* 放行options请求,否则无法让前端带上自定义的header信息,导致
    sessionID改变,shiro验证失败 */
    if (HttpMethod.OPTIONS.toString().equals(httpServletRequest.
    getMethod())) {
    httpServletResponse.setStatus(HttpStatus.NO_CONTENT.value());
    return true;
    }
    Subject subject = SecurityUtils.getSubject();
    // 使用 shiro 验证
    if (!subject.isAuthenticated()) {
    return false;
    }
    return true;
    }
    由于跨域问题会先发出一个 options 请求试探,这个请求是不带cookie信息的,
    所以shiro无法获取到sessionId,将导致认证失败。之后为了允许跨域的cookie
    需要在配置类MyWebConfigurer做一些修改,主要是addCorsMappings方法
    1
    2
    3
    4
    5
    6
    7
    8
    @Override
    public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
    .allowCredentials(true)
    .allowedOrigins("http://localhost:8080")
    .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
    .allowedHeaders("*")
    }
    这里注意,在allowCredentials(true) ,即允许跨域使用cookie的情况下,
    allowedOrigins() 不能使用通配符 *,这也是出于安全上的考虑

前端配置

为了让前端能够带上cookie,需要通过axios主动开启withCredentials功能,
即在main.js中添加

1
axios.defaults.withCredentials = true

这样前端每次请求都会带上sessionId,shiro就可以通过sessionId获取登录
状态并执行是否登录的判断。目前后端接口的拦截实现了当页面的拦截还未实现
仍然可以伪造参数绕过前端路由的限制,访问本来需要登录才能访问的页面,
所以接下来修改router.beforeEach方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.beforeEach((to, from, next) => {
if (to.meta.requireAuth) {
if (store.state.user) {
axios.get('/authentication').then(resp => {
if (resp) next()
})
} else {
next({
path: 'login',
query: {redirect: to.fullPath}
})
}
} else {
next()
}
}
)

即在访问每个页面前都向后端发送一个请求,目的是经由拦截器验证服务器端的
登录状态,防止上述情况的发生,后端接口

1
2
3
4
5
@ResponseBody
@GetMapping(value = "api/authentication")
public String authentication(){
return "身份认证成功";
}

rememberMe


cookie的生命周期如果未特别设置则与浏览器保持一致,关闭浏览器后sessionId
就会消失,再次发送请求shiro就会认为用户已经变更,有时需要保持登录状态,
不然每次都需要重新登录。所以shiro提供了rememberMe机制,rememberMe不
是单纯设置cookie存活时间,而是又单独保存一种新的状态,之所以这样设计
也是基于一种安全考虑,把记住我的状态和实际登录状态做出区分这样就可以
控制用户在访问不太敏感的页面时无需重新登录,而访问类似于购物车、订单
之类的页面时必须重新登录。接下来修改shiro配置类,添加两个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager =
new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
cookieRememberMeManager.setCipherKey("EVANNIGHTLY_WAOU".getBytes());
return cookieRememberMeManager;
}
@Bean
public SimpleCookie rememberMeCookie() {
SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
simpleCookie.setMaxAge(259200);
return simpleCookie;
}

在登录方法中设置UsernamePasswordToken的rememberMe属性

1
2
3
4
5
6
7
8
···
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken
(username, requestUser.getPassword());
usernamePasswordToken.setRememberMe(true);
try {
subject.login(usernamePasswordToken);
···
}

这时再看登录方法的响应头,发现多了一条rememberMe的设置
在拦截器 LoginInterceptor 中进行具体的判断逻辑

1
2
3
if (!subject.isAuthenticated() && !subject.isRemembered()) {
return false;
}

动态加载后台菜单

这一部分的主要内容就是实现按照用户角色动态加载后台管理页面的菜单,重点
如下

  • 如何设计数据库以建立用户-角色-菜单之间的联系
  • 如何查询与处理树结构的数据
  • Vue如何实现动态加载路由

后端实现

实现动态加载菜单功能的第一步,就是就是根据当前用户查询出可访问菜单信息
的接口

表的设计

基于之前的RBAC原则,应该设计一张角色表,用角色去对应菜单,同时为了建立
用户与角色、角色与菜单之间的关系,又需要两张中间表
新创建的表用没有使用外键,admin_menu表的结构解释如下

pojo

需要创建 AdminUserRole、AdminRole、AdminRoleMenu、AdminMenu四个PO,
数据库中不存在的字段需要用@Transient标注,注意windows下默认不区分
mysql字段大小写,而linux区分,所以数据库字段不推荐大小写混用(最
好都小写),而Java属性一般采用小驼峰法命名

1
2
3
4
5
6
7
8
9
10
11
12
//这个注解的功能是帮我们提供set get方法
@Data
public class AdminMenu {
private int id;
private String path;
private String name_zh;
private String icon_cls;
private String component;
private int parentId;
@Transient
List<AdminMenu> children;
}

菜单查询接口(树查询结构)

根据用户查询出对应菜单的步骤是

  • 利用shiro获取当前用户登录的id
  • 根据用户id查询出该用户对应的所有角色id,这里一个用户应该可以对应多个
    角色,每个角色也可对应多个菜单
  • 根据这些角色的id,查询出所有可访问的菜单项
  • 根据parentId把子菜单放进父菜单对象中,整理返回有正确层级关系的菜单数据

为了实现这个接口,我们需要新增AdminUserRoleDAO、AdminRoleMenuDAO、
AdminMenuDAO 三个数据库访问对象并编写Service对象
在AdminMenuService中需要实现一个根据当前用户查询出所有菜单项的方法

1
2


整合查询出的菜单数据的思路如下

  • 遍历菜单项,根据每一项的id查询出该项所有的子项,并放进children属性
  • 剔除掉所有的子项,只保留第一层的父项。比如c是b的子项,b是a的子项,
    只需要保留a就行,因为a包含b和c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public void handleMenus(List<AdminMenu> menus) {
    for (AdminMenu menu : menus) {
    List<AdminMenu> children = getAllByParentId(menu.getId());
    menu.setChildren(children);
    }
    Iterator<AdminMenu> iterator = menus.iterator();
    while (iterator.hasNext()) {
    AdminMenu menu = iterator.next();
    if (menu.getParentId() != 0) {
    iterator.remove();
    }
    }
    }

前端实现

前端要做的事情就是处理后端传来的数据,并传递给路由和导航菜单,以
实现动态渲染

后台页面设计

需要创建的组件如下图
这是首页的图片

数据处理

之前设计 AdminMenu 表,实际上包含了前端路由(router)与导航菜单需要的信
息,从后台传来的数据,需要被整理成路由能够识别的格式。导航菜单倒是无所谓,
赋给相应的属性就行,进行格式转换的方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const formatRoutes = (routes) => {
let fmtRoutes = []
routes.forEach(route => {
if (route.children) {
route.children = formatRoutes(route.children)
}

let fmtRoute = {
path: route.path,
component: resolve => {
require(['./components/admin/' + route.component + '.vue'], resolve)
},
name: route.name,
nameZh: route.nameZh,
iconCls: route.iconCls,
children: route.children
}
fmtRoutes.push(fmtRoute)
})
return fmtRoutes
}

这里传入的参数routes代表从后端获取的菜单列表。遍历这个列表,首先判断
一条菜单项是否包含子项,如果包含则进行递归处理。然后就是把路由的属性
和菜单项的属性对应起来,component是一个对象需要根据名称做出解析

添加路由与渲染菜单

首先需要考虑什么时候去请求接口并渲染菜单。如果访问每个页面都加载一次
有点太浪费,如果只在后台主页面渲染时加载一次那么就不能再主页面进行
刷新操作,因此可以继续利用全局守卫,在用户登录且访问/admin开头的
路径时请求菜单信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
router.beforeEach((to, from, next) => {
if (store.state.user.username && to.path.startsWith('/admin')) {
initAdminMenu(router, store)
}
// 已登录状态下访问 login 页面直接跳转到后台首页
if (store.state.username && to.path.startsWith('/login')) {
next({
path: 'admin/dashboard'
})
}
if (to.meta.requireAuth) {
if (store.state.user.username) {
axios.get('/authentication').then(resp => {
if (resp) next()
})
} else {
next({
path: 'login',
query: {redirect: to.fullPath}
})
}
} else {
next()
}
}
)

为了保证用户确实登录,仍需要向后台发送一个验证请求
initAdminMenu 用于执行请求,调用格式化方法并向路由表中添加信息

1
2
3
4
5
6
7
8
9
10
11
12
const initAdminMenu = (router, store) => {
if (store.state.adminMenus.length > 0) {
return
}
axios.get('/menu').then(resp => {
if (resp && resp.status === 200) {
var fmtRoutes = formatRoutes(resp.data)
router.addRoutes(fmtRoutes)
store.commit('initAdminMenu', fmtRoutes)
}
})
}

首先判断store中有没有菜单数据,如果有说明是正常跳转,无需重新加载,
第一次进入或进行刷新时需要重新加载。记得在store.state里添加变量
adminMenu: [],同时在 mutations 里添加如下方法

1
2
3
initAdminMenu (state, menus) {
state.adminMenus = menus
}

这个menus就是上面的fmtRoutes。当然也可以把数据放进localStorage,
在登出时清空就行。接下来编写菜单组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<template>
<div>
<el-menu
:default-active="'/admin/users'"
class="el-menu-admin"
router
mode="vertical"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b">
<div style="height: 80px;"></div>
<template v-for="(item,i) in adminMenus">
<!--index 没有用但是必需字段且为 string -->
<el-submenu :key="i" :index="i + ''" style="text-align: left">
<span slot="title" style="font-size: 17px;">
<i :class="item.iconCls"></i>
{{item.nameZh}}
</span>
<el-menu-item v-for="child in item.children"
:key="child.path" :index="child.path">
<i :class="child.icon"></i>
{{ child.nameZh }}
</el-menu-item>
</el-submenu>
</template>
</el-menu>
</div>
</template>

<script>
export default {
name: 'AdminMenu',
computed: {
adminMenus () {
return this.$store.state.adminMenus
}
}
}
</script>

利用element的导航栏组件,进行两层循环渲染所需要的菜单。<\el-submenu>
代表一个有子菜单的菜单项,<\el-menu-item> 则代表单独的菜单项,如果
有三个层级的话就是<\el-submenu>套<\el-submenu>再套<\el-menu-item>
接下来登录显示结果如下
点击用户信息菜单,跳转到相应路由并加载
使用editor登录只有用户内容管理菜单
接下来会完善一下各个板块

  • 开发用户角色、角色菜单分配组件
  • 迁移图书管理功能
  • 完成其它模块的基础界面
  • 实现功能级权限并开发分配组件

功能级访问控制的实现

这部分主要是功能级访问控制的实现方式,之所以要实现这个粒度的访问控制,
是因为仅仅对菜单进行控制是不够的。如果不想让内容管理员角色查看用户列
表的权限,可以通过对菜单的控制,让这个角色无法加载用户信息组件,但
在会话持续状态下,该角色仍然可以向后台展示用户列表的接口发送请求,
获取所有的用户信息,也就是说虽然初始加载时不会有用户信息,但是可
以构建请求获取用户信息

  • 设计数据库表(功能表与角色-功能表)
  • 完善新表对应的 pojo、DAO、service 类
  • 编写shiro过滤器并配置过滤条件

运行情况 用户信息
角色配置 图书管理

数据库与后端准备

这部分内容涉及到的表
除了两张新建的表admin_permission和admin_role_permission外,还对
user进行扩充。权限表的设计如下

  • name 即权限的名称,推荐使用英文
  • desc_ 即对权限功能的具体描述
  • url 即权限对应的接口,是实现功能控制的关键

Service

AdminPermissionService中需要实现一个根据当前用户获取所有权限的方法

1
2


还可以实现一个方法用于判断用户请求接口是否在权限列表中,如果没有对应
权限则说明不需要维护

1
2


为了实现上述功能,可以在Controller中查询所有用户接口

1
2
3
4
@GetMapping("/api/admin/user")
public List<User> listUsers() throws Exception {
return userService.list();
}

Shiro实现

之前我们在做登录拦截的时候使用了拦截器,即Interceptor。由于Shiro的
权限机制要靠它自身提供的过滤器实现,所以我们现在弃用之前的拦截器,
首先在MyWebConfigurer中删除addInterceptors方法和相关类

编写基于URL的过滤器

首先自定义一个过滤器,PathMatchingFilter是Shiro提供的路径过滤器,
我们可以通过继承它来编写过滤放行条件,即判断是否具有相应权限。判断
的逻辑为

  • 首先判断当前会话对应的用户是否登录,如果未登录直接false
  • 第二步判断访问的接口是否有对应的权限,如果没有视为不需要权限即
    可访问,直接true
  • 如果需要权限查询出当前用户对应的所有权限,遍历并与需要访问的接
    口进行比对,如果存在相应权限则true,否则false

新建一个filter包,编写URLPathMatchingFilter类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.gm.wj.filter;

import com.gm.wj.service.AdminPermissionService;
import com.gm.wj.util.SpringContextUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.PathMatchingFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Set;

public class URLPathMatchingFilter extends PathMatchingFilter {
@Autowired
AdminPermissionService adminPermissionService;

@Override
protected boolean onPreHandle(ServletRequest request,
ServletResponse response, Object mappedValue) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse)
response;
// 放行 options 请求
if (HttpMethod.OPTIONS.toString().equals((httpServletRequest).
getMethod())) {
httpServletResponse.setStatus(HttpStatus.NO_CONTENT.value());
return true;
}
/*
在 Shiro 的配置文件中,我们不能把 URLPathMatchingFilter用
@Bean 被 Spring 管理起来。 原因是 Shiro 存在 bug, 这个也是
过滤器,ShiroFilterFactoryBean 也是过滤器,当他们都出现的
时候,默认的什么 anno,authc 过滤器就失效了。所以不能把他声
明为@Bean。
*/
if (null==adminPermissionService) {
adminPermissionService = SpringContextUtils.getContext().
getBean(AdminPermissionService.class);
}

String requestAPI = getPathWithinApplication(request);
System.out.println("访问接口:" + requestAPI);

Subject subject = SecurityUtils.getSubject();

if (!subject.isAuthenticated()) {
System.out.println("需要登录");
return false;
}

// 判断访问接口是否需要过滤(数据库中是否有对应信息)
boolean needFilter = adminPermissionService.needFilter(requestAPI);
if (!needFilter) {
System.out.println("接口:" + requestAPI + "无需权限");
return true;
} else {
System.out.println("验证访问权限:" + requestAPI);
// 判断当前用户是否有相应权限
boolean hasPermission = false;
String username = subject.getPrincipal().toString();
Set<String> permissionAPIs =
adminPermissionService.listPermissionURLsByUser(username);
for (String api : permissionAPIs) {
if (api.equals(requestAPI)) {
hasPermission = true;
break;
}
}

if (hasPermission) {
System.out.println("访问权限:" + requestAPI + "验证成功");
return true;
} else {
System.out.println("当前用户没有访问接口" + requestAPI +
"的权限");
return false;
}
}
}
}

我们无法在URLPathMatchingFilter中使用@Autowired注入
AdminPermissionService类,所以需要借助一个工具类利用
Spring应用上下文获取AdminPermissionService的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.gm.wj.util;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public class SpringContextUtils implements ApplicationContextAware {
private static ApplicationContext context;

public void setApplicationContext(ApplicationContext context)
throws BeansException {
SpringContextUtils.context = context;
}

public static ApplicationContext getContext() {
return context;
}
}

接下来再配置类ShiroConfiguration中增加获取过滤器的方法,注意这里
不能使用@Bean

1
2
3
public URLPathMatchingFilter getURLPathMatchingFilter() {
return new URLPathMatchingFilter();
}

然后编写shiroFilter方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new
ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);

Map<String, String > filterChainDefinitionMap = new
LinkedHashMap<String, String>();
Map<String, Filter> customizedFilter = new HashMap<>();

// 设置自定义过滤器名称为 url
customizedFilter.put("url", getURLPathMatchingFilter());

/*对管理接口的访问启用自定义拦截(url 规则),即执行
URLPathMatchingFilter 中定义的过滤方法*/
filterChainDefinitionMap.put("/api/admin/**", "url");
// 启用自定义过滤器
shiroFilterFactoryBean.setFilters(customizedFilter);
filterChainDefinitionMap.put("/api/authentication", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(
filterChainDefinitionMap);
return shiroFilterFactoryBean;
}

授权操作

写好Service之后

  • 在Realm中配置授权信息
  • 为需要控制的接口添加注解
  • 编写异常处理类(统一处理未授权的异常)

在WJRealm中重写获取授权信息的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected AuthorizationInfo doGetAuthorizationInfo(
PrincipalCollection principalCollection) {
// 获取当前用户的所有权限
String username = principalCollection.getPrimaryPrincipal().
toString();
Set<String> permissions = adminPermissionService.
listPermissionURLsByUser(username);

// 将权限放入授权信息中
SimpleAuthorizationInfo s = new SimpleAuthorizationInfo();
s.setStringPermissions(permissions);
return s;
}

选用注解的方式控制用户信息查询权限

1
2
3
4
5
@RequiresPermissions("/api/admin/user")
@GetMapping("/api/admin/user")
public List<User> listUsers() throws Exception {
return userService.list();
}

最后编写一个处理所有未授权异常的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.gm.wj.exception;
import com.gm.wj.result.Result;
import com.gm.wj.result.ResultFactory;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice
public class DefaultExceptionHandler {
@ExceptionHandler(Exception.class)
@ResponseBody
public Result handleAuthorizationException(UnauthorizedException e) {
String message = "权限认证失败";
return ResultFactory.buildFailResult(message);
}
}

后台角色、权限与菜单分配

这部分主要在实现菜单和功能访问控制的情况下,如何编写角色、权限、菜单
分配功能的接口和页面,本质是增删查改,表单表格

  • 如何使用 element-ui 的树组件
  • 如何接收并处理没有实体类型对应的数据
  • Vue 如何不刷新清除路由记录

角色、权限分配

角色分配,也就是给用户指定角色。根据我们之前的数据库设计,本质是更新
admin_user_role表

用户信息表与行数据获取

首先要有一个组件负责展示查询出来的用户信息,并提供编辑操作的入口。
element的table组件提供了表格能用到的很多功能,比如排序、筛选、选
择、懒加载等等
前端表格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<el-table
:data="users"
stripe>
<el-table-column
prop="id"
label="id"
width="100">
</el-table-column>
<el-table-column
prop="username"
label="用户名"
fit>
</el-table-column>

······

<el-table-column
label="操作"
width="120">
<template slot-scope="scope">
<el-button
@click="editUser(scope.row)"
type="text"
size="small">
编辑
</el-button>
<el-button
type="text"
size="small">
移除
</el-button>
</template>
</el-table-column>
</el-table>

通过data绑定表格对应的数据,并通过 prop 指定列对应的字段。若想对表
格里某一行的数据进行操作,就要想办法获取当前的数据

1
2
3
4
5
6
7
8
9
10
11
12
<el-table-column
label="操作"
width="120">
<template slot-scope="scope">
<el-button
@click="editUser(scope.row)"
type="text"
size="small">
编辑
</el-button>
</template>
</el-table-column>

scope.row 便是点击编辑按钮所获取到的该行的数据。这里实际上利用的是作
用域插槽,通过 <\el-table-column> 组件获取到了数据。通过点击事件触
发editUser方法并传入了该行的数据,弹出对话框,并通过表单组件实现单
用户信息的显示与修改

角色分配

用户和角色是多对多关系,所以这里使用多选框组件。为了正确显示用户对应
的角色,我们需要先把所有的角色信息查询出来,再根据用户信息选中相应
的角色,查询出所有角色的方法很简单,在mounted()方法中调用即可。关
键是如何选中当前用户对应的角色。查询用户的角色有两种思路

  1. 可以以用户名或id为参数向后端发送请求,查询出对应的角色值并返回
  2. 改造后端查询用户信息的接口,直接在查询用户信息时就把角色信息查
    询出来

为了前后端传递参数更方便一点,这里采用第二种方法,使用这种方法需要在
User实体类中添加属性来存放角色信息,但是由于数据库中并没有相应定义
,所以我们要加上@Transient注释

1
2
3
4
5
6
7
8
9
10
11
@Transient
List<AdminRole> roles;

// getter and setter
public List<AdminRole> getRoles() {
return roles;
}

public void setRoles(List<AdminRole> roles) {
this.roles = roles;
}

相应地在Userservice中修改列出所有用户的方法

1
2
3
4
5
6
7
8
9
public List<User> list() {
List<User> users = userDAO.list();
List<AdminRole> roles;
for (User user : users) {
roles = adminRoleService.listRolesByUser(user.getUsername());
user.setRoles(roles);
}
return users;
}

在AdminRoleService中添加listRolesByUser()方法

1
2
3
4
5
6
public List<AdminRole> listRolesByUser(String username) {
int uid = userService.findByUsername(username).getId();
List<Integer> rids = adminUserRoleService.listAllByUid(uid)
.stream().map(AdminUserRole::getRid).collect(Collectors.toList());
return adminRoleDAO.findAllById(rids);
}

这样查询出的用户就带角色信息了

博客功能开发

这部分主要讲解如何开发博客系统

  • 如何使用开源编辑器
  • Vue如何在不同页面传递参数

mavon-editor 编辑器

目前常见的文本编辑器有两种,富文本编辑器和markdown编辑器。markdown
编辑器的本质就是把输入源转化成html代码。富文本编辑器是一种可内嵌于
浏览器,所见即所得的文本编辑器

功能设计

博客功能可以分为三个部分:文章展示、文章管理和编辑器,文章展示又可以
分为文章列表和文章详情两部分
虽然编辑器提供预览功能,但一般在前台不需要向用户展示markdown原文,
所以单独写一个文章详情页渲染html,有两种思路

  • 第一种在数据库中仅保存markdown语法的文本,在需要使用时解析为html
    并在前台渲染
  • 第二种是html markdown均保存在数据库中,需要使用时取出html并在前台
    渲染

第一种的好处是节省传输的数据量与数据库空间,坏处就是需要自己编写解析
方法,相当于又重写一遍编辑器,而且难以保证解析出来的样式跟原编辑器一
致,所以目前使用第二种方法

文章列表

展示文章的题目、摘要、封面等信息,提供文章详情页入口,主要是前端设计与
分页功能实现,后期可以扩展分页标签、检索、归档等功能,还可以在侧边栏
加入作者简介等信息

文章详情

这个页面用于展示文章的具体内容,也就是渲染从数据库中取出的html

文章管理

后台的管理页面,提供查看、发布、修改文章的入口以及删除功能,需要内容管理
权限

编辑器

核心页面,在开源编辑器的基础上,添加了标题、摘要及封面设置功能

数据库设计

为了保存文章相关的信息,设计jotter_article表
目前包含的字段是 id、标题、文章 html、md 原文、文章摘要、文章标题和发文日期

1
2
3
4
5
6
7
8
9
10
create table jotter_article
(
id int primary key,
article_title varchar(255),
article_content_html longtext,
article_content_md longtext,
article_abstract varchar(255),
article_cover varchar(255),
article_date datetime
);

编辑器的引入与改造

在项目根目录执行如下命令安装mavon-editor

1
npm install mavon-editor --save

然后在main.js中全局注册

1
2
3
4
import mavonEditor from 'mavon-editor'
...
...
Vue.use(mavonEditor)

在admin/content文件夹下新建ArticleEditor.vue组件,该组件的主体就是
mavon-editor编辑器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<mavon-editor
v-model="article.articleContentMd"
style="height: 100%;"
ref=md
@save="saveArticles"
fontSize="16px">
</mavon-editor>
</template>

<script>
export default {
name: 'Editor',
data () {
return {
article: {}
}
}
</script>

接下来进行一些改造

  • 第一步添加标题输入栏
  • 第二步插入自定义工具,提供摘要与封面录入功能
  • 第三步编写save方法,与后端交互

添加el-input实现输入

1
2
3
4
<el-input
v-model="article.articleTitle"
style="margin: 10px 0px;font-size: 18px;"
placeholder="请输入标题"></el-input>

设置type class和title属性,弄一个添加摘要和封面的按钮

1
2
3
4
5
<button type="button"
class="op-icon el-icon-document"
:title="'摘要/封面'"
slot="left-toolbar-after"
@click="dialogVisible = true"></button>

注意图片上传这个组件需要单独设置属性才能带上cookie,不带cookie
后端就拿不到sessionId,就会被shiro拦截

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<el-upload
class="img-upload"
ref="upload"
action="http://localhost:8443/api/admin/content/books/covers"
with-credentials
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:on-success="handleSuccess"
multiple
:limit="1"
:on-exceed="handleExceed"
:file-list="fileList">
<el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div>
</el-upload>
</template>

后端

controller中保存对应的方法

1
2
3
4
5
@PostMapping("api/admin/content/article")
public Result saveArticle(@RequestBody JotterArticle article) {
//jotterArticleService.addOrUpdate(article);
return ResultFactory.buildSuccessResult("保存成功");
}

前端路由写法

1
2
3
4
5
6
7
8
{
path: '/admin/content/editor',
name: 'Editor',
component: Editor,
meta: {
requireAuth: true
}
}

文章列表页面

这部分主要涉及分页问题,图书馆的页面分页可以靠前端实现,也可以靠后端
实现。Spring Data提供了页码、页面尺寸等信息,

Author: 高明
Link: https://skysea-gaoming.github.io/2020/10/05/VBlog2/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.