SpringBoot集成Shiro
一、Shiro 简介
Apache Shiro 是一个强大且易用的Java安全框架,能够用于身份验证、授权、加密和会话管理。
Shiro 功能:
-
核心功能:
- Authentication(认证):用户登录,身份识别。
- Authorization(授权):授权和鉴权,处理用户和访问的目标资源之间的权限。
- Session Management(会话管理):即 Session 的管理。
- Cryptography(加密):用户密码加密。
-
其他功能:
- Web支持:可以非常容易集成到web应用程序中。
- 缓存:缓存是Apache Shiro API中的第一级,以确保安全操作保持快速和高效。
- 并发性:多线程环境完成认证和授权。
- 测试:存在测试支持,可帮助您编写单元测试和集成测试,并确保代码按预期得到保障。
- 运行方式:允许用户承担另一个用户的身份(如果允许)的功能,有时在管理方案中很有用。
- 记住我:记住用户在会话中的身份,所以用户只需要强制登录即可。
Shiro 核心对象:
- Subject:当前用户,Subject 可以是一个人,但也可以是第三方服务、守护进程帐户等和软件交互的任何对象。
- SecurityManager:管理所有Subject,SecurityManager 是 Shiro 框架的核心。
- Realms:用于认证和授权,提供扩展点,使用者自行实现认证逻辑和授权逻辑。
二、SpringBoot集成Shiro
1:引入pom
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.7.1</version>
</dependency>
在GroupId为 org.apache.shiro下的ArtifaceId有好几个shiro-spring;shiro-springboot;shiro-springboot-starter。暂不清楚这三个之间的区别和联系,本文中引用的是 shiro-spring,使用的是当前(2021-07)最新版本 1.7.1。
2:增加配置类
配置类有两个:
-
新建一个类 ShiroRealm ,此类继承 AuthorizingRealm ,是框架提供的扩展口,使用者通过重写doGetAuthenticationInfo 方法检验用户登录信息是否正确,并将用户信息存放到 session 中,此方法会抛出 AuthenticationException 异常,需要在统一异常处理中捕获此异常。通过重写 doGetAuthorizationInfo 方法为当前请求的用户赋予角色和权限信息,配合Shiro 提供的 @RequiresRoles 和 @RequiresPermissions 注解完成鉴权。
-
新建一个类 ShiroConfig ,此类需要添加 @Configuration 注解,此类的作用是向应用程序上下文(俗称的容器)注入使用者添加的shiro配置和自定义功能。
-
注入 SecurityManager,此对象是Shiro的核心对象之一,Shiro 框架提供了多种 DefaultSecurityManger可供使用,暂不清楚他们之间的区别,本文使用的是 DefaultWebSecurityManager 。通过 setRealm 将上一步新建的认证和授权配置类注入 SecurityManager。
-
注入 ShiroFilterFactoryBean,此对象是一个过滤器,作用是配置一些默认页面和过滤规则。setLoginUrl是配置认证失败,默认要重定向的页面,可以是一个jsp页面,也可以是一个RESTFul接口。setFilterChainDefinitionMap是配置认证过滤规则,比如哪些URL不需要认证,接收一个 LinkedHashMap ,key为 url ,value 为认证策略,这里必须要吐槽认证策略没有设计成一个枚举。
- logout:配置退出登录过滤器,其中的具体的退出代码Shiro已经替我们实现了,调用此接口后,页面会重定向到setLoginUrl配置的URL。
- authc:配置需要认证的URL
- anon:配置不需要认证的URL
-
注入 authorizationAttributeSourceAdvisor 和 defaultAdvisorAutoProxyCreator ,如果不注入这两个对象,RequiresRoles 和RequiresPermissions 注解将无法使用。
-
ShiroRealm:
package com.naylor.shiro.config;
import com.alibaba.fastjson.JSON;
import com.naylor.shiro.dto.UserInfo;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import java.util.ArrayList;
import java.util.List;
/**
* @ClassName ShiroRealm
* @类描述 realm(领域、范围)不太清楚这里用这个单词是什么寓意。此类继承 AuthorizingRealm ,是框架给使用者留下的两个扩展点,doGetAuthenticationInfo 扩展登录认证的逻辑;doGetAuthorizationInfo 扩展授权鉴权的逻辑
*
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 9:28
* @Version 1.0.0
**/
public class ShiroRealm extends AuthorizingRealm {
/**
* 授权
* 在访问接口前,为当前登录用户赋予角色和权限
* 实际应用中从数据库中查询用户拥有的角色和权限信息
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String principal = JSON.toJSONString(principalCollection);
System.out.println(principal);
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
UserInfo userInfo = (UserInfo)principalCollection.getPrimaryPrincipal();
//根据用户名查询出用户角色和权限,并交给shiro管理。实际应用中用户角色和权限从数据库获取
if (userInfo.getUserName().equals("cml")) {
simpleAuthorizationInfo = buildUserCmlRolePermission();
} else if (userInfo.getUserName().equals("admin")) {
simpleAuthorizationInfo = buildUserAdminRolePermission();
} else if (userInfo.getUserName().equals("hn")) {
simpleAuthorizationInfo = buildUserHnRolePermission();
}
return simpleAuthorizationInfo;
}
/**
* 登录认证
* 保存用户信息到session中
* 在调用登录接口后会进入到此方法(/common/singin)
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String authToken = JSON.toJSONString(authenticationToken);
System.out.println("authToken:" + authToken);
String userName = authenticationToken.getPrincipal().toString();
UserInfo userInfo = this.getUserInfoByUserName(userName);
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(userInfo, userInfo.getPassword(), getName());
return simpleAuthenticationInfo;
}
/**
* 构造用户名为admin的用户的角色和权限
* 实际应用中用户角色权限信息从数据库中获取
* @return
*/
private SimpleAuthorizationInfo buildUserAdminRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
roles.add("roleB");
roles.add("roleC");
simpleAuthorizationInfo.addRoles(roles);
List<String> permissions = new ArrayList<>();
permissions.add("permissionsA");
permissions.add("permissionsB");
permissions.add("permissionsC");
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
/**
* 构造用户名为cml的用户的角色和权限
* 实际应用中用户角色权限信息从数据库中获取
* @return
*/
private SimpleAuthorizationInfo buildUserCmlRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
roles.add("roleB");
simpleAuthorizationInfo.addRoles(roles);
List<String> permissions = new ArrayList<>();
permissions.add("permissionsA");
permissions.add("permissionsB");
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
/**
* 构造用户名为hn的用户的角色和权限
* 实际应用中用户角色权限信息从数据库中获取
* @return
*/
private SimpleAuthorizationInfo buildUserHnRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
List<String> permissions = new ArrayList<>();
permissions.add("permissionsAA");
simpleAuthorizationInfo.addStringPermissions(permissions);
simpleAuthorizationInfo.addRoles(roles);
return simpleAuthorizationInfo;
}
/**
* 获取用户信息根据用户名
* 实际应用场景中是从数据库查询用户信息并根据需求组装 userInfo 对象
*
* @param userName
* @return
*/
private UserInfo getUserInfoByUserName(String userName) {
UserInfo userInfo = new UserInfo().setId("112233445566778899").setUserName(userName).setRealName("陈明亮").setUserType(5).setNation("中国").setPassword("123456");
return userInfo;
}
}
ShiroConfig:
package com.naylor.shiro.config;
import org.apache.shiro.authc.Authenticator;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
/**
* @ClassName ShiroConfgi
* @类描述 Shiro 配置
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 9:24
* @Version 1.0.0
**/
@Configuration
public class ShiroConfig {
/**
* 注入安全管理
* 为shiro框架核心对象,可注入不同的SecurityNamager对象,另外可根据实际需求通过securityManager的set方法自定义安全管理对象
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(buildShiroRealm());
return securityManager;
}
/**
* 注入认证、授权
* @return
*/
@Bean(name = "shiroRealm")
public ShiroRealm buildShiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
return shiroRealm;
}
/**
* 注入过滤器
* 通过setLoginUrl配置认证失败,重定向的uri地址,可以是一个页面,也可以是一个RESTFul接口
*
* @param securityManager
* @return
*/
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
Map<String, String> map = new HashMap<>();
//退出登录
map.put("/logout", "logout");
//对所有URI认证
map.put("/**", "authc");
// 设置不用认证的URI
map.put("/common/login", "anon");
map.put("/common/singin", "anon");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
shiroFilterFactoryBean.setSecurityManager(securityManager);
//认证失败重定向URI
shiroFilterFactoryBean.setLoginUrl("/common/login");
return shiroFilterFactoryBean;
}
/**
* 加入注解的使用,不加入这个注解不生效
*
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 加入注解的使用,不加入这个注解不生效
*
* @return
*/
@Bean
@ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAAP = new DefaultAdvisorAutoProxyCreator();
defaultAAP.setProxyTargetClass(true);
return defaultAAP;
}
}
3:增加全局异常处理
捕获异常,防止将tomcat的错误页面直接抛给用户。
配置文件中增加以下配置:
出现错误时, 直接抛出异常。这两个配置是为了让404异常正常抛出
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false
GlobalException
package com.naylor.shiro.handler;
import com.naylor.shiro.dto.GlobalResponseEntity;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
/**
* @ClassName GlobalException
* @类描述
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 14:23
* @Version 1.0.0
**/
//@RestControllerAdvice("com.naylor")
@RestControllerAdvice()
@ResponseBody
@Slf4j
public class GlobalException {
/**
* 处理511异常
* @param e
* @return
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Object> handleAuthenticationException(AuthenticationException e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "511",
e.getMessage() == null ? "认证失败" : e.getMessage()),
HttpStatus.NETWORK_AUTHENTICATION_REQUIRED);
}
/**
* 处理401异常
* @param e
* @return
*/
@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<Object> handleAuthorizationException(AuthorizationException e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "401",
e.getMessage() == null ? "未授权" : e.getMessage()),
HttpStatus.UNAUTHORIZED);
}
/**
* 处理404异常
*
* @return
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException e) {
return new ResponseEntity<>(
new GlobalResponseEntity(false, "404",
e.getMessage() == null ? "请求的资源不存在" : e.getMessage()),
HttpStatus.NOT_FOUND);
}
/**
* 捕获运行时异常
*
* @param e
* @return
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Object> handleRuntimeException(RuntimeException e) {
log.error("handleRuntimeException:", e);
return new ResponseEntity<>(
new GlobalResponseEntity(false, "500",
e.getMessage() == null ? "运行时异常" : e.getMessage().replace("java.lang.RuntimeException: ", "")),
HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* 捕获一般异常
* 捕获未知异常
*
* @param e
* @return
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(Exception e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "555",
e.getMessage() == null ? "未知异常" : e.getMessage()),
HttpStatus.INTERNAL_SERVER_ERROR);
}
}
4:增加统一的RESTFul响应结构体
GlobalResponse:
package com.naylor.shiro.handler;
import com.alibaba.fastjson.JSON;
import com.naylor.shiro.dto.GlobalResponseEntity;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import javax.annotation.Resource;
/**
* @ClassName GlobalResponse
* @类描述
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 14:22
* @Version 1.0.0
**/
@RestControllerAdvice("com.naylor")
public class GlobalResponse implements ResponseBodyAdvice<Object> {
/**
* 拦截之前业务处理,请求先到supports再到beforeBodyWrite
* <p>
* 用法1:自定义是否拦截。若方法名称(或者其他维度的信息)在指定的常量范围之内,则不拦截。
*
* @param methodParameter
* @param aClass
* @return 返回true会执行拦截;返回false不执行拦截
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
//TODO 过滤
return true;
}
/**
* 向客户端返回响应信息之前的业务逻辑处理
* <p>
* 用法1:无论controller返回什么类型的数据,在写入客户端响应之前统一包装,客户端永远接收到的是约定的格式
* <p>
* 用法2:在写入客户端响应之前统一加密
*
* @param responseObject 响应内容
* @param methodParameter
* @param mediaType
* @param aClass
* @param serverHttpRequest
* @param serverHttpResponse
* @return
*/
@Override
public Object beforeBodyWrite(Object responseObject, MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
//responseObject是否为null
if (null == responseObject) {
return new GlobalResponseEntity<>("55555", "response is empty.");
}
//responseObject是否是文件
if (responseObject instanceof Resource) {
return responseObject;
}
//该方法返回值类型是否是void
//if ("void".equals(methodParameter.getParameterType().getName())) {
// return new GlobalResponseEntity<>("55555", "response is empty.");
//}
if (methodParameter.getMethod().getReturnType().isAssignableFrom(Void.TYPE)) {
return new GlobalResponseEntity<>("55555", "response is empty.");
}
//该方法返回值类型是否是GlobalResponseEntity。若是直接返回,无需再包装一层
if (responseObject instanceof GlobalResponseEntity) {
return responseObject;
}
//处理string类型的返回值
//当返回类型是String时,用的是StringHttpMessageConverter转换器,无法转换为Json格式
//必须在方法体上标注RequestMapping(produces = "application/json; charset=UTF-8")
if (responseObject instanceof String) {
String responseString = JSON.toJSONString(new GlobalResponseEntity<>(responseObject));
return responseString;
}
//该方法返回的媒体类型是否是application/json。若不是,直接返回响应内容
if (!mediaType.includes(MediaType.APPLICATION_JSON)) {
return responseObject;
}
return new GlobalResponseEntity<>(responseObject);
}
}
5:用户登录认证
用户信息都存放在 Subject 对象中,用户登录认证的过程只需调用其 login 方法即可,login方法内部会调用 doGetAuthenticationInfo 扩展点完成登录的认证。
package com.naylor.shiro.controller;
import com.naylor.shiro.dto.User;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.springframework.web.bind.annotation.*;
/**
* @ClassName LoginController
* @类描述
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 9:51
* @Version 1.0.0
**/
@RestController
@RequestMapping("/common")
public class CommonController {
/**
* 提示需要登录
* @return
*/
@GetMapping(value = "/login")
public String login() {
return "请登录";
}
/**
* 登录
* @param user
* @return
*/
@PostMapping("/singin")
public String singIn(@RequestBody User user) {
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(user.getUserName(), user.getPassword());
SecurityUtils.getSubject().login(usernamePasswordToken);
return "登录成功";
}
@GetMapping("/error")
public String error() {
return "500";
}
}
6:用户请求鉴权
通过 @RequiresRoles 和 @RequiresPermissions 注解的配合使用,完成对后端接口的鉴权。鉴权的逻辑其实就是从Shiro 中取出当前用户拥有的角色和权限,然后和RESTFul接口上面注解的角色和权限进行对比,如果包含那么就鉴权通过,允许访问,否则就抛出401异常。
鉴权原理:
debug 到AuthorizingRealm类的 isPermitted 方法,该方法接收两个参数,Permission为RESTFul接口上面添加的权限相关注解,AuthorizationInfo是当前请求用户拥有的角色和权限。鉴权的原理就是判断AuthorizationInfo 中是否包含Permission。
获取用户信息和session信息:
通过 SecurityUtils 工具类中的 getSubject方法获取用户的登录信息和sessionId
package com.naylor.shiro.controller;
import com.naylor.shiro.dto.UserInfo;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @ClassName AnimalController
* @类描述
* @Author MingliangChen
* @Email cnaylor@163.com
* @Date 2021-07-08 13:49
* @Version 1.0.0
**/
@RestController
@RequestMapping("/animal")
public class AnimalController {
@GetMapping("/cat")
public String cat() {
Subject subject = SecurityUtils.getSubject();
UserInfo userInfo = (UserInfo) SecurityUtils.getSubject().getPrincipal();
String sessionId = String.valueOf(SecurityUtils.getSubject().getSession().getId());
return "cat";
}
@RequiresRoles({"roleA"})
@RequiresPermissions("permissionsAA")
@GetMapping("/fish")
public String fish() {
return "fish";
}
@RequiresRoles({"roleA", "roleB"})
@GetMapping("/dog")
public String dog() {
return "dog";
}
@RequiresPermissions("permissionsC")
@GetMapping("/tiger")
public String tiger() {
Boolean a = SecurityUtils.getSubject().hasRole("roleC");
Boolean b = SecurityUtils.getSubject().isPermitted("permissionsC");
return "tiger";
}
}
7:测试
使用postman模拟用户请求进行测试。
a. 在没有登录的情况下,调用任何接口都会重定向到 /common/login 接口,该接口返回“请登录”。即使是访问一个不存在的页面也会重定向,因为我们在ShiroConfig 中配置的是全局认证。
b.调用登录接口登录,注意用户名需要和代码中写死的用户名一致
c.调用受限接口
admin 用户有 tiger 接口的权限,调用之后接口正常返回 tiger ; 没有 fish 接口的权限,调用之后返回 “Subject does not have permission [permissionsAA]”
8:总结
本文演示了SpringBoot集成 Shiro ,基于 Session 来管理用户会话,实现用户和web服务的认证和鉴权。Shiro 作为一个古老的框架,历史悠久,功能和拓展性也特别的强,如使用者可以自定义 SessionMode=HTTP 从而可以达到web服务横向扩容的目的;也可以结合 JWT 搭建无状态的web服务;还可以搭建 oauth2 。但是后两者并不推荐,在分布式系统和微服务应用中,推荐使用SpringBootSecutiryOauth2来搭建自己的授权服务。