最近的需求是要在项目中加入权限模块。在对接shrio中查找资料遇到了一些问题。所以记录下spring boot 对接 shiro 以及jwt 生成token 做权限校验。
Shiro框架中有三个核心概念:Subject ,SecurityManager和Realms。
Subject
Subject表示 执行当前操作的用户,当然subject 不单单指的是用户,它可以是第三方进程、定时任务,等与程序交互的东西。我们可以先给它理解为 当前操作的用户。
SecurityManager
SecurityManager安全管理器,是Shiro的核心。主要作用于登录、登出用创建主题Subject。它引用了多个内部嵌套安全组件,shiro本身自己调用,客户端使用应该使用Subject,而不是SecurityManager。
几乎在所有环境下,都能够获得当前执行的 Subject 通过使用 org.apache.shiro.SecurityUtils; getSubject()方法调用一个独立的应用程序,该应用程序可以返回一个在应用程序特有位置上基于用户数据的 Subject, 在服务器环境中(如,Web 应用程序),它基于与当前线程或传入的请求相关的用户数据上获得 Subject。
Realms
Realm充当了Shiro与应用安全数据间的“桥梁”或者“连接器”。也就是说,当与像用户帐户这类安全相关数据进行交互,执行认证(登录)和授权(访问控制)时,Shiro会从应用配置的Realm中查找很多内容。由SecurityManager来管理如何使用Realms来获取安全的身份数据
JWT
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用 JWT 在用户和服务器之间传递安全可靠的信息
开始对接
在项目对接中,我们约定了每次请求 都需要在请求头中带上token ,后端从token中取得当前登录用户的信息,进行权限校验
一般来说,数据库的权限表 分为用户表、角色表、权限表、用户角色表、角色权限表,一共五张表来实现,我们此次demo 中不进行数据库设计。假设用户 user,假设角色admin。 具体的角色、权限 可以在实际项目中验证用户时进行处理。很方便的。
首先pom文件添加依赖
org.apache.shiro
shiro-spring
1.3.2
io.jsonwebtoken
jjwt
0.7.0
两个依赖 一个是shiro依赖 一个是jwt 生成token 的依赖
我们需要给shiro 进行一个配置。 但是在在配置之前 要写一个过滤器,进行过滤请求,如果请求头里有token则交给realm 进行登录 。需要继承 shiro的 BasicHttpAuthenticationFilter,大概我们需要实现 几个方法,先从请求头取到token,如果有token,则交给 shiro 登录处理。如果没token 则表明属于游客登录,或者没权限
再者说token shiro中AuthenticationToken 用于收集用户提交的身份(如用户名)及凭据(如密码)。Shiro会调用CredentialsMatcher对象的doCredentialsMatch方法对AuthenticationInfo对象和AuthenticationToken进行匹配。匹配成功则表示主体(Subject)认证成功,否则表示认证失败
这里我们需要自定义一个token并且是shrio 认可的token 所以需要继承于 AuthenticationToken
我们先看下token,很简单的继承实现
public class JWTToken implements AuthenticationToken {
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
然后我们看一下 过滤器的实现
package com.jzdsh.demo.filter;
import com.jzdsh.demo.shiro.JWTToken;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
public class JWTFilter extends BasicHttpAuthenticationFilter {
private Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* 如果带有 token,则对 token 进行检查,否则直接通过
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
//判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)) {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
//token 错误
responseError(response, e.getMessage());
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
return true;
}
/**
* 判断用户是否想要登入。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("Token");
return token != null;
}
/**
* 执行登陆操作
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Token");
JWTToken jwtToken = new JWTToken(token);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 对跨域提供支持
*/
@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);
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
}
接下来是 Realm的实现,我们需要做些什么?Realm 是在执行方法前进行 身份验证 和权限验证的,doGetAuthenticationInfo是身份认证方法,doGetAuthorizationInfo是权限认证方法,需要继承与 AuthorizingRealm
我们看下代码
import com.jzdsh.demo.model.Userinfo;
import com.jzdsh.demo.service.UsersService;
import com.jzdsh.demo.util.JWT;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
@Component
public class CustomRealm extends AuthorizingRealm {
@Autowired
UsersService usersService;
/**
* 必须重写此方法,不然会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
System.out.println("————身份认证方法————");
String token = (String) authenticationToken.getCredentials();
// 解密获得uid,用于和数据库进行对比
Long uid = JWT.getuseridbytoken(token);
Userinfo userinfo = usersService.selectByKey(uid);
if(userinfo==null){
throw new AuthenticationException("用户不存在!");
}
return new SimpleAuthenticationInfo(userinfo, token, "MyRealm");
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
System.out.println("————权限认证————");
Long uid = JWT.getuseridbytoken(principals.toString());
Userinfo userinfo = usersService.selectByKey(uid);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//获得该用户角色
//每个角色拥有默认的权限
//每个用户可以设置新的权限
Set roleSet = new HashSet<>();
Set permissionSet = new HashSet<>();
//需要将 role, permission 封装到 Set 作为 info.setRoles(), info.setStringPermissions() 的参数
//测试数据 给用户 admin 角色 以及 add,test 权限
roleSet.add("admin");
permissionSet.add("add");
permissionSet.add("test");
//设置该用户拥有的角色和权限
info.setRoles(roleSet);
info.setStringPermissions(permissionSet);
return info;
}
}
doGetAuthenticationInfo 身份认证方法一些说明
在过滤器中,如果判断存在token,我们new 了一个自定义 JWTToken ,并且调用了 login方法,JWTToken继承了AuthenticationToken。我们CustomRealm 中doGetAuthenticationInfo() 方法的 参数AuthenticationToken,即为调用登录时传递的 自定义 JWTToken ,在JWTToken 中重写了getCredentials() 返回了当前token,所以在此时 进行用户身份验证的时候,我们可以拿到请求头的token,并且注入service,进行数据库操作,核对用户(这里就是是一般对接用户表了,可以判断用户是否禁用,是否有该用户等反正就是一些自定义的随便判断)。注意返回值 如果核对成功 我们 new SimpleAuthenticationInfo(),进行了返回,看下它的代码
public SimpleAuthenticationInfo(Object principal, Object credentials, String realmName) {
this.principals = new SimplePrincipalCollection(principal, realmName);
this.credentials = credentials;
}
大概参数是 身份,凭证,这里后边要用到;
doGetAuthorizationInfo 权限认证方法一些说明
上一个方法,我们验证了用户,在这个方法中,我们可以进行数据库一些读取用户角色权限的方法。PrincipalCollection 参数 即为我们在身份认证中返回的第一个数据,所以在此时,可以进行数据库查询,相对比较灵活了。demo 中没有涉及到数据库,假设说我们此时查询到了该用户拥有admin角色以及add、test权限。在这里setRoles 方法设置角色 和setStringPermissions设置权限,所接收的参数是set集合,所以 一个用户可以有多个角色,一个角色可以有多个权限。具体业务 具体对接
接下来需要给我们这些进行配置 新建一个ShiroConfig类
@Configuration
public class ShiroConfig {
/**
* 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
*/
@Bean
public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
Map filterMap = new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
// 设置无权限时跳转的 url;
factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
Map filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/admin/**", "jwt");
// 访问 /unauthorized/** 不通过JWTFilter
filterRuleMap.put("/unauthorized/**", "anon");
filterRuleMap.put("/**", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 注入 securityManager
*/
@Bean
public SecurityManager securityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(customRealm);
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* 添加注解支持
*/
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
首先是配置过滤器,在这里添加了注解支持。如果没开启注解,需要在配置过滤器的时候 添加url 对应需要的权限,需要的角色等。具体配置参数 可以百度。
禁用掉了shiro的session管理,因为shiro默认是给用户登陆过后存储session。然后在我们现在前后端分离中 没有使用session 这里给禁用掉了。 主要配置就是 过滤器
此时项目启动没问题了。还要继续整合一些。
异常处理,我们在用户 token错误的时候,CustomRealm 类有抛出异常这里要捕获处理
@RestControllerAdvice
public class ShiroExceptionController {
// 捕捉shiro的异常
@ExceptionHandler(ShiroException.class)
public ApiMessage handle401() {
return new ApiMessage<>(false, "400", "","您没有权限访问" );
}
// 捕捉其他所有异常
@ExceptionHandler(Exception.class)
public ApiMessage globalException(HttpServletRequest request, Throwable ex) {
return new ApiMessage<>(false, "400", "","访问出错,无法访问: " + ex.getMessage() );
}
}
ApiMessage类是自定义 的后台统一返回前端数据类。这都可以自己替换与前端预定好的数据格式
开始写controller 层 进行测试
controller 主要用到两个注解 @RequiresRoles @RequiresPermissions 前者为拥有XX角色可以访问 ,后者为拥有XX权限可以访问
@RestController
@RequestMapping("admin")
public class ShiroTestController {
@PostMapping("/login")
public ApiMessage login() {
Map map = new HashMap<>();
map.put("uid", "1");
String token = JWT.createJavaWebToken(map, DateUtils.getPreviousOrNextDaysOfDate(new Date(), 30));
return new ApiMessage<>(true, "200", "", token);
}
@RequestMapping(value = "test", method = RequestMethod.GET, produces = {"application/json;charset=UTF-8"})
@RequiresPermissions("add")
public ApiMessage test(){
Userinfo userinfo = (Userinfo) SecurityUtils.getSubject().getPrincipal();
System.err.println(userinfo.getName());
return new ApiMessage<>(true, "200", "", "有权限访问!");
}
}
login 方法,就是说 用户登录 获取token方法。test方法要拥有add权限后可以访问;
测试
未登录情况下访问 test方法
错误的调用登录方法,走异常处理
正确调用 获取token (这里应当是传递账号密码,进行校验)
带着token 访问test方法
看下控制台
看到了访问方法前,先进行了身份认证,后进行了权限认证。最后输出当前用户名 zhangsan 。
前边说到
getSubject()方法调用一个独立的应用程序,该应用程序可以返回一个在应用程序特有位置上基于用户数据的 Subject
CustomRealm 中身份认证方法 返回了 new SimpleAuthenticationInfo(),数据 参数为 身份和凭证。所以在程序中 SecurityUtils.getSubject().getPrincipal();就获得了CustomRealm 中身份认证方法的第一个返回值。我们传递的是user 对象。所以获取了当前操作人 为zhangsan。
后边 更改controller 层权限,角色 进行测试。到此已经整合结束。
jwt 生成token 使用了一个帮助类,如需要可以自行百度
一些其他的东西
精确到方法级别的权限,对应的页面上就是按钮的显示与隐藏,如果并非前后端分离项目,shiro有session管理。可以给用户角色 权限存储session。在查找资料时候发现 shiro 有对应的jsp 标签库。可以实现按钮 页面的显示与隐藏
对接shiro 中学习了 教你 Shiro + SpringBoot 整合 JWT 一文,表示感谢。