1.简介
Shiro 的架构图:
各种客户端通过 Subject 与 Shiro 的核心组件 SecurityManager 交互,SecurityManager 包含以下几部分:
-
Authenticator,负责身份认证
-
Authorize,负责授权
-
Session Manager,管理 Session
-
SessionDAO,对 Session 进行持久化,可以使用 Redis、数据库等
-
Cache Manager,缓存管理
-
Realm,领域,负责从数据库查询数据以实现身份认证或授权
-
Cryptography,密码系统,用于对密码加密等
2.快速开始
新建一个 Maven 项目,选择使用骨架创建:
因为网络的原因,从中央仓库下载骨架目录很可能会失败,阿里云 Maven 镜像上好像骨架目录是缺失的,如果生成框架目录失败,可以尝试用 maven 命令行来构建骨架(需要科学上网),具体可以参考中用插件创建工程一节。
默认生成的框架中存在一个 JUnit 依赖,但是版本太老无法使用@Test
注解,更换为比较新的版本:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
我们还需要添加 Shiro 的依赖:
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.3.2</version>
</dependency>
以及日志:
<!-- 添加slf4j日志api -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.20</version>
</dependency>
<!-- 添加logback-classic依赖 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<!-- 添加logback-core依赖 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.1.3</version>
</dependency>
还需要在/resources
目录下为 logback 添加一个配置文件 。
添加 Maven 编译插件:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
这个插件的主要用途是指定项目使用的 Java 版本和编码,方便 IDE 进行语法检查。
在/resources
目录下添加一个 Shiro 配置文件 shiro.ini:
#声明用户账号 [users] jay=123
编写一个简单的登录模块:
public class ShiroApp {
private Subject subject;
public ShiroApp() {
// 从配置文件加载 SecurityManager
Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();
// 将 SecurityManager 关联到 SecurityUtils
SecurityUtils.setSecurityManager(securityManager);
// 获取 subject
this.subject = SecurityUtils.getSubject();
}
public void login(String userName, String password){
// 认证方式可以是多种多样,这里是采用用户密码进行身份认证
AuthenticationToken token = new UsernamePasswordToken(userName, password);
try {
// 使用 subject 进行登录
this.subject.login(token);
System.out.println(String.format("登录成功,欢迎回来:%s", userName));
}
catch (AuthenticationException e){
// 登录失败,抛出异常
System.out.println("登录失败:"+e.getLocalizedMessage());
throw e;
}
}
}
测试用例:
public class ShiroAppTests {
private ShiroApp shiroApp = new ShiroApp();
public void testWrongLogin() {
shiroApp.login("jay", "1234");
}
public void testRightLogin() {
shiroApp.login("zhangsan", "123");
}
}
上边这个简单示例,用户的认证信息(用户名和密码)保存在配置文件中,用相关 API 可以加载认证信息,并与客户端程序传递的用户名和密码进行比较,就可以知道身份是否正确,能否进行登录。
3.Realm
一般用户的认证信息(用户名和密码)是保存在数据库中的,如果要实现从数据库中查找认证信息并完成认证,需要实现Realm
接口,该接口有多个继承层次:
要同时实现认证和授权逻辑,需要至少继承AuthorizingRealm
这个抽象类。
这里我实现了一个从数据库里查询用户密码,作为认证信息的 Realm:
public class MyRealm extends AuthorizingRealm {
/**
* 获取授权信息
*
* @param principals
* @return
*/
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return null;
}
/**
* 获取认证信息
*
* @param token
* @return
* @throws AuthenticationException
*/
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 从 token 中获取用户名
UsernamePasswordToken upt = (UsernamePasswordToken) token;
String userName = upt.getUsername();
// 从数据库查询用户密码
SqlSession sqlSession = MyBatisUtil.createSqlSession();
UserService userService = new UserServiceImpl(sqlSession);
String correctPassword = userService.getPasswordByUserName(userName);
if (correctPassword == null || "".equals(correctPassword)) {
throw new UnknownAccountException(String.format("没有名称为%s的用户", userName));
}
// 认证通过,返回认证信息
sqlSession.close();
return new SimpleAuthenticationInfo(userName, correctPassword, getName());
}
}
示例中的持久层是用 实现的,完整内容可以查看文末的完整示例。
需要注意的是,Realm 只需要根据传入 Token 中的用户标识(这里是用户名)从持久层(这里是数据库)获取对应的登录信息(这里是用户名+密码),并不需要进行认证工作。具体的认证工作(密码比对)由调用 Realm 的 SecurityManager 的 Authenticator 模块执行。
关于 Shiro 登录流程的源码分析,可以观看这个。
实现了 Realm 后还需要告诉 SecurityManager 如何加载这个 Realm,我们这里的 SecurityManager 初始化是依赖于 ini 配置文件的,所以修改 ini 配置文件:
[main]
definitionRealm=cn.icexmoon.shirodemo.realm.MyRealm
securityManager.realms=$definitionRealm
#声明用户账号
;[users]
;jay=123
在[main]
中向 SecurityManager 注册了 Realm。现在已经实现了从数据库查询用户认证信息,所以配置文件中的帐号信息可以注释或删除掉。
4.编码与解码
Shiro 的 jar 包中有一组关于编码与解码的工具类:
利用这些内建的编解码器,可以确保 Shiro 编码和解码的一致性。
4.1.Hex
Hex 就是十六进制编码,也就是用两个16 进制数(0~f)表示 8 位二进制数(一个字节)。
一个十六进制数可以表示4位二进制数(2的4次方)。
可以用一个简单的测试用例体现这一过程:
public class EncodeTests {
@Test
public void testEncodeHex() {
System.out.println("dec\tbinary\t\thex");
System.out.println("------------------");
for (byte a = 1; a > 0; a += 10) {
String dec = Byte.valueOf(a).toString();
String binary = toBinaryStr(a);
String hex = toHexStr(a);
System.out.println(String.format("%s\t%s\t%s", dec, binary, hex));
}
}
private static String toBinaryStr(byte b) {
return Integer.toBinaryString((b & 0xFF) + 0x100).substring(1);
}
private static String toHexStr(byte b) {
return Hex.encodeToString(new byte[]{b});
}
}
输出:
dec binary hex ------------------ 1 00000001 01 11 00001011 0b 21 00010101 15 31 00011111 1f 41 00101001 29 51 00110011 33 61 00111101 3d 71 01000111 47 81 01010001 51 91 01011011 5b 101 01100101 65 111 01101111 6f 121 01111001 79
当然,对于一般性字符内容来说,十六进制编码后没有任何可读性,因为其原始的二进制存储本身就因为字符集的不同有着不同的编码方式,没有什么可读性。
但是我们可以将十六进制编码作为一种“中间编码”,比如将某个字符串先转换为字节数组,再利用十六进制编码为字符串(char 数组),然后可以重新解码回字节数组,并重新转换为字符串。
可以用一个简单测试用例进行验证:
@Test
public void testEncodeHex2() throws UnsupportedEncodingException {
String msg = "你好";
char[] encoded = Hex.encode(msg.getBytes(StandardCharsets.UTF_8));
byte[] decoded = Hex.decode(encoded);
String strAfterHex = new String(decoded, StandardCharsets.UTF_8);
Assert.assertEquals(msg, strAfterHex);
}
4.2.Base64
在 Web 编程中,如果需要浏览器用 url 参数传输中文,你就需要使用 Base64 对参数值进行编码。因为 HTTP 协议中,url 部分是仅支持 ASCII 字符集的,使用 Base64 可以将 UTF-8 字符集中不兼容 ASCII 的字符(通常是中文)编码成 ASCII 字符组成的字符串。
看一个简单示例:
public class Base64Tests {
@Test
public void testBase64(){
String msg = "你好";
String encoded = Base64.encodeToString(msg.getBytes(StandardCharsets.UTF_8));
System.out.println(encoded);
}
}
输出:
5L2g5aW9
当然,所有的编码方式都需要确保编码后的内容可以解码回“原文”。
有多个 jar 包都实现了 Base64,我们这里讨论的是 Shiro 自带的
org.apache.shiro.codec.Base64
。
用一个测试用例验证:
@Test
public void testEncodeAndDecode(){
String msg = "你好";
String encoded = Base64.encodeToString(msg.getBytes(StandardCharsets.UTF_8));
byte[] decoded = Base64.decode(encoded);
String strAfterBase64 = new String(decoded, StandardCharsets.UTF_8);
Assert.assertEquals(msg, strAfterBase64);
}
5.散列算法
通常我们不会直接将用户的明文密码存储到数据库,那样存在密码泄露的风险。通常会使用散列算法对明文密码进行加密,数据库中存放的是加密后的密码。
散列算法是不可逆加密,即无法通过散列后的内容推导出原始明文密码,只能用于内容一致性验证。
散列算法有很多种,常见的有 MD5 和 SHA1 等。
Shiro 包含常见的散列算法实现:
5.1.SHA1
看一个简单例子:
public class ShaTests {
@Test
public void testSha1(){
final String PASSWROD = "12345";
final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD).toString();
System.out.println(HASHED_PWD);
}
}
这使用 Shiro 提供的 SHA1 散列算法对密码进行加密,加密后的密码内容:
8cb2237d0679ca88db6464eac60da96345513964
和原文完全没有关系,这就可以很好的保护明文密码。但问题在于像 12345 或 password 这种很长见的密码,攻击者会提前收录进一个密码本,然后用常见的散列算法依次对密码本中的密码进行加密,获取到一个常见密码加密后的密码本,然后就可以用这个密码本对照这里加密后的密码进行暴力破解,理论上你的密码越简单,就越容易被这种攻击得逞。
5.2.Salt
应对这种攻击的一个解决方案是,在生成散列值的时候添加一个盐(salt)作为“随机变量”,让产生的散列值不那么具备“常见性”。此时只要攻击者不知道我们的盐是什么值,就无法生成和我们一样的加密后密码,也就无从得知对应的明文密码是什么,即使我们使用的是很简单的明文密码。
但这样并不代表攻击者无法破解系统。因为即使加密体系没有漏洞,只要你使用的是简单密码,攻击者也可以通过对登录接口进行暴力破解的方式依次尝试来成功登录。
下面是使用 salt 对密码进行加密的示例:
@Test
public void testSha2(){
final String PASSWROD = "12345";
String salt = "cn.icexmoon.shirodemo.123";
final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt).toString();
System.out.println(HASHED_PWD);
}
加密后的密码:
9916bddfcaf8ae23e401e35af544a75acf91f79b
一般来说,salt 选取一个别人不容易猜到的内容就行。
当然,如果你加密部分的代码泄露了,使用和不使用 salt 也没有多少区别。当然,你也可以将 salt 存储到别的地方,或者用 RSA 进行加密,系统启动时才读取。
5.3.多次加密
除了加 salt 以外,我们还可以对加密后的内容再次加密,这样理论上可以提升破解难度以及降低密文与原始明文的关联性。
下面是一个多次加密的例子:
@Test
public void testSha4(){
final String PASSWROD = "12345";
String salt = "cn.icexmoon.shirodemo.123";
int times = 2;
final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt, times).toString();
System.out.println(HASHED_PWD);
}
这里times
代表加密次数,默认为 1 次。
5.4.随机 Salt
除了使用固定 salt,对每个用户使用同一个 salt 加密以外。也可以为每个用户随机生成一个 salt 用于加密,当然,这样做就需要在存储密码的同时将对应的 salt 也存储起来。
两种方式我觉得各有利弊。
下面是一个使用随机产生的 salt 进行加密的示例:
@Test
public void testSha3(){
final String PASSWROD = "12345";
String salt = generateSalt();
final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt).toString();
System.out.println(HASHED_PWD);
}
public static String generateSalt(){
SecureRandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
return randomNumberGenerator.nextBytes().toHex();
}
SecureRandomNumberGenerator
是 Shiro 用于产生随机数序列的工具。
6.案例:使用 SHA1 加密密码
6.1.表结构
先修改表结构,密码字段使用 40 位字符存储 SHA1 加密后的密文,添加一个 salt 字段存储 32 位字符的 salt:
CREATE TABLE `tb_user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(20) DEFAULT NULL,
`password` char(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`gender` char(1) DEFAULT NULL,
`addr` varchar(30) DEFAULT NULL,
`salt` char(32) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
SecureRandomNumberGenerator
的nextBytes
方法默认产生长度为 16 的 byte 数组,用 Hex 编码就是 32 个十六进制字符。
6.2.实体类
添加一个实体类,作为对散列后密码的抽象:
@Value
public class HashPassword {
// 哈希后的密码内容
String password;
// 哈希时使用的 salt
String salt;
}
修改实体类,添加一个 salt 属性:
@Data
public class User {
private Integer id;
private String username;
private String password;
private String gender;
private String addr;
private String salt;
/**
* 返回加密后的密码内容
* @return 加密后的密码
*/
public HashPassword getHashPassword(){
return new HashPassword(password, salt);
}
}
6.3.工具类
添加一个加密用的工具类:
public class EncryptorUtil {
/**
* 加密次数
*/
public static final int TIMES = 2;
/**
* 散列算法
*/
public static final String ALGORITHM_NAME = "SHA-1";
/**
* 用散列算法对明文密码进行加密
* @param password 明文密码
* @return 加密后的密码
*/
public static HashPassword hashPassword(String password){
final String SALT = generateSalt();
final String HASHED_PWD = new SimpleHash(ALGORITHM_NAME, password, SALT, TIMES).toString();
return new HashPassword(HASHED_PWD, SALT);
}
/**
* 生成32位16进制数组成的随机字符串
* @return 随机字符串
*/
private static String generateSalt(){
SecureRandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
return randomNumberGenerator.nextBytes().toHex();
}
}
6.4.DTO
添加一个 DTO 类,用于从前端传入包含明文密码的用户信息:
@Data
public class UserDTO {
private Integer id;
private String username;
/**
* 明文密码
*/
private String password;
private String gender;
private String addr;
public User getUser() {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setGender(gender);
user.setAddr(addr);
// 对明文密码进行加密
if (password != null && !password.isEmpty()) {
HashPassword hashPassword = EncryptorUtil.hashPassword(password);
user.setPassword(hashPassword.getPassword());
user.setSalt(hashPassword.getSalt());
}
return user;
}
}
getUser
用于将其转换为使用加密后密码的实体类User
。
6.5.服务层
服务层添加一个添加用户的方法:
public class UserServiceImpl extends BaseServiceImpl implements UserService {
// ...
@Override
public void addUser(UserDTO userDTO) {
this.userMapper.insert(userDTO.getUser());
}
}
简单测试一下:
@Test
public void testUserAdd(){
UserDTO dto = new UserDTO();
dto.setUsername("icexmoon");
dto.setAddr("北京路 116 号");
dto.setGender("男");
dto.setPassword("12345");
userService.addUser(dto);
sqlSession.commit();
}
现在数据库中保存的是加密后的密码:
6.6.Realm
接下来要修改验证逻辑,让 Shiro 能够使用加密后的密码作为认证信息进行登录验证。
为 Realm 添加一个构造器,在其中设置 Realm 的密码匹配方式:
public class MyRealm extends AuthorizingRealm {
public MyRealm() {
// 设置密码匹配方式为 HASH 加密后匹配
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(EncryptorUtil.ALGORITHM_NAME);
// 设置加密次数
matcher.setHashIterations(EncryptorUtil.TIMES);
setCredentialsMatcher(matcher);
}
}
修改认证方法,在返回的认证信息中使用加密后的密码和 salt:
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// ...
HashPassword correctPassword = userService.getPasswordByUserName(userName);
if (correctPassword == null
|| correctPassword.getPassword() == null
|| correctPassword.getPassword().isEmpty()) {
throw new UnknownAccountException(String.format("没有名称为%s的用户", userName));
}
// 认证通过,返回认证信息
sqlSession.close();
ByteSource salt = ByteSource.Util.bytes(correctPassword.getSalt());
return new SimpleAuthenticationInfo(userName, correctPassword.getPassword(), salt, getName());
}
登录部分的代码和相关测试用例不需要修改,直接测试即可。
7.案例:身份授权
身份授权的过程与身份验证类似,只不过返回的是授权信息(AuthorizationInfo),其中包含的是用户拥有的角色(Role)。此外,授权是依托于身份认证的,也就是必须先执行登录,进行身份认证,认证通过,完成登录后,才能进行身份授权。
7.1.数据库
添加角色表:
CREATE TABLE `tb_role` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
`description` varchar(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表'
添加用户和角色的关系表:
CREATE TABLE `tb_user_role` (
`user_id` int NOT NULL COMMENT '用户id',
`role_id` int NOT NULL COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`),
KEY `role_fk` (`role_id`),
CONSTRAINT `role_fk` FOREIGN KEY (`role_id`) REFERENCES `tb_role` (`id`),
CONSTRAINT `user_fk` FOREIGN KEY (`user_id`) REFERENCES `tb_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色表'
7.2.实体类
添加角色表的实体类:
@Data
public class Role {
public static final String ROLE_SYS_MANAGER = "sys_manager";
public static final String ROLE_CUSTOMER = "customer";
public static final String ROLE_EMPLOYEE = "employee";
public static final String ROLE_DEP_MANAGER = "dep_manager";
private Integer id;
private String name;
private String description;
}
修改用户的实体类,在其中加入角色属性:
@Data
public class User {
// ...
private List<Role> roles = new ArrayList<>();
}
角色相关属性是List
类型,一个账户可以有多个角色(也可能没有)。
修改UserMapper
的selectByUserName
方法实现,改为使用 XML 配置用关联查询的方式查询关联的角色,并映射到结果集的roles
属性中。
这里不展示具体代码,可以查看完整示例,或者参考。
7.3.Realm
我们已经可以根据用户名查询到用户相关的角色和权限信息了,还需要在 Realm 中将这些信息写入“授权信息”(Authorization Info):
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 获取用户名,在这里主身份(Primary Principal)就是用户名
String userName = (String) principals.getPrimaryPrincipal();
// 从数据库中查询用户相关的角色和权限
SqlSession sqlSession = MyBatisUtil.createSqlSession();
UserService userService = new UserServiceImpl(sqlSession);
User user = userService.getUserByUserName(userName);
sqlSession.close();
SimpleAuthorizationInfo sa = new SimpleAuthorizationInfo();
// 添加角色到授权信息中
List<String> roles = user.getRoles().stream()
.map(Role::getName)
.collect(Collectors.toList());
sa.addRoles(roles);
// 添加权限到授权信息中
List<String> perms = user.getPerms().stream()
.map(Permission::getName)
.collect(Collectors.toList());
sa.addStringPermissions(perms);
return sa;
}
7.4.控制层
现在在控制层中可以使用 Shiro 通过检查当前用户是否拥有某个角色或权限来进行访问控制:
public class OrderController{
private final String PERM_ORDER_ADD = "order:add";
private final String PERM_ORDER_EDIT = "order:edit";
private final String PERM_ORDER_DEL = "order:del";
private final String PERM_ORDER_LIST = "order:list";
/**
* 返回订单列表
*/
public void listOrder(Subject subject) {
// 检查是否登录
if (!subject.isAuthenticated()) {
throw new RuntimeException("没有登录,需要登录");
}
// 检查是否拥有相关角色
if (!subject.hasRole(Role.ROLE_SYS_MANAGER)
&& !subject.hasRole(Role.ROLE_CUSTOMER)) {
// 只有系统管理员和顾客可以查看
throw new RuntimeException("你没有相关权限");
}
// 检查是否有相关权限
if (!subject.isPermitted(PERM_ORDER_LIST)) {
throw new RuntimeException("你没有相关权限");
}
// 查询并返回订单信息
}
}
7.5.测试
测试用例:
public class OrderControllerTests {
private OrderController orderController = new OrderController();
@Test
public void testListOrder() {
UserController userController = new UserController();
Subject subject = userController.login("icexmoon", "12345");
orderController.listOrder(subject);
}
}
Shiro 授权流程的源码分析可以观看。
The End,谢谢阅读。
本文的完整示例可以从获取,对应的测试数据 SQL 可以从获取。
文章评论