当前位置: 首页 > article >正文

安全框架:Apache Shiro

安全框架:Apache Shiro

  • 前言
  • 您的第一个 Apache Shiro 应用程序
    • Multiple Parts(多个部分)
    • INI配置
      • [main]部分
      • [users]部分
      • [roles]部分
      • [urls]部分
      • 默认过滤器
      • 常规启用/禁用
    • 密码学
    • 会话管理
    • Remember Me
  • 整合SpringBoot
    • 登录
    • 登录超时
    • 记住我
    • 注解
    • 登录后跳回之前页面
    • 自定义缓存
    • 自定义SessionDao
    • 自定义过滤器
    • 登录退出
    • 单用户登录
  • 问题排查
    • 解决SpringBoot整合Shiro报Submitted credentials for token [org.apache.shiro.authc.UsernamePasswordToken...
    • 解决Springboot整合Shiro自定义SessionDAO+Redis管理会话,登录后不跳转首页
    • 解决Springboot整合Shiro+Redis退出登录后不清除缓存
  • 项目示例

前言

Apache Shiro™ 是一个功能强大且易于使用的 Java 安全框架,可执行身份验证、授权、加密和会话管理。使用Shiro易于理解的API,您可以快速轻松地保护任何应用程序 - 从最小的移动应用程序到最大的Web和企业应用程序。

下图显示了 Shiro 的精力集中在哪里:

在这里插入图片描述

  • Authentication(身份验证):有时称为“登录”,这是证明用户是他们所声称的身份的行为。
  • Authorization(授权):访问控制的过程,即确定“谁”可以访问“什么”。
  • Session Management(会话管理):管理特定于用户的会话,即使在非 Web 或 EJB 应用程序中也是如此。
  • Cryptography(加密):使用加密算法确保数据安全,同时仍然易于使用。

此外,还有一些其他功能可以在不同的应用程序环境中支持和加强这些问题,特别是:

  • Web 支持:ShiroWeb 支持 API 有助于轻松保护 Web 应用程序。
  • 缓存:缓存是Apache ShiroAPI中的第一层公民,用于确保安全操作保持快速和高效。
  • 并发:Apache Shiro 通过其并发功能支持多线程应用程序。
  • 测试:测试支持可帮助您编写单元测试和集成测试,并确保您的代码按预期得到保护。
  • “运行方式”:允许用户代入其他用户的身份(如果允许)的功能,有时在管理方案中很有用。
  • Remember Me(记住我)”:记住用户跨会话的身份,因此他们只需要在必须登录时登录。

Shiro 试图在所有应用程序环境中实现这些目标 - 从最简单的命令行应用程序到最大的企业应用程序,而无需强制依赖其他第三方框架、容器或应用程序服务器。当然,该项目的目标是尽可能地集成到这些环境中,但它可以在任何环境中开箱即用

您的第一个 Apache Shiro 应用程序

我们创建一个Maven项目,引入依赖配置,示例代码如下(最新版2.0.2需要JDK11):

        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-core</artifactId>
            <version>1.13.0</version>
        </dependency>

SpringBoot依赖配置如下:

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

要在应用程序中启用Shiro,首先要了解的是,Shiro中的几乎所有内容都与称为SecurityManager的中心/核心组件相关。SecurityManager是应用程序的Shiro环境的核心

虽然我们可以直接实例化SecurityManager类,但ShiroSecurityManager实现有足够的配置选项和内部组件,这使得在Java源代码中这样做很痛苦——使用灵活的基于文本的配置格式来配置SecurityManager会容易得多。

为此,Shiro通过基于文本的INI配置提供了默认的“公分母”解决方案。如今,人们已经厌倦了使用庞大的XML文件,而INI易于阅读、使用简单,并且只需要很少的依赖项。,INI可以有效地用于配置像SecurityManager这样的简单对象图。

src/main/resources目录下创建一个shiro.ini文件,内容如下:

# -----------------------------------------------------------------------------
# 用户和他们(可选)分配的角色
# username = password, role1, role2, ..., roleN
# -----------------------------------------------------------------------------
[users]
root = admin, admin
guest = guest, guest

# -----------------------------------------------------------------------------
# 已分配权限的角色
# roleName = perm1, perm2, ..., permN
# -----------------------------------------------------------------------------
[roles]
admin = *
guest = user:query

此配置基本上设置了一小部分静态用户帐户,对于我们的第一个应用程序来说已经足够了。

然后创建工厂获取实例,进行登录操作,示例代码如下:

public class Test {
    public static void main(String[] args) {
        // 初始化Security工厂
        IniSecurityManagerFactory iniSecurityManagerFactory = new IniSecurityManagerFactory("classpath:shiro.ini");
        // 解析INI文件,获取认证管理器实例
        SecurityManager securityManager = iniSecurityManagerFactory.getInstance();
        // 将认证管理器放入SecurityUtils
        SecurityUtils.setSecurityManager(securityManager);
        // 使用实例,它只是表示“当前正在与软件交互的事物”
        Subject subject = SecurityUtils.getSubject();
        // 身份验证
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("guest", "12345");
        try {
            subject.login(usernamePasswordToken);
        } catch (UnknownAccountException uae) {
            // 用户名不在系统中,给他们显示错误信息?
            System.out.println("用户不存在");
        } catch (IncorrectCredentialsException ice) {
            // 密码不匹配,再试一次?
            System.out.println("密码错误");
        } catch (LockedAccountException lae) {
            // 该用户名的/帐户被锁定-无法登录。
            System.out.println("账户被锁定");
        } catch (AuthenticationException ae) {
            //意外情况-错误?
            System.out.println("意外错误");
        }

    }
}

不同的catch捕获的异常不同,上述示例的guest的密码错误会打印对应日志。

当我们登录成功后,我们可以对当前账户进行一些校验操作,比如获取当前登录的信息,示例代码如下:

Object principal = subject.getPrincipal();
/** Output: 
 *  root
 */

还可以测试它们是否具有特定的角色:

boolean hasRole = subject.hasRole("president");
System.out.println(hasRole);
/** Output:
 *  false
 */

我们还可以查看他们是否有权对某种类型的实体执行操作:

boolean permitted = subject.isPermitted("user:query");
System.out.println(permitted);
/** Output:
 *  true
 */

最后,当用户使用完应用程序后,他们可以注销:

subject.logout();

Multiple Parts(多个部分)

通配符权限支持多个级别或部分的概念。例如,您可以通过授予用户权限来重新构建前面的简单示例:

user:query

此示例中第一部分是正在操作的域 (user),第二部分是正在执行的操作 (query),冒号是一个特殊字符,用于分隔权限字符串中的下一部分。

每个部分可以包含多个值。

user:query,user:insert,user:delete

如果要向用户授予特定部分中的所有值,该怎么办?我们可以根据通配符执行此操作。

user:*
*:query

INI配置

INI 基本上是一种文本配置,由由唯一命名的部分组织的键/值对组成。键仅对每个部分是唯一的,而不是在整个配置中唯一的(与 JDK 属性不同)。但是,每个部分都可以被视为单个 Properties 定义。

以下是 Shiro 理解的部分示例:

# =======================
# Shiro INI configuration
# =======================

[main]
# 对象和它们的属性在这里定义,例如securityManager,Realms等等,或者需要构建SecurityManager
myRealm = com.example.study.MyRealm
[users]
# 用户和他们(可选)分配的角色 如:username = password, role1, role2, ..., roleN
admin = admin
[roles]
# 已分配权限的角色,例如:roleName = perm1, perm2, ..., permN
admin = user:*
[urls]
# 用于基于url的安全

[main]部分

[main] 部分用于配置应用程序的 SecurityManager 实例及其任何依赖项,例如 Realms

[main]
myRealm = com.example.shiro.MyRealm
# 定义参数
myRealm.username = hello

示例代码如下:

public class MyRealm extends AuthenticatingRealm {
    private String username;

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println(username);
        /** Output:
         *  hello
         */
        return null;
    }
}

如果您需要设置的值不是基元,而是另一个对象,该怎么办?您可以使用美元符号 ($) 来引用以前定义的实例。例如:

[main]
sha256Matcher = org.apache.shiro.authc.credential.Sha256CredentialsMatcher
myRealm = com.example.shiro.MyRealm
myRealm.sha256CredentialsMatcher = $sha256Matcher

示例代码如下:

public class MyRealm extends AuthenticatingRealm {
    private Sha256CredentialsMatcher sha256CredentialsMatcher;

    public void setSha256CredentialsMatcher(Sha256CredentialsMatcher sha256CredentialsMatcher) {
        this.sha256CredentialsMatcher = sha256CredentialsMatcher;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println(sha256CredentialsMatcher);
        /** Output:
         *  org.apache.shiro.authc.credential.Sha256CredentialsMatcher@f6c48ac
         */
        return null;
    }
}

使用 INI 配置对象实例(如 SecurityManager 或其任何依赖项)听起来像是一件困难的事情,因为我们只能使用名称/值对。

我们经常喜欢将这种方法称为“穷人”依赖注入,虽然不如成熟的 Spring/Guice/JBoss XML 文件强大,但您会发现它可以完成很多工作,而不会太复杂。当然,其他配置机制也可用,但它们不是使用 Shiro 所必需的。

[users]部分

[users] 部分允许您定义一组静态用户帐户。[users] 部分中的每一行都必须符合以下格式:

username = password , 角色名称 1, 角色名称 2, …, 角色名称 N

这在用户帐户数量非常少或不需要在运行时动态创建用户帐户的环境中非常有用。下面是一个示例:

[users]
root = admin, admin
guest = guest, guest
  • 等号左侧的值是用户名

  • 等号右侧的第一个值是用户的密码。需要密码。

  • 密码后的任何逗号分隔值都是分配给该用户的角色的名称。角色名称是可选的。

Shiro 1 的算法(例如 md5SHA1SHA256 等)长期以来被认为不安全,不再受支持。 既没有直接的迁移路径,也没有向后兼容性。

我们先生成一个密钥,示例代码如下:

import org.apache.shiro.authc.credential.DefaultPasswordService;
import org.apache.shiro.authc.credential.PasswordService;

public class Test {
    public static String encriptPassword(String password) {
        PasswordService service = new DefaultPasswordService();
        return service.encryptPassword(password);
    }

    public static void main(String[] args) {
        System.out.println(encriptPassword("guest")); 
        /** Output:
         *  $shiro1$SHA-256$500000$69Zvt22yzkGtnTcqQ6UrvQ==$6k7YPH02jl130ZKzEugbafHxGLAcJB/ohBTaZcMibpA=
         */
    }
}

[main]部分里配置加密规则,将加密后的密码放入ini配置文件中,示例代码如下:

[main]
# 设置加密
passwordMatcher=org.apache.shiro.authc.credential.PasswordMatcher
passwordService=org.apache.shiro.authc.credential.DefaultPasswordService
passwordMatcher.passwordService=$passwordService
iniRealm.credentialsMatcher=$passwordMatcher
securityManager.realms=$iniRealm
# -----------------------------------------------------------------------------
# 用户和他们(可选)分配的角色
# username = password, role1, role2, ..., roleN
# -----------------------------------------------------------------------------
[users]
root = admin, admin
guest = $shiro1$SHA-256$500000$69Zvt22yzkGtnTcqQ6UrvQ==$6k7YPH02jl130ZKzEugbafHxGLAcJB/ohBTaZcMibpA=, guest

然后执行登录操作即可。

Shiro 2.0 开始,该部分不能包含纯文本密码。 您可以使用密钥派生函数对它们进行加密。 Shiro 提供了 bcryptargon2 的实现。 如果不确定,请使用 argon2 派生的密码。

[main]
# Shiro2CryptFormat

[users]
# user1 = sha256-hashed-hex-encoded password, role1, role2, ...
user1 = "$shiro2$argon2id$v=19$t=1,m=65536,p=4$H5z81Jpr4ntZr3MVtbOUBw$fJDgZCLZjMC6A2HhnSpxULMmvVdW3su+/GCU3YbxfFQ", role1, role2, ...

一旦指定了派生的文本密码值,就必须告诉Shiro这些值是加密的。您可以通过配置[main]部分中隐式创建的方法来使用与您指定的散列算法相对应的适当实现。

[roles]部分

[roles] 部分允许您将权限与 [users] 部分中定义的角色相关联。[roles] 部分中的每一行都必须按以下格式定义角色到权限的键/值映射:

rolename = permissionDefinition1, permissionDefinition2, …, permissionDefinitionN

同样,这在角色数量较少或不需要在运行时动态创建角色的环境中非常有用。下面是一个示例:

[roles]
admin = user:*
guest = user:query

[urls]部分

[urls]部分能够为应用程序中的任何匹配 URL 路径定义临时过滤器链!这比你通常定义过滤器链的方式要灵活、强大和简洁得多,该部分中每行的格式如下:

[urls]
URL Ant Path Expression(URL Ant路径表达式) = Path Specific Filter Chain(路径特定的过滤器链)

URL Ant路径表达式是一种用于匹配URL路径的模式表达式。支持使用?匹配一个字符)、*匹配零个或多个字符(不包括路径分隔符))、**匹配零个或多个目录层级(包括路径分隔符))等符号来匹配路径中的特定部分。

例如:

[urls]
# 匹配 /index.html 不匹配 /home/index.html
/?ndex.html = anon
# 匹配 /user/login 不匹配 /user 、/user/a/b
/user/* = anon
# 匹配 /file、/file/a/b
/file/** = authc

路径特定的过滤器链是指针对特定URL路径配置的过滤器链。Shiro允许你为不同的URL路径定义不同的过滤器链,以执行不同的安全检查和操作。

等号 = 右侧的标记是要为匹配该路径的请求执行的筛选条件的逗号分隔列表。它必须与以下格式匹配:

# filterN是在section和[main]中定义的过滤器bean的名称。
# [optional_configN]是一个可选的带括号的字符串,它对特定路径的特定过滤器有意义(per-filter, path-specific configuration!)。如果过滤器不需要对URL路径进行特定配置,则可以丢弃括号
filter1[optional_config1], filter2[optional_config2], ..., filterN[optional_configN]

因为 filter token 定义了链(又名 List),所以请记住顺序很重要!按照您希望请求流经链的顺序定义逗号分隔的列表

Shiro中,两者结合起来使用,以实现基于URL路径的访问控制。例如,在ShiroINI配置文件中,你可以这样配置:

[urls]
# 允许匿名访问
/login = anon
# 表示`/admin`目录下的所有路径都需要身份验证,并且用户需要拥有**admin**角色。
/admin/** = authc, roles[admin]
# 表示/user目录下的所有路径都需要身份验证,并且用户需要拥有user:view权限。
/user/** = authc, perms[user:view]

默认过滤器

当运行 Web 应用程序时,Shiro 将创建一些有用的默认实例并自动使它们在该部分中可用。您可以像配置任何其他 bean 一样配置它们,并在链定义中引用它们。例如:

[main]
...
# 注意,我们没有为FormAuthenticationFilter ('authc')定义类——它已经被实例化并且可用:
authc.loginUrl = /login.jsp
...

[urls]
...
# 确保最终用户已经过身份验证。如果没有,重定向到'authc。loginUrl”上面,
# 验证成功后,将它们重定向到原始帐户页面
# 我们试图查看:
/account/** = authc

自动可用的默认过滤器实例由DefaultFilter枚举定义,枚举的字段是可用于配置的名称。

名称描述
anonAnonymousFilter匿名过滤器,表示不需要登录即可访问的资源。这通常用于过滤静态资源,如图片、CSS、JavaScript等。
authcFormAuthenticationFilter身份验证过滤器,需要用户进行身份验证(登录)才能访问资源。
authcBasicBasicHttpAuthenticationFilterHTTP Basic身份验证过滤器,使用HTTP Basic协议进行身份验证。
authcBearerBearerHttpAuthenticationFilterBearer Token身份验证过滤器,通常用于OAuth 2.0的Bearer Token身份验证。用户需要在请求中携带一个有效的Bearer Token。
logoutLogoutFilter登出过滤器,用于处理用户的登出请求。执行登出操作后,用户通常会被重定向到登录页面或指定的其他页面。
noSessionCreationNoSessionCreationFilter不创建会话过滤器,用于阻止Shiro为请求创建会话。
permsPermissionsAuthorizationFilter权限授权过滤器,用于验证用户是否拥有特定的权限。
portPortFilter端口过滤器,用于限制请求只能通过指定的端口访问。
restHttpMethodPermissionFilterREST风格的方法权限过滤器,允许为不同的HTTP方法(如GET、POST、PUT、DELETE等)配置不同的权限。
rolesRolesAuthorizationFilter角色授权过滤器,用于验证用户是否拥有特定的角色。
sslSslFilterSSL过滤器,用于强制某些请求必须通过SSL(HTTPS)连接进行。
userUserFilter用户拦截器。如果用户未登录且未选择RememberMe,则请求可能会被重定向到登录页面(取决于配置)。
invalidRequestInvalidRequestFilter无效请求过滤器,用于处理无效的请求,如请求参数不完整或格式不正确等。这通常会导致一个错误响应或重定向到错误页面。

常规启用/禁用

通常,通过将其属性设置为 truefalse 来启用或禁用所有请求的过滤器。默认设置true是因为如果大多数过滤器在链中配置,则它们本身需要执行。

例如:

[main]
...
# 在测试时禁用Shiro的默认‘ssl’过滤器:
ssl.enabled = false

[urls]
/some/path = ssl, authc
/another/path = ssl, roles[admin]

你也可以调用实现类的方法实现此操作,示例代码如下:

SslFilter sslFilter = new SslFilter();
sslFilter.setEnabled(false);

密码学

Shiro 专注于密码学的两个核心元素:使用公钥或私钥加密电子邮件等数据的密码,以及不可逆地加密密码等数据的哈希值(又名消息摘要)。

Shiro提供默认哈希(在JDK中称为消息摘要)的开箱即用实现,如:MD5SHA-256SHA-386SHA-512等。比如:new Sha256Hash(data)

Md5Hash md5Hash = new Md5Hash("123456", "admin");
Sha256Hash sha256Hash = new Sha256Hash("123456", "root", 2);

他们有三个参数:

  • source:计算哈希的原始数据,可能是字符串、字节数组或者任何可以转换为字节流的对象。它是你希望进行哈希处理的数据,比如密码、文件内容、消息等。
  • salt:盐的作用是将原本相同的输入值通过添加不同的盐值变得不同,从而防止不同的输入数据产生相同的哈希值(即避免哈希冲突或彩虹表攻击)。盐通常是随机生成的一段额外数据。
  • hashIterations:用于指定哈希算法重复执行的次数,通常是为了增加计算的复杂性和提高安全性。这意味着,即使原始输入数据和盐值保持不变,增加哈希迭代的次数也能使得最终的哈希值更加安全,尤其是在面对暴力破解或彩虹表攻击时。

Shiro Hash 实例可以通过其 toHex()toBase64() 方法自动提供哈希数据的 HexBase-64 编码。因此,现在您无需弄清楚如何自己正确编码数据。

完整示例代码如下:

    public static void main(String[] args) {
        // md5方式
        Md5Hash md5Hash = new Md5Hash("123456", "admin");
        System.out.println("md5-tohex:"+md5Hash.toHex());
        System.out.println("md5-toBase64:"+md5Hash.toBase64());
        // sha256Hash方式
        Sha256Hash sha256Hash = new Sha256Hash("123456", "root");
        System.out.println("sha256Hash-tohex:"+sha256Hash.toHex());
        System.out.println("sha256Hash-toBase64:"+sha256Hash.toBase64());
        Sha256Hash sha256Hash2 = new Sha256Hash("123456", "root", 2);
        System.out.println("sha256Hash2-tohex:"+sha256Hash2.toHex());
        System.out.println("sha256Hash2-toBase64:"+sha256Hash2.toBase64());
        /** Output:
         *  md5-tohex:a66abb5684c45962d887564f08346e8d
         *  md5-toBase64:pmq7VoTEWWLYh1ZPCDRujQ==
         *  sha256Hash-tohex:28f4c77c534d5358329b61b326c995cd1743e2e37dd13949ace9c9b816de1fa9
         *  sha256Hash-toBase64:KPTHfFNNU1gym2GzJsmVzRdD4uN90TlJrOnJuBbeH6k=
         *  sha256Hash2-tohex:16c273b943b67a662cacf11b777729103eb235d0aa1d0daf3ce8953001651cf3
         *  sha256Hash2-toBase64:FsJzuUO2emYsrPEbd3cpED6yNdCqHQ2vPOiVMAFlHPM=
         */
    }

会话管理

  • 使用会话

Shiro中的几乎所有其他内容一样,您可以通过Subject交互来获取:

Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();
session.setAttribute("userId", "test");

在开发框架代码时,可以使用subject.getSession(false)来确保不会不必要地创建会话。

一旦你获得了一个Subject,你就可以用它做很多事情,比如设置或检索属性,设置超时,等等。调用getSession()方法可以在任何应用程序中工作,甚至是非web应用程序。

  • 会话超时

默认情况下,Shiro的实现默认为30分钟的会话超时。也就是说,如果创建的任何数据在 30 分钟或更长时间内保持空闲状态,则认为该数据已过期,不允许再使用。

在这里插入图片描述

您可以设置所有会话的默认超时值,该值以毫秒为单位(而不是秒)为单位。配置如下:

[main]
sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager
# 如果需要,可以在这里配置属性(如会话超时)该值以毫秒为单位(而不是秒)为单位。
securityManager.sessionManager.globalSessionTimeout = 1800000
  • 会话侦听器

Shiro允许您在重要的会话事件发生时做出反应。你可以实现 SessionListener 接口(或扩展方便的 SessionListenerAdapter)并相应地对 session 操作做出反应。示例代码如下:

public class MySessionListener implements SessionListener {
    @Override
    public void onStart(Session session) {
        System.out.println("进来了");
    }

    @Override
    public void onStop(Session session) {
        System.out.println("结束了");
    }

    @Override
    public void onExpiration(Session session) {
        System.out.println("过期了");
    }
}

配置监听类:

[main]
anotherSessionListener = org.example.MySessionListener
securityManager.sessionManager.sessionListeners = $anotherSessionListener

执行结果如图:

在这里插入图片描述

  • 会话存储

每当创建或更新会话时,其数据都需要保存到存储位置,以便应用程序稍后可以访问它。同样,当会话无效且使用时间较长时,需要将其从存储中删除,以免耗尽会话数据存储空间。这些实现将这些 CRUD 操作委托给内部组件 SessionDAO,它反映了数据访问对象 设计模式。

默认情况下,Web 应用程序不使用本机会话管理器,而是保留 Servlet 容器的默认会话管理器,该管理器不支持 SessionDAO。如果您想在基于 Web 的应用程序中启用 SessionDAO 以进行自定义会话存储或会话集群,则必须首先配置本机 Web 会话管理器。示例代码如下:

public class MyCustomSessionDAO implements SessionDAO {
    private final Map<Serializable, Session> sessions = new HashMap<>();
    @Override
    public Serializable create(Session session) {
        System.out.println("创建");
        String str = UUID.randomUUID().toString();
        ((SimpleSession)session).setId(str); // sessionId赋值
        sessions.put(str, session);
        return str;
    }

    @Override
    public Session readSession(Serializable serializable) throws UnknownSessionException {
        System.out.println("查询");
        return sessions.get(serializable);
    }

    @Override
    public void update(Session session) throws UnknownSessionException {
        System.out.println("修改");
    }

    @Override
    public void delete(Session session) {
        System.out.println("删除");
    }

    @Override
    public Collection<Session> getActiveSessions() {
        return List.of();
    }
}

配置监听类:

[main]
sessionDAO = org.example.MyCustomSessionDAO
securityManager.sessionManager.sessionDAO = $sessionDAO

执行结果如图:

在这里插入图片描述

  • 自定义会话 ID

Shiro的实现使用内部SessionIdGenerator组件在每次创建新会话时生成一个新的Session ID。将其分配给新创建的实例,然后通过SessionDAO保存ID

默认值为 JavaUuidSessionIdGenerator,它基于 Java UUID 生成 ID。此实现适用于所有生产环境。

如果这不能满足你的需求,你可以在 Shiro 的实例上实现接口并配置实现。示例代码如下:

public class CustomSessionManager implements SessionIdGenerator {
    @Override
    public Serializable generateId(Session session) {
        return "custom_"+ UUID.randomUUID();
    }
}

配置监听类:

[main]
sessionIdGenerator = org.example.CustomSessionManager
securityManager.sessionManager.sessionDAO.sessionIdGenerator = $sessionIdGenerator

执行结果如图:

在这里插入图片描述

  • 会话验证和调度

必须验证会话,以便可以从会话数据存储中删除任何无效(过期或已停止)的会话。这可确保数据存储不会随着时间的推移而填满永远不会再次使用的会话。

在所有环境中默认可用的是 ExecutorServiceSessionValidationScheduler,它使用 JDK ScheduledExecutorService 来控制验证的频率。此实施将每小时执行一次验证。您可以通过指定的新实例并指定不同的间隔(以毫秒为单位)来更改验证的发生速率:

sessionValidationScheduler = org.apache.shiro.session.mgt.ExecutorServiceSessionValidationScheduler
# Default is 3,600,000 millis = 1 hour:
sessionValidationScheduler.interval = 3600000

如果您希望提供自定义实现,您可以将其指定为 default 实例的属性。示例代码如下:

public class MySessionValidationScheduler implements SessionValidationScheduler {
    @Override
    public boolean isEnabled() {
        return false;
    }

    @Override
    public void enableSessionValidation() {

    }

    @Override
    public void disableSessionValidation() {

    }
}

配置监听类:

[main]
sessionValidationScheduler = org.example.MySessionValidationScheduler
securityManager.sessionManager.sessionValidationScheduler = $sessionValidationScheduler

在某些情况下,你可能希望完全禁用会话验证,因为你已经设置了一个不受Shiro控制的流程来为你执行验证。

[main]
securityManager.sessionManager.sessionValidationSchedulerEnabled = false

如果你关闭了 Shiro 的会话验证调度器,你必须通过其他机制(cron job 等)执行定期会话验证。这是保证 Session不会填满数据存储的唯一方法。

但是,某些应用程序可能不希望 Shiro 自动删除会话。但是,某些应用程序可能不希望 Shiro 自动删除会话。

[main]
securityManager.sessionManager.deleteInvalidSessions = false

Remember Me

如果 Shiro 实现了 org.apache.shiro.authc.RememberMeAuthenticationToken 接口,则 Shiro 将执行 'rememberMe' 服务。

public interface RememberMeAuthenticationToken extends AuthenticationToken {
    boolean isRememberMe();
}

以编程方式使用 rememberMe,您可以将值设置为在支持此配置的类上。例如:

UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("guest", "guest");
usernamePasswordToken.setRememberMe(true);

整合SpringBoot

经过前面的内容讲解,掌握了Shiro的基本知识。下面将整合SpringBoot框架,模拟Web操作(前后不分离项目)。

pom依赖配置文件如下:

    <properties>
        <java.version>8</java.version>
    </properties>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
	<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.13.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.9</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>
    </dependencies>

注:并没有使用最新的Shiro2.x.x版本,要求是需要JDK11以上,但是再与SpringBoot3.x.x整合过程中遇到很多问题,所以建议使用:Shiro1.x.x+SpringBoot2.x.x+JDK1.8

然后创建Shiro需要的基本配置信息,示例代码如下:

@Configuration
public class ShiroConfig {
    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
}

一切就绪后,运行一下没问题,最基本的SpringBoot+Shiro整合已经完成。

在这里插入图片描述

登录

框架搭建完成后,作为权限认证和授权最重要的一部分,也是项目访问的入口,我们先从登录开始讲解。

根据.ini文件的配置,所需要的表有三个,用户表、角色表、权限表,然后用户表和角色表关联中间表,角色表和权限表管理中间表,一共有5个表:

-- 用户表
CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
  `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '密码',
  `age` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 角色表
CREATE TABLE `role` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(64) DEFAULT NULL,
  `description` varchar(100) DEFAULT NULL COMMENT '描述',
  PRIMARY KEY (`id`),
  KEY `role_id_IDX` (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 权限表
CREATE TABLE `permission` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 用户-角色关联表
CREATE TABLE `user_role` (
  `id` int NOT NULL,
  `user_id` int NOT NULL,
  `role_id` int NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 角色-权限管理表
CREATE TABLE `role_permission` (
  `id` int NOT NULL AUTO_INCREMENT,
  `role_id` int NOT NULL,
  `permission_id` int NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

配置默认超管数据:

-- 插入用户(生成密码通过Sha256Hash.tobase64,密码123456)
INSERT INTO `user` (id, username, password, age) VALUES(1, 'admin', 'rA59A3gXCU6eC0RB+brjIJ1nsC+khJFwZfcbFhCaGng=', 22);
-- 插入角色
INSERT INTO `role` (id, name, description) VALUES(1, 'admin', '超级管理员');
-- 插入权限(所有权限)
INSERT INTO permission (id, name) VALUES(1, '*');
-- 插入用户-角色关联关系
INSERT INTO user_role (id, user_id, role_id) VALUES(1, 1, 1);
-- 插入角色-权限关联关系
INSERT INTO role_permission (id, role_id, permission_id) VALUES(1, 1, 1);

生成对应POJO实体以及Mapper文件(这里使用Mybatis-plus,你要可以使用其他框架,生成的代码就不展示了,只介绍重点)。

我们先创建一个Realm类,继承AuthorizingRealm抽象类(该类可以直接自定义验证和授权的业务),示例代码如下:

@Component
public class UserRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private PermissionService permissionService;
    // 验证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String username = (String) authenticationToken.getPrincipal(); // 用户名
        String password = new String((char[]) authenticationToken.getCredentials()); // 凭证=密码,需要转换类型
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            throw new UnknownAccountException("账号不存在");
        }
        // 手动验证密码是否正确,shiro不会帮你做这些
        Sha256Hash sha256Hash = new Sha256Hash(password.getBytes(StandardCharsets.UTF_8), username);
        if (!sha256Hash.toHex().equals(user.getPassword())) {
            throw new IncorrectCredentialsException("密码错误");
        }
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, sha256Hash.toHex(), ByteSource.Util.bytes(username), getName());
        return simpleAuthenticationInfo;
    }
    // 授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        User user = (User) principalCollection.getPrimaryPrincipal();
        // 获取角色
        List<Role> roleList = roleService.getByUserId(user.getId());
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        roleList.forEach(item ->{
            simpleAuthorizationInfo.addRole(item.getName());
        });
        List<Integer> roleIds = roleList.stream().map(Role::getId).collect(Collectors.toList());
        // 获取权限
        List<Permission> permissions = permissionService.listByIds(roleIds);
        permissions.forEach(item->{
            simpleAuthorizationInfo.addStringPermission(item.getName());
        });
        return simpleAuthorizationInfo;
    }

}

简单介绍下SimpleAuthenticationInfo类,以四个构造参数为例:

  • principal:表示用户的身份信息,这个参数可以通过subject.getPrincipal()方法获取,表示当前记录的用户,进而可以获取该用户的一系列所需属性。
  • credentials:表示用户的凭证信息,通常是密码明文或密码的加密形式。如果未指定密码匹配规则就是明文,否则就要按指定加密方式进行传参,比如:MD5SHA-256等等。不然就会报异常错误。
  • salt:盐值,用于加密密码。这是为了防止两个用户的初始密码相同,通过添加盐值可以增加密码的复杂性和唯一性。在前面密码学章节有介绍。
  • realmName:表示当前方法调用的Realm名称。

Realm创建好后,需要交给SecurityManager进行管理,示例代码如下:

@Configuration
public class ShiroConfig {
    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
    /**
     * 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询
     */
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法
        return userRealm;
    }
}

另外数据库使用的SHA-256加密,在Realm校验时,向SimpleAuthenticationInfo传参时,为了保持一致,防止报错,所以还需要指定密码算法规则。

    /**
     * 指定密码加密算法类型
     */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("SHA-256"); // 设置哈希算法
        return hashedCredentialsMatcher;
    }

然后我们需要配置接口的过滤和拦截,否则访问时会被拦截,如图所示:

在这里插入图片描述
示例代码如下:

    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 当未登录的用户尝试访问受保护的资源时,重定向到这个指定的登录页面。
        //shiroFilterFactoryBean.setLoginUrl("/user/index");
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/user/index", "anon");
        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

然后我们编写Controller层代码,示例代码如下:

@RestController
@RequestMapping(value = "/user")
public class UserController {
    // 登录跳转
    @GetMapping("/index")
    public String index() {
        return "login.html";
    }
    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody User user) {
        UsernamePasswordToken token = new UsernamePasswordToken(
                user.getUsername(),//身份信息
                user.getPassword());//凭证信息
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(token);
        } catch (UnknownAccountException e) {
            return ResponseEntity.ok("账号或密码错误");
        }
        return ResponseEntity.ok("success");
    }
    // 登录成功后跳转页面
    @GetMapping("/main")
    public String main() {
        return "main.html";
    }
}

然后我们输入地址:http://localhost:8080/user/index,进入登录页面,输入正确的账号,SESSIONID也生成了,进入首页(前端代码不展示),如图所示:

在这里插入图片描述

  • 未授权跳转

前面的示例中在filterChainDefinitionMap中定义了/user/index登录页面接口为匿名,未登录的情况下访问其他就会重定向到http://localhost:8080/login.jsp,如图所示:

在这里插入图片描述

现在.jsp页面用的也比较少,如果想让他未授权的时候访问任何地址都会跳转到指定登录页面,我们可以通过setLoginUrl()方法进行设置,示例代码如下:

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 当未登录的用户尝试访问受保护的资源时,重定向到这个指定的登录页面。
        shiroFilterFactoryBean.setLoginUrl("/user/index");
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

这样设置后,未授权的情况下访问任何接口都可以跳到登录页面。

在这里插入图片描述

  • 已授权跳转

前面我们通过setLoginUrl()方法指定未登录情况下的页面跳转,setUnauthorizedUrl()方法用于登陆后访问没权限的资源:

		ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 当未登录的用户尝试访问受保护的资源时,重定向到这个指定的登录页面。
        shiroFilterFactoryBean.setLoginUrl("/user/index");
        // 成功后跳转地址,但是测试时未生效
//        shiroFilterFactoryBean.setSuccessUrl("/user/main");
        // 当用户访问没有权限的资源时,系统重定向到指定的URL地址。
        shiroFilterFactoryBean.setUnauthorizedUrl("/user/unauth");

如果没有设置接口拦截(指定接口访问角色等设置),访问的一些接口就会报错误页面,如图所示:

在这里插入图片描述

设置接口拦截(指定角色访问),示例代码如下:

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 当用户访问没有权限的资源时,系统重定向到指定的URL地址。
        shiroFilterFactoryBean.setUnauthorizedUrl("/user/unauth");
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        // 设置/root下的资源只能root角色访问
        filterChainDefinitionMap.put("/root/**", "roles[root]");
        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

使用其他角色访问/root/**下的资源,就会跳转指定页面,如图所示:

在这里插入图片描述

setSuccessUrl()方法用于成功后页面跳转,但是在测试过程中没有生效(如果知道解决办法的,可以评论区交流),不过可以在登录成功后前端或后端跳转,解决这种问题。

现在很多互联网公司在跳转登录页面时会判断用户会话是否存在,如果存在就直接跳转首页,不会进行登录操作,除非登录退出后重新登录,shiro提供非常方便的操作,示例代码如下:

    @GetMapping("/index")
    public ModelAndView index() {
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated() || subject.isRemembered()) {
            return new ModelAndView("redirect:main");
        }
        return new ModelAndView("login.html");
    }

我们在跳转登录页面时通过isAuthenticated()方法(用户已登录)和isRemembered()方法(用户是否勾选“记住我”功能)判断用户身份,如果有存在的会话就不让他重新登录,直接跳到首页即可。

另外在多说一句,在前后不分离项目中,可能ShiroFilterFactoryBean 用的比较多,如果前后分离,还有一种方式定义请求过滤链的组件,示例代码如下:

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/user/login", "anon");
        chainDefinition.addPathDefinition("/**", "authc");
        return chainDefinition;
    }

登录超时

登录超时是一种安全措施,如果用户在公共计算机或共享设备上登录后忘记退出,登录超时可以确保他们的会话在一段时间后自动结束,从而降低被他人冒用的风险。
另一方面释放服务器端资源。每个活跃的会话都会占用服务器的一定资源,包括内存、数据库连接等。通过设置合理的登录超时时间,可以自动释放那些不再需要的会话资源,提高服务器的性能和效率。

示例代码如下:

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        defaultWebSecurityManager.setSessionManager(defaultWebSessionManager()); // 注册会话管理
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
    /**
     * 创建会话管理
     */
    @Bean
    public DefaultWebSessionManager defaultWebSessionManager(){
        DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
        defaultWebSessionManager.setGlobalSessionTimeout(10000); //设置会话过期时间,毫秒
        return defaultWebSessionManager;
    }

上述代码设置了会话过期时间为10s,当时间范围内没有操作,自动跳转登录页面,

记住我

“记住我”(Remember Me)功能允许系统记住用户的登录状态,即使用户关闭浏览器后(关闭整个浏览器,不是只关闭页面),下一次访问时仍然能够自动登录。这个功能通常通过在用户登录时设置一个特殊的 rememberMe cookie 来实现。

调用登录接口,设置RememberMe的参数值,示例代码如下:

// 方法一:
UsernamePasswordToken token = new UsernamePasswordToken(username, password, true);
// 方法二:
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token.setRememberMe(true);

然后登录成功后,我们可以在浏览器的Application中看到,有一个rememberMe属性,如图所示:

在这里插入图片描述

注:当你再次把浏览器全部关闭后,rememberMe可能依旧未生效,你需要检查你的拦截器链,将authc过滤器改为user过滤器

示例代码如下:

        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/root/**", "roles[root]");
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/**", "user");

然后再次尝试即可(user过滤器可以过滤未登录和rememberMe功能)。

默认情况下,cookie有效期为一年,如图所示:

在这里插入图片描述
你也可以自定义cookie有效期,示例代码如下:

    /**
     * cookie管理对象;记住我功能,rememberMe管理器
     * @return
     */
    @Bean
    public CookieRememberMeManager rememberMeManager(){
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(rememberMeCookie());
        //rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
        cookieRememberMeManager.setCipherKey(Base64.getDecoder().decode("h0nCDfE5LEh3owvvugjP+HyYa7NDuduM2bUUPJf8zII="));
        return cookieRememberMeManager;
    }
    @Bean
    public SimpleCookie rememberMeCookie(){
        // 使用默认命名:rememberMe
        SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
        // setcookie 的 httponly 属性如果设为 true 的话,会增加对 xss 防护的安全系数,
        // 只能通过http访问,javascript无法访问,防止xss读取cookie
        simpleCookie.setHttpOnly(true);
        simpleCookie.setPath("/");
        // 记住我 cookie 生效时间10秒 ,单位是秒
        simpleCookie.setMaxAge(10);
        return simpleCookie;
    }
    /**
     * aes加密方法
     * @return
     */
    public static String generateRandomKey(int keySize) throws Exception {
        // 创建一个密钥生成器,指定算法为AES(或其他你选择的算法)
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
        // 初始化密钥生成器,指定密钥长度(例如128, 192, 或 256位)
        keyGenerator.init(keySize);
        // 生成一个密钥
        SecretKey secretKey = keyGenerator.generateKey();
        // 将密钥转换为字节数组
        byte[] keyBytes = secretKey.getEncoded();
        // 将字节数组编码为Base64字符串
        String encodedKey = Base64.getEncoder().encodeToString(keyBytes);
        return encodedKey;
    }

然后将rememberMeManager注册到DefaultWebSecurityManager中,示例代码如下:

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        defaultWebSecurityManager.setRememberMeManager(rememberMeManager());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }

设置完成后,重启后,访问页面登录后,把浏览器关闭等待10s后再次打卡访问就会跳转到登录页面。

有的时候会很容易把过期时间和“记住我”弄混,下面谈谈他们的区别:

  • 过期时间指的是某个对象、数据或会话在创建后能够保持有效或可用的时间段。一旦超过这个时间,对象、数据或会话将不再有效或可用。
  • “记住我”是一个功能选项,通常出现在登录表单中。当用户勾选此选项并成功登录后,即使关闭浏览器或清除会话,下次访问网站时也会自动登录,无需重新输入用户名和密码。

也就是说即使勾选了“记住我”登录后,到会话过期时间后未操作一样会重新登录,现在很多公司登录页面都没有“记住我”的功能选项了,反而更偏向于会话时长。

注解

Shiro框架提供的一种简化开发的方式,通过在代码中添加注解,可以方便地实现权限控制和身份认证。

  • @RequiresAuthentication注解

表示当前用户必须已经通过身份验证(即登录)才能访问被注解的方法或类。相当于Subject.isAuthenticated()返回true。为了演示我们把config中的权限都去掉,示例代码如下:

    @GetMapping("/list")
    @RequiresAuthentication
    public ResponseEntity<String> list() {
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        System.out.println(session.getId());
        return ResponseEntity.ok("success");
    }

如果我们没有登录的情况下访问该接口,默认情况下就会报错(我们可以自定义处理方式,跳转登录页面),如图所示:

在这里插入图片描述
登录后就可以返回正确的数据,如图所示:

在这里插入图片描述

  • @RequiresUser注解

表示当前用户已经进行了身份验证,或者通过“记住我”功能进行了登录。这个注解比@RequiresAuthentication更宽松,因为它允许通过“记住我”功能登录的用户访问。

    @GetMapping("/list")
    @RequiresUser
    public ResponseEntity<String> list() {
        //2.2对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        System.out.println(session.getId());
        return ResponseEntity.ok("success");
    }

该注解不仅支持登录后访问,还支持“记住我”功能,浏览器关闭后也可以继续访问。

  • @RequiresGuest注解

表示当前用户没有进行身份验证,或者是以游客身份访问。如果用户已经登录,则会被拒绝访问。

    @GetMapping("/list")
    @RequiresGuest
    public ResponseEntity<String> list() {
        //2.2对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        System.out.println(session.getId());
        return ResponseEntity.ok("success");
    }

感觉这个注解用的应该挺少把,游客可以访问,登录用户不能访问,如图所示:

在这里插入图片描述

  • @RequiresRoles注解

表示当前用户必须拥有指定的角色才能访问被注解的方法或类。可以指定一个或多个角色,并设置逻辑关系(如ANDOR)。

    @GetMapping("/list")
    @RequiresRoles(value = {"admin","root"}, logical = Logical.AND)
    public ResponseEntity<String> list() {
        //2.2对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        System.out.println(session.getId());
        return ResponseEntity.ok("success");
    }

如图所示:
在这里插入图片描述

  • @RequiresPermissions注解

当前用户必须拥有指定的权限才能访问被注解的方法或类。可以指定一个或多个权限,并设置逻辑关系(如ANDOR)。

    @GetMapping("/list")
    @RequiresPermissions(value = {"user:list","user:insert"}, logical = Logical.OR)
    public ResponseEntity<String> list() {
        //2.2对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        System.out.println(session.getId());
        return ResponseEntity.ok("success");
    }

当未登录或者没有该权限的用户访问就会报错,否则返回success,如上图所示。

登录后跳回之前页面

通常我们希望在用户成功登录后能够跳回到他们之前尝试访问的页面(即被拦截之前的页面)。

Shiro提供了SavedRequest功能来自动处理这种情况。当你配置了ShiroWeb过滤器后,Shiro会自动捕获被拦截的请求并存储在会话中。一旦用户成功登录,你可以从会话中获取这个请求并重定向到它。

在这里插入图片描述
比如:登录超时后重新登录跳转上次所在页面,示例代码如下:

    @PostMapping("/login")
    public ModelAndView login(HttpServletRequest request, @RequestParam("username") String username
            , @RequestParam("password") String password) {
        UsernamePasswordToken token = new UsernamePasswordToken(
                username,//身份信息
                password);//凭证信息
        ModelAndView modelAndView = new ModelAndView();
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated() || subject.isRemembered()) {
            modelAndView.setViewName("redirect:main");
            return modelAndView;
        }
        try {
            subject.login(token);
            // 判断savedRequest不为空时,获取上一次停留页面,进行跳转
            SavedRequest savedRequest = WebUtils.getSavedRequest(request);
            if (savedRequest != null) {
                String requestUrl = savedRequest.getRequestUrl();
                modelAndView.setViewName("redirect:"+ requestUrl);
                return modelAndView;
            }
        } catch (AuthenticationException e) {
            modelAndView.addObject("responseMessage", "用户名或者密码错误");
            modelAndView.setViewName("login");
            return modelAndView;
        }
        modelAndView.setViewName("redirect:main");
        return modelAndView;
    }

如图所示:

在这里插入图片描述
如果上一个页面是错误页面就会导致登录进来直接进入登录错误页面,所以不建议这种处理方式

自定义缓存

Shiro提供的一个缓存管理器MemoryConstrainedCacheManager使用JVM的内存来存储缓存数据,开发者可能会选择使用EhCache作为默认的缓存实现,因为Shiro提供了与EhCache的集成支持。

登录时虽然继承的父类AuthorizingRealm,实际进入的是AuthenticatingRealm进行的缓存操作,当缓存数据为null时,进入对应的Realm进行数据库查询,否则使用缓存,减轻数据库压力,提供系统性能。

在这里插入图片描述

下面使用Redis当作Shiro缓存(参考MemoryConstrainedCacheManagerMapCache),示例代码如下:

// 定义cachemanage
public class RedisCacheManage implements CacheManager {
    private final RedisTemplate<String, Object> redisTemplate;
    public RedisCacheManage(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new RedisCache<>(s, redisTemplate);
    }
}
// 定义shiro缓存
public class RedisCache<K, V> implements Cache<K, V> {
    private final HashOperations<String, K, V> hashOperations;
    private final String name;
    public RedisCache(String name, RedisTemplate<String, Object> redisTemplate) {
        this.name = name;
        this.hashOperations = redisTemplate.opsForHash();
    }
    @Override
    public V get(K k) throws CacheException {
        return hashOperations.get(name, k);
    }

    @Override
    public V put(K k, V v) throws CacheException {
        hashOperations.put(name, k, v);
        return v;
    }

    @Override
    public V remove(K k) throws CacheException {
        V v = hashOperations.get(name, k);
        hashOperations.delete(name, k);
        return v;
    }

    @Override
    public void clear() throws CacheException {
        hashOperations.delete(name);
    }

    @Override
    public int size() {
        return hashOperations.size(name).intValue();
    }

    @Override
    public Set<K> keys() {
        return hashOperations.keys(name);
    }

    @Override
    public Collection<V> values() {
        return hashOperations.values(name);
    }
}

为了防止会话堆积你可以设置缓存过期时间。

Config中设置缓存管理,示例代码如下:

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
    /**
     * 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询
     */
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCachingEnabled(true); // 启动全局缓存
        userRealm.setAuthenticationCachingEnabled(true); // 启动验证缓存
        userRealm.setAuthenticationCacheName("Authentication"); // 定义验证缓存名
        //userRealm.setAuthorizationCachingEnabled(true); // 启动授权缓存
        //userRealm.setAuthorizationCacheName("Authorization"); // 定义授权缓存名
        userRealm.setCacheManager(cacheManager());
        return userRealm;
    }

    @Bean
    public CacheManager cacheManager() {
        RedisCacheManage redisCacheManage = new RedisCacheManage(redisTemplate());
        return redisCacheManage;
    }

然后进行登录操作,第一次登录会查询数据库,如图所示:

在这里插入图片描述
并在Redis中会插入一条会话的数据(实际上缓存了验证时候的用户信息,授权时候并没缓存也就不需要开启),如图所示:

在这里插入图片描述

第二次登录时,就会直接从缓存中获取数据,不会进入自定义Realm中。

在这里插入图片描述
你有可能遇到ByteSource无法序列化的问题,你需要自己实现序列号和ByteSource(具体逻辑参考SimpleByteSource),示例代码如下:

public class ByteSourceSerializable implements ByteSource,Serializable {
    private static final long serialVersionUID = 9206836077237410719L;
    private final byte[] bytes;
    private String cachedHex;
    private String cachedBase64;

    public ByteSourceSerializable(byte[] bytes) {
        this.bytes = bytes;
    }

    public ByteSourceSerializable(char[] chars) {
        this.bytes = CodecSupport.toBytes(chars);
    }
    // ... ...
}

然后再自定义Realm中,盐值使用自定义的类,示例代码如下:

SimpleAuthenticationInfo sai= new SimpleAuthenticationInfo(user, sha256Hash.toHex(), new ByteSourceSerializable(username), getName());

自定义SessionDao

Shiro框架中,SessionDAO的默认实现是MemorySessionDAOMemorySessionDAOShiro已经实现的SessionCRUD(创建、读取、更新、删除)接口类,它内部维护了一个ConcurrentMap来保存session数据,即将session数据缓存在内存中。

在这里插入图片描述
似乎在单机部署的情况下够用,但是在技术发展迅速的今天来说带来很多问题,比如:

  • 不提供持久化功能,这意味着当服务器重启或发生故障时,用户的会话状态无法恢复。
  • 在分布式系统中,由于session数据仅在单个服务器内存中保存,因此当用户请求被负载均衡到其他服务器时,无法访问到之前的session数据。

为了解决这些问题,使用Redis作为Session存储可以解决分布式系统中的Session共享问题,并提供较高的读写性能和可靠性。

创建自定义SessionDao(参考EnterpriseCacheSessionDAO),示例代码如下:

public class RedisSessionDao extends CachingSessionDAO {

    protected Serializable doCreate(Session session) {
        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        return sessionId;
    }

    protected Session doReadSession(Serializable sessionId) {
        return null;
    }

    protected void doUpdate(Session session) {
    }

    protected void doDelete(Session session) {
    }
}

Config中设置自定义SessionDao,示例代码如下:

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        defaultWebSecurityManager.setSessionManager(defaultWebSessionManager()); // 注册会话管理
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
    /**
     * 创建会话管理
     */
    @Bean
    public DefaultWebSessionManager defaultWebSessionManager() {
        DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
        defaultWebSessionManager.setGlobalSessionTimeout(10000); // 缓存过期时间
        defaultWebSessionManager.setSessionDAO(sessionDAO());
        defaultWebSessionManager.setCacheManager(cacheManager()); // 设置缓存管理器,自动给sessiondao赋值
        return defaultWebSessionManager;
    }
    @Bean
    public SessionDAO sessionDAO() {
        RedisSessionDao redisSessionDao = new RedisSessionDao();
//        redisSessionDao.setSessionIdGenerator(sessionIdGenerator());
//        redisSessionDao.setCacheManager(cacheManager()); // 设置缓存管理器
        redisSessionDao.setActiveSessionsCacheName("shiro:session"); // 自定义redis存放的key名称
        return redisSessionDao;
    }
    @Bean
    public SessionIdGenerator sessionIdGenerator(){
        return new CustomSessionManager();
    }
    @Bean
    public CacheManager cacheManager() {
        RedisCacheManage redisCacheManage = new RedisCacheManage(redisTemplate());
        return redisCacheManage;
    }
    // redis配置
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //设置默认序列化方式,类型转换错误
        redisTemplate.setDefaultSerializer(new JdkSerializationRedisSerializer());
        //启动默认序列化
        redisTemplate.setEnableDefaultSerializer(true);
        return redisTemplate;
    }

登录后跳转正常,如图所示:

在这里插入图片描述

查看Redis中是否保存Session信息,如图所示:

在这里插入图片描述

等待10s后会话过期,自动跳回登录页面,也没问题(每次过期后会将当前Session对象删除再创建新的Session对象,新的对象未使用隔段时间会定时清除)。

自定义过滤器

Shiro的内置过滤器可能无法满足所有复杂的权限控制需求。自定义过滤器可以用于实现特定的认证逻辑,如验证码验证、第三方登录等;也可以实现基于用户访问频率的限制,防止恶意请求或过度使用资源。

  • OncePerRequestFilter: 确保每个请求只会被过滤一次。如果你只是需要执行一些通用的过滤逻辑(例如日志、请求记录等),可以继承这个类。
public class UserLoginFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        System.out.println("进来了");
        if (false) {
            // 执行下一个过滤器
            filterChain.doFilter(request, response);
        } else {
            // 方法一:返回json数据
            Map<String, Object> map = new HashMap<>();
            map.put("code", "fail");
            map.put("message", "错误");
            response.getWriter().println(map);
            // 方法二:重定向其它页面
            ((HttpServletResponse)response).sendRedirect("/user/main");
        }
    }
}

Config配置文件,示例代码如下:

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
		// 过滤器赋值
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("userLoginFilter", userLoginFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/**", "userLoginFilter");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }
    @Bean
    public UserLoginFilter userLoginFilter(){
        return new UserLoginFilter();
    }

  • AccessControlFilter:用来进行访问控制,比如判断用户是否有权限访问某个 URL。如果你要实现基于认证、授权等条件的控制,可以继承这个类。
public class UserLoginFilter extends AccessControlFilter {
    /**
     * 确定是否允许访问。如果返回true,则允许访问,继续进行;如果返回false,则会调用onAccessDenied方法
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object o) throws Exception {
        System.out.println("允许访问-进来了");
        return ((HttpServletRequest)request).getRequestURI().equals("/user/main");
    }

    /**
     * 当isAccessAllowed方法返回false时,此方法被调用。在这里可以实现访问被拒绝时的处理
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        System.out.println("拒绝访问-进来了");
        // 方法一:返回json数据
        Map<String, Object> map = new HashMap<>();
        map.put("code", "fail");
        map.put("message", "错误");
        response.getWriter().println(map);
        // 方法二:重定向其它页面
        ((HttpServletResponse)response).sendRedirect("/user/index");
        return false;
    }
}

当访问/user/main时被允许,访问其它页面则拒绝,进入onAccessDenied()方法,然后进行对应拒绝处理,如图所示:
在这里插入图片描述

登录退出

Apache Shiro中,退出系统通常意味着释放当前用户的所有认证信息并结束会话。

    @GetMapping("/logout")
    public void logout() {
        SecurityUtils.getSubject().logout();
    }

调用logout()方法就可以实现登录退出的功能,我们可以看下里面有做哪些处理,
在这里插入图片描述

以缓存为例,进入CachingRealm类,如图所示:
在这里插入图片描述
根据Debug先进入AuthorizingRealm类(前面介绍过缓存没保存授权的记录,如果开启了缓存依旧会删除操作),先进入AuthenticatingRealm.doClearCache(),然后获取缓存和凭证进行删除操作,如图所示:
在这里插入图片描述
如果使用自定义了SessionDAO使用缓存进行管理的话,进入finally代码块,执行会话清除工作,如图所示:

在这里插入图片描述

然后CachingSessionDAO类将原来的Session删除后,生成一个新的Session对象用于下次登录操作(登录时再执行修改操作将登录的信息保存到该Session),如图所示:

在这里插入图片描述

单用户登录

单用户登录(Single User Login)其核心意义在于一个账号在同一时间仅能由一个用户进行登录。如果某个用户已经使用某一账号成功登录系统,那么当另一用户尝试以同一账号登录时,系统将会阻止这次登录尝试,或者迫使先前登录的用户退出系统,以便新用户可以登录,确保账号使用的唯一性和安全性。

网上最常见的方法就是获取所有已存在会话,通过遍历匹配会话进行删除,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @Autowired
    private SessionDAO sessionDAO;
    @PostMapping("/login")
    public ModelAndView login(HttpServletRequest request, @RequestParam("username") String username
            , @RequestParam("password") String password) {
        // 提前加密,解决自定义缓存匹配时错误
        UsernamePasswordToken token = new UsernamePasswordToken(
                username,//身份信息
                password);//凭证信息
        ModelAndView modelAndView = new ModelAndView();
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated() && subject.isRemembered()) {
            modelAndView.setViewName("redirect:main");
            return modelAndView;
        }
        try {
            subject.login(token);
            // 单用户登录:匹配已存在会话进行清除
            Collection<Session> activeSessions = sessionDAO.getActiveSessions();
            for (Session session : activeSessions) {
                String oldUsername = (String) session.getAttribute("username");
                if (oldUsername != null && oldUsername.equals(username)) {
                    sessionDAO.delete(session);
                }
            }
        } catch (AuthenticationException e) {
            e.printStackTrace();
            modelAndView.addObject("responseMessage", "用户名或者密码错误");
            modelAndView.setViewName("redirect:index");
            return modelAndView;
        }
        // 设置会话属性
        Session session = subject.getSession();
        session.setAttribute("username", username);
        modelAndView.setViewName("redirect:main");
        return modelAndView;
    }
}

通过遍历去匹配会话的方式,在会话数量庞大的情况下,影响系统的整体性能。

解决该问题的另一种方法就是再定义一个usernameKey,里面存放会话信息,这样匹配的效率提高。示例代码如下:

public class RedisSessionDao extends CachingSessionDAO {
    private final static String usernameKey = "basic_";
    protected Serializable doCreate(Session session) {
        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        return sessionId;
    }

    protected Session doReadSession(Serializable sessionId) {
        return null;
    }

    protected void doUpdate(Session session) {
        if (session.getAttribute("username") != null){
            String username = (String) session.getAttribute("username");
            getActiveSessionsCache().put(usernameKey+username, session);
        }
    }

    protected void doDelete(Session session) {
        if (session.getAttribute("username") != null){
            String username = (String) session.getAttribute("username");
            getActiveSessionsCache().remove(usernameKey+username);
        }
    }

    public Session getByUsername(String username){
        String key = usernameKey+username;
        return getActiveSessionsCache().get(key);
    }
}

然后调用getByUsername()方法进行下线逻辑,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @Autowired
    private SessionDAO sessionDAO;
    @PostMapping("/login")
    public ModelAndView login(HttpServletRequest request, @RequestParam("username") String username
            , @RequestParam("password") String password) {
        // 提前加密,解决自定义缓存匹配时错误
        UsernamePasswordToken token = new UsernamePasswordToken(
                username,//身份信息
                password);//凭证信息
        ModelAndView modelAndView = new ModelAndView();
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated() && subject.isRemembered()) {
            modelAndView.setViewName("redirect:main");
            return modelAndView;
        }
        try {
            subject.login(token);
            // 单用户登录:匹配已存在会话进行清除
            Session activeSessions = sessionDAO.getByUsername(username);
            if (activeSessions != null) {
                sessionDAO.delete(activeSessions);
            }
        } catch (AuthenticationException e) {
            e.printStackTrace();
            modelAndView.addObject("responseMessage", "用户名或者密码错误");
            modelAndView.setViewName("redirect:index");
            return modelAndView;
        }
        modelAndView.setViewName("redirect:main");
        return modelAndView;
    }
}

问题排查

在整合Shiro过程会遇到很多问题,相关文章如下:

解决SpringBoot整合Shiro报Submitted credentials for token [org.apache.shiro.authc.UsernamePasswordToken…

解决SpringBoot整合Shiro报Submitted credentials for token org.apache.shiro.authc.UsernamePasswordToken…

解决Springboot整合Shiro自定义SessionDAO+Redis管理会话,登录后不跳转首页

解决Springboot整合Shiro自定义SessionDAO+Redis管理会话,登录后不跳转首页

解决Springboot整合Shiro+Redis退出登录后不清除缓存

解决Springboot整合Shiro+Redis退出登录后不清除缓存

项目示例

项目示例


http://www.kler.cn/a/465180.html

相关文章:

  • 《learn_the_architecture_-_aarch64_exception_model》学习笔记
  • Mysql SQL 超实用的7个日期算术运算实例(10k)
  • 数据看板如何提升决策效率?
  • [创业之路-232]:《华为闭环战略管理》-5-组织架构、业务架构、产品架构、技术架构、项目架构各自设计的原则是什么?
  • Python 数据结构揭秘:栈与队列
  • git 创建tag, 并推送到远程仓库,启动actions构建release自动发布
  • Springboot数据层开发 — 整合jdbcTemplate、mybatis
  • Word格式修改
  • Nginx知识详解(理论+实战更易懂)
  • PDF预览插件
  • 服务器数据恢复—离线盘数超过热备盘数导致raidz阵列崩溃的数据恢复
  • 【微软,模型规模】模型参数规模泄露:理解大型语言模型的参数量级
  • 基于MongoDB和PostgreSQL的百货公司进销管理系统
  • 李宏毅机器学习笔记-自注意力机制(self-attention)
  • HTML——57. type和name属性
  • QML学习(一) Qt Quick和QML介绍以及适用场景说明
  • linux最常用最新基础命令
  • vscode实用插件(持续更新)
  • QT集成IntelRealSense双目摄像头3,3D显示
  • 【gopher的java学习笔记】什么是po,vo
  • 南京市建邺区南苑街道一行莅临园区考察交流
  • 【Python3教程】Python3基础篇之List(列表)
  • [网络安全] DVWA之 Command Injection 攻击姿势及解题详析合集
  • C语言----分支语句
  • Python - 游戏:飞机大战;数字华容道
  • HTML——29. 音频引入二