Shiro 是一个强大、简单易用的 Java 安全框架,可使认证、授权、加密,会话过程更便捷,并可为应用提供安全保障。本节重点介绍下 Shiro 的认证和授权功能。
1 Shiro 三大核心组件
Shiro 有三大核心组件,即 Subject、SecurityManager 和 Realm。先来看一下它们之间的关系。
可以看到:应用代码直接交互的对象是 Subject,也就是说 Shiro 的对外 API 核心就是 Subject;其每个 API 的含义:
Subject:主体,代表了当前 “用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是 Subject,如网络爬虫,机器人等;即一个抽象概念;所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给 SecurityManager;可以把 Subject 认为是一个门面;SecurityManager 才是实际的执行者;
SecurityManager:安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,你可以把它看成 DispatcherServlet 前端控制器;
Realm:域,Shiro 从从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。
也就是说对于我们而言,最简单的一个 Shiro 应用:
-
应用代码通过 Subject 来进行认证和授权,而 Subject 又委托给 SecurityManager;
- 我们需要给 Shiro 的 SecurityManager 注入 Realm,从而让 SecurityManager 能得到合法的用户及其权限进行判断。
从以上也可以看出,Shiro 不提供维护用户 / 权限,而是通过 Realm 让开发人员自己注入。
接下来我们来从 Shiro 内部来看下 Shiro 的架构,如下图所示:
Subject:主体,可以看到主体可以是任何可以与应用交互的 “用户”;
SecurityManager:相当于 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher;是 Shiro 的心脏;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理。
Authenticator:认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;其需要认证策略(Authentication Strategy),即什么情况下算用户认证通过了;
Authrizer:授权器,或者访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
Realm:可以有 1 个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是 JDBC 实现,也可以是 LDAP 实现,或者内存实现等等;由用户提供;注意:Shiro 不知道你的用户 / 权限存储在哪及以何种格式存储;所以我们一般在应用中都需要实现自己的 Realm;
SessionManager:如果写过 Servlet 就应该知道 Session 的概念,Session 呢需要有人去管理它的生命周期,这个组件就是 SessionManager;而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB 等环境;所有呢,Shiro 就抽象了一个自己的 Session 来管理主体与应用之间交互的数据;这样的话,比如我们在 Web 环境用,刚开始是一台 Web 服务器;接着又上了台 EJB 服务器;这时想把两台服务器的会话数据放到一个地方,这个时候就可以实现自己的分布式会话(如把数据放到 Memcached 服务器);
SessionDAO:DAO 大家都用过,数据访问对象,用于会话的 CRUD,比如我们想把 Session 保存到数据库,那么可以实现自己的 SessionDAO,通过如 JDBC 写到数据库;比如想把 Session 放到 Memcached 中,可以实现自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;
CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能
Cryptography:密码模块,Shiro 提高了一些常见的加密组件用于如密码加密 / 解密的。
2 Shiro 身份和权限认证
2.1 Shiro 身份认证
我们分析下 Shiro 身份认证的过程,首先看一下官方给出的认证图。
从图中可以看到,这个过程包括五步。
Step1:应用程序代码调用 Subject.login(token) 方法后,传入代表最终用户身份的 AuthenticationToken 实例 Token。
Step2:将 Subject 实例委托给应用程序的 SecurityManager(Shiro 的安全管理)并开始实际的认证工作。这里开始了真正的认证工作。
Step3、4、5:SecurityManager 根据具体的 Realm 进行安全认证。从图中可以看出,Realm 可进行自定义(Custom Realm)。
2.2 Shiro 权限认证
权限认证,也就是访问控制,即在应用中控制谁能访问哪些资源。在权限认证中,最核心的三个要素是:权限、角色和用户。
权限(Permission):即操作资源的权利,比如访问某个页面,以及对某个模块的数据进行添加、修改、删除、查看操作的权利。
角色(Role):指的是用户担任的角色,一个角色可以有多个权限。
用户(User):在 Shiro 中,代表访问系统的用户,即上面提到的 Subject 认证主体。
它们之间的的关系可以用下图来表示:
一个用户可以有多个角色,而不同的角色可以有不同的权限,也可有相同的权限。比如说现在有三个角色,1 是普通角色,2 也是普通角色,3 是管理员,角色 1 只能查看信息,角色 2 只能添加信息,管理员对两者皆有权限,而且还可以删除信息。
3 Spring Boot 集成 Shiro
因为是demo,没有引入数据库表,有需要的可以自己建,也不难,就5张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。有需要的话还可以加一张部门表
项目结构如下:
3.1 依赖导入
Spring Boot 2.0.4 集成 Shiro 需要导入如下 starter 依赖:
<!--引入shiro--> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.5.3</version> </dependency>
3.2 自定义 Realm
自定义 Realm 需要继承 AuthorizingRealm 类,该类封装了很多方法,且继承自 Realm 类。
继承 AuthorizingRealm 类后,我们需要重写以下两个方法。
doGetAuthenticationInfo() 方法:用来验证当前登录的用户,获取认证信息。
doGetAuthorizationInfo() 方法:为当前登录成功的用户授予权限和分配角色。
具体实现如下,相关注解请见代码注释:
package com.zdyl.springboot_shiro.shiro.realms; import com.zdyl.springboot_shiro.shiro.utils.CurrentCredentialsMatcher; import com.zdyl.springboot_shiro.shiro.utils.JwtToken; 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.authc.credential.CredentialsMatcher; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; /** * 4 自定义realm */ public class CustomerRealm extends AuthorizingRealm { // 设置realm的名称 @Override public void setName(String name) { super.setName("customRealm"); } /** * 大坑!,必须重写此方法,不然Shiro会报错 */ @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 授权 * * @param principalCollection * @return */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { System.out.println("进来了授权"); JwtToken jwtToken = (JwtToken) principalCollection.getPrimaryPrincipal(); SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); authorizationInfo.addStringPermission("zdyl:test:1"); return authorizationInfo; } /** * 认证 * * @param authenticationToken * @return * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { System.out.println("进来了认证"); JwtToken jwtToken = (JwtToken) authenticationToken; jwtToken.setUsername("1"); String token = jwtToken.getToken(); // 第二步:根据用户输入的userCode从数据库查询 // .... // 如果查询不到返回null //数据库中用户账号是zhangsansan /*if(!userCode.equals("zhangsansan")){// return null; }*/ // 模拟从数据库查询到密码 String password = "111112"; // 如果查询到返回认证信息AuthenticationInfo SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo( jwtToken, password, this.getName()); return simpleAuthenticationInfo; } /** * 自定义密码匹配器 * @param credentialsMatcher */ @Override public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) { //自定义密码匹配器 CurrentCredentialsMatcher currentCredentialsMatcher = new CurrentCredentialsMatcher(); super.setCredentialsMatcher(currentCredentialsMatcher); } }
*认证的时候需要返回SimpleAuthenticationInfo,它有3参数构造和4参数构造,第一个参数可以传用户名或者用户,主要是比较密码的时候从里面取,第二个参数传数据库查询的密码,第三个参数(如果需要传的话)传盐,至于盐是什么不知道的可以自行百度,
第四个传realmName。
3.3 自定义 密码匹配器
验证密码的时候默认走的是SimpleCredentialsMatcher的doCredentialsMatch方法,是明文比较的,需要自定义的可以继承SimpleCredentialsMatcher重写doCredentialsMatch方法。
如果需要MD5加密比较可以继承HashedCredentialsMatcher重写doCredentialsMatch方法。
package com.zdyl.springboot_shiro.shiro.utils; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.credential.SimpleCredentialsMatcher; /** * 自定义密码匹配器 */ public class CurrentCredentialsMatcher extends SimpleCredentialsMatcher { @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { JwtToken jwtToken = (JwtToken) token; Object tokenCredentials = jwtToken.getToken(); Object accountCredentials = this.getCredentials(info); return this.equals(tokenCredentials, accountCredentials); } }
3.4 Shiro 配置
自定义 Realm 写好了,接下来需要配置 Shiro。我们主要配置三个东西:自定义 Realm、安全管理器 SecurityManager、seession管理器sessionManager和 Shiro 过滤器。
首先,配置自定义的 Realm,代码如下:
package com.zdyl.springboot_shiro.shiro.config; import com.zdyl.springboot_shiro.shiro.realms.CustomerRealm; import com.zdyl.springboot_shiro.shiro.utils.AuthFilter; import org.apache.shiro.realm.Realm; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.spring.LifecycleBeanPostProcessor; 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.session.mgt.DefaultWebSessionManager; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.Filter; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; /** * shiro配置 */ @Configuration public class ShiroConfig { //1创建shiroFilter 负责拦截请求 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); //给filter设置安全管理器 shiroFilterFactoryBean.setSecurityManager(securityManager); Map<String, Filter> filter = new HashMap<>(); filter.put("oauth2", new AuthFilter());
//自定义过滤器 shiroFilterFactoryBean.setFilters(filter); //配置系统受限资源 Map<String, String> filterMap = new LinkedHashMap<>(); // filterMap.put("/test", "anon"); filterMap.put("/**", "oauth2"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap); return shiroFilterFactoryBean; } //2.创建安全管理器 @Bean("securityManager") public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("realm") Realm realm, SessionManager sessionManager) { DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager(); //给安全管理器设置realm defaultWebSecurityManager.setRealm(realm); //给安全管理器设置sessionManager defaultWebSecurityManager.setSessionManager(sessionManager); return defaultWebSecurityManager; } //3.创建自定义realm @Bean("realm") public Realm getRealm() { CustomerRealm customerRealm = new CustomerRealm(); return customerRealm; } //4.创建sessionManager @Bean("sessionManager") public DefaultWebSessionManager sessionManager() { DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager(); //设置session过期时间3600s defaultWebSessionManager.setGlobalSessionTimeout(3600000L); return defaultWebSessionManager; } /** * Shiro生命周期处理器: * 用于在实现了Initializable接口的Shiro bean初始化时调用Initializable接口回调(例如:UserRealm) * 在实现了Destroyable接口的Shiro bean销毁时调用 Destroyable接口回调(例如:DefaultSecurityManager) */ @Bean public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } /** * 启用shrio授权注解拦截方式,AOP式方法级权限检查 */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } }
配置 Shiro 过滤器时,我们引入了安全管理器。
至此,我们可以看出,Shiro 配置一环套一环,遵循从 Reaml 到 SecurityManager 再到 Filter 的过程。在过滤器中,我们需要定义一个 shiroFactoryBean,然后将 SecurityManager 引入其中,需要配置的内容主要有以下几项。
- 默认登录的 URL:身份认证失败会访问该 URL。
- 认证成功之后要跳转的 URL。
- 权限认证失败后要跳转的 URL。
- 需要拦截或者放行的 URL:这些都放在一个 Map 中。
通过上面的代码,我们也了解到, Map 中针对不同的 URL有不同的权限要求,下表总结了几个常用的权限。
3.5 自定义AuthenticationToken认证的时候传参用(用户名、密码、token)
package com.zdyl.springboot_shiro.shiro.utils; import org.apache.shiro.authc.AuthenticationToken; public class JwtToken implements AuthenticationToken { String serialVersionUID = "8939244780389542801"; private String token; private String username; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return null; } @Override public Object getCredentials() { return null; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } }
3.6 自定义过滤器
package com.zdyl.springboot_shiro.shiro.utils; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import org.apache.shiro.web.util.WebUtils; import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMethod; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 自定义过滤器 */ public class AuthFilter extends AuthenticatingFilter { /** * 获取token * * @param servletRequest * @param servletResponse * @return * @throws Exception */ @Override protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { String requestToken = getRequestToken((HttpServletRequest) servletRequest); JwtToken jwtToken = new JwtToken(requestToken); return jwtToken; } /** * 对跨域提供支持 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } /** * 验证token * 当访问拒绝时是否已经处理了; * 如果返回true表示需要继续处理; * 如果返回false表示该拦截器实例已经处理完成了,将直接返回即可。 * @param servletRequest * @param servletResponse * @return * @throws Exception */ @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { //完成token登入 //1.检查请求头中是否含有token HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String token = getRequestToken(httpServletRequest); //2. 如果客户端没有携带token,拦下请求 if (null == token || "".equals(token)) { responseTokenError(servletResponse, "Token为空,您无权访问该接口"); return false; } //3. 如果有,对进行进行token验证 return executeLogin(servletRequest, servletResponse); } /** * 执行认证 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { JwtToken jwtToken = (JwtToken) createToken(request, response); try { SecurityUtils.getSubject().login(jwtToken); } catch (AuthenticationException e) { responseTokenError(response, "Token无效,您无权访问该接口"); return false; } return true; } /** * 获取请求的token */ private String getRequestToken(HttpServletRequest httpRequest) { //从header中获取token String token = httpRequest.getHeader("token"); //如果header中不存在token,则从参数中获取token if (StringUtils.isEmpty(token)) { token = httpRequest.getParameter("token"); } if (StringUtils.isEmpty(token)) { Cookie[] cks = httpRequest.getCookies(); if (cks != null) { for (Cookie cookie : cks) { if (cookie.getName().equals("yzjjwt")) { token = cookie.getValue(); return token; } } } } return token; } /** * 无需转发,直接返回Response信息 Token认证错误 */ private void responseTokenError(ServletResponse response, String msg) { HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setStatus(HttpStatus.OK.value()); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json; charset=utf-8"); try (PrintWriter out = httpServletResponse.getWriter()) { ObjectMapper objectMapper = new ObjectMapper(); String data = objectMapper.writeValueAsString(new ResponseBean(401, msg, null)); out.append(data); } catch (IOException e) { e.printStackTrace(); } } }
3.7 自定义返回数据类
package com.zdyl.springboot_shiro.shiro.utils; import lombok.Data; /** * 返回数据 */ @Data public class ResponseBean { /** * 200:操作成功 -1:操作失败 **/ // http 状态码 private int code; // 返回信息 private String msg; // 返回的数据 private Object data; public ResponseBean() { } public ResponseBean(int code, String msg, Object data) { this.code = code; this.msg = msg; this.data = data; } public static ResponseBean error(String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setMsg(message); responseBean.setCode(-1); return responseBean; } public static ResponseBean error(int code, String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setMsg(message); responseBean.setCode(code); return responseBean; } public static ResponseBean success(Object data) { ResponseBean responseBean = new ResponseBean(); responseBean.setData(data); responseBean.setCode(200); responseBean.setMsg("成功"); return responseBean; } public static ResponseBean success(String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setData(null); responseBean.setCode(200); responseBean.setMsg(message); return responseBean; } public static ResponseBean success() { ResponseBean responseBean = new ResponseBean(); responseBean.setData(null); responseBean.setCode(200); responseBean.setMsg("Success"); return responseBean; } }
3.8 使用 Shiro 进行认证
至此,我们完成了 Shiro 的准备工作。接下来开始使用 Shiro 进行认证。
使用 http://localhost:8080/test进行身份认证。
请求的时候请求头携带token
@RestController public class TestController { @RequestMapping("/test") public void test() { System.out.println("555555555555"); } }
我们重点分析下用户带token访问。整个处理过程是这样的。
首先,根据前端传来的用户名和密码,创建一个 Token。
然后,请求头携带token访问 http://localhost:8080/test。首先被自定义的过滤器拦截,验证token。
紧接着,调用 subject.login(token) 进行身份认证——注意,这里传入了刚刚创建的 Token,如注释所述,这一步会跳转入自定义的 Realm,访问 doGetAuthenticationInfo 方法,开始身份认证。
最后,启动项目,测试一下。在浏览器中请求:http://localhost:8080/test, 首先进行身份认证,此时token不正确或者为空,会返回401提示token为空或者不正确。
3.9 使用 Shiro 进行授权
使用 http://localhost:8080/test进行授权。在需要授权的接口是上增加@RequiresPermissions("zdyl:test:1")
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class TestController { @RequiresPermissions("zdyl:test:1") @RequestMapping("/test") public void test() { System.out.println("555555555555"); } }
我们重点分析下授权的过程。整个处理过程是这样的。
用户通过了认证环节,请求的资源也就是接口上有@RequiresPermissions("zdyl:test:1")这个注解,就会进到自定义realm中执行doGetAuthorizationInfo方法,该方法有一个入参PrincipalCollection,通俗点说就是用户信息(用户名啥的 ),就是认证最后一步返回的SimpleAuthenticationInfo里的第一个参数。通过用户信息,从数据库获取用户的角色列表和权限列表放进SimpleAuthorizationInfo,最后返回SimpleAuthorizationInfo,后面的工作shiro就帮我们干了。权限列表里如果包含注解上的zdyl:test:1,就可以正常访问,如果不
包含就会报没有访问权限的错误。