Web安全框架与简单应用

平台及技术栈

  开发:Windows 10,部署:Ubuntu 18.04
  后端框架:Spring Boot 2.5.8
  前端框架: Vue 2.6.10

项目地址

  github

Web安全系统简介

  软件应用系统安全主要包括认证(登录)授权(权限管理)两部分,通常称为权限管理。
  安全框架主要是解决应用系统中的两类问题:认证和授权。其中认证就是登录,即判断用户是否是系统的合法用户;而授权就是权限设计与验证,即判断该用户是否具备访问系统中某些资源的权限。
  目前,在Java安全框架中有Spring SecurityApache Shiro两个比较优秀的架构。

Spring Security和Apache Shiro

Spring Security

  官网源代码
  Spring Security是强大的,且容易定制的,基于Spring开发的实现认证登录与资源授权的应用安全框架。
  Spring Security的核心功能:

  1. Authentication:认证,用户登陆的验证(解决你是谁的问题)
  2. Authorization:授权,授权系统资源的访问权限(解决你能干什么的问题)
  3. 安全防护,防止跨站请求,session攻击等

Apache Shiro

  官网
  Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码学和会话管理。使用Shiro易于理解的API,可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
  三个核心组件:SubjectSecurityManagerRealms

  1. Subject:即当前操作用户。但是,在Shiro中,Subject这一概念并不仅仅指人,也可以是第三方进程、后台帐户(Daemon Account)或其他类似事物。它仅仅意味着当前跟软件交互的东西。但考虑到大多数目的和用途,你可以把它认为是Shiro的用户概念。
    Subject代表了当前用户的安全操作,SecurityManager则管理所有用户的安全操作。
  2. SecurityManager:它是Shiro框架的核心,典型的Facade模式,Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务。
  3. RealmRealm充当了Shiro与应用安全数据间的桥梁或者连接器。也就是说,当对用户执行认证(登录)和授权(访问控制)验证时,Shiro会从应用配置的Realm中查找用户及其权限信息。
    从这个意义上讲,Realm实质上是一个安全相关的DAO:它封装了数据源的连接细节,并在需要时将相关数据提供给Shiro。当配置Shiro时,你必须至少指定一个Realm,用于认证和(或)授权。配置多个Realm是可以的,但是至少需要一个。
    Shiro内置了可以连接大量安全数据源(又名目录)的Realm,如LDAP、关系数据库(JDBC)、类似INI的文本配置资源以及属性文件等。如果缺省的Realm不能满足需求,你还可以插入代表自定义数据源的自己的Realm实现。

总结

  在Spring Boot项目中选用Spring Security的优点:Spring Security基于Spring开发,在已经选用Spring框架(尤其是Spring Boot框架)的项目下,Spring Security的使用更加方便。另外,Spring Security的功能也比Shiro的强大。
  在非Spring Boot项目下,选择Shiro的优点:Shiro的配置和使用比较简单,不需要任何框架和容器,可以单独运行,而Spring Security依赖Spring容器。

JWT简介

  JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

JWT构成

  JSON Web Token由三部分构成,它们之间用圆点(.)连接。这三部分分别是:HeaderPayloadSignature
JWT构成

认证流程

认证流程

  1. 浏览器发起请求登录,携带用户名和密码;
  2. 服务端验证身份,根据算法,将用户标识符打包生成token
  3. 服务器返回JWT信息给浏览器,JWT不包含敏感信息;
  4. 浏览器发起请求获取用户资料,把刚刚拿到的token一起发送给服务器;
  5. 服务器发现数据中有token,验明正身;
  6. 服务器返回该用户的用户资料。

结合Spring Boot实现

添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- ======BEGIN jwt ====== -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- ======END jwt ====== -->

JWT工具类

  JWTUtil.java

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
public class JWTUtil {
// 私有签名
private static final String SING = "6Dx8SIuaHXJYnpsG18SSpjPs50lZcT52";

/*
* 生成token
*/
public static String getToken(Map<String, String> map){
String token = null;

Calendar instance = Calendar.getInstance();
// 默认7天过期
instance.add(Calendar.DATE, 7);

// 创建jwt builder
JWTCreator.Builder builder = JWT.create();

// 添加payload
map.forEach((k, v) -> {
builder.withClaim(k, v);
});

// 指定令牌过期时间
builder.withExpiresAt(instance.getTime());

// sign
token = builder.sign(Algorithm.HMAC256(SING));

return token;
}

/*
* 验证token
*/

public static void verify(String token) throws Exception{
JWT.require(Algorithm.HMAC256(SING)).build().verify(token);
}

/*
* 获取信息
*/
public static DecodedJWT getTokenInfo(String token) {
return JWT.require(Algorithm.HMAC256(SING)).build().verify(token);
}
}

  这里主要包含生成token和验证token两个主要的功能模块。

头部信息

  JWT头部分是一个描述JWT元数据的JSON对象,通常如下所示。

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

  alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存。
  代码中使用了默认的头部值,当然也可以更改为其他的:

1
2
builder.setHeaderParam("typ", "JWT")     //令牌类型
.setHeaderParam("alg", "HS256") //签名算法

有效载荷pyload

  有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。JWT指定七个默认字段供选择。

1
2
3
4
5
6
7
iss: jwt签发者
sub: 主题
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

  除以上默认字段外,我们还可以自定义私有字段,如下例

1
2
3
4
5
{
"name": "Cxx",
"admin": true,
"avatar": "Cxx.jpg"
}

  例如,在本例中,通过提供map参数,可以自定义添加任何信息。
  默认情况下JWT是未加密的,任何人都可以解读其内容,因此不要构建隐私信息字段,存放保密信息,以防止信息泄露。JSON对象也使用Base64 URL算法转换为字符串保存。

数字签名

  数字签名一般是随机生成的一长串字符串,是保存在服务器端的,任何时候都不能泄露出去。

有效时间

  为了提高token的有效性,一般还会设置其有效时间。

参考博客

  1. 五分钟带你了解啥是JWT
  2. JSON Web令牌(JWT)的原理,流程和数据结构
  3. JWT认证原理、流程整合springboot实战应用,前后端分离认证的解决方案!

  注

  1. 这里所有的异常处理均放在全局异常处理中。

拦截器实现

Spring Security实现

Apache Shiro实现

数据库系统配置

用户数据信息表

  这里需要对用户密码进行加密处理,并以密文的方式存储在数据库中。

用户-角色-权限关系数据表

  这里采用目前比较普遍的权限设计模型:RBAC,即基于角色的访问控制(Role-Based Access Control)。
  一个用户可用具有多个角色,一个角色也可以具有多个权限控制,而权限控制也可以细分为可访问资源控制和可操作资源控制,即可以访问哪些菜单资源和可以执行哪些功能按钮。
  为了简化开发,这里设定为一个用户只能拥有一个角色。最终的关系图为:
用户-角色-权限表结构
  其中为了完整的描述用户-角色-权限三者的关系,还需要添加用户-角色表、角色-资源表和角色-操作表。

基于Spring Boot的Apache Shiro配置

Shiro Config配置

  ShiroConfig.java

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
86
87
88
89
90
91
@Configuration
public class ShiroConfig {
// 1.创建shiroFilter
// 负责拦截所有请求
@Bean
public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager, JwtFilter jwtFilter) {
// 实例化shiroFilter
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 给filter设置安全管理器
shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);

// 添加自定义过滤器
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("jwt", jwtFilter);
shiroFilterFactoryBean.setFilters(filterMap);

// 配置拦截设置
Map<String, String> map = new LinkedHashMap<>();
// 不拦截swagger
// anon为shiro自带的过滤器,即不拦截
map.put("/swagger-ui.html#/**", "anon");
map.put("/v2/api-docs", "anon");
map.put("/swagger-resources/**", "anon");
map.put("/webjars/**", "anon");

// 其余均用自定义jwt拦截
map.put("/**", "jwt");

//默认认证界面路径---当认证不通过时跳转
shiroFilterFactoryBean.setLoginUrl("/user/login");
shiroFilterFactoryBean.setUnauthorizedUrl("/user/login");

shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

return shiroFilterFactoryBean;
}

// 自定义过滤器,注入Bean中
@Bean
public JwtFilter getJwtFilter() {
return new JwtFilter();
}

//2.创建安全管理器
@Bean
public DefaultWebSecurityManager getDefaultWebSecurityManager(MyRealm myRealm, MyCredentialsMatcher myCredentialsMatcher) {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
// 设置Realm的自定义密码验证器
myRealm.setCredentialsMatcher(myCredentialsMatcher);
// 设置自定义Realm
defaultWebSecurityManager.setRealm(myRealm);

return defaultWebSecurityManager;
}

// 3.自定义realm 注入Bean中
@Bean
public MyRealm getRealm() {

return new MyRealm();
}

// 自定义密码验证器 注入Bean中
@Bean
public MyCredentialsMatcher getMyCredentialsMatcher() {

return new MyCredentialsMatcher();
}

/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions),
* 需借助SpringAOP扫描使用Shiro注解的类,并在必要时进行安全逻辑验证
* 配置以下两个bean(DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor)即可实现此功能
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}

/**
* 开启aop注解支持
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}

  根据上文的shiro框架简介,配置文件主要可以分为3个部分。
  首先创建shiroFilter,负责拦截所有的请求,其中对于swagger测试页采用自带的anno拦截方式,即不拦截方式。其余均采用自定义过滤器jwt
  其次需要配置安全管理器,这里采用自定义的Realm,且Realm中的密码验证器也需要结合jwt进行自定义设计。
  最后是将自定义的Realm注入到SpringbootBean中。
  注:最后需要开启shiro的注解模式。

自定义过滤器

  JwtFilter.java

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
// 自定义过滤器
public class JwtFilter extends AuthenticatingFilter {

// 从请求中获取token信息,然后对String类型的token进行转换成JwtToken
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 前端获取X-Token
String jwt = request.getHeader("X-Token");
if (StringUtils.isEmpty(jwt)) {
return null;
}

// 返回shiro中需要的Token格式
return new JwtToken(jwt);
}

// 真正的拦截方法,判断请求是否带有token,没有token即为未登录状态,有token的为登录状态
// 若没有token直接放行(登录状态或者其他情况)
// 若有token则需要判断这个token是否合法或者是否失效等
// 最后调用executeLogin方法,提交给登录
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 需要和前端的axios的header字段保持一致
String jwt = request.getHeader("X-Token");
if (StringUtils.isEmpty(jwt)) {
return true;
} else {
// 校验jwt
Claims claim = JwtUtil.parseJWT(jwt);
// 判断token是否为空或者是否过期
if (claim == null || JwtUtil.isTokenExpired(claim.getExpiration())) {
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
// 向前端发送过期错误提示
writer.write(JSON.toJSONString(R.setResult(ResultCodeEnum.TokenExpiredException)));
return false;
}
// 执行shiro中的登录方法
// 需要执行登录,否则获取接口时没有subject信息
return executeLogin(servletRequest, servletResponse);
}
}

// 登录失败处理
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {

HttpServletResponse httpServletResponse = (HttpServletResponse) response;

Throwable throwable = e.getCause() == null ? e : e.getCause();

String json = JSON.toJSONString(R.error().message(e.getMessage()));

try {
// 向前端返回错误信息
httpServletResponse.getWriter().print(json);
} catch (IOException ioException) {
ioException.printStackTrace();
}

return false;
}

// 跨域处理
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}

  自定义过滤器主要基于继承AuthenticatingFilter类实现。
  首先是从请求中获取token信息,然后对String类型的token进行转换成自定义的JwtToken类型。注意这里的request.getHeader()字段需要和前端保持一致!
  然后需要实现继承类的重载方法onAccessDenied()。当未获取到前端的token信息时,返回true放行(因此这里一定要保证前端发送的非登录请求是包含token信息的!)。否则需要对token进行简单的验证处理,如果过期则需要将错误信息返回至前端,最后执行shiro中的登录方法。
  最后实现登录失败和跨域处理的重载方法。

自定义JwtToken

  这里主要参考的是shiro框架中的UsernamePasswordToken实现的一些构造函数等信息。

自定义Realm

  这里参考下文的认证和授权部分。

安全认证过程

  身份认证,就是判断一个用户是否为合法用户的处理过程。最常用的简单身份认证方式是系统通过核对用户输入的用户名和密码,看其是否与系统中存储的该用户的用户名和密码一致,来判断用户身份是否正确。

关键对象

  1. Subject:主体
    访问系统的用户,主体可以是用户、程序等,进行认证的都称为主体;
  2. Principal:身份信息
    是主体(subject)进行身份认证的标识,标识必须具有唯一性,如用户名、手机号、邮箱地址等,一个主体可以有多个身份,但是必须有一个主身份(Primary Principal)。
  3. credential:凭证信息
    是只有主体自己知道的安全信息,如密码、证书等。

认证流程

shiro认证流程
  在自定义的MyRealm.java中需要实现protected AuthenticationInfo doGetAuthenticationInfo()方法的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 验证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
JwtToken jwtToken = (JwtToken) authenticationToken;
// Jwt获取username
String token = (String) jwtToken.getPrincipal(); // 拿到token
Claims claims = JwtUtil.parseJWT(token); // 解析jwt
String username = claims.getId(); // jwt中拿到username

// 数据库获取用户信息并验证
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("username", username);

User user = userMapper.selectOne(userQueryWrapper);

if(user == null){
return null;
}

// 返回密码对比结果 这里会保存用户的用户名和密码,以便后续的密码验证
return new SimpleAuthenticationInfo(username, user.getPassword(), getName());
}

  首先通过传入的token信息拿到用户名信息,然后获取到数据库中的用户名和用户名密码信息,并将这些信息传入SimpleAuthenticationInfo()方法中进行密码验证。
  这里由于采用了MD5+盐值+迭代的加密算法,因此需要自定义密码验证器。
  MyCredentialsMatcher.java

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
// 自定义密码验证器
@Component
public class MyCredentialsMatcher extends SimpleCredentialsMatcher {
@Autowired
private UserMapper userMapper;

// 判断密码是否一致
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
// 前端返回的token
JwtToken jwtToken = (JwtToken) token;

if(jwtToken.getPassword() == null) {
return true;
}

// 根据token获取输入的密码
String inputPassword = new String(jwtToken.getPassword());

// 根据Realm中存储的用户名和密码得到数据库密码
String username = String.valueOf(info.getPrincipals());
String dbPassword = (String) info.getCredentials();

// 获取数据库盐值
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("username", username);

User user = userMapper.selectOne(userQueryWrapper);
String salt = user.getSalt();

// 重新对密码加密 md5+盐值+迭代次数
String md5Password = new SimpleHash("md5", inputPassword, salt, 1024).toHex();

// 比较加密后的密码和数据库(shiro)中的密码
return this.equals(md5Password, dbPassword);
}
}

  这里的盐值为新建用户时随机生成并保存到数据库中的盐值信息。

授权流程

  授权,即访问控制,控制谁能访问哪些资源。主体进行身份认证后需要分配权限方可访问系统的资源,对于某些资源没有权限是无法访问的。

关键对象

  授权可简单理解为whowhat(which)进行How操作:

  1. Who,即主体(Subject),主体需要访问系统中的资源。
  2. What,即资源(Resource),如系统菜单、页面、按钮、类方法、系统商品信息等。资源包括资源类型和资源实例,比如商品信息为资源类型,类型为t01的商品为资源实例,编号为001的商品信息也属于资源实例。
  3. How,权限/许可(Permission),规定了主体对资源的操作许可,权限离开资源没有意义,如用户查询权限、用户添加权限、某个类方法的调用权限、编号为001用户的修改权限等,通过权限可知主体对哪些资源都有哪些操作许可。

授权流程

shiro授权流程
  在自定义的MyRealm.java中需要实现protected AuthorizationInfo doGetAuthorizationInfo()方法的重载:

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
// 授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
// 获取用户名
String username = (String) principalCollection.getPrimaryPrincipal();

// 根据用户名获取当前用户的角色信息,以及权限信息
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
// 数据库中获取用户的角色信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("username", username);
User user = userMapper.selectOne(userQueryWrapper);

QueryWrapper<UserRole> userRoleQueryWrapper = new QueryWrapper<>();
userRoleQueryWrapper.eq("user_id", user.getId());
UserRole userRole = userRoleMapper.selectOne(userRoleQueryWrapper);

QueryWrapper<Role> roleQueryWrapper = new QueryWrapper<>();
roleQueryWrapper.eq("id", userRole.getRoleId());
Role role = roleMapper.selectOne(roleQueryWrapper);

// 添加角色 权限信息
// admin
simpleAuthorizationInfo.addRole(role.getRoleName());
simpleAuthorizationInfo.addStringPermission("user:*");

return simpleAuthorizationInfo;
}

  本项目主要采用的在前端进行授权管理,这里主要演示shiro的授权功能。
  首先根据输入的信息获取到用户名信息,然后根据用户名查询其角色和权限信息,并将此信息通过simpleAuthorizationInfo.addRole()simpleAuthorizationInfo.addStringPermission()添加至shiro中。
  最后在controller类中添加注解@RequiresRoles("admin")@RequiresPermissions("user:update")即可实现权限控制。
  个人认为基于后端的权限控制是通过控制controller的访问权限机制实现的,其开放性和灵活性相较于前端不是太方便,因此本项目主要是通过前端实现权限管理控制,后端只需将所有的角色和资源信息发送至前端即可。

参考博客

shiro的认证+shiro的授权

基于vue element admin的前端安全权限系统

安全登录系统

安全登录流程

前端安全登录流程

login->index.Vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
handleLogin() {
// 表单验证
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true
// 登录功能 store->modules->user.js:login()
this.$store.dispatch('user/login', this.loginForm)
.then(() => {
// 成功则进入主界面
// 在进入主界面前 会首先加载路由:permission.js
this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
this.$message.success('登录成功')
this.loading = false
})
.catch(() => {
this.loading = false
})
} else {
console.log('error submit!!')
return false
}
})
}

  首先是一个简单的表单验证,检查是否输入用户名和密码,验证通过则跳转至登录功能,如果登录成功,则跳转至主页面。

store->modules->user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
login({ commit }, userInfo) {
// 结构解析
const { username, password } = userInfo
return new Promise((resolve, reject) => {
login({ username: username.trim(), password: password }).then(response => {
const { data } = response
// 存储token
commit('SET_TOKEN', data.token)
setToken(data.token)
resolve()
}).catch(error => {
reject(error)
})
})
}

  首先调用api->user.js中的login()后端接口,如果成功则获取并存储token信息。

src->utils->request.js

  前后端所有的交互信息均在此文件中配置。

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
// create an axios instance
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000
})

// request interceptor
service.interceptors.request.use(
config => {

if (store.getters.token) {
config.headers['X-Token'] = getToken()
}
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)

// response interceptor
service.interceptors.response.use(
response => {
const res = response.data

if (res.code !== 20000) {
Message({
message: res.message || 'Error',
type: 'error',
duration: 5 * 1000
})

if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// to re-login
MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
confirmButtonText: 'Re-Login',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
},
error => {
console.log('err' + error)
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)

  首先实例化一个axios的对象,并配置好url路径。
  其次需要配置请求拦截器。如果已经获取到token信息,即此时是非登录页,在请求头headers中均添加X-Token字段。
  然后是配置响应拦截器。如果此时返回的code50008或者其他自定义的code则执行相应的操作,否则其他不为20000的状态均进行错误拦截。
  最后是对错误信息的简单处理。
  注:这里的字段需要和后端的字段保持一致!

路由管理系统

  本项目主要采用的方案是通过前端去进行路由和权限的控制,后端只需要向前端返回全部的角色信息、路由信息和操作信息等。
  这里的路由主要指的是,哪些角色可以访问哪些资源(路由)。

后端接口配置

  这里只针对部分接口解释说明,其余接口可参考源代码。

获取所有用户的角色信息

  UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
// 获取所有用户信息
@Select("select * from user")
@Results(id = "UserMap", value = {
@Result(column = "id", property = "id", jdbcType = JdbcType.INTEGER, id = true),
@Result(column = "username", property = "username", jdbcType = JdbcType.VARCHAR),
@Result(column = "nickname", property = "nickname", jdbcType = JdbcType.VARCHAR),
@Result(column = "introduction", property = "introduction", jdbcType = JdbcType.VARCHAR),
@Result(column = "avatar", property = "avatar", jdbcType = JdbcType.VARCHAR),
// 用户的角色信息
@Result(column = "id", property = "roleList", many = @Many(select = "com.shiro.demo.service.mapper.RoleMapper.selectRolesByUserId"))
})
List<FrontUser> getAllUsers();

  这是通过自定义Sql语句和返回结果实现复杂的数据库查询。
  这里返回的数据和数据库中的原始数据并不一致,因此需要在实体类中自定义需要向前端返回的数据格式(这里针对User类自定义FrontUser类,即添加了roleList变量)。
  其中roleList项需要嵌套调用RoleMapperselectRolesByUserId的接口方法。
  获取资源等其他信息同理。

前端路由配置

前端路由管理流程

前端路由管理流程

路由拦截

  vue-router在生成路由前,会首先进入router.beforeEach()函数,也即src->permission.js(注意文件路径,前端项目中有很多重名的文件)文件中定义的。
  permission.js

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
try {
// get user info
// note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
// 获取当前用户的角色信息

const { roles } = await store.dispatch('user/getUserInfo')

// 根据用户的角色信息生成动态信息表
// generate accessible routes map based on roles
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

// dynamically add accessible routes
// 挂载到动态路由
router.addRoutes(accessRoutes)

// 404 page must be placed at the end !!!
// 否则会经常显示404
router.addRoutes([{ path: '*', redirect: '/404', hidden: true }])

// hack method to ensure that addRoutes is complete
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true })
} catch (error) {
// remove token and go to login page to re-login
// await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
// next(`/login?redirect=${to.path}`)
// NProgress.done()
next()
}

  首先获取到当前用户的角色信息,然后根据此角色信息,进入src->modules->permission.js中的generateRoutes()生成动态路由表:
  src->modules->permission.js

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
async generateRoutes({ commit }, roles) {
// 1.从后端数据库中获取所有的路由信息
const res = await getRoutes()
const dbAsyncRoutes = res.data.data

// 2.过滤路由
const myAsyncRoutes = dbAsyncRoutes.filter(curr => {
if (curr.children == null || curr.children.length === 0) {
delete curr.children
}
return replaceComponent(curr)
})

// 3.根据角色动态生成路由
let accessedRoutes
// 判断当前的角色列表中,是否有包含admin
// 传入的roles信息为role_name

if (roles.includes('admin')) {
// 所有路由都可以被访问,将ansyncRoutes改成从数据库中获取
accessedRoutes = myAsyncRoutes || []
} else {
// 根据角色,过滤掉不能访问的路由表
accessedRoutes = filterAsyncRoutes(myAsyncRoutes, roles)
}
commit('SET_ROUTES', accessedRoutes)
return accessedRoutes
}

  这里需要从后端数据库中获取到所有的路由信息,然后进行过滤和生成。

路由配置

  最后所有的路由信息都会进入到src->router->index.js中去查找并生成。由于路由中的component参数需要指定为项目中的vue组件(例如:component: () => import('@/views/redirect/index')),而在数据库中只能用名字代替,因此这里需要先预设好对应的组件信息:
  src->router->index.js

1
2
3
4
5
6
export const componentMap = {
// 和数据库中的component字段绑定
'layout': require('@/layout').default,
'permission_role': () => import('@/views/permission/role').then(m => m.default),
'permission_user': () => import('@/views/permission/user').then(m => m.default)
}

  然后在src->modules->permission.js中替换后端路由字段component的信息。
  src->modules->permission.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// 替换route对象中的component
function replaceComponent(comp) {
if (comp.component && typeof (comp.component) === 'string') {
comp.component = componentMap[comp.component]
}

if (comp.children && comp.children.length > 0) {
for (let i = 0; i < comp.children.length; i++) {
comp.children[i] = replaceComponent(comp.children[i])
}
}
return comp
}

权限管理系统

  这里的权限主要指的是,哪些角色可以访问哪些路由中的哪些按钮或其他操作。例如,分配角色权限的角色可以访问查询用户,但不能访问创建和删除用户按钮。

后端接口配置

  所有的可操作的权限均在数据库permission表中。
  RoleMapper.java

1
2
3
4
5
// 根据permission获取能访问的角色信息
@Select({"select rl.role_name",
"from role rl, role_permission rp, permission pm",
"where pm.permission_name=#{permissionName} and pm.id=rp.permission_id and rp.role_id=rl.id"})
List<String> getRoleNameByPermissionName(String permissionName);

  这里需要跨表查询,最终返回角色名列表。

前端权限配置

  前端首先从后端拿到每个操作权限的角色信息:
  user.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取 能够操纵 各个操作权限的 角色信息
async getOperateRoles() {
// 获取能够操作 创建用户 功能的所有角色名称
let res = await getRoleNameByPermissionName('createUser')
this.createUserRoles = res.data.data

// 获取能够操作 更新用户 功能的所有角色名称
res = await getRoleNameByPermissionName('updateUser')
this.updateUserRoles = res.data.data

// 获取能够操作 分配用户角色 功能的所有角色名称
res = await getRoleNameByPermissionName('assignUserRole')
this.assignUserRoles = res.data.data

// 获取能够操作 删除用户 功能的所有角色名称
res = await getRoleNameByPermissionName('deleteUser')
this.deleteUserRoles = res.data.data
},

  然后在每个按钮中添加v-if属性,并通过checkPermission()函数去判断是否拥有该角色并决定是否显示(v-if="checkPermission(createUserRoles)"):
  src->utils->permission.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function checkPermission(value) {
if (value && value instanceof Array && value.length > 0) {
// 获取用户的角色信息
const roles = store.getters && store.getters.roles
// 具有该权限的角色信息
const permissionRoles = value

// 判断该用户是否具有该权限
const hasPermission = roles.some(role => {
return permissionRoles.includes(role)
})

return hasPermission
} else {
// console.error(`need roles! Like v-permission="['admin','editor']"`)
return false
}
}

分配信息管理系统

  最后一部分是重新分配用户的角色信息、重新分配角色的路由/操作信息等。

后端接口配置

分配角色资源

  RoleServiceImpl.java

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
@Override
public Boolean assignRoleMenu(Integer roleId, List<Integer> menuList) {
QueryWrapper<RoleMenu> roleMenuQueryWrapper = new QueryWrapper<>();
roleMenuQueryWrapper.select("menu_id").eq("role_id", roleId);
List<Object> dbRoleMenuList = roleMenuMapper.selectObjs(roleMenuQueryWrapper);

List<Object> listAll = new ArrayList();
listAll.addAll(menuList);
listAll.addAll(dbRoleMenuList);
LinkedHashSet<Object> newMenuList = new LinkedHashSet<Object>(listAll);

// 是否添加/删除 成功标志
int insert = 0, delete = 0;

for (Object menu : menuList) {
// 如果数据库中已有menu,则将合并后的newMenuList中的删除
if (dbRoleMenuList.contains(menu)) {
newMenuList.remove(menu);
} else {
newMenuList.remove(menu);
RoleMenu roleMenu = new RoleMenu();
roleMenu.setRoleId(roleId);
roleMenu.setMenuId((Integer) menu);
// 插入新的menu
insert = roleMenuMapper.insert(roleMenu);
}
}

// 删除 数据库中存在,但更新的menu中不存在的数据
for (Object menu2 : newMenuList) {
roleMenuQueryWrapper.clear();
roleMenuQueryWrapper.eq("role_id", roleId);
roleMenuQueryWrapper.eq("menu_id", menu2);
delete = roleMenuMapper.delete(roleMenuQueryWrapper);
}

if (insert == 0 && delete == 0) {
return false;
} else {
return true;
}
}

  这里主要考虑到一个逻辑需求:当数据库存放的角色资源信息为[1,2,3],此时前端传入的信息为[2,3,4],正确的处理逻辑为:删除[1],保留[2,3],添加[4]
  因此,这里首先将数据库中的信息和前端输入的信息合并成新的列表,然后遍历前端输入的信息,如果在数据库中已经存在,则不对数据库操作,并删除合并列表中的相应的数据,如果不存在,则添加至数据库中,最后判断此时合并的列表还剩下的元素,也就是数据库中存在,但前端没有传入的元素,也就是需要删除的元素。

前端分配系统配置

  前端实现比较简单,获取选择的数据并传入后端的接口即可。

项目展示

登录

登录表单及用户名和密码验证

登录验证

登录登出token变化

token变化

未登录访问其他路由

未登录访问路由

分配系统

分配用户角色

分配用户角色

分配角色资源

分配角色资源

分配角色操作

分配角色操作

修复Bug

1.前端路由树el-tree获取不到父节点数据
  解决:加上半选中状态的父节点id

1
const selectedKeys = this.$refs.menuTree.getHalfCheckedKeys().concat(this.$refs.menuTree.getCheckedKeys())

2.当分配角色信息为空时,后端会提示报错
  分析:后端接口为@RequestParam List<Integer> menuList,此时必须传入列表数据,而如果分配角色信息为空,则此时列表为空,不符合后端接口数据要求。
  解决:如果用户分配角色的信息为空,则向后端传入的数据为[-1],后端通过分析传入的数据是否包含-1来判断逻辑。
  前端:

1
2
3
if (menuList.length === 0) {
menuList = 'menuList=-1'
}

  后端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ApiOperation("分配角色资源")
@RequestMapping(value = "assignRoleMenu", method = RequestMethod.POST)
public R assignRoleMenu(Integer roleId, @RequestParam List<Integer> menuList) {
// 判断前端用户是否选择资源
if (menuList.contains(-1)) {
Integer delete = roleMenuService.delete("role_id", String.valueOf(roleId));
if (delete != 0) {
return R.ok().message("分配成功");
} else {
return R.error().message("和数据库数据一致或分配失败");
}
}else {
Boolean result = roleService.assignRoleMenu(roleId, menuList);

if (result) {
return R.ok().message("分配成功");
} else {
return R.error().message("和数据库数据一致或分配失败");
}
}
}

谢谢老板!
-------------本文结束感谢您的阅读给个五星好评吧~~-------------