文章

Shiro

官网

源码地址

1 简介

Apache Shiro 是一个开源的安全管理框架,提供身份认证、授权、密码学和会话管理。

Shiro 框架直观、易用,同时提供健壮的安全性。

相对于 Spring Security 而言,Shiro 是一个轻量级的安全管理框架。

1.1 由来

Shiro 的前身是 JSecurity,2004年,Les Hazlewood 和 Jeremy Haile 创办了 JSecurity。当时没有合适的适用于应用程序级别的 Java 安全管理框架,同时又对 JAAS 非常失望。2004年到2008年期间,JSecurity 托管在 SourceForge 上,贡献者包括 Peter Ledbrook、Alan Ditzel 和 Tim Veil。2008年,JSecurity 项目贡献给了 Apache 软件基金会(ASF),并被接纳成为 Apache Incubator 项目,由导师管理,目标是成为一个顶级 Apache 项目。期间,JSecurity 曾短暂更名为 Ki,随后因商标问题被社区更名为“Shiro”。随后项目持续在 Apache Incubator 中孵化,并增加了贡献者 Kalle Korhonen。2010年7月,Shiro 社区发布了 1.0 版,随后社区创建了其项目管理委员会,并选举 Les Hazlewood 为主席。2010年9月22日,Shrio 成为 Apache 软件基金会的顶级项目(TLP)。

1.2 功能

Apache Shiro 是一个强大而又灵活并开源的安全管理框架,它极简处理身份认证,授权,加密和企业会话管理。Apache Shiro 的首要目标是易于使用和理解。

Apache Shiro 可以做的事情:

  1. 验证用户核实身份
  2. 对用户执行访问控制
  3. 在任何环境下使用 Session API,即使没有Web容器
  4. 在身份认证,访问控制期间或在会话的生命周期,对事件作出反应
  5. 聚集一个或多个用户安全数据的数据源,并作为一个单一的复合用户“视图”
  6. 单点登录(SSO)
  7. 为登录的用户启用”Remember Me”服务

Apache Shiro 是一个拥有很多功能的综合性程序安全框架。下图展示了 Shiro 的重点:

Shiro 四大基石——身份认证,授权,会话管理和加密。

  1. Authentication:简称为“登录”,这是一个证明用户是谁的行为。
  2. Authorization:访问控制,也就是决定“谁”去访问“什么”。
  3. Session Management:管理用户特定的会话,即使在非 Web 应用程序。
  4. Cryptography:通过使用加密算法保持数据安全的同时又易于使用。

除此之外,Shiro 也提供了额外的功能来解决在不同环境下所面临的安全问题,尤其是以下这些:

  1. Web Support:Shiro Web 支持的 API 能够轻松地帮助保护 Web 应用程序。
  2. Caching:缓存是 Apache Shiro 中的第一层公民,来确保安全操作快速而又高效。
  3. Concurrency:Apache Shiro 利用它的并发特性来支持多线程应用程序。
  4. Testing:测试支持来帮助编写单元测试和集成测试。
  5. “Run As”:一个允许用户假设成为另一个用户身份的功能,在管理脚本时可以起到很大的作用。
  6. “Remember Me”:在会话中记住用户的身份,这样用户只需要在强制登录时候登录。

2 初步了解

2.1下载

shiro-root-1.7.1

2.2 快速开始

源码中有一个快速开始的简单案例:🌰samples - quickstart🌰;这是一个 JavaSE 项目,这个简单案例可以让我们初步了解 Shiro 的登录(Authentication)和授权(Authorization)。

shiro.ini

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
# 配置用户
[users]
# user 'root' with password 'secret' and the 'admin' role
# 用户名:root,密码:secret,角色:admin
root = secret, admin
# user 'guest' with the password 'guest' and the 'guest' role
guest = guest, guest
# user 'presidentskroob' with password '12345' ("That's the same combination on
# my luggage!!!" ;)), and role 'president'
presidentskroob = 12345, president
# user 'darkhelmet' with password 'ludicrousspeed' and roles 'darklord' and 'schwartz'
# 多个角色
darkhelmet = ludicrousspeed, darklord, schwartz
# user 'lonestarr' with password 'vespa' and roles 'goodguy' and 'schwartz'
lonestarr = vespa, goodguy, schwartz

# 配置角色
[roles]
# 'admin' role has all permissions, indicated by the wildcard '*'
admin = *
# The 'schwartz' role can do anything (*) with any lightsaber:
schwartz = lightsaber:*
# The 'goodguy' role is allowed to 'drive' (action) the winnebago (type) with
# license plate 'eagle5' (instance specific id)
goodguy = winnebago:drive:eagle5

Quickstart.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
92
93
94
95
96
97
98
99
100
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.config.IniSecurityManagerFactory;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.Factory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Simple Quickstart application showing how to use Shiro's API.
 *
 * @since 0.9 RC2
 */
public class Quickstart {

    private static final transient Logger log = LoggerFactory.getLogger(Quickstart.class);


    public static void main(String[] args) {

        // * 使用 shiro.ini 文件来创建一个 SecurityManager 对象
        Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
        SecurityManager securityManager = factory.getInstance();
        
        // * 将 SecurityManager 设置给 SecurityUtils,以便将来获取 Subject
        SecurityUtils.setSecurityManager(securityManager);
        
        // * 获取当前的访问对象
        // * 这里拿到的 Subject 可能是已经登录的用户对象,也可能是一个匿名的用户对象
        Subject currentUser = SecurityUtils.getSubject();
        
        // * 获取 Session 对象
        Session session = currentUser.getSession();
        // * 关于 Session 的操作
        session.setAttribute("someKey", "aValue");
        String value = (String) session.getAttribute("someKey");
        if (value.equals("aValue")) {
            log.info("Retrieved the correct value! [" + value + "]");
        }
        
        // * 判断当前用户是否已经登录
        if (!currentUser.isAuthenticated()) {
            // * 构建 UsernamePasswordToken 准备登陆
            UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
            // * 设置记住我
            token.setRememberMe(true);
            try {
                // * 执行登陆操作
                currentUser.login(token);
            } catch (UnknownAccountException uae) {
                // * 用户名错误,抛出 UnknownAccountException 异常
                log.info("There is no user with username of " + token.getPrincipal());
            } catch (IncorrectCredentialsException ice) {
                // * 密码错误,抛出 IncorrectCredentialsException 异常
                log.info("Password for account " + token.getPrincipal() + " was incorrect!");
            } catch (LockedAccountException lae) {
                // * 账户被锁定,抛出 LockedAccountException 异常
                log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                        "Please contact your administrator to unlock it.");
            }
            // ... catch more exceptions here (maybe custom ones specific to your application?
            catch (AuthenticationException ae) {
                //unexpected condition?  error?
            }
        }
        
        // * 打印当前登录的用户名
        log.info("User [" + currentUser.getPrincipal() + "] logged in successfully.");

        
        // * 测试当前用户是否具备 schwartz 角色
        if (currentUser.hasRole("schwartz")) {
            log.info("May the Schwartz be with you!");
        } else {
            log.info("Hello, mere mortal.");
        }
        
        // * 测试当前用户是否具备 lightsaber:wield 权限
        if (currentUser.isPermitted("lightsaber:wield")) {
            log.info("You may use a lightsaber ring.  Use it wisely.");
        } else {
            log.info("Sorry, lightsaber rings are for schwartz masters only.");
        }
        
        // * 测试当前用户是否具备 winnebago:drive:eagle5 权限
        if (currentUser.isPermitted("winnebago:drive:eagle5")) {
            log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  " +
                    "Here are the keys - have fun!");
        } else {
            log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!");
        }
        
        // * 注销登录
        currentUser.logout();

        System.exit(0);
    }
}

3 SSM 整合 Shiro

3.1 Spring + SpringMVC

  1. 创建项目

  1. 引入依赖并配置打包方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    <packaging>war</packaging>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>5.3.25</version>
        </dependency>
    </dependencies>
    
  2. Spring 配置文件 applicationContext.xml
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:context="http://www.springframework.org/schema/context"
           xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
       
        <context:component-scan base-package="top.yueyazhui.learn_shiro" use-default-filters="true">
            <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
        </context:component-scan>
    </beans>
    
  3. SpringMVC 配置文件 spring-servlet.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xmlns:context="http://www.springframework.org/schema/context"
               xmlns:mvc="http://www.springframework.org/schema/mvc"
               xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
    
        <context:component-scan base-package="top.yueyazhui.learn_shiro" use-default-filters="false">
            <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
        </context:component-scan>
    
        <!-- 启用注解驱动的处理器映射、适配器和视图解析器 -->
        <mvc:annotation-driven/>
    </beans>
    
    
  4. 创建 webapp 文件夹

  5. 配置 web.xml 文件

    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
    
    <?xml version="1.0" encoding="UTF-8"?>
    <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
             version="4.0">
    
        <context-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:applicationContext.xml</param-value>
        </context-param>
           
        <listener>
            <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
        </listener>
           
        <servlet>
            <servlet-name>springmvc</servlet-name>
            <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
            <init-param>
                <param-name>contextConfigLocation</param-name>
                <param-value>classpath:spring-servlet.xml</param-value>
            </init-param>
        </servlet>
        <servlet-mapping>
            <servlet-name>springmvc</servlet-name>
            <url-pattern>/</url-pattern>
        </servlet-mapping>
    </web-app>
    
  6. HelloController

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/18
     */
    @RestController
    public class HelloController {
    
        @GetMapping("")
        public String hello() {
            return "Hello Shiro";
        }
    }
    
  7. 配置 tomcat

  8. 启动

  9. 项目目录

3.2 MyBatis

  1. 添加依赖

    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
    
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis</artifactId>
        <version>3.5.11</version>
    </dependency>
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis-spring</artifactId>
        <version>2.0.7</version>
    </dependency>
    
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.30</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>5.3.25</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.8</version>
    </dependency>
    
  2. 创建数据库

    ```sql /* Navicat Premium Data Transfer

    Source Server : localhost Source Server Type : MySQL Source Server Version : 80030 Source Host : localhost:3306 Source Schema : learn_shiro

    Target Server Type : MySQL Target Server Version : 80030 File Encoding : 65001

    Date: 19/02/2023 18:50:50 */

    SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0;


– Table structure for sys_user – —————————- DROP TABLE IF EXISTS sys_user; CREATE TABLE sys_user ( id int NOT NULL AUTO_INCREMENT COMMENT ‘ID’, username varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT ‘用户名’, password varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT ‘密码’, PRIMARY KEY (id) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = ‘用户’ ROW_FORMAT = Dynamic;


– Records of sys_user – —————————- INSERT INTO sys_user VALUES (1, ‘yueyazhui’, ‘123’);

SET FOREIGN_KEY_CHECKS = 1;

1
2
3
4
5
6
7
8
3. db.properties

   ```properties
   db.username=root
   db.password=123456
   db.driver=com.mysql.cj.jdbc.Driver
   db.url=jdbc:mysql:///learn_shiro?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true
  1. applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    <context:property-placeholder location="classpath:db.properties"/>
       
    <bean class="com.alibaba.druid.pool.DruidDataSource" id="dataSource">
        <property name="driverClassName" value="${db.driver}"/>
        <property name="url" value="${db.url}"/>
        <property name="username" value="${db.username}"/>
        <property name="password" value="${db.password}"/>
    </bean>
       
    <bean class="org.mybatis.spring.SqlSessionFactoryBean" id="sqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="mapperLocations">
            <list>
                <value>classpath*:top/yueyazhui/learn_shiro/mapper/xml/*.xml</value>
            </list>
        </property>
    </bean>
       
    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer" id="mapperScannerConfigurer">
        <property name="sqlSessionFactoryBeanName" value="sqlSessionFactoryBean"/>
        <property name="basePackage" value="top.yueyazhui.learn_shiro.mapper"/>
    </bean>
    

3.3 Shiro

  1. 添加依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-web</artifactId>
        <version>1.7.1</version>
    </dependency>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.7.1</version>
    </dependency>
    
  2. web.xml 中配置代理过滤器

    不管是 SpringSecurity 还是 Shiro,都是通过一堆过滤器来实现的,那如何把 Shiro 里的过滤器配置进来;

    DelegatingFilterProxy 代理过滤器,源码剖析:这个过滤器就是从 Spring 容器中通过 FilterName 来获取的;

    1
    2
    3
    4
    5
    6
    7
    8
    
    <filter>
        <filter-name>shiroFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>shiroFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    

3.4 自定义 Realm

  1. User.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/18
     */
    @Data
    public class User {
    
        private Integer id;
        private String username;
        private String password;
    }
    
  2. MyRealm01.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
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/18
     *
     * 继承 AuthenticatingRealm 可以实现认证功能
     */
    public class MyRealm01 extends AuthenticatingRealm {
    
        private UserService userService;
    
        public MyRealm01(UserService userService) {
            this.userService = userService;
        }
    
        /**
         * 核心方法:根据用户输入的用户名去数据库查询用户信息
         *
         * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
         * @return 从数据库中查询到的用户信息
         * @throws AuthenticationException
         */
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
            // 获取用户登录时输入的用户名
            String username = usernamePasswordToken.getUsername();
            User user = userService.getUserByUsername(username);
            if (ObjectUtil.isNull(user)) {
                throw new UnknownAccountException("用户名输入错误");
            }
            // 返回查询到的用户信息
            return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName());
        }
    }
    
  3. UserServiceImpl.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/18
     */
    @Service
    public class UserServiceImpl implements UserService {
    
        @Autowired
        UserMapper userMapper;
    
        @Override
        public User getUserByUsername(String username) {
            return userMapper.getUserByUsername(username);
        }
    }
    
  4. UserMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="top.yueyazhui.learn_shiro.mapper.UserMapper">
       
        <select id="getUserByUsername" resultType="top.yueyazhui.learn_shiro.entity.User">
            SELECT id, username, password FROM sys_user WHERE username = #{username};
        </select>
    </mapper>
    

3.5 SSM 整合 Shiro

  1. 在 Spring 配置文件(applicationContext.xml)中配置 Shiro

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    <bean id="myRealm" class="top.yueyazhui.learn_shiro.realm.MyRealm01"/>
    
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="myRealm"/>
    </bean>
    
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
        <property name="securityManager" ref="securityManager"/>
        <property name="filterChainDefinitions">
            <!--
             /login 匿名访问
             /** 剩余的其他接口,需要认证才能访问
             -->
            <value>
                /login=anon
                /**=authc
            </value>
        </property>
    </bean>
    
  2. 登录接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/18
     */
    @RestController
    public class LoginController {
       
        @PostMapping(value = "doLogin", produces = "text/html;charset=utf-8")
        public String doLogin(String username, String password) {
            UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
            try {
                // 执行登录
                SecurityUtils.getSubject().login(usernamePasswordToken);
            } catch (AuthenticationException e) {
                return "登录失败:" + e.getMessage();
            }
            return "登录成功";
        }
    }
    

4 登录流程

Shiro官方文档登录流程图:

步骤:

  1. 应用程序调用 Subject.login 方法,传递创建好的包含终端用户的 Principals(身份)和 Credentials(凭证)的 AuthenticationToken 实例(UsernamePasswordToken)。
  2. Subject 实例,通常是 DelegatingSubject(或子类)委托应用程序的 SecurityManager 通过调用securityManager.login(token) 开始真正的验证工作(在 DelegatingSubject.login 打断点查看)。
  3. SecurityManager 作为一个基本“保护伞”的组成部分,接收 token 并通过调用 authenticator.authenticate(token) 简单地委托给内部的 Authenticator 实例。Authenticator 实例通常是一个 ModularRealmAuthenticator 实例,支持在身份验证中协调一个或多个 Realm 实例。ModularRealmAuthenticator 本质上是 Apache Shiro 提供了 PAM-style 范式(PAM:每个 Realm 都是一个 Module)。
  4. 如果在应用程序中配置了多个 Realm,ModularRealmAuthenticator 实例将利用配置好的 AuthenticationStrategy 来启动 Multi-Realm 认证尝试。在 Realms 被身份验证调用之前,期间和之后,AuthenticationStrategy 被调用使其能够对每个 Realm 的结果作出反应。如果只配置一个 Realm ,它将被直接调用。
  5. 配置的 Realm 是否支持提交的 AuthenticationToken。如果支持,Realm 的 getAuthenticationInfo 方法会伴随着提交的 token 被调用。

5 密码加密方案

加密方案:

  • 可逆加密
    • 对称加密:DES、3DES、AES
    • 非对称加密:RSA
  • 不可逆加密
    • 消息摘要算法:MD5
    • 安全散列算法:SHA

前车之鉴:

2011年12月21日,有人在网络上公开了一个包含600万个CSDN用户资料的数据库,数据全部为明文储存,包含用户名、密码以及注册邮箱。事件发生后CSDN在微博、官方网站等渠道发出声明,解释说此数据库时2009年备份所用,因不明原因泄露,已向警方报案。后又在官网网站上发出了公开道歉信。在接下来的十多天里,金山、网易、京东、当当、新浪等多家公司被卷入到这次事件中。整个事件中最触目惊心的莫过于CSDN把用户密码明文存储,由于很多用户是多个网站共用一个密码,因此一个网站密码泄露就会造成很大的安全隐患。

密码加密一般会用到散列函数,又称散列算法、哈希函数。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。

常用的散列函数:

  1. MD5消息摘要算法

MD5消息摘要算法是一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值,用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特设计,于1992年公开,以取代MD4算法。这套算法的程序在 RFC 1321 中被加以规范。将数据(如一段文字)运算变为另一固定长度值,是散列算法的基础原理。1996年后被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞,因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。

  1. 安全散列算法

安全散列算法(Secure Hash Algorithm)是一个密码散列函数家族,是 FIPS 所认证的安全散列算法。能计算出一个数字消息所对应到的、长度固定的字符串(又称消息摘要)的算法。输入相同的消息,它们对应到不同字符串的机率很高。SHA家族的算法,由美国国家安全局所设计,并由美国国家标准与技术研究院发布,是美国的政府标准,其分别是:SHA-0:1993年发布,是SHA-1的前身;SHA-1:1995年发布,SHA-1在许多安全协议中广泛使用,包括TLS和SSL、PGP、SSH、S/MIME和IPsec,曾被视为是MD5的后继者。但SHA-1的安全性在2000年以后已经不被大多数的加密场景所接受。2017年荷兰密码学研究小组CWI和Google正式宣布攻破了SHA-1;SHA-2:2001年发布,包括SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。虽然至今尚未出现对SHA-2有效的攻击,它的算法跟SHA-1基本上仍然相似;因此有些人开始发展其他可以替代的散列算法;SHA-3:2015年正式发布,SHA-3并不是要取代SHA-2,因为SHA-2目前并没有出现明显的弱点。由于对MD5出现成功的破解,以及对SHA-0和SHA-1出现理论上破解的方法,NIST感觉需要一个与之前算法不同的,可替换的加密散列算法,也就是现在的SHA-3。

加密测试:

1
2
3
4
5
6
7
8
9
10
11
@Test
public void test01() {
  Md5Hash md5Hash = new Md5Hash("123");
  log.info("【123】MD5加密后的值:{}", md5Hash);
  Sha512Hash sha512Hash = new Sha512Hash("123");
  log.info("【123】Sha512加密后的值:{}", sha512Hash);
  SimpleHash md5SimpleHash = new SimpleHash("md5", "123");
  log.info("【123】MD5加密后的值:{}", md5SimpleHash);
  SimpleHash sha512SimpleHash = new SimpleHash("sha-512", "123");
  log.info("【123】Sha512加密后的值:{}", sha512SimpleHash);
}

打印结果:

applicationContext.xml 配置 credentialsMatcher(密码比对器),配置 hashAlgorithmName(算法名称)

1
2
3
4
5
6
7
<bean class="top.yueyazhui.learn_shiro.realm.MyRealm01" id="myRealm">
  <property name="credentialsMatcher">
    <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
      <property name="hashAlgorithmName" value="md5"/>
    </bean>
  </property>
</bean>

加盐测试:

@Test
public void test02() {
  Md5Hash md5Hash = new Md5Hash("123", "yueyazhui", 1024);
  log.info("【123】MD5加盐加密后的值:{}", md5Hash);
  SimpleHash md5SimpleHash = new SimpleHash("md5", "123", "yue", 1024);
  log.info("【123】MD5加盐加密后的值:{}", md5SimpleHash);
}

打印结果:

applicationContext.xml 配置 hashIterations(迭代次数)

<bean class="top.yueyazhui.learn_shiro.realm.MyRealm01" id="myRealm">
  <property name="credentialsMatcher">
    <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
      <property name="hashAlgorithmName" value="md5"/>
      <property name="hashIterations" value="1024"/>
    </bean>
  </property>
</bean>

MyRealm01.java 返回用户信息加盐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 核心方法:根据用户输入的用户名去数据库查询用户信息
 *
 * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
 * @return 从数据库中查询到的用户信息
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
  UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
  // 获取用户登录时输入的用户名
  String username = usernamePasswordToken.getUsername();
  User user = userService.getUserByUsername(username);
  if (ObjectUtil.isNull(user)) {
    throw new UnknownAccountException("用户名输入错误");
  }
  // 返回查询到的用户信息(用户名、密码)
  //        return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName());
  // 返回查询到的用户信息(用户名、密码、盐)
  return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), ByteSource.Util.bytes(user.getUsername()), getName());
}

6 JdbcRealm

数据库:

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
92
93
94
95
96
97
98
99
100
101
# ************************************************************
# Sequel Pro SQL dump
# Version 5446
#
# https://www.sequelpro.com/
# https://github.com/sequelpro/sequelpro
#
# Host: 127.0.0.1 (MySQL 8.0.27)
# Database: learn_shiro
# Generation Time: 2023-02-24 14:09:12 +0000
# ************************************************************


/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
SET NAMES utf8mb4;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;


# Dump of table roles_permissions
# ------------------------------------------------------------

DROP TABLE IF EXISTS `roles_permissions`;

CREATE TABLE `roles_permissions` (
  `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `permission` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '权限',
  `role_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '角色',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

LOCK TABLES `roles_permissions` WRITE;
/*!40000 ALTER TABLE `roles_permissions` DISABLE KEYS */;

INSERT INTO `roles_permissions` (`id`, `permission`, `role_name`)
VALUES
	(1,'book:*','manager'),
	(2,'author:create','manager');

/*!40000 ALTER TABLE `roles_permissions` ENABLE KEYS */;
UNLOCK TABLES;


# Dump of table user_roles
# ------------------------------------------------------------

DROP TABLE IF EXISTS `user_roles`;

CREATE TABLE `user_roles` (
  `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `role_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '角色',
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

LOCK TABLES `user_roles` WRITE;
/*!40000 ALTER TABLE `user_roles` DISABLE KEYS */;

INSERT INTO `user_roles` (`id`, `role_name`, `username`)
VALUES
	(1,'manager','yueyazhui');

/*!40000 ALTER TABLE `user_roles` ENABLE KEYS */;
UNLOCK TABLES;


# Dump of table users
# ------------------------------------------------------------

DROP TABLE IF EXISTS `users`;

CREATE TABLE `users` (
  `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户',
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码',
  `password_salt` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '盐',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

LOCK TABLES `users` WRITE;
/*!40000 ALTER TABLE `users` DISABLE KEYS */;

INSERT INTO `users` (`id`, `username`, `password`, `password_salt`)
VALUES
	(1,'yueyazhui','123',NULL);

/*!40000 ALTER TABLE `users` ENABLE KEYS */;
UNLOCK TABLES;



/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

applicationContext.xml 配置 JdbcRealm

1
2
3
4
5
6
7
8
9
10
<bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
    <property name="dataSource" ref="dataSource"/>
    <bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
        <property name="dataSource" ref="dataSource"/>
    </bean>
</bean>

<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
    <property name="realm" ref="jdbcRealm"/>
</bean>

密码加密加盐,applicationContext.xml 给 JdbcRealm 配置 credentialsMatcher(密码比对器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
    <property name="dataSource" ref="dataSource"/>
    <bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
        <property name="dataSource" ref="dataSource"/>
        <property name="credentialsMatcher">
          <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <property name="hashAlgorithmName" value="md5"/>
            <property name="hashIterations" value="1024"/>
          </bean>
        </property>
      	<!-- column:自动读取数据库中的盐字段,默认的盐字段 password_salt -->
      	<!-- external:默认的盐字段 username -->
        <property name="saltStyle" value="COLUMN"/>
      	<!-- 声明:盐字段没有进行 base64 编码,无需解码 -->
        <property name="saltIsBase64Encoded" value="false"/>
    </bean>
</bean>

修改 users 表中的盐字段:password_salt 改为 salt,配置自定义用户认证查询语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
    <property name="dataSource" ref="dataSource"/>
    <bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
        <property name="dataSource" ref="dataSource"/>
        <property name="credentialsMatcher">
          <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <property name="hashAlgorithmName" value="md5"/>
            <property name="hashIterations" value="1024"/>
          </bean>
        </property>
      	<!-- column:自动读取数据库中的盐字段,默认的盐字段 password_salt -->
      	<!-- external:默认的盐字段 username -->
        <property name="saltStyle" value="COLUMN"/>
      	<!-- 声明:盐字段没有进行 base64 编码,无需解码 -->
        <property name="saltIsBase64Encoded" value="false"/>
      	<!-- 自定义用户认证查询SQL -->
        <property name="authenticationQuery" value="select password, salt from users where username = ?"/>
    </bean>
</bean>

7 多 Realm 的策略配置

定义两个 Realm,其中 MyRealm01 不做认证,只是为了测试多 Realm 的认证策略

MyRealm01:

/**
 * @Author yueyazhui
 * @Date 2023/2/25
 *
 * 继承 AuthenticatingRealm 可以实现认证功能
 */
public class MyRealm01 extends AuthenticatingRealm {

    private UserService userService;

    public MyRealm01(UserService userService) {
        this.userService = userService;
    }

    /**
     * 这个 Realm 不做认证,只是为了测试多 Realm 的认证策略
     *
     * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
     * @return 用户登录时输入的用户名和密码等信息
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        // 返回的密码就是用户登录时输入的密码,所以不管用户填什么用户名和密码都可以认证成功
        return new SimpleAuthenticationInfo("yue", usernamePasswordToken.getPassword(), getName());
    }
}

MyRealm02:

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
/**
 * @Author yueyazhui
 * @Date 2023/2/25
 *
 * 继承 AuthenticatingRealm 可以实现认证功能
 */
public class MyRealm02 extends AuthenticatingRealm {

    private UserService userService;

    public MyRealm02(UserService userService) {
        this.userService = userService;
    }

    /**
     * 核心方法:根据用户输入的用户名去数据库查询用户信息
     *
     * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
     * @return 从数据库中查询到的用户信息
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        // 获取用户登录时输入的用户名
        String username = usernamePasswordToken.getUsername();
        User user = userService.getUserByUsername(username);
        if (ObjectUtil.isNull(user)) {
            throw new UnknownAccountException("用户名输入错误");
        }
        // 返回查询到的用户信息(用户名、密码、盐)
        return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName());
    }
}

为了方便测试,在登录的方法中,打印认证成功 Realm 返回的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PostMapping(value = "login", produces = "text/html;charset=utf-8")
public String login(String username, String password) {
  UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
  try {
    // 执行登录
    Subject subject = SecurityUtils.getSubject();
    subject.login(usernamePasswordToken);
    List list = subject.getPrincipals().asList();
    for (Object o : list) {
      System.out.println("o = " + o);
    }
  } catch (AuthenticationException e) {
    return "登录失败:" + e.getMessage();
  }
  return "登录成功";
}

7.1 三种认证策略

1. AtLeastOneSuccessfulStrategy

至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <!-- 顺序很关键,认证的时候会按照这个顺序去认证 -->
          <ref bean="myRealm01"/>
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息 -->
        <bean class="org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
</bean>

打印结果:

o = yue
o = yueyazhui

2. FirstSuccessfulStrategy

只返回第一个认证成功的 Realm 信息;默认情况下,即使已经有 Realm 认证成功,剩下的 Realm 还会继续进行认证操作

<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <!-- 顺序很关键,认证的时候会按照这个顺序去认证 -->
          <ref bean="myRealm01"/>
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 只返回第一个认证成功的 Realm 信息;默认情况下,即使已经有 Realm 认证成功,剩下的 Realm 还会继续进行认证操作 -->
        <bean class="org.apache.shiro.authc.pam.FirstSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
</bean>

打印结果:

o = yue

3. AllSuccessfulStrategy

必须所有的 Realm 都认证成功,才算认证成功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<bean class="top.yueyazhui.learn_shiro.realm.MyRealm01" id="myRealm01"/>

<bean class="top.yueyazhui.learn_shiro.realm.MyRealm02" id="myRealm02"/>

<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <!-- 顺序很关键,认证的时候会按照这个顺序去认证 -->
          <ref bean="myRealm01"/>
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 必须所有的 Realm 都认证成功,才算认证成功 -->
        <bean class="org.apache.shiro.authc.pam.AllSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
</bean>

打印结果:

o = yue

7.2 源码分析

1. ModularRealmAuthenticator

1.1 Realm

Realm,可以直接配置给 SecurityManager,也可以配置给 SecurityManager 中的 ModularRealmAuthenticator。

如果是直接配置给 SecurityManager,那么在完成 Realm 的配置后,会自动调用 afterRealmsSet 方法,在该方法中,会将配置的所有 Realm 配置给 ModularRealmAuthenticator。

相关源码如下:

RealmSecurityManager#setRealm(RealmSecurityManager 是 DefaultWebSecurityManager 的父类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void setRealm(Realm realm) {
    if (realm == null) {
        throw new IllegalArgumentException("Realm argument cannot be null");
    }
    Collection<Realm> realms = new ArrayList<Realm>(1);
    realms.add(realm);
    setRealms(realms);
}
public void setRealms(Collection<Realm> realms) {
    if (realms == null) {
        throw new IllegalArgumentException("Realms collection argument cannot be null.");
    }
    if (realms.isEmpty()) {
        throw new IllegalArgumentException("Realms collection argument cannot be empty.");
    }
    this.realms = realms;
    afterRealmsSet();
}

可以看到,无论是设置单个 Realm 还是设置多个 Realm,最终都会调用到 afterRealmsSet 方法,该方法在 AuthorizingSecurityManager#afterRealmsSet 类中被重写,内容如下:

1
2
3
4
5
6
protected void afterRealmsSet() {
    super.afterRealmsSet();
    if (this.authorizer instanceof ModularRealmAuthorizer) {
        ((ModularRealmAuthorizer) this.authorizer).setRealms(getRealms());
    }
}

可以看到,所有的 Realm 最终都被配置给 ModularRealmAuthenticator 了。

无论是单个 Realm 还是多个 Realm,最终都是由 ModularRealmAuthenticator 统一管理统一调用的。

1.2 ModularRealmAuthenticator

ModularRealmAuthenticator 中核心的方法就是 doAuthenticate,如下:

1
2
3
4
5
6
7
8
9
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
    assertRealmsConfigured();
    Collection<Realm> realms = getRealms();
    if (realms.size() == 1) {
        return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
    } else {
        return doMultiRealmAuthentication(realms, authenticationToken);
    }
}

这个方法的逻辑很简单:

  1. 首先调用 assertRealmsConfigured 方法判断一下开发者有没有配置 Realm,要是没有配置就直接抛异常。
  2. 判断开发者配置了几个 Realm,要是配置了一个,就调用 doSingleRealmAuthentication 方法进行处理,要是配置了多个 Realm 则调用 doMultiRealmAuthentication 方法进行处理。

如果存在多个 Realm,必然会带来一个问题:认证策略?一个 Realm 认证成功就算成功还是所有 Realm 认证成功才算成功?

2. AuthenticationStrategy

整体上看,负责认证策略的类是 AuthenticationStrategy,这是一个接口,有三个实现类:

单从字面上来看,三个实现类都好理解:

  • AtLeastOneSuccessfulStrategy:至少有一个 Realm 认证成功。
  • AllSuccessfulStrategy:所有 Realm 都要认证成功。
  • FirstSuccessfulStrategy:只返回第一个认证成功的用户信息。

疑问:第一个和第三个的区别在哪???

首先这里一共涉及到四个方法:

  • beforeAllAttempts:在所有 Realm 验证之前做准备。
  • beforeAttempt:在单个 Realm 验证之前做准备。
  • afterAttempt:处理单个 Realm 验证之后的后续事宜。
  • afterAllAttempts:处理所有 Realm 验证之后的后续事宜。

第一个和第四个方法在每次认证流程中只调用一次,而中间两个方法则在每个 Realm 调用前后都会被调用到,伪代码如下:

这四个方法,在 AuthenticationStrategy 的四个实现类中有不同的实现,关系如下:

  AbstractAuthenticationStrategy AtLeastOneSuccessfulStrategy AllSuccessfulStrategy FirstSuccessfulStrategy
beforeAllAttempts ✔️     ✔️
beforeAttempt ✔️   ✔️ ✔️
afterAttempt ✔️   ✔️  
afterAllAttempts ✔️ ✔️    
merge ✔️     ✔️

merge 方法是在 AbstractAuthenticationStrategy 类中定义的,当存在多个 Realm 时,合并多个 Realm 中的认证信息使用的。

2.1 AbstractAuthenticationStrategy
2.1.1 beforeAllAttempts
1
2
3
public AuthenticationInfo beforeAllAttempts(Collection<? extends Realm> realms, AuthenticationToken token) throws AuthenticationException {
    return new SimpleAuthenticationInfo();
}

创建了一个空的 SimpleAuthenticationInfo 对象

2.1.2 beforeAttempt
1
2
3
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
    return aggregate;
}

传入的 aggregate 参数是指多个 Realm 认证后聚合的结果。

2.1.3 afterAttempt
1
2
3
4
5
6
7
8
9
10
11
12
13
public AuthenticationInfo afterAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo singleRealmInfo, AuthenticationInfo aggregateInfo, Throwable t) throws AuthenticationException {
    AuthenticationInfo info;
    if (singleRealmInfo == null) {
        info = aggregateInfo;
    } else {
        if (aggregateInfo == null) {
            info = singleRealmInfo;
        } else {
            info = merge(singleRealmInfo, aggregateInfo);
        }
    }
    return info;
}

这是每个 Realm 认证完成后调用的方法,参数 singleRealmInfo 表示单个 Realm 认证的结果,aggregateInfo 表示多个 Realm 认证聚合的结果,具体逻辑如下:

  1. 如果当前 Realm 认证结果为 null,则把聚合的结果赋值给 info 并返回。
  2. 如果当前 Realm 认证结果不为 null,并且聚合结果为 null,那么就把当前 Realm 的认证结果赋值给 info 并返回。
  3. 如果当前 Realm 认证结果不为 null,并且聚合结果也不为 null,则将两者合并之后返回。
2.1.4 afterAllAttempts
1
2
3
public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
    return aggregate;
}
2.1.5 merge
1
2
3
4
5
6
7
8
9
protected AuthenticationInfo merge(AuthenticationInfo info, AuthenticationInfo aggregate) {
    if( aggregate instanceof MergableAuthenticationInfo ) {
        ((MergableAuthenticationInfo)aggregate).merge(info);
        return aggregate;
    } else {
        throw new IllegalArgumentException( "Attempt to merge authentication info from multiple realms, but aggregate " +
                  "AuthenticationInfo is not of type MergableAuthenticationInfo." );
    }
}

merge 其实就是调用 aggregate 的 merge 方法进行合并,正常情况下 SimpleAuthenticationInfo 就是 MergableAuthenticationInfo 的子类,所以这里合并没问题。

2.2 AtLeastOneSuccessfulStrategy
2.2.1 beforeAllAttempts

同 2.1.1 小节。

2.2.2 beforeAttempt

同 2.1.2 小节。

2.2.3 afterAttempt

同 2.1.3 小节。

2.2.4 afterAllAttempts
1
2
3
4
5
6
7
8
9
10
public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
    //we know if one or more were able to successfully authenticate if the aggregated account object does not
    //contain null or empty data:
    if (aggregate == null || isEmpty(aggregate.getPrincipals())) {
        throw new AuthenticationException("Authentication token of type [" + token.getClass() + "] " +
                "could not be authenticated by any configured realms.  Please ensure that at least one realm can " +
                "authenticate these tokens.");
    }
    return aggregate;
}

当聚合结果为空时抛出异常。

2.2.5 merge

同 2.1.5 小节。

2.2.6 小结

梳理 AtLeastOneSuccessfulStrategy 的功能。

  1. 首先,系统调用 beforeAllAttempts 方法会获取一个空的 SimpleAuthenticationInfo 对象作为聚合结果 aggregate。
  2. 接下来遍历所有的 Realm,在每个 Realm 调用之前先调用 beforeAttempt 方法,该方法会原封不动的返回聚合结果 aggregate。
  3. 调用每个 Realm 的 getAuthenticationInfo 方法进行认证。
  4. 调用 afterAttempt 方法对认证结果进行聚合处理。如果当前 Realm 认证返回 null,就把聚合结果返回;如果当前 Realm 认证不返回 null,就把 当前的 Realm 的认证结果和 aggregate 进行合并后返回(aggregate 不会为 null,因为 beforeAllAttempts 方法中已经创建了一个空对象)。

这就是 AtLeastOneSuccessfulStrategy 的认证策略。如果只有一个 Realm 认证成功,那么返回一个认证用户的信息,如果有多个 Realm 认证成功,那么返回的用户信息中将包含多个认证用户的信息。

可以通过如下方式获取返回的多个用户信息:

1
2
3
4
5
6
7
Subject subject = SecurityUtils.getSubject();
subject.login(token);
PrincipalCollection principals = subject.getPrincipals();
List list = principals.asList();
for (Object o : list) {
    System.out.println("o = " + o);
}

subject.getPrincipals() 方法可以获取多个认证成功的凭证。

2.3 AllSuccessfulStrategy
2.3.1 beforeAllAttempts

同 2.1.1 小节。

2.3.2 beforeAttempt
1
2
3
4
5
6
7
8
9
10
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
    if (!realm.supports(token)) {
        String msg = "Realm [" + realm + "] of type [" + realm.getClass().getName() + "] does not support " +
                " the submitted AuthenticationToken [" + token + "].  The [" + getClass().getName() +
                "] implementation requires all configured realm(s) to support and be able to process the submitted " +
                "AuthenticationToken.";
        throw new UnsupportedTokenException(msg);
    }
    return info;
}

可以看到,这里就是去检查一下 Realm 是否支持当前 token。

2.3.3 afterAttempt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public AuthenticationInfo afterAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo info, AuthenticationInfo aggregate, Throwable t)
        throws AuthenticationException {
    if (t != null) {
        if (t instanceof AuthenticationException) {
            throw ((AuthenticationException) t);
        } else {
            String msg = "Unable to acquire account data from realm [" + realm + "].  The [" +
                    getClass().getName() + " implementation requires all configured realm(s) to operate successfully " +
                    "for a successful authentication.";
            throw new AuthenticationException(msg, t);
        }
    }
    if (info == null) {
        String msg = "Realm [" + realm + "] could not find any associated account data for the submitted " +
                "AuthenticationToken [" + token + "].  The [" + getClass().getName() + "] implementation requires " +
                "all configured realm(s) to acquire valid account data for a submitted token during the " +
                "log-in process.";
        throw new UnknownAccountException(msg);
    }
    merge(info, aggregate);
    return aggregate;
}

如果当前认证出错,或者认证结果为 null,就直接抛出异常(因为这里要求每个 Realm 都认证成功,但凡有一个认证失败,后面的就没有必要认证了)。

如果全部认证成功,就会把合并后的结果返回。

2.3.4 afterAllAttempts

同 2.1.4 小节。

2.3.5 merge

同 2.1.5 小节。

2.3.6 小结

如果有多个 Realm 认证成功,那么会返回多个 Realm 的认证信息。

2.4 FirstSuccessfulStrategy
2.4.1 beforeAllAttempts
1
2
3
public AuthenticationInfo beforeAllAttempts(Collection<? extends Realm> realms, AuthenticationToken token) throws AuthenticationException {
    return null;
}

直接返回 null。

2.4.2 beforeAttempt
1
2
3
4
5
6
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
    if (getStopAfterFirstSuccess() && aggregate != null && !isEmpty(aggregate.getPrincipals())) {
        throw new ShortCircuitIterationException();
    }
    return aggregate;
}

如果 getStopAfterFirstSuccess() 方法返回 true,并且当前认证结果的聚合不为空,那么就直接抛出异常,一旦抛出异常,就会跳出当前循环,也就是不会调用当前 Realm 进行认证操作了。

getStopAfterFirstSuccess() 方法,是否在第一次成功后停止认证,默认情况下,该变量为 false,即即使第一次认证成功,也还是会继续后面 Realm 认证。

如果想当第一次认证成功后,后面的 Realm 就不认证了,那么需要将该属性配置为 true。

2.4.3 afterAttempt

同 2.1.3 小节。

2.4.4 afterAllAttempts

同 2.1.4 小节。

2.4.5 merge

如果当前 Realm 的认证和聚合结果都不为 null,就需要对结果进行合并,原本的合并是真的去合并,这里重写该方法,就没有去执行合并了。

1
2
3
4
5
6
protected AuthenticationInfo merge(AuthenticationInfo info, AuthenticationInfo aggregate) {
    if (aggregate != null && !isEmpty(aggregate.getPrincipals())) {
        return aggregate;
    }
    return info != null ? info : aggregate;
}

这是三个策略中,唯一重写 merge 方法的。

这里的 merge 并没有真正的 merge,而是:

  1. 如果聚合结果不为空,就直接返回聚合结果。
  2. 否则,如果当前认证结果不为空,就返回当前认证结果。
  3. 否则返回空。
2.4.6 小结

总结 FirstSuccessfulStrategy 和 AtLeastOneSuccessfulStrategy 的区别:

  1. AtLeastOneSuccessfulStrategy:当存在多个 Realm 时,即使已经有一个 Realm 认证成功,后面的 Realm 也还是会去认证,并且如果后面的 Realm 也认证成功了,那么会将多个 Realm 认证成功的结果进行合并后返回。
  2. FirstSuccessfulStrategy:当存在多个 Realm 时,默认情况下,即使已经有一个 Realm 认证成功,后面的 Realm 也还是会去认证,但是如果后面的 Realm 也认证成功了,却并不会使用后面认证成功的 Realm 返回的结果。如果希望当一个 Realm 认证成功后,后面的 Realm 就不再认证,那么可以配置 stopAfterFirstSuccess 属性的值,配置方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <!-- 顺序很关键,认证的时候会按照这个顺序去认证 -->
          <ref bean="myRealm01"/>
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 只返回第一个认证成功的 Realm 信息;默认情况下,即使已经有 Realm 认证成功,剩下的 Realm 还会继续进行认证操作 -->
        <bean class="org.apache.shiro.authc.pam.FirstSuccessfulStrategy">
          <!-- 当已经有 Realm 认证成功,剩下的 Realm 就不用再进行认证了 -->
          <property name="stopAfterFirstSuccess" value="true"/>
        </bean>
      </property>
    </bean>
  </property>
</bean>

8 三种登录方式

8.1 自定义表单登录

applicationContext.xml 修改 authenticator 的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息 -->
        <bean class="org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
</bean>

登录表单

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
<%--
  Created by IntelliJ IDEA.
  User: yue
  Date: 2023/3/7
  Time: 21:44
  To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>登录</title>
</head>
<body>
<div>
    <div style="color: red">${errorMsg1}</div>
    <div style="color: red">${errorMsg2}</div>

    <form action="login" method="post">
        <table>
            <tr>
                <td>登录用户:</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>登录密码:</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td><input type="submit" value="登录"></td>
            </tr>
        </table>
    </form>
</div>
</body>
</html>

首页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<%--
  Created by IntelliJ IDEA.
  User: yue
  Date: 2023/3/7
  Time: 21:45
  To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>首页</title>
</head>
<body>
<div>
    登录成功
</div>
</body>
</html>

修改登录接口 LoginController.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
/**
 * @Author yueyazhui
 * @Date 2023/2/18
 */
@Controller
public class LoginController {

    @PostMapping(value = "login", produces = "text/html;charset=utf-8")
    public String login(String username, String password, Model model) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
        try {
            // 执行登录
            Subject subject = SecurityUtils.getSubject();
            subject.login(usernamePasswordToken);
            List list = subject.getPrincipals().asList();
            for (Object o : list) {
                System.out.println("o = " + o);
            }
        } catch (AuthenticationException e) {
            request.setAttribute("errorMsg1", e.getMessage());
            model.addAttribute("errorMsg2", e.getMessage());
            return "forward:/loginForm";
        }
        // 登录成功,重定向到 index
        return "redirect:/index";
    }

    @RequestMapping("index")
    public String index() {
        return "index";
    }

    @RequestMapping("loginForm")
    public String loginForm() {
        return "loginForm";
    }
}

applicationContext.xml 修改 shiroFilter 的属性配置,配置登录页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/loginForm"/>
  <property name="filterChainDefinitions">
    <!--
     /login 匿名访问
     /** 剩余的其他接口,需要认证才能访问
     -->
    <value>
      /login=anon
      /**=authc
    </value>
  </property>
</bean>

测试:

8.2 默认表单登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * Shiro 提供的表单登录,这个接口提供了两个功能
 * 1、提供登录页面
 * 2、处理登录请求
 * @param model
 * @return
 */
@RequestMapping(value = "login", produces = "text/html;charset=utf-8")
public String login(Model model) {
  HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
  // 获取登录失败的异常信息
  String shiroLoginFailure = (String) request.getAttribute("shiroLoginFailure");
  if(UnknownAccountException.class.getName().equals(shiroLoginFailure) || IncorrectCredentialsException.class.getName().equals(shiroLoginFailure)) {
    request.setAttribute("errorMsg1", "用户名或密码输入错误");
    model.addAttribute("errorMsg2", "用户名或密码输入错误");
  }
  return "loginForm";
}

修改 applicationContext.xml 中 shiroFilter 的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /**=authc
    </value>
  </property>
</bean>

测试:

8.3 HTTP Basic 登录

不安全

无法注销

在 applicationContext.xml 中修改 shiroFilter 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /**=authcBasic
    </value>
  </property>
</bean>

测试

SpringSecurity 在 HTTP Basic 登录时,提供了 MD5 加密,相对来说更安全

8.4 注销登录

  • 返回表单

    在 applicationContext.xml 中修改 shiroFilter 配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    <bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
      <property name="securityManager" ref="securityManager"/>
      <!-- 配置登录页面 -->
      <property name="loginUrl" value="/login"/>
      <!-- 登录成功跳转的地址 -->
      <property name="successUrl" value="/index"/>
      <!-- 权限不足跳转的地址 -->
      <property name="unauthorizedUrl" value="/unauthorized"/>
      <property name="filterChainDefinitions">
        <value>
          /logout=logout
          /**=authc
        </value>
      </property>
    </bean>
    

    测试

  • 返回 JSON

在 applicationContext.xml 中修改 shiroFilter 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /**=authc
    </value>
  </property>
</bean>

LoginController.java

1
2
3
4
5
6
7
8
9
10
@GetMapping("logout")
@ResponseBody
public Response logout() {
  try {
    SecurityUtils.getSubject().logout();
    return Response.success("注销成功");
  } catch (Exception e) {
    return Response.error("注销失败");
  }
}

测试

8.5 RememberMe

没有开启 RememberMe 的测试

登录:

关闭浏览器

修改登录接口

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
@GetMapping(value = "login", produces = "text/html;charset=utf-8")
public String login() {
  return "loginForm";
}

@PostMapping(value = "login", produces = "text/html;charset=utf-8")
public String login(String username, String password, Model model) {
  HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
  UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
  // 开启 RememberMe
  usernamePasswordToken.setRememberMe(true);
  try {
    // 执行登录
    Subject subject = SecurityUtils.getSubject();
    subject.login(usernamePasswordToken);
    List list = subject.getPrincipals().asList();
    for (Object o : list) {
      System.out.println("o = " + o);
    }
  } catch (AuthenticationException e) {
    request.setAttribute("errorMsg1", e.getMessage());
    model.addAttribute("errorMsg2", e.getMessage());
    return "forward:/loginForm";
  }
  // 登录成功,重定向到 index
  return "redirect:/index";
}

在 applicationContext.xml 中修改 shiroFilter 的配置:/hello 允许 RememberMe 登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /login=anon
      /hello=user
      /**=authc
    </value>
  </property>
</bean>

测试

退出浏览器

RememberMe 初步分析

Shiro 在登录时,地址栏中会把 jssessionid 显示出来,会存在安全性问题。

更换 Shiro 版本 V1.7.1 —> V1.11.0

登录成功后,在响应头的 Set-Cookie 中包含 rememberMe

Path=/shiro:url 基础路径

Max-Age=31536000:多久过期,默认一年

Expires=Sat, 09-Mar-2024 15:13:30 GMT:到期时间

登录成功之后的请求,在请求头中会携带 Cookie

RememberMe 源码追踪

  1. 获取身份信息
  2. 将身份信息序列化转成字节数组
  3. 获取加密工具,默认 AES 对称加密
  4. Base64 转码
  5. 赋值给模版 Cookie

配置 多久过期 maxAge

在 applicationContext.xml 中修改 securityManager 的配置

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
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="rememberMeManager">
    <bean class="org.apache.shiro.web.mgt.CookieRememberMeManager">
      <property name="cookie">
        <bean class="org.apache.shiro.web.servlet.SimpleCookie">
          <property name="name" value="rememberMe"/>
          <!-- 多久过期:一周 -->
          <property name="maxAge" value="604800"/>
        </bean>
      </property>
    </bean>
  </property>
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="realms">
        <list>
          <!-- 顺序很关键,认证的时候会按照这个顺序去认证 -->
          <!--                        <ref bean="myRealm01"/>-->
          <ref bean="myRealm02"/>
        </list>
      </property>
      <property name="authenticationStrategy">
        <!-- 至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息 -->
        <bean class="org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
</bean>

测试

9 授权

测试接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
public class HelloController {

    @GetMapping({"","hello"})
    public String hello() {
        return "Hello Shiro";
    }

    @GetMapping("admin")
    public String admin() {
        return "Hello Admin";
    }

    @GetMapping("unauthorized")
    public String unauthorized() {
        return "Hello Unauthorized";
    }
}

在 applicationContext.xml 中修改 shiroFilter 的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /login=anon
      /hello=user
      /admin=roles[admin]
      /**=authc
    </value>
  </property>
</bean>

测试

报错信息:没有配置 Realm,这是因为配置的 Realm 只有认证功能,没有授权功能。

修改 MyRealm02.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
/**
 * @Author yueyazhui
 * @Date 2023/2/25
 *
 * 继承 AuthenticatingRealm 可以实现认证功能
 * 继承 AuthorizingRealm 可以实现认证、授权功能
 */
public class MyRealm02 extends AuthorizingRealm {

    private UserService userService;

    public MyRealm02(UserService userService) {
        this.userService = userService;
    }

    /**
     * 核心方法:根据用户输入的用户名去数据库查询用户信息(认证)
     *
     * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
     * @return 从数据库中查询到的用户信息
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        // 获取用户登录时输入的用户名
        String username = usernamePasswordToken.getUsername();
        User user = userService.getUserByUsername(username);
        if (ObjectUtil.isNull(user)) {
            throw new UnknownAccountException("用户名输入错误");
        }
        // 返回查询到的用户信息(用户名、密码、盐)
        return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName());
    }

    /**
     * 返回用户的角色和权限(授权)
     * @param principalCollection 含有用户的登录信息
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        // 获取登录用户
        String username = (String) principalCollection.getPrimaryPrincipal();
        return new SimpleAuthorizationInfo(userService.findRolesByUsername(username));
    }
}

在 applicationContext.xml 中修改 securityManager 的配置,授权时 realms 不能配置给 ModularRealmAuthenticator,而要直接配置给 securityManager,并且要配置到 authenticator 的后面,因为 setRealms 时 会调用 authenticator

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
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="rememberMeManager">
    <bean class="org.apache.shiro.web.mgt.CookieRememberMeManager">
      <property name="cookie">
        <bean class="org.apache.shiro.web.servlet.SimpleCookie">
          <property name="name" value="rememberMe"/>
          <!-- 多久过期:一周 -->
          <property name="maxAge" value="604800"/>
        </bean>
      </property>
    </bean>
  </property>
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <!--                <property name="realms">-->
      <!--                    <list>-->
      <!--                        <ref bean="myRealm02"/>-->
      <!--                    </list>-->
      <!--                </property>-->
      <property name="authenticationStrategy">
        <!-- 至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息 -->
        <bean class="org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
  <property name="realms">
    <list>
      <bean class="top.yueyazhui.learn_shiro.realm.MyRealm02"/>
    </list>
  </property>
</bean>

测试

注解

修改 applicationContext.xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /login=anon
      /hello=user
      /**=authc
    </value>
  </property>
</bean>

<!-- 支持基于注解的权限配置 -->
<bean class="org.apache.shiro.spring.LifecycleBeanPostProcessor" id="beanPostProcessor"/>
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
  <property name="securityManager" ref="securityManager"/>
</bean>

HelloController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
public class HelloController {

    @Autowired
    HelloService helloService;

    @GetMapping({"","hello"})
    public String hello() {
        return helloService.hello();
    }

    @GetMapping("admin")
    public String admin() {
        return helloService.admin();
    }
}

HelloServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class HelloServiceImpl implements HelloService {

    @Override
    @RequiresUser // 登录之后就可以访问;无论是 Realm 认证登录,还是 RememberMe 认证登录
//    @RequiresAuthentication // Realm 认证登录可以访问,RememberMe 认证登录不能访问
    public String hello() {
        return "Hello Shiro";
    }

    @Override
    @RequiresRoles("admin")
    public String admin() {
        return "Hello Admin";
    }
}

测试

JSP 标签

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
<%@ taglib prefix="shiro" uri="http://shiro.apache.org/tags" %>
<%--
  Created by IntelliJ IDEA.
  User: yue
  Date: 2023/3/19
  Time: 14:53
  To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>JSP</title>
</head>
<body>
【guest】
<shiro:guest>
    guest:游客可以访问
</shiro:guest>
<hr>
【user】
<shiro:user>
    user:登录可以访问,不管是通过用户名/密码登录还是通过 RememberMe 登录<br>
    用户名:<shiro:principal/><br>
    <a href="/shiro/hello">Hello</a>
</shiro:user>
<hr>
【authenticated】
<shiro:authenticated>
    authenticated:通过用户名/密码登录可以访问
</shiro:authenticated>
<hr>
【notAuthenticated】
<shiro:notAuthenticated>
    notAuthenticated:没有通过用户名/密码登录可以访问
</shiro:notAuthenticated>
<hr>
【hasRole-admin】
<shiro:hasRole name="admin">
    hasRole-admin:用户有 admin 的角色可以访问<br>
    <a href="/shiro/admin">Admin</a>
</shiro:hasRole>
<hr>
【lacksRole-admin】
<shiro:lacksRole name="admin">
    lacksRole-admin:用户没有 admin 的角色可以访问
</shiro:lacksRole>
</body>
</html>
1
2
3
4
5
6
7
8
@Controller
public class JspController {

    @GetMapping("jsp")
    public String jsp() {
        return "jsp";
    }
}

修改 applicationContext.xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /jsp=anon
      /login=anon
      /hello=user
      /logout=logout
      /**=authc
    </value>
  </property>
</bean>

测试:

重启浏览器

缓存机制

添加依赖:

1
2
3
4
5
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-ehcache</artifactId>
  <version>${shiro.version}</version>
</dependency>

ehcache.xml

复制官方模版

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
<!-- 配置缓存策略 -->
<ehcache>
    <diskStore path="java.io.tmpdir/shiro-spring-sample"/>

    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"/>

    <cache name="shiro-activeSessionCache"
           maxElementsInMemory="10000"
           eternal="true"
           overflowToDisk="true"
           diskPersistent="true"
           diskExpiryThreadIntervalSeconds="600"/>

    <cache name="org.apache.shiro.realm.SimpleAccountRealm.authorization"
           maxElementsInMemory="100"
           eternal="false"
           timeToLiveSeconds="600"
           overflowToDisk="false"/>
</ehcache>

在 applicationContext.xml 中配置缓存

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
<bean class="org.apache.shiro.cache.ehcache.EhCacheManager" id="ehCacheManager">
  <property name="cacheManagerConfigFile" value="classpath:ehcache.xml"/>
</bean>

<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
  <property name="cacheManager" ref="ehCacheManager"/>
  <property name="rememberMeManager">
    <bean class="org.apache.shiro.web.mgt.CookieRememberMeManager">
      <property name="cookie">
        <bean class="org.apache.shiro.web.servlet.SimpleCookie">
          <property name="name" value="rememberMe"/>
          <!-- 多久过期:一周 -->
          <property name="maxAge" value="604800"/>
        </bean>
      </property>
    </bean>
  </property>
  <property name="authenticator">
    <bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
      <property name="authenticationStrategy">
        <!-- 至少有一个 Realm 认证成功才算成功,如果有多个 Realm 认证成功,那么会返回所有认证成功 Realm 的信息 -->
        <bean class="org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy"/>
      </property>
    </bean>
  </property>
  <property name="realms">
    <list>
      <bean class="top.yueyazhui.learn_shiro.realm.MyRealm02"/>
    </list>
  </property>
</bean>

<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
  <property name="securityManager" ref="securityManager"/>
  <!-- 配置登录页面 -->
  <property name="loginUrl" value="/login"/>
  <!-- 登录成功跳转的地址 -->
  <property name="successUrl" value="/index"/>
  <!-- 权限不足跳转的地址 -->
  <property name="unauthorizedUrl" value="/unauthorized"/>
  <property name="filterChainDefinitions">
    <value>
      /jsp=anon
      /login=anon
      /hello=user
      /admin=roles[admin]
      /logout=logout
      /**=authc
    </value>
  </property>
</bean>

测试

10 SpringBoot 集成 Shiro

在 SpringBoot 中,建议使用 SpringSecurity

10.1 基础搭建

  1. 创建 SpringBoot 项目,添加 Spring WebMySQL DriverMyBatis Framework 依赖

  2. 添加 Shiro 依赖

    1
    2
    3
    4
    5
    
    <dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-spring-boot-web-starter</artifactId>
      <version>1.11.0</version>
    </dependency>
    
  3. 配置在 maven 编译时不要过滤掉 java 包内的 xml 文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    <build>
      <resources>
        <resource>
          <directory>src/main/java</directory>
          <includes>
            <include>**/*.xml</include>
          </includes>
        </resource>
        <resource>
          <directory>src/main/resources</directory>
        </resource>
      </resources>
      ...
    </build>
    
  4. 在 application.properties 文件中,配置数据库、Mybatis 和 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
    
    # 应用名称
    spring.application.name=learn_shiro_spring_boot
    # 应用服务 WEB 访问端口
    server.port=8080
    
    # 数据库驱动
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    # 数据源名称
    spring.datasource.name=defaultDataSource
    # 数据库连接地址
    spring.datasource.url=jdbc:mysql:///learn_shiro?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true
    # 数据库用户名
    spring.datasource.username=root
    # 数据库密码
    spring.datasource.password=123456
    
    # 指定 Mybatis 的 Mapper 目录
    mybatis.mapper-locations=top/yueyazhui/learn_shiro_spring_boot/mapper/xml/*.xml
    # 指定 Mybatis 的实体目录
    mybatis.type-aliases-package=top.yueyazhui.learn_shiro_spring_boot.entity
    
    # 开启 Shiro,默认 true
    shiro.enabled=true
    # 开启 Shiro Web 的自动化配置,默认 true
    shiro.web.enabled=true
    # 配置登录地址
    shiro.loginUrl=/login
    # sessionId 存入 cookie
    shiro.sessionManager.sessionIdCookieEnabled=true
    # sessionId 存放地址栏
    shiro.sessionManager.sessionIdUrlRewritingEnabled=false
    
  5. 目录结构

  6. ShiroConfig.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    @Configuration
    public class ShiroConfig {
    
        @Autowired
        MyRealm02 myRealm02;
    
        @Bean
        DefaultWebSecurityManager defaultWebSecurityManager () {
            DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
            defaultWebSecurityManager.setRealm(myRealm02);
            return defaultWebSecurityManager;
        }
    
        @Bean
        ShiroFilterChainDefinition shiroFilterChainDefinition () {
            DefaultShiroFilterChainDefinition shiroFilterChainDefinition = new DefaultShiroFilterChainDefinition();
            shiroFilterChainDefinition.addPathDefinition("/login", "anon");
            shiroFilterChainDefinition.addPathDefinition("/logout", "logout");
            shiroFilterChainDefinition.addPathDefinition("/**", "authc");
            return shiroFilterChainDefinition;
        }
    }
    
  7. 其他的文件都是从 SSM 整合 Shiro 的项目中 CV 过来的。在 SSM 整合 Shiro 的项目中,MyRealm02 是没有注册到 Spring 容器的,在这个项目中,为了方便引用,就把 MyRealm02 注册到 Spring 容器中了。后续这个项目会增加多种登录方式(手机验证码/QQ/微信等),项目也会以前后端分离的方式去做,因此在这里就不配置登录成功跳转地址/权限不足跳转地址了。

    MyRealm02.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
    
    /**
     * @Author yueyazhui
     * @Date 2023/2/25
     *
     * 继承 AuthenticatingRealm 可以实现认证功能
     * 继承 AuthorizingRealm 可以实现认证、授权功能
     */
    @Component
    public class MyRealm02 extends AuthorizingRealm {
    
        @Autowired
        UserService userService;
    
        /**
         * 核心方法:根据用户输入的用户名去数据库查询用户信息(认证)
         *
         * @param authenticationToken 包含用户登录时输入的用户名和密码等信息
         * @return 从数据库中查询到的用户信息
         * @throws AuthenticationException
         */
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
            // 获取用户登录时输入的用户名
            String username = usernamePasswordToken.getUsername();
            User user = userService.getUserByUsername(username);
            if (ObjectUtil.isNull(user)) {
                throw new UnknownAccountException("用户名输入错误");
            }
            // 返回查询到的用户信息(用户名、密码、盐)
            return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName());
        }
    
        /**
         * 返回用户的角色和权限(授权)
         * @param principalCollection 含有用户的登录信息
         * @return
         */
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            // 获取登录用户
            String username = (String) principalCollection.getPrimaryPrincipal();
            return new SimpleAuthorizationInfo(userService.findRolesByUsername(username));
        }
    }
    

    LoginController.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
    
    @RestController
    public class LoginController {
    
        @PostMapping("login")
        public Response login(String username, String password) {
            UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
            // 开启 RememberMe
            usernamePasswordToken.setRememberMe(true);
            try {
                // 执行登录
                Subject subject = SecurityUtils.getSubject();
                subject.login(usernamePasswordToken);
                List list = subject.getPrincipals().asList();
                for (Object o : list) {
                    System.out.println("o = " + o);
                }
            } catch (AuthenticationException e) {
                return Response.error("登录失败", e.getMessage());
            }
            return Response.success("登录成功");
        }
    
        @GetMapping("logout")
        public Response logout() {
            try {
                SecurityUtils.getSubject().logout();
                return Response.success("注销成功");
            } catch (Exception e) {
                return Response.error("注销失败");
            }
        }
    }
    

    HelloController.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    @RestController
    public class HelloController {
    
        @GetMapping("hello")
        public String hello() {
            return "Hello";
        }
    
        @GetMapping("admin")
        @RequiresRoles("admin")
        public String admin() {
            return "Hello Admin";
        }
    }
    
  8. 测试

源码

SSM 整合 Shiro

SpringBoot 整合 Shiro

本文由作者按照 CC BY 4.0 进行授权