Shiro权限框架

Apache Shiro


强大易用的 Java 安全框架,身份验证、授权、密码和会话管理。不跟任何框架或者容器绑定,可以独立运行。

组件 作用
Subject(用户) 认证主体,相当于当前操作用户。它可以是任何事物,比如程序等 Subject currentUser = SecurityUtils.getSubject(); 类似 Employee user = SessionUtil.getAttribute(Employee.class); 一旦获得 Subject,就可以立即使用 Shrio 为当前用户做事,比如登录、登出、访问会话、执行授权等
SecurityManager 安全管理器 它是 Shiro 功能实现的核心,负责与其他组件(认证器/授权器/缓存控制器)进行交互,实现 Subject 委托的各种功能。类似于 SpringMVC 中的 DispatcherServlet 前端控制器
Realms 数据源 Realm 是 Shiro 与应用数据之间的桥梁,可以把 Realm 看成连接池 DataSource,执行认证(登录)和授权(访问控制)时,Shiro 会从应用配置的 Realm 中查找相关的比对数据,确认用户是否合法,操作是否合理
Authenticator 认证 协调一个或者多个 Realm,从 Realm 指定的数据源取得数据之后执行具体的认证
Authorizer 授权 决定用户是否拥有执行指定操作的权限
SessionManager Shiro 支持会话管理,在安全框架中是独一无二的功能,即便不存在 web 容器环境,Shiro 都可以使用自己的会话管理机制,提供相同的会话 API
CacheManager 缓存组件 缓存认证信息
Cryptography 加密组件 Shiro 提供了加密解密的命令行工具 jar 包,需要单独下载使用

Shiro 架构的核心(Subject,SecurityManager,Realms)

Shiro 在应用中最常用的两个功能:用户登录认证和访问授权。

基于 ini 的 Shiro 认证


Shiro 认证概述,认证的过程为用户的身份确认过程。

上图表明了用户访问的认证流程,是否需要认证和是否已经通过认证,最关键的是如何认证。

准备工作:导入 jar 包

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.1.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.4.1</version>
</dependency>

编写 ini 配置文件:
shiro-permission.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 用户
[users]
# 用户zhang的密码是123,此用户具有role1和role2两个角色
zhang=123,role1,role2
wang=123,role3

# 权限
[roles]
# 角色role1对资源user拥有create、update权限
role1=user:create,user:update
# 角色role2对资源user拥有create、delete权限
role2=user:create,user:delete
# 角色role3对资源user拥有create权限
role3=user:create



# 权限标识符号规则:资源:操作:实例(中间使用半角:分隔)
user:create:01 表示对用户资源的01实例进行create操作。
user:create:表示对用户资源进行create操作,相当于user:create:*,对所有用户资源实例进行create操作。
user:*:01 表示对用户资源实例01进行所有操作。

shiro-permi.ini

1
2
3
4
5
6
7
8
[users]
zhang=123
lisi=555

# 自定义的 Realm 信息
crmRealm=shiro.CRMRealm
# 将 crmRealm 设置到当前环境中
securityManager.realms=$crmRealm

使用 Shiro 相关的 API 完成身份认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Test
public void testShiro() {
// 创建SecurityManager工厂
Factory<SecurityManager> factory = new IniSecurityManagerFactory(
"classpath:shiro-permission.ini");
// 创建SecurityManager
SecurityManager securityManager = factory.getInstance();
// 将SecurityManager设置到系统运行环境,
// 和spring后将SecurityManager配置spring容器中,一般单例管理
SecurityUtils.setSecurityManager(securityManager);
// 创建subject
Subject subject = SecurityUtils.getSubject();
// 创建token令牌
UsernamePasswordToken token = new UsernamePasswordToken("wang", "123");
// 执行认证
// 1. 账号错误 org.apache.shiro.authc.UnknownAccountException
// 2. 密码错误 org.apache.shiro.authc.IncorrectCredentialsException
try {
subject.login(token);
} catch (AuthenticationException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("认证状态:" + subject.isAuthenticated());
// 认证通过后执行授权

// 基于角色的授权判断
// hasRole传入角色标识
boolean ishasRole = subject.hasRole("role1");
System.out.println("单个角色判断" + ishasRole);
// hasAllRoles是否拥有多个角色
boolean hasAllRoles = subject.hasAllRoles(Arrays.asList("role1",
"role2", "role3"));
System.out.println("多个角色判断" + hasAllRoles);
// 使用check方法进行授权,如果授权不通过会抛出异常
// subject.checkRole("role13");

// 基于资源的授权判断
// isPermitted传入权限标识符
boolean isPermitted = subject.isPermitted("user:create:1");
System.out.println("单个权限判断" + isPermitted);

boolean isPermittedAll = subject.isPermittedAll("user:create:1",
"user:delete");
System.out.println("多个权限判断" + isPermittedAll);
// 使用check方法进行授权,如果授权不通过会抛出异常
subject.checkPermission("items:create:1");
}

@Test
public void test2() {
// 加载 ini 文件,获取配置中的用户信息(账号+密码)
IniSecurityManagerFactory factory = new IniSecurityManagerFactory("classpath:shiro-permi.ini");
// 创建 Shiro 安全管理器
SecurityManager manager = factory.getInstance();
// 安全管理器添加到运行环境中
SecurityUtils.setSecurityManager(manager);
// 获取用户对象
Subject subject = SecurityUtils.getSubject();

System.out.println("登录前的用户认证状态" + subject.isAuthenticated());

// 创建登录用户身份凭证
UsernamePasswordToken token = new UsernamePasswordToken("zhang", "123");
subject.login(token);
System.out.println("登录后用户认证状态" + subject.isAuthenticated());
}

Shiro 认证流程分析:

自定义 Realm


实际开发中,需要的账户信息来自于数据库或者程序中,而不是 ini 配置文件。
上面的例子中,通过源码可以看到,使用的是 SimpleAccountRealm 类来处理账户的认证和授权的。其实现为:

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
// 认证,获取认证信息
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
SimpleAccount account = getUser(upToken.getUsername());
if (account != null) {
if (account.isLocked()) {
throw new LockedAccountException("Account [" + account + "] is locked.");
}
if (account.isCredentialsExpired()) {
String msg = "The credentials for account [" + account + "] are expired";
throw new ExpiredCredentialsException(msg);
}
}
return account;
}

// 授权获取授权信息
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String username = getUsername(principals);
USERS_LOCK.readLock().lock();
try {
return this.users.get(username);
} finally {
USERS_LOCK.readLock().unlock();
}
}

在 AuthenticatingRealm 中调用该方法判断账号是否正确,如果返回的 account 不为空,说明账号存在,然后进行密码校验。

覆写 doGetAuthenticationInfo() 方法,在该方法中实现账号的校验,返回 AuthenticationInfo 对象给上层调用者。

上图为 Realm 的继承体系。我们需要继承 AuthorizingRealm,因为我们会使用缓存、认证和授权的所有功能。

创建自己的 Realm 文件,继承 AuthorizingRealm。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CRMRealm extends AuthorizingRealm {
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String username = "111";
String password = "555";
// 如果账号正确,返回一个 AuthenticationInfo 对象
if (username.equals(token.getPrincipal())) {
return new SimpleAuthenticationInfo(username, password, this.getName());
}
return null;
}

public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {

return null;
}
}

然后需要告诉 Shiro 认证或者授权时,去找哪个类完成功能。修改 SecurityManager 中的默认 Realm 的使用

shiro.ini

1
2
3
4
# 自定义的 Realm 信息
crmRealm=shiro.CRMRealm
# 将 crmRealm 设置到当前环境中
securityManager.realms=$crmRealm

运行与之前测试结果一致,但现在是我们自己自定义的 Realm 完成了账号的校验。

Spring 与 Shiro 整合


导入 jar 包,shiro-web、shiro-spring、shiro-code

shiro 通过 filter 进行拦截,拦截后将操作权交给 spring 中配置的 filterChain 进行过滤。

web.xml

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

shiro过滤器,DelegatingFilterProxy 会从 spring 容器中找 shiroFilter,过滤器生命周期交给 Spring 容器管理。
Shiro 中定义了多个过滤器完成不同的预处理操作:

所有的过滤器导入的包为 org.apache.shiro.web.filter.authc.

过滤器名称 过滤器 理解
anon AnonymousFilter 匿名拦截器,即不需要登录即可访问;一般用于静态资源过滤;示例 /static/**=anon
authc FormAuthenticationFilter 表示需要认证(登录)才能使用;示例 /**=authc 主要属性下面有说明
authcBasic BasicHttpAuthenticationFilter Basic HTTP身份验证拦截器,主要属性: applicationName:弹出登录框显示的信息(application)
roles RolesAuthorizationFilter 角色授权拦截器,验证用户是否拥有资源角色;示例 /admin/=roles[admin]
perms PermissionsAuthorizationFilter 权限授权拦截器,验证用户是否拥有资源权限;示例 /employee/input=perms["user:update"]
user UserFilter 用户拦截器,用户已经身份验证/记住 我登录的都可;示例/index=user
logout LogoutFilter 注销拦截器,主要属性:redirectUrl:退出成功后重定向的地址(/);示例 /logout=logout
port PortFilter 端口拦截器,主要属性 port(80),可以通过的端口;示例 /test= port[80] 如果用户访问该页面是非80,将自动将请求端口改为80并重定向到该80端口,其他路径/参数等都一样
rest HttpMethodPermissionFilter rest风格拦截器,自动根据请求方法构建权限字符串
ssl SslFilter ssl:SSL拦截器,只有请求协议是https才能通过;否则自动跳转会https端口(443)其他和port拦截器一样

HttpMethodPermissionFilter : rest 风格拦截器,自动根据请求方法构建权限字符串(GET=read,POST=create,PUT=update,DELETE=delete,HEAD=read,TRACE=read,OPTIONS=read, MKCOL=create)构建权限字符串,示例 /users=rest[user],会自动拼出 user:read,user:create,user:update,user:delete 权限字符串进行权限匹配(所有都得匹配isPermittedAll)

CRMRealm.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component("crmRealm")
public class CRMRealm extends AuthorizingRealm {
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String username = "zhang";
String password = "123";
// 如果账号正确,返回一个 AuthenticationInfo 对象
if (username.equals(token.getPrincipal())) {
return new SimpleAuthenticationInfo(username, password, this.getName());
}
return null;
}
public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return null;
}
}

shiro.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!-- 扫描 realm 包 -->
<context:component-scan base-package="cn.lizhaoloveit.crm.realm"/>

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

<!-- web.xml 中 shiro 的 filter 对应的 bean -->
<!-- Shiro 的 Web 过滤器 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<property name="securityManager" ref="securityManager"/>
<!-- loginUrl 认证提交地址,如果没有认证将会请求此地址进行认证,请求此地址将由formAuthenticationFilter进行表单认证 -->
<property name="loginUrl" value="/login.html"/>
<!--认证成功统一跳转到 first.action,建议不配置,shiro 认证成功自动到上一个请求路径 -->
<property name="successUrl" value="/first.action"/>
<!--通过 unauthorizedUrl 指定没有权限操作时跳转页面-->
<property name="unauthorizedUrl" value="/refuse.jsp"/>
<!--自定义 filter 配置 -->
<property name="filters">
<map>
<!-- 将自定义 的 FormAuthenticationFilter 注入 shiroFilter 中-->
<entry key="authc" value-ref="crmFormAuthenticationFilter"/>
</map>
</property>

<!-- 过虑器链定义,从上向下顺序执行,一般将/**放在最下边 -->
<property name="filterChainDefinitions">
<value>
<!--所有 url 都可以匿名访问-->
/css/** = anon
/js/** = anon
/images/** = anon
/uploads/** = anon
/** = authc
</value>
</property>
</bean>

步骤:

  1. 在 web.xml 文件中配置 shiro 的过滤器
  2. 在对应的 spring 配置文件中配置与 web.xml 中对应的 filter
    • 保证静态资源不被拦截 /css/** = anon、/js/** = anon、/images/** = anon
    • 匿名可以访问
  3. 配置安全管理器,注入自定义的 realm
  4. 配置自定义的 realm
  5. 交验完,告知前端页面校验结果

有了上面的配置,在访问达到具体资源前,会通过制定的过滤器做预处理,允许通过后才能继续访问。在 JavaEE 环境中,使用过的安全管理器是 DefaultWebSecurityManager,并且在该安全管理器中指定我们自定义的 Realm。需要将自定义的 realm 交给 spring 容器管理,之后 shiro 就会通过自定义的 realm 进行账号认证和授权了。

如果你使用 Dispatcher 配置路径 url-pattern 为 /,且 mvc 中配置了 <mvc:default-servlet-handler/> 就一定记住,动态资源访问名与静态资源访问名不能同名。

默认情况下,会调用 FormAuthenticationFilter 中的 onLoginFailure 和 onloginSuccess 对成功和失败的结果进行处理,而 shiro 不知道失败或者成功或者权限被拒后要做什么,需要我们去告诉它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component("crmFormAuthenticationFilter")
public class CRMFormAuthenticationFilter extends FormAuthenticationFilter {

@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
response.setContentType("application/json;charset=UTF-8");
// 响应页面需要的数据 成功
JsonResult result = new JsonResult();
response.getWriter().print(JSON.toJSONString(result));
return false;
}

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
response.setContentType("application/json;charset=UTF-8");
JsonResult result = new JsonResult();
try {
result.mark("用户名或者密码错误");
response.getWriter().print(JSON.toJSONString(result));
} catch (IOException e1) {
e1.printStackTrace();
}
return false;
}
}

最后在 Realm 中实现真实的查询即可。

CRMReam.java

1
2
3
4
5
6
7
8
9
10
11
12
public class CRMRealm extends AuthorizingRealm {
...
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
...
// 验证用户名 密码
String username = (String) token.getPrincipal();
Employee employee = mapper.selectByName(username);
if (employee != null) {
return new SimpleAuthenticationInfo(username, employee.getPassword(), this.getName());
}
}
}

Shiro 制定化认证登录


从 Shiro 的认证体系可以看出来,FormAuthenticationFilter 中通过认证参数获取的几个参数是固定的,封装的认证数据类型为 UsernamePasswordToken ,UsernamePasswordToken 中只有 username,password,rememberMe,这显然不能满足需求,因此我们在覆写 FormAuthenticationFilter.java 时,也要扩展 Token 类,以提供需要的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CRMToken extends UsernamePasswordToken {
// 序列化ID
private static final long serialVersionUID = -2804050723838289739L;
// 验证码
private String captchaCode;
/**
* 构造函数
* 用户名和密码是登录必须的,因此构造函数中包含两个字段
*/
public CRMToken(String username, String password, String captchaCode) {
// 父类UsernamePasswordToken的构造函数,后两个参数暂不需要, 不设置
super(username, password, false, "");
this.captchaCode = captchaCode;
}

/**
* 获取验证码
*/
public String getCaptchaCode() {
return captchaCode;
}
}

必须在覆写的 FormAuthenticationFilter 中,重写 createToken 方法来创建我们自己的 Token 对象,否则不会有扩展。

CRMFormAuthenticationFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CRMFormAuthenticationFilter extends FormAuthenticationFilter {

/**
* 构造Token,重写Shiro构造Token的方法,增加验证码
*/
@Override
protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
// 获取登录请求中用户输入的验证码
String captchaCode = request.getParameter("captchaCode");
// 返回带验证码的Token,Token会被传入Realm, 在Realm中可以取得验证码
return new CRMToken(username, password, captchaCode);
}
...
}

然后使用自己的数据模块来进行认证,CRMRealm 继承 org.apache.shiro.realm.AuthorizingRealm.java 类,代码如下:
CRMRealm.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Component("crmRealm")
public class CRMRealm extends AuthorizingRealm {

@Autowired
private EmployeeMapper mapper;

public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

// 在自定义认证过滤器中奖验证码保存到 KaptchaCodeToken 中
// 此处的 Token 就是认证过滤器中实例化的 Token 可以直接强制转换
CRMToken crmToken = (CRMToken) token;

// 验证码
String loginCaptcha = crmToken.getCaptchaCode();
// 验证码未输入
if (StringUtil.isEmptyStr(loginCaptcha)) {
throw new CaptchaEmptyException();
}

Subject subject = SecurityUtils.getSubject();

String sessionCaptcha = (String) subject
.getSession().getAttribute("RANDOMCODE_IN_SESSION");
if (!loginCaptcha.equals(sessionCaptcha)) {
// 抛出自定义异常(继承AuthenticationException),
// Shiro会捕获 AuthenticationException 异常
// 发现该异常时认为登录失败, 执行登录失败逻辑,
// 登录失败页中可以判断如果是 CaptchaEmptyException 时为验证码错误
throw new CaptchaErrorException();
}
// 记住用户名

// 验证用户名 密码
String username = (String) token.getPrincipal();
Employee employee = mapper.selectByName(username);
if (employee != null) {
return new SimpleAuthenticationInfo(employee, employee.getPassword(), this.getName());
}
return null;
}

public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {

return null;
}
}

CRMRealm 做的是用户名认证,会将用户名密码传递给下一个认证链,如果抛出异常(AuthenticationException类型),就会返回,并调用 FormAuthenticationFilter 的 onLoginSuccessonLoginFailure 方法将结果响应给前端浏览器。所以我们也可以抛出自己自定义的异常类型,来精准判断错误。AuthenticationException 类型的异常会被 onLoginFailure 捕捉。

1
2
3
4
public class CaptchaEmptyException extends AuthenticationException {
}
public class CaptchaErrorException extends AuthenticationException {
}

最终根据程序执行流程响应结果

CRMFormAuthenticationFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component("crmFormAuthenticationFilter")
public class CRMFormAuthenticationFilter extends FormAuthenticationFilter {

/**
* 构造Token,重写Shiro构造Token的方法,增加验证码
*/
@Override
protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
// 获取登录请求中用户输入的验证码
String captchaCode = request.getParameter("captchaCode");
// 返回带验证码的Token,Token会被传入Realm, 在Realm中可以取得验证码
return new CRMToken(username, password, captchaCode);
}

@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
response.setContentType("application/json;charset=UTF-8");
// 响应页面需要的数据 成功
JsonResult result = new JsonResult();
response.getWriter().print(JSON.toJSONString(result));
return false;
}

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
response.setContentType("application/json;charset=UTF-8");
JsonResult result = new JsonResult();
if (e instanceof CaptchaEmptyException) {
result.mark("验证码为空");
} else if (e instanceof CaptchaErrorException) {
result.mark("验证码错误");
} else {
result.mark("用户名或者密码错误");
}

try {
response.getWriter().print(JSON.toJSONString(result));
} catch (IOException e1) {
e1.printStackTrace();
}
return false;
}
}

Shiro RememberMe 流程原理


Shiro 授权概述


基于 ini 的授权,详情见上面基于 ini 的 Shiro 认证代码。

规则,”资源标识符:操作:资源实例标识符”,意思是对哪个资源的哪个实例具有什么操作,:是资源/操作/实例的分割付,权限字符串也可以用 * 通配符。

exp:用户创建权限:(user:create),或者 user:create.* 用户修改实例 001 的权限:user:update001,用户实例001的所有权限:user:*:001

方法:

  • isPermitted(String permission) 是否拥有当前指定的一个权限
  • isPermitted(String permission,…) 是否拥有当前指定的一个或者多个权限,以数组形式返回每个判断结果
  • hasRole(String role) 是否拥有当前指定的一个角色
  • hasRoles(List roles) 是否拥有当前指定的多个角色,以数组形式返回每个判断的结果
  • hasAllRoles(Collection roles) 是否拥有指定的多个角色,只有当都有的时候才返回 true,反之返回 false

web 下 Realm 的授权


  1. 对 subject 进行授权,调用方法 isPermitted(“permission 串”)
  2. SecurityManager 执行授权,通过 ModularRealmAuthorizer 执行授权
  3. ModularRealmAuthorizer 执行自定义的 Realm 中的方法 doGetAuthorizationInfo 从数据库查询权限数据调用 realm 的授权方法。
  4. realm 从数据库查询权限数据,返回 ModularRealmAuthorizer
  5. 如果对比后,isPermitted 中 “permission串”在realm 查询到权限数据中,说明用户访问 permission 传有权限,否则没有权限,抛出异常。

在权限管理模块中,采用自定义权限注解,贴在需要授权的方法上,获取对应的注解,生成权限表达式。但这个注解是我们自己定义的,Shiro 并不知道,所以需要使用 Shiro 自身提供的注解来完成。

  • 在 Controller 的方法上贴上 Shiro 提供的权限注解(@RequiresPermissions)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequestMapping("/list")
@RequiresPermissions(value = {"Employee:list", "员工列表"}, logical = Logical.OR)
public String list(Model model, @ModelAttribute("qe") QueryEmployee qe) {
if (qe == null || qe.getPageNum() == null) {
qe = QueryEmployee.EMPTYQUERY;
model.addAttribute("qe", qe);
}
qe.setDeptId(Optional.ofNullable(qe.getDeptId())
.filter(s -> s.intValue() > 0).orElse(null));
PageInfo pageInfoEmp = employeeService.gets(qe);
model.addAttribute("pageInfo", pageInfoEmp);
PageInfo pageInfoDept = departmentService.gets(null);
model.addAttribute("pageInfoDept", pageInfoDept);
return "employee/list";
}

value 属性可以传递 String[] ,同时设置权限表达式和权限名称,
logical Shiro 会将 value 中的数据都作为权限表达式使用,校验的时候都要有,或者拥有其中一个,对应的值是 Logical.AND 和 Logical.OR

  • 开启 Shiro 注解扫描器,当扫描到 Controller 中有使用 @RequiresPermissions 注解时,会使用动态代理为当前 Controller 生成代理对象,增强对应方法的权限校验功能。
1
2
3
4
<aop:config proxy-target-class="true"></aop:config>
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
<property name="securityManager" ref="securityManager"/>
</bean>

上面的增强类需要依赖 aop 组件,并且要注明是用 CGLib 创建动态代理

1
2
3
4
5
6
7
8
9
10
11
<!-- aop 组件 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${aspectj.version}</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>${aspectj.version}</version>
</dependency>

PermissionService.reload()

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
@Override
public void reload() {
// 获取所有的权限,并且做成一个 map 方便查找
List<Permission> permissions = permissionMapper.selectAll();
// 将所有的 Controller 从容器中取出
Map<String, Object> beans = context.getBeansWithAnnotation(Controller.class);
Collection<Object> controllers = beans.values();

// 遍历所有的 Controller
for (Object controller : controllers) {
// 获取 Controller 中的方法,因为在运行时,shiro 会对贴有注解的 Controller 生成动态代理
// 所以需要通过其父类来获取方法
Method[] methods = controller.getClass().getSuperclass().getDeclaredMethods();
// 遍历所有的方法
for (Method method : methods) {
boolean isPermissionMethod = method.isAnnotationPresent(RequiresPermissions.class);
// 如果方法没有贴 @RequiresPermissions 注解,就继续循环
if (!isPermissionMethod) continue;
RequiresPermissions reqPerAnn = method.getDeclaredAnnotation(RequiresPermissions.class);
// 说明数据库中已经存在了 methodExpression,继续循环
if (permissions.contains(reqPerAnn.value()[0])) continue;
// 来到这里说明数据库不存在该权限
Permission permission = new Permission();
permission.setName(reqPerAnn.value()[1]);
permission.setExpression(reqPerAnn.value()[0]); // employee:delete
permissionMapper.insert(permission);
}
}
}

注意:

  1. 从 Spring 容器中获取的 Controller 对象是代理对象,该对象的类型继承原 Controller,而注解是在原 Controller 类方法上的,所以需要获取到 Controller 代理对象的父类字节码,然后获取方法
  2. 权限表达式在注解中直接指定。

在 CRMRealm.java 中获取当前登录用户的授权信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 获取当前登录用户
Employee employee = (Employee) principals.getPrimaryPrincipal();
// 如果是超管,放行
if (employee.isAdmin()) {
info.addRole("admin");
info.addStringPermission("*:*");
return info;
}
// 根据用户 id 获取其所有角色名
List<String> roleSns = roleMapper.selectRoleSnsWithEmpId(employee.getId());
// 根据 id 获取所有权限表达式
List<String> expressions = permissionMapper.selectExpressionsByEmpId(employee.getId());
info.addRoles(roleSns);
info.addStringPermissions(expressions);
return info;
}

*:* 表示所有权限,如果用户是超管,可以访问所有权限,不是超管,就分配给用户响应的权限。doGetAuthorizationInfo 方法配置角色,基于角色获取对象是否有访问权限,或者基于角色判断页面是否需要展示页面。这里 subject.hasRole(String sn),sn 为上面我们在授权时,添加的角色名。

1
2
3
4
5
6
7
8
Subject subject = SecurityUtils.getSubject();
Employee employee = (Employee) subject.getPrincipal();
// 如果不是管理员且不是销售经理,就只能查看自己的客户 否则,可以看到全部用户
if (!subject.hasRole(Role.ROLE_ADMIN) && !subject.hasRole(Role.ROLE_MARKETMASTER))
qc.setSellerId(employee.getId());
else { // 可以看到全部用户
model.addAttribute("sellers", employeeService.getsSellers());
}

前端页面中的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 如果是管理员或者销售经理才会出现这个 -->
<@shiro.hasAnyRoles name="admin,marketMaster">
<div class="form-group">
<label for="seller">市场专员:</label>
<select class="form-control" id="seller" name="sellerId">
<option value="-1">全部</option>

<#list sellers.list as e>
<option value="${e.id}">${e.name}</option>
</#list>
</select>
<script>
$("#seller option[value='${qc.sellerId!'-1'}']").prop("selected", true);
</script>
</div>
</@shiro.hasAnyRoles>

如果访问没有权限的资源会抛出异常:org.apache.shiro.authz.UnauthorizedException ,只要捕获到该异常,然后进行处理。使用 SpringMVC 的统一异常处理。

mvc.xml

1
2
3
4
5
6
7
8
9
10
<bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
<!-- 定义默认的异常处理页面 -->
<property name="defaultErrorView" value="common/error"/>
<!-- 定义需要特殊处理的异常,使用类名或完全路径名作为 Key,异常页作为值 -->
<property name="exceptionMappings">
<value>
org.apache.shiro.authz.UnauthorizedException=common/nopermission
</value>
</property>
</bean>

在 exceptionMappings 中键值对的格式设置,是如果异常类名是 org.apache.shiro.authz.UnauthorizedException,则跳转的错误页面资源路径为 common/nopermission

异步请求没有权限异常处理

异步请求需要拦截 org.apache.shiro.authz.UnauthorizedException 异常,并判断是否是异步请求,然后做相应处理,而拦截访问资源的异常,最好使用 aop 对 Controller 进行增强,就可以拦截器方法抛出的异常了。

Spring 已经提供了一个增强器 @ControllerAdvice,贴上改标签的类被认为是 Controller 的增强类型。通常和 @ExceptionHandler、@InitBinder、@ModelAttribute 注解配合使用,最常用的是 @ExceptionHandler。对Controller 中的异常做特定处理。

@ExceptionHandler 贴了该注解的方法,会在Controller 抛出异常时,进入方法执行逻辑,参数如下:

  • 异常参数,包括一般的异常或者自定义异常,如果注解没有指定异常类,会默认进行映射。
  • 请求或者响应对象,(HttpServletRequest、ServletRequest、HttpServletResponse、ServletResponse)
  • HttpSession
  • WebRequest 或者 NativeWebRequest
  • Locale
  • InputStream/Reader
  • OutputStream/Writer
  • Model

UnauthorizedExceptionUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* DESCRIPTION: 处理异步请求时,用户没有权限的异常
* Author: Ammar
* Date: 2019-08-28
* Time: 19:33
*/
@ControllerAdvice
public class UnauthorizedExceptionUtil {
// 专门捕获 UnauthorizedException 异常
@ExceptionHandler(UnauthorizedException.class)
public void handler(HttpServletResponse response, HandlerMethod method, UnauthorizedException e) throws IOException {
// 判断资源方法是否为异步请求
if (method.getMethod().isAnnotationPresent(ResponseBody.class)) {
response.setContentType("text/json;charset=UTF-8");
JsonResult jsonResult = new JsonResult();
jsonResult.mark("对不起,您没有权限执行该操作");
response.getWriter().print(JSON.toJSONString(jsonResult));
} else {
throw e;
}
}
}

Shiro标签控制权限


准备工作

前端页面的权限处理,根据权限控制前端页面。比如:用户拥有删除员工的权限,则在页面上把删除按钮显示出来。
引入 jar 包,shiro-freemarker-tags

并且默认 freemarker 是不支持 shiro 标签的,需要扩展 freemarker 功能。

MyFreeMarkerConfig.java

1
2
3
4
5
6
7
8
public class MyFreeMarkerConfig extends FreeMarkerConfigurer {
@Override
public void afterPropertiesSet() throws IOException, TemplateException {
super.afterPropertiesSet();
Configuration cfg = this.getConfiguration();
cfg.setSharedVariable("shiro", new ShiroTags());
}
}

mvc.xml

1
2
3
4
5
6
<bean class="cn.lizhaoloveit.crm.util.MyFreeMarkerConfig">
<!-- 配置 freemarker 的文件编码 -->
<property name="defaultEncoding" value="UTF-8"/>
<!-- 配置 freemarker 寻找模板的路径 -->
<property name="templateLoaderPath" value="/WEB-INF/view"/>
</bean>

覆写 FreemarkerConfigerer 类,加入 shiro 标签,然后再 mvc.xml 中用自己的 FreemarkerConfig 类来覆盖原来的 bean。

常用标签

  1. authenticated:已经认证通过的用户 <@shiro.authenticated></@shiro.authenticated>
  2. notAuthenticated
  3. principal 输出当前用户信息,通常为登录账号信息 <@shiro.principal property="name"/>,就是 Employee 对象,也就是我们在验证的时候传入的对象。相当于Employee principal = (Employee) SecurityUtils.getSubject().getPrincipal();
  4. hasRole 验证当前用户是否属于该角色 <@shiro.hasRole name="admin">Hello admin!</@shiro.hasRole>
  5. hasAnyRoles 验证当前用户是否属于这些角色中的任何一个,角色之间用逗号分隔 <@shiro.hasAnyRoles name="admin,user,operator">Hello admin!</@shiro.hasAnyRoles>
  6. hasPermission 验证当前用户是否拥有该权限 <@shiro.hasPermission name="/order:*">订单</@shiro.hasPermission>
1
2
3
4
5
<@shiro.hasPermission name="employee:delete">
<a href="#" class="btn btn-danger btn-xs btn-delete" data-href="/employee/delete?id=${entity.id}">
<span class="glyphicon glyphicon-trash"></span> 删除
</a>
</@shiro.hasPermission>

如果当前登录的用户没有员工删除权限,就不在页面中显示超链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!-- authenticated 用户已经经过身份验证,但不是记住我登录的 -->
<shiro:authenticated>
<shiro:principal />已经经过身份验证<br><br>
</shiro:authenticated>

<!-- 用户没有进行身份验证,记住我自动登录的属于没有进行身份验证 -->
<shiro:notAuthenticated>
用户没有进行身份验证,记住我自动登录的属于没有进行身份验证<br><br>
</shiro:notAuthenticated>

<!-- guest :用户没有验证时显示相应信息 ,如登录等相关信息-->
<shiro:guest>
<a href="login.jsp">登录</a><br><br>
</shiro:guest>

<!-- 当前用户有任意一个角色将会显示body体中的内容 -->
<shiro:hasAnyRoles name="admin,user,manager">
<shiro:principal></shiro:principal>拥有admin/user/manager中的角色<br><br>
</shiro:hasAnyRoles>

<!-- 当前用户有相应的权限,将显示body体中的信息 -->
<shiro:hasPermission name="customer:delete">
<shiro:principal />拥有customer:delete权限<br><br>
</shiro:hasPermission>

<!-- 当前用户没有相应的权限,将显示body体中的信息 -->
<shiro:lacksPermission name="customer:delete">
没有权限customer:delete<br><br>
</shiro:lacksPermission>

<!-- 当前用户没有相应的角色,将显示body中的信息 -->
<shiro:lacksRole name="manager">
<shiro:principal></shiro:principal>没有manager角色<br><br>
</shiro:lacksRole>

<!-- user: 用户已经经过认证/记住我登录后 显示相应的信息 -->
<shiro:user>
<a href="logout">登出</a><br><br>
</shiro:user>

<!-- 当前用户是否拥有该角色,有就显示相关信息 -->
<shiro:hasRole name="admin">
<a href="admin.jsp">Admin Page</a><br><br>
</shiro:hasRole>

MD5 加密


登录的时候,虽然 POST 请求,仍然要对用户数据进行加密。而加密后的数据需要告诉 shiro 是何种加密算法,不然 shiro 不能对用户进行认证,需要在 shiro.xml 中配置加密器

1
2
3
4
5
6
7
<!-- 加密器 -->
<bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
<!-- 加密算法 -->
<property name="hashAlgorithmName" value="MD5"/>
<!-- 加密次数 -->
<!-- <property name="hashIterations" value="3"/>-->
</bean>

首先,在保存用户名密码时,应该加密保存。
EmployeeServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public void save(Employee e, Long[] roleIds) throws RuntimeException {
// 加密
// Md5Hash password = new Md5Hash(e.getPassword(), e.getName(), 1); (加密体,salt,加密次数)
Md5Hash password = new Md5Hash(e.getPassword(), e.getName(), 1);
e.setPassword(password.toString());
try {
if (e.getId() == null) {
employeeMapper.insert(e);
} else {
employeeMapper.update(e);
}

// 为空就是超管
if (roleIds == null) return;

// 删除 emp_role 中的 empId 的数据
employeeMapper.deleteFromEmpRoleWithEmpId(e.getId());
// 维护表关系
for (Long roleId : roleIds) {
employeeMapper.insertEmpRole(e.getId(), roleId);
}

} catch (Exception e1) {
throw new RuntimeException("用户名已存在");
}
}

下面模拟整个登录认证,用户输入用户名密码登录,shiro 的 realm 拿到用户名密码后,调用 doGetAuthenticationInfo 方法。在调用 SimpleAuthenticationInfo(Object principal, Object hashedCredentials, ByteSource credentialsSalt, String realmName) 时,如果用了 md5 加密算法,则要将 salt 告诉 shiro,在 ByteSource credentialsSalt。

1
2
3
4
5
return new SimpleAuthenticationInfo(
employee,
employee.getPassword(),
ByteSource.Util.bytes(employee.getName()),
this.getName());

但此时还是不能让 shiro 来进行认证,因为我们虽然在 spring 容器中存放了加密器 HashedCredentialsMatcher,但是并没有将这个加密器设置给 realm,所以在 realm 中要设置 HashedCredentialsMatcher 为自定义的加密器。

1
2
3
4
@Autowired
public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
super.setCredentialsMatcher(credentialsMatcher);
}

realm 接受一个 CredentialsMatcher 参数,但是 HashedCredentialsMatcher 继承了 SimpleCredentialsMatcher 实现了 CredentialsMatcher。

此时,shiro 已经可以为我们进行认证了。

集成 EhCache


shiro 中的缓存。在请求中,一旦进行权限控制,例如 @RequiresPermissions(“employee:list”) 或者 <@shiro:hasPermission name="employee:delete"> 都会去 Realm 的 doGetAuthorizationInfo 方法中进行授权,而且每次都要查数据库,用到就查是非常麻烦的。而且用户登录以后,授权信息一般很少变动,所以在第一次授权后,就把授权信息缓存。

shiro 中没有实现自己的缓存机制,只提供了一个可以支持具体缓存的实现的抽象 API 接口,允许 shiro 根据自己的需求灵活的选择具体的 CacheManager,(Hazelcast, Ehcache, OSCache, Terracotta, Coherence, GigaSpaces, JBossCache等),这里选择使用 EHCache。

实现步骤:

  • 添加依赖
1
2
3
4
5
6
7
8
9
10
11
<!-- shiro 权限管理缓存组件 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache-core</artifactId>
<version>2.6.11</version>
</dependency>

shiro.xml 中配置缓存管理器,并引用缓存

1
2
3
4
5
6
7
8
9
10
<bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
<!-- 缓存配置文件 -->
<property name="cacheManagerConfigFile" value="classpath:shiro-ehcache.xml"/>
</bean>

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realm" ref="crmRealm"/>
<property name="cacheManager" ref="cacheManager"/>
<!-- <property name="rememberMeManager" ref="rememberMeManager"/>-->
</bean>

shiro-ehcache.xml

1
2
3
4
5
6
7
8
<ehcache>
<defaultCache maxElementsInMemory="1000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
memoryStoreEvictionPolicy="LRU">
</defaultCache>
</ehcache>

Ehcache 配置属性名:

  • name:缓存名称
  • maxElementsInmemory 缓存最大个数,
  • eternal 对象是否永久有效,一旦设置,timeout 将不起作用。
  • timeToLiveSeconds 设置对象在失效前允许存活时间(单位:s)最大时间介于创建时间和失效时间之间。当 eternal=false 时使用,默认是0,也就是对象存活时间无穷大。
  • overflowToDisk 当内存中对象数量达到 maxElementsInMemory 时,Ehcache 会将对象写到磁盘中,
  • diskSpoolBufferSizeMB 这个参数设置 DiskStore 磁盘缓存区域大小。默认 30 MB。每个 Cache 都有自己的一个缓冲区
  • maxElementsOnDisk 硬盘最大缓存个数,
  • diskPersistent 是否缓存虚拟机重启期数据。默认值是 false
  • diskExpireThreadIntervalSeconds 磁盘失效线程运行时间间隔,默认120秒
  • memoryStoreEvictionPolicy 当达到 maxElementsInMemory 限制时,Ehcache 会根据指定的策略去清理内存。默认策略是 LRU (最近最少使用),可以设置为 FIFO(先进先出) 或者 LFU(较少使用)。
  • clearOnFlush 内存数量最大时是否清楚。

配置结束,登录之后,多次访问权限资源时,不会在反复查询权限数据,而是直接使用缓存汇总的权限数据。

清空缓存

  1. 用户正常退出,缓存自动清空
  2. 如果用户非正常退出,缓存自动清空
  3. 如果修改了用户的权限,而用户不退出系统,修改的权限无法立即生效
  4. 当用户权限修改以后,用户再次登录 shiro 会自动调用 realm 从数据库获取权限,修改权限后想立即清除缓存的话,可以调用 realm 的 clearCache 方法清除缓存。
1
2
3
4
public void clearCache() {
PrincipalCollection principals = SecurityUtils.getSubject().getPrincipals();
super.clearCache(principals);
}

在角色权限 Service 中,delete、update 方法区调用 realm 的清除缓存方法。
测试时,可以用命令对数据库中的密码加密:

1
2
3
4
-- 将所有的密码设置为1
UPDATE employee SET password = 1;
-- 使用 MD5 函数对密码进行加密,使用 name 作为 salt
UPDATE employee SET password=MD5(concat(name, password));

上述方法只能清空自己的授权缓存

如果想要清空所有用户的缓存

1
cacheManager.getCacheManager().clearAll(); // 性能比较差,不建议使用

总结


Shiro 做了哪些事情

  1. 实现了用户的身份认证
  2. 实现了系统级别的注销登录
  3. 实现了访问权限的控制
  4. 用户信息的加密
  5. 权限数据的缓存

Spring Security


目前最流行的一个安全权限管理框架,它与 Spring 结合紧密。如果项目使用 Spring 可以使用。

文章作者: Ammar
文章链接: http://lizhaoloveit.cn/2019/08/25/Shiro%E6%9D%83%E9%99%90%E6%A1%86%E6%9E%B6/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Ammar's Blog
打赏
  • 微信
  • 支付宝

评论