和大家分享一个松哥原创的 Shiro 教程吧,还没写完,先整一部分,剩下的敬请期待。
1.Shiro简介
Apache Shiro是一个开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro框架具有直观、易用等特性,同时也能提供健壮的安全性,虽然它的功能不如SpringSecurity那么强大,但是在普通的项目中也够用了。
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的首要目标是易于使用和理解。安全有时候是很复杂的,甚至是痛苦的,但它没有必要这样。框架应该尽可能掩盖复杂的地方,露出一个干净而直观的API,来简化开发人员在应用程序安全上所花费的时间。
以下是你可以用Apache Shiro 所做的事情:
- 验证用户来核实他们的身份
- 对用户执行访问控制,如:判断用户是否被分配了一个确定的安全角色;判断用户是否被允许做某事
- 在任何环境下使用Session API,即使没有Web容器
- 在身份验证,访问控制期间或在会话的生命周期,对事件作出反应
- 聚集一个或多个用户安全数据的数据源,并作为一个单一的复合用户“视图”
- 单点登录(SSO)功能
为没有关联到登录的用户启用”Remember Me”服务
等等
Apache Shiro是一个拥有许多功能的综合性的程序安全框架。下面的图表展示了Shiro的重点:
Shiro中有四大基石——身份验证,授权,会话管理和加密。
- Authentication:有时也简称为“登录”,这是一个证明用户是谁的行为。
- Authorization:访问控制的过程,也就是决定“谁”去访问“什么”。
- Session Management:管理用户特定的会话,即使在非Web 或EJB 应用程序。
- Cryptography:通过使用加密算法保持数据安全同时易于使用。
除此之外,Shiro也提供了额外的功能来解决在不同环境下所面临的安全问题,尤其是以下这些:
- Web Support:Shiro的web支持的API能够轻松地帮助保护Web应用程序。
- Caching:缓存是Apache Shiro中的第一层公民,来确保安全操作快速而又高效。
- Concurrency:Apache Shiro利用它的并发特性来支持多线程应用程序。
- Testing:测试支持的存在来帮助你编写单元测试和集成测试。
- “Run As”:一个允许用户假设为另一个用户身份(如果允许)的功能,有时候在管理脚本很有用。
- “Remember Me”:在会话中记住用户的身份,这样用户只需要在强制登录时候登录。
2.从一个简单的案例开始身份认证
2.1 shiro下载
要学习shiro,我们首先需求去shiro官网下载shiro,官网地址地址https://shiro.apache.org/,截至本文写作时,shiro的最新稳定版本为1.7.1(Shiro 在 2017-2019 曾经停更了两年,我一度以为以为这个项目 gg 了),本文将采用这个版本。当然,shiro我们也可以从github上下载到源码。两个源码下载地址如下:
上面我主要是和小伙伴们介绍下源码的下载,并没有涉及到jar包的下载,jar包我们到时候直接使用maven即可。
2.2 创建演示工程
这里我们先不急着写代码,我们先打开刚刚下载到的源码,源码中有一个samples目录,如下:
这个samples目录是官方给我们的一些演示案例,其中有一个quickstart项目,这个项目是一个maven项目,参考这个quickstart,我们来创建一个自己的演示工程。
1.首先使用maven创建一个JavaSE工程
工程创建成功后在pom文件中添加如下依赖:
1 | <dependency> |
2.配置用户
参考quickstart项目中的shiro.ini文件,我们来配置一个用户,配置方式如下:首先在resources目录下创建一个shiro.ini文件,文件内容如下:
1 | [users] |
以上配置表示我们创建了一个名为sang的用户,该用户的密码是123,该用户的角色是admin,而admin具有操作所有资源的权限。
3.执行登录
OK,做完上面几步之后,我们就可以来看看如何实现一次简单的登录操作了。这个登录操作我们依然是参考quickstart项目中的类来实现,首先我们要通过shiro.ini创建一个SecurityManager,再将这个SecurityManager设置为单例模式,如下:
1 | Factory<org.apache.shiro.mgt.SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini"); |
如此之后,我们就配置好了一个基本的Shiro环境,注意此时的用户和角色信息我们配置在shiro.ini这个配置文件中,接下来我们就可以获取一个Subject了,这个Subject就是我们当前的用户对象,获取方式如下:
1 | Subject currentUser = SecurityUtils.getSubject(); |
拿到这个用户对象之后,接下来我们可以获取一个session了,这个session和我们web中的HttpSession的操作基本上是一致的,不同的是,这个session不依赖任何容器,可以随时随地获取,获取和操作方式如下:
1 | //获取session |
说了这么多,我们的用户到现在还没有登录呢,Subject中有一个isAuthenticated方法用来判断当前用户是否已经登录,如果isAuthenticated方法返回一个false,则表示当前用户未登录,那我们就可以执行登陆,登录方式如下:
1 | if (!currentUser.isAuthenticated()) { |
首先构造UsernamePasswordToken,两个参数就是我们的用户名和密码,然后调用Subject中的login方法执行登录,当用户名输错,密码输错、或者账户锁定等问题出现时,系统会通过抛异常告知调用者这些问题。
当登录成功之后,我们可以通过如下方式获取当前登陆用户的用户名:
1 | log.info("User [" + currentUser.getPrincipal() + "] logged in successfully."); |
我们也可以通过调用Subject中的hasRole和isPermitted方法来判断当前用户是否具备某种角色或者某种权限,如下:
1 | if (currentUser.hasRole("admin")) { |
最后,我们可以通过logout方法注销本次登录,如下:
1 | currentUser.logout(); |
OK,至此,我们通过官方案例给小伙伴们简单介绍了Shiro中的登录操作,完整案例大家可以参考官方的demo。
3. 聊一聊Shiro中的Realm
3.1 登录流程是什么样的
首先我们来看shiro官方文档中这样一张登录流程图:
参照此图,我们的登录一共要经过如下几个步骤:
- 应用程序代码调用Subject.login方法,传递创建好的包含终端用户的Principals(身份)和Credentials(凭证)的AuthenticationToken实例(即上文例子中的UsernamePasswordToken)。
- Subject实例,通常是DelegatingSubject(或子类)委托应用程序的SecurityManager通过调用securityManager.login(token)开始真正的验证工作(在DelegatingSubject类的login方法中打断点即可看到)。
- SubjectManager作为一个基本的“保护伞”的组成部分,接收token以及简单地委托给内部的Authenticator实例通过调用authenticator.authenticate(token)。这通常是一个ModularRealmAuthenticator实例,支持在身份验证中协调一个或多个Realm实例。ModularRealmAuthenticator本质上为Apache Shiro 提供了PAM-style 范式(其中在PAM 术语中每个Realm 都是一个’module’)。
- 如果应用程序中配置了一个以上的Realm,ModularRealmAuthenticator实例将利用配置好的AuthenticationStrategy来启动Multi-Realm认证尝试。在Realms 被身份验证调用之前,期间和以后,AuthenticationStrategy被调用使其能够对每个Realm的结果作出反应。如果只有一个单一的Realm 被配置,它将被直接调用,因为没有必要为一个单一Realm的应用使用AuthenticationStrategy。
- 每个配置的Realm用来帮助看它是否支持提交的AuthenticationToken。如果支持,那么支持Realm的getAuthenticationInfo方法将会伴随着提交的token被调用。
OK,通过上面的介绍,相信小伙伴们对整个登录流程都有一定的理解了,小伙伴可以通过打断点来验证我们上文所说的五个步骤。那么在上面的五个步骤中,小伙伴们看到了有一个Realm承担了很重要的一部分工作,那么这个Realm到底是个什么东西,接下来我们就来仔细看一看。
3.2 什么是Realm
根据Realm文档上的解释,Realms担当Shiro和你的应用程序的安全数据之间的“桥梁”或“连接器”。当它实际上与安全相关的数据如用来执行身份验证(登录)及授权(访问控制)的用户帐户交互时,Shiro从一个或多个为应用程序配置的Realm 中寻找许多这样的东西。在这个意义上说,Realm 本质上是一个特定安全的DAO:它封装了数据源的连接详细信息,使Shiro 所需的相关的数据可用。当配置Shiro 时,你必须指定至少一个Realm 用来进行身份验证和/或授权。SecurityManager可能配置多个Realms,但至少有一个是必须的。Shiro 提供了立即可用的Realms 来连接一些安全数据源(即目录),如LDAP,关系数据库(JDBC),文本配置源,像INI 及属性文件,以及更多。你可以插入你自己的Realm 实现来代表自定义的数据源,如果默认地Realm不符合你的需求。
看了上面这一段解释,可能还有小伙伴云里雾里,那么接下来我们来通过一个简单的案例来看看Realm到底扮演了一个什么样的作用,注意,本文的案例在上文案例的基础上完成。首先自定义一个MyRealm,内容如下:
1 | public class MyRealm implements Realm { |
自定义Realm实现Realm接口,该接口中有三个方法,第一个getName方法用来获取当前Realm的名字,第二个supports方法用来判断这个realm所支持的token,这里我假设值只支持UsernamePasswordToken类型的token,第三个getAuthenticationInfo方法则进行了登陆逻辑判断,从token中取出用户的用户名密码等,进行判断,当然,我这里省略掉了数据库操作,当登录验证出现问题时,抛异常即可,这里抛出的异常,将在执行登录那里捕获到(注意,由于我这里定义的MyRealm是实现了Realm接口,所以这里的用户名和密码都需要我手动判断是否正确,后面的文章我会介绍其他写法)。
OK,创建好了MyRealm之后还不够,我们还需要做一个简单配置,让MyRealm生效,将shiro.ini文件中的所有东西都注释掉,添加如下两行:
1 | MyRealm= org.sang.MyRealm |
第一行表示定义了一个realm,第二行将这个定义好的交给securityManger,这里实际上会调用到RealmSecurityManager类的setRealms方法。OK,做好这些之后,小伙伴们可以在MyRealm类中的一些关键节点打上断点,再次执行main方法,看看整个的登录流程。
4. 再来聊一聊Shiro中的Realm
4.1 Realm的继承关系
通过查看类的继承关系,我们发现Realm的子类实际上有很多种,这里我们就来看看有代表性的几种:
- IniRealm
可能我们并不知道,实际上这个类在我们第二篇文章中就已经用过了。这个类一开始就有如下两行定义:
1 | public static final String USERS_SECTION_NAME = "users"; |
这两行配置表示shiro.ini文件中,[users]下面的表示表用户名密码还有角色,[roles]下面的则是角色和权限的对应关系。
- PropertiesRealm
PropertiesRealm则规定了另外一种用户、角色定义方式,如下:
user.user1=password,role1
role.role1=permission1
- JdbcRealm
这个顾名思义,就是从数据库中查询用户的角色、权限等信息。打开JdbcRealm类,我们看到源码中有如下几行:
1 | protected static final String DEFAULT_AUTHENTICATION_QUERY = "select password from users where username = ?"; |
根据这几行预设的SQL我们就可以大致推断出数据库中表的名称以及字段了,当然,我们也可以自定义SQL。JdbcRealm实际上是AuthenticatingRealm的子类,关于AuthenticatingRealm我们在后面还会详细说到,这里先不展开。接下来我们就来详细说说这个JdbcRealm。
4.2 JdbcRealm
- 准备工作
使用JdbcRealm,涉及到数据库操作,要用到数据库连接池,这里我使用Druid数据库连接池,因此首先添加如下依赖:
1 | <dependency> |
- 数据库创建
想要使用JdbcRealm,那我首先要创建数据库,根据JdbcRealm中预设的SQL,我定义的数据库表结构如下:
这里为了大家能够直观的看到表的关系,我使用了外键,实际工作中,视情况而定。然后向表中添加几条测试数据。数据库脚本小伙伴可以在github上下载到(https://github.com/lenve/shiroSamples/blob/v4/shiroDemo.sql)。
- 配置文件处理
然后将shiro.ini中的所有配置注释掉,添加如下配置:
1 | jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm |
这里的配置文件都很简单,不做过多赘述,小伙伴唯一需要注意的是permissionsLookupEnabled需要设置为true,否则一会JdbcRealm就不会去查询权限用户权限。
- 测试
OK,做完上面几步就可以测试了,测试方式和第二篇文章中一样,我们可以测试下用户登录,用户角色和用户权限。
- 自定义查询SQL
小伙伴们看懂了上文,对于自定义查询SQL就没什么问题了。我这里举一个简单的例子,比如我要自定义authenticationQuery对对应的SQL,查看JdbcRealm源码,我们发现authenticationQuery对应的SQL本来是1
2
jdbcRealm.authenticationQuery=select password from employee where username = ?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
OK,这个小伙伴下来自己做尝试,我这里就不演示了。
## 5. Shiro中多Realm的认证策略问题
### 5.1 多Realm认证策略
不知道小伙伴们是否还记得这张登录流程图:
![p308](https://img-blog.csdnimg.cn/img_convert/f8b6568be71a8cb15f49f862101dd661.png)
从这张图中我们可以清晰看到Realm是可以有多个的,不过到目前为止,我们所有的案例都还是单Realm,那么我们先来看一个简单的多Realm情况。
前面的文章我们自己创建了一个MyRealm,也用过JdbcRealm,但都是单独使用的,现在我想将两个一起使用,只需要修改shiro.ini配置即可,如下:
```properties
MyRealm= org.sang.MyRealm
jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://localhost:3306/shiroDemo
dataSource.username=root
dataSource.password=123
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true
securityManager.realms=$jdbcRealm,$MyRealm
但是此时我数据库中用户的信息是sang/123,MyRealm中配置的信息也是sang/123,我把MyRealm中的用户信息修改为1
2
3
4
5
6
7
8
9
10
11
12
13
```java
public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String password = new String(((char[]) token.getCredentials()));
String username = token.getPrincipal().toString();
if (!"江南一点雨".equals(username)) {
throw new UnknownAccountException("用户不存在");
}
if (!"456".equals(password)) {
throw new IncorrectCredentialsException("密码不正确");
}
return new SimpleAuthenticationInfo(username, password, getName());
}
这个时候我们就配置了两个Realm,还是使用我们一开始的测试代码进行登录测试,这个时候我们发现我既可以使用1
2
3
4
5
6
7
8
9
10
11
### 5.2 原理追踪
好了,有了上面的问题后,接下来我们在Subject的login方法上打断点,跟随程序的执行步骤,我们来到了ModularRealmAuthenticator类的doMultiRealmAuthentication方法中,如下:
```java
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
this.assertRealmsConfigured();
Collection<Realm> realms = this.getRealms();
return realms.size() == 1?this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken):this.doMultiRealmAuthentication(realms, authenticationToken);
}
在这个方法中,首先会获取当前一共有多少个realm,如果只有一个则执行doSingleRealmAuthentication方法进行处理,如果有多个realm,则执行doMultiRealmAuthentication方法进行处理。doSingleRealmAuthentication方法部分源码如下:
1 | protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) { |
小伙伴们看到这里就明白了,这里调用了realm的getAuthenticationInfo方法,这个方法实际上就是我们自己实现的MyRealm中的getAuthenticationInfo方法。
那如果有多个Realm呢?我们来看看doMultiRealmAuthentication方法的实现,部分源码如下:
1 | protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) { |
我这里主要来说下这个方法的实现思路:
首先获取多Realm认证策略
构建一个AuthenticationInfo用来存放一会认证成功之后返回的信息
遍历Realm,调用每个Realm中的getAuthenticationInfo方法,看是否能够认证成功
每次获取到AuthenticationInfo之后,都调用afterAttempt方法进行结果合并
遍历完所有的Realm之后,调用afterAllAttempts进行结果合并,这里主要判断下是否一个都没匹配上
5.3 自由配置认证策略
OK,经过上面的简单解析,小伙伴们对认证策略应该有一个大致的认识了,那么在Shiro中,一共支持三种不同的认证策略,如下:
AllSuccessfulStrategy,这个表示所有的Realm都认证成功才算认证成功
AtLeastOneSuccessfulStrategy,这个表示只要有一个Realm认证成功就算认证成功,默认即此策略
FirstSuccessfulStrategy,这个表示只要第一个Realm认证成功,就算认证成功
配置方式也很简单,在shiro.ini中进行配置,在上面配置的基础上,增加如下配置:
1 | authenticator=org.apache.shiro.authc.pam.ModularRealmAuthenticator |
此时,我们再进行登录测试,则会要求每个Realm都认证通过才算认证通过。
6. Shiro中密码加密
6.1 密码为什么要加密
2011年12月21日,有人在网络上公开了一个包含600万个CSDN用户资料的数据库,数据全部为明文储存,包含用户名、密码以及注册邮箱。事件发生后CSDN在微博、官方网站等渠道发出了声明,解释说此数据库系2009年备份所用,因不明原因泄露,已经向警方报案。后又在官网网站发出了公开道歉信。在接下来的十多天里,金山、网易、京东、当当、新浪等多家公司被卷入到这次事件中。整个事件中最触目惊心的莫过于CSDN把用户密码明文存储,由于很多用户是多个网站共用一个密码,因此一个网站密码泄露就会造成很大的安全隐患。由于有了这么多前车之鉴,我们现在做系统时,密码都要加密处理。
密码加密我们一般会用到散列函数,又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。我们常用的散列函数有如下几种:
- MD5消息摘要算法
MD5消息摘要算法是一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值,用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特设计,于1992年公开,用以取代MD4算法。这套算法的程序在 RFC 1321中被加以规范。将数据(如一段文字)运算变为另一固定长度值,是散列算法的基础原理。1996年后被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞,因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。
- 安全散列算法
安全散列算法(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。
6.2 Shiro中如何加密
Shiro中对以上两种散列算法都提供了支持,对于MD5,Shiro中生成消息摘要的方式如下:
1 | Md5Hash md5Hash = new Md5Hash("123", null, 1024); |
第一个参数是要生成密码的明文,第二个参数密码的盐值,第三个参数是生成消息摘要的迭代次数。
Shiro中对于安全散列算法的支持如下(支持多种算法,这里我举一个例子):
1 | Sha512Hash sha512Hash = new Sha512Hash("123", null, 1024); |
这里三个参数含义与上文基本一致,不再赘述。shiro中也提供了通用的算法,如下:
1 | SimpleHash md5 = new SimpleHash("md5", "123", null, 1024); |
当用户注册时,我们可以通过上面的方式对密码进行加密,将加密后的字符串存入数据库中。我这里为了简单,就不写注册功能了,就把昨天数据库中用户的密码123改成sha512所对应的字符串,如下:
1 | cb5143cfcf5791478e057be9689d2360005b3aac951f947af1e6e71e3661bf95a7d14183dadfb0967bd6338eb4eb2689e9c227761e1640e6a033b8725fabc783 |
同时,为了避免其他Realm的干扰,数据库中我只配置一个JdbcRealm。
此时如果我不做其他修改的话,登录必然会失败,原因很简单:我登录时输入的密码是123,但是数据库中的密码是一个很长的字符串,所以登录肯定不会成功。通过打断点,我们发现最终的密码比对是在SimpleCredentialsMatcher类中的doCredentialsMatch方法中进行密码比对的,比对的方式也很简单,直接使用了对用户输入的密码和数据库中的密码生成byte数组然后进行比较,最终的比较在MessageDigest类的isEqual方法中。部分逻辑如下:
1 | protected boolean equals(Object tokenCredentials, Object accountCredentials) { |
MessageDigest的isEqual方法如下:
1 | public static boolean isEqual(byte[] digesta, byte[] digestb) { |
都是很容易理解的比较代码,这里不赘述。我们现在之所以登录失败是因为没有对用户输入的密码进行加密,通过对源代码的分析,我们发现是因为在AuthenticatingRealm类的assertCredentialsMatch方法中获取了一个名为SimpleCredentialsMatcher的密码比对器,这个密码比对器中比对的方法就是简单的比较,因此如果我们能够将这个密码比对器换掉就好了。我们来看一下CredentialsMatcher的继承关系:
我们发现这个刚好有一个Sha512CredentialsMatcher比对器,这个比对器的doCredentialsMatch方法在它的父类HashedCredentialsMatcher,方法内容如下:
1 | public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { |
这时我们发现获取tokenHashedCredentials的方式不像以前那样简单粗暴了,而是调用了hashProvidedCredentials方法,而hashProvidedCredentials方法最终会来到下面这个重载方法中:
1 | protected Hash hashProvidedCredentials(Object credentials, Object salt, int hashIterations) { |
这几行代码似曾相识,很明显,是系统帮我们对用户输入的密码进行了转换。了解了这些之后,那我只需要将shiro.ini修改成如下样子即可实现登录了:
1 | sha512=org.apache.shiro.authc.credential.Sha512CredentialsMatcher |
如此之后,我们再进行登录测试,就可以登录成功了。
本小节案例下载:https://github.com/lenve/shiroSamples/archive/refs/tags/v6.zip
7. Shiro中密码加盐
7.1 密码为什么要加盐
不管是消息摘要算法还是安全散列算法,如果原文一样,生成密文也是一样的,这样的话,如果两个用户的密码原文一样,存到数据库中密文也就一样了,还是不安全,我们需要做进一步处理,常见解决方案就是加盐。盐从那里来呢?我们可以使用用户id(因为一般情况下,用户id是唯一的),也可以使用一个随机字符,我这里采用第一种方案。
7.2 Shiro中如何实现加盐
shiro中加盐的方式很简单,在用户注册时生成密码密文时,就要加入盐,如下几种方式:
1 | Md5Hash md5Hash = new Md5Hash("123", "sang", 1024); |
然后我们首先将sha512生成的字符串放入数据库中,接下来我要配置一下我的jdbcRealm,因为我要指定我的盐是什么。在这里我的盐就是我的用户名,每个用户的用户名是不一样的,因此这里没法写死,在JdbcRealm中,系统提供了四种不同的SaltStyle,如下:
SaltStyle | 含义 |
---|---|
NO_SALT | 默认,密码不加盐 |
CRYPT | 密码是以Unix加密方式储存的 |
COLUMN | salt是单独的一列储存在数据库中 |
EXTERNAL | salt没有储存在数据库中,需要通过JdbcRealm.getSaltForUser(String)函数获取 |
四种不同的SaltStyle对应了四种不同的密码处理方式,部分源码如下:
1 | switch (saltStyle) { |
在COLUMN这种情况下,SQL查询结果应该包含两列,第一列是密码,第二列是盐,这里默认执行的SQL在JdbcRealm一开头就定义好了,如下:
1 | protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY = "select password, password_salt from users where username = ?"; |
即系统默认的盐是数据表中的password_salt提供的,但是我这里是username字段提供的,所以这里我一会要自定义这条SQL。自定义方式很简单,修改shiro.ini文件,添加如下两行:
1 | jdbcRealm.saltStyle=COLUMN |
首先设置saltStyle为COLUMN,然后重新定义authenticationQuery对应的SQL。注意返回列的顺序很重要,不能随意调整。如此之后,系统就会自动把username字段作为盐了。
不过,由于ini文件中不支持枚举,saltStyle的值实际上是一个枚举类型,所以我们在测试的时候,需要增加一个枚举转换器在我们的main方法中,如下:
1 | BeanUtilsBean.getInstance().getConvertUtils().register(new AbstractConverter() { |
当然,以后当我们将shiro和web项目整合之后,就不需要这个转换器了。
如此之后,我们就可以再次进行登录测试了,会发现没什么问题了。
7.3 非JdbcRealm如何配置盐
OK,刚刚是在JdbcRealm中配置了盐,如果没用JdbcRealm,而是自己定义的普通Realm,要怎么解决配置盐的问题?
首先要说明一点是,我们前面的文章在自定义Realm时都是通过实现Realm接口实现的,这种方式有一个缺陷,就是密码比对需要我们自己完成,一般在项目中,我们自定义Realm都是通过继承AuthenticatingRealm或者AuthorizingRealm,因为这两个方法中都重写了getAuthenticationInfo方法,而在getAuthenticationInfo方法中,调用doGetAuthenticationInfo方法获取登录用户,获取到之后,会调用assertCredentialsMatch方法进行密码比对,而我们直接实现Realm接口则没有这一步,部分源码如下:
1 | public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { |
基于上面所述的原因,这里我先继承AuthenticatingRealm,如下:
1 | public class MyRealm extends AuthenticatingRealm { |
关于这个类,我说如下几点:
用户名我这里还是手动判断了下,实际上这个地方要从数据库查询用户信息,如果查不到用户信息,则直接抛UnknownAccountException
返回的SimpleAuthenticationInfo中,第二个参数是密码,正常情况下,这个密码是从数据库中查询出来的,我这里直接写死了
第三个参数是盐值,这样构造好SimpleAuthenticationInfo之后返回,shiro会去判断用户输入的密码是否正确
上面的核心步骤是第三步,系统去自动比较密码输入是否正确,在比对的过程中,需要首先对用户输入的密码进行加盐加密,既然加盐加密,就会涉及到credentialsMatcher,这里我们要用的credentialsMatcher实际上和在JdbcRealm中用的credentialsMatcher一样,只需要在配置文件中增加如下一行即可:
1 | MyRealm.credentialsMatcher=$sha512 |
sha512和我们上文定义的一致,这里就不再重复说了。
本小节案例下载:https://github.com/lenve/shiroSamples/archive/refs/tags/v7.zip
8. Shiro中自定义带角色和权限的Realm
密码加密加盐小伙伴们应该没有问题了,但是前面几篇文章又给我们带来了一个新的问题:我们前面IniRealm、JdbcRealm以及自定义的MyRealm,其中前两个我们都能实现用户认证以及授权,即既能管理用户登录,又能管理用户角色,而我们自定义的MyRealm,目前还只能实现登录,不能实现授权,本文我们就来看看自定义Realm如何实现授权。
8.1 问题追踪
上篇文章我们没有实现自定义Realm的授权操作,但是这个并不影响我们调用hasRole方法去获取用户的权限,我在上文测试代码上的currentUser.hasRole上面打断点,通过层层追踪,我们发现最终来到了ModularRealmAuthorizer类的hasRole方法中,部分源码如下:
1 | public boolean hasRole(PrincipalCollection principals, String roleIdentifier) { |
我们看到在这里会遍历所有的realm,如果这个realm是Authorizer的实例,则会进行进一步的授权操作,如果不是Authorizer的实例,则直接跳过,而我们只有一个自定义的MyRealm继承自AuthenticatingRealm,很明显不是Authorizer的实例,所以这里必然返回false,授权失败,所以要解决授权问题,第一步,得先让我们的MyRealm成为Authorizer的实例。
8.2 解决方案
如下图是Authorizer的继承关系:
小伙伴们看到,在Authorizer的实现类中有一个AuthorizingRealm,打开这个类,我们发现它的继承关系如下:
1 | public abstract class AuthorizingRealm extends AuthenticatingRealm |
我们发现,这个AuthorizingRealm不仅是Authorizer的实现类,同时也是我们上文所用的AuthenticatingRealm的实现类,既然AuthorizingRealm同时是这两个类的实现类,那么我把MyRealm的继承关系由AuthenticatingRealm改为AuthorizingRealm,肯定不会影响我上文的功能,修改之后的MyRealm如下(部分关键代码):
1 | public class MyRealm extends AuthorizingRealm { |
继承了AuthorizingRealm之后,需要我们实现doGetAuthorizationInfo方法。在这个方法中,我们配置用户的权限。这里我为了方便,直接添加了普通用户这个权限,实际上,这里应该根据用户名去数据库里查询权限,查询方式不赘述。
通过源码追踪,我们发现最终授权会来到AuthorizingRealm类的如下两个方法中:
1 | public boolean hasRole(PrincipalCollection principal, String roleIdentifier) { |
这两个方法的逻辑很简单,第一个方法中调用的getAuthorizationInfo方法会最终调用到我们自定义的doGetAuthorizationInfo方法,第二个hasRole方法接收的两个参数,第一个是用户申请的角色,第二个是用户具备的角色集,一个简单的contains函数就判断出用户是否具备某个角色了。
但是这个时候,用户只有角色,没有权限,我们可以对doGetAuthorizationInfo方法做进一步的完善,如下:
1 | protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { |
当然,正常情况下,权限也应当是从数据库中查询得到的,我这里简化下。
那么这个角色是怎么验证的呢?追踪源码我们来到了AuthorizingRealm类的如下两个方法中:
1 | public boolean isPermitted(PrincipalCollection principals, Permission permission) { |
第一个isPermitted方法中调用了getAuthorizationInfo方法,而getAuthorizationInfo方法最终会调用到我们自己定义的doGetAuthorizationInfo方法,即获取到用户的角色权限信息,然后在第二个方法中进行遍历判断,查看是否具备相应的权限,第二个isPermitted方法的第一个参数就是用户要申请的权限。
本小节案例下载:https://github.com/lenve/shiroSamples/archive/refs/tags/v8.zip
9. Shiro整合Spring
9.1 Spring&SpringMVC环境搭建
Spring和SpringMVC环境的搭建,整体上来说,还是比较容易的,因为这个不是本文的重点,因此这里我不做详细介绍,小伙伴可以在文末下载源码查看Spring+SpringMVC环境的搭建。同时,由于MyBatis的整合相对要容易很多,这里为了降低项目复杂度,我也就先不引入MyBatis。
对于项目依赖,除了Spring、SpringMVC、Shiro相关的依赖,还需要加入Shiro和Spring整合的jar,如下:
1 | <dependency> |
9.2 整合Shiro
搭建好Spring+SpringMVC环境之后,整合Shiro我们主要配置两个地方:
- web.xml中配置代理过滤器,如下:
1 | <filter> |
这样之后,当DelegatingFilterProxy拦截到所有请求之后,都会委托给shiroFilter来处理,shiroFilter是我们第二步在Spring容器中配置的一个实例。
- 配置Spring容器
在Spring容器中至少有两个Bean需要我们配置,一个就是第一步中的shiroFilter,还有一个就是SecurityManager,完整配置如下:
1 | <bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager"> |
这是一个非常简单的配置,我们在以后的文章中还会继续完善它,关于这个配置我说如下几点:
首先我们需要配置一个securityManager,到时候我们的realm要配置在这里。
还要配置一个名为shiroFilter的bean,这个名字要和web.xml中代理过滤器的名字一致。
shiroFilter中,loginUrl表示登录页面地址。
successUrl表示登录成功地址。
unauthorizedUrl表示授权失败地址。
filterChainDefinitions中配置的
1
2
3
4
7. authc实际上是一个过滤器,这个我们在后文还会再详细说到。
8. 匹配符遵循Ant风格路径表达式,这里可以配置多个,匹配顺序从上往下匹配到了就不再匹配了。比如下面这个写法:
/a/b/*=anon
/a/**=authc1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
假设我的路径是/a/b/c那么就会匹配到第一个过滤器anon,而不会匹配到authc,所以这里的顺序很重要。
OK,这些配置写完后,在webpap目录下创建对应的jsp文件,如下:
![p312](https://img-blog.csdnimg.cn/img_convert/881d145a378233fac5eb1fc00c6fe8a4.png)
此时,启动项目去浏览器中访问,无论我们访问什么地址,最后都会回到login.jsp页面,因为所有的页面(即使不存在的地址)都需要认证后才可以访问。
本小节案例:https://github.com/lenve/shiroSamples/archive/refs/tags/v9.zip
## 10. Shiro处理登录的三种方式
### 10.1 准备工作
很明显,不管是那种登录,都离不开数据库,这里数据库我采用我们前面的数据库,这里不做赘述(文末可以下载数据库脚本),但是我这里需要首先配置JdbcRealm,在applicationContext.xml中首先配置数据源,如下:
```xml
<context:property-placeholder location="classpath:db.properties"/>
<bean class="com.alibaba.druid.pool.DruidDataSource" id="dataSource">
<property name="username" value="${db.username}"/>
<property name="password" value="${db.password}"/>
<property name="url" value="${db.url}"/>
</bean>
有了数据源之后,接下来配置JdbcRealm,如下:
1 | <bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm"> |
JdbcRealm中这几个属性和我们本系列第七篇文章基本是一致的,首先我们配置了密码比对器为HashedCredentialsMatcher,相应的算法为sha512,密码加密迭代次数为1024次,然后我们配置了密码的盐从数据表的列中来,username列就是我们的盐,这些配置和前文都是一致的,不清楚的小伙伴可以参考我们本系列第七篇文章。
10.2 自定义登录逻辑
自定义登录逻辑比较简单,首先我们把login.jsp页面进行简单改造:
1 | <form action="/login" method="post"> |
然后创建我们的登录处理Controller,如下:
1 | @PostMapping("/login") |
登录成功我们就去success页面,登录失败就回到登录页面。做完这两步之后,我们还要修改shiroFilter中的filterChainDefinitions属性,要设置1
2
3
4
5
6
7
8
9
10
11
12
13
14
```java
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
<property name="securityManager" ref="securityManager"/>
<property name="loginUrl" value="/login.jsp"></property>
<property name="successUrl" value="/success.jsp"/>
<property name="unauthorizedUrl" value="/unauthorized.jsp"/>
<property name="filterChainDefinitions">
<value>
/login=anon
/**=authc
</value>
</property>
</bean>
做完这些之后,就可以去login.jsp页面测试登录了。
上面中方式是我们自己写登录逻辑,shiro也给我们提供了两种不用自己写登录逻辑的登录方式,请继续往下看。
10.3 基于HTTP的认证
shiro中也提供了基于http协议的认证,当然,这种认证也得有数据库的辅助,数据配置和前文一样,我们只需要修改一个配置即可,如下:
1 | <bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter"> |
这个表示所有的页面都要经过基于http的认证。此时我们打开任意一个页面,认证方式如下:
10.4 表单登录
表单登录和基于HTTP的登录类似,都是不需要我们自己写登录逻辑的登录,但是出错的逻辑还是要稍微处理下,首先修改shiroFilter:
1 | <bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter"> |
配置登录页面,也配置登录成功后的跳转页面,同时设置所有页面都要登录后才能访问。
配置登录页面请求,如下:
1 | @RequestMapping("/login") |
如果登录失败,那么在request中会有一个shiroLoginFailure的属性中保存了登录失败的异常类名,通过判断这个类名,我们就可以知道是什么原因导致了登录失败。
OK,配置好这两步之后,就可以去登录页面测试了。
10.5 注销登录
注销登录比较简单,就一个过滤器,按如下方式配置:
1 | <property name="filterChainDefinitions"> |
通过get请求访问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
本小节有三个案例,下载地址如下:
- https://github.com/lenve/shiroSamples/archive/refs/tags/v10.1.zip
- https://github.com/lenve/shiroSamples/archive/refs/tags/v10.2.zip
- https://github.com/lenve/shiroSamples/archive/refs/tags/v10.3.zip
## 11. Shiro中的授权问题
### 11.1 配置角色
本文的案例在上文的基础上完成,因此Realm这一块我依然采用JdbcRealm,相关的授权就不必配置了。但是这里的数据库脚本有更新,小伙伴需要下载重新执行(https://github.com/lenve/shiroSamples/blob/v11/shiroDemo.sql)。
先来介绍下目前数据库中用户的情况,数据库中有两个用户,sang具有admin的角色,同时具有```book:*```和```author:create```两个权限,lisi具有user的角色,同时具有```user:info```和```user:delete```两个权限。修改shiroFilter,如下:
```xml
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
<property name="securityManager" ref="securityManager"/>
<property name="loginUrl" value="/login"/>
<property name="successUrl" value="/success.jsp"/>
<property name="unauthorizedUrl" value="/unauthorized.jsp"/>
<property name="filterChainDefinitions">
<value>
/admin.jsp=authc,roles[admin]
/user.jsp=authc,roles[user]
/logout=logout
/**=authc
</value>
</property>
</bean>
关于这里的配置,我说如下几点:
- unauthorizedUrl表示授权失败时展示的页面
- filterChainDefinitions中我们配置了admin.jsp页面必须登录后才能访问,同时登录的用户必须具有admin角色,user.jsp也是必须登录后才能访问,同时登录的用户必须具有user角色
11.2 测试
测试时我们分别用sang/123和lisi/123进行登录,登录成功后分别访问user.jsp和admin.jsp就能看到效果。
11.3 配置权限
上面的方式是配置角色,但是还没有配置权限,要配置权限,首先要在jdbcRealm中添加允许权限信息的查询:
1 | <property name="permissionsLookupEnabled" value="true"/> |
然后配置下shiroFilter:
1 | <property name="filterChainDefinitions"> |
这里假设访问userinfo.jsp需要user:info权限,访问bookinfo.jsp需要book:info权限。
OK,做完这些之后就可以测试了,分别用sang/123和lisi/123进行登录,登录成功后分别访问bookinfo.jsp和userinfo.jsp就可以看到不同效果了。
本小节案例下载:https://github.com/lenve/shiroSamples/archive/refs/tags/v11.zip
12. Shiro中的JSP标签
12.1 缘起
上篇文章中,我们在success.jsp中写了很多像下面这种超链接:
1 | <h1>登录成功!</h1> |
但是对于不同身份的用户,并不是每一个链接都是有效的,点击无效的链接会进入到未授权的页面,这样用户体验并不好,最好能够把不可达的链接自动隐藏起来,同时,我也希望能够方便获取当前登录用户的信息等,考虑到这些需求,我们来聊聊shiro中的jsp标签。
12.2 标签介绍
shiro中的标签并不多,主要有如下几种:
- shiro:guest
shiro:guest标签只有在当前未登录时显示里边的内容,如下:
1 | <shiro:guest> |
- shiro:user
shiro:user是在用户登录之后显示该标签中的内容,无论是通过正常的登录还是通过Remember Me登录,如下:
1 | <shiro:user> |
- shiro:principal
shiro:principal用来获取当前登录用户的信息,显示效果如下:
4.shiro:authenticated
和shiro:user相比,shiro:authenticated的范围变小,当用户认证成功且不是通过Remember Me认证成功,这个标签中的内容才会显示出来:
1 | <shiro:authenticated> |
- shiro:notAuthenticated
shiro:notAuthenticated也是在用户未认证的情况下显示内容,和shiro:guest不同的是,对于通过Remember Me方式进行的认证,shiro:guest不会显示内容,而shiro:notAuthenticated会显示内容(因为此时并不是游客,但是又确实未认证),如下:
1 | <shiro:notAuthenticated> |
- shiro:lacksRole
当用户不具备某个角色时候,显示内容,如下:
1 | <shiro:lacksRole name="admin"> |
- shiro:lacksPermission
当用户不具备某个权限时显示内容:
1 | <shiro:lacksPermission name="book:info"> |
- shiro:hasRole
当用户具备某个角色时显示的内容:
1 | <shiro:hasRole name="admin"> |
- shiro:hasAnyRoles
当用户具备多个角色中的某一个时显示的内容:
1 | <shiro:hasAnyRoles name="user,aaa"> |
- shiro:hasPermission
当用户具备某一个权限时显示的内容:
1 | <shiro:hasPermission name="book:info"> |
本小节案例下载:https://github.com/lenve/shiroSamples/archive/refs/tags/v12.zip
13.Shiro 中的缓存机制
13.1 添加依赖
使用缓存,首先需要添加相关依赖,如下:
1 | <dependency> |
13.2 添加配置文件
ehcache的配置文件主要参考官方的配置,在resources目录下创建ehcache.xml文件,内容如下:
1 | <ehcache> |
这些都是ehcache缓存中常规的配置,含义我就不一一解释了,文末下载源码有注释。
13.3 缓存配置
接下来我们只需要在applicationContext中简单配置下缓存即可,配置方式如下:
1 | <bean class="org.apache.shiro.cache.ehcache.EhCacheManager" id="cacheManager"> |
首先配置EhCacheManager类,指定缓存位置,然后在DefaultWebSecurityManager中引入cacheManager即可,如此之后,我们的缓存就应用上了。
13.4 测试
由于我这里使用了JdbcRealm,如果使用了自定义Realm那么可以通过打日志看是否使用了缓存,使用了JdbcRealm之后,我们可以通过打断点来查看是否应用了缓存,比如我执行如下代码:
1 | subject.checkRole("admin"); |
通过断点跟踪,发现最终会来到AuthorizingRealm的getAuthorizationInfo方法中,在该方法中,首先会去缓存中检查数据,如果缓存中有数据,则不会执行doGetAuthorizationInfo方法(数据库操作就在doGetAuthorizationInfo方法中进行),如果缓存中没有数据,则会执行doGetAuthorizationInfo方法,并且在执行成功后将数据保存到缓存中(前提是配置了缓存,cache不为null),此时我们通过断点,发现执行了缓存而没有查询数据库中的数据,部分源码如下:
1 | protected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) { |
OK,整体来说shiro中的缓存配置还是非常简单的。
That’s all.
本小节案例下载地址:https://github.com/lenve/shiroSamples/archive/v13.zip
待续。。。