1. 整合 SpringBoot

1.1 创建项目

新建一个项目 shiro-boot:
image.png
image.png
image.png
项目结构如下:
image.png

1.2 添加依赖

Shiro 官方提供了和 SpringBoot 集成的启动器,使用官方提供的启动器,只需要极少的配置和代码就可以顺利整合SpringBoot。
官方文档地址:http://shiro.apache.org/spring-boot.html
在项目的 pom.xml 中添加如下坐标。

  1. <dependency>
  2. <groupId>org.apache.shiro</groupId>
  3. <artifactId>shiro-spring-boot-web-starter</artifactId>
  4. <version>1.7.1</version>
  5. </dependency>

完整 pom.xml 内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.3</version>
        <relativePath/>
    </parent>

    <groupId>com.imcode</groupId>
    <artifactId>shiro-boot</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>shiro-boot</name>

    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

1.3 配置信息

配置文件

application.properties 文件添加如下配置:

shiro.loginUrl=/login  # 系统登录的链接 GET 请求 默认是login.jsp

配置类

image.png
只需要配置Realm和过滤规则就可以,安全管理器、会话管理器会使用默认配置。

@Configuration
public class ShiroConfig {
    @Bean
    public Realm realm() {
        TextConfigurationRealm realm = new TextConfigurationRealm();
        realm.setUserDefinitions("jack=123456,user \n rose=111111,admin,user");
        realm.setRoleDefinitions("admin=dept:view,dept:add \n user=dept:view");
        return realm;
    }

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = 
            new DefaultShiroFilterChainDefinition();
        // 不拦截登录
        chainDefinition.addPathDefinition("/login", "anon");
        // 用户退出
        chainDefinition.addPathDefinition("/logout", "logout");
        // 除登录和退出外,其它所有资源都需要认证通过才能访问
        chainDefinition.addPathDefinition("/**", "user");
        return chainDefinition;
    }
}

1.4 控制器和页面

登录控制器

image.png

@Controller
public class LoginController {
    /**
     * 跳转到登录页面
     * @return
     */
    @GetMapping("/login")
    public String login() {
        return "login";
    }

    /**
     * 登录系统
     * @return
     */
    @PostMapping("/login")
    public String login(String username, String password) {
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token =
                new UsernamePasswordToken(username,password);
        subject.login(token);
        return "redirect:index";
    }

    /**
     * 跳转到系统主页
     * @return
     */
    @GetMapping("/index")
    public String main() {
        return "index";
    }
}

页面

image.png
login.html 内容如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" >
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<form th:action="@{/login}" method="post" shiro:guest>
    <input name="username" type="text"/><br>
    <input name="password" type="password"/><br>
    <input type="submit" value="登录"/>
</form>
</body>
</html>

index.html 内容如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" >
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
Hello Shiro
<a th:href="@{/logout}">退出</a>
<hr>
<a th:href="@{/dept/add}">新增部门</a> | <a th:href="@{/dept/list}">部门列表</a>
</body>
</html>

1.5 认证

认证验证

  • 启动应用,访问 http://localhsot:8080, 未登录的时候访问根目录,跳转到了登录页面,访问其它链接也会跳转到登录页面。
  • 用户名和密码都输入正确,会进入系统首页。
  • 如果用户名输入错误,抛出 org.apache.shiro.authc.UnknownAccountException 异常。
  • 如果用户名正确,密码输入错误,抛出 org.apache.shiro.authc.IncorrectCredentialsException 异常。
  • 通过查看源码,以上两个异常类都是 org.apache.shiro.ShiroException.AuthenticationException 的子类。

    统一处理认证异常

    新建统一异常处理类:
    image.png
    内容如下:

    @RestControllerAdvice
    public class GlobalExceptionHandler {
      @ExceptionHandler(AuthenticationException.class)
      public Map<String, Object> handleUnknownAccountException(AuthenticationException e) {
          Map<String, Object> map = new HashMap<>();
          map.put("code", HttpStatus.UNAUTHORIZED.value());
          map.put("msg", "用户名或密码错误");
          return map;
      }
    }
    

    重启验证效果:
    image.png

    1.6 授权

    权限注解

    Shiro 提供了一些注解用于权限控制,使用方式非常简单,在需要权限控制的方法上添加相关注解就可以了。Shiro 提供的权限注解如下:

  • @RequiresAuthentication

    • 表示当前 Subject 已经通过认证;即 subject.isAuthenticated() 返回 true。
  • @RequiresUser
    • 表示当前 Subject 已经通过认证或者通过记住我登录成功。
  • @RequiresGuest
    • 表示当前 Subject 没有身份验证或通过记住我登录过,即是游客身份。
  • @RequiresRoles(value={"admin", "user"}, logical= Logical.OR)
    • 表示当前 Subject 需要角色 admin 或 user。
  • @RequiresPermissions (value={"dept:view","dept:add"}, logical= Logical.AND)

    • 表示当前 Subject 需要权限 dept:viewdept:add

      编写部门控制器

      image.png

      @RestController
      @RequestMapping("/dept")
      public class DeptController {
      
      @GetMapping("/list")
      // @RequiresPermissions("dept:view")
      @RequiresRoles(value = {"admin","user"},logical= Logical.OR)
      public String list() {
         return "dept-view";
      }
      
      @GetMapping("/add")
      // @RequiresPermissions("dept:add")
      @RequiresRoles({"admin"})
      public String add() {
         return "dept-add";
      }
      }
      

      验证权限注解是否生效。如果Subject没有权限注解配置的角色和权限,会抛出 org.apache.shiro.authz.AuthorizationException 异常。
      image.png

      权限标签

      Shiro 官方提供了用于JSP页面的权限标签,如果使用JSP作为试图,请参考:http://shiro.apache.org/web.html#Web-JSP%2FGSPTagLibrary
      我们使用 thymeleaf 模板,thymeleaf 官方没有提供对应的 shiro 权限标签,我们使用第三方扩展的标签库。
      该标签库的地址:https://github.com/theborakompanioni/thymeleaf-extras-shiro

      引入标签

      在pom.xml 中添加 thymeleaf-extras-shiro 相关依赖:

      <dependency>
      <groupId>com.github.theborakompanioni</groupId>
      <artifactId>thymeleaf-extras-shiro</artifactId>
      <version>2.0.0</version>
      </dependency>
      

      引入依赖后,需要在 ShiroConfig 中配置该方言标签:
      image.png

      @Bean
      public ShiroDialect shiroDialect() {
      return new ShiroDialect();
      }
      

      标签说明

      参考官方文档:https://github.com/theborakompanioni/thymeleaf-extras-shiro

      使用示例

      login.html

      <!DOCTYPE html>
      <html xmlns:th="http://www.thymeleaf.org"
       xmlns:shiro="http://www.pollix.at/thymeleaf/shiro" >
      <head>
      <meta charset="UTF-8">
      <title>登录</title>
      </head>
      <body>
      <shiro:guest>
      <form th:action="@{/login}" method="post">
         <input name="username" type="text"/><br>
         <input name="password" type="password"/><br>
         <input type="submit" value="登录"/>
      </form>
      </shiro:guest>
      <shiro:authenticated>
      您好, <shiro:principal/> <br>
      <a href="/index">系统首页</a>
      </shiro:authenticated>
      </body>
      </html>
      

      index.html

      <!DOCTYPE html>
      <html xmlns:th="http://www.thymeleaf.org"
       xmlns:shiro="http://www.pollix.at/thymeleaf/shiro" >
      <head>
      <meta charset="UTF-8">
      <title>Title</title>
      </head>
      <body>
      Hello <shiro:principal/>
      <a th:href="@{/logout}">退出</a> <br>
      <hr>
      您的角色是:
      <p shiro:hasRole="admin">管理员</p>
      <p shiro:hasRole="user">普通用户</p>
      <hr>
      <a th:href="@{/dept/add}" shiro:hasPermission="dept:add">新增部门</a>
      <a th:href="@{/dept/list}" shiro:hasPermission="dept:view">部门列表</a>
      </body>
      </html>
      

      统一处理授权异常

      在 GlobalExceptionHandler 类中添加授权异常处理方法:
      image.png

      @ExceptionHandler(AuthorizationException.class)
      public Map<String, Object> handleAuthorizationException(AuthorizationException e) {
      Map<String, Object> map = new HashMap<>();
      map.put("code", HttpStatus.FORBIDDEN.value());
      map.put("msg", "您没有访问该资源的权限");
      return map;
      }
      

      访问没有授权的资源效果如下:
      image.png

      2. 自定义 Realm

      在前面我们使用 TextConfigurationRealm,将 Subject 的认证和授权信息写到了该Realm的对象里,实际开发中,用户的认证和授权信息是存储在数据库的。我们需要自定义Realm从数据库来读取授权和认证信息,我们先来实现用户的业务逻辑。

      2.1 User

      引入lombok:

      <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
      </dependency>
      

      image.png ```java package com.imcode.entity;

import lombok.AllArgsConstructor; import lombok.Data;

@Data @AllArgsConstructor public class User { private Long userId; private String username; private String password; }

<a name="qSVMA"></a>
## 2.2 UserService
![image.png](https://cdn.nlark.com/yuque/0/2021/png/1429839/1615605868531-54391ace-d541-440b-af87-f33987001394.png#align=left&display=inline&height=281&margin=%5Bobject%20Object%5D&name=image.png&originHeight=281&originWidth=303&size=9128&status=done&style=none&width=303)<br />UserService 接口:
```java
public interface UserService {
    User queryByUsername(String username);
}

UserServiceImpl 实现类:

@Service
public class UserServiceImpl implements UserService {

    @Override
    public User queryByUsername(String username) {
        if ("jack".equals(username)) {
            // 实际开发中用户信息从数据库查询获得
            Long dbUserId = 1007L;
            String dbUsername = username;
            String dbpassword = "123456";
            return new User(dbUserId, dbUsername, dbpassword);
        }
        return null;
    }
}

2.3 Realm

Realm 接口

Realm 接口和类的关系:
image.png
image.png
最基础的是Realm接口,CachingRealm负责缓存处理,AuthenticatingRealm负责认证,AuthorizingRealm负责授权,通常自定义的realm 继承 AuthorizingRealm

编写 Realm

image.png
自定义的 ShiroRealm 继承 AuthorizingRealm ,需要实现父类两个抽象方法:

  • doGetAuthenticationInfo(AuthenticationToken token)
    • 该方法实现认证
  • doGetAuthorizationInfo(PrincipalCollection principals)

    • 该方法实现授权

      public class ShiroRealm extends AuthorizingRealm {
      /**
      * 认证
      * @param token
      * @return
      * @throws AuthenticationException
      */
      @Override
      protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
             throws AuthenticationException {
         System.out.println("认证...");
         return null;
      }
      
      /**
      * 授权
      * @param principals
      * @return
      */
      @Override
      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
         System.out.println("授权...");
         return null;
      }
      }
      

      配置 Realm

      在ShiroConfig中配置该Realm:
      image.png

      @Bean
      public Realm realm() {
      // TextConfigurationRealm realm = new TextConfigurationRealm();
      // realm.setUserDefinitions("jack=123456,user \n rose=111111,admin,user");
      // realm.setRoleDefinitions("admin=dept:view,dept:add \n user=dept:view");
      ShiroRealm realm = new ShiroRealm();
      return realm;
      }
      

      2.4 认证

      public class ShiroRealm extends AuthorizingRealm {
      
      @Autowired
      private UserService userService;
      /**
      * 认证
      * 调用 subject.login(token) 会调用该方法并传入token
      * @param token
      * @return
      * @throws AuthenticationException
      */
      @Override
      protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
             throws AuthenticationException {
         System.out.println("认证...");
         UsernamePasswordToken userToken = (UsernamePasswordToken) token;
         // 根据用户名从数据库查询用户
         User user = userService.queryByUsername(userToken.getUsername());
         // 用户名不存在
         if (user == null) {
             throw new UnknownAccountException("用户名或密码错误");
         }
         // 创建简单认证信息对象并返回
         // 第二个参数是数据库中存储的用户密码
         return new SimpleAuthenticationInfo(user, user.getPassword(), getName());
      }
      
      /**
      * 授权
      * @param principals
      * @return
      */
      @Override
      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
         return null;
      }
      }
      

      2.5 授权

      /**
      * 授权
      * @param principals
      * @return
      */
      @Override
      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
      System.out.println("授权...");
      // 创建简单授权信息对象
      SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
      info.addRole("user");
      info.addStringPermission("dept:view");
      return info;
      }
      

      验证是否成功。

      3. 密码加密

      3.1 MD5

      简介

      MD5(Message-Digest Algorithm 5)是一种被广泛使用的消息摘要算法,也称为哈希算法、散列算法,可以产生出一个定长的128位(16字节)的散列值(Hash Value),一般用于数字签名以确保信息传输完整性与密码的加密存储。
      简单的说,使用MD5可以将任意长度的明文字符串生产为128位的哈希值。MD5生产的hash值是不可逆的,因为MD5算法里面有很多不可逆的运算,会丢失很多原文的信息,无法找回,所以该算法是不可逆的。
      密码的存储方案:

  • 明文存储到数据库 绝对不可以

  • MD5 散列后存储 暴力枚举攻击(时间长)、字典攻击(空间大)、彩虹表攻击(折中)
  • MD5 加盐散列后存储 有效避免彩虹表攻击

解决方案:让相同的明文密码散列后的HASH值都不一样。
参考博客:
https://blog.csdn.net/qq_44970883/article/details/111112681
https://www.sohu.com/a/197288583_684445

Md5Hash

Shiro 的 Md5Hash 类实现集成了该算法,具体使用方式如下:
image.png

@Test
public void test() {
    /**
      * 只使用散列算法
      */
    String password1 = new Md5Hash("123456").toHex();
    System.out.println(password1);

    /**
     * 散列算法 + 随机盐
     */
    String password2 = new Md5Hash("123456", UUID.randomUUID().toString()).toHex();
    System.out.println(password2);

    /**
     * 散列算法 + 随机盐 + 多次散列
     * MD5(MD5(MD5(m)))
     */
    String password3 = new Md5Hash("123456", UUID.randomUUID().toString(),3).toHex();
    System.out.println(password3);
}

在数据库保存用户密码的时候,可以使用 散列算法 + 随机盐 + 多次散列 后的密文存储到数据库。

3.2 Shiro 配置

配置凭证匹配器

image.png
添加凭证匹配器:

@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
    HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
    // 使用MD5散列算法
    matcher.setHashAlgorithmName("MD5");
    // 设置散列次数
    matcher.setHashIterations(3);
    return matcher;
}

在Realm中配置该匹配器:
image.png

修改认证方法

image.png

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
    throws AuthenticationException {
    System.out.println("认证...");
    UsernamePasswordToken userToken = (UsernamePasswordToken) token;
    // 根据用户名从数据库查询用户
    User user = userService.queryByUsername(userToken.getUsername());
    // 用户名不存在
    if (user == null) {
        throw new UnknownAccountException("用户名或密码错误");
    }
    // 创建简单认证信息对象并返回
    // 第二个参数是数据库中存储的用户密码
    // 第三个参数是盐
    ByteSource salt = ByteSource.Util.bytes(user.getUsername());
    return new SimpleAuthenticationInfo(user, user.getPassword(), salt, getName());
}

模拟数据库存储密文

使用
ByteSource salt = ByteSource.Util.bytes(“jack”);
String password = new Md5Hash(“123456”, salt ,3).toHex();
对密码123456 进行MD5 哈希,哈希的次数是3。该次数和ShiroConfig中的次数要保持一致。
HASH后的密文是:4ebb57a4e260f12e429c469bd6d77bd0
修改UserService的 queryByUsername() 方法:
image.png

测试验证

4. 缓存管理

目前的方案中,用户每次访问系统资源的时候,都需要调用 ShiroRealm 中的授权方法doGetAuthorizationInfo(PrincipalCollection principals),该方法每次都会访问数据库获取用户的授权信息,为了提高性能,我们可以使用缓存来缓存用户的授权信息,避免每次都去访问数据库。
Shiro 提供和基于内存的缓存方案,以及 ehcache 缓存框架的整合。

4.1 内存缓存

启用内存缓存非常简单,只需要在 ShiroConfig 中配置缓存管理器即可,配置好的缓存管理器会自动注入安全管理器。

@Bean
public CacheManager cacheManager() {
    return new MemoryConstrainedCacheManager();
}

验证效果。

4.2 ehcache 缓存

pom.xml

引入 shiro 对 ehcache 的支持包和 ehcache 包:

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-ehcache</artifactId>
  <version>1.7.1</version>
</dependency>

<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <version>3.8.0</version>
</dependency>

配置缓存管理器

image.png
验证效果。

5. 分布式缓存和集群会话

5.1 介绍

在集群环境中,我们需要集群中的多台服务器能够共享缓存和会话,目前流行的方案是使用Redis数据库来作为缓存服务器。Shiro 官方没有提供对 Redis 做缓存的集成支持,在官方提供的第三方扩展库中有对 Redis的支持。
参考地址:http://shiro.apache.org/integration.html
image.png
该项目的名称叫 shiro-redis
该项目文档地址:https://github.com/alexxiyang/shiro-redis/tree/master/docs

5.2 集成 shiro-redis

shiro-redis 提供了和SpringBoot 整合的 starter,让整合变得非常简单。shiro-redis 可以用来缓存用户的会话信息和认证信息。

pom.xml

<dependency>
  <groupId>org.crazycake</groupId>
  <artifactId>shiro-redis-spring-boot-starter</artifactId>
  <version>3.3.1</version>
  <exclusions>
    <exclusion>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>

shiro-redis 目前最新版本是 3.3.1,该版本以来的 shiro 版本是1.6.0。我们项目使用shiro 1.7.1,要排除掉 shiro 1.6.0的依赖。
image.png

application.properties

添加如下配置:shiro-redis.cache-manager.principal-id-field-name=userId
userId 是User 类中用户id属性的名称。给配置用来针对不同的主体(用户) 生成唯一的缓存key。
image.png

User 对象序列化

User 对象是作为 principal 的,所以User 对象的信息如果缓存到Redis,则该类需要实现序列化接口。
image.png
验证效果:
将上一小节配置的缓存管理器注释掉
image.png
用户登录成功以后,访问系统功能,查看Redis中数据的变化:
image.png

5.3 会话管理器

shiro 提供了三种会话管理器:

  • DefaultSessionManager:DefaultSecurityManager 使用的默认实现,用于 JavaSE 环境;
  • ServletContainerSessionManager:DefaultWebSecurityManager 使用的默认实现,用于Web环境,直接使用 Servlet 容器的会话;
  • DefaultWebSessionManager:用于Web环境,Shiro 维护和管理会话,不使用Servlet容器的会话。
    • 在 配置文件添加如下配置: shiro.userNativeSessionManager=true

可实现 Shiro 维护和管理会话。**
WEB环境会话管理总结:

  • HttpServletRequest 对象被 Shiro 包装成 ShiroHttpServletRequest对象 。
  • 通过 request.getSession() 获取 session,该session是Servlet容器的还是Shiro的,由使用的安全管理器SecurityManager 和会话管理器 SessionManager 决定。
  • 不管是通过 request.getSession() 或者 subject.getSession() 获取到session对象,两者都是等价的。操作 subject.getSession() 等价于操作 request.getSession()。