Shiro在Spring Boot中的实践

在Web应用中安全问题同样不可忽视,所以Spring Framework体系中也为此提供了解决方案——Spring Security。但是由于其较为复杂,新人上手较为困难。所以这里我们来介绍另外一个简单易用的开源安全框架——Apache Shiro,并说明如何在SpringBoot中实现身份认证、权限授权

abstract.jpeg

配置

Maven依赖

在Pom.xml添加Shiro Framework依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Shiro Framework -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>

<!-- 如果使用Shiro权限授权的注解,还需要添加Spring AOP依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.1.4.RELEASE</version>
</dependency>

自定义令牌

相比较每次HTTP请求通过用户名、密码来进行身份认证,使用后端生成的Token则会更加安全、方便,为此我们需要先自定义一个基于Token的令牌以用于身份认证,如下所示

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
/**
* 自定义令牌
*/
public class CustomToken implements AuthenticationToken {

// HTTP Request Heard token 字段名
public static String TOKENHEARDNAME = "token";

private String token;

public CustomToken(String token) {
this.token = token;
}

/**
* 获取标识信息
* @return
*/
@Override
public Object getPrincipal() {
return token;
}

/**
* 获取凭证信息
* @return
*/
@Override
public Object getCredentials() {
return token;
}
}

自定义拦截器 Filter

为保证Web应用安全,不被非法访问攻击。我们还需要自定义一个拦截器Filter,其作用就是使得HTTP请求在到达指定的Contorller之前被拦截以便进行认证、授权等任务。这里我们通过继承AuthenticatingFilter定义了一个我们自己的拦截器CustomFilter。在拦截器中,如果我们发现请求头中包含token字段(携带令牌),则予以放行说明该请求可以进行认证、授权任务;反之则在拦截器中直接过滤掉该请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
* 自定义拦截器
*/
public class CustomFilter extends AuthenticatingFilter {

/**
* 以供认证时,根据Http请求头构建一个基于Token的自定义令牌
* @param servletRequest
* @param servletResponse
* @return
*/
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
String token = getTokenFromHeard(servletRequest);
if( token == null ) {
return null;
}
return new CustomToken(token);
}

/**
* 判定该请求是否允许访问,true: 则拦截器直接放行该请求; false: 将继续调用onAccessDenied方法
* @apiNote 1. 在url拦截规则中, 对于无需认证的url直接设置为anon(可匿名访问),故不会被该拦截器所拦截,
* @apiNote 2. 这里该方法可直接返回false,将是否需要进行认证放在 onAccessDenied 方法中去实现
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return false;
}

/**
* 如果请求头中不含 token, 则返回false,拦截该请求;
* 否则调用 executeLogin 方法 以进行基于token的自定义令牌的认证
* @param servletRequest
* @param servletResponse
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
String token = getTokenFromHeard(servletRequest);
// 无token则拦截该请求
if( token==null ) {
HttpServletResponse httpServletResponse = (HttpServletResponse)servletResponse;
httpServletResponse.setStatus(401);
httpServletResponse.getWriter().write("Error: No Token Message");
return false;
}
return executeLogin(servletRequest, servletResponse); // 有token则进行认证
}

/**
* 可选地,用于向浏览器返回认证失败时的响应信息
* @param token
* @param e
* @param request
* @param response
* @return
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpServletResponse = (HttpServletResponse)response;
httpServletResponse.setStatus(401);
try {
httpServletResponse.getWriter().write( e.getMessage() );
} catch (IOException e1) {
}
return false;
}

/**
* 从 HTTP Request Heard 中提取 token 字段的数据
* @param request
* @return
*/
private String getTokenFromHeard(ServletRequest request) {
return ((HttpServletRequest)request).getHeader(CustomToken.TOKENHEARDNAME);
}
}

自定义Realm

在Shiro中有一个Realm的概念,通过它来可以完成认证、授权等任务的具体逻辑。一般地,我们需要根据自己的实际业务需求来实现它,换句话说,Realm就相当于是一个安全相关的Dao。这里我们通过继承AuthorizingRealm类实现了一个自定义的Realm类,我们需要重写父类的doGetAuthenticationInfo、doGetAuthorizationInfo方法来定义我们的身份认证、权限授权的具体逻辑。在前文中,我们定义了一个自定义令牌——CustomToken,所以这里通过重写supports方法来只使用我们自定义的令牌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
* 自定义Realm类,用于实现认证、授权的判定逻辑
*/
public class CustomRealm extends AuthorizingRealm {

@Autowired
private UserTokenService userTokenService;

@Autowired
private UserService userService;

@Autowired
private RolePermService rolePermService;

/**
* 是否支持该类型的令牌
* @param token
* @return
*/
@Override
public boolean supports(AuthenticationToken token) {
// 只支持基于token的自定义令牌
return token instanceof CustomToken;
}

/**
* 认证,失败直接抛出异常即可
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String token = (String)authenticationToken.getPrincipal();

Map param = new HashMap();
param.clear();
param.put("token", token);
List<UserToken> userTokenList = userTokenService.findList(param);
// 从数据库中查询符合的用户Token记录只应该有一条
if( userTokenList==null || userTokenList.size() != 1 ) {
throw new AuthenticationException("Error: Invalid Token");
}
Integer userId = userTokenList.get(0).getUserId();
// 用户主键不应无效
if( userId==null || userId <=0) {
throw new AuthenticationException("Error: Invalid Token");
}
// 根据主键查询用户信息
User user = userService.findById(userId);
if( user==null ) {
throw new AuthenticationException("Error: Invalid Token");
}
// 根据HTTP请求头中的token查找到相关的用户,支持认证成功
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user, token, getName());
return authenticationInfo;
}

/**
* 授权
* @apiNote 只有认证通过后才会进行授权
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
// 获取用户的角色、权限信息
User user = (User) principalCollection.getPrimaryPrincipal();
RolePerm rolePerm = rolePermService.findById( user.getRoleId() );

// 用户的角色名称集合
Set<String> roleNameSet = new HashSet<>();
roleNameSet.add( rolePerm.getName() );

// 用户的权限集合
Set<String> permNameSet = new HashSet<>();
permNameSet.add( rolePerm.getPerm() );

SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles( roleNameSet ); // 设置角色信息
info.setStringPermissions( permNameSet ); // 设置权限信息
return info;
}
}

配置Shiro

至此,我们已经把Shiro的相关组件——Token、Filter、Realm,均已经配置完成,现在只需将它们装配在一起即可。示例如下所示,除login登录接口(该接口接收用户名、密码信息来返回token)外,其他接口均需要先进行身份认证

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
/**
* Shiro Framework Config 配置类
*/
@Configuration
public class ShiroConfig {

// 配置一个自定义的Realm类
@Bean(name = "customRealm")
public CustomRealm getCustomRealm() {
return new CustomRealm();
}

// 配置安全管理器
@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm( getCustomRealm() );
return securityManager;
}

// 设置 Shiro 的url拦截规则、拦截器
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);

// 设置自定义的拦截器Filter
Map<String, Filter> filters = new HashMap(2);
filters.put("customFilter", new CustomFilter() );
shiroFilterFactoryBean.setFilters(filters);

// 设置 url 拦截规则, 拦截时将按定义顺序进行匹配,故 /** 规则应放在最后
Map<String, String> urlFilterMap = new LinkedHashMap<>();
// anon 表示该url可匿名访问
urlFilterMap.put("/login", "anon");
// customFilter 表示该url将由指定的拦截器 customFilter 进行拦截处理
urlFilterMap.put("/**", "customFilter");
shiroFilterFactoryBean.setFilterChainDefinitionMap(urlFilterMap);

return shiroFilterFactoryBean;
}

/******************** 如果使用Shiro权限授权的注解,还需配置 Shiro 权限控制的注解通知 ********************/

@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}

//配置Shiro通知器
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/******************************************************************/
}

身份认证

所谓认证就如我们在CustomRealm的doGetAuthenticationInfo方法中的逻辑一样,即判断该请求携带的Token数据是否能查询到相关的用户。如果能则说明身份认证成功;反之则不能。这里问为认证测试提供了一个Controller

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
@RestController
@ResponseBody
public class ShiroAuthenTestController {

@Autowired
private UserService userService;

@Autowired
private UserTokenService userTokenService;

@GetMapping("/login")
public String login(String username, String password) {
String msg = "登录失败";
if( StrUtil.isBlank(username) || StrUtil.isBlank(password) ) {
return msg;
}
User user = userService.findByLoginMsg(username, password);
if( user==null || user.getId()<=0 ) {
return msg;
}
Map param = new HashMap<>();
param.put("userId", user.getId());
List<UserToken> userTokens = userTokenService.findList(param);
if( userTokens==null || userTokens.size()!=1 ) {
return msg;
}
msg = "Token: " + userTokens.get(0).getToken();
return msg;
}

/**
* Shiro 认证测试 Controller
* @return
*/
@GetMapping("/testAuthen")
public String testAuthen() {

// 获取登录者的信息
Subject subject = SecurityUtils.getSubject();
User user = (User) subject.getPrincipal();
System.out.println("Login Msg: " + user);

String msg = "test Shiro success";
return msg;
}
}

现在我们通过PostMan来进行测试

1. 请求login接口

通过登录接口分别获取到用户Aaron、Bob的token信息为abcdefg、kfc

figure 1.jpeg

figure 2.jpeg

2. 请求testAuthen接口

当我们不携带token、携带错误的token发起请求时,可以看到会导致认证失败,接口没有响应

figure 3.jpeg

figure 4.jpeg

当我们携带正确的token发起请求时,则可以看到认证成功,接口正常响应

figure 5.jpeg

权限控制

这里为了便于演示,我们建立了一个简单的用户-权限表,如下图所示

figure 6.jpeg

从中我们可以得知:

  • 用户Aaron,其Token为abcdefg,角色为user用户,权限为accessPerm1
  • 用户Bob,其Token为kfc,角色为root用户,权限为accessPerm2

基于角色的授权

正如我们在CustomRealm的doGetAuthorizationInfo方法中的所做的那样,我们在认证通过之后会为其添加角色、权限相关的信息。在Shiro中可以通过@RequiresRoles注解来方便地表示访问Controller需要的角色条件,这里我们提供了testAuthor1、testAuthor2两个请求接口,前者只能是user用户可以访问,而后者只能是root用户才可以访问

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
@RestController
@ResponseBody
public class ShiroAuthorTestController {

/**
* Shiro 授权测试 Controller 1
* 只允许 User 用户访问
* @return
*/
@GetMapping("/testAuthor1")
@RequiresRoles("user")
public String testAuthor1() {
return "Msg: success access by User";
}

/**
* Shiro 授权测试 Controller 2
* 只允许 Root 用户访问
*/
@GetMapping("/testAuthor2")
@RequiresRoles("root")
public String testAuthor2() {
return "Msg: success access by User";
}
}

现在我们通过PostMan来进行测试testAuthor1接口:

  • 当我们不携带token、携带错误的token发起请求时,可以看到会同样导致认证失败,接口没有响应

figure 7.jpeg

figure 8.jpeg

  • 当Aaron访问时,由于其角色为user用户,故接口被正确响应

figure 9.jpeg

  • 当Bob访问时,虽然认证通过了,但是由于其角色为root用户,没有权限访问该接口,故该接口没有响应

figure 10.jpeg

基于权限的授权

在Shiro中也可以通过@RequiresPermissions注解来方便地表示访问Controller需要的权限条件,这里我们提供了testAuthor3、testAuthor4两个请求接口,前者只能是拥有accessPerm1权限的用户可以访问,而后者只能是拥有accessPerm2权限的用户可以访问

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
@RestController
@ResponseBody
public class ShiroAuthorTestController {

/**
* Shiro 授权测试 Controller 3
* 必须具备指定权限 accessPerm1 的 用户才可以访问
* @return
*/
@GetMapping("/testAuthor3")
@RequiresPermissions("accessPerm1")
public String testAuthor3() {
return "Msg: success access by Perm [accessPerm1] ";
}

/**
* Shiro 授权测试 Controller 4
* 必须具备指定权限 accessPerm2 的 用户才可以访问
* @return
*/
@GetMapping("/testAuthor4")
@RequiresPermissions("accessPerm2")
public String testAuthor4() {
return "Msg: success access by Perm [accessPerm2] ";
}
}

现在我们通过PostMan来进行测试testAuthor3接口:

  • 当Aaron访问时,由于其权限条件为accessPerm1,故接口被正确响应

figure 11.jpeg

  • 当Bob访问时,虽然认证通过了,但是由于其只有accessPerm2的权限条件,故该接口没有响应

figure 12.jpeg

0%