前篇日志了解到 Shiro 是什么,Shiro 可以做什么,以及 Shiro 结合 ini 文件来做一个简单的权限管理的示例。

接下来,就是将 Shiro 与 SpringBoot 进行整合,然后结合数据库对用户进行身份认证。

整合的基本思路

在未整合 SpringBoot 之前,所有的数据都是在文件中,所以要读取 ini 配置文件,我们一般的步骤如下图所示。

graph

A(创建 IniSecurityManagerFactory 对象) --> B(获取 SecurityManager 实例对象)
B --> C(将 SecurityManager 对象保存到 SecurityUtils 中)
C --> D(获取 Subject 对象)
D --> E(调用相关方法: 登录, 角色判断等)

在 Spring Web 中,整合的步骤和上述基本一致。

但是,现在变了,由于现在是处于 Spring Web 的环境中,要控制访问网站资源的权限,哪些资源可以匿名访问,哪些资源需要登录才能访问等等,所以需要使用 Shiro 的 Filter,而不是 SecurityManagerFactory 对象。

首先 Shiro 中有一个过滤器,叫 ShiroFilterFactoryBean ,利用它可以控制资源访问,同时可以设置过滤器的类型来过滤某些请求。

接下来就是 SecurityManager ,在 Spring Web 的环境中,我们无法在过滤器中找到 getInstance 方法,而是需要创建 DefaultWebSecurityManager 对象,将该对象放到 ShiroFilterFactoryBean 中。

最后就是 Realm 了,Realm 做的事情主要是授权和认证了,可自定义一个自己的 Realm 继承自 AuthorizingRealm。将自定义的 Realm 添加到 DefaultWebSecurityManager 对象中即可,

后面的操作就基本一致了,比如调用 SecurityUtils 方法等等。

在了解非 web 环境下的调用步骤以及上述的整合步骤之后,可知每个组件的依赖关系如下图所示。

graph LR
自定义Realm --> DefaultWebSecurityFactoryBean-->ShiroFactoryBean

接下来,就通过 SpringBoot 来整合 Shiro。

导入依赖

先通过 Spring Initializer 初始化项目模板,然后导入 Shiro 的 spring-boot-starter。

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-starter</artifactId>
    <version>1.8.0</version>
</dependency>

后面需要用到 MySQL,使用 MyBatis-Plus 来操作数据库,JDBC,连接池 Druid,以及是用 jsp 文件来做页面渲染,导入 jsp 相关的依赖。

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>jstl</artifactId>
</dependency>

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-starter</artifactId>
    <version>1.8.0</version>
</dependency>

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.22</version>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.8</version>
</dependency>

配置

创建一个 ShiroConfig 类,用于配置 Shiro,按照之前的依赖关系,可以这样配置。

@Configuration
public class ShiroConfig {
    // 1、创建 ShiroFilter
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(@Autowired DefaultWebSecurityManager defaultWebSecurityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        // 配置安全管理器
        factoryBean.setSecurityManager(defaultWebSecurityManager);
        return factoryBean;
    }

    // 2、创建 SecurityManager 对象
    @Bean
    public DefaultWebSecurityManager defaultWebSecurityManager(@Autowired Realm realm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm);
        securityManager.setRealm(realm);
        return securityManager;
    }

    // 3、创建 Realm。
    @Bean
    public Realm realm() {
        CustomerRealm realm = new CustomerRealm();
        return realm;
    }
}

这些配置完成之后,还需要在 application.yml 文件中配置好数据库。

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/yourdb?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8
    username: root
    password: yourpassword

mybatis-plus:
  type-aliases-package: top.bestguo.springboot_shiro.entity
  mapper-locations: classpath:/mapper/**/*.xml

在 application.properties 的配置中,配置好 jsp 的前后缀,以及 web 服务的端口等等。

# 应用名称
spring.application.name=springboot_shiro
# 应用服务 WEB 访问端口
server.port=8080
server.servlet.context-path=/shiro

spring.mvc.view.prefix=/
spring.mvc.view.suffix=.jsp

logging.level.top.bestguo.springboot_shiro.mapper=debug

新建页面

创建三个页面,分别是登录页、注册页和系统主页。这些页面分别创建在 webapp 文件夹下,没有可以新建一个文件夹。可以直接在浏览器直接访问 jsp 文件。

image-20220329102659182

页面都很简单,这三个页面的的代码如下。

login.jsp

<%@page contentType="text/html; UTF-8" pageEncoding="UTF-8" isELIgnored="false" %>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>登录</h1>
<form action="${pageContext.request.contextPath}/user/login">
    用户名:<input type="text" name="username"><br>
    密码:<input type="password" name="password"><br>
    <input type="submit" value="登录">
</form>
</body>
</html>

register.jsp

<%@page contentType="text/html; UTF-8" pageEncoding="UTF-8" isELIgnored="false" %>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>注册</h1>
<form action="${pageContext.request.contextPath}/user/register">
    用户名:<input type="text" name="username"><br>
    密码:<input type="password" name="password"><br>
    <input type="submit" value="注册">
</form>
</body>
</html>

index.jsp

<%@page contentType="text/html; UTF-8" pageEncoding="UTF-8" isELIgnored="false" %>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>系统主页V1.0</h1>
    <a href="${pageContext.request.contextPath}/user/logout">登出</a>
    <ul>
        <li>
            <a href="">用户管理</a>
            <ul>
                <li><a href="">添加</a></li>
                <li><a href="">修改</a></li>
                <li><a href="">删除</a></li>
                <li><a href="">更新</a></li>
            </ul>
        </li>
        <li><a href="">商品管理</a></li>
        <li><a href="">物流管理</a></li>
        <li><a href="">订单管理</a></li>
    </ul>
</body>
</html>

资源访问控制

页面创建完成之后,测试这些页面均能够正常的访问。由于主页是不能够直接访问的到的,所以可以利用 Filter 来允许哪些页面可以匿名访问,哪些页面需要验证和授权才能访问。所以,我们在 ShiroConfig 类中设置。

ShiroFilterFactoryBean 中有一个 setFilterChainDefinitionMap 方法,用于设置拦截的资源,需要传入一个 map 集合,key 为访问的路径,value 为使用的过滤器名称

利用通配符可以拦截子路径下的资源, /** 就是拦截全部的。

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(@Autowired DefaultWebSecurityManager defaultWebSecurityManager) {
    ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
    // 配置安全管理器
    factoryBean.setSecurityManager(defaultWebSecurityManager);
    
    // 配置系统受限资源和公共资源
    HashMap<String, String> map = new HashMap<>();
    // authc 请求这个资源需要认证和授权,anon就是该资源无需认证
    map.put("/user/login", "anon");
    map.put("/register.jsp", "anon");
    map.put("/user/register", "anon");
    map.put("/**", "authc");

    factoryBean.setFilterChainDefinitionMap(map);
    return factoryBean;
}

当我访问至 index.jsp 页面时,如果未进行认证,那么就会直接跳转至 login.jsp 页面。因为在 Shiro 中,默认的访问路径就是 login.jsp 页面,该项可以通过 setLoginUrl 方法来执行需要跳转的认证页面。

// shiro有一个默认的认证界面路径,默认就是 login.jsp 页面
factoryBean.setLoginUrl("/your/login/path");

如果以进行认证,那么就可以正常进行访问了。

数据库设计

新建一个 shiro 数据库,创建一个 t_user 表,表中的字段分别如下所示。

create table t_user
(
    id       int auto_increment      primary key,
    username varchar(40)  null,
    password varchar(40)  null,
    salt     varchar(255) null
);

然后利用 MybatisX-generator 生成相对应的 mapper 以及对应的实体类,该插件可以在 idea 中找到并安装。

注册功能实现

用户注册的实现也是非常简单的。

@RequestMapping("register")
public String register(TUser user) {
    try {
        userService.register(user);
        return "redirect:/login.jsp";
    } catch (Exception e) {
        e.printStackTrace();
        return "redirect:/register.jsp";
    }
}

密码的加密使用的是 MD5 来对密码进行加密,利用加盐工具类,传入明文密码,盐值和迭代次数即可,相关的代码在后面的附录中。

登录功能实现

登录的实现逻辑如下,首先获取 Subject,然后调用登录方法。利用捕获的哪些异常来说明登录失败的原因。

@RequestMapping("login")
public String login(String username, String password) {
    Subject subject = SecurityUtils.getSubject();
    try {
        subject.login(new UsernamePasswordToken(username, password));
        return "redirect:/index.jsp";
    } catch (UnknownAccountException e) {
        System.out.println("用户名错误!");
    } catch (IncorrectCredentialsException e) {
        System.out.println("密码错误!");
    }
    return "redirect:/login.jsp";
}

然后在自定义的 Realm 中,重写认证的方法,调用 userService,判断是否查询到用户信息,如果有用户信息,那么比对用户的密码。创建 SimpleAuthenticationInfo 对象来对用户进行过认证。

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    System.out.println("=====================================");
    String username = (String) authenticationToken.getPrincipal();
    // 按照用户名查询
    UserService userService = (UserService) ApplicationUtils.getBean("userService");
    TUser userInfo = userService.findUserInfo(username);
    if(Objects.nonNull(userInfo)) {
        // 用户名、密码、盐值、名字。
        return new SimpleAuthenticationInfo(username, userInfo.getPassword(), ByteSource.Util.bytes(userInfo.getSalt()), this.getName());
    }
    return null;
}

在 SimpleAuthenticationInfo 类中,如果是对加密的密码进行比较,需要有 4 个参数:一个用户名、一个加密所使用的盐值、一个加密的类型和 realm 的名字。如果是比较明文的话,只需要三个参数即可,分别是用户名、密码、 realm 的名字。

这样,登录功能就基本上实现了。只要认证成功之后就能访问主页了。

image-20220329114737704

退出登录功能实现

退出功能很简单,只需调用 logout 方法即可。

@RequestMapping("logout")
public String logout() {
    Subject subject = SecurityUtils.getSubject();
    subject.logout();
    return "redirect:/login.jsp";
}

授权功能实现

这里只做一个简单的授权功能实现,由于还没有授权之类的相关表,后面再详细的介绍授权的完整流程。

每个用户登录之后,应该需要授予对应的角色和权限。若要给对应的用户赋予对应的权限,则需要创建一个 SimpleAuthorizationInfo 对象,用于授予角色和权限。

比如,我要给 xiaohe123 这个用户授予管理员角色。该管理员的角色拥有用户操作,所有订单保存和所有商品保存,可以这样写。

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    // 获取身份信息
    String username = (String) principalCollection.getPrimaryPrincipal();
    System.out.println("调用授权验证:" + username);
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    if("xiaohe123".equals(username)) {
        authorizationInfo.addRole("admin");
        authorizationInfo.addStringPermission("user:*:*");
        authorizationInfo.addStringPermission("order:save:*");
        authorizationInfo.addStringPermission("shop:save:*");
        return authorizationInfo;
    } else if("xiaohe124".equals(username)) {
        authorizationInfo.addRole("user");
        authorizationInfo.addStringPermission("user:revise:*");
        authorizationInfo.addStringPermission("user:update:*");
        return authorizationInfo;
    }
    return null;
}

addRole 方法就是授予角色,addStringPermission 就是根据字符串来授予相对应的权限。授权方式可以参考上一篇的日志。

权限判断方式

授权完成之后,就可以判断用户具备那些权限了。判断权限有多种方式,可以在 jsp 中引入 shiro 标签,可以通过程序进行判断,也可以使用注解的方式来判断。

Shiro 标签对判断方式

利用 jstl 标签判断权限,不同的角色来用于展示不同的菜单页。

<ul>
    <shiro:hasAnyRoles name="user,admin">
        <li>
            <a href="">用户管理</a>
            <ul>
                <shiro:hasPermission name="user:add:*">
                    <li><a href="">添加</a></li>
                </shiro:hasPermission>
                <shiro:hasPermission name="user:update:*">
                    <li><a href="">修改</a></li>
                </shiro:hasPermission>
                <shiro:hasPermission name="user:delete:*">
                    <li><a href="">删除</a></li>
                </shiro:hasPermission>
                <shiro:hasPermission name="user:update:*">
                    <li><a href="">更新</a></li>
                </shiro:hasPermission>
            </ul>
        </li>
    </shiro:hasAnyRoles>
    <shiro:hasRole name="admin">
        <!-- 管理员权限可见 -->
        <li><a href="">商品管理</a></li>
        <li><a href="">物流管理</a></li>
        <li><a href="">订单管理</a></li>
    </shiro:hasRole>
</ul>

按照授权的代码来讲,如果我登录的账户是 xiaohe123,那么它具备用户操作的所有权限,和其它的功能。如果是 xiaohe124,那么它只有用户的修改和更新操作。

下图是 xiaohe124 的页面。

image-20220329143610917

下图是 xiaohe123 的页面。

image-20220329114737704

程序判断的方式

程序的判断在上一篇的日志中有提及到,这里就不再赘述了。这里我写了一个小例子。

@RequestMapping("save")
public String save() {
    // 获取主题对象
    Subject subject = SecurityUtils.getSubject();
    // 代码方式
    if(subject.hasRole("admin")) {
        // 按照权限字符串
        if(subject.isPermitted("order:save:*")) {
            System.out.println("保存订单");
        } else {
            System.out.println("保存订单失败");
        }
    } else {
        System.out.println("无权访问");
    }
    return "redirect:/index.jsp";
}

注解判断方式

注解的判断在控制层上,利用 @RequireRoles 注解就类似于 subject.hasRole 方法来判断权限的规则。利用 @RequiresPermissions 注解用于判断该角色对应的相关权限。

比如下面这个代码,访问的用户必须是 admin 的角色,且能够保存商品编号为 114 的商品。

@RequestMapping("saveShop")
@RequiresRoles("admin") // 用来判断角色
@RequiresPermissions({"shop:save:114"})
public String saveShop() {
    System.out.println("商品信息保存!");
    return "redirect:/index.jsp";
}

当我是 xiaohe123 操作时,它的权限字符串是 shop:save:* ,也就是说它能够保存全部的商品信息,自然也就包括 114 这个商品了。

附录

UserService 代码

@Service("userService")
public class UserServiceImpl implements UserService {

    @Autowired
    private TUserMapper tUserMapper;

    @Override
    public void register(TUser tUser) {
        String salt = SaltUtils.getSalt(8);
        // 生成 md5,传入明文密码,盐值和迭代次数。
        Md5Hash md5Hash = new Md5Hash(tUser.getPassword(), salt, 3);
        tUser.setSalt(salt);
        tUser.setPassword(md5Hash.toHex());
        tUserMapper.insert(tUser);
    }

    @Override
    public TUser findUserInfo(String username) {
        TUser user = tUserMapper.selectOne(new QueryWrapper<TUser>()
                .eq("username", username));
        return user;
    }
}

ApplicationUtils.java

/**
 * 获取 spring 容器中的 bean 对象
 */
@Component
public class ApplicationUtils implements ApplicationContextAware {

    private static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        context = applicationContext;
    }

    public static Object getBean(String beanName) {
        return context.getBean(beanName);
    }
}

SaltUtils.java

import java.util.Random;

public class SaltUtils {

    public static String getSalt(int n) {
        char[] chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()".toCharArray();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            char c = chars[new Random().nextInt(chars.length)];
            sb.append(c);
        }
        return sb.toString();
    }
    
}